Merge PR 25 locally for testing

This commit is contained in:
Benjiyaya
2026-08-27 11:50:18 +08:00
13 changed files with 636 additions and 38 deletions
@@ -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)."
)
@@ -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
@@ -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",
@@ -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}
)
@@ -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:
@@ -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
@@ -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,
@@ -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:
@@ -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):
@@ -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"]
@@ -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
@@ -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;
@@ -81,7 +81,7 @@
<img class="thumb-media" src={thumb.src} alt="" loading="lazy" />
{:else if thumb?.kind === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="thumb-media" src={thumb.src} muted playsinline preload="metadata"></video>
<video class="thumb-media" src={thumb.src + '#t=0.1'} muted playsinline preload="metadata"></video>
{:else}
<span class="thumb-slate">#{scene.order_index}</span>
{/if}