Merge PR 25 locally for testing
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user