diff --git a/calliope-backend/src/calliope/agent/harness/__init__.py b/calliope-backend/src/calliope/agent/harness/__init__.py
index c9559b23..7ceab027 100644
--- a/calliope-backend/src/calliope/agent/harness/__init__.py
+++ b/calliope-backend/src/calliope/agent/harness/__init__.py
@@ -85,10 +85,11 @@ def _destructive_guard(ctx: ToolContext, t: ToolDefinition, args: dict) -> PreEx
if not has_replace_param:
return allow()
replace = bool(args.get("replace", True))
- if not replace:
- return allow()
- # Destructive path: only allow when the project is effectively empty, or
- # the user explicitly confirmed.
+ # BOTH modes are gated on a non-empty project: replace=true deletes the
+ # existing content, and replace=false APPENDS a second full set alongside
+ # it (observed live 2026-08-25: an unconfirmed replace=false generate_story
+ # silently doubled a project's beats, 25 -> 50). Only allow when the
+ # project is effectively empty, or the user explicitly confirmed.
from calliope.agent.harness.registry import _db
if ctx.project_id is None:
@@ -117,11 +118,25 @@ def _destructive_guard(ctx: ToolContext, t: ToolDefinition, args: dict) -> PreEx
return allow()
if _user_confirmed_replacement(ctx):
return allow()
- return deny(
+ counts = (
f"Project #{ctx.project_id} already has content ({scenes} scenes, {beats} beats, "
- f"{chars} characters, {locs} locations, {items} items). Ask the user to confirm before replacing; "
- "once they confirm (e.g. 'yes, replace'), call this tool again. "
- "To append instead, pass replace=false."
+ f"{chars} characters, {locs} locations, {items} items)."
+ )
+ if replace:
+ return deny(
+ counts + " replace=true DELETES all of it first. Ask the user to "
+ "confirm before replacing; once they confirm (e.g. 'yes, replace'), "
+ "call this tool again. For targeted changes use the granular tools "
+ "instead (add/update/delete for beats, characters, locations, "
+ "items, scenes)."
+ )
+ return deny(
+ counts + " replace=false would APPEND a second full set alongside the "
+ "existing content (e.g. doubling the beat list) — usually wrong. Ask "
+ "the user to confirm before appending; once they confirm (e.g. 'yes, "
+ "append'), call this tool again. For targeted changes use the granular "
+ "tools instead (add/update/delete for beats, characters, locations, "
+ "items, scenes)."
)
diff --git a/calliope-backend/src/calliope/agent/harness/log.py b/calliope-backend/src/calliope/agent/harness/log.py
index 615c7524..1513228c 100644
--- a/calliope-backend/src/calliope/agent/harness/log.py
+++ b/calliope-backend/src/calliope/agent/harness/log.py
@@ -37,6 +37,16 @@ TASK_START = "task/start"
TASK_END = "task/end"
TOOL_RESULT_TRUNCATE = 4000
+# Appended wherever a tool result is cut for the LLM. Must TEACH the way out —
+# a bare "[truncated]" sends agents into retry loops hoping for a different
+# cut (observed live 2026-08-24: an assets sub-agent re-called get_workspace
+# repeatedly, blind to characters/locations below the cut).
+TRUNCATE_NOTE = (
+ "…[truncated — the full result is too large for one reply. Fetch a "
+ "smaller slice instead of retrying: get_workspace accepts "
+ "sections=[\"characters\",\"locations\",\"items\",\"beats\",\"scenes\"], "
+ "and scoped tools (get_story, list_scenes) return less.]"
+)
# Cap for ANY single event payload at append time: keeps the append-only log
# faithful in shape but bounded in size (a 500 KB tool result would otherwise
@@ -311,7 +321,7 @@ def derive_llm_history(
def _truncate_result(result: Any) -> str:
text = json.dumps(result, ensure_ascii=False, default=str)
if len(text) > TOOL_RESULT_TRUNCATE:
- return text[:TOOL_RESULT_TRUNCATE] + "…[truncated]"
+ return text[:TOOL_RESULT_TRUNCATE] + TRUNCATE_NOTE
return text
diff --git a/calliope-backend/src/calliope/agent/harness/loop.py b/calliope-backend/src/calliope/agent/harness/loop.py
index f5575722..447a2887 100644
--- a/calliope-backend/src/calliope/agent/harness/loop.py
+++ b/calliope-backend/src/calliope/agent/harness/loop.py
@@ -242,7 +242,7 @@ async def run_turn(
)
result_text = json.dumps(result, ensure_ascii=False, default=str)
if len(result_text) > session_log.TOOL_RESULT_TRUNCATE:
- result_text = result_text[: session_log.TOOL_RESULT_TRUNCATE] + "…[truncated]"
+ result_text = result_text[: session_log.TOOL_RESULT_TRUNCATE] + session_log.TRUNCATE_NOTE
messages.append(
{
"role": "tool",
diff --git a/calliope-backend/src/calliope/agent/harness/orchestrator.py b/calliope-backend/src/calliope/agent/harness/orchestrator.py
index c1dbe03c..d197fd25 100644
--- a/calliope-backend/src/calliope/agent/harness/orchestrator.py
+++ b/calliope-backend/src/calliope/agent/harness/orchestrator.py
@@ -32,7 +32,15 @@ MAX_HISTORY_USER_TURNS = 40
# Tool subsets per sub-agent role. Scoped tighter than the full registry so a
# sub-agent cannot wander into another role's tools.
ROLE_TOOLS: dict[str, list[str]] = {
- "story": ["get_workspace", "get_story", "generate_story", "list_workflows"],
+ "story": [
+ "get_workspace",
+ "get_story",
+ "generate_story",
+ "add_beat",
+ "update_beat",
+ "delete_beat",
+ "list_workflows",
+ ],
"script": [
"get_workspace",
"list_scenes",
@@ -44,6 +52,9 @@ ROLE_TOOLS: dict[str, list[str]] = {
],
"assets": [
"get_workspace",
+ "update_character",
+ "update_location",
+ "update_item",
"list_workflows",
"comfy_server_info",
"run_workflow",
@@ -285,7 +296,14 @@ async def orchestrate(
}
)
except Exception as exc: # noqa: BLE001
- results.append(f"[{role}] FAILED: {exc}")
+ # Some exceptions stringify EMPTY (httpx.ReadTimeout/ReadError,
+ # TimeoutError) — always name the type, and keep the traceback
+ # (observed live 2026-08-25: "Sub-agent failed: " with nothing
+ # after the colon, because a deploy restart killed the in-flight
+ # stream and the ReadError carried no message).
+ logger.exception("Sub-agent %s failed", role)
+ detail = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
+ results.append(f"[{role}] FAILED: {detail}")
session_log.append_event(
session_id,
session_log.TASK_END,
@@ -298,7 +316,7 @@ async def orchestrate(
session_id,
session_log.ASSISTANT_MESSAGE,
{
- "content": f"Sub-agent failed: {exc}",
+ "content": f"Sub-agent failed: {detail}",
"agent_name": f"{role}-agent",
"status": "error",
},
@@ -307,7 +325,7 @@ async def orchestrate(
{
"role": "assistant",
"agent_name": f"{role}-agent",
- "content": f"Sub-agent failed: {exc}",
+ "content": f"Sub-agent failed: {detail}",
"status": "error",
}
)
@@ -471,7 +489,7 @@ async def _run_sub_agent(
)
result_text = json.dumps(result, ensure_ascii=False, default=str)
if len(result_text) > session_log.TOOL_RESULT_TRUNCATE:
- result_text = result_text[: session_log.TOOL_RESULT_TRUNCATE] + "…[truncated]"
+ result_text = result_text[: session_log.TOOL_RESULT_TRUNCATE] + session_log.TRUNCATE_NOTE
messages.append(
{"role": "tool", "tool_call_id": call_id, "content": result_text}
)
diff --git a/calliope-backend/src/calliope/agent/harness/plugins/story.py b/calliope-backend/src/calliope/agent/harness/plugins/story.py
index b696812d..dafba6ee 100644
--- a/calliope-backend/src/calliope/agent/harness/plugins/story.py
+++ b/calliope-backend/src/calliope/agent/harness/plugins/story.py
@@ -52,6 +52,182 @@ def register(registry: ToolRegistry) -> None:
)
)
_register_asset_crud(registry)
+ _register_beat_crud(registry)
+
+
+def _register_beat_crud(registry: ToolRegistry) -> None:
+ """Register add/update/delete tools for story beats.
+
+ Before these existed, beats could ONLY be changed via generate_story —
+ a bulk regenerate — so "align the beats to this story" left an agent no
+ non-destructive path (observed live 2026-08-25: a story sub-agent, boxed
+ in, appended a duplicate 25-beat set and then hit the destructive guard).
+ """
+ registry.register(
+ ToolDefinition(
+ name="add_beat",
+ description=(
+ "Insert one story beat. title is required. order_index is the "
+ "1-based timeline position — later beats shift down to make "
+ "room; omit it to append at the end."
+ ),
+ parameters={
+ "type": "object",
+ "properties": {
+ "title": {"type": "string"},
+ "description": {"type": "string"},
+ "order_index": {
+ "type": "integer",
+ "description": "1-based position; omit to append at the end",
+ },
+ },
+ "required": ["title"],
+ },
+ executor=t_add_beat,
+ category="story",
+ )
+ )
+ registry.register(
+ ToolDefinition(
+ name="update_beat",
+ description=(
+ "Update one existing story beat. beat_id must come from "
+ "get_story/get_workspace — never guess it. Pass only the fields "
+ "to change (title, description, order_index)."
+ ),
+ parameters={
+ "type": "object",
+ "properties": {
+ "beat_id": {"type": "integer"},
+ "title": {"type": "string"},
+ "description": {"type": "string"},
+ "order_index": {"type": "integer"},
+ },
+ "required": ["beat_id"],
+ },
+ executor=t_update_beat,
+ category="story",
+ )
+ )
+ registry.register(
+ ToolDefinition(
+ name="delete_beat",
+ description=(
+ "Delete one story beat permanently; later beats renumber to "
+ "close the gap. beat_id must come from get_story/get_workspace. "
+ "Prefer update_beat for content changes."
+ ),
+ parameters={
+ "type": "object",
+ "properties": {"beat_id": {"type": "integer"}},
+ "required": ["beat_id"],
+ },
+ executor=t_delete_beat,
+ category="story",
+ destructive=True,
+ )
+ )
+
+
+async def t_add_beat(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]:
+ title = args.get("title")
+ if not isinstance(title, str) or not title.strip():
+ return {"ok": False, "error": "title is required (non-empty string)"}
+ conn = _db()
+ try:
+ max_idx = conn.execute(
+ "SELECT COALESCE(MAX(order_index), 0) AS m FROM story_beats WHERE project_id = ?",
+ (ctx.project_id,),
+ ).fetchone()["m"]
+ idx = args.get("order_index")
+ idx = int(idx) if idx is not None else max_idx + 1
+ idx = max(1, min(idx, max_idx + 1))
+ conn.execute(
+ "UPDATE story_beats SET order_index = order_index + 1 "
+ "WHERE project_id = ? AND order_index >= ?",
+ (ctx.project_id, idx),
+ )
+ cur = conn.execute(
+ "INSERT INTO story_beats (project_id, order_index, title, description) "
+ "VALUES (?, ?, ?, ?)",
+ (ctx.project_id, idx, title.strip(), args.get("description")),
+ )
+ conn.execute(
+ "UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ (ctx.project_id,),
+ )
+ conn.commit()
+ beat = conn.execute(
+ "SELECT * FROM story_beats WHERE id = ?", (cur.lastrowid,)
+ ).fetchone()
+ return {"ok": True, "beat": row_to_dict(beat)}
+ finally:
+ conn.close()
+
+
+async def t_update_beat(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]:
+ bid = int(args["beat_id"])
+ data = {
+ k: v
+ for k, v in args.items()
+ if k in ("title", "description", "order_index") and v is not None
+ }
+ if "title" in data and not str(data["title"]).strip():
+ data.pop("title")
+ conn = _db()
+ try:
+ existing = conn.execute(
+ "SELECT id FROM story_beats WHERE id = ? AND project_id = ?",
+ (bid, ctx.project_id),
+ ).fetchone()
+ if not existing:
+ return {"ok": False, "error": f"Beat {bid} not found in this project"}
+ if data:
+ fields = ", ".join(f"{k} = :{k}" for k in data)
+ conn.execute(
+ f"UPDATE story_beats SET {fields} WHERE id = :id AND project_id = :project_id",
+ {**data, "id": bid, "project_id": ctx.project_id},
+ )
+ conn.execute(
+ "UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ (ctx.project_id,),
+ )
+ conn.commit()
+ beat = conn.execute(
+ "SELECT * FROM story_beats WHERE id = ?", (bid,)
+ ).fetchone()
+ return {"ok": True, "beat": row_to_dict(beat)}
+ finally:
+ conn.close()
+
+
+async def t_delete_beat(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]:
+ bid = int(args["beat_id"])
+ conn = _db()
+ try:
+ row = conn.execute(
+ "SELECT order_index FROM story_beats WHERE id = ? AND project_id = ?",
+ (bid, ctx.project_id),
+ ).fetchone()
+ if not row:
+ return {"ok": False, "error": f"Beat {bid} not found in this project"}
+ conn.execute(
+ "DELETE FROM story_beats WHERE id = ? AND project_id = ?",
+ (bid, ctx.project_id),
+ )
+ conn.execute(
+ "UPDATE story_beats SET order_index = order_index - 1 "
+ "WHERE project_id = ? AND order_index > ?",
+ (ctx.project_id, row["order_index"]),
+ )
+ conn.execute(
+ "UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ (ctx.project_id,),
+ )
+ conn.commit()
+ return {"ok": True, "deleted_beat_id": bid}
+ finally:
+ conn.close()
def _register_asset_crud(registry: ToolRegistry) -> None:
diff --git a/calliope-backend/src/calliope/agent/harness/plugins/workspace.py b/calliope-backend/src/calliope/agent/harness/plugins/workspace.py
index f007f124..40f46895 100644
--- a/calliope-backend/src/calliope/agent/harness/plugins/workspace.py
+++ b/calliope-backend/src/calliope/agent/harness/plugins/workspace.py
@@ -21,11 +21,35 @@ def register(registry: ToolRegistry) -> None:
name="get_workspace",
description=(
"Get the current workspace: session linkage and, when linked, "
- "the project with story beats, characters, locations, scenes, "
- "and stats. ALWAYS call this before editing anything or when "
- "you need current ids/counts."
+ "the project with story beats, characters, locations, items, "
+ "scenes, and stats. ALWAYS call this before editing anything or "
+ "when you need current ids/counts. On a LARGE project the full "
+ "result gets truncated — pass sections (e.g. "
+ "[\"characters\",\"locations\",\"items\"]) to fetch only what "
+ "you need; project + stats are always included."
),
- parameters={"type": "object", "properties": {}},
+ parameters={
+ "type": "object",
+ "properties": {
+ "sections": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "beats",
+ "characters",
+ "locations",
+ "items",
+ "scenes",
+ ],
+ },
+ "description": (
+ "Which sections to include. Omit for all — but on "
+ "large projects prefer only the sections you need."
+ ),
+ }
+ },
+ },
executor=t_get_workspace,
requires_project=False,
category="workspace",
@@ -37,7 +61,8 @@ def register(registry: ToolRegistry) -> None:
description=(
"Link this sandbox session to an EXISTING project (call "
"list_projects first to get valid ids). Only possible while the "
- "session is not yet linked — do NOT create a duplicate project "
+ "session is not yet linked — to switch projects, call "
+ "unlink_project first. Do NOT create a duplicate project "
"when one already matches."
),
parameters={
@@ -56,6 +81,21 @@ def register(registry: ToolRegistry) -> None:
category="workspace",
)
)
+ registry.register(
+ ToolDefinition(
+ name="unlink_project",
+ description=(
+ "Detach this session from its linked project and return to "
+ "sandbox mode. The project and all its content are untouched — "
+ "only the session binding is cleared. Use this when the user "
+ "wants a NEW project (then create_project) or a DIFFERENT "
+ "existing one (then link_project). Not available in sandbox."
+ ),
+ parameters={"type": "object", "properties": {}},
+ executor=t_unlink_project,
+ category="workspace",
+ )
+ )
registry.register(
ToolDefinition(
name="create_project",
@@ -127,6 +167,9 @@ def register(registry: ToolRegistry) -> None:
)
+_WS_SECTIONS = ("beats", "characters", "locations", "items", "scenes")
+
+
# ── executors ───────────────────────────────────────────────────
@@ -154,6 +197,16 @@ async def t_get_workspace(ctx: ToolContext, args: dict[str, Any]) -> dict[str, A
out["mode"] = "sandbox"
out["hint"] = "Linked project no longer exists."
return out
+ requested = args.get("sections")
+ if requested:
+ wanted = {sec for sec in requested if sec in _WS_SECTIONS}
+ if not wanted:
+ return {
+ "ok": False,
+ "error": f"No valid sections in {requested!r}; valid: {sorted(_WS_SECTIONS)}",
+ }
+ else:
+ wanted = set(_WS_SECTIONS)
beats = [
row_to_dict(r)
for r in conn.execute(
@@ -161,7 +214,7 @@ async def t_get_workspace(ctx: ToolContext, args: dict[str, Any]) -> dict[str, A
"WHERE project_id = ? ORDER BY order_index",
(ctx.project_id,),
).fetchall()
- ]
+ ] if "beats" in wanted else None
characters = [
row_to_dict(r)
for r in conn.execute(
@@ -169,7 +222,7 @@ async def t_get_workspace(ctx: ToolContext, args: dict[str, Any]) -> dict[str, A
"sheet_path, consistency_prompt FROM characters WHERE project_id = ?",
(ctx.project_id,),
).fetchall()
- ]
+ ] if "characters" in wanted else None
locations = [
row_to_dict(r)
for r in conn.execute(
@@ -177,7 +230,7 @@ async def t_get_workspace(ctx: ToolContext, args: dict[str, Any]) -> dict[str, A
"FROM locations WHERE project_id = ?",
(ctx.project_id,),
).fetchall()
- ]
+ ] if "locations" in wanted else None
items = [
row_to_dict(r)
for r in conn.execute(
@@ -185,7 +238,7 @@ async def t_get_workspace(ctx: ToolContext, args: dict[str, Any]) -> dict[str, A
"FROM items WHERE project_id = ?",
(ctx.project_id,),
).fetchall()
- ]
+ ] if "items" in wanted else None
scenes = [
row_to_dict(r)
for r in conn.execute(
@@ -193,15 +246,21 @@ async def t_get_workspace(ctx: ToolContext, args: dict[str, Any]) -> dict[str, A
"location_id, video_path FROM scenes WHERE project_id = ? ORDER BY order_index",
(ctx.project_id,),
).fetchall()
- ]
+ ] if "scenes" in wanted else None
out["mode"] = "linked"
out["project"] = project
out["stats"] = _project_stats(ctx.project_id, conn)
- out["beats"] = beats
- out["characters"] = characters
- out["locations"] = locations
- out["items"] = items
- out["scenes"] = scenes
+ for key, value in (
+ ("beats", beats),
+ ("characters", characters),
+ ("locations", locations),
+ ("items", items),
+ ("scenes", scenes),
+ ):
+ if value is not None:
+ out[key] = value
+ if len(wanted) < len(_WS_SECTIONS):
+ out["sections_omitted"] = sorted(set(_WS_SECTIONS) - wanted)
return out
finally:
conn.close()
@@ -228,6 +287,29 @@ async def t_link_project(ctx: ToolContext, args: dict[str, Any]) -> dict[str, An
return {"ok": True, "project_id": pid, "title": project["title"]}
+async def t_unlink_project(ctx: ToolContext, args: dict[str, Any]) -> dict[str, Any]:
+ released_id = ctx.project_id
+ conn = _db()
+ try:
+ project = _project_or_error(conn, released_id)
+ conn.execute(
+ "UPDATE agent_sessions SET project_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ (ctx.session_id,),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ ctx.project_id = None
+ await publish_session_updated(ctx.session_id)
+ return {
+ "ok": True,
+ "released_project_id": released_id,
+ "released_title": (project or {}).get("title"),
+ "mode": "sandbox",
+ "hint": "Session is back in sandbox — create_project and link_project are available again.",
+ }
+
+
_TITLE_MAX = 200 # mirrors ProjectCreate/ProjectUpdate schema bounds
diff --git a/calliope-backend/src/calliope/agent/harness/policy.py b/calliope-backend/src/calliope/agent/harness/policy.py
index f4c99223..a737aaf3 100644
--- a/calliope-backend/src/calliope/agent/harness/policy.py
+++ b/calliope-backend/src/calliope/agent/harness/policy.py
@@ -14,7 +14,7 @@ from calliope.agent.harness.registry import ToolContext
# (or "replace it" / "start over") for the destructive guard.
_CONFIRM_RE = re.compile(
r"\b(yes|yeah|yep|yup|sure|ok|okay|k|confirm(?:ed)?|proceed|"
- r"go\s+ahead|do\s+it|please\s+do|overwrite|replace|regenerate|"
+ r"go\s+ahead|do\s+it|please\s+do|overwrite|replace|append|regenerate|"
r"redo|re-?do|start\s+over|restart|delete|wipe|reset|from\s+scratch|"
r"go\s+for\s+it|fine|sounds\s+good|that'?s\s+fine)\b",
re.IGNORECASE,
diff --git a/calliope-backend/src/calliope/agent/harness/registry.py b/calliope-backend/src/calliope/agent/harness/registry.py
index 29974293..07e2a0de 100644
--- a/calliope-backend/src/calliope/agent/harness/registry.py
+++ b/calliope-backend/src/calliope/agent/harness/registry.py
@@ -161,7 +161,13 @@ class ToolRegistry:
"error": "This tool requires a linked project. Create one with create_project first.",
}
if t.blind_only and ctx.project_id is not None:
- return {"ok": False, "error": "This tool is only available in a sandbox (unlinked) session."}
+ return {
+ "ok": False,
+ "error": (
+ "This tool is only available in a sandbox (unlinked) session. "
+ "Call unlink_project first to return this session to sandbox."
+ ),
+ }
decision = ALLOW
for hook in self.pre_execute:
diff --git a/calliope-backend/tests/test_agent_event_log.py b/calliope-backend/tests/test_agent_event_log.py
index 5f4b4109..385cda3f 100644
--- a/calliope-backend/tests/test_agent_event_log.py
+++ b/calliope-backend/tests/test_agent_event_log.py
@@ -298,12 +298,16 @@ def test_destructive_guard_blocks_replace_on_nonempty_project(client):
)
assert out["ok"] is False
assert "blocked:" in out["error"]
- assert "replace=false" in out["error"]
+ # The old message suggested "pass replace=false" as the escape — that
+ # advice caused a silent beat-doubling (2026-08-25), so append is now
+ # gated too and the message points at the granular tools instead.
+ assert "granular tools" in out["error"]
- # replace=false passes the guard (then may fail later for other reasons —
- # but NOT with a "blocked:" error).
+ # replace=false on a NON-EMPTY project is also blocked now (it would
+ # append a duplicate full set), with its own explanatory message.
out2 = asyncio.run(registry.execute(ctx, "generate_story", {"replace": False}))
- assert "blocked:" not in str(out2.get("error", ""))
+ assert out2["ok"] is False
+ assert "APPEND" in out2["error"]
def test_destructive_guard_allows_empty_project(client):
diff --git a/calliope-backend/tests/test_agent_harness.py b/calliope-backend/tests/test_agent_harness.py
index 1265ed70..c96386fc 100644
--- a/calliope-backend/tests/test_agent_harness.py
+++ b/calliope-backend/tests/test_agent_harness.py
@@ -66,6 +66,9 @@ def test_openai_payload_scoping():
assert "create_project" not in linked_names
assert "link_project" not in linked_names
assert "generate_story" in linked_names
+ # unlink_project is the way back: linked-only, hidden in sandbox
+ assert "unlink_project" in linked_names
+ assert "unlink_project" not in blind_names
def test_execute_tool_unknown_and_unscoped():
@@ -682,3 +685,53 @@ def test_bulk_video_policy_phrases():
assert allows_bulk_video_enqueue("yes", 24) is False
assert allows_bulk_video_enqueue("generate all scenes", 24) is True
assert allows_bulk_video_enqueue("do every clip", 10) is True
+
+
+def test_sub_agent_failure_names_empty_str_exception(client):
+ """An exception whose str() is EMPTY must still be identifiable in the
+ failure message and event log. Observed live 2026-08-25: 'Sub-agent
+ failed: ' with nothing after the colon — an httpx.ReadError('') raised
+ when a deploy restart killed the in-flight stream."""
+ import httpx
+ import calliope.agent.harness.orchestrator as orch
+ from calliope.agent.harness import log as session_log
+
+ r = client.post("/api/agent/sessions", json={"title": "crash-detail"})
+ sid = r.json()["id"]
+ pid = _make_project(client)
+
+ class _SwarmPlanClient:
+ async def chat(self, messages, temperature=0.2, **kw):
+ return json.dumps(
+ {
+ "mode": "swarm",
+ "note": "plan",
+ "tasks": [{"role": "assets", "goal": "update text assets"}],
+ }
+ )
+
+ async def close(self):
+ return None
+
+ async def dying_sub_agent(ctx, sub_history, allowed, *, agent_name, on_message=None, **kw):
+ raise httpx.ReadError("")
+
+ orig_client = orch.LLMClient
+ orig_sub = orch._run_sub_agent
+ orch.LLMClient = lambda: _SwarmPlanClient()
+ orch._run_sub_agent = dying_sub_agent
+ try:
+ ctx = ToolContext(session_id=sid, project_id=pid)
+ asyncio.run(orchestrate(ctx, [], session_id=sid))
+ finally:
+ orch.LLMClient = orig_client
+ orch._run_sub_agent = orig_sub
+
+ events = session_log.read_events(sid)
+ fails = [
+ e.data for e in events
+ if e.type == session_log.ASSISTANT_MESSAGE
+ and str(e.data.get("content", "")).startswith("Sub-agent failed:")
+ ]
+ assert fails, "no failure message recorded"
+ assert "ReadError" in fails[0]["content"]
diff --git a/calliope-backend/tests/test_agent_security.py b/calliope-backend/tests/test_agent_security.py
index a1a09ad7..0f235544 100644
--- a/calliope-backend/tests/test_agent_security.py
+++ b/calliope-backend/tests/test_agent_security.py
@@ -1727,3 +1727,234 @@ def test_concurrent_start_turn_single_winner(client):
results = aio.run(scenario())
assert sorted(results) == ["ok", "rejected"], results
+
+
+# ─────────────────────────────────────────────────────────────────────────
+# unlink_project: the way out of the linked-session one-way door
+# (Observed live 2026-08-24, session 82: a linked agent burned three failed
+# tool calls trying to create a fresh project — create_project and
+# link_project are sandbox-only and no unlink existed.)
+# ─────────────────────────────────────────────────────────────────────────
+
+
+def test_unlink_project_round_trip(client):
+ from calliope.agent.harness.registry import ToolContext
+ from calliope.agent.harness.tools import execute_tool
+
+ pid = _mk_project(client, "Original Film")
+ sid = _mk_session(project_id=pid)
+ ctx = ToolContext(session_id=sid, project_id=pid)
+
+ # 1. Linked session: create_project is blocked, and the error now says how
+ # to get out.
+ out = asyncio.run(execute_tool(ctx, "create_project", {"title": "Fresh", "idea": "x"}))
+ assert out["ok"] is False
+ assert "unlink_project" in out["error"]
+
+ # 2. unlink: binding cleared, project untouched.
+ out = asyncio.run(execute_tool(ctx, "unlink_project", {}))
+ assert out["ok"] is True
+ assert out["released_project_id"] == pid
+ assert ctx.project_id is None
+ conn = get_db(settings.db_path)
+ try:
+ row = conn.execute(
+ "SELECT project_id FROM agent_sessions WHERE id = ?", (sid,)
+ ).fetchone()
+ assert row["project_id"] is None
+ assert conn.execute(
+ "SELECT COUNT(*) AS n FROM projects WHERE id = ?", (pid,)
+ ).fetchone()["n"] == 1
+ finally:
+ conn.close()
+
+ # 3. Sandbox again: create_project now works and auto-links.
+ out = asyncio.run(execute_tool(ctx, "create_project", {"title": "Fresh", "idea": "x"}))
+ assert out["ok"] is True
+ assert ctx.project_id == out["project"]["id"]
+ assert ctx.project_id != pid
+
+ # 4. And the session can come back to the original via unlink + link.
+ out = asyncio.run(execute_tool(ctx, "unlink_project", {}))
+ assert out["ok"] is True
+ out = asyncio.run(execute_tool(ctx, "link_project", {"project_id": pid}))
+ assert out["ok"] is True
+ assert ctx.project_id == pid
+
+
+def test_unlink_project_requires_linked_session(client):
+ from calliope.agent.harness.registry import ToolContext
+ from calliope.agent.harness.tools import execute_tool
+
+ sid = _mk_session(project_id=None)
+ ctx = ToolContext(session_id=sid, project_id=None)
+ out = asyncio.run(execute_tool(ctx, "unlink_project", {}))
+ assert out["ok"] is False
+ assert "requires a linked project" in out["error"]
+
+
+# ─────────────────────────────────────────────────────────────────────────
+# get_workspace sections + teaching truncation + assets-role editors
+# (Observed live 2026-08-25: on a 43-scene project the assets sub-agent's
+# get_workspace result truncated mid-beats, hiding characters/locations/items
+# entirely; it retried in a loop and had no editor tools anyway.)
+# ─────────────────────────────────────────────────────────────────────────
+
+
+def test_get_workspace_sections_scoped_fetch(client):
+ from calliope.agent.harness.registry import ToolContext
+ from calliope.agent.harness.tools import execute_tool
+
+ pid = _mk_project(client, "Sectioned")
+ conn = get_db(settings.db_path)
+ try:
+ conn.execute(
+ "INSERT INTO characters (project_id, name, role) VALUES (?, ?, ?)",
+ (pid, "Mia", "lead"),
+ )
+ conn.execute(
+ "INSERT INTO story_beats (project_id, order_index, title, description) "
+ "VALUES (?, 1, 'Open', 'a')",
+ (pid,),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ sid = _mk_session(project_id=pid)
+ ctx = ToolContext(session_id=sid, project_id=pid)
+
+ out = asyncio.run(execute_tool(ctx, "get_workspace", {"sections": ["characters"]}))
+ assert out["mode"] == "linked"
+ assert [c["name"] for c in out["characters"]] == ["Mia"]
+ assert "beats" not in out and "scenes" not in out
+ assert "beats" in out["sections_omitted"]
+ assert "stats" in out and "project" in out # always included
+
+ # No sections -> everything (back-compat)
+ out = asyncio.run(execute_tool(ctx, "get_workspace", {}))
+ for key in ("beats", "characters", "locations", "items", "scenes"):
+ assert key in out
+ assert "sections_omitted" not in out
+
+ # Garbage sections -> explicit error, not a silent full fetch
+ out = asyncio.run(execute_tool(ctx, "get_workspace", {"sections": ["bogus"]}))
+ assert out["ok"] is False
+ assert "valid" in out["error"]
+
+
+def test_truncation_note_teaches_scoped_fetch():
+ from calliope.agent.harness import log as session_log
+
+ big = {"blob": "x" * (session_log.TOOL_RESULT_TRUNCATE + 100)}
+ text = session_log._truncate_result(big)
+ assert "sections=" in text
+ assert len(text) < session_log.TOOL_RESULT_TRUNCATE + len(session_log.TRUNCATE_NOTE) + 1
+
+
+def test_assets_role_has_editors_and_they_resolve():
+ from calliope.agent.harness.orchestrator import ROLE_TOOLS, _scoped_payload
+ from calliope.agent.harness.registry import ToolContext
+
+ for tool in ("update_character", "update_location", "update_item"):
+ assert tool in ROLE_TOOLS["assets"]
+ ctx = ToolContext(session_id=9_999_003, project_id=99)
+ names = {t["function"]["name"] for t in _scoped_payload(ctx, ROLE_TOOLS["assets"])}
+ assert {"update_character", "update_location", "update_item", "get_workspace"} <= names
+
+
+# ─────────────────────────────────────────────────────────────────────────
+# Append gating + beat CRUD
+# (Observed live 2026-08-25: an unconfirmed generate_story replace=false
+# silently APPENDED a duplicate 25-beat set — 25 -> 50 beats — because the
+# guard only gated replace=true; and with no beat editors, bulk regeneration
+# was the sub-agent's only lever for "align the beats to this story".)
+# ─────────────────────────────────────────────────────────────────────────
+
+
+def test_guard_blocks_append_on_nonempty_project(client):
+ registry, _ = build_harness()
+ pid = _mk_project(client, "Append Film")
+ client.post(f"/api/projects/{pid}/scenes", json={"heading": "S1", "order_index": 1})
+ ctx = ToolContext(session_id=_mk_session(), project_id=pid)
+
+ out = asyncio.run(registry.execute(ctx, "generate_story", {"replace": False}))
+ assert out["ok"] is False
+ assert "APPEND" in out["error"]
+ assert "granular tools" in out["error"]
+
+
+def test_guard_allows_append_after_user_confirms(client):
+ pid = _mk_project(client, "Append OK Film")
+ client.post(f"/api/projects/{pid}/scenes", json={"heading": "S1", "order_index": 1})
+ sid = _mk_session(pid)
+ session_log.append_event(
+ sid, session_log.USER_MESSAGE, {"content": "yes, append the new beats"}
+ )
+ reg = _guard_registry()
+ out = asyncio.run(
+ reg.execute(ToolContext(session_id=sid, project_id=pid), "bulk_replace", {"replace": False})
+ )
+ assert "blocked:" not in str(out.get("error", ""))
+
+
+def test_beat_crud_round_trip(client):
+ from calliope.agent.harness.tools import execute_tool
+
+ registry, _ = build_harness()
+ pid = _mk_project(client, "Beat Film")
+ sid = _mk_session(project_id=pid)
+ ctx = ToolContext(session_id=sid, project_id=pid)
+
+ # Append two, then insert one at position 2 — later beats shift down.
+ a = asyncio.run(execute_tool(ctx, "add_beat", {"title": "Open", "description": "a"}))
+ b = asyncio.run(execute_tool(ctx, "add_beat", {"title": "End", "description": "c"}))
+ assert (a["beat"]["order_index"], b["beat"]["order_index"]) == (1, 2)
+ m = asyncio.run(
+ execute_tool(ctx, "add_beat", {"title": "Turn", "description": "b", "order_index": 2})
+ )
+ assert m["beat"]["order_index"] == 2
+
+ conn = get_db(settings.db_path)
+ try:
+ rows = conn.execute(
+ "SELECT order_index, title FROM story_beats WHERE project_id = ? ORDER BY order_index",
+ (pid,),
+ ).fetchall()
+ finally:
+ conn.close()
+ assert [(r["order_index"], r["title"]) for r in rows] == [
+ (1, "Open"), (2, "Turn"), (3, "End"),
+ ]
+
+ # Update content + reject foreign/unknown ids.
+ out = asyncio.run(
+ execute_tool(ctx, "update_beat", {"beat_id": m["beat"]["id"], "description": "the turn"})
+ )
+ assert out["beat"]["description"] == "the turn"
+ out = asyncio.run(execute_tool(ctx, "update_beat", {"beat_id": 999999}))
+ assert out["ok"] is False
+
+ # Delete the middle beat — the gap closes.
+ out = asyncio.run(execute_tool(ctx, "delete_beat", {"beat_id": m["beat"]["id"]}))
+ assert out["ok"] is True
+ conn = get_db(settings.db_path)
+ try:
+ rows = conn.execute(
+ "SELECT order_index, title FROM story_beats WHERE project_id = ? ORDER BY order_index",
+ (pid,),
+ ).fetchall()
+ finally:
+ conn.close()
+ assert [(r["order_index"], r["title"]) for r in rows] == [(1, "Open"), (2, "End")]
+
+
+def test_story_role_has_beat_editors():
+ from calliope.agent.harness.orchestrator import ROLE_TOOLS, _scoped_payload
+
+ for tool in ("add_beat", "update_beat", "delete_beat"):
+ assert tool in ROLE_TOOLS["story"]
+ ctx = ToolContext(session_id=9_999_004, project_id=99)
+ names = {t["function"]["name"] for t in _scoped_payload(ctx, ROLE_TOOLS["story"])}
+ assert {"add_beat", "update_beat"} <= names
+ # delete_beat is destructive but not approval-gated; it should resolve too
+ assert "delete_beat" in names
diff --git a/calliope-web/src/lib/components/video/ClipMonitor.svelte b/calliope-web/src/lib/components/video/ClipMonitor.svelte
index 29caaf9d..63cbfa85 100644
--- a/calliope-web/src/lib/components/video/ClipMonitor.svelte
+++ b/calliope-web/src/lib/components/video/ClipMonitor.svelte
@@ -38,7 +38,10 @@
}: Props = $props();
let errorOpen = $state(false);
- const previewUrl = $derived(previewPath ? assetUrl(previewPath) : null);
+ // '#t=0.1' media fragment: with preload="metadata" browsers paint NOTHING
+ // until playback, so every clip preview sat black ("no picture, but sound").
+ // The fragment makes the browser seek+paint a first frame without playing.
+ const previewUrl = $derived(previewPath ? assetUrl(previewPath) + '#t=0.1' : null);
$effect(() => {
void previewPath;
diff --git a/calliope-web/src/lib/components/video/SceneFilmstrip.svelte b/calliope-web/src/lib/components/video/SceneFilmstrip.svelte
index 71570a26..a4eb2883 100644
--- a/calliope-web/src/lib/components/video/SceneFilmstrip.svelte
+++ b/calliope-web/src/lib/components/video/SceneFilmstrip.svelte
@@ -81,7 +81,7 @@
{:else if thumb?.kind === 'video'}
-
+
{:else}
#{scene.order_index}
{/if}