Backend:
- agents_registry: five AIGC roles (剧本创作/视觉设计/分镜设计/素材资产/镜头脚本)
with zh personas, agent-chat mode text, and llm_role mapping onto the
models-page five-role binding surface (story/style/script/assets/video)
- agent_sessions.agent_key column + migration; sessions filter/exclusion;
named replies stamped with the owner agent's display name
- prompt assembly: per-agent persona + AGENT CHAT mode section replace the
generic sandbox text; mentions section suppressed for agent chats
- main loop routes the LLM through the agent's bound role
- skill admin surface: skill_overrides/skill_bindings/skill_versions tables,
PUT/PATCH/DELETE /api/agent/skills/{name} + bindings + versions/restore;
effective rule = global enable ∩ enabled per-agent binding; builtin skills
seeded to all five agents at upgrade; read_skill/list_skills gated for
named-agent sessions
Frontend:
- /agents is now the three-column A-darkroom page (agent list + skill library
/ chat with reused AgentChat / skill manager with editor, assignment
capsules, version history + rollback); per-agent model select synced with
the models page; narrow breakpoint turns the manager into a drawer
- legacy ?project=&task= deep links still route into the canvas flow
- i18n keys added to all 7 locales
Tests: tests/test_agent_roles.py + tests/test_skill_admin.py (27 cases)
Co-authored-by: multica-agent <github@multica.ai>
221 lines
10 KiB
Python
221 lines
10 KiB
Python
"""Skill management surface (HEL-613): create/edit/enable/assign/delete user
|
|
skills, version snapshots with restore, and the effective-skill rule —
|
|
a named-agent session sees exactly: globally enabled ∩ enabled binding for
|
|
its agent_key. Plain sessions keep the legacy behavior (global enable only)."""
|
|
from __future__ import annotations
|
|
|
|
from calliope.agent.skills_store import (
|
|
effective_skill_dirs,
|
|
skills_prompt_lines,
|
|
)
|
|
|
|
|
|
def _mk(client, name: str, bindings: dict | None = None, body: str = "正文 v1") -> dict:
|
|
payload = {
|
|
"description": f"{name} 描述",
|
|
"skill_type": "template",
|
|
"content": body,
|
|
"tags": ["a", "b"],
|
|
}
|
|
if bindings is not None:
|
|
payload["bindings"] = bindings
|
|
r = client.put(f"/api/agent/skills/{name}", json=payload)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# Create / list
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_create_skill_and_admin_list(client):
|
|
_mk(client, "my-style-kit", bindings={"style": True, "script": False})
|
|
skills = client.get("/api/agent/skills?all=true").json()
|
|
mine = next(s for s in skills if s["dir"] == "my-style-kit")
|
|
assert mine["name"] == "my-style-kit"
|
|
assert mine["is_enabled"] is True
|
|
assert mine["bindings"] == {"style": True, "script": False}
|
|
assert mine["assigned_count"] == 1
|
|
assert mine["type"] == "template"
|
|
assert mine["version"] == "1.0.0"
|
|
# Non-admin list stays capped + without admin fields (composer contract).
|
|
plain = client.get("/api/agent/skills").json()
|
|
mine_plain = next(s for s in plain if s["dir"] == "my-style-kit")
|
|
assert "bindings" not in mine_plain
|
|
|
|
|
|
def test_migration_seeded_builtin_bindings_for_all_agents(client):
|
|
"""At upgrade, every pre-existing skill (the builtins) is bound to all
|
|
five agents so agent chats keep the out-of-the-box experience."""
|
|
skills = client.get("/api/agent/skills?all=true").json()
|
|
builtin = next(s for s in skills if s["dir"] == "h3-video-prompt-enhancer")
|
|
assert set(builtin["bindings"].keys()) == {
|
|
"script",
|
|
"style",
|
|
"storyboard",
|
|
"assets",
|
|
"shot",
|
|
}
|
|
assert builtin["assigned_count"] == 5
|
|
|
|
|
|
def test_invalid_names_rejected(client):
|
|
for bad in ("../escape", "a/b", "", " ", ".hidden", "x" * 65):
|
|
r = client.put(f"/api/agent/skills/{bad}", json={"content": "x"})
|
|
assert r.status_code in (404, 422), bad
|
|
|
|
|
|
def test_invalid_type_rejected(client):
|
|
r = client.put("/api/agent/skills/ok-name", json={"content": "x", "skill_type": "wild"})
|
|
assert r.status_code == 422
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# Effective rule: global enable ∩ per-agent binding
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_effective_rule_for_agent_sessions(client):
|
|
_mk(client, "gated-skill", bindings={"style": True, "script": True})
|
|
assert "gated-skill" in effective_skill_dirs("style")
|
|
assert "gated-skill" in effective_skill_dirs("script")
|
|
assert "gated-skill" not in effective_skill_dirs("assets")
|
|
lines = skills_prompt_lines(agent_key="assets")
|
|
assert not any("gated-skill" in ln for ln in lines)
|
|
lines = skills_prompt_lines(agent_key="style")
|
|
assert any("gated-skill" in ln for ln in lines)
|
|
|
|
|
|
def test_unassigned_skill_invisible_everywhere(client):
|
|
_mk(client, "orphan-skill", bindings={})
|
|
for key in ("script", "style", "storyboard", "assets", "shot"):
|
|
assert "orphan-skill" not in effective_skill_dirs(key)
|
|
# Plain sessions still see it (legacy behavior).
|
|
assert "orphan-skill" in effective_skill_dirs(None)
|
|
|
|
|
|
def test_global_disable_hides_from_all_sessions(client):
|
|
_mk(client, "kill-switch", bindings={"style": True})
|
|
r = client.patch("/api/agent/skills/kill-switch", json={"is_enabled": False})
|
|
assert r.status_code == 200
|
|
assert "kill-switch" not in effective_skill_dirs("style")
|
|
assert "kill-switch" not in effective_skill_dirs(None)
|
|
r = client.patch("/api/agent/skills/kill-switch", json={"is_enabled": True})
|
|
assert "kill-switch" in effective_skill_dirs("style")
|
|
|
|
|
|
def test_binding_matrix_replace(client):
|
|
_mk(client, "matrix-skill", bindings={"style": True})
|
|
r = client.put(
|
|
"/api/agent/skills/matrix-skill/bindings",
|
|
json={"bindings": {"shot": True, "style": False}},
|
|
)
|
|
assert r.status_code == 200
|
|
assert "matrix-skill" in effective_skill_dirs("shot")
|
|
assert "matrix-skill" not in effective_skill_dirs("style")
|
|
|
|
|
|
def test_unknown_agent_key_in_bindings_rejected(client):
|
|
r = client.put(
|
|
"/api/agent/skills/some-skill/bindings",
|
|
json={"bindings": {"director": True}},
|
|
)
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_read_skill_tool_gates_by_assignment(client):
|
|
"""The read_skill tool denies skills not effective for the agent."""
|
|
from calliope.agent.harness.plugins.skills import t_read_skill
|
|
from calliope.agent.harness.registry import ToolContext
|
|
|
|
_mk(client, "gated-read", bindings={"style": True})
|
|
style_ctx = ToolContext(session_id=1, agent_key="style")
|
|
shot_ctx = ToolContext(session_id=2, agent_key="shot")
|
|
ok = asyncio_run(t_read_skill(style_ctx, {"name": "gated-read"}))
|
|
assert ok["ok"] is True
|
|
denied = asyncio_run(t_read_skill(shot_ctx, {"name": "gated-read"}))
|
|
assert denied["ok"] is False
|
|
assert "not assigned" in denied["error"]
|
|
|
|
|
|
def asyncio_run(coro):
|
|
import asyncio
|
|
|
|
return asyncio.run(coro)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# Versions: snapshot on save, list, restore
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_edit_creates_snapshot_and_version_bumps(client):
|
|
first = _mk(client, "versioned", bindings={"script": True}, body="# v1 正文")
|
|
v1 = first["skill"]["version"]
|
|
second = _mk(client, "versioned", bindings={"script": True}, body="# v2 正文改了")
|
|
v2 = second["skill"]["version"]
|
|
assert v2 != v1 # auto-bumped when the editor leaves version empty
|
|
versions = client.get("/api/agent/skills/versioned/versions").json()
|
|
assert len(versions) == 1 # the pre-edit snapshot
|
|
assert "# v1 正文" in versions[0]["preview"]
|
|
# File now holds v2 content.
|
|
body = client.get(
|
|
"/api/agent/skills/versioned/file?path=SKILL.md"
|
|
).json()["content"]
|
|
assert "# v2 正文改了" in body
|
|
|
|
|
|
def test_restore_writes_snapshot_back(client):
|
|
_mk(client, "rollback", bindings={"script": True}, body="第一版")
|
|
_mk(client, "rollback", bindings={"script": True}, body="第二版")
|
|
versions = client.get("/api/agent/skills/rollback/versions").json()
|
|
old_id = versions[0]["id"] # newest first: snapshot of 第一版
|
|
r = client.post(f"/api/agent/skills/rollback/versions/{old_id}/restore")
|
|
assert r.status_code == 200
|
|
body = client.get("/api/agent/skills/rollback/file?path=SKILL.md").json()["content"]
|
|
assert "第一版" in body
|
|
# The restore itself snapshotted 第二版 — undoable.
|
|
versions = client.get("/api/agent/skills/rollback/versions").json()
|
|
assert any("第二版" in v["preview"] for v in versions)
|
|
|
|
|
|
def test_restore_unknown_version_404(client):
|
|
_mk(client, "nope-skill", bindings={"script": True})
|
|
r = client.post("/api/agent/skills/nope-skill/versions/99999/restore")
|
|
assert r.status_code == 404
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# Delete
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_delete_removes_everything(client):
|
|
_mk(client, "doomed", bindings={"script": True}, body="拜拜")
|
|
r = client.delete("/api/agent/skills/doomed")
|
|
assert r.status_code == 200
|
|
skills = client.get("/api/agent/skills?all=true").json()
|
|
assert all(s["dir"] != "doomed" for s in skills)
|
|
assert client.get("/api/agent/skills/doomed/versions").json() == []
|
|
assert "doomed" not in effective_skill_dirs("script")
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# Agents endpoint skill counts
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_agents_endpoint_counts_effective_skills(client):
|
|
_mk(client, "count-me", bindings={"style": True, "shot": True})
|
|
_mk(client, "count-me-disabled", bindings={"style": True})
|
|
client.patch("/api/agent/skills/count-me-disabled", json={"is_enabled": False})
|
|
agents = {a["key"]: a for a in client.get("/api/agent/agents").json()}
|
|
style_count = agents["style"]["skill_count"]
|
|
shot_count = agents["shot"]["skill_count"]
|
|
assert style_count >= 1 and shot_count >= 1
|
|
# A globally disabled skill never counts.
|
|
skills = client.get("/api/agent/skills?all=true").json()
|
|
disabled_dirs = {s["dir"] for s in skills if not s["is_enabled"]}
|
|
assert "count-me-disabled" in disabled_dirs
|