能力: 只读工具server、会话复用与依赖清单(阶段D)

This commit is contained in:
zizi 2026-08-23 01:10:06 +08:00
parent 5d4abe7be9
commit 860771a28e
10 changed files with 2694 additions and 1822 deletions

View File

@ -13,20 +13,25 @@ disable-model-invocation: true
```
.venv/bin/python .agent/skills/dispatch-agent-task/scripts/dispatch_agent_task.py \
--spec task.json --provider P --model M [--thinking low] \
[--repo-root .] [--run-id ID] [--run-dir DIR] [--trigger-source user]
[--repo-root .] [--run-id ID] [--run-dir DIR] [--trigger-source user] \
[--session-id ID --session-dir DIR] [--enable-read-tools]
```
| 模块 | 职责 |
|---|---|
| `scripts/agent_task.py` | 可移植任务包合同:spec 加载校验、角色身份+中央角色合同+Schema 装配、结构化输出校验。 |
| `scripts/pi_runner.py` | pi 框架适配器:argv 构造(`--system-prompt` 注入、`--tools` 白名单、`--no-context-files/--no-skills/--no-extensions` 隔离)、JSON 事件流消费、看门狗超时。 |
| `scripts/dispatch_agent_task.py` | CLI:运行登记 -> 事件入账 -> 框架执行 -> 校验 -> 证据落库 -> 回执。 |
| `scripts/pi_runner.py` | pi 框架适配器:argv 构造(`--system-prompt` 注入、`--tools` 白名单、`--no-context-files/--no-skills/--no-extensions` 隔离、会话复用 `--session-id/--session-dir`)、JSON 事件流消费、看门狗超时。 |
| `scripts/read_tools.py` | 探索工具 server 只读实现:五个登记工具(细纲/文风/范式绑定/章节正文/实体检索),只读连接、未知工具拒绝、结果有界;`--list` 输出登记表。 |
| `scripts/muse_read_tools_extension.ts` | 工具 server 的框架扩展:装载时从 `read_tools.py --list` 动态注册工具,每次执行转给只读实现;不经派发器注入环境变量时不注册任何工具。 |
| `scripts/dispatch_agent_task.py` | CLI:运行登记 -> 事件入账 -> 框架执行 -> 校验 -> 证据落库 -> 依赖清单 -> 回执。 |
## 输入与输出
- 输入:`AgentTaskSpec` JSON(specVersion=agent-task-v1;role 限五个角色,角色合同来自 `.agent/docs/architecture/角色合同.md`;outputSchema 必须是合法 Draft 2020-12;inputSha256 可选校验)。`provider`、`model`、`thinking` 属于执行策略,其中 `provider` 和 `model` 必须由调用方显式传入并如实记账。
- 输出:回执 JSON(runId、框架、请求/实际模型、逐回合用量与成本、哈希链、证据 ID)与退出码;结构化输出经 `muse_llm.extract_json` + 完整 Draft 2020-12 校验,失败关闭(退出码 4)。
- 事件协议(九类闭集,逐条追加 `example_agent_event`):run.started / agent.started / model.completed / tool.started / tool.completed / agent.completed / agent.failed / run.completed / run.failed。
- 会话复用:`--session-id` + `--session-dir` 继续/创建指定框架会话(同一章补证/修改复用);缺省为一次性会话。会话文件由框架写入指定目录,随运行目录同权限保护。
- 探索工具:`--enable-read-tools` 启用只读工具 server(扩展经 `-e` 显式加载,自动发现仍被 `--no-extensions` 禁用);任务包含登记表工具名但未启用时失败关闭(READ_TOOLS_NOT_ENABLED)。工具调用参数进事件账本,成功后产 `dependencies.json`(依赖清单:本次运行实际读了什么),回执含 `dependencies` 摘要。
- 退出码:0 成功;2 spec 非法;3 框架失败(超时/非零退出/流不可解析/无模型回合);4 输出不合 Schema;5 证据落库失败。
## 红线
@ -35,6 +40,7 @@ disable-model-invocation: true
- 只有 `pi_runner.py` 可以直接调用框架二进制;其余任何位置 shell 调模型 CLI 都被架构门禁阻断。
- 不读取本机模型客户端配置文件;框架凭据走框架自身环境变量,本 Skill 不经手。
- 角色文件只提供身份提示;中央角色合同是稳定边界唯一事实源。系统提示词由适配器按固定顺序装配,不裁剪角色合同;工具白名单外的能力不开放(空名单 = `--no-tools`)。
- 工具 server 只提供登记过的只读工具:不提供裸查询、不写库;登记表(`read_tools.py` TOOL_REGISTRY)是唯一事实源,扩展不自带工具清单。
- 失败一律关闭:框架异常、Schema 不符、证据落库失败都终止运行并记 run.failed,不部分成功。
## 数据边界
@ -42,7 +48,7 @@ disable-model-invocation: true
- `example_agent_event`(DDL-113,append-only):归一事件账本,只存身份、用量、成本与安全摘要。
- `example_llm_call`:每个模型回合一条投影(无额度窗时 `window_key=NULL`,以 `run_id` 归属本次派发,raw 指针指向全量转录)。
- raw 表:system prompt(prompt)、最终输出(response)、框架全量转录(supplier)经 `record-run-evidence/agent_trace.persist_agent_evidence` 单事务原子落库,写前密钥拦截。
- `example_run`:start_run/finish_run 登记终态;运行目录(/tmp/muse-agent-runs/<run_id>)保留 task-spec、system-prompt、user-message、transcript、output、receipt 审计件。
- `example_run`:start_run/finish_run 登记终态;运行目录(/tmp/muse-agent-runs/<run_id>)保留 task-spec、system-prompt、user-message、transcript、output、dependencies(如有)、receipt 审计件。
- 留痕是旁路义务:派发路径不提供「不留痕」选项,业务调用方不能决定是否记录。
## 复利合同

