Files
aigc-home/calliope-backend/src/calliope/agent/harness/registry.py
T
Benjiyaya ee7648d957 feat: release 1.5.4 - steering, Asset Library, canvas delete, agent hardening
- Mid-run steering input in AI Canvas + Build Scene (steering/message event, step-boundary drain, composer Steer mode)
- New /library Asset Library page: unlinked Playground media with All/Image/Video filters, 50-per-page lazy loading, bulk delete, add-to-project
- AI Canvas delete now persists: reference-checked file cleanup for playground-owned artifacts, entity tombstones, canvas.updated sync
- Agent loop hardening batch: swarm safety nets, clip tools in roles, capture false-success fix, planner fast-path, per-tool timeout, char-bounded history
- Build Scene Brief/Cut gates + MP4 export normalization
2026-09-19 06:31:14 +08:00

353 lines
15 KiB
Python

"""Tool registry with a guarded execution pipeline.
Ported from the deepseek-harness `ctx.tools` seam: a tool is a registration
(schema + async executor) contributed by a plugin; execution runs through a
`pre-execute → execute → post-execute` waterfall so policy (destructive-action
guarding, approval, audit) lives outside the loop and outside tool bodies.
Key invariants kept from the previous registry:
- `project_id` is injected via ToolContext — never an LLM argument.
- `requires_project` / `blind_only` enforce sandbox scoping at BOTH payload
assembly and execution time.
"""
from __future__ import annotations
import asyncio
import inspect
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from calliope.config import settings
from calliope.db import get_db, row_to_dict
from calliope.events.bus import event_bus
# ─────────────────────────────────────────────────────────────────────────
# Scene tool scope (Build Scene surface)
# ─────────────────────────────────────────────────────────────────────────
# Build Scene sessions (origin='scene') are the 3D blockout surface: their
# toolset is the shot_* set plus this minimal base. Enforced at BOTH payload
# assembly and execute time (same two-layer pattern as requires_project).
# Defined here (not harness/__init__) to avoid a circular import; re-exported
# there for tests/UI consumers.
GUARD_SCENE_TOOL_SCOPE = "guard_scene_tool_scope"
_SCENE_ALLOWED_CATEGORIES = {"shot"}
_SCENE_ALLOWED_TOOLS = {
"ask_user",
"list_skills",
"read_skill",
"save_memory",
"list_memories",
"forget_memory",
}
# Invented agent tool — there is no export_video; Cut + UI Export video instead.
GUARD_NO_EXPORT_VIDEO_TOOL = "guard_no_export_video_tool"
EXPORT_VIDEO_DENIED_NAMES = frozenset({"export_video", "exportVideo"})
def _tool_timeout_sec() -> float:
"""Per-tool wall-clock cap from Settings; 0 = disabled."""
try:
t = float(settings.agent_tool_timeout_sec)
except (TypeError, ValueError, AttributeError):
return 0.0
return max(0.0, t)
def _scene_scoped(t: "ToolDefinition") -> bool:
return getattr(t, "category", None) in _SCENE_ALLOWED_CATEGORIES or t.name in _SCENE_ALLOWED_TOOLS
# ─────────────────────────────────────────────────────────────────────────
# Context & definitions
# ─────────────────────────────────────────────────────────────────────────
@dataclass
class ToolContext:
"""Per-run context. `project_id` is the session's workspace binding:
None means a blind/sandbox session (only project-creating tools allowed).
`origin` stamps WHERE the session was born ('chat' | 'scene') — scene
sessions are Build Scene's blockout surface and get the shot-tool-only
payload; it says nothing about project linking."""
session_id: int
project_id: int | None = None
origin: str = "chat"
@dataclass
class ToolDefinition:
"""One registered tool: model-facing schema + async executor."""
name: str
description: str
parameters: dict[str, Any] # JSON Schema (object)
executor: Callable[[ToolContext, dict[str, Any]], Awaitable[dict[str, Any] | list | str]]
requires_project: bool = True # False → also available in blind sessions
blind_only: bool = False # True → hidden once the session is linked
category: str = "general"
destructive: bool = False # flagged in descriptions; pre-execute guard uses it
requires_approval: bool = False # HITL: blocked unless the user explicitly asked
# True → exempt from the per-tool wall-clock timeout (agent_tool_timeout_sec);
# the tool owns its own wait contract (e.g. wait_for_jobs → queue_poll_timeout_sec).
long_running: bool = False
# Hook results — the vocabulary of the execution pipeline.
# pre-execute: allow | deny (veto/replace before the body runs)
# post-execute: keep | replace(result) (audit/redaction)
@dataclass
class PreExecuteDecision:
kind: str # "allow" | "deny"
error: str | None = None
reason_code: str | None = None # stable machine token for UI/tests, e.g. guard_render_approval
ALLOW = PreExecuteDecision(kind="allow")
def allow() -> PreExecuteDecision:
return ALLOW
def deny(reason: str, code: str | None = None) -> PreExecuteDecision:
return PreExecuteDecision(kind="deny", error=reason, reason_code=code)
@dataclass
class PostExecuteDecision:
kind: str # "keep" | "replace"
result: dict[str, Any] | None = None
KEEP = PostExecuteDecision(kind="keep")
def replace_result(result: dict[str, Any]) -> PostExecuteDecision:
return PostExecuteDecision(kind="replace", result=result)
# Hook signatures (all optional pipeline stages)
PreExecuteHook = Callable[[ToolContext, "ToolDefinition", dict[str, Any]], Awaitable[PreExecuteDecision]]
PostExecuteHook = Callable[
[ToolContext, "ToolDefinition", dict[str, Any], dict[str, Any]], Awaitable[PostExecuteDecision]
]
@dataclass
class ToolRegistry:
"""Scoped tool registry. Plugins contribute definitions; the loop and the
sub-agent runner request the payload for a ToolContext."""
tools: dict[str, ToolDefinition] = field(default_factory=dict)
pre_execute: list[PreExecuteHook] = field(default_factory=list)
post_execute: list[PostExecuteHook] = field(default_factory=list)
async def _execute_with_timeout(
self, t: "ToolDefinition", ctx: ToolContext, args: dict[str, Any]
) -> dict[str, Any]:
timeout = _tool_timeout_sec()
if timeout <= 0 or t.long_running:
return await t.executor(ctx, args)
return await asyncio.wait_for(t.executor(ctx, args), timeout=timeout)
def register(self, definition: ToolDefinition) -> None:
if definition.name in self.tools:
raise ValueError(f"Tool already registered: {definition.name}")
self.tools[definition.name] = definition
def on_pre_execute(self, hook: PreExecuteHook) -> None:
self.pre_execute.append(hook)
def on_post_execute(self, hook: PostExecuteHook) -> None:
self.post_execute.append(hook)
# ── scoping ──────────────────────────────────────────────────
def _visible(self, t: ToolDefinition, ctx: ToolContext) -> bool:
if t.requires_project and ctx.project_id is None:
return False
if t.blind_only and ctx.project_id is not None:
return False
# Build Scene surface: shot tools + minimal base ONLY. A missing tool
# is harder to misuse than one that fails at execute time.
if ctx.origin == "scene" and not _scene_scoped(t):
return False
# Hide render tools until the user asks — the model still "knows"
# run_workflow from earlier turns, but a missing tool is harder to
# call than one that only fails in pre-execute (HITL cards).
# Approval via a scoped question card counts the same as prose.
if t.requires_approval:
from calliope.agent.harness.policy import user_allows_render
if not user_allows_render(ctx):
return False
return True
def openai_payload(self, ctx: ToolContext) -> list[dict[str, Any]]:
"""OpenAI tool-calling payload for the tools available in this context."""
return [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters,
},
}
for t in self.tools.values()
if self._visible(t, ctx)
]
def get(self, name: str) -> ToolDefinition | None:
return self.tools.get(name)
# ── execution pipeline ───────────────────────────────────────
async def execute(self, ctx: ToolContext, name: str, args: dict[str, Any]) -> dict[str, Any]:
"""Run one tool through the guarded pipeline. Returns a JSON-serializable dict."""
t = self.tools.get(name)
if t is None:
if name in EXPORT_VIDEO_DENIED_NAMES:
return {
"ok": False,
"reason_code": GUARD_NO_EXPORT_VIDEO_TOOL,
"error": (
"There is no tool named export_video. Build Scene "
"export is human-gated: ask_user Cut approval → "
"record_build_scene_gate(gate='cut') → tell the user to "
"click **Export video** in the Build Scene UI. "
"For stills only use request_capture. Do not invent "
"export_video; do not call enqueue_video_jobs or follow "
"scene-to-video — that lane is Comfy closed for origin=scene. "
"Prefer read_skill(\"shot-composer-blockout\")."
),
}
return {"ok": False, "error": f"Unknown tool: {name}"}
if t.requires_project and ctx.project_id is None:
return {
"ok": False,
"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. "
"Call unlink_project first to return this session to sandbox."
),
}
if ctx.origin == "scene" and not _scene_scoped(t):
extra = ""
if name in {"enqueue_video_jobs", "enqueue_asset_jobs", "run_workflow"} or name.startswith(
("enqueue_", "comfy_")
):
extra = (
" scene-to-video / enqueue_video_jobs is the WRONG skill/lane "
"for Build Scene — use read_skill(\"shot-composer-blockout\") "
"and Cut → user clicks Export video."
)
return {
"ok": False,
"reason_code": GUARD_SCENE_TOOL_SCOPE,
"error": (
"This is a Build Scene session: "
"only shot_* tools plus ask_user / skills / memory are available. "
"ComfyUI and workflow tools (run_workflow, list_workflows, "
"enqueue_*, comfy_*) are banned here. Build with shot_* "
"recipes, then Capture / Export video (Cut-gated) — "
"pick the clip from Playground later; do not generate via Comfy "
"from this chat."
+ extra
),
}
decision = ALLOW
for hook in self.pre_execute:
outcome = hook(ctx, t, args)
if inspect.isawaitable(outcome):
outcome = await outcome
decision = outcome
if decision.kind == "deny":
denied: dict[str, Any] = {"ok": False, "error": f"blocked: {decision.error}"}
if decision.reason_code:
denied["reason_code"] = decision.reason_code
return denied
try:
result = await self._execute_with_timeout(t, ctx, args)
except TimeoutError:
# Per-tool wall-clock cap (0 = disabled): a hung tool must stall
# its step, not the session. CancelledError passes through so the
# Stop button keeps working.
return {
"ok": False,
"error": f"{name} timed out after {_tool_timeout_sec():.0f}s",
"timeout": True,
}
except Exception as exc: # noqa: BLE001 — tool errors are loop-feedback, not crashes
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
if isinstance(result, dict):
result.setdefault("ok", True)
else:
result = {"ok": True, "result": result}
for hook in self.post_execute:
d = hook(ctx, t, args, result)
if inspect.isawaitable(d):
d = await d
if d.kind == "replace":
result = d.result or {}
return result
# ─────────────────────────────────────────────────────────────────────────
# Session-changed notification (kept from the old registry)
# ─────────────────────────────────────────────────────────────────────────
async def publish_session_updated(session_id: int) -> None:
"""Fire agent.session.updated with the enriched session (project info
included) so the UI can flip sandbox→linked live."""
from calliope.routers.agent import session_event_payload
await event_bus.publish(
"agent.session.updated",
{"session": await session_event_payload(session_id)},
)
# ─────────────────────────────────────────────────────────────────────────
# Shared DB helpers (used by tool executors)
# ─────────────────────────────────────────────────────────────────────────
def _db():
return get_db(settings.db_path)
def _project_or_error(conn, project_id: int) -> dict[str, Any] | None:
row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
return row_to_dict(row) if row else None
def _project_stats(project_id: int, conn) -> dict[str, int]:
cur = conn.execute(
"""
SELECT
(SELECT COUNT(*) FROM scenes WHERE project_id = :pid) AS scene_count,
(SELECT COUNT(*) FROM characters WHERE project_id = :pid) AS character_count,
(SELECT COUNT(*) FROM characters WHERE project_id = :pid AND sheet_path IS NOT NULL)
+ (SELECT COUNT(*) FROM locations WHERE project_id = :pid AND reference_image_path IS NOT NULL)
+ (SELECT COUNT(*) FROM items WHERE project_id = :pid AND reference_image_path IS NOT NULL) AS asset_ready_count,
(SELECT COUNT(*) FROM characters WHERE project_id = :pid)
+ (SELECT COUNT(*) FROM locations WHERE project_id = :pid)
+ (SELECT COUNT(*) FROM items WHERE project_id = :pid) AS asset_total_count
""",
{"pid": project_id},
)
return dict(cur.fetchone())