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}