View File

@ -39,6 +39,7 @@ from agent_task import ( # noqa: E402
)
from agent_trace import AgentTraceWriter, persist_agent_evidence # noqa: E402
from persist_raw import _check_no_secrets # noqa: E402
import read_tools
from pi_runner import ( # noqa: E402
DEFAULT_PI_BIN,
AgentStreamOutcome,
@ -129,6 +130,11 @@ def _write_private_json(path: Path, value: Mapping[str, Any]) -> None:
_write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2))
# 工具 server 扩展路径与内建只读文件工具(探索白名单的两类合法成员)。
READ_TOOL_EXTENSION = Path(__file__).resolve().parent / "muse_read_tools_extension.ts"
BUILTIN_TOOL_NAMES = frozenset({"read", "grep", "find", "ls"})
def run_dispatch(
spec_path: str | Path,
*,
@ -140,6 +146,9 @@ def run_dispatch(
launcher: Callable[..., Iterable] | None = None,
trigger_source: str = "user",
trigger_detail: Mapping[str, Any] | None = None,
session_id: str | None = None,
session_dir: str | Path | None = None,
enable_read_tools: bool = False,
) -> tuple[dict[str, Any], int]:
"""执行一次完整派发;所有可控失败都返回稳定回执与退出码。"""
@ -170,7 +179,34 @@ def run_dispatch(
EXIT_SPEC_INVALID,
)
effective_policy = replace(policy, cwd=policy.cwd or str(repo_root_path))
disabled_read_tools = sorted(
{
tool
for tool in package.spec.tool_allowlist
if tool in read_tools.TOOL_REGISTRY and not enable_read_tools
}
)
if disabled_read_tools:
return (
{
"status": "failed",
"errorCode": "READ_TOOLS_NOT_ENABLED",
"error": f"任务包含探索工具但未启用工具 server: {disabled_read_tools}",
},
EXIT_SPEC_INVALID,
)
policy_kwargs: dict[str, Any] = {"cwd": policy.cwd or str(repo_root_path)}
if session_id is not None:
policy_kwargs["session_id"] = session_id
policy_kwargs["session_dir"] = str(session_dir) if session_dir is not None else None
if enable_read_tools:
# 工具 server 扩展经环境变量定位只读实现;框架进程继承该环境。
os.environ["MUSE_READ_TOOLS_PYTHON"] = sys.executable
os.environ["MUSE_READ_TOOLS_SCRIPT"] = str(
Path(__file__).resolve().parent / "read_tools.py"
)
policy_kwargs["extension_path"] = str(READ_TOOL_EXTENSION)
effective_policy = replace(policy, **policy_kwargs)
if (
package.role_contract.model_policy == "fixed-opus"
and "opus" not in effective_policy.model.lower()
@ -546,6 +582,29 @@ def run_dispatch(
evidence=evidence,
)
# 依赖清单:本次运行实际读取了什么(工具调用序列与参数),链路透视的读侧材料。
dependency_count = len(outcome.tool_calls)
if dependency_count:
dependencies = [
{
"seq": index + 1,
"tool": call.name,
"args": dict(call.args) if call.args else {},
"isError": call.is_error,
}
for index, call in enumerate(outcome.tool_calls)
]
try:
_write_private_json(run_dir_path / "dependencies.json", dependencies)
except OSError as exc:
return _failed(
"AUDIT_WRITE_FAILED",
f"依赖清单写入失败: {type(exc).__name__}",
EXIT_EVIDENCE_FAILED,
session_id=outcome.session_id,
evidence=evidence,
)
usage = _usage_totals(outcome)
total_cost, cost_complete = _cost_totals(outcome)
try:
@ -586,6 +645,11 @@ def run_dispatch(
durationMs=outcome.duration_ms,
turns=outcome.turns,
toolCallCount=len(outcome.tool_calls),
dependencies=(
{"count": dependency_count, "file": "dependencies.json"}
if dependency_count
else None
),
modelCallCount=len(outcome.model_calls),
actualModelIds=[call.actual_model_id for call in outcome.model_calls],
usage=usage,
@ -621,6 +685,9 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--run-id", default=None, help="指定 run_id(默认自动生成)")
parser.add_argument("--run-dir", default=None, help="运行目录(默认 /tmp/muse-agent-runs/<run_id>)")
parser.add_argument("--trigger-source", default="user", choices=["user", "replay_eval", "diagnostic"])
parser.add_argument("--session-id", default=None, help="框架会话 ID(复用=继续原会话,缺省=一次性会话)")
parser.add_argument("--session-dir", default=None, help="框架会话目录(与 --session-id 同给)")
parser.add_argument("--enable-read-tools", action="store_true", help="启用只读探索工具 server")
args = parser.parse_args(argv)
policy = ExecutionPolicy(
@ -633,6 +700,9 @@ def main(argv: list[str] | None = None) -> int:
run_id=args.run_id,
run_dir=args.run_dir,
trigger_source=args.trigger_source,
session_id=args.session_id,
session_dir=args.session_dir,
enable_read_tools=args.enable_read_tools,
)
print(json.dumps(receipt, ensure_ascii=False, indent=2))
return code

View File

@ -0,0 +1,91 @@
// 探索工具 server 的框架扩展(阶段 D)。
//
// 边界合同中的工具层:给创作智能体暴露只读探索工具。本扩展不自带任何查询逻辑,
// 工具登记表与实现都在 read_tools.py(Python,只读连接、未知工具拒绝、结果有界)。
// 加载时执行 `read_tools.py --list` 取登记表,按登记表动态注册工具,保证单一事实源;
// 每次工具执行把参数原样转给 `read_tools.py <工具名> '<json>'`,stdout 即结果。
//
// 派发器经环境变量定位实现:MUSE_READ_TOOLS_PYTHON(解释器)、MUSE_READ_TOOLS_SCRIPT(脚本)。
// 未经派发器注入这两个变量时不注册任何工具(静默跳过),隔离会话不受影响。
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
interface ToolSpec {
description: string;
tables: string[];
args: Record<string, "int" | "str">;
}
function runReadTools(python: string, script: string, argv: string[]): string {
const bun = (globalThis as { Bun?: any }).Bun;
if (bun?.spawnSync) {
const res = bun.spawnSync([python, script, ...argv], {
stdout: "pipe",
stderr: "pipe",
});
const stdout = new TextDecoder().decode(res.stdout);
if (res.exitCode !== 0) {
const stderr = new TextDecoder().decode(res.stderr).trim();
throw new Error(stderr || `read_tools 退出码 ${res.exitCode}`);
}
return stdout;
}
// Node 运行时的回退路径。
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { execFileSync } = require("node:child_process");
return execFileSync(python, [script, ...argv], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
});
}
export default function (pi: ExtensionAPI) {
const python = process.env.MUSE_READ_TOOLS_PYTHON;
const script = process.env.MUSE_READ_TOOLS_SCRIPT;
if (!python || !script) {
return; // 非工具 server 派发:不注册任何工具。
}
let registry: Record<string, ToolSpec> = {};
try {
registry = JSON.parse(runReadTools(python, script, ["--list"]));
} catch {
return; // 登记表不可读时保持无工具,不放行裸查询。
}
for (const [name, spec] of Object.entries(registry)) {
const props: Record<string, any> = {};
for (const [rawName, argType] of Object.entries(spec.args ?? {})) {
const optional = rawName.endsWith("?");
const argName = optional ? rawName.slice(0, -1) : rawName;
const base =
argType === "int"
? Type.Integer({ description: argName })
: Type.String({ description: argName });
props[argName] = optional ? Type.Optional(base) : base;
}
pi.registerTool({
name,
label: name,
description: `${spec.description}(只读;读取 ${spec.tables.join("、")})`,
promptGuidelines: [
`Use ${name} only for read-only exploration; it never modifies data.`,
],
parameters: Type.Object(props),
async execute(_toolCallId, params) {
try {
const stdout = runReadTools(python, script, [
name,
JSON.stringify(params ?? {}),
]);
return { content: [{ type: "text", text: stdout }] };
} catch (error) {
// 抛出错误使框架把该工具调用标记为失败,模型可据此调整探索。
throw new Error(
error instanceof Error ? error.message : String(error)
);
}
},
});
}
}

View File

@ -53,6 +53,11 @@ class ExecutionPolicy:
thinking: str | None = None
pi_bin: str = DEFAULT_PI_BIN
cwd: str | None = None
# 会话复用:同一章补证/修改继续原会话;两者同给或同缺。
session_id: str | None = None
session_dir: str | None = None
# 显式加载的框架扩展(工具 server);--no-extensions 只禁自动发现,-e 照常加载。
extension_path: str | None = None
def __post_init__(self) -> None:
if not isinstance(self.provider, str) or not self.provider.strip():
@ -63,6 +68,8 @@ class ExecutionPolicy:
"off", "minimal", "low", "medium", "high", "xhigh", "max"
}:
raise ValueError("thinking 不受支持")
if bool(self.session_id) != bool(self.session_dir):
raise ValueError("session_id 与 session_dir 必须同时给出")
@property
def framework(self) -> str:
@ -91,6 +98,7 @@ class ToolCallRecord:
tool_call_id: str
name: str
is_error: bool = False
args: Mapping[str, Any] | None = None
@dataclass
@ -111,12 +119,19 @@ class AgentStreamOutcome:
def build_pi_argv(package: TaskPackage, policy: ExecutionPolicy) -> list[str]:
"""构造 pi 子代理 argv:system prompt 注入、工具白名单、上下文隔离。"""
argv = [policy.pi_bin, "--print", "--mode", "json", "--no-session"]
argv = [policy.pi_bin, "--print", "--mode", "json"]
if policy.session_id:
argv += ["--session-id", policy.session_id, "--session-dir", policy.session_dir or ""]
else:
argv.append("--no-session")
argv += ["--provider", policy.provider, "--model", policy.model]
if policy.thinking:
argv += ["--thinking", policy.thinking]
# 上下文隔离:不加载项目 AGENTS.md/skills/extensions,角色合同全部来自任务包。
# --no-extensions 只禁自动发现,-e 显式传入的工具 server 扩展不受影响。
argv += ["--no-context-files", "--no-skills", "--no-extensions", "--no-approve"]
if policy.extension_path:
argv += ["-e", policy.extension_path]
allowlist = package.spec.tool_allowlist
if allowlist:
argv += ["--tools", ",".join(allowlist)]
@ -386,12 +401,22 @@ class PiAgentRunner:
raise FrameworkError("TOOL_EVENT_INVALID", "工具开始事件缺 toolCallId")
if name not in allowed_tools:
raise FrameworkError("TOOL_NOT_ALLOWED", f"框架执行了未授权工具: {name or '<empty>'}")
outcome.tool_calls.append(ToolCallRecord(tool_call_id=tool_call_id, name=name))
raw_args = event.get("args")
args = raw_args if isinstance(raw_args, Mapping) else None
outcome.tool_calls.append(
ToolCallRecord(tool_call_id=tool_call_id, name=name, args=args)
)
# 依赖清单材料:工具读取参数进事件账本(截断,避免撑爆事件行)。
args_summary: str | None = None
if args is not None:
args_summary = json.dumps(dict(args), ensure_ascii=False, sort_keys=True)
if len(args_summary) > 512:
args_summary = args_summary[:512] + "…"
sink.emit(
"tool.started",
status="ok",
tool_name=name,
details={"toolCallId": tool_call_id},
details={"toolCallId": tool_call_id, "args": args_summary},
)
elif kind == "tool_execution_end":
is_error = bool(event.get("isError"))

View File

@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""探索工具 server 的只读取数实现(阶段 D)。
边界合同中的工具 server 组件:给创作智能体提供只读取数能力的统一入口。
每个工具声明读什么表、什么版本语义、返回什么结构;不提供裸查询,不写库。
连接固定 `muse_db.connect(readonly=True)`(会话级只读,写语句被库直接拒)。
查询语义与 assemble-context 的三个一等取数端保持一致(按边界合同工具 server
自持实现,不跨 Skill 导入):已确认细纲、已确认文风投影、已确认范式绑定;
另加正文章节读取与实体检索两个探索工具。
CLI:`read_tools.py <工具名> '<json 参数>'`,JSON 结果输出到 stdout;
未知工具或参数非法退出码 2(合同拒绝),运行时错误退出码 1。
`--list` 输出工具登记表(名称、描述、读取表),供扩展与测试对账。
"""
from __future__ import annotations
import json
import sys
from typing import Any, Callable, Mapping
import muse_db
# 返回规模上限:防御性裁剪,避免单次工具返回撑爆模型上下文。
MAX_TEXT_CHARS = 30000
MAX_ROWS = 50
MAX_DESC_CHARS = 500
def _payload(value: Any) -> Any:
"""规划行 payload 统一解成对象(库里可能是 jsonb 或字符串)。"""
if isinstance(value, str):
return json.loads(value)
return value
def _style_constraints(style_payload: Any) -> list[str]:
"""把文风投影成约束字符串列表(与 assemble-context 口径一致)。"""
if isinstance(style_payload, str):
text = style_payload.strip()
return [text] if text else []
if isinstance(style_payload, dict):
rules = []
for aspect, value in style_payload.items():
text = str(value).strip()
if text:
rules.append(f"{aspect}:{text}")
return rules
return []
def _require_int(args: Mapping[str, Any], name: str) -> int:
value = args.get(name)
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"参数 {name} 必须是整数")
return value
def read_fine_outline(conn, args: Mapping[str, Any]) -> dict[str, Any]:
"""读指定章最新一条已确认细纲(state=confirmed,version 倒序)。"""
work_id = _require_int(args, "work_id")
target_chapter = _require_int(args, "target_chapter")
row = conn.execute(
"SELECT payload, version FROM example_planning_section WHERE work_id=%s AND "
"section_type='fine_outline' AND target_chapter=%s AND state='confirmed' AND "
"deleted=false ORDER BY version DESC LIMIT 1",
(work_id, target_chapter),
).fetchone()
if not row:
return {"found": False, "reason": f"无第{target_chapter}章已确认细纲"}
return {"found": True, "version": row[1], "payload": _payload(row[0])}
def read_style_constraints(conn, args: Mapping[str, Any]) -> dict[str, Any]:
"""读已确认文风并投影为约束;优先独立 style 行,回退设定行 style 字段。"""
work_id = _require_int(args, "work_id")
row = conn.execute(
"SELECT payload FROM example_planning_section WHERE work_id=%s AND "
"schema_type='style' AND state='confirmed' AND deleted=false "
"ORDER BY version DESC LIMIT 1",
(work_id,),
).fetchone()
if row:
return {"constraints": _style_constraints(_payload(row[0]))}
setting = conn.execute(
"SELECT payload FROM example_planning_section WHERE work_id=%s AND "
"section_type='setting' AND state='confirmed' AND deleted=false "
"ORDER BY version DESC LIMIT 1",
(work_id,),
).fetchone()
if setting:
payload = _payload(setting[0])
if isinstance(payload, dict):
return {"constraints": _style_constraints(payload.get("style"))}
return {"constraints": []}
def read_pattern_bindings(conn, args: Mapping[str, Any]) -> dict[str, Any]:
"""读最新一条已确认 assembly 规划行的 patternReferences;无绑定诚实返空。"""
work_id = _require_int(args, "work_id")
row = conn.execute(
"SELECT payload FROM example_planning_section WHERE work_id=%s AND "
"section_type='assembly' AND state='confirmed' AND deleted=false "
"ORDER BY version DESC LIMIT 1",
(work_id,),
).fetchone()
if not row:
return {"bindings": []}
payload = _payload(row[0])
if not isinstance(payload, dict):
return {"bindings": []}
references = payload.get("patternReferences", [])
return {"bindings": [dict(item) for item in references if isinstance(item, dict)]}
def read_chapter_text(conn, args: Mapping[str, Any]) -> dict[str, Any]:
"""读指定章正式正文(章节行 + 正文块按序拼接);超长截断并标记。"""
work_id = _require_int(args, "work_id")
chapter_order = _require_int(args, "chapter_order")
chapter = conn.execute(
"SELECT c.id, c.order_no, c.title FROM muse_content_chapter c "
"WHERE c.work_id=%s AND c.order_no=%s AND c.deleted=false LIMIT 1",
(work_id, chapter_order),
).fetchone()
if not chapter:
return {"found": False, "reason": f"无第{chapter_order}章(work={work_id})"}
blocks = conn.execute(
"SELECT b.content_text FROM muse_content_block b "
"WHERE b.chapter_id=%s AND b.deleted=false ORDER BY b.order_no",
(chapter[0],),
).fetchall()
text = "\n".join(str(block[0] or "") for block in blocks)
truncated = len(text) > MAX_TEXT_CHARS
return {
"found": True,
"chapterOrder": chapter[1],
"title": chapter[2],
"text": text[:MAX_TEXT_CHARS],
"truncated": truncated,
"totalChars": len(text),
}
def search_entities(conn, args: Mapping[str, Any]) -> dict[str, Any]:
"""按名称模糊检索作品实体(人物、势力、地点、力量体系等);结果有界。"""
work_id = _require_int(args, "work_id")
keyword = str(args.get("keyword") or "").strip()
if not keyword:
raise ValueError("参数 keyword 不能为空")
entity_type = args.get("entity_type")
if entity_type is not None and not isinstance(entity_type, str):
raise ValueError("参数 entity_type 必须是字符串")
query = (
"SELECT entity_type, normalized_name, description, status "
"FROM muse_knowledge_entity WHERE work_id=%s AND deleted=false "
"AND normalized_name ILIKE %s"
)
params: list[Any] = [work_id, f"%{keyword}%"]
if entity_type:
query += " AND entity_type=%s"
params.append(entity_type)
query += " ORDER BY normalized_name LIMIT %s"
params.append(MAX_ROWS)
rows = conn.execute(query, tuple(params)).fetchall()
return {
"entities": [
{
"entityType": row[0],
"name": row[1],
"description": str(row[2] or "")[:MAX_DESC_CHARS],
"status": row[3],
}
for row in rows
]
}
# 工具登记表:名称 -> (描述、读取表、参数、实现)。扩展与测试以此对账,保证单一事实源。
# args 是参数说明:{参数名: 类型},类型取 int / str;可选参数名后加 ?。
TOOL_REGISTRY: dict[str, dict[str, Any]] = {
"read_fine_outline": {
"description": "读指定作品指定章的最新一条已确认细纲(结构骨架与硬约束)。",
"tables": ("example_planning_section",),
"args": {"work_id": "int", "target_chapter": "int"},
"impl": read_fine_outline,
},
"read_style_constraints": {
"description": "读作品已确认文风并投影为约束列表。",
"tables": ("example_planning_section",),
"args": {"work_id": "int"},
"impl": read_style_constraints,
},
"read_pattern_bindings": {
"description": "读作品已确认的范式绑定(规划期选定,写作期只消费)。",
"tables": ("example_planning_section",),
"args": {"work_id": "int"},
"impl": read_pattern_bindings,
},
"read_chapter_text": {
"description": "读指定章的正式正文全文(超长截断并标记)。",
"tables": ("muse_content_chapter", "muse_content_block"),
"args": {"work_id": "int", "chapter_order": "int"},
"impl": read_chapter_text,
},
"search_entities": {
"description": "按名称模糊检索作品实体(人物、势力、地点、力量体系等)。",
"tables": ("muse_knowledge_entity",),
"args": {"work_id": "int", "keyword": "str", "entity_type?": "str"},
"impl": search_entities,
},
}
def execute_tool(name: str, args: Mapping[str, Any], connect: Callable[..., Any] | None = None) -> dict[str, Any]:
"""执行一个登记工具;只读连接,未知工具拒绝。"""
entry = TOOL_REGISTRY.get(name)
if entry is None:
raise KeyError(f"未知探索工具: {name}")
factory = connect or (lambda: muse_db.connect(readonly=True))
conn = factory()
try:
return entry["impl"](conn, args)
finally:
closer = getattr(conn, "close", None)
if callable(closer):
closer()
def main(argv: list[str]) -> int:
if argv and argv[0] == "--list":
listing = {
name: {
"description": entry["description"],
"tables": list(entry["tables"]),
"args": entry.get("args", {}),
}
for name, entry in TOOL_REGISTRY.items()
}
print(json.dumps(listing, ensure_ascii=False, indent=1))
return 0
if len(argv) != 2:
print("用法: read_tools.py <工具名> '<json 参数>' | --list", file=sys.stderr)
return 2
name, raw_args = argv
if name not in TOOL_REGISTRY:
print(f"未知探索工具: {name}", file=sys.stderr)
return 2
try:
args = json.loads(raw_args)
except json.JSONDecodeError:
print("参数不是合法 JSON", file=sys.stderr)
return 2
if not isinstance(args, dict):
print("参数必须是 JSON 对象", file=sys.stderr)
return 2
try:
result = execute_tool(name, args)
except ValueError as exc:
print(f"参数合同拒绝: {exc}", file=sys.stderr)
return 2
except Exception as exc: # 运行时失败如实上报,不静默
print(f"工具执行失败: {type(exc).__name__}: {exc}", file=sys.stderr)
return 1
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
__all__ = ["TOOL_REGISTRY", "execute_tool", "main"]

View File

@ -147,7 +147,7 @@ A、B 可并行;C 依赖 A;E 依赖 D;F 依赖 E;G 依赖 D(链路透
### 阶段 B:链路盘点与清理
- 意图:盘点全部模型调用路径、59 个技能、一次性脚本与散落设计文档,每项定终态归属,删除无消费者的遗留。
- 意图:盘点全部模型调用路径、58 个技能、一次性脚本与散落设计文档,每项定终态归属,删除无消费者的遗留。
- 边界:4 条模型调用路径定终态--框架派发链为生产与评测主链;治理直调链为对照实验与内容模型任务;角色运行时链随直调链处置;批处理执行链定保留或退役。删除以零引用检索为依据。
- 验证:被删入口全仓零引用;全量离线清单无新增失败;单一提交可一次对比删除面。

View File

@ -0,0 +1,58 @@
# 阶段 D:能力建设(只读工具 server / 会话复用 / 依赖清单)
日期:2026-08-22
总 plan:[2026-08-22-agent-example整体收敛总plan.md](2026-08-22-agent-example整体收敛总plan.md)
状态:离线部分完成;真实派发验证待人工授权(一次带探索工具的显式模型调用)。
## 1. 意图
给框架派发链补齐三块底座:只读工具 server、会话复用、依赖清单。
## 2. 实现落点
### 2.1 只读工具 server(边界合同的工具层组件)
- `read_tools.py`:五个登记工具,单一事实源 `TOOL_REGISTRY`(名称、描述、读取表、参数说明、实现):
- `read_fine_outline`(已确认细纲)、`read_style_constraints`(已确认文风投影)、`read_pattern_bindings`(已确认范式绑定)——查询语义与 assemble-context 三个一等取数端一致(工具 server 按边界合同自持实现,不跨 Skill 导入)。
- `read_chapter_text`(章节正式正文,30000 字截断标记)、`search_entities`(实体模糊检索,50 行上限)。
- 只读连接 `muse_db.connect(readonly=True)`;未知工具/非法参数合同拒绝(退出码 2);`--list` 输出登记表。
- `muse_read_tools_extension.ts`:框架扩展,装载时从 `--list` 动态注册工具(不硬编码工具清单,防双源漂移);每次执行转给只读实现;派发器未注入环境变量时不注册任何工具。
- 派发器经 `-e` 显式加载该扩展;`--no-extensions` 只禁自动发现(框架资源加载器合同已核实),隔离与工具加载共存。
- 派发门禁:任务白名单含登记表工具名但未 `--enable-read-tools` 时,运行前失败关闭(READ_TOOLS_NOT_ENABLED)。
### 2.2 会话复用
- `ExecutionPolicy` 增加 `session_id` / `session_dir`(同给或同缺,否则构造拒绝);`build_pi_argv` 映射为框架原生 `--session-id` + `--session-dir`(不自造会话存储)。
- 派发语义:每次派发仍是一次独立运行记录;会话连续通过共享会话目录实现——同一章补证/修改传前次运行的会话 ID 与会话目录继续原会话;完全重写新建。
- CLI 新增 `--session-id` / `--session-dir` / `--enable-read-tools`。
### 2.3 依赖清单
- 框架工具事件携带参数(`tool_execution_start.args`);适配器捕获进 `ToolCallRecord.args`,截断后随 `tool.started` 事件入账本。
- 成功运行把工具调用序列写为运行目录 `dependencies.json`(seq/tool/args/isError);回执含 `dependencies` 摘要(count/file)。无工具调用不产文件。
- 不加新表:事件账本 + 运行目录审计件承载;创作台链路透视(阶段 G)读这两处。
## 3. 文件台账
| 处置 | 文件 | 原因 |
|---|---|---|
| 新增 | `scripts/read_tools.py` | 填补边界合同工具层空白:探索读取的唯一受控入口;登记表为单一事实源;入口为扩展装载与测试 |
| 新增 | `scripts/muse_read_tools_extension.ts` | 工具 server 的框架适配:只从登记表动态注册,无第二套工具清单;经派发器 `-e` 加载 |
| 修改 | `scripts/pi_runner.py` | 会话复用字段与 argv、扩展加载、工具参数捕获(依赖清单材料);职责仍是薄适配,无业务决策 |
| 修改 | `scripts/dispatch_agent_task.py` | 三个 CLI 开关、工具门禁、环境变量注入、依赖清单写入、回执字段 |
| 修改 | `SKILL.md`(dispatch-agent-task) | 登记新模块、命令参数、事件与依赖清单合同、工具 server 红线 |
| 新增 | `tests/skills/dispatch-agent-task/test_read_tools.py` | 工具合同离线测试:五工具行为、拒绝路径、登记表 CLI、扩展动态装载对账 |
| 修改 | `tests/skills/dispatch-agent-task/test_dispatch_agent_task.py` | 新增 5 项:会话成对校验与 argv、扩展标志、工具参数捕获、工具门禁失败关闭、依赖清单产出 |
| 修改 | `harness/manifests/test-inventory.json` | 登记新测试条目,摘要同步重算(110 条) |
| 保留 | `execute-role-task`、直调链 | 阶段 F 裁决对象,本阶段不动 |
| 遗留 | 真实派发验证(一次带工具的显式模型调用,查库核对事件/依赖清单) | 需人工授权;授权后执行并回填本文件验证节 |
## 4. 验证
- 工具离线测试:10 项通过(五工具行为、拒绝路径、登记表、扩展对账)。
- 派发测试:25 项通过(原 20 项 + 会话/工具/依赖清单 5 项)。
- 选择器定向:新增两条目通过。
- 全量离线清单:102 通过、8 项外部依赖阻断、0 失败(较阶段 C 基线 +1 条目)。
- 技能严格审计:58 个 Skill,阻断 0,质量发现 0;索引一致性通过。
- 架构门禁(导入边界 + 索引):通过。`git diff --check`:通过。
- 真实派发验证:待授权(见 §3 遗留)。

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,7 @@ from pi_runner import ( # noqa: E402
PiAgentRunner,
build_pi_argv,
)
from dispatch_agent_task import EXIT_OUTPUT_INVALID, run_dispatch # noqa: E402
from dispatch_agent_task import EXIT_OUTPUT_INVALID, EXIT_SPEC_INVALID, run_dispatch # noqa: E402
from agent_trace import AgentTraceWriter # noqa: E402
REPO_ROOT = PROJECT_ROOT
@ -548,5 +548,129 @@ class ValidateOutputTest(unittest.TestCase):
validate_structured_output('{"title":"t"}', spec)
class SessionAndReadToolsTest(unittest.TestCase):
"""阶段 D:会话复用、工具 server 开关与依赖清单。"""
def setUp(self):
self.tmp = pathlib.Path(tempfile.mkdtemp())
def test_session_requires_dir_and_maps_to_argv(self):
with self.assertRaises(ValueError):
ExecutionPolicy(provider="p", model="m", session_id="s1")
package = build_task_package(load_spec(make_spec(self.tmp)), REPO_ROOT)
argv = build_pi_argv(
package,
ExecutionPolicy(provider="p", model="m", session_id="s1", session_dir="/tmp/sd"),
)
self.assertIn("--session-id", argv)
self.assertEqual(argv[argv.index("--session-id") + 1], "s1")
self.assertEqual(argv[argv.index("--session-dir") + 1], "/tmp/sd")
self.assertNotIn("--no-session", argv)
def test_extension_path_maps_to_e_flag_with_isolation(self):
package = build_task_package(load_spec(make_spec(self.tmp)), REPO_ROOT)
argv = build_pi_argv(
package,
ExecutionPolicy(provider="p", model="m", extension_path="/x/ext.ts"),
)
self.assertIn("-e", argv)
self.assertEqual(argv[argv.index("-e") + 1], "/x/ext.ts")
# 自动发现仍被禁:只加载显式扩展。
self.assertIn("--no-extensions", argv)
def test_tool_args_captured_for_dependency_manifest(self):
sink_holder = {}
class RecordingSink(AgentTraceWriter):
def __init__(self):
super().__init__(
run_id="t", framework="pi", agent_role="planner",
connect=lambda: (_ for _ in ()).throw(AssertionError("离线测试不应触库")),
)
self.rows = []
def emit(self, event_type, **kwargs):
self.rows.append((event_type, kwargs))
return len(self.rows)
sink = RecordingSink()
sink_holder["sink"] = sink
lines = pi_stream_lines('{"title":"t","beats":["b"]}', with_tool=True)
# 给工具事件注入真实参数形状(依赖清单材料)。
patched = []
for raw in lines:
event = json.loads(raw)
if event.get("type") == "tool_execution_start":
event["args"] = {"work_id": 12, "target_chapter": 4}
patched.append(json.dumps(event, ensure_ascii=False).encode("utf-8") + b"\n")
package = build_task_package(
load_spec(make_spec(self.tmp, toolAllowlist=["read"])), REPO_ROOT
)
runner = PiAgentRunner(launcher=fake_launcher(patched))
outcome = runner.run(
package,
ExecutionPolicy(provider="p", model="m"),
sink,
timeout_seconds=30,
)
self.assertEqual(outcome.tool_calls[0].args, {"work_id": 12, "target_chapter": 4})
started = [kwargs for kind, kwargs in sink.rows if kind == "tool.started"]
self.assertIn("work_id", started[0]["details"]["args"])
def test_read_tools_disabled_fails_closed(self):
spec_path = make_spec(self.tmp, toolAllowlist=["read_fine_outline"])
receipt, code = run_dispatch(
spec_path,
repo_root=REPO_ROOT,
policy=ExecutionPolicy(provider="p", model="claude-opus-test"),
run_id="unittest-agent-dispatch-rt",
run_dir=self.tmp / "run",
connect_factory=RecordingConnect(),
launcher=fake_launcher(pi_stream_lines('{"title":"t","beats":["b"]}')),
trigger_source="diagnostic",
)
self.assertEqual(code, EXIT_SPEC_INVALID)
self.assertEqual(receipt["errorCode"], "READ_TOOLS_NOT_ENABLED")
def test_dependency_manifest_written_on_tool_use(self):
spec_path = make_spec(self.tmp, toolAllowlist=["read"])
run_dir = self.tmp / "run"
receipt, code = run_dispatch(
spec_path,
repo_root=REPO_ROOT,
policy=ExecutionPolicy(provider="p", model="claude-opus-test"),
run_id="unittest-agent-dispatch-dep",
run_dir=run_dir,
connect_factory=RecordingConnect(),
launcher=fake_launcher(pi_stream_lines('{"title":"t","beats":["b"]}', with_tool=True)),
trigger_source="diagnostic",
enable_read_tools=True,
)
self.assertEqual(code, 0, receipt)
dependencies = json.loads((run_dir / "dependencies.json").read_text(encoding="utf-8"))
self.assertEqual(len(dependencies), 1)
self.assertEqual(dependencies[0]["tool"], "read")
self.assertEqual(dependencies[0]["seq"], 1)
self.assertEqual(receipt["dependencies"], {"count": 1, "file": "dependencies.json"})
# 无工具调用的运行不产依赖清单文件。
tmp2 = pathlib.Path(tempfile.mkdtemp())
run_dir2 = tmp2 / "run"
receipt2, code2 = run_dispatch(
make_spec(tmp2),
repo_root=REPO_ROOT,
policy=ExecutionPolicy(provider="p", model="claude-opus-test"),
run_id="unittest-agent-dispatch-nodep",
run_dir=run_dir2,
connect_factory=RecordingConnect(),
launcher=fake_launcher(pi_stream_lines('{"title":"t","beats":["b"]}')),
trigger_source="diagnostic",
)
self.assertEqual(code2, 0, receipt2)
self.assertFalse((run_dir2 / "dependencies.json").exists())
self.assertIsNone(receipt2["dependencies"])
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""探索工具 server(read_tools)的离线测试:不连真实库。
固定工具合同:五个只读工具按登记表执行、未知工具与非法参数拒绝、
结果有界截断、登记表 CLI 输出结构稳定;并校验派发器的工具开关门禁
(探索工具未启用工具 server 时失败关闭)与依赖清单产出。
"""
from __future__ import annotations
import json
import pathlib
import subprocess
import sys
import tempfile
import unittest
PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3]
SKILL_DIR = PROJECT_ROOT / ".agent" / "skills" / "dispatch-agent-task" / "scripts"
if str(SKILL_DIR) not in sys.path:
sys.path.insert(0, str(SKILL_DIR))
import read_tools # noqa: E402
from read_tools import TOOL_REGISTRY, execute_tool # noqa: E402
READ_TOOLS_PY = SKILL_DIR / "read_tools.py"
PYTHON = sys.executable
class FakeCursor:
def __init__(self, rows):
self._rows = rows
def fetchone(self):
return self._rows[0] if self._rows else None
def fetchall(self):
return self._rows
class FakeConn:
"""记录每条 SQL 与参数、按构造行返回的假连接。"""
def __init__(self, rows):
self.rows = rows
self.queries = []
def execute(self, sql, params=None):
self.queries.append((sql, params))
return FakeCursor(self.rows)
def close(self):
pass
class ToolBehaviourTest(unittest.TestCase):
def test_read_fine_outline_hit_and_miss(self):
conn = FakeConn([({"chapterGoal": "g"}, 3)])
result = execute_tool(
"read_fine_outline", {"work_id": 12, "target_chapter": 4}, connect=lambda: conn
)
self.assertTrue(result["found"])
self.assertEqual(result["version"], 3)
self.assertEqual(result["payload"], {"chapterGoal": "g"})
sql, params = conn.queries[0]
self.assertIn("state='confirmed'", sql)
self.assertEqual(params, (12, 4))
conn = FakeConn([])
result = execute_tool(
"read_fine_outline", {"work_id": 12, "target_chapter": 9}, connect=lambda: conn
)
self.assertFalse(result["found"])
def test_read_style_constraints_dict_projection(self):
conn = FakeConn([({"句式": "短句", "用词质感": "冷"},)])
result = execute_tool("read_style_constraints", {"work_id": 12}, connect=lambda: conn)
self.assertEqual(result["constraints"], ["句式:短句", "用词质感:冷"])
def test_read_style_falls_back_to_setting_row(self):
class TwoStepConn:
def __init__(self):
self._step = 0
def execute(self, sql, params=None):
self._step += 1
rows = [] if self._step == 1 else [({"style": "一句话文风"},)]
return FakeCursor(rows)
def close(self):
pass
result = execute_tool("read_style_constraints", {"work_id": 12}, connect=TwoStepConn)
self.assertEqual(result["constraints"], ["一句话文风"])
def test_read_pattern_bindings(self):
conn = FakeConn([({"patternReferences": [{"id": 1}, {"x": 2}]},)])
result = execute_tool("read_pattern_bindings", {"work_id": 12}, connect=lambda: conn)
self.assertEqual(result["bindings"], [{"id": 1}, {"x": 2}])
conn = FakeConn([])
result = execute_tool("read_pattern_bindings", {"work_id": 12}, connect=lambda: conn)
self.assertEqual(result["bindings"], [])
def test_read_chapter_text_truncates(self):
class ChapterConn:
def __init__(self):
self._step = 0
def execute(self, sql, params=None):
self._step += 1
rows = [(1, 3, "第三章")] if self._step == 1 else [("字" * 40000,)]
return FakeCursor(rows)
def close(self):
pass
result = execute_tool(
"read_chapter_text", {"work_id": 12, "chapter_order": 3}, connect=ChapterConn
)
self.assertTrue(result["found"])
self.assertTrue(result["truncated"])
self.assertEqual(len(result["text"]), 30000)
self.assertEqual(result["totalChars"], 40000)
def test_search_entities_like_and_bounds(self):
conn = FakeConn([("character", "林深", "主角", "active")])
result = execute_tool(
"search_entities", {"work_id": 12, "keyword": "林"}, connect=lambda: conn
)
self.assertEqual(result["entities"][0]["name"], "林深")
sql, params = conn.queries[0]
self.assertIn("ILIKE", sql)
self.assertEqual(params, (12, "%林%", 50))
def test_unknown_tool_and_bad_args_rejected(self):
with self.assertRaises(KeyError):
execute_tool("drop_table", {}, connect=lambda: FakeConn([]))
with self.assertRaises(ValueError):
execute_tool(
"search_entities", {"work_id": 12, "keyword": ""}, connect=lambda: FakeConn([])
)
with self.assertRaises(ValueError):
execute_tool(
"read_fine_outline", {"work_id": "x", "target_chapter": 1},
connect=lambda: FakeConn([]),
)
class RegistryCliTest(unittest.TestCase):
def test_registry_shape_and_cli_list(self):
self.assertEqual(
set(TOOL_REGISTRY),
{
"read_fine_outline",
"read_style_constraints",
"read_pattern_bindings",
"read_chapter_text",
"search_entities",
},
)
for entry in TOOL_REGISTRY.values():
self.assertTrue(entry["description"])
self.assertTrue(entry["tables"])
self.assertTrue(entry["args"])
proc = subprocess.run(
[PYTHON, str(READ_TOOLS_PY), "--list"],
capture_output=True,
text=True,
timeout=30,
)
self.assertEqual(proc.returncode, 0, proc.stderr)
listing = json.loads(proc.stdout)
self.assertEqual(set(listing), set(TOOL_REGISTRY))
for name, item in listing.items():
self.assertEqual(item["args"], TOOL_REGISTRY[name]["args"])
def test_cli_rejects_unknown_tool_with_contract_exit_code(self):
proc = subprocess.run(
[PYTHON, str(READ_TOOLS_PY), "drop_table", "{}"],
capture_output=True,
text=True,
timeout=30,
)
self.assertEqual(proc.returncode, 2)
class ExtensionSyncTest(unittest.TestCase):
"""扩展不自带工具清单:它必须从登记表 --list 动态装载,防止双源漂移。"""
def test_extension_loads_registry_dynamically(self):
extension = (SKILL_DIR / "muse_read_tools_extension.ts").read_text(encoding="utf-8")
self.assertIn("--list", extension)
self.assertIn("registerTool", extension)
self.assertIn("MUSE_READ_TOOLS_PYTHON", extension)
self.assertIn("MUSE_READ_TOOLS_SCRIPT", extension)
# 扩展不得硬编码登记表之外的工具名(只能出现动态注册语句)。
for name in ("read_fine_outline", "search_entities"):
self.assertNotIn(f'name: "{name}"', extension)
if __name__ == "__main__":
unittest.main()