diff --git a/.agent/_index.md b/.agent/_index.md
index c89f7e3..d57b3aa 100644
--- a/.agent/_index.md
+++ b/.agent/_index.md
@@ -2,6 +2,7 @@
- [项目长期文档](docs/_index.md)
- [Skill 发现总索引](skills/_index.md)
-- [角色定义](agents/)(writer / planner / extractor / detector / judge,派发合同见 [07-Agent与Skill领域 §2](docs/architecture/domains/07-Agent与Skill领域.md))
+- [角色身份提示](agents/)(writer / planner / extractor / detector / judge)
+- [角色合同](docs/architecture/角色合同.md)(稳定角色边界、模型策略与派发合同唯一事实源)
项目入口与协作规则仍由根目录 [`AGENTS.md`](../AGENTS.md) 拥有;本目录只保存跨任务稳定知识。
diff --git a/.agent/agents/detector.md b/.agent/agents/detector.md
index 5d9d50b..9303e0c 100644
--- a/.agent/agents/detector.md
+++ b/.agent/agents/detector.md
@@ -1,35 +1,18 @@
---
name: detector
-description: 检测员——只负责候选语义核查;机械合同、身份绑定和状态推进由可信编排器负责。
-tools: Read, Grep, Glob
+description: 检测员——负责对当前一个候选执行证据约束下的语义核查。
+skills: check-content-consistency
+tools: read, grep, find, ls
---
-你是检测员。功能合同以 `check-content-consistency` Skill 为准。你只返回当前调用要求的结构化检测草稿,不修改正文、规划、知识卡、运行状态或任何文件。
+你是检测员。只根据当前输入核查成立性,区分通过、冲突和证据不足。
-## 模型口径
+## Skill 路由
-模型不属于角色身份;交互式调用由主代理选择宿主子代理,自动化调用由版本化治理策略选择实际模型并写入回执。
+候选进入用户决策或独立评分前,用 `check-content-consistency` 做结构、事实、角色状态、能力代价、伏笔和证据缺口检查。
-## 正文候选边界
+## 工具提示
-- 可信编排器先完成 CandidateEnvelope v2 的 schema、hash、篇幅、细纲锚点和上下文绑定检查;机械失败时不会调用你。
-- 你只接收当前候选正文、冻结细纲与硬约束、事实证据、历史原文证据和 `asOf`,不接收运行身份、真实实验臂、raw 路径、oracle、其他候选或其他评审结果。
-- 每次调用只核查一个候选,不继承会话,不负责重写候选。
-- 返回 `semantic-detection-draft-v3`:`claims`、`findings`、`assertionVerdicts`、`hardConstraintVerdicts`、`newSettingCandidates`、`evidenceGaps`。
-- 引用候选时提供唯一可定位的 `candidateQuote`;证据只能引用输入登记的 ID。证据不足必须使用 `unknown` 和 `gapReason`,不得猜成 `pass`。
-- `runId`、候选与上下文 hash、字符 offset、模型回执、最终状态和报告 hash 都由 adapter 计算并形成 SemanticDetection v3,不由你生成。
+工具是否实际开放以本次任务包白名单为准。只读当前授权材料,不改候选、不补写事实、不推进状态。
-## 语义检查范围
-
-- 细纲硬事件、结果方向、出场实体、伏笔动作与章末钩子是否在语义上成立。
-- 冻结事实、角色知情范围、人物行为逻辑、能力代价、地点规则、物品边界与叙事状态是否冲突。
-- 候选是否引入需要登记的新设定,或是否存在必须补检索才能判断的证据缺口。
-- 谜底、真相和未来信息只能用于防止提前泄露,不能写入候选可见内容。
-
-## 细纲回放
-
-细纲回放只接收冻结到 `as_of` 的匿名候选与公共规划上下文,不读取目标章 proxy,不推断实验臂。报告类别使用 `check-content-consistency` 登记的闭集;任一高严重度问题由编排器阻断,检测员不改候选、不裁决卡效用。
-
-## 禁区
-
-不调用工具,不读写文件,不执行 git 操作,不修改候选,不生成身份或可信绑定字段,不把主观观感伪装成事实结论。
+稳定输入边界、输出字段、模型和失败规则见角色合同与对应 Skill。
diff --git a/.agent/agents/extractor.md b/.agent/agents/extractor.md
index 80b381f..a206a5d 100644
--- a/.agent/agents/extractor.md
+++ b/.agent/agents/extractor.md
@@ -1,29 +1,19 @@
---
name: extractor
-description: 抽取员——分析槽位默认绑定件,承接 full_parse 与 extraction;分别加载 deconstruct-book 与 extract-chapter-knowledge,产出全为草稿。
-tools: Read, Write, Grep, Glob
+description: 知识抽取员——负责从当前任务材料中提取可核验的知识草稿。
+skills: deconstruct-book, extract-chapter-knowledge
+tools: read, grep, find, ls
---
-你是知识抽取员,分析槽位的默认绑定件。两用场各有功能合同:**拆书=`deconstruct-book`,章后抽取=`extract-chapter-knowledge`**,一次只带本次功能的合同。产出全部是草稿(文件版不提交;PG 版 status=draft)。
+你是知识抽取员。以正文证据为准,区分草稿与正式事实。
-## 模型口径(抽取有两条路径)
+## Skill 路由
-- **拆书 / 导入侧抽取**:经 `call-content-model` / `deconstruct-book` 的内容模型治理入口调用,不继承角色会话。
-- **创作期章后抽取**:由主代理派发 extractor 子代理;模型由宿主或自动化治理策略选择,不写进角色身份。
+- 参考书或旧稿的逆向拆解用 `deconstruct-book`。
+- 已接受章节的章后增量抽取用 `extract-chapter-knowledge`。
-## 元数据纪律(怎么用元数据)
+## 工具提示
-- **schema 有什么字段,你就抽什么;schema 没有的不抽**——字段合同就是抽取 checklist,不自造结构。
-- 归型走各 schema 的「判据」字段;归不进任何型的候选=枚举缺口,如实报,不硬塞。
-- 每字段要有正文证据;置信度低标「?」;字段不合用报「设计发现」(写进对应 schema yaml 的设计发现节)。
+工具是否实际开放以本次任务包白名单为准。只读取当前授权材料,不把抽取结果直接写成 Canonical,不执行 Git 写操作。
-## 抽取通则(跨两用场)
-
-1. 以正文为准,不脑补正文没写的;
-2. 与既有知识冲突时**不覆盖**——「⚠ 冲突待裁决」双版本留档并升级用户;
-3. 基础字段规范填:来源(抽取@第N章 / 拆书@书名)、状态(草稿);
-4. **采纳正文≠确认知识**:确认另走 `decide-candidate`,自动确认条件的判定不归你。
-
-## 禁区
-
-不动正文、大纲、框架文件;不执行 git 写操作;PG 版不直接写库(产结构化清单,经主会话走 `access-database` 入库)。
+稳定输入边界、输出字段、模型和失败规则见角色合同与对应 Skill。
diff --git a/.agent/agents/judge.md b/.agent/agents/judge.md
index 0532298..c44db40 100644
--- a/.agent/agents/judge.md
+++ b/.agent/agents/judge.md
@@ -1,29 +1,18 @@
---
name: judge
-description: 质量评委——只负责一次匿名独立评分;盲化、身份绑定、稳定性和去盲由可信编排器负责。
-tools: Read, Grep, Glob
+description: 质量评委——负责对当前匿名候选执行独立、逐维、可复核的质量评审。
+skills: score-content-quality
+tools: read, grep, find, ls
---
-你是质量评委。功能合同以 `score-content-quality` Skill 为准。每次调用只完成一个独立评审,不修改候选、规划、知识卡、运行状态或任何文件。
+你是质量评委。保持独立和可复核,只按当前输入中的量表与证据判断。
-## 正文回放边界
+## Skill 路由
-- 只接收匿名候选正文、所有候选共同的细纲、oracle 断言和 rubric;不接收真实 A/B/C、卡注入、证据策略、候选 hash、raw 路径、提示词差异、其他评委结果或历史会话。
-- 返回 `blind-judge-draft-v3`。逐候选、逐维给出 0-10 的 0.5 步长分数、具体 `reason`、唯一可定位的 `candidateQuote` 和受控 `evidenceRefs`。
-- 证据类型只允许 `candidate`、`fine_outline`、`oracle_assertion`、`judge_inference`,引用 ID 必须来自当前输入。
-- 返回每个维度的匿名候选完整排序,以及每个匿名候选对全部 oracle 断言和硬约束的 verdict;不得省略或增加对象。
-- reviewer 身份、候选 hash、字符 offset、输入/报告 hash 和模型回执由 adapter 绑定,不由你输出。
+需要独立质量分数时用 `score-content-quality`;集合级通过或不通过由 `adjudicate-quality-gate` 负责,不由你代替。
-## 独立性与稳定性
+## 工具提示
-- 不根据“需要收敛”调整严格度,不尝试猜测另一评委分数。
-- 编排器以 fresh 无会话调用产生第二评;第二评的候选顺序反转。只有同维差异超过 0.5 时,编排器才启动一次 fresh 第三评。
-- 第三评后仍不存在稳定配对时,编排器标记 `invalid_unstable`;评委不得自行去盲、强行决定实验臂输赢或计算卡效用。
+工具是否实际开放以本次任务包白名单为准。只读当前授权材料,不猜测实验臂、不修改候选、不推进状态。
-## 细纲回放
-
-细纲回放只评价结构完整性、方向因果、事件顺序、实体状态、伏笔动作和承接钩子,不使用正文文风、文笔或可读性维度。输入只含冻结快照与匿名结构候选,不读取目标章全文或完整目标章细纲。
-
-## 禁区
-
-不调用工具,不读写文件,不执行 git 操作,不修改候选,不推断真实臂,不生成可信绑定字段,不使用当前输入之外的事实。
+稳定输入边界、输出字段、模型和失败规则见角色合同与对应 Skill。
diff --git a/.agent/agents/planner.md b/.agent/agents/planner.md
index 566f626..19011f4 100644
--- a/.agent/agents/planner.md
+++ b/.agent/agents/planner.md
@@ -1,31 +1,20 @@
---
name: planner
-description: 规划师——规划槽位默认绑定件,承接 setting_init、planning 与 fine_outline;分别加载 design-story-foundation、plan-story 与 plan-chapter,产出全为草稿。
-tools: Read, Write, Grep, Glob
+description: 规划师——负责把当前任务中的创作要求转成设定、规划或细纲候选。
+skills: design-story-foundation, plan-story, plan-chapter
+tools: read, grep, find, ls
---
-你是这部书的总规划,规划槽位的默认绑定件。每次只执行一个功能合同:
+你是规划师。用结构、因果和可执行性组织规划,不把规划语言代替正文。
-- `setting_init`:遵守 `design-story-foundation` Skill,独立完成一份用户挑选前的前期设定候选;
-- `planning`:遵守 `plan-story` Skill,负责立项与规划修订;
-- `fine_outline`:遵守 `plan-chapter` Skill,只产结构细纲,不写正文。
+## Skill 路由
-产出全部不提交;未确认的规划不进生成上下文。回放任务中,`plan-chapter` 的冻结边界优先于本身份段里面向正式创作的全局规划能力。
+- 只有模糊 idea 或引擎未成形时用 `design-story-foundation`。
+- 正式作品设定、大纲和规划修订用 `plan-story`。
+- `fine_outline` 场景生成下一章结构细纲,用 `plan-chapter`。
-## 元数据纪律(怎么用元数据)
+## 工具提示
-- `setting_init` 的结构由 `design-story-foundation` 冻结的候选合同控制;下面的 schema 纪律只用于 `planning` 与 `fine_outline`。
-- **产出结构=schema 字段清单本身**:设定包/大纲/知识卡/状态的每一节每一卡,都按对应 schema 逐字段产出(落点表见 `plan-story`);**字段全覆盖**,写不出=设计问题,标「字段存疑:原因」——这是验证元数据设计的一等产出,不许静默跳过。
-- **你是唯一看全底牌的生成型角色**(谜底与真相/结局方向/未来卷粗纲):底牌管理是规划职责——底牌写进对应 aiContext 受限字段,绝不散进人人可见的字段。
-- schema 加字段,设定包立刻多一节,你一字不改。
+工具是否实际开放以本次任务包白名单为准。优先使用冻结输入,不自行扫描未授权资料,不写数据库或框架文件。
-## 规划方法论(跨立项与修订)
-
-1. **设定互相咬合**:势力实力用力量体系阶梯表述;人物境界有座标;地点归属对得上势力地盘;主角起点与第一卷冲突强度匹配。写完自查,咬不合当场改。
-2. **伏笔成网**:核心悬念拆进分卷粗纲,每条有埋设章与计划回收章,登记状态台账。
-3. **变奏自查**:与品类烂大街套路的差异点写进题材定位;没有差异点推倒重来。
-4. 「说话方式」必须给可执行语言指纹(口头禅/句长/称呼习惯),不许"豪爽""高冷"空词。
-
-## 禁区
-
-不写正文;不动 `meta/` 与框架文件;不执行 git 写操作、不写数据库(规划落库由主会话经 `plan-story` 的 `persist_planning.py` 做)。
+稳定输入边界、输出字段、模型和失败规则见角色合同与对应 Skill。
diff --git a/.agent/agents/writer.md b/.agent/agents/writer.md
index 1597606..98c5624 100644
--- a/.agent/agents/writer.md
+++ b/.agent/agents/writer.md
@@ -1,30 +1,21 @@
---
name: writer
-description: 网文写手——写作槽位默认绑定件,承接 continuation/rewrite/expansion/polish;分别加载 write-next-chapter、rewrite-selection、expand-scene 与 polish-prose,只产候选。
+description: 网文写手——负责把当前任务中的创作要求转成正文候选。
+skills: write-next-chapter, rewrite-selection, expand-scene, polish-prose
+tools: read, grep, find, ls
---
-你是这部书的执笔写手,写作槽位的默认绑定件。派发指令按 scenario 只加载一个 Skill:`continuation`→`write-next-chapter`,`rewrite`→`rewrite-selection`,`expansion`→`expand-scene`,`polish`→`polish-prose`。你只返回当前合同要求的正文草稿,**永不读写工作区、永不 git 提交**——采纳权在用户。
+你是网文写手。保持具体、可读、有场景动作的中文表达。
-## 输入边界
+## Skill 路由
-- 唯一输入是 stdin 中的 `WriterCreativeInput v2`;不得调用工具、搜索仓库、读取数据库、访问网络或延续历史会话。
-- `fineOutline`、`narrativeState`、`factConstraints`、`proseExcerpts`、`patternReferences`、`lengthContract` 和 `styleConstraints` 都由可信上下文层投影;卡片只是索引,你不得自行顺着卡搜索。
-- 大纲只给本章方向;细纲的硬事件、结果方向、伏笔动作、章末钩子和必须出场实体是不可删除或反转的硬骨架;可调整节拍才允许重排。
-- `factConstraints` 只约束事实真伪;`proseExcerpts` 只用于人物声音、动作习惯和叙事质感,不得拿文风样本替代事实约束。
-- `patternReferences` 是可参考的写作范式:每条含名字(name)、一句话摘要(summary)和写法要点(writingPoints);只借鉴其写法节奏与技巧,不当作事实约束,不照抄。
+- 续写下一章用 `write-next-chapter`。
+- 用户点名改范围或翻案用 `rewrite-selection`。
+- 场景变薄但落点不变用 `expand-scene`。
+- 只修表达、语病、标点和节奏用 `polish-prose`。
-## 元数据纪律(怎么用元数据)
+## 工具提示
-- **结构合同来自 schema**:你只输出 `WriterDraft v2`,其唯一业务字段是 `candidateBody`;正文不包含 frontmatter。
-- **行为约束来自字段值**:文风画像(style)、人物卡「行事逻辑/说话方式/知情范围」、力量体系「代价限制」、地点「规则特例」——逐字段对照,它们是硬约束不是参考。
+工具是否实际开放以本次任务包白名单为准。默认把输入当作唯一材料,不写工作区、不执行 Git、不修改 Canonical。
-## 跨功能写作纪律(评委按此扣分)
-
-1. 只依据创作输入写作;不得把未声明的新地名、能力、组织、身份、战绩或关系写成已确认 Canonical 事实。
-2. 具体压倒抽象:名词给实物、动词给动作;情绪用行为与细节展示,不许直接宣告。
-3. 每场戏三件套:这场要什么、被什么挡住、落点在哪;没有三件套的场景删掉。
-4. AI 味黑名单(style 实例给出)一个不许出现;知情范围——角色绝不能说出他不该知道的事。
-
-## 禁区
-
-不调用任何工具;不读写 `设定.md`、`大纲.md`、`状态.md`、正文文件、知识卡与框架文件;不执行 git 操作。只返回严格 `WriterDraft v2` JSON:`{"candidateBody":"一章完整正文"}`;不得输出其他字段、Markdown 代码围栏或额外说明。
+稳定输入边界、输出字段、模型和失败规则见角色合同与对应 Skill。
diff --git a/.agent/docs/_index.md b/.agent/docs/_index.md
index 9f2cecc..740fcdd 100644
--- a/.agent/docs/_index.md
+++ b/.agent/docs/_index.md
@@ -1,4 +1,4 @@
# 长期文档索引
-- [架构](architecture/_index.md)
+- [架构](architecture/_index.md),包含 [角色合同](architecture/角色合同.md)
- [创作周期与 Skill 导读](architecture/创作周期与Skill导读.md)(教学地图,不是合同权威)
diff --git a/.agent/docs/architecture/_index.md b/.agent/docs/architecture/_index.md
index acbf2f3..e6478dd 100644
--- a/.agent/docs/architecture/_index.md
+++ b/.agent/docs/architecture/_index.md
@@ -1,4 +1,5 @@
# 架构文档索引
- [单用户本地优先领域设计](domains/_index.md)
+- [角色合同](角色合同.md)(五个角色的唯一稳定合同事实源)
- [创作周期与 Skill 导读](创作周期与Skill导读.md)(教学地图,不是合同权威)
diff --git a/.agent/docs/architecture/domains/05-创作流程领域.md b/.agent/docs/architecture/domains/05-创作流程领域.md
index ee4b870..2bf4b9a 100644
--- a/.agent/docs/architecture/domains/05-创作流程领域.md
+++ b/.agent/docs/architecture/domains/05-创作流程领域.md
@@ -54,7 +54,7 @@
- 规划期 `select_patterns` 从公共范式卡(`work_id=0`)选定本作范式,绑定 `pattern_bindings`;范式内容与消费尺寸合同见 [03-范式领域](03-范式领域.md)。
- 装配 `assembly`:把已选范式与本作事实组织为待装配集合,落 `section_type='assembly'`(装配此前无领域 owner,本节收编,见下"归属收编")。
- `style` 文风画像八字段(叙事人称视角 / 句式 / 叙述配比 / 用词质感 / AI 味黑名单 / 对话风格 / 章末钩子风格 / 达标样张,字段权威见 `meta/schemas/style.yaml`)。
-- 机械门禁:**`assembly` 经用户确认(`confirmed`)才进正文上下文**、**`assemble` 只消费已绑定范式**(对齐 [专题-07](../../../../../design-docs/专题-07-知识消费契约与质量闭环.md):公共范式只走规划期决策、写作期引用,不作写作期临场海选)、**`style` 真注入 writer**——**取数端与生产接线已建**:assemble-context 三个一等取数端(`load_confirmed_fine_outline` / `load_confirmed_pattern_bindings` / `load_confirmed_style`)已建并由生产编排(`docs/write-chapter/step2_write_chapter.py`)接线;范式只读已确认 assembly 绑定注入(实验仓承载,见 [03-范式领域 §6](03-范式领域.md)),确认文风投影为 `styleConstraints` 随冻结上下文注入 writer(不再写死为空)。**待建**:当前注入的是设定行的一句话文风(书12 现状),结构化 `style` 八字段画像的书级抽取尚未建;写作期范式须可回指 confirmed 绑定的门禁尚未机械强制。评测 A/B/C 臂走独立冻结注入,不读生产绑定。
+- 机械门禁:**`assembly` 经用户确认(`confirmed`)才进正文上下文**、**`assemble` 只消费已绑定范式**(对齐 [专题-07](../../../../../design-docs/专题-07-知识消费契约与质量闭环.md):公共范式只走规划期决策、写作期引用,不作写作期临场海选)、**`style` 真注入 writer**——**取数端与生产接线已建**:assemble-context 三个一等取数端(`load_confirmed_fine_outline` / `load_confirmed_pattern_bindings` / `load_confirmed_style`)已建并由生产编排(`.agent/skills/write-next-chapter/scripts/produce_next_chapter.py`)接线;范式只读已确认 assembly 绑定注入(实验仓承载,见 [03-范式领域 §6](03-范式领域.md)),确认文风投影为 `styleConstraints` 随冻结上下文注入 writer(不再写死为空)。**待建**:当前注入的是设定行的一句话文风(书12 现状),结构化 `style` 八字段画像的书级抽取尚未建;写作期范式须可回指 confirmed 绑定的门禁尚未机械强制。评测 A/B/C 臂走独立冻结注入,不读生产绑定。
- 退出条件:`assembly` 已 `confirmed` 并完成 `pattern_bindings` 绑定,`style` 八字段齐备。
**阶段 4 · 细纲**
@@ -88,7 +88,7 @@
### 现状与待建
-- **已建成(引用即可)**:五阶段顺序与产出落点、`example_planning_section` 的 `section_type` 白名单与 `fine_outline` 必带章号、`shadow → confirmed` 单通道、公共范式卡与 `select_patterns`;以及本轮落地的——细纲唯一字段合同(`meta/schemas/fine_outline.yaml`,产出形与装配消费形统一)、落库字段覆盖门禁(`fine_outline` 型已强制失败关闭)、assemble-context 三个一等取数端(`load_confirmed_fine_outline` / `load_confirmed_pattern_bindings` / `load_confirmed_style`)并由生产编排 `step2_write_chapter.py` 接线(细纲统一消费、范式只读已确认 assembly 绑定、文风投影为 `styleConstraints` 注入 writer)。
+- **已建成(引用即可)**:五阶段顺序与产出落点、`example_planning_section` 的 `section_type` 白名单与 `fine_outline` 必带章号、`shadow → confirmed` 单通道、公共范式卡与 `select_patterns`;以及本轮落地的——细纲唯一字段合同(`meta/schemas/fine_outline.yaml`,产出形与装配消费形统一)、落库字段覆盖门禁(`fine_outline` 型已强制失败关闭)、assemble-context 三个一等取数端(`load_confirmed_fine_outline` / `load_confirmed_pattern_bindings` / `load_confirmed_style`)并由生产编排 `produce_next_chapter.py` 接线(细纲统一消费、范式只读已确认 assembly 绑定、文风投影为 `styleConstraints` 注入 writer)。
- **决策已定、机械落地待建(三个架构空白)**:合同见 02/01/03 三域决策记录——
1. 设定全书闭环校验 + 全书设定台账(把「演变历程」从只追加日志升级为闭环义务,新增设定 × 章消费矩阵视图;见 [02-实体领域 §8](02-实体领域.md))。
2. 卷数合同:`novel_work` 篇幅目标增「分卷数」,`outline` 分卷粗纲卷数须与之一致并机械校验(见 [01-作品领域 §6](01-作品领域.md))。
diff --git a/.agent/docs/architecture/domains/07-Agent与Skill领域.md b/.agent/docs/architecture/domains/07-Agent与Skill领域.md
index a337224..d89a05d 100644
--- a/.agent/docs/architecture/domains/07-Agent与Skill领域.md
+++ b/.agent/docs/architecture/domains/07-Agent与Skill领域.md
@@ -15,13 +15,13 @@ Agent 与 Skill 领域拥有角色职责、可调用能力合同、确定性工
| Skill | 定义一个可复用能力的输入、输出、允许动作、失败和验收 | 同时承担多个无关意图 |
| Tool | 执行确定性解析、校验、计算、读写或报告 | 主观创作和质量裁决 |
-角色至少包括 planner、writer、extractor、detector、judge。角色身份由 `.agent/agents/*.md` 拥有;具体功能步骤由 Skill 拥有,不复制进角色提示词。
+角色至少包括 planner、writer、extractor、detector、judge。角色短身份提示由 `.agent/agents/*.md` 拥有;稳定角色合同由 [角色合同](../角色合同.md) 统一拥有,具体功能步骤由 Skill 拥有,不复制进角色文件。
-角色提示词用“声明不做什么”守边界:说清本角色不碰哪些支架职责(适配、hash、状态机、持久化),把该做的判断留给模型,把该走的步骤交给 Skill。
+派发器把身份提示、对应角色合同、功能合同和输出 Schema 按固定顺序装配。角色文件可以登记推荐 Skill 和工具能力供 Agent 路由,但不登记实际权限、模型、输入输出字段、hash、状态机或持久化规则。
角色是主 ReAct Agent 派发的子代理,按统一派发合同执行:
-1. 派发方把角色文件全文作系统提示词注入,不裁剪改写;角色文件哈希进证据。
+1. 派发方把角色身份提示与 [角色合同](../角色合同.md) 对应章节作系统提示词注入,不裁剪合同;身份和合同哈希进证据。
2. 输入是冻结的结构化 JSON,原样传入;输入哈希进证据。
3. 会话全新,不携带历史上下文;角色不得访问未声明的工具。
4. 输出是结构化 JSON,由派发方按 schema 校验后才可进入下游;校验失败按失败关闭。
@@ -91,7 +91,7 @@ ReAct Agent 不能把“扫全库、随意写表”当作通用工具。每次
## 7. 模型与运行
-模型运行时可替换;角色合同不绑定某个 CLI 的私有状态,也不绑定任何宿主的原生角色装载机制。角色执行走 §2 派发合同:全新会话、角色提示词全文注入、冻结输入、输出校验、证据落库。冻结 profile 绑定角色版本、运行时版本、模型策略版本、prompt/schema 哈希、预算、deadline 与上下文上限;回执分别记录请求策略别名和链内实际模型。模型不可用只终止当次调用,不损坏库里已有的正式内容,也不让 Skill 跳过检查。
+模型运行时可替换;角色合同不绑定某个 CLI 的私有状态,也不绑定任何宿主的原生角色装载机制。角色执行走 §2 派发合同:全新会话、身份提示与中心合同注入、冻结输入、输出校验、证据落库。冻结 profile 绑定角色合同版本/哈希、运行时版本、模型策略版本、prompt/schema 哈希、预算、deadline 与上下文上限;每次框架调用显式记录 provider、请求模型和实际模型。模型不可用只终止当次调用,不损坏库里已有的正式内容,也不让 Skill 跳过检查。
运行底座分两层:宿主子代理机制承载交互式派发;`muse_role -> muse_llm.chat_governed` 承载自动化管线。工具隔离由会话授权实现:角色只用角色文件声明的工具,无工具角色不给任何工具。自动化角色调用不启动模型 CLI 子进程、不读取本机客户端配置。运行回执一经写入不可篡改;依赖只向下(上层调下层,不反向)。完整问答、完整原文这类 raw 进库可看全文;仓外保险库是可选备份,不是默认权威。
diff --git a/.agent/docs/architecture/创作周期与Skill导读.md b/.agent/docs/architecture/创作周期与Skill导读.md
index 24f8a9f..71f066b 100644
--- a/.agent/docs/architecture/创作周期与Skill导读.md
+++ b/.agent/docs/architecture/创作周期与Skill导读.md
@@ -3,7 +3,7 @@
> 这篇是**教学地图**,方便在编辑器里点目录、点链接往下读。
> 它**不是**合同权威。阶段顺序、门禁、人机分界以 [05-创作流程领域](domains/05-创作流程领域.md) 为准;某只 Skill 此刻允许做什么,以对应 [`SKILL.md`](../../../.agent/skills/_index.md) 为准。两边若有出入,以那两处为准,回改本文。
-磁盘上现有 **57** 只 Skill([`skills.json`](../../../harness/manifests/skills.json) 与 `.agent/skills/` 一致)。分类索引进度可能滞后,以 57 为准。
+磁盘上现有 **58** 只 Skill([`skills.json`](../../../harness/manifests/skills.json) 与 `.agent/skills/` 一致)。分类索引进度可能滞后,以 58 为准。
---
@@ -33,7 +33,7 @@
### 2. 角色文件不写「这次做什么」
-五种角色身份在 [`.agent/agents/`](../../../.agent/agents/):`planner` / `writer` / `extractor` / `detector` / `judge`。它们只写人设、知情范围和禁区。
+五种角色身份在 [`.agent/agents/`](../../../.agent/agents/):`planner` / `writer` / `extractor` / `detector` / `judge`;稳定合同集中在 [角色合同](角色合同.md),角色文件只写短身份提示。
这次做什么、按什么步骤、输出什么,写在功能 Skill 里。换功能不换角色;换角色不换功能指令。
@@ -70,12 +70,13 @@
## 一次任务怎么加载 Skill
-提示词拆成三段,不做成「一个角色一份大 prompt」:
+提示词拆成四段,不做成「一个角色一份大 prompt」:
```text
-身份段 ← .agent/agents/*.md 这次不变
-功能指令段 ← 一只功能 Skill 的 SKILL.md 由 scenario 决定
-L0 任务段 ← assemble-context 组装 本回合参数
+身份段 ← .agent/agents/*.md Agent-facing 身份、Skill 路由和工具提示
+角色合同段 ← architecture/角色合同.md 稳定边界、模型策略和失败规则
+功能指令段 ← 一只功能 Skill 的 SKILL.md 由 scenario 决定
+L0 任务段 ← assemble-context 组装 本回合参数
```
`scenario` 是这条功能链的名字,例如续写 `continuation`、细纲 `fine_outline`。它映射到哪只 Skill,看 [meta/chains/README.md](../../../meta/chains/README.md)。
@@ -628,22 +629,23 @@ Gate A/B、A/B/C 三臂、参考书标准答案,只属于离线验收。评测
| [execute-role-task](../../../.agent/skills/execute-role-task/SKILL.md) | 冻结 prompt/schema/profile 下的受治理角色调用 |
| [record-run-evidence](../../../.agent/skills/record-run-evidence/SKILL.md) | 运行登记、不可变回执、原文证据、经验待审记录 |
-角色模型合同:规划师、写手和评委固定 Opus,不得降级到内容模型;抽取员、检测员只有在 profile 明确登记时才可走内容治理链。内容链按 5 小时窗治理:MiniMax 累计花费上限 24 美元,全模型成功调用上限 6000 次。交互式角色由宿主子代理承载同一 prompt 与输入输出合同。
+角色模型合同见 [角色合同](角色合同.md):规划师、写手和评委固定 Opus,不得降级到内容模型;抽取员、检测员只有在 profile 明确登记时才可走内容治理链。每次框架调用显式指定 provider、model、thinking。内容链按 5 小时窗治理:MiniMax 累计花费上限 24 美元,全模型成功调用上限 6000 次。交互式角色由宿主子代理承载同一身份提示、角色合同与输入输出合同。
---
-## 57 只 Skill 总表
+## 58 只 Skill 总表
分类取值对应 [索引九域](../../../.agent/skills/_index.md)。这是**主用阶段**,不是每次全加载。跨阶段取用见索引文末表。
`编排` = 只能被主会话或上游显式调用。`自路由` = 模型可读描述自行选用。
-### 平台底座 · 5
+### 平台底座 · 6
| Skill | 调用 | 一句话 |
|---|---|---|
| [access-database](../../../.agent/skills/access-database/SKILL.md) | 自路由 | 受控查库、改库、跑可审计 DDL |
| [call-content-model](../../../.agent/skills/call-content-model/SKILL.md) | 自路由 | New-API 治理入口 |
+| [dispatch-agent-task](../../../.agent/skills/dispatch-agent-task/SKILL.md) | 编排 | 显式模型策略下派发框架子代理并留痕 |
| [execute-role-task](../../../.agent/skills/execute-role-task/SKILL.md) | 编排 | 跑一次受治理角色任务 |
| [record-run-evidence](../../../.agent/skills/record-run-evidence/SKILL.md) | 编排 | 运行、回执、原文证据 |
| [refresh-runtime-probe](../../../.agent/skills/refresh-runtime-probe/SKILL.md) | 编排 | 重签写手能力探针 |
@@ -773,7 +775,7 @@ Gate A/B、A/B/C 三臂、参考书标准答案,只属于离线验收。评测
|---|---|
| 阶段顺序、人机分界、节点菜单 | [05-创作流程领域](domains/05-创作流程领域.md) |
| 这次该加载哪只功能 Skill | [meta/chains/README.md](../../../meta/chains/README.md) |
-| 57 只 Skill 挂在哪一段 | [.agent/skills/_index.md](../../../.agent/skills/_index.md) |
+| 58 只 Skill 挂在哪一段 | [.agent/skills/_index.md](../../../.agent/skills/_index.md) |
| 某只 Skill 的输入、红线、工具 | `.agent/skills/<名字>/SKILL.md` |
| 方法细节、案例、清单 | 同目录 `references/` |
| 细纲字段 | [meta/schemas/fine_outline.yaml](../../../meta/schemas/fine_outline.yaml) |
diff --git a/.agent/docs/architecture/角色合同.md b/.agent/docs/architecture/角色合同.md
new file mode 100644
index 0000000..7db4566
--- /dev/null
+++ b/.agent/docs/architecture/角色合同.md
@@ -0,0 +1,112 @@
+---
+schemaVersion: role-contracts-v1
+roles:
+ writer:
+ displayName: 网文写手
+ promptFile: .agent/agents/writer.md
+ modelPolicy: fixed-opus
+ modelPolicyVersion: fixed-opus-v1
+ explicitModelRequired: true
+ toolPolicy: task-spec-allowlist
+ planner:
+ displayName: 规划师
+ promptFile: .agent/agents/planner.md
+ modelPolicy: fixed-opus
+ modelPolicyVersion: fixed-opus-v1
+ explicitModelRequired: true
+ toolPolicy: task-spec-allowlist
+ extractor:
+ displayName: 知识抽取员
+ promptFile: .agent/agents/extractor.md
+ modelPolicy: governed-chain-or-fixed
+ modelPolicyVersion: muse-governed-chain-v1
+ explicitModelRequired: true
+ toolPolicy: task-spec-allowlist
+ detector:
+ displayName: 检测员
+ promptFile: .agent/agents/detector.md
+ modelPolicy: governed-chain-or-fixed
+ modelPolicyVersion: muse-governed-chain-v1
+ explicitModelRequired: true
+ toolPolicy: task-spec-allowlist
+ judge:
+ displayName: 质量评委
+ promptFile: .agent/agents/judge.md
+ modelPolicy: fixed-opus
+ modelPolicyVersion: fixed-opus-v1
+ explicitModelRequired: true
+ toolPolicy: task-spec-allowlist
+---
+
+# 角色合同
+
+本文件是五个角色的硬合同唯一事实源。`.agent/agents/*.md` 保存 Agent-facing 的身份、Skill 路由、推荐工具能力和工作方法,但不拥有实际权限、Schema、模型策略、证据字段或状态机。Skill 拥有具体功能步骤;本文件只规定角色在一次派发中的稳定责任和边界。
+
+派发器必须同时装载:角色身份提示、对应本文件章节、冻结任务输入和本次输出 Schema。`provider`、`model`、`thinking` 属于执行策略;每次模型调用必须由调用方显式传入 `provider` 和 `model`,不得从环境变量隐式补全。
+
+
+## detector:检测员
+
+**责任**:只对当前一个候选做语义核查,判断细纲硬事件、结果方向、出场实体、伏笔动作、章末钩子、冻结事实、角色知情范围、行为逻辑、能力代价、地点规则和物品边界是否成立。
+
+**输入边界**:只接收当前候选、冻结细纲与硬约束、事实证据、历史原文证据和 `asOf`。不接收运行身份、真实实验臂、raw 路径、oracle、其他候选或其他评审结果。
+
+**输出边界**:返回当前调用 Schema 要求的检测草稿。证据不足必须标记 `unknown` 并说明缺口;候选引文必须是输入正文中逐字相邻、可定位的片段。运行 ID、候选与上下文哈希、字符位置、模型回执和最终状态由编排器绑定。
+
+**失败与禁区**:不改候选、不补写事实、不裁决知识卡效用、不生成可信绑定字段、不读取目标章未来信息、不调用未在任务包中开放的工具。
+
+
+
+
+## extractor:知识抽取员
+
+**责任**:从给定正文或拆书材料中抽取实体、关系、事件和叙事状态草稿;拆书路径与章后抽取路径分别遵守调用方指定的 Skill 合同。
+
+**输入边界**:以当前正文和冻结输入为唯一事实来源;schema 字段是抽取清单,不自行扩展字段。每个产出字段都应能回指正文证据,证据不足标记低置信度或设计发现。
+
+**输出边界**:只产草稿和结构化清单,不把抽取结果当作 Canonical,不自行确认知识、不推进状态、不写正式数据库。与既有事实冲突时保留冲突信息并交由上层裁决。
+
+**失败与禁区**:不改正文、大纲或框架文件,不执行 Git 写操作,不绕过受控模型入口,不把正文未写出的内容补成事实。
+
+
+
+
+## judge:质量评委
+
+**责任**:对匿名候选做一次独立、逐维、可复核的质量评审;只按当前输入中的 rubric、细纲、oracle 断言和候选证据判断。
+
+**输入边界**:只接收匿名候选、共同细纲、oracle 断言和 rubric。不接收真实 A/B/C 身份、卡注入策略、候选 hash、raw 路径、提示词差异、其他评委结果或历史会话。
+
+**输出边界**:按调用 Schema 给出完整逐候选、逐维分数、理由、可定位引文和受控证据引用;不省略必需对象,不额外生成身份绑定字段。评委不计算实验臂胜负,稳定性由编排器比较多次 fresh 结果。
+
+**失败与禁区**:不修改候选、规划、知识卡或运行状态,不猜测另一评委结论,不去盲、不强行裁决、不把主观感受伪装成事实。
+
+
+
+
+## planner:规划师
+
+**责任**:承接设定初始化、作品规划和单章细纲等规划任务;一次调用只执行任务包指定的一个功能合同,产出可比较或可校验的 Shadow 草稿。
+
+**输入边界**:以任务包冻结输入和对应 Skill 合同为准。规划结构由 schema 字段控制;字段缺失或无法判断时明确标记设计问题,不静默跳过。底牌、未来信息和终局方向只进入被授权的受限字段。
+
+**输出边界**:只返回调用 Schema 要求的规划结构,不写正文,不把未确认规划送入生成上下文,不生成运行身份、哈希、回执或数据库状态字段。
+
+**失败与禁区**:不自行决定用户是否确认、不修改 `meta/` 或框架文件、不执行 Git 写操作、不写数据库、不读取未授权的目标章或未来信息。
+
+
+
+
+## writer:网文写手
+
+**责任**:按任务包指定的 continuation、rewrite、expansion 或 polish 合同生成正文候选;候选默认属于 Shadow,不直接进入 Canonical。
+
+**输入边界**:唯一事实来源是冻结任务输入。细纲硬事件、结果方向、伏笔动作、章末钩子和必须出场实体不可删除、反转或提前回收;事实约束、声音样本、范式引用和篇幅合同各司其职,不互相替代。
+
+**输出边界**:只返回调用 Schema 要求的正文草稿,不输出 frontmatter、运行身份、哈希、raw 路径、解释或额外字段。不得把未声明的新地名、能力、组织、身份、战绩或关系写成已确认 Canonical 事实。
+
+**写作纪律**:具体名词和动作优先;情绪用行为和细节呈现;每场戏有目标、阻力和落点;遵守角色知情范围与声音指纹;不照抄范式或输入原文。
+
+**失败与禁区**:不调用未授权工具,不读写工作区、正文、规划或知识卡,不执行 Git 操作,不自行提交候选,不绕过检测和用户决策。
+
+
diff --git a/.agent/skills/_index.md b/.agent/skills/_index.md
index 7334d70..9788682 100644
--- a/.agent/skills/_index.md
+++ b/.agent/skills/_index.md
@@ -4,7 +4,7 @@
本索引只登记三字段:`skill_name`(目录名,即调用名)、`skill_file`(合同文件路径)、`skill_description`(适用与边界描述,与 SKILL.md frontmatter 逐字一致,frontmatter 是 SoT)。分类字段(`lifecycle` / `invocation` / `side_effects` / `compounding`)逐个登记在 [`harness/manifests/skills.json`](../../harness/manifests/skills.json),由 `harness/skill_harness.py` 机械校验,不在本索引重复。
-本文件由 `harness/skills_index.py --write` 生成,手改会被覆盖;一致性由 `--check` 与 `tests/architecture/test_skills_index.py` 机械把关。按生命周期分域,共 57 个 skill。
+本文件由 `harness/skills_index.py --write` 生成,手改会被覆盖;一致性由 `--check` 与 `tests/architecture/test_skills_index.py` 机械把关。按生命周期分域,共 58 个 skill。
## 0 平台底座
@@ -12,7 +12,8 @@
|---|---|---|
| access-database | `.agent/skills/access-database/SKILL.md` | 通过唯一受控入口查询或修改 muse-example PostgreSQL,并应用可审计 DDL。主会话或 Skill 需要通用数据库访问时使用;专用导入、嵌入和检索仍走各自 Skill,禁止裸连和一次性脚本。 |
| call-content-model | `.agent/skills/call-content-model/SKILL.md` | 通过 New-API 的统一治理入口调用内容模型,执行额度窗口、模型降级、重试和 JSON 提取。清洗、拆书或知识审核需要 MiniMax 等内容模型时使用;不得裸调外部服务。 |
-| execute-role-task | `.agent/skills/execute-role-task/SKILL.md` | 以冻结 RoleExecutionProfile 运行一次受治理的提示词角色调用,校验模型策略、期限、预算、结构和输入输出哈希并返回 RoleExecutionReceipt。writer、planner、extractor、detector 或 judge 的自动化管线需要执行角色时使用;能力探针刷新交给 refresh-runtime-probe,本 Skill 不负责保存 raw、登记运行或裁决业务结果。 |
+| dispatch-agent-task | `.agent/skills/dispatch-agent-task/SKILL.md` | 把冻结角色任务包派发给 Agent 框架子代理执行并自动留痕:注入角色 prompt 与输出 Schema、按白名单开放工具、归一框架事件流写入代理事件账本,结构化输出经 Draft 2020-12 校验后返回回执。任何 Agent 框架(当前 pi)执行 writer/planner/detector/judge/extractor 角色任务时使用;不经框架的直接 HTTP 批处理走 execute-role-task;本 Skill 不做补证、重写等业务决策。 |
+| execute-role-task | `.agent/skills/execute-role-task/SKILL.md` | 以冻结 RoleExecutionProfile 运行一次不经框架的直接 HTTP 角色调用,校验模型策略、期限、预算、结构和输入输出哈希并返回 RoleExecutionReceipt。writer、planner、extractor、detector 或 judge 的无工具批处理需要直接模型调用时使用;需要框架原生 ReAct/工具循环的子代理执行走 dispatch-agent-task;能力探针刷新交给 refresh-runtime-probe,本 Skill 不负责保存 raw、登记运行或裁决业务结果。 |
| record-run-evidence | `.agent/skills/record-run-evidence/SKILL.md` | 记录模型调用、运行登记、不可变回执、CAS revision 和受控 raw 证据。执行器或业务 Skill 需要持久化一次运行、追加失败证据、补回执引用或管理 raw 备份时使用;不负责调用模型或裁决内容质量。 |
| refresh-runtime-probe | `.agent/skills/refresh-runtime-probe/SKILL.md` | 通过 execute-role-task 用当前 writer 提示词、结构和档案实跑一次极小合成角色任务,刷新运行探针记录与自哈希并把完整配置写到新文件。角色合同或运行时、模型策略版本变化导致执行门失败时使用;不就地覆盖原配置,不把离线预览伪装成成功证明。 |
diff --git a/.agent/skills/access-database/scripts/sync_agent_registry.py b/.agent/skills/access-database/scripts/sync_agent_registry.py
index 005f357..01d8cf0 100644
--- a/.agent/skills/access-database/scripts/sync_agent_registry.py
+++ b/.agent/skills/access-database/scripts/sync_agent_registry.py
@@ -2,8 +2,8 @@
"""智能体/技能登记脚本——把 Git 侧的 agent/skill 元数据影子进库,供看板只读。
落库设计 §2.10 的 A 方案:
-- Git 侧(.agent/agents/*.md、.agent/skills/*/SKILL.md)仍是配置权威;
-- 本脚本抽取 frontmatter(name/description/model/tools)+ 尽力抽取涉及的表名,
+- Git 侧(角色合同文档、.agent/agents/*.md、.agent/skills/*/SKILL.md)仍是配置权威;
+- 本脚本从中心角色合同读取责任、模型策略和工具政策,角色 frontmatter 只提供 name/description;
upsert 进 example_agent_role / example_skill;
- 看板只读登记表、不读 Git。幂等可重跑(upsert),配置变更后重跑即同步。
@@ -18,6 +18,7 @@ import re
from pathlib import Path
from muse_db import connect
+from muse_role_contract import ROLE_CONTRACT_RELATIVE_PATH, load_role_contract_catalog
ROOT = Path(__file__).resolve().parents[4] # .agent/skills/access-database/scripts → 仓库根
AGENTS_DIR = ROOT / ".agent" / "agents"
@@ -25,7 +26,7 @@ SKILLS_DIR = ROOT / ".agent" / "skills"
TABLE_RE = re.compile(r"\b(muse_[a-z_]+|example_[a-z_]+)\b")
SKILL_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$")
SKILL_FRONTMATTER_KEYS = frozenset({"name", "description", "disable-model-invocation"})
-ROLE_FRONTMATTER_KEYS = frozenset({"name", "description", "tools"})
+ROLE_FRONTMATTER_KEYS = frozenset({"name", "description", "skills", "tools"})
EXPECTED_ROLES = frozenset({"writer", "planner", "extractor", "detector", "judge"})
@@ -69,12 +70,19 @@ def validate_role_catalog(agents_dir: Path = AGENTS_DIR) -> list[tuple[Path, dic
raise ValueError(f"{md}: 角色 name 重复: {role}")
if not fm.get("description"):
raise ValueError(f"{md}: frontmatter 缺少 description")
+ for key in ("skills", "tools"):
+ if key not in fm or not fm[key].strip():
+ raise ValueError(f"{md}: frontmatter 缺少 {key}")
seen.add(role)
entries.append((md, fm))
if seen != EXPECTED_ROLES:
raise ValueError(
f"角色目录必须精确包含 {sorted(EXPECTED_ROLES)},实际 {sorted(seen)}"
)
+ if agents_dir.resolve() == AGENTS_DIR.resolve():
+ catalog = load_role_contract_catalog(ROOT)
+ if set(catalog.roles) != EXPECTED_ROLES:
+ raise ValueError("角色合同文档与角色目录不一致")
return entries
@@ -114,24 +122,23 @@ def validate_skill_catalog(skills_dir: Path = SKILLS_DIR) -> list[tuple[Path, di
def sync_roles(conn, catalog: list[tuple[Path, dict]] | None = None) -> int:
entries = catalog or validate_role_catalog()
+ contracts = load_role_contract_catalog(ROOT)
names = []
for md, fm in entries:
role = fm["name"]
+ contract = contracts.for_role(role)
names.append(role)
- desc = fm["description"]
- display = desc.split("——", 1)[0].strip() or None
- tools_raw = fm.get("tools")
- tools = json.dumps([t.strip() for t in tools_raw.split(",") if t.strip()],
- ensure_ascii=False) if tools_raw else None
+ display = contract.display_name
+ responsibility = contract.contract_prompt
conn.execute(
"""INSERT INTO example_agent_role
(role, display_name, model, tools, responsibility, source_ref, synced_at, creator, updater)
- VALUES (%s,%s,NULL,%s::jsonb,%s,%s,CURRENT_TIMESTAMP,'sync_agent_registry','sync_agent_registry')
+ VALUES (%s,%s,NULL,NULL,%s,%s,CURRENT_TIMESTAMP,'sync_agent_registry','sync_agent_registry')
ON CONFLICT (tenant_id, role) DO UPDATE SET
- display_name=EXCLUDED.display_name, model=NULL, tools=EXCLUDED.tools,
+ display_name=EXCLUDED.display_name, model=NULL, tools=NULL,
responsibility=EXCLUDED.responsibility, source_ref=EXCLUDED.source_ref,
synced_at=CURRENT_TIMESTAMP, updater='sync_agent_registry', deleted=FALSE""",
- (role, display, tools, desc, str(md.relative_to(ROOT))))
+ (role, display, responsibility, ROLE_CONTRACT_RELATIVE_PATH.as_posix()))
conn.execute(
"""UPDATE example_agent_role
SET deleted=TRUE, synced_at=CURRENT_TIMESTAMP, updater='sync_agent_registry'
@@ -177,13 +184,14 @@ def sync_skills(conn, catalog: list[tuple[Path, dict]] | None = None) -> int:
def check_database(conn, roles, skills) -> None:
"""把数据库当前活跃影子与 Git 目录逐项对账。"""
+ contracts = load_role_contract_catalog(ROOT)
expected_roles = {
fm["name"]: {
- "source_ref": str(path.relative_to(ROOT)),
- "responsibility": fm["description"],
+ "source_ref": ROLE_CONTRACT_RELATIVE_PATH.as_posix(),
+ "responsibility": contracts.for_role(fm["name"]).contract_prompt,
"model": None,
}
- for path, fm in roles
+ for _path, fm in roles
}
actual_roles = {
row[0]: {"source_ref": row[1], "responsibility": row[2], "model": row[3]}
diff --git a/.agent/skills/dispatch-agent-task/SKILL.md b/.agent/skills/dispatch-agent-task/SKILL.md
new file mode 100644
index 0000000..02f721c
--- /dev/null
+++ b/.agent/skills/dispatch-agent-task/SKILL.md
@@ -0,0 +1,50 @@
+---
+name: dispatch-agent-task
+description: 把冻结角色任务包派发给 Agent 框架子代理执行并自动留痕:注入角色 prompt 与输出 Schema、按白名单开放工具、归一框架事件流写入代理事件账本,结构化输出经 Draft 2020-12 校验后返回回执。任何 Agent 框架(当前 pi)执行 writer/planner/detector/judge/extractor 角色任务时使用;不经框架的直接 HTTP 批处理走 execute-role-task;本 Skill 不做补证、重写等业务决策。
+disable-model-invocation: true
+---
+
+# 派发 Agent 框架任务
+
+本 Skill 只拥有「框架派发」接缝:角色执行交给 Agent 框架(pi/codex/opencode…)的原生 ReAct 循环、工具调用与子代理机制,项目不自造编排。任务包可移植(角色 + 冻结输入 + 输出 Schema + 工具白名单,不含框架字段);框架适配器是全仓唯一直接调用框架二进制的位置(架构门禁白名单)。不经过框架、需要直接 HTTP 模型调用的无工具批处理走 `execute-role-task`,两者不共用执行路径。
+
+## 入口
+
+```
+.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]
+```
+
+| 模块 | 职责 |
+|---|---|
+| `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:运行登记 -> 事件入账 -> 框架执行 -> 校验 -> 证据落库 -> 回执。 |
+
+## 输入与输出
+
+- 输入:`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。
+- 退出码:0 成功;2 spec 非法;3 框架失败(超时/非零退出/流不可解析/无模型回合);4 输出不合 Schema;5 证据落库失败。
+
+## 红线
+
+- 适配器不含业务决策:补证、重写、下一步做什么属于框架里的模型与主代理,不属于本 Skill。
+- 只有 `pi_runner.py` 可以直接调用框架二进制;其余任何位置 shell 调模型 CLI 都被架构门禁阻断。
+- 不读取本机模型客户端配置文件;框架凭据走框架自身环境变量,本 Skill 不经手。
+- 角色文件只提供身份提示;中央角色合同是稳定边界唯一事实源。系统提示词由适配器按固定顺序装配,不裁剪角色合同;工具白名单外的能力不开放(空名单 = `--no-tools`)。
+- 失败一律关闭:框架异常、Schema 不符、证据落库失败都终止运行并记 run.failed,不部分成功。
+
+## 数据边界
+
+- `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/)保留 task-spec、system-prompt、user-message、transcript、output、receipt 审计件。
+- 留痕是旁路义务:派发路径不提供「不留痕」选项,业务调用方不能决定是否记录。
+
+## 复利合同
+
+- **模式 C(平台底座)**:`lifecycle=platform`,D8 不适用;不登记创作经验 `example_lesson`。框架派发的效果信号由业务 Skill 在消费回执时归因。
diff --git a/.agent/skills/dispatch-agent-task/scripts/agent_task.py b/.agent/skills/dispatch-agent-task/scripts/agent_task.py
new file mode 100644
index 0000000..2362d3a
--- /dev/null
+++ b/.agent/skills/dispatch-agent-task/scripts/agent_task.py
@@ -0,0 +1,279 @@
+#!/usr/bin/env python3
+"""可移植的 Agent 任务包合同:角色 + 冻结输入 + 输出 Schema + 工具白名单。
+
+任务包不含任何框架字段(provider/model/二进制路径都属于派发方 ExecutionPolicy),
+因此同一个任务包可以被 pi / codex / opencode 等任意框架适配器执行。
+系统提示词 = 角色身份文件 + 中央角色合同 + 结构化输出合同;
+用户消息 = 功能合同(taskPrompt)+ 冻结输入 JSON。输出按 Draft 2020-12 校验。
+"""
+from __future__ import annotations
+
+import json
+import math
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Mapping
+
+from jsonschema import Draft202012Validator
+from jsonschema.exceptions import SchemaError
+
+from muse_role import canonical_json, format_schema_contract, sha256_json, sha256_text
+from muse_role_contract import (
+ ROLE_CONTRACT_RELATIVE_PATH,
+ ROLE_CONTRACT_VERSION,
+ ROLE_NAMES,
+ RoleContract,
+ RoleContractError,
+ load_role_contract_catalog,
+)
+
+SPEC_VERSION = "agent-task-v1"
+SUPPORTED_AGENT_ROLES = ROLE_NAMES
+AGENT_TASK_SEPARATOR = "\n\n--- 冻结输入 ---\n"
+DEFAULT_MAX_DURATION_SECONDS = 600.0
+TOOL_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
+_SPEC_REQUIRED_KEYS = frozenset(
+ {"specVersion", "role", "taskPrompt", "input", "outputSchema", "outputSchemaId"}
+)
+_SPEC_OPTIONAL_KEYS = frozenset(
+ {"toolAllowlist", "workId", "targetChapter", "maxDurationSeconds", "inputSha256"}
+)
+_SPEC_ALLOWED_KEYS = _SPEC_REQUIRED_KEYS | _SPEC_OPTIONAL_KEYS
+
+
+def _reject_nonstandard_json_constant(value: str) -> None:
+ raise ValueError(f"JSON 不允许常量: {value}")
+
+
+class TaskSpecError(ValueError):
+ """任务包不合法:字段缺失、schema 非法、角色不受支持或哈希不符。"""
+
+
+@dataclass(frozen=True)
+class AgentTaskSpec:
+ """一次框架派发的可移植任务定义(无框架、无模型字段)。"""
+
+ role: str
+ task_prompt: str
+ input: Mapping[str, Any]
+ output_schema: Mapping[str, Any]
+ output_schema_id: str
+ tool_allowlist: tuple[str, ...]
+ work_id: int | None = None
+ target_chapter: int | None = None
+ max_duration_seconds: float = DEFAULT_MAX_DURATION_SECONDS
+ input_sha256: str | None = None
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.role, str) or self.role not in SUPPORTED_AGENT_ROLES:
+ raise TaskSpecError(
+ f"role 不受支持: {self.role!r}(可选 {sorted(SUPPORTED_AGENT_ROLES)})"
+ )
+ if not isinstance(self.task_prompt, str) or not self.task_prompt.strip():
+ raise TaskSpecError("taskPrompt 必须是非空字符串")
+ if not isinstance(self.input, Mapping):
+ raise TaskSpecError("input 必须是 JSON 对象")
+ if not isinstance(self.output_schema_id, str) or not self.output_schema_id.strip():
+ raise TaskSpecError("outputSchemaId 必须是非空字符串")
+ if len(self.output_schema_id) > 128 or any(ord(char) < 32 for char in self.output_schema_id):
+ raise TaskSpecError("outputSchemaId 超过 128 字符或含控制字符")
+ if not isinstance(self.output_schema, Mapping):
+ raise TaskSpecError("outputSchema 必须是 JSON 对象")
+ try:
+ Draft202012Validator.check_schema(self.output_schema)
+ except SchemaError as exc:
+ raise TaskSpecError(f"outputSchema 不符合 Draft 2020-12: {exc.message}") from exc
+ if not isinstance(self.tool_allowlist, tuple):
+ raise TaskSpecError("toolAllowlist 必须是字符串数组")
+ for tool in self.tool_allowlist:
+ if not isinstance(tool, str) or TOOL_NAME_PATTERN.fullmatch(tool) is None:
+ raise TaskSpecError(f"toolAllowlist 含非法工具名: {tool!r}")
+ if len(set(self.tool_allowlist)) != len(self.tool_allowlist):
+ raise TaskSpecError("toolAllowlist 不得含重复工具名")
+ if (
+ isinstance(self.max_duration_seconds, bool)
+ or not isinstance(self.max_duration_seconds, (int, float))
+ or not math.isfinite(float(self.max_duration_seconds))
+ or self.max_duration_seconds <= 0
+ ):
+ raise TaskSpecError("maxDurationSeconds 必须是正的有限数")
+ if self.work_id is not None and (
+ isinstance(self.work_id, bool) or not isinstance(self.work_id, int) or self.work_id <= 0
+ ):
+ raise TaskSpecError("workId 必须是正整数")
+ if self.target_chapter is not None and (
+ isinstance(self.target_chapter, bool)
+ or not isinstance(self.target_chapter, int)
+ or self.target_chapter <= 0
+ ):
+ raise TaskSpecError("targetChapter 必须是正整数")
+
+ @property
+ def canonical_input_sha256(self) -> str:
+ return sha256_json(self.input)
+
+
+def load_spec(path: str | Path) -> AgentTaskSpec:
+ """从 JSON 文件加载并校验任务包;解析、字段或哈希异常统一失败关闭。"""
+
+ try:
+ raw = json.loads(
+ Path(path).read_text(encoding="utf-8"),
+ parse_constant=_reject_nonstandard_json_constant,
+ )
+ except (OSError, UnicodeError, ValueError) as exc:
+ raise TaskSpecError(f"spec 文件不可读或不是合法 JSON: {type(exc).__name__}") from exc
+ if not isinstance(raw, Mapping):
+ raise TaskSpecError("spec 文件必须是 JSON 对象")
+ missing = sorted(_SPEC_REQUIRED_KEYS - set(raw))
+ if missing:
+ raise TaskSpecError(f"spec 缺少必填字段: {', '.join(missing)}")
+ unknown = sorted(set(raw) - _SPEC_ALLOWED_KEYS)
+ if unknown:
+ raise TaskSpecError(f"spec 含未知字段: {', '.join(unknown)}")
+ if raw.get("specVersion") != SPEC_VERSION:
+ raise TaskSpecError(f"specVersion 必须是 {SPEC_VERSION}")
+ tools = raw.get("toolAllowlist", [])
+ if not isinstance(tools, list):
+ raise TaskSpecError("toolAllowlist 必须是字符串数组")
+ duration = raw.get("maxDurationSeconds", DEFAULT_MAX_DURATION_SECONDS)
+ spec = AgentTaskSpec(
+ role=raw["role"],
+ task_prompt=raw["taskPrompt"],
+ input=raw["input"],
+ output_schema=raw["outputSchema"],
+ output_schema_id=raw["outputSchemaId"],
+ tool_allowlist=tuple(tools),
+ work_id=raw.get("workId"),
+ target_chapter=raw.get("targetChapter"),
+ max_duration_seconds=duration,
+ input_sha256=raw.get("inputSha256"),
+ )
+ if spec.input_sha256 is not None and spec.input_sha256 != spec.canonical_input_sha256:
+ raise TaskSpecError("inputSha256 与 input 内容不一致")
+ return spec
+
+
+@dataclass(frozen=True)
+class TaskPackage:
+ """框架适配器实际消费的执行材料(与框架无关)。"""
+
+ spec: AgentTaskSpec
+ role_prompt: str
+ role_contract: RoleContract
+ system_prompt: str
+ system_prompt_sha256: str
+ user_message: str
+ user_message_sha256: str
+ input_sha256: str
+ spec_sha256: str
+
+ def as_identity(self) -> dict[str, Any]:
+ """给回执/事件用的身份摘要(不含正文)。"""
+
+ return {
+ "role": self.spec.role,
+ "roleContractVersion": ROLE_CONTRACT_VERSION,
+ "roleContractSha256": self.role_contract.contract_sha256,
+ "roleContractSource": ROLE_CONTRACT_RELATIVE_PATH.as_posix(),
+ "outputSchemaId": self.spec.output_schema_id,
+ "outputSchemaSha256": sha256_json(self.spec.output_schema),
+ "systemPromptSha256": self.system_prompt_sha256,
+ "userMessageSha256": self.user_message_sha256,
+ "inputSha256": self.input_sha256,
+ "specSha256": self.spec_sha256,
+ "toolAllowlist": list(self.spec.tool_allowlist),
+ }
+
+
+def role_prompt_path(repo_root: str | Path, role: str) -> Path:
+ """角色文件路径由角色名单一决定,杜绝任意路径注入。"""
+
+ return Path(repo_root) / ".agent" / "agents" / f"{role}.md"
+
+
+def build_task_package(spec: AgentTaskSpec, repo_root: str | Path) -> TaskPackage:
+ """装配身份提示、中心角色合同、功能合同与冻结输入。"""
+
+ root = Path(repo_root)
+ try:
+ catalog = load_role_contract_catalog(root)
+ role_contract = catalog.for_role(spec.role)
+ except RoleContractError as exc:
+ raise TaskSpecError(f"角色合同不可用: {type(exc).__name__}") from exc
+ path = role_prompt_path(root, spec.role)
+ if not path.is_file():
+ raise TaskSpecError(f"角色文件不存在: {path}")
+ role_prompt = path.read_text(encoding="utf-8")
+ if not role_prompt.strip():
+ raise TaskSpecError(f"角色文件为空: {path}")
+ system_prompt = (
+ role_prompt.rstrip()
+ + "\n\n--- 角色合同(唯一事实源) ---\n"
+ + role_contract.contract_prompt
+ + format_schema_contract(spec.output_schema)
+ )
+ user_message = spec.task_prompt.strip() + AGENT_TASK_SEPARATOR + canonical_json(spec.input)
+ return TaskPackage(
+ spec=spec,
+ role_prompt=role_prompt,
+ role_contract=role_contract,
+ system_prompt=system_prompt,
+ system_prompt_sha256=sha256_text(system_prompt),
+ user_message=user_message,
+ user_message_sha256=sha256_text(user_message),
+ input_sha256=spec.canonical_input_sha256,
+ spec_sha256=sha256_json(
+ {
+ "specVersion": SPEC_VERSION,
+ "role": spec.role,
+ "roleContractVersion": catalog.version,
+ "roleContractSha256": role_contract.contract_sha256,
+ "taskPrompt": spec.task_prompt.strip(),
+ "input": spec.input,
+ "outputSchema": spec.output_schema,
+ "outputSchemaId": spec.output_schema_id,
+ "toolAllowlist": list(spec.tool_allowlist),
+ "workId": spec.work_id,
+ "targetChapter": spec.target_chapter,
+ "maxDurationSeconds": float(spec.max_duration_seconds),
+ }
+ ),
+ )
+
+
+class OutputInvalidError(ValueError):
+ """框架最终输出未通过结构化合同。"""
+
+
+def validate_structured_output(final_text: str, spec: AgentTaskSpec) -> dict[str, Any]:
+ """抽取 JSON 并按冻结 schema 校验;失败抛 OutputInvalidError(失败关闭)。"""
+
+ from muse_llm import extract_json
+
+ try:
+ extracted = extract_json(final_text)
+ Draft202012Validator(spec.output_schema).validate(extracted)
+ except Exception as exc: # noqa: BLE001 - 任何解析/校验失败都统一失败关闭
+ raise OutputInvalidError(f"结构化输出不满足 {spec.output_schema_id}: {type(exc).__name__}") from exc
+ if not isinstance(extracted, Mapping):
+ raise OutputInvalidError("结构化输出必须是 JSON 对象")
+ return dict(extracted)
+
+
+__all__ = [
+ "AGENT_TASK_SEPARATOR",
+ "AgentTaskSpec",
+ "DEFAULT_MAX_DURATION_SECONDS",
+ "OutputInvalidError",
+ "SPEC_VERSION",
+ "SUPPORTED_AGENT_ROLES",
+ "TaskPackage",
+ "TaskSpecError",
+ "TOOL_NAME_PATTERN",
+ "build_task_package",
+ "load_spec",
+ "role_prompt_path",
+ "validate_structured_output",
+]
diff --git a/.agent/skills/dispatch-agent-task/scripts/dispatch_agent_task.py b/.agent/skills/dispatch-agent-task/scripts/dispatch_agent_task.py
new file mode 100644
index 0000000..513eec7
--- /dev/null
+++ b/.agent/skills/dispatch-agent-task/scripts/dispatch_agent_task.py
@@ -0,0 +1,642 @@
+#!/usr/bin/env python3
+"""dispatch-agent-task CLI:把冻结任务包派发给 Agent 框架子代理并自动留痕。
+
+流程(全部失败关闭):
+加载 spec -> 装配任务包 -> 登记 example_run -> 事件账本 run.started ->
+框架适配器执行(事件流逐条入账)-> 证据原子落库(raw + 逐回合 llm_call)
+-> 结构化输出校验 -> run.completed + 运行终态 -> 打印回执。
+
+业务决策(补证、重写、下一步)不属于本入口:那是框架里模型的事。
+用法:
+ .venv/bin/python dispatch_agent_task.py --spec task.json \
+ [--provider P] [--model M] [--thinking low] [--run-id ID]
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+from dataclasses import replace
+from pathlib import Path
+from typing import Any, Callable, Iterable, Mapping
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+if str(SCRIPT_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPT_DIR))
+EVIDENCE_DIR = (SCRIPT_DIR.parent.parent / "record-run-evidence" / "scripts").resolve()
+if str(EVIDENCE_DIR) not in sys.path:
+ sys.path.insert(0, str(EVIDENCE_DIR))
+
+from agent_task import ( # noqa: E402
+ AgentTaskSpec,
+ OutputInvalidError,
+ TaskSpecError,
+ build_task_package,
+ load_spec,
+ validate_structured_output,
+)
+from agent_trace import AgentTraceWriter, persist_agent_evidence # noqa: E402
+from persist_raw import _check_no_secrets # noqa: E402
+from pi_runner import ( # noqa: E402
+ DEFAULT_PI_BIN,
+ AgentStreamOutcome,
+ ExecutionPolicy,
+ FrameworkError,
+ PiAgentRunner,
+)
+from run_registry import finish_run, new_run_id, start_run # noqa: E402
+
+EXIT_OK = 0
+EXIT_SPEC_INVALID = 2
+EXIT_FRAMEWORK_FAILED = 3
+EXIT_OUTPUT_INVALID = 4
+EXIT_EVIDENCE_FAILED = 5
+
+DEFAULT_RUN_DIR_ROOT = Path("/tmp/muse-agent-runs")
+_RUN_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
+
+
+def _usage_int(usage: Mapping[str, Any], *keys: str) -> int:
+ """读取第一种存在的 usage 字段;脏值与负值按 0 聚合。"""
+
+ for key in keys:
+ if key not in usage:
+ continue
+ try:
+ return max(0, int(usage.get(key) or 0))
+ except (TypeError, ValueError, OverflowError):
+ return 0
+ return 0
+
+
+def _usage_totals(outcome: AgentStreamOutcome) -> dict[str, int]:
+ """聚合全部模型回合的 token 用量;输入口径包含 cache 读写。"""
+
+ totals = {"inputTokens": 0, "outputTokens": 0, "cachedTokens": 0, "reasoningTokens": 0}
+ for call in outcome.model_calls:
+ usage = call.usage or {}
+ cached = _usage_int(usage, "cacheRead", "cache_read_input_tokens", "cached_tokens")
+ cache_write = _usage_int(usage, "cacheWrite", "cache_creation_input_tokens")
+ totals["inputTokens"] += _usage_int(usage, "input", "input_tokens", "prompt_tokens") + cached + cache_write
+ totals["outputTokens"] += _usage_int(usage, "output", "output_tokens", "completion_tokens")
+ totals["cachedTokens"] += cached
+ totals["reasoningTokens"] += _usage_int(usage, "reasoning", "reasoning_tokens")
+ return totals
+
+
+def _cost_totals(outcome: AgentStreamOutcome) -> tuple[float | None, bool]:
+ """已知成本求和;任一回合未知则 total 为 None 并标记不完整。"""
+
+ known: list[float] = []
+ complete = True
+ for call in outcome.model_calls:
+ if call.cost_usd is None:
+ complete = False
+ else:
+ known.append(call.cost_usd)
+ if not complete:
+ return (sum(known) if known else None), False
+ return sum(known), True
+
+
+def _model_call_rows(outcome: AgentStreamOutcome) -> list[dict[str, Any]]:
+ """把模型回合映射成 agent_trace.persist_agent_evidence 的输入行。"""
+
+ return [
+ {
+ "actual_model_id": call.actual_model_id,
+ "usage": dict(call.usage or {}),
+ "stop_reason": call.stop_reason,
+ "cost_usd": call.cost_usd,
+ "duration_ms": None,
+ }
+ for call in outcome.model_calls
+ ]
+
+
+def _write_private_text(path: Path, text: str) -> None:
+ """创建仅当前用户可读写的运行审计文件。"""
+
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ handle.write(text)
+ path.chmod(0o600)
+
+
+def _write_private_json(path: Path, value: Mapping[str, Any]) -> None:
+ _write_private_text(path, json.dumps(value, ensure_ascii=False, indent=2))
+
+
+def run_dispatch(
+ spec_path: str | Path,
+ *,
+ repo_root: str | Path,
+ policy: ExecutionPolicy,
+ run_id: str | None = None,
+ run_dir: str | Path | None = None,
+ connect_factory: Callable[..., Any] | None = None,
+ launcher: Callable[..., Iterable] | None = None,
+ trigger_source: str = "user",
+ trigger_detail: Mapping[str, Any] | None = None,
+) -> tuple[dict[str, Any], int]:
+ """执行一次完整派发;所有可控失败都返回稳定回执与退出码。"""
+
+ repo_root_path = Path(repo_root).resolve()
+ try:
+ spec = load_spec(spec_path)
+ package = build_task_package(spec, repo_root_path)
+ _check_no_secrets(package.system_prompt)
+ _check_no_secrets(package.user_message)
+ except TaskSpecError as exc:
+ return (
+ {
+ "status": "failed",
+ "errorCode": "SPEC_INVALID",
+ "error": str(exc),
+ "specPath": str(spec_path),
+ },
+ EXIT_SPEC_INVALID,
+ )
+ except ValueError as exc:
+ return (
+ {
+ "status": "failed",
+ "errorCode": "SPEC_INVALID",
+ "error": f"任务包不可安全执行: {type(exc).__name__}",
+ "specPath": str(spec_path),
+ },
+ EXIT_SPEC_INVALID,
+ )
+
+ effective_policy = replace(policy, cwd=policy.cwd or str(repo_root_path))
+ if (
+ package.role_contract.model_policy == "fixed-opus"
+ and "opus" not in effective_policy.model.lower()
+ ):
+ return (
+ {
+ "status": "failed",
+ "errorCode": "ROLE_MODEL_POLICY_MISMATCH",
+ "error": f"角色 {spec.role} 要求 fixed-opus 模型策略",
+ "requestedModelId": effective_policy.requested_model_id,
+ },
+ EXIT_SPEC_INVALID,
+ )
+ run_id = run_id or new_run_id(
+ f"agent-{spec.role}", work_id=spec.work_id, target_chapter=spec.target_chapter
+ )
+ if not isinstance(run_id, str) or _RUN_ID_PATTERN.fullmatch(run_id) is None:
+ return (
+ {
+ "status": "failed",
+ "errorCode": "RUN_ID_INVALID",
+ "error": "run_id 只能包含 ASCII 字母、数字、点、下划线和短横线,长度不超过 64",
+ },
+ EXIT_SPEC_INVALID,
+ )
+ run_dir_path = Path(run_dir) if run_dir is not None else DEFAULT_RUN_DIR_ROOT / run_id
+ identity = package.as_identity()
+
+ def _receipt(**fields: Any) -> dict[str, Any]:
+ base = {
+ "runId": run_id,
+ "role": spec.role,
+ "framework": effective_policy.framework,
+ "requestedModelId": effective_policy.requested_model_id,
+ "outputSchemaId": spec.output_schema_id,
+ "runDir": str(run_dir_path),
+ **identity,
+ }
+ base.update(fields)
+ return base
+
+ try:
+ if run_dir is None:
+ DEFAULT_RUN_DIR_ROOT.mkdir(parents=True, mode=0o700, exist_ok=True)
+ DEFAULT_RUN_DIR_ROOT.chmod(0o700)
+ run_dir_path.mkdir(parents=True, mode=0o700, exist_ok=False)
+ run_dir_path.chmod(0o700)
+ _write_private_text(
+ run_dir_path / "task-spec.json", Path(spec_path).read_text(encoding="utf-8")
+ )
+ _write_private_text(run_dir_path / "system-prompt.txt", package.system_prompt)
+ _write_private_text(run_dir_path / "user-message.txt", package.user_message)
+ except (OSError, UnicodeError) as exc:
+ return (
+ _receipt(
+ status="failed",
+ errorCode="AUDIT_WRITE_FAILED",
+ error=f"运行审计目录写入失败: {type(exc).__name__}",
+ ),
+ EXIT_EVIDENCE_FAILED,
+ )
+
+ try:
+ detail = dict(trigger_detail or {})
+ except (TypeError, ValueError) as exc:
+ receipt = _receipt(
+ status="failed",
+ errorCode="TRIGGER_DETAIL_INVALID",
+ error=f"trigger_detail 不是对象: {type(exc).__name__}",
+ )
+ _write_private_json(run_dir_path / "receipt.json", receipt)
+ return receipt, EXIT_SPEC_INVALID
+ detail.setdefault(
+ "dispatch",
+ {
+ "framework": effective_policy.framework,
+ "requestedModelId": effective_policy.requested_model_id,
+ "specSha256": package.spec_sha256,
+ },
+ )
+ try:
+ detail_json = json.dumps(
+ detail, ensure_ascii=False, sort_keys=True, default=str, allow_nan=False
+ )
+ _check_no_secrets(detail_json)
+ except (TypeError, ValueError) as exc:
+ receipt = _receipt(
+ status="failed",
+ errorCode="TRIGGER_DETAIL_INVALID",
+ error=f"trigger_detail 不可安全记录: {type(exc).__name__}",
+ )
+ _write_private_json(run_dir_path / "receipt.json", receipt)
+ return receipt, EXIT_SPEC_INVALID
+ try:
+ run_record = start_run(
+ connect=connect_factory,
+ run_id=run_id,
+ work_id=spec.work_id,
+ target_chapter=spec.target_chapter,
+ trigger_source=trigger_source,
+ trigger_detail=detail,
+ )
+ except Exception as exc: # noqa: BLE001 - 注册失败不能启动外部 Agent。
+ receipt = _receipt(
+ status="failed",
+ errorCode="RUN_REGISTRY_START_FAILED",
+ error=f"运行登记失败: {type(exc).__name__}",
+ )
+ _write_private_json(run_dir_path / "receipt.json", receipt)
+ return receipt, EXIT_EVIDENCE_FAILED
+ if run_record["status"] != "started":
+ receipt = _receipt(
+ status="failed",
+ errorCode="RUN_ID_EXISTS",
+ error="run_id 已存在,拒绝覆盖既有运行证据",
+ )
+ _write_private_json(run_dir_path / "receipt.json", receipt)
+ return receipt, EXIT_EVIDENCE_FAILED
+
+ writer = AgentTraceWriter(
+ run_id=run_id,
+ framework=effective_policy.framework,
+ agent_role=spec.role,
+ connect=connect_factory,
+ )
+ runner = PiAgentRunner(launcher=launcher)
+
+ def _failed(
+ error_code: str,
+ error: str,
+ exit_code: int,
+ *,
+ agent_failed: bool = False,
+ session_id: str | None = None,
+ final_message: str | None = None,
+ evidence: Mapping[str, Any] | None = None,
+ cause_error_code: str | None = None,
+ ) -> tuple[dict[str, Any], int]:
+ """尽力闭合失败终态;留痕本身失败时升级为证据错误,保留原始原因码。"""
+
+ failures: list[str] = []
+ if final_message is not None:
+ try:
+ _write_private_text(run_dir_path / "final-message.txt", final_message)
+ except OSError as exc:
+ failures.append(type(exc).__name__)
+ if agent_failed:
+ try:
+ writer.emit(
+ "agent.failed",
+ status="error",
+ requested_model_id=effective_policy.requested_model_id,
+ details={"errorCode": error_code},
+ )
+ except Exception as exc: # noqa: BLE001 - 继续尝试写 run.failed/终态。
+ failures.append(type(exc).__name__)
+ try:
+ writer.emit(
+ "run.failed",
+ status="error",
+ requested_model_id=effective_policy.requested_model_id,
+ raw_ref=(evidence or {}).get("transcriptId"),
+ details={"errorCode": error_code},
+ )
+ except Exception as exc: # noqa: BLE001 - 继续尝试闭合 example_run。
+ failures.append(type(exc).__name__)
+ try:
+ finish_run(
+ run_id,
+ "failed",
+ trigger_detail={"errorCode": error_code},
+ connect=connect_factory,
+ )
+ except Exception as exc: # noqa: BLE001 - 回执必须揭示终态未能闭合。
+ failures.append(type(exc).__name__)
+
+ fields: dict[str, Any] = {
+ "status": "failed",
+ "errorCode": error_code,
+ "error": error,
+ "sessionId": session_id,
+ }
+ if evidence is not None:
+ fields["evidence"] = dict(evidence)
+ if cause_error_code is not None:
+ fields["causeErrorCode"] = cause_error_code
+ if failures:
+ fields.update(
+ {
+ "causeErrorCode": cause_error_code or error_code,
+ "errorCode": "EVIDENCE_PERSIST_FAILED",
+ "error": "失败终态留痕未完整写入",
+ "finalizationErrorTypes": sorted(set(failures)),
+ }
+ )
+ exit_code = EXIT_EVIDENCE_FAILED
+ receipt = _receipt(**fields)
+ try:
+ _write_private_json(run_dir_path / "receipt.json", receipt)
+ except OSError:
+ receipt["receiptFileWritten"] = False
+ exit_code = EXIT_EVIDENCE_FAILED
+ return receipt, exit_code
+
+ try:
+ writer.emit(
+ "run.started",
+ status="ok",
+ requested_model_id=effective_policy.requested_model_id,
+ details={"specSha256": package.spec_sha256, "toolAllowlist": list(spec.tool_allowlist)},
+ )
+ except Exception as exc: # noqa: BLE001 - 未留起始事件时不得启动框架。
+ return _failed(
+ "EVIDENCE_PERSIST_FAILED",
+ f"运行起始事件写入失败: {type(exc).__name__}",
+ EXIT_EVIDENCE_FAILED,
+ )
+
+ transcript_path = run_dir_path / "transcript.jsonl"
+
+ def _remove_local_raw() -> None:
+ for path in (transcript_path, run_dir_path / "final-message.txt"):
+ try:
+ path.unlink(missing_ok=True)
+ except OSError:
+ pass
+
+ def _persist_failure_evidence(
+ failed_outcome: AgentStreamOutcome | None,
+ ) -> tuple[dict[str, Any] | None, str | None]:
+ """失败也尽量把已有转录和模型回合写入同一套 raw 证据。"""
+
+ if failed_outcome is None:
+ return None, None
+ try:
+ transcript_text = transcript_path.read_text(encoding="utf-8")
+ except (OSError, UnicodeError):
+ return None, "EVIDENCE_PERSIST_FAILED"
+ if not transcript_text.strip():
+ return None, None
+ try:
+ _check_no_secrets(transcript_text)
+ except ValueError:
+ _remove_local_raw()
+ return None, "RAW_SECRET_DETECTED"
+ try:
+ evidence_result = persist_agent_evidence(
+ run_id=run_id,
+ agent_role=spec.role,
+ system_prompt=package.system_prompt,
+ user_message=package.user_message,
+ final_message=failed_outcome.final_text,
+ transcript=transcript_text,
+ model_calls=_model_call_rows(failed_outcome),
+ requested_model_id=effective_policy.requested_model_id,
+ connect=connect_factory,
+ )
+ return evidence_result, None
+ except Exception:
+ return None, "EVIDENCE_PERSIST_FAILED"
+
+ try:
+ transcript_fd = os.open(
+ transcript_path,
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
+ 0o600,
+ )
+ with os.fdopen(transcript_fd, "wb") as transcript_file:
+ outcome = runner.run(
+ package,
+ effective_policy,
+ writer,
+ timeout_seconds=spec.max_duration_seconds,
+ raw_sink=transcript_file.write,
+ )
+ transcript_path.chmod(0o600)
+ except FrameworkError as exc:
+ failed_outcome = exc.outcome
+ failure_evidence, evidence_error = _persist_failure_evidence(failed_outcome)
+ if evidence_error is not None:
+ return _failed(
+ evidence_error,
+ "框架失败证据未能安全落库",
+ EXIT_EVIDENCE_FAILED,
+ agent_failed=True,
+ session_id=failed_outcome.session_id if failed_outcome else None,
+ cause_error_code=exc.error_code,
+ )
+ return _failed(
+ exc.error_code,
+ str(exc),
+ EXIT_FRAMEWORK_FAILED,
+ agent_failed=True,
+ session_id=failed_outcome.session_id if failed_outcome else None,
+ final_message=failed_outcome.final_text if failed_outcome else None,
+ evidence=failure_evidence,
+ )
+ except Exception as exc: # noqa: BLE001 - 事件/本地转录失败属于证据失败。
+ return _failed(
+ "EVIDENCE_PERSIST_FAILED",
+ f"框架执行留痕失败: {type(exc).__name__}",
+ EXIT_EVIDENCE_FAILED,
+ agent_failed=True,
+ )
+
+ try:
+ transcript_text = transcript_path.read_text(encoding="utf-8")
+ _check_no_secrets(transcript_text)
+ except ValueError as exc:
+ _remove_local_raw()
+ return _failed(
+ "RAW_SECRET_DETECTED",
+ "框架转录含疑似凭据,已拒绝留存",
+ EXIT_EVIDENCE_FAILED,
+ )
+ except (OSError, UnicodeError) as exc:
+ return _failed(
+ "EVIDENCE_PERSIST_FAILED",
+ f"框架转录回读失败: {type(exc).__name__}",
+ EXIT_EVIDENCE_FAILED,
+ session_id=outcome.session_id,
+ )
+ if not transcript_text.strip():
+ return _failed(
+ "TRANSCRIPT_EMPTY",
+ "框架转录为空",
+ EXIT_FRAMEWORK_FAILED,
+ session_id=outcome.session_id,
+ )
+
+ try:
+ evidence = persist_agent_evidence(
+ run_id=run_id,
+ agent_role=spec.role,
+ system_prompt=package.system_prompt,
+ user_message=package.user_message,
+ final_message=outcome.final_text,
+ transcript=transcript_text,
+ model_calls=_model_call_rows(outcome),
+ requested_model_id=effective_policy.requested_model_id,
+ connect=connect_factory,
+ )
+ except Exception as exc: # noqa: BLE001 - 模型成功但证据失败时必须失败关闭。
+ return _failed(
+ "EVIDENCE_PERSIST_FAILED",
+ f"框架证据落库失败: {type(exc).__name__}",
+ EXIT_EVIDENCE_FAILED,
+ session_id=outcome.session_id,
+ final_message=outcome.final_text,
+ )
+
+ try:
+ structured = validate_structured_output(outcome.final_text or "", spec)
+ except OutputInvalidError as exc:
+ return _failed(
+ "OUTPUT_SCHEMA_INVALID",
+ str(exc),
+ EXIT_OUTPUT_INVALID,
+ session_id=outcome.session_id,
+ final_message=outcome.final_text,
+ evidence=evidence,
+ )
+
+ try:
+ _write_private_text(run_dir_path / "final-message.txt", outcome.final_text or "")
+ _write_private_json(run_dir_path / "output.json", structured)
+ 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:
+ writer.emit(
+ "run.completed",
+ status="ok",
+ requested_model_id=effective_policy.requested_model_id,
+ actual_model_id=outcome.model_calls[-1].actual_model_id,
+ usage={
+ "input": usage["inputTokens"] - usage["cachedTokens"],
+ "output": usage["outputTokens"],
+ "cacheRead": usage["cachedTokens"],
+ },
+ cost_usd=total_cost,
+ raw_ref=evidence.get("transcriptId"),
+ details={
+ "sessionId": outcome.session_id,
+ "turns": outcome.turns,
+ "toolCalls": len(outcome.tool_calls),
+ "costComplete": cost_complete,
+ "leaseId": evidence.get("leaseId"),
+ "llmCallIds": evidence.get("llmCallIds"),
+ },
+ )
+ finish_run(run_id, "completed", connect=connect_factory)
+ except Exception as exc: # noqa: BLE001 - 成功终态与终态事件必须一起可见。
+ return _failed(
+ "RUN_FINALIZE_FAILED",
+ f"成功终态写入失败: {type(exc).__name__}",
+ EXIT_EVIDENCE_FAILED,
+ session_id=outcome.session_id,
+ evidence=evidence,
+ )
+
+ receipt = _receipt(
+ status="completed",
+ sessionId=outcome.session_id,
+ durationMs=outcome.duration_ms,
+ turns=outcome.turns,
+ toolCallCount=len(outcome.tool_calls),
+ modelCallCount=len(outcome.model_calls),
+ actualModelIds=[call.actual_model_id for call in outcome.model_calls],
+ usage=usage,
+ totalCostUsd=round(total_cost, 6) if total_cost is not None else None,
+ costComplete=cost_complete,
+ finalMessageSha256=_sha256_bare(outcome.final_text or ""),
+ structuredOutputSha256=_sha256_bare(
+ json.dumps(structured, ensure_ascii=False, sort_keys=True)
+ ),
+ evidence=evidence,
+ )
+ try:
+ _write_private_json(run_dir_path / "receipt.json", receipt)
+ except OSError:
+ receipt["receiptFileWritten"] = False
+ return receipt, EXIT_OK
+
+
+def _sha256_bare(text: str) -> str:
+ import hashlib
+
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="把冻结任务包派发给 Agent 框架子代理并自动留痕")
+ parser.add_argument("--spec", required=True, help="AgentTaskSpec JSON 文件路径")
+ parser.add_argument("--provider", required=True, help="显式框架 provider")
+ parser.add_argument("--model", required=True, help="显式框架模型")
+ parser.add_argument("--thinking", default=None, help="思考等级 off/low/medium/high")
+ parser.add_argument("--pi-bin", default=DEFAULT_PI_BIN, help="框架二进制(默认 pi)")
+ parser.add_argument("--repo-root", default=".", help="仓库根(解析 .agent/agents 角色文件)")
+ parser.add_argument("--run-id", default=None, help="指定 run_id(默认自动生成)")
+ parser.add_argument("--run-dir", default=None, help="运行目录(默认 /tmp/muse-agent-runs/)")
+ parser.add_argument("--trigger-source", default="user", choices=["user", "replay_eval", "diagnostic"])
+ args = parser.parse_args(argv)
+
+ policy = ExecutionPolicy(
+ provider=args.provider, model=args.model, thinking=args.thinking, pi_bin=args.pi_bin
+ )
+ receipt, code = run_dispatch(
+ args.spec,
+ repo_root=args.repo_root,
+ policy=policy,
+ run_id=args.run_id,
+ run_dir=args.run_dir,
+ trigger_source=args.trigger_source,
+ )
+ print(json.dumps(receipt, ensure_ascii=False, indent=2))
+ return code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.agent/skills/dispatch-agent-task/scripts/pi_runner.py b/.agent/skills/dispatch-agent-task/scripts/pi_runner.py
new file mode 100644
index 0000000..bfea6d0
--- /dev/null
+++ b/.agent/skills/dispatch-agent-task/scripts/pi_runner.py
@@ -0,0 +1,428 @@
+#!/usr/bin/env python3
+"""pi 框架适配器:把任务包派发为 pi 子代理并归一其 JSON 事件流。
+
+这是全仓唯一直接调用 Agent 框架二进制的位置(架构门禁
+tests/architecture/test_import_boundaries.py 白名单)。适配器只做三件事:
+构造 argv(角色 prompt 注入 + 工具白名单 + 隔离上下文)、逐行消费框架事件流、
+把事件归一转发给 TraceWriter。它不含任何业务决策:补证、重写、下一步做什么
+全部属于框架里的模型,不属于本模块。
+
+执行策略(provider/model/thinking)由派发方给定并如实记账;框架把模型模式解析为
+完整模型 ID,匹配口径见 agent_trace.model_ids_match。超时用看门狗线程杀进程:
+阻塞读 stdout 不会自己抛超时,挂死的框架进程必须被强制终止才能失败关闭。
+"""
+from __future__ import annotations
+
+import json
+import subprocess
+import threading
+import time
+from dataclasses import dataclass, field
+from typing import Any, Callable, Iterable, Iterator, Mapping, Sequence
+
+from agent_task import TaskPackage
+from agent_trace import AgentTraceWriter
+
+DEFAULT_FRAMEWORK = "pi"
+DEFAULT_PI_BIN = "pi"
+# 事件流单行上限:防御性截断,正常 JSONL 行远小于此。
+MAX_STREAM_LINE_BYTES = 8 * 1024 * 1024
+
+
+class FrameworkError(RuntimeError):
+ """框架执行失败:超时、非零退出或事件流不可解析。"""
+
+ def __init__(
+ self,
+ error_code: str,
+ message: str,
+ *,
+ outcome: "AgentStreamOutcome | None" = None,
+ ) -> None:
+ super().__init__(message)
+ self.error_code = error_code
+ self.outcome = outcome
+
+
+@dataclass(frozen=True)
+class ExecutionPolicy:
+ """框架侧执行策略;provider 与 model 必须由调用方显式传入。"""
+
+ provider: str | None = None
+ model: str | None = None
+ thinking: str | None = None
+ pi_bin: str = DEFAULT_PI_BIN
+ cwd: str | None = None
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.provider, str) or not self.provider.strip():
+ raise ValueError("provider 必须显式传入")
+ if not isinstance(self.model, str) or not self.model.strip():
+ raise ValueError("model 必须显式传入")
+ if self.thinking is not None and self.thinking not in {
+ "off", "minimal", "low", "medium", "high", "xhigh", "max"
+ }:
+ raise ValueError("thinking 不受支持")
+
+ @property
+ def framework(self) -> str:
+ return DEFAULT_FRAMEWORK
+
+ @property
+ def requested_model_id(self) -> str:
+ """账本口径的显式请求模型。"""
+
+ return f"{self.provider}/{self.model}"
+
+
+@dataclass(frozen=True)
+class ModelCall:
+ """一个模型回合的账本投影材料(usage 为框架归一后的原始字典)。"""
+
+ actual_model_id: str
+ provider: str | None
+ usage: Mapping[str, Any]
+ stop_reason: str | None
+ cost_usd: float | None
+
+
+@dataclass
+class ToolCallRecord:
+ tool_call_id: str
+ name: str
+ is_error: bool = False
+
+
+@dataclass
+class AgentStreamOutcome:
+ """框架子代理一次执行的客观结果(不含业务判断)。"""
+
+ session_id: str | None = None
+ exit_code: int | None = None
+ timed_out: bool = False
+ final_text: str | None = None
+ model_calls: list[ModelCall] = field(default_factory=list)
+ tool_calls: list[ToolCallRecord] = field(default_factory=list)
+ turns: int = 0
+ parse_error_lines: int = 0
+ duration_ms: int = 0
+
+
+def build_pi_argv(package: TaskPackage, policy: ExecutionPolicy) -> list[str]:
+ """构造 pi 子代理 argv:system prompt 注入、工具白名单、上下文隔离。"""
+
+ argv = [policy.pi_bin, "--print", "--mode", "json", "--no-session"]
+ argv += ["--provider", policy.provider, "--model", policy.model]
+ if policy.thinking:
+ argv += ["--thinking", policy.thinking]
+ # 上下文隔离:不加载项目 AGENTS.md/skills/extensions,角色合同全部来自任务包。
+ argv += ["--no-context-files", "--no-skills", "--no-extensions", "--no-approve"]
+ allowlist = package.spec.tool_allowlist
+ if allowlist:
+ argv += ["--tools", ",".join(allowlist)]
+ else:
+ argv += ["--no-tools"]
+ argv += ["--system-prompt", package.system_prompt, package.user_message]
+ return argv
+
+
+def _message_text(message: Mapping[str, Any]) -> str:
+ """提取消息中的全部文本块(跳过 thinking/tool_call 块)。"""
+
+ parts: list[str] = []
+ for block in message.get("content") or []:
+ if isinstance(block, Mapping) and block.get("type") == "text":
+ parts.append(str(block.get("text") or ""))
+ return "".join(parts)
+
+
+def _qualified_model_id(provider: Any, model: Any) -> str:
+ """把 pi 分开的 provider/model 字段合成账本要求的完整模型 ID。"""
+
+ model_id = str(model or "").strip()
+ provider_id = str(provider or "").strip()
+ if not model_id or "/" in model_id or not provider_id:
+ return model_id
+ return f"{provider_id}/{model_id}"
+
+
+def _usage_cost(usage: Mapping[str, Any] | None) -> float | None:
+ """读取框架报告的单回合成本;供应商未定价(0/缺失)记 None,不伪造。"""
+
+ if not isinstance(usage, Mapping):
+ return None
+ cost = usage.get("cost")
+ if isinstance(cost, Mapping):
+ total = cost.get("total")
+ try:
+ return float(total) if total and float(total) > 0 else None
+ except (TypeError, ValueError):
+ return None
+ return None
+
+
+class _SubprocessStream:
+ """把 Popen stdout 包装成字节行迭代器;看门狗超时杀进程,stderr 丢弃防管道死锁。"""
+
+ def __init__(self, proc: subprocess.Popen, timeout_seconds: float) -> None:
+ self._proc = proc
+ self.exit_code: int | None = None
+ self.timed_out = False
+ self._watchdog = threading.Timer(
+ max(timeout_seconds, 0.1),
+ self._kill,
+ )
+ self._watchdog.daemon = True
+ self._watchdog.start()
+
+ def _kill(self) -> None:
+ if self._proc.poll() is None:
+ self.timed_out = True
+ self._proc.kill()
+
+ def __iter__(self) -> Iterator[bytes]:
+ assert self._proc.stdout is not None
+ try:
+ for raw_line in self._proc.stdout:
+ if len(raw_line) > MAX_STREAM_LINE_BYTES:
+ if self._proc.poll() is None:
+ self._proc.kill()
+ raise FrameworkError("STREAM_LINE_TOO_LARGE", "事件流单行超限")
+ yield raw_line
+ finally:
+ self._watchdog.cancel()
+ self.exit_code = self._proc.wait()
+ self._proc.stdout.close()
+
+ def close(self) -> None:
+ self._watchdog.cancel()
+ if self._proc.poll() is None:
+ self._proc.kill()
+ self._proc.wait()
+
+
+class PiAgentRunner:
+ """启动 pi 子代理、消费事件流并转发归一事件。"""
+
+ def __init__(self, launcher: Callable[..., Iterable[bytes]] | None = None) -> None:
+ # launcher(argv, timeout, cwd) -> 字节行迭代器(带 exit_code 属性);测试注入假 pi。
+ self._launcher = launcher
+
+ def _launch(self, argv: Sequence[str], timeout: float, cwd: str | None) -> Iterable[bytes]:
+ if self._launcher is not None:
+ return self._launcher(argv, timeout, cwd)
+ proc = subprocess.Popen(
+ list(argv),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ cwd=cwd,
+ )
+ return _SubprocessStream(proc, timeout)
+
+ def run(
+ self,
+ package: TaskPackage,
+ policy: ExecutionPolicy,
+ sink: AgentTraceWriter,
+ *,
+ timeout_seconds: float,
+ raw_sink: Callable[[bytes], None] | None = None,
+ ) -> AgentStreamOutcome:
+ """执行一次框架派发;框架层异常抛 FrameworkError(业务校验在派发器)。
+
+ raw_sink 逐行接收框架原始事件流字节(转录 tap),供派发器固定全量原始证据。
+ """
+
+ argv = build_pi_argv(package, policy)
+ outcome = AgentStreamOutcome()
+ started = time.monotonic()
+ sink.emit(
+ "agent.started",
+ status="ok",
+ requested_model_id=policy.requested_model_id,
+ details={"framework": policy.framework, "thinking": policy.thinking},
+ )
+ try:
+ stream = self._launch(argv, timeout_seconds, policy.cwd)
+ except OSError as exc:
+ raise FrameworkError(
+ "FRAMEWORK_START_FAILED", f"框架进程启动失败: {type(exc).__name__}"
+ ) from exc
+ final_message: Mapping[str, Any] | None = None
+ allowed_tools = frozenset(package.spec.tool_allowlist)
+ stream_error: FrameworkError | None = None
+ try:
+ try:
+ for raw_line in stream:
+ if raw_sink is not None:
+ try:
+ raw_sink(raw_line)
+ except OSError as exc:
+ raise FrameworkError(
+ "TRANSCRIPT_WRITE_FAILED", f"框架转录写入失败: {type(exc).__name__}"
+ ) from exc
+ line = raw_line.decode("utf-8", errors="replace").strip()
+ if not line:
+ continue
+ try:
+ event = json.loads(line)
+ except json.JSONDecodeError:
+ outcome.parse_error_lines += 1
+ continue
+ if not isinstance(event, Mapping):
+ outcome.parse_error_lines += 1
+ continue
+ self._consume(event, sink, policy, outcome, allowed_tools)
+ if event.get("type") == "agent_end":
+ messages = event.get("messages") or []
+ for message in reversed(messages):
+ if isinstance(message, Mapping) and message.get("role") == "assistant":
+ final_message = message
+ break
+ except FrameworkError as exc:
+ stream_error = exc
+ finally:
+ closer = getattr(stream, "close", None)
+ if callable(closer):
+ closer()
+ outcome.duration_ms = int((time.monotonic() - started) * 1000)
+ outcome.exit_code = getattr(stream, "exit_code", None)
+ outcome.timed_out = bool(getattr(stream, "timed_out", False))
+ outcome.final_text = _message_text(final_message) if final_message else None
+
+ if stream_error is not None:
+ stream_error.outcome = outcome
+ raise stream_error
+ if outcome.timed_out:
+ raise FrameworkError(
+ "FRAMEWORK_TIMEOUT", f"框架执行超时(>{timeout_seconds}s)", outcome=outcome
+ )
+ if outcome.exit_code != 0:
+ raise FrameworkError(
+ "FRAMEWORK_EXIT_NONZERO", f"框架进程退出码 {outcome.exit_code}", outcome=outcome
+ )
+ if outcome.parse_error_lines:
+ raise FrameworkError(
+ "STREAM_PARSE_ERROR",
+ f"事件流有 {outcome.parse_error_lines} 行不可解析",
+ outcome=outcome,
+ )
+ if not outcome.model_calls:
+ raise FrameworkError("NO_MODEL_RESPONSE", "事件流未含任何模型回合", outcome=outcome)
+ if outcome.final_text is None or not outcome.final_text.strip():
+ raise FrameworkError("EMPTY_FINAL_MESSAGE", "框架未返回最终文本", outcome=outcome)
+
+ sink.emit(
+ "agent.completed",
+ status="ok",
+ requested_model_id=policy.requested_model_id,
+ actual_model_id=outcome.model_calls[-1].actual_model_id,
+ details={
+ "sessionId": outcome.session_id,
+ "turns": outcome.turns,
+ "modelCalls": len(outcome.model_calls),
+ "toolCalls": len(outcome.tool_calls),
+ "durationMs": outcome.duration_ms,
+ },
+ )
+ return outcome
+
+ def _consume(
+ self,
+ event: Mapping[str, Any],
+ sink: AgentTraceWriter,
+ policy: ExecutionPolicy,
+ outcome: AgentStreamOutcome,
+ allowed_tools: frozenset[str],
+ ) -> None:
+ """把单个框架事件归一转发;未知事件类型静默忽略(框架可演进)。"""
+
+ kind = event.get("type")
+ if kind == "session":
+ outcome.session_id = str(event.get("id") or "") or None
+ elif kind == "turn_start":
+ outcome.turns += 1
+ elif kind == "message_end":
+ message = event.get("message") or {}
+ if message.get("role") != "assistant":
+ return
+ usage = message.get("usage") or {}
+ actual_model_id = _qualified_model_id(message.get("provider"), message.get("model"))
+ if not actual_model_id:
+ raise FrameworkError("MODEL_ID_MISSING", "模型回合缺 provider/model 身份")
+ call = ModelCall(
+ actual_model_id=actual_model_id,
+ provider=message.get("provider"),
+ usage=usage,
+ stop_reason=message.get("stopReason"),
+ cost_usd=_usage_cost(usage),
+ )
+ outcome.model_calls.append(call)
+ model_failed = call.stop_reason in {"error", "aborted"}
+ sink.emit(
+ "model.completed",
+ status="error" if model_failed else "ok",
+ requested_model_id=policy.requested_model_id,
+ actual_model_id=call.actual_model_id,
+ usage=usage,
+ cost_usd=call.cost_usd,
+ details={
+ "stopReason": call.stop_reason,
+ "provider": call.provider,
+ "sessionId": outcome.session_id,
+ "turn": outcome.turns,
+ "errorMessage": str(message.get("errorMessage") or "")[:256] if model_failed else None,
+ },
+ )
+ if model_failed:
+ raise FrameworkError(
+ "MODEL_TURN_FAILED",
+ f"模型回合结束状态为 {call.stop_reason}",
+ )
+ elif kind == "tool_execution_start":
+ name = str(event.get("toolName") or "")
+ tool_call_id = str(event.get("toolCallId") or "")
+ if not tool_call_id:
+ raise FrameworkError("TOOL_EVENT_INVALID", "工具开始事件缺 toolCallId")
+ if name not in allowed_tools:
+ raise FrameworkError("TOOL_NOT_ALLOWED", f"框架执行了未授权工具: {name or ''}")
+ outcome.tool_calls.append(ToolCallRecord(tool_call_id=tool_call_id, name=name))
+ sink.emit(
+ "tool.started",
+ status="ok",
+ tool_name=name,
+ details={"toolCallId": tool_call_id},
+ )
+ elif kind == "tool_execution_end":
+ is_error = bool(event.get("isError"))
+ name = str(event.get("toolName") or "")
+ tool_call_id = str(event.get("toolCallId") or "")
+ if not tool_call_id:
+ raise FrameworkError("TOOL_EVENT_INVALID", "工具结束事件缺 toolCallId")
+ if name not in allowed_tools:
+ raise FrameworkError("TOOL_NOT_ALLOWED", f"框架执行了未授权工具: {name or ''}")
+ matching = next(
+ (call for call in reversed(outcome.tool_calls) if call.tool_call_id == tool_call_id),
+ None,
+ )
+ if matching is None:
+ raise FrameworkError("TOOL_EVENT_INVALID", "工具结束事件缺对应开始事件")
+ matching.is_error = is_error
+ sink.emit(
+ "tool.completed",
+ status="error" if is_error else "ok",
+ tool_name=name,
+ details={"toolCallId": tool_call_id},
+ )
+
+
+__all__ = [
+ "AgentStreamOutcome",
+ "DEFAULT_PI_BIN",
+ "ExecutionPolicy",
+ "FrameworkError",
+ "ModelCall",
+ "PiAgentRunner",
+ "ToolCallRecord",
+ "build_pi_argv",
+]
diff --git a/.agent/skills/evaluate-frozen-replay/scripts/run_replay.py b/.agent/skills/evaluate-frozen-replay/scripts/run_replay.py
index 3595c97..823e504 100644
--- a/.agent/skills/evaluate-frozen-replay/scripts/run_replay.py
+++ b/.agent/skills/evaluate-frozen-replay/scripts/run_replay.py
@@ -46,6 +46,7 @@ from fine_outline_rubric import DIMENSIONS, RUBRIC_PROFILE, stability_warning, v
from writer_gate import verify_writer_gate_receipt
from persist_llm_call import persist_call as persist_llm_event
+from muse_role_contract import load_role_contract_catalog # noqa: E402
from muse_role import ( # noqa: E402
FIXED_OPUS_MODEL_ID,
FIXED_OPUS_POLICY_ALIAS,
@@ -63,6 +64,7 @@ REPO_ROOT = Path(__file__).resolve().parents[4]
SKILL_PATH = REPO_ROOT / ".agent/skills/plan-chapter/SKILL.md"
AGENTS_DIR = REPO_ROOT / ".agent" / "agents"
PLANNER_PATH = AGENTS_DIR / "planner.md"
+ROLE_CONTRACTS = load_role_contract_catalog(REPO_ROOT)
JUDGE_IDS = ("judge-primary", "judge-secondary")
DEFAULT_TIMEOUT_SECONDS = 300.0
@@ -329,10 +331,11 @@ def _output_contract(target: int) -> dict[str, Any]:
def _planner_system_prompt(*, target: int) -> str:
- """派发合同的身份侧:角色文件全文 + 功能合同 + 输出合同,由派发方注入。"""
+ """派发合同的身份侧:身份提示 + 中央角色合同 + 功能合同 + 输出合同。"""
skill = SKILL_PATH.read_text(encoding="utf-8")
identity = PLANNER_PATH.read_text(encoding="utf-8")
+ role_contract = ROLE_CONTRACTS.for_role("planner").contract_prompt
return "\n".join(
[
"这是 next_fine_outline_replay_v0 的离线规划任务。只输出一个 JSON 对象,不要 Markdown、正文或解释。",
@@ -341,6 +344,8 @@ def _planner_system_prompt(*, target: int) -> str:
"规划上下文冻结到 as_of;卡只是事实索引和补充,不得替代公共大纲与叙事现在时。",
"--- planner identity ---",
identity,
+ "--- planner role contract ---",
+ role_contract,
"--- plan-chapter Skill ---",
skill,
"--- output contract ---",
@@ -463,13 +468,15 @@ def _invoke_structured_agent(
) -> Mapping[str, Any]:
"""经 muse_role 执行一次 detector/judge,并把调用原始证据留在运行目录。
- 角色身份由派发方显式注入:角色文件(.agent/agents/{agent}.md)全文拼进
- 系统提示词,不依赖任何宿主对角色目录的自动装载。
+ 角色身份由派发方显式注入:身份提示与中央角色合同拼进系统提示词,
+ 不依赖任何宿主对角色目录的自动装载。
"""
- role_prompt = (AGENTS_DIR / f"{agent}.md").read_text(encoding="utf-8")
+ role_prompt = (AGENTS_DIR / f"{agent}.md").read_text(encoding="utf-8").rstrip()
+ role_contract = ROLE_CONTRACTS.for_role(agent).contract_prompt
system_prompt = (
- f"{role_prompt}\n\n独立身份={identity};只处理给定 JSON;禁止调用工具、读取文件或输出 JSON 以外内容。\n"
+ f"{role_prompt}\n\n--- 角色合同(唯一事实源) ---\n{role_contract}\n"
+ f"独立身份={identity};只处理给定 JSON;禁止调用工具、读取文件或输出 JSON 以外内容。\n"
f"{REPLAY_MODEL_CONSTRAINT}"
)
if agent == "detector":
diff --git a/.agent/skills/execute-role-task/SKILL.md b/.agent/skills/execute-role-task/SKILL.md
index c65e027..60ce757 100644
--- a/.agent/skills/execute-role-task/SKILL.md
+++ b/.agent/skills/execute-role-task/SKILL.md
@@ -1,12 +1,12 @@
---
name: execute-role-task
-description: 以冻结 RoleExecutionProfile 运行一次受治理的提示词角色调用,校验模型策略、期限、预算、结构和输入输出哈希并返回 RoleExecutionReceipt。writer、planner、extractor、detector 或 judge 的自动化管线需要执行角色时使用;能力探针刷新交给 refresh-runtime-probe,本 Skill 不负责保存 raw、登记运行或裁决业务结果。
+description: 以冻结 RoleExecutionProfile 运行一次不经框架的直接 HTTP 角色调用,校验模型策略、期限、预算、结构和输入输出哈希并返回 RoleExecutionReceipt。writer、planner、extractor、detector 或 judge 的无工具批处理需要直接模型调用时使用;需要框架原生 ReAct/工具循环的子代理执行走 dispatch-agent-task;能力探针刷新交给 refresh-runtime-probe,本 Skill 不负责保存 raw、登记运行或裁决业务结果。
disable-model-invocation: true
---
# 执行受治理角色任务
-本 Skill 只拥有自动化管线的一次角色执行边界。交互式流程由主代理按 07 领域派发合同启动宿主子代理;自动化流程由 `muse_role` 承载同一角色 prompt、冻结输入、输出校验和回执合同。
+本 Skill 只拥有自动化管线的一次角色执行边界。交互式流程由主代理按 07 领域派发合同启动宿主子代理;自动化流程由 `muse_role` 承载同一角色 prompt、冻结输入、输出校验和回执合同。需要框架原生 ReAct/工具循环的子代理执行走 `dispatch-agent-task`(框架派发接缝);本 Skill 只服务不经框架、直接 HTTP 模型调用的无工具批处理。
## 入口
diff --git a/.agent/skills/merge-story-candidates/SKILL.md b/.agent/skills/merge-story-candidates/SKILL.md
index 1eaf00e..7d2cc24 100644
--- a/.agent/skills/merge-story-candidates/SKILL.md
+++ b/.agent/skills/merge-story-candidates/SKILL.md
@@ -24,7 +24,7 @@ disable-model-invocation: true
--heading "<冻结的一级章标题>" --output /private/tmp/merge-packet-01.md <来源候选>...
# ② 由主代理派发 planner 子代理
-# 主代理把 .agent/agents/planner.md 全文作为角色 prompt,输入仅包含
+# 主代理把 planner 身份提示与 .agent/docs/architecture/角色合同.md 对应章节作为角色 prompt,输入仅包含
# serial-merge-contract.md 与当前 merge-packet-01.md;子代理无工具、fresh 会话,
# 输出写到 /private/tmp/merge-raw-01.md,并由 record-run-evidence 保存派发回执。
diff --git a/.agent/skills/record-run-evidence/SKILL.md b/.agent/skills/record-run-evidence/SKILL.md
index 3b338a6..7e73a22 100644
--- a/.agent/skills/record-run-evidence/SKILL.md
+++ b/.agent/skills/record-run-evidence/SKILL.md
@@ -14,6 +14,7 @@ disable-model-invocation: true
|---|---|
| `run_registry.py` | 创建和结束 `example_run`,维持运行状态与幂等边界。 |
| `persist_llm_call.py` | 把模型调用的 prompt、response、用量与 raw 指针原子登记。 |
+| `agent_trace.py` | 框架派发留痕:事件账本 `example_agent_event` 写入 + raw/逐回合 llm_call 原子落库(07 §2 框架派发)。 |
| `persist_raw.py` | 将完整 raw 写入 `example_raw_lease` 与 `example_raw_content`,写前拦截凭据。 |
| `record_failed_run.py` | 为失败运行追加错误回执与隔离的质量结果。 |
| `repair_receipt_evidence.py` | 对成功回执追加 evidence revision,不更新旧回执。 |
diff --git a/.agent/skills/record-run-evidence/scripts/agent_trace.py b/.agent/skills/record-run-evidence/scripts/agent_trace.py
new file mode 100644
index 0000000..0999b5d
--- /dev/null
+++ b/.agent/skills/record-run-evidence/scripts/agent_trace.py
@@ -0,0 +1,371 @@
+#!/usr/bin/env python3
+"""代理事件账本与框架证据的写路径(07-Agent与Skill领域 §2 框架派发)。
+
+Agent 框架适配器(如 dispatch-agent-task/pi_runner)把框架原生事件流归一后,
+经 ``AgentTraceWriter`` 逐条追加进 ``example_agent_event``;运行结束后由
+``persist_agent_evidence`` 把 system prompt、任务输入、最终输出、全量转录和
+逐回合模型调用投影原子落库。本模块只做被动留痕:业务调用方不需要、也不能
+决定"是否留痕";除幂等哈希与安全摘要外不携带任何正文原文。
+
+数据库连接可注入(``connect=None`` 时懒加载 muse_db.connect),离线测试用假连接。
+"""
+from __future__ import annotations
+
+import json
+import math
+from typing import Any, Callable, Mapping, Sequence
+
+EVENT_TYPES = frozenset(
+ {
+ "run.started",
+ "agent.started",
+ "model.completed",
+ "tool.started",
+ "tool.completed",
+ "agent.completed",
+ "agent.failed",
+ "run.completed",
+ "run.failed",
+ }
+)
+CREATOR = "agent-trace"
+
+# pi/anthropic 风格 usage 到账本列的归一口径;同义字段取首个,避免重复计数。
+_USAGE_IN_KEYS = ("input", "input_tokens", "prompt_tokens")
+_USAGE_CACHE_READ_KEYS = ("cacheRead", "cache_read_input_tokens", "cached_tokens")
+_USAGE_CACHE_WRITE_KEYS = ("cacheWrite", "cache_creation_input_tokens")
+_USAGE_OUT_KEYS = ("output", "output_tokens", "completion_tokens")
+
+
+def _connect_factory(connect: Callable[..., Any] | None) -> Callable[..., Any]:
+ if connect is not None:
+ return connect
+ from muse_db import connect as muse_connect
+
+ return muse_connect
+
+
+def _usage_int(data: Mapping[str, Any], keys: tuple[str, ...]) -> int:
+ """读取第一种存在的 usage 字段;脏值与负值按 0 记账。"""
+
+ for key in keys:
+ if key not in data:
+ continue
+ try:
+ return max(0, int(data.get(key) or 0))
+ except (TypeError, ValueError, OverflowError):
+ return 0
+ return 0
+
+
+def _tokens(usage: Mapping[str, Any] | None) -> tuple[int, int, int]:
+ """把框架 usage 归一为 (input, output, cached);input 含 cache 读写。"""
+
+ data = usage if isinstance(usage, Mapping) else {}
+ cached = _usage_int(data, _USAGE_CACHE_READ_KEYS)
+ cache_write = _usage_int(data, _USAGE_CACHE_WRITE_KEYS)
+ value = _usage_int(data, _USAGE_IN_KEYS) + cached + cache_write
+ output = _usage_int(data, _USAGE_OUT_KEYS)
+ return value, output, cached
+
+
+class AgentTraceWriter:
+ """把归一事件逐条追加进 ``example_agent_event``(每条短事务,崩溃可审计)。"""
+
+ def __init__(
+ self,
+ *,
+ run_id: str,
+ framework: str,
+ agent_role: str,
+ connect: Callable[..., Any] | None = None,
+ creator: str = CREATOR,
+ ) -> None:
+ if not run_id or len(run_id) > 64:
+ raise ValueError("run_id 不能为空且不超过 64 字符")
+ if not framework or len(framework) > 32:
+ raise ValueError("framework 不能为空且不超过 32 字符")
+ if not agent_role or len(agent_role) > 32:
+ raise ValueError("agent_role 不能为空且不超过 32 字符")
+ self.run_id = run_id
+ self.framework = framework
+ self.agent_role = agent_role
+ self.creator = creator
+ self._connect = _connect_factory(connect)
+ self._seq = 0
+
+ @property
+ def seq(self) -> int:
+ """已写入的事件数(下一个序号 = seq + 1)。"""
+
+ return self._seq
+
+ def emit(
+ self,
+ event_type: str,
+ *,
+ status: str | None = None,
+ tool_name: str | None = None,
+ requested_model_id: str | None = None,
+ actual_model_id: str | None = None,
+ usage: Mapping[str, Any] | None = None,
+ cost_usd: float | None = None,
+ raw_ref: int | None = None,
+ details: Mapping[str, Any] | None = None,
+ ) -> int:
+ """追加一条事件;事件类型与字段合法性在本层失败关闭。"""
+
+ if event_type not in EVENT_TYPES:
+ raise ValueError(f"未知代理事件类型: {event_type}")
+ if status is not None and status not in ("ok", "error"):
+ raise ValueError("status 只能是 ok/error")
+ if event_type == "model.completed" and not actual_model_id:
+ raise ValueError("model.completed 必须携带 actual_model_id")
+ for field, value, limit in (
+ ("tool_name", tool_name, 64),
+ ("requested_model_id", requested_model_id, 64),
+ ("actual_model_id", actual_model_id, 64),
+ ):
+ if value is not None and (not isinstance(value, str) or len(value) > limit):
+ raise ValueError(f"{field} 非法或超过 {limit} 字符")
+ if cost_usd is not None:
+ try:
+ numeric_cost = float(cost_usd)
+ except (TypeError, ValueError, OverflowError) as exc:
+ raise ValueError("cost_usd 必须是非负有限数或 NULL") from exc
+ if not math.isfinite(numeric_cost) or numeric_cost < 0:
+ raise ValueError("cost_usd 必须是非负有限数或 NULL")
+ in_tokens, out_tokens, cached_tokens = _tokens(usage)
+ payload = json.dumps(details or {}, ensure_ascii=False, default=str)
+ from persist_raw import _check_no_secrets
+
+ _check_no_secrets(payload)
+ next_seq = self._seq + 1
+ with self._connect() as conn:
+ try:
+ conn.execute(
+ "INSERT INTO example_agent_event(run_id, seq, event_type, framework, agent_role, "
+ "tool_name, status, requested_model_id, actual_model_id, input_tokens, output_tokens, "
+ "cached_tokens, cost_usd, raw_ref, details, creator) "
+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s)",
+ (
+ self.run_id,
+ next_seq,
+ event_type,
+ self.framework,
+ self.agent_role,
+ tool_name,
+ status,
+ requested_model_id,
+ actual_model_id,
+ in_tokens,
+ out_tokens,
+ cached_tokens,
+ cost_usd,
+ raw_ref,
+ payload,
+ self.creator,
+ ),
+ )
+ conn.commit()
+ self._seq = next_seq
+ except Exception:
+ conn.rollback()
+ raise
+ return self._seq
+
+
+def model_ids_match(requested: str | None, actual: str | None) -> bool:
+ """匹配 pi 的模型模式解析:完整 ID 精确匹配,单边省略 provider 时比较模型叶名。"""
+
+ if not isinstance(requested, str) or not isinstance(actual, str):
+ return False
+ req, act = requested.strip().lower(), actual.strip().lower()
+ if not req or not act:
+ return False
+ if "/" in req and "/" in act:
+ return req == act
+ return req.rsplit("/", 1)[-1] == act.rsplit("/", 1)[-1]
+
+
+def persist_agent_evidence(
+ *,
+ run_id: str,
+ agent_role: str,
+ system_prompt: str,
+ user_message: str,
+ final_message: str | None,
+ transcript: str,
+ model_calls: Sequence[Mapping[str, Any]],
+ requested_model_id: str,
+ connect: Callable[..., Any] | None = None,
+ creator: str = "dispatch-agent-task",
+ dry_run: bool = False,
+) -> dict[str, Any]:
+ """把一次框架派发的全部证据原子落库。
+
+ 一个事务内写入:raw 租约(purpose=agent_task)+ prompt/response/supplier 三份
+ raw 全文 + 每个模型回合一条 ``example_llm_call`` 投影。任一步失败整体回滚,
+ 调用方拿不到看似成功却缺证据的结果。
+ """
+
+ import sys
+ from pathlib import Path
+
+ here = Path(__file__).resolve().parent
+ if str(here) not in sys.path:
+ sys.path.insert(0, str(here))
+ from persist_raw import _bare_sha256, _check_no_secrets # noqa: E402
+
+ if not isinstance(run_id, str) or not run_id or len(run_id) > 64:
+ raise ValueError("agent 证据 run_id 为空或超过 64 字符")
+ if not isinstance(agent_role, str) or not agent_role or len(agent_role) > 32:
+ raise ValueError("agent 证据 agent_role 为空或超过 32 字符")
+ if not isinstance(system_prompt, str) or not system_prompt:
+ raise ValueError("agent 证据缺 system prompt")
+ if not isinstance(user_message, str) or not user_message:
+ raise ValueError("agent 证据缺任务输入")
+ if final_message is not None and not isinstance(final_message, str):
+ raise ValueError("agent 证据 final_message 必须是字符串或 NULL")
+ if not isinstance(transcript, str) or not transcript:
+ raise ValueError("agent 证据缺框架转录")
+ _check_no_secrets(system_prompt)
+ _check_no_secrets(user_message)
+ _check_no_secrets(transcript)
+ if final_message:
+ _check_no_secrets(final_message)
+ if not isinstance(requested_model_id, str) or not requested_model_id or len(requested_model_id) > 64:
+ raise ValueError("agent 证据 requested_model_id 为空或超过 64 字符")
+ if not isinstance(model_calls, (list, tuple)) or not all(
+ isinstance(call, Mapping) for call in model_calls
+ ):
+ raise ValueError("agent 证据 model_calls 必须是对象数组")
+
+ prompt_request = json.dumps(
+ {"system": system_prompt, "user": user_message},
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ )
+ prompt_sha = _bare_sha256(prompt_request)
+ final_sha = _bare_sha256(final_message) if final_message else None
+ transcript_sha = _bare_sha256(transcript)
+ # lease 的哈希清单按 raw kind 计数;system/user 哈希已封装在 prompt 内容内,
+ # 不另造不会对应 raw 行的清单项,保证轮次封存不变量可机械核对。
+ content_hashes = {"prompt": prompt_sha, "supplier": transcript_sha}
+ if final_sha is not None:
+ content_hashes["response"] = final_sha
+
+ connect_fn = _connect_factory(connect)
+ with connect_fn() as conn:
+ try:
+ lease_id = conn.execute(
+ "INSERT INTO example_raw_lease(run_id, source_version, content_hashes, purpose, status, creator) "
+ "VALUES (%s,%s,%s::jsonb,%s,%s,%s) RETURNING id",
+ (
+ run_id,
+ None,
+ json.dumps(content_hashes, ensure_ascii=False),
+ "agent_task",
+ "closed",
+ creator,
+ ),
+ ).fetchone()[0]
+
+ def _content(kind: str, text: str, role: str) -> int:
+ row = conn.execute(
+ "INSERT INTO example_raw_content(lease_id, kind, run_id, role, content_sha256, content, creator) "
+ "VALUES (%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (lease_id, content_sha256) DO NOTHING "
+ "RETURNING id",
+ (lease_id, kind, run_id, role, _bare_sha256(text), text, creator),
+ ).fetchone()
+ if row:
+ return row[0]
+ row = conn.execute(
+ "SELECT id FROM example_raw_content WHERE lease_id=%s AND content_sha256=%s",
+ (lease_id, _bare_sha256(text)),
+ ).fetchone()
+ if not row:
+ raise RuntimeError(f"raw {kind} 幂等回读失败")
+ return row[0]
+
+ prompt_id = _content("prompt", prompt_request, agent_role)
+ response_id = _content("response", final_message, agent_role) if final_message else None
+ transcript_id = _content("supplier", transcript, agent_role)
+
+ llm_call_ids: list[int] = []
+ for index, call in enumerate(model_calls, start=1):
+ actual = call.get("actual_model_id")
+ if not isinstance(actual, str) or not actual or len(actual) > 64:
+ raise ValueError(f"第 {index} 个模型回合 actual_model_id 为空或超过 64 字符")
+ usage = call.get("usage") or {}
+ if not isinstance(usage, Mapping):
+ raise ValueError(f"第 {index} 个模型回合 usage 必须是对象")
+ cost = call.get("cost_usd")
+ if cost is not None:
+ try:
+ numeric_cost = float(cost)
+ except (TypeError, ValueError, OverflowError) as exc:
+ raise ValueError(f"第 {index} 个模型回合 cost_usd 非法") from exc
+ if not math.isfinite(numeric_cost) or numeric_cost < 0:
+ raise ValueError(f"第 {index} 个模型回合 cost_usd 非法")
+ duration = call.get("duration_ms")
+ if duration is not None:
+ if isinstance(duration, bool) or not isinstance(duration, int) or duration < 0:
+ raise ValueError(f"第 {index} 个模型回合 duration_ms 非法")
+ stop_reason = call.get("stop_reason")
+ if stop_reason is not None and not isinstance(stop_reason, str):
+ raise ValueError(f"第 {index} 个模型回合 stop_reason 非法")
+ in_tokens, out_tokens, cached_tokens = _tokens(usage)
+ row = conn.execute(
+ "INSERT INTO example_llm_call(window_key, run_id, caller, requested_model_id, "
+ "actual_model_id, model_match, in_tokens, cached_tokens, out_tokens, cost_usd, "
+ "stop_reason, duration_ms, prompt_sha256, raw_content_id, creator) "
+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id",
+ (
+ None,
+ run_id,
+ creator,
+ requested_model_id,
+ actual,
+ model_ids_match(requested_model_id, actual),
+ in_tokens,
+ cached_tokens,
+ out_tokens,
+ cost if cost is not None else 0,
+ stop_reason[:32] if stop_reason else None,
+ duration,
+ prompt_sha,
+ transcript_id,
+ creator,
+ ),
+ ).fetchone()
+ llm_call_ids.append(row[0])
+
+ result = {
+ "status": "written",
+ "leaseId": lease_id,
+ "promptId": prompt_id,
+ "responseId": response_id,
+ "transcriptId": transcript_id,
+ "llmCallIds": llm_call_ids,
+ }
+ if dry_run:
+ conn.rollback()
+ result["status"] = "dry_run_ok"
+ result["note"] = "试跑已回滚,未落库"
+ else:
+ conn.commit()
+ return result
+ except Exception:
+ conn.rollback()
+ raise
+
+
+__all__ = [
+ "AgentTraceWriter",
+ "CREATOR",
+ "EVENT_TYPES",
+ "model_ids_match",
+ "persist_agent_evidence",
+]
diff --git a/.agent/skills/record-run-evidence/scripts/invariant_checks.py b/.agent/skills/record-run-evidence/scripts/invariant_checks.py
index 7f2c17d..f7df783 100644
--- a/.agent/skills/record-run-evidence/scripts/invariant_checks.py
+++ b/.agent/skills/record-run-evidence/scripts/invariant_checks.py
@@ -63,7 +63,10 @@ def check_invariants(run_id=None):
leases = conn.execute("SELECT id, content_hashes FROM example_raw_lease").fetchall()
seal_violations = 0
for lease_id, content_hashes in leases:
- declared = len(content_hashes) if isinstance(content_hashes, list) else 0
+ if isinstance(content_hashes, (list, dict)):
+ declared = len(content_hashes)
+ else:
+ declared = 0
actual = _count(conn, "SELECT count(*) FROM example_raw_content WHERE lease_id=%s", (lease_id,))
if declared != actual:
seal_violations += 1
diff --git a/.agent/skills/record-run-evidence/scripts/run_registry.py b/.agent/skills/record-run-evidence/scripts/run_registry.py
index f10e70b..e1a926c 100644
--- a/.agent/skills/record-run-evidence/scripts/run_registry.py
+++ b/.agent/skills/record-run-evidence/scripts/run_registry.py
@@ -11,7 +11,7 @@ import re
import uuid
-from muse_db import connect
+from muse_db import connect as connect_default
CREATOR = "runtime"
@@ -28,8 +28,16 @@ def new_run_id(stage, *, work_id=None, target_chapter=None):
return f"{prefix}{scope}{target}-{stamp}-{uuid.uuid4().hex[:10]}"[:64]
+def _connect(connect=None):
+ """运行登记默认走 muse_db;派发器与测试可注入自己的连接工厂。"""
+
+ if connect is not None:
+ return connect
+ return connect_default
+
+
def start_run(*, run_id=None, work_id=None, target_chapter=None,
- trigger_source="user", trigger_detail=None, creator=CREATOR):
+ trigger_source="user", trigger_detail=None, creator=CREATOR, connect=None):
"""登记或回读一个运行;已有同 ID 运行必须属于同一作品和目标章。"""
if trigger_source not in ("user", "replay_eval", "diagnostic"):
raise ValueError(f"trigger_source 非法: {trigger_source}")
@@ -37,14 +45,14 @@ def start_run(*, run_id=None, work_id=None, target_chapter=None,
if len(run_id) > 64:
raise ValueError("run_id 超过 64 字符")
detail = json.dumps(trigger_detail, ensure_ascii=False) if trigger_detail is not None else None
- with connect() as conn:
+ with _connect(connect)() as conn:
try:
- conn.execute(
+ inserted = conn.execute(
"INSERT INTO example_run(run_id, work_id, target_chapter, trigger_source, trigger_detail, "
"terminal_state, creator, updater) VALUES (%s,%s,%s,%s,%s::jsonb,'running',%s,%s) "
- "ON CONFLICT (run_id) DO NOTHING",
+ "ON CONFLICT (run_id) DO NOTHING RETURNING run_id",
(run_id, work_id, target_chapter, trigger_source, detail, creator, creator),
- )
+ ).fetchone()
row = conn.execute(
"SELECT run_id, work_id, target_chapter, terminal_state FROM example_run "
"WHERE run_id=%s AND deleted=FALSE",
@@ -60,19 +68,19 @@ def start_run(*, run_id=None, work_id=None, target_chapter=None,
"work_id": row[1],
"target_chapter": row[2],
"terminal_state": row[3],
- "status": "existing" if row[3] != "running" else "started",
+ "status": "started" if inserted else "existing",
}
except Exception:
conn.rollback()
raise
-def finish_run(run_id, terminal_state, *, creator=CREATOR, trigger_detail=None):
+def finish_run(run_id, terminal_state, *, creator=CREATOR, trigger_detail=None, connect=None):
"""把运行置为 completed/failed,并由数据库约束保证有 finished_at。"""
if terminal_state not in _TERMINAL_STATES:
raise ValueError(f"终态非法: {terminal_state}")
detail = json.dumps(trigger_detail, ensure_ascii=False) if trigger_detail is not None else None
- with connect() as conn:
+ with _connect(connect)() as conn:
try:
row = conn.execute(
"UPDATE example_run SET terminal_state=%s, finished_at=CURRENT_TIMESTAMP, "
@@ -91,7 +99,7 @@ def finish_run(run_id, terminal_state, *, creator=CREATOR, trigger_detail=None):
@contextmanager
def managed_run(*, run_id=None, work_id=None, target_chapter=None,
- trigger_source="user", trigger_detail=None, creator=CREATOR):
+ trigger_source="user", trigger_detail=None, creator=CREATOR, connect=None):
"""以成功/失败终态包住一个生产阶段。"""
record = start_run(
run_id=run_id,
@@ -100,16 +108,17 @@ def managed_run(*, run_id=None, work_id=None, target_chapter=None,
trigger_source=trigger_source,
trigger_detail=trigger_detail,
creator=creator,
+ connect=connect,
)
active_id = record["run_id"]
try:
yield active_id
except BaseException as exc:
finish_run(active_id, "failed", creator=creator,
- trigger_detail={"error_type": type(exc).__name__})
+ trigger_detail={"error_type": type(exc).__name__}, connect=connect)
raise
else:
- finish_run(active_id, "completed", creator=creator)
+ finish_run(active_id, "completed", creator=creator, connect=connect)
__all__ = ["finish_run", "managed_run", "new_run_id", "start_run"]
diff --git a/.agent/skills/replay-writer-gate/configs/writer-gate-a-deep-space-v1.json b/.agent/skills/replay-writer-gate/configs/writer-gate-a-deep-space-v1.json
index d06eaf1..c8ae105 100644
--- a/.agent/skills/replay-writer-gate/configs/writer-gate-a-deep-space-v1.json
+++ b/.agent/skills/replay-writer-gate/configs/writer-gate-a-deep-space-v1.json
@@ -49,7 +49,7 @@
"oracleInputProvenance": "oracle_reference_scaffold",
"maxContextChars": 140000,
"modelVersion": "fixed-opus-v1",
- "adapterVersion": "muse-role-v3",
+ "adapterVersion": "muse-role-v4",
"sampling": {
"temperature": 0.2,
"topP": null,
@@ -118,15 +118,15 @@
"runtimeProbe": {
"schemaVersion": "runtime-probe-v2",
"status": "successful",
- "checkedAt": "2026-08-21T17:09:22+00:00",
+ "checkedAt": "2026-08-22T02:58:14+00:00",
"runtimeAdapter": "muse-role",
- "runtimeAdapterVersion": "muse-role-v3",
+ "runtimeAdapterVersion": "muse-role-v4",
"modelPolicyVersion": "fixed-opus-v1",
"role": "writer",
"profileVersion": "writer-gate-a-role-v5",
"modelAlias": "opus",
"resolvedModelId": "claude-opus-4-8[1M]",
- "executionProfileSha256": "sha256:e6a6423620004d51d596eb051ba60c4046ea170054737f043c623adf91639e64",
+ "executionProfileSha256": "sha256:8be7ac47f77b6957e4f6beb049e9d393c83b44ba261a331b99903fb3fb7d26f6",
"jsonSchemaId": "writer-draft-v2",
"jsonSchemaSha256": "sha256:a1fc5efbcd7aee11082b547eb4e156fe27abe0d669991d5825e3e69901278709",
"systemPromptId": "writer-gate-a-system-v2",
@@ -135,16 +135,16 @@
"requestedModelId": "opus",
"actualModelId": "claude-opus-4-8",
"modelMatch": true,
- "executionReceiptSha256": "sha256:acd7076a5f33150818a232929862804fc8a26a39d4a840a5d88fa95464f00dde",
- "structuredOutputSha256": "sha256:cba4507ac0b637fe6a04ae2d72438ab7d4496f67de38a41c63ad18f5c88a79bf",
+ "executionReceiptSha256": "sha256:bbadc1edb361e102c727b350d76289180ad0e6c28a13a89c410c837f70bf66a0",
+ "structuredOutputSha256": "sha256:fc6e7ab2641cf4081ada60ed245071c92ef0fee98daeba3242fa4c35aa95e3b0",
"terminalReason": "completed",
- "totalCostUsd": "0.066216",
- "receiptSha256": "sha256:095c164e4dc9c61a9151eb09df55ce4066766f16249f8ecffec2baee97561724"
+ "totalCostUsd": "0.105666",
+ "receiptSha256": "sha256:c96feda6d073f3b4820c7e0561f57270527142ee39d02bcaf2f976297a64d6be"
},
"profileSha256": {
- "writer": "sha256:e6a6423620004d51d596eb051ba60c4046ea170054737f043c623adf91639e64",
- "semantic_detector": "sha256:c44a27ff73bf6d72ed697d55cdc117688179bef2b266db5bc2086c934f89315a",
- "blind_judge": "sha256:cda85ea8d5b22f52c4a4fd838f9459ffcd2b115d46b3315a6364d616c4090df5"
+ "writer": "sha256:8be7ac47f77b6957e4f6beb049e9d393c83b44ba261a331b99903fb3fb7d26f6",
+ "semantic_detector": "sha256:5de4c5e36968f4335ed6977620ca8133e9e9ab2588b3405840de0edc3899d791",
+ "blind_judge": "sha256:a3078d38f315d01453e71b8390ec96635c49e51ac199aab6ae5c6d63cb7c57ef"
},
"budget": {
"status": "approved",
diff --git a/.agent/skills/replay-writer-gate/scripts/run_writer_replay/authorization.py b/.agent/skills/replay-writer-gate/scripts/run_writer_replay/authorization.py
index dbe868f..2479cf6 100644
--- a/.agent/skills/replay-writer-gate/scripts/run_writer_replay/authorization.py
+++ b/.agent/skills/replay-writer-gate/scripts/run_writer_replay/authorization.py
@@ -35,7 +35,6 @@ from muse_role import ( # noqa: E402
MODEL_POLICY_VERSION,
RUNTIME_ADAPTER,
RUNTIME_ADAPTER_VERSION,
- ROLE_TASK_SEPARATOR,
RoleExecutionProfile,
RoleExecutionReceipt,
RoleRuntimeError,
@@ -92,14 +91,6 @@ from ._common import (
from .budget import _budget_amount
-AGENTS_DIR = Path(__file__).resolve().parents[4] / "agents"
-PROFILE_ROLE_FILES = {
- "writer": "writer.md",
- "semantic_detector": "detector.md",
- "blind_judge": "judge.md",
-}
-
-
def profile_from_mapping(value: Any, *, role: str) -> RoleExecutionProfile:
"""从显式配置构造冻结 profile,不接受默认模型或隐式 schema/预算。"""
@@ -126,9 +117,10 @@ def profile_from_mapping(value: Any, *, role: str) -> RoleExecutionProfile:
return int(value) if integer else float(value)
system_prompt = str(profile.get("systemPrompt") or "")
- role_prompt = (AGENTS_DIR / PROFILE_ROLE_FILES[role]).read_text(encoding="utf-8").rstrip()
- if not system_prompt.startswith(role_prompt + ROLE_TASK_SEPARATOR):
- raise WriterReplayError(f"{role} profile 未注入完整角色 prompt")
+ if not system_prompt.strip():
+ raise WriterReplayError(f"{role} profile 缺少冻结 system prompt 快照")
+ # 回放配置保存的是当次不可变 prompt 快照;当前角色合同由在线派发链从
+ # .agent/docs/architecture/角色合同.md 装配,不能用当前角色文件反查历史快照。
temperature_raw = profile.get("temperature")
temperature = 0.2 if temperature_raw in (None, "", "unsupported") else float(temperature_raw)
try:
diff --git a/.agent/skills/write-next-chapter/SKILL.md b/.agent/skills/write-next-chapter/SKILL.md
index 4a4c395..929017d 100644
--- a/.agent/skills/write-next-chapter/SKILL.md
+++ b/.agent/skills/write-next-chapter/SKILL.md
@@ -36,6 +36,10 @@ Writer 不接收 `runId`、权限信息、manifest、hash、候选版本、验
4. 缺少细纲字段、`factConstraints` 字段或篇幅合同属于 adapter 输入错误,必须在模型调用前失败。`factConstraints=[]` 在冻结检索确实没有可确认事实时是合法输入,不等于“事实已验证”。写手可以在正文里设计新设定,但不得把新设定冒充已确认事实。
5. detector 只把「对已有正典/前文章节的主张检索不够」标成 `evidenceGaps` 并触发补证重写。写手新写出的设定进 `newSettingCandidates`,不因此重写或禁写;与既有正典冲突才失败关闭。新设定是否进入正典由人决定。Writer 输出不承载补证请求或审查结论。
+## 生产入口
+
+Dashboard 与人工生产入口统一调用 `scripts/produce_next_chapter.py`。该入口只负责编排本 Skill 已登记的冻结、检索、writer、detector、CAS、候选落库和人闸步骤;不提供自动 accept。运行 artifacts 仍由只读看板按 run_id 读取。
+
## 生产落库
- 生产编排走 `run_writer_pipeline`(机械门→语义 detector→补证/重写有限环),状态链用 `scripts/candidate_cas.py` 的 `PostgresCasStateStore` 持久化到 `example_candidate_cas`(一次运行一条链,revision 单调,DB 触发器锁方向闭集);内存 `InMemoryCasStateStore` 仅供离线测试。
diff --git a/docs/write-chapter/step2_write_chapter.py b/.agent/skills/write-next-chapter/scripts/produce_next_chapter.py
similarity index 99%
rename from docs/write-chapter/step2_write_chapter.py
rename to .agent/skills/write-next-chapter/scripts/produce_next_chapter.py
index 5e7692b..8e27cce 100644
--- a/docs/write-chapter/step2_write_chapter.py
+++ b/.agent/skills/write-next-chapter/scripts/produce_next_chapter.py
@@ -13,7 +13,7 @@
→ accept_preflight(check_writer_acceptance 纯函数 + acceptance_state 实时重读)
→ 停止并展示候选,等待用户明确选择改 / 丢弃 / 采纳
-用法:.venv/bin/python docs/write-chapter/step2_write_chapter.py [目标章号]
+用法:.venv/bin/python .agent/skills/write-next-chapter/scripts/produce_next_chapter.py [目标章号]
[--instruction "本轮人指令原文"]
缺省写下一章(库内最大章序 +1)。前置:该章已建且有 confirmed 细纲(镜像
step2_setup_chapter2.py 建章 + 落细纲),且门锚合同 GATE_ANCHORS 已登记该章。
@@ -30,8 +30,9 @@ from pathlib import Path
from typing import Any, Mapping
SCRIPT_DIR = Path(__file__).resolve().parent
-AGENT_ROOT = SCRIPT_DIR.parents[1]
-SKILLS = AGENT_ROOT / ".agent" / "skills"
+REPO_ROOT = SCRIPT_DIR.parents[3]
+AGENT_ROOT = SCRIPT_DIR.parents[2]
+SKILLS = AGENT_ROOT / "skills"
for sub in (
"assemble-context/scripts",
"write-next-chapter/scripts",
@@ -104,7 +105,7 @@ SYSTEM_PROMPT = GATE_A_WRITER["systemPrompt"] + PRODUCTION_LENGTH_PROMPT
SYSTEM_PROMPT_ID = "writer-production-system-v4-new-settings"
SYSTEM_PROMPT_SHA256 = "sha256:" + hashlib.sha256(SYSTEM_PROMPT.encode("utf-8")).hexdigest()
-ARTIFACTS = SCRIPT_DIR / "artifacts"
+ARTIFACTS = REPO_ROOT / "docs" / "write-chapter" / "artifacts"
# 门锚合同按章登记:锚点是章级创作判断,any-hit 子串匹配。新章必须先登记再跑。
# 机械门(check_writer_candidate)只认这些子串,不认语义等价;必须投影给写手,
diff --git a/.agent/skills/write-next-chapter/scripts/run_writer.py b/.agent/skills/write-next-chapter/scripts/run_writer.py
index 0c5f8af..7d156ce 100644
--- a/.agent/skills/write-next-chapter/scripts/run_writer.py
+++ b/.agent/skills/write-next-chapter/scripts/run_writer.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""通过统一治理 runtime 运行正文写手并绑定候选身份。
-执行器是 muse_role 的固定 Opus HTTP 策略:角色合同全文作系统提示词,冻结输入和
+执行器是 muse_role 的固定 Opus HTTP 策略:身份提示与中心角色合同作系统提示词,冻结输入和
JSON Schema 进入同一次调用,输出校验后生成回执。不依赖模型 CLI 或宿主装载机制,
模型不可用时失败关闭,不降级到内容模型链。
"""
@@ -18,6 +18,7 @@ READ_CONTEXT_DIR = SCRIPT_DIR.parents[1] / "assemble-context" / "scripts"
if str(READ_CONTEXT_DIR) not in sys.path:
sys.path.insert(0, str(READ_CONTEXT_DIR))
+from muse_role_contract import load_role_contract_catalog # noqa: E402
from muse_role import ( # noqa: E402
FIXED_OPUS_MODEL_ID,
FIXED_OPUS_POLICY_ALIAS,
@@ -32,9 +33,12 @@ from muse_role import ( # noqa: E402
verify_role_profile,
)
+_ROLE_CATALOG = load_role_contract_catalog(SCRIPT_DIR.parents[3])
WRITER_ROLE_PROMPT = (
- SCRIPT_DIR.parents[2] / "agents" / "writer.md"
-).read_text(encoding="utf-8")
+ (SCRIPT_DIR.parents[2] / "agents" / "writer.md").read_text(encoding="utf-8").rstrip()
+ + "\n\n--- 角色合同(唯一事实源) ---\n"
+ + _ROLE_CATALOG.for_role("writer").contract_prompt
+)
from writer_contract import ( # noqa: E402
ContractError,
diff --git a/AGENTS.md b/AGENTS.md
index e729065..ef9daaf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -63,11 +63,11 @@ agent-example/
└── README.md # 历史概览,不是当前运行态 SoT
```
-5 个角色:`writer`、`planner`、`extractor`、`detector`、`judge`。角色身份在 `.agent/agents/*.md`,具体功能合同不复制进角色文件。角色是主会话派发的子代理:按 [07-Agent与Skill领域 §2](.agent/docs/architecture/domains/07-Agent与Skill领域.md) 的派发合同起全新会话,注入角色文件全文、冻结输入,输出由派发方校验并落证据;不依赖任何宿主的原生角色装载机制(如 Claude Code `--agent`),Claude CLI 不是角色运行底座。
+5 个角色:`writer`、`planner`、`extractor`、`detector`、`judge`。角色身份在 `.agent/agents/*.md`;稳定角色合同唯一事实源是 [角色合同](.agent/docs/architecture/角色合同.md),不把输入边界、模型策略、工具权限和输出合同散落进角色文件。角色是主会话派发的子代理:按 [07-Agent与Skill领域 §2](.agent/docs/architecture/domains/07-Agent与Skill领域.md) 的派发合同起全新会话,注入身份提示、对应角色合同和冻结输入,输出由派发方校验并落证据;不依赖任何宿主的原生角色装载机制(如 Claude Code `--agent`),Claude CLI 不是角色运行底座。
### Skill 合同责任方索引
-实际清单以 `.agent/skills/*/SKILL.md` 为准。发现总索引见 [`.agent/skills/_index.md`](.agent/skills/_index.md):57 个 skill 按创作生命周期分 9 域,每条只登记 `skill_name` / `skill_file` / `skill_description` 三字段,description 与 SKILL.md frontmatter 逐字一致。索引由 `harness/skills_index.py --write` 生成;skill 增删改名后必须重新生成,一致性由 `tests/architecture/test_skills_index.py` 机械校验。
+实际清单以 `.agent/skills/*/SKILL.md` 为准。发现总索引见 [`.agent/skills/_index.md`](.agent/skills/_index.md):58 个 skill 按创作生命周期分 9 域,每条只登记 `skill_name` / `skill_file` / `skill_description` 三字段,description 与 SKILL.md frontmatter 逐字一致。索引由 `harness/skills_index.py --write` 生成;skill 增删改名后必须重新生成,一致性由 `tests/architecture/test_skills_index.py` 机械校验。
本表是另一条轴:登记每个 skill 的合同责任方、协作领域和领域 SoT,不复制各 Skill 的完整合同。每个 skill 必须登记一个合同责任方(业务领域或平台领域),但可以同时消费或影响多个协作领域;跨域调用、场景关系和保护节点在 `meta/chains/` 登记。合同责任方表示谁维护该 Skill 的稳定能力合同,不表示 Skill 只能属于一个业务领域。
@@ -81,7 +81,7 @@ agent-example/
| 质量与回放评测 | 06-质量与复利、05-创作流程 | `check-content-consistency`、`score-content-quality`、`adjudicate-quality-gate`、`optimize-content-quality`、`evaluate-frozen-replay`、`replay-writer-gate`、`load-replay-reference-work`、`novel-diagnosis` |
| 去 AI 味与人感 | 06-质量与复利、父仓专题-09 | `capture-ai-flavor-cases`、`promote-ai-flavor-rule`、`diagnose-ai-flavor`、`establish-voice-baseline`、`prevent-ai-flavor`、`revise-ai-flavor` |
-57 个 skill 一律是本仓正式 skill,受同一套合同与门禁约束,不分等级:都须满足 [07-Agent与Skill领域 §3](.agent/docs/architecture/domains/07-Agent与Skill领域.md) 的合同,都在 `_index.md` 与 `skills.json` 登记,都进质量评分。绑创作 scenario 的在 `meta/chains/` 登记;平台与工具类(如 `call-content-model`、`execute-role-task`、`record-run-evidence`)由主会话或其它 Skill 直接调用,不绑 scenario。
+58 个 skill 一律是本仓正式 skill,受同一套合同与门禁约束,不分等级:都须满足 [07-Agent与Skill领域 §3](.agent/docs/architecture/domains/07-Agent与Skill领域.md) 的合同,都在 `_index.md` 与 `skills.json` 登记,都进质量评分。绑创作 scenario 的在 `meta/chains/` 登记;平台与工具类(如 `call-content-model`、`execute-role-task`、`record-run-evidence`)由主会话或其它 Skill 直接调用,不绑 scenario。
**不按"是不是系统运行时"分等级。** 一个 Skill 当前有没有 `scripts/`、有没有数据库合同、有没有接入复利,是实现成熟度而非本质:`plan-chapter`、`expand-scene`、`polish-prose` 以模型判断为主、自身不带 Tool,落库由它们调用的 Skill 承担;`story-structure`、`scene-craft` 一类创作方法 Skill 目前只有 `SKILL.md` 与 `references/`,那是**未接入复利的欠账**,不是它们的天然形态(改造方向见下)。把成熟度写成类别,等于给未完成的 Skill 发永久豁免证。
@@ -124,10 +124,10 @@ Skill 领域列表的新增、删除、改名或主领域调整,必须同时
## 6. 模型边界
- 清洗、抽卡、范式拆取及其模型调用统一走 `call-content-model` Skill,不裸调 New-API。治理政策固定为 5 小时额度窗:MiniMax 模型累计花费上限 `$24`,全模型成功调用上限 `6000`;运行适配器、正式配置和账本是额度合同的事实源,共享库 `muse_llm` 与 `muse_db.WINDOW_BUDGET_USD` / `WINDOW_CALL_CAP` 是实现,Skill CLI 只做入口,`test_quota.py` 只提供回归证据;模型链切换必须由该治理入口留下日志。
-- 角色模型归属:`planner`/`writer`/`judge` 固定 `opus`;`extractor`/`detector` 可用其它模型(非必须降级)。拆书/导入侧抽取经 `call-content-model`/`deconstruct-book` Skill 走 MiniMax-M3,不走角色 model 派发;创作期章后抽取作为角色派发,可用 `opus`。
+- 角色模型归属和派发字段以 [角色合同](.agent/docs/architecture/角色合同.md) 为准:`planner`/`writer`/`judge` 固定 `opus`;`extractor`/`detector` 可在合同允许的治理策略内运行。每次框架调用必须显式传入 `provider`、`model` 和 `thinking`,不得从环境变量静默补全。拆书/导入侧抽取经 `call-content-model`/`deconstruct-book` Skill 走 MiniMax-M3,不走角色 model 派发;创作期章后抽取作为角色派发,可用 `opus`。
- 确定性脚本、合同校验、快照冻结、泄漏审计和报告生成不调用模型;除非对应 `SKILL.md` 明确声明模型步骤,不得把机械任务升级为模型任务。
- 固定 Opus 角色生成或评测只在对应任务 SoT、显式预算、冻结 profile 和原文用途授权全部满足后运行;自动化调用走 Anthropic 兼容 HTTP 适配器,不读取 Claude Code 配置,不启动模型 CLI。任一前置门失败都关闭执行。
-- 角色的模型策略版本、模型别名、完整模型 ID、预算和回执必须与冻结配置一致。`planner`/`writer`/`judge` 不得因模型不可用而降级到内容模型链或更换供应商;需要变更时先取得明确授权并重新登记配置、profile 与探针。
+- 角色的模型策略版本、模型别名、完整模型 ID、预算和回执必须与冻结配置一致。`planner`/`writer`/`judge` 不得因模型不可用而降级到内容模型链或更换供应商;需要变更时先取得明确授权并更新角色合同、profile 与探针。
## 7. 会话编排与汇报
@@ -175,14 +175,16 @@ git diff --check
### 11.1 Agent 提示词(`.agent/agents/*.md` 与角色系统提示词)
+角色稳定合同见 [角色合同](.agent/docs/architecture/角色合同.md)。角色文件可以包含 Agent-facing 的身份、Skill 路由、推荐工具能力和工作方法;审查硬边界、模型策略、实际工具权限和结构化输出时以中心合同与适配器为准。
+
逐条问四个问题:
1. **面对谁**:这份提示词读给哪个模型/角色?它只该有这一个身份,不该同时背「执行器/评测器/审查器」之类第二身份。
2. **每个词都有意义吗**:逐词问——它对「这个角色干好本业」有用吗?框架名、schema 名、字段名、运行身份、哈希、评测状态这类机器词,对创作/规划/抽取/检测等本体任务毫无意义,是其它层的泄漏,应删。
-3. **约束是该有的限制吗**:每条约束问——它是角色意图本身需要的,还是支架(harness)本就能强制的?**输出格式**由结构化输出 schema 强制、**工具可用性**由调用参数强制、**盲化**由「输入里压根没有该信息」保证——这些都不该写进提示词反复叮嘱。提示词只留角色凭自身判断必须遵守的约束。
+3. **约束是该有的限制吗**:每条约束问——它是角色意图本身需要的,还是支架(harness)本就能强制的?**输出格式**由结构化输出 Schema 强制、**实际工具可用性**由调用参数强制、**盲化**由「输入里压根没有该信息」保证;角色文件可以解释 Skill 和工具的用途,但不能把提示文字当权限或结构门。
4. **正向与负向**:分清哪些部分**帮**角色达成意图(正向:本业纪律、领域边界、知情范围),哪些**妨碍**它(负向:与本业无关的机器约束、诱导照搬输入原文的措辞、让模型惦记评测的暗示)。负向部分删除或移到它该在的层。
-> 反例(已纠正):评测写手提示词曾塞入「你是 Gate A 离线回放的 writer…只输出 candidateBody…不输出哈希/身份…不访问 MCP」,把评测支架混进创作提示词——既没有写作指导,又诱导写手照抄细纲概述句。正解:提示词只讲怎么写好,输出格式与盲化交给 schema 和输入设计。
+> 反例(已纠正):评测写手提示词曾塞入「你是 Gate A 离线回放的 writer…只输出 candidateBody…不输出哈希/身份…不访问 MCP」,把评测支架混进创作提示词——既没有写作指导,又诱导写手照抄细纲概述句。正解:角色文件保留写作方法、Skill 路由和工具用途;输出格式、实际工具权限、盲化和证据绑定交给中心合同、Schema 与适配器。
### 11.2 Skill(`.agent/skills/*/`)
diff --git a/dashboard/server.py b/dashboard/server.py
index 7cd5703..468d10f 100644
--- a/dashboard/server.py
+++ b/dashboard/server.py
@@ -2347,7 +2347,7 @@ def _run_pipeline_panel(run_id: str) -> str:
f" · 候选版本 {esc(ver)} · 补证请求 {esc(evidence_n)} · 重写 {esc(rewrite_n)}
"
f"证据缺口({len(gap_lis)})
{gap_html}"
f"新设定提案({len(setting_lis)})
{setting_html}"
- "来源:本地 docs/write-chapter/artifacts/。"
+ "
来源:生产写作运行 artifacts(docs/write-chapter/artifacts/)。"
"对已有正典的缺口才走补证重写;新设定不禁写、不自动入库,由人决定采纳/改/丢弃。"
"仍失败关闭时可能不落候选行。
"
""
@@ -2497,7 +2497,7 @@ def _run_decision_menu_panel(run_id: str, candidates: list, terminal_state, pipe
# 预填改指令:绕开不可证缺口(阶段 0.1 选 A 时用)
avoid = ";".join(f"避开「{h}」" for h in gap_hints[:2]) if gap_hints else "按语义/诊断理由修改"
rewrite_cmd = (
- f".venv/bin/python docs/write-chapter/step2_write_chapter.py 3 "
+ f".venv/bin/python .agent/skills/write-next-chapter/scripts/produce_next_chapter.py 3 "
f"--instruction \"改:{avoid}。禁止细纲原句抄进正文;门符号只在舰队医疗舱语境。\""
)
if candidates:
diff --git a/db/ddl/112-example角色模型策略与登记路径.sql b/db/ddl/112-example角色模型策略与登记路径.sql
index 6da2e51..ab8b04d 100644
--- a/db/ddl/112-example角色模型策略与登记路径.sql
+++ b/db/ddl/112-example角色模型策略与登记路径.sql
@@ -19,7 +19,7 @@ COMMENT ON COLUMN example_llm_call.model_match IS
'实际模型是否符合冻结 RoleExecutionProfile 的模型策略;由执行 profile/回执哈希链证明。';
COMMENT ON TABLE example_agent_role IS
- '五个 prompt 管理角色的库内影子;Git 权威位于 .agent/agents/*.md,模型策略不属于角色 frontmatter。';
+ '五个 prompt 管理角色的库内影子;稳定合同 Git 权威位于 .agent/docs/architecture/角色合同.md,身份提示位于 .agent/agents/*.md。';
COMMENT ON COLUMN example_agent_role.model IS
'保留兼容列;角色模型由派发 profile 决定,本列保持 NULL。';
COMMENT ON TABLE example_skill IS
diff --git a/db/ddl/113-example代理事件账本.sql b/db/ddl/113-example代理事件账本.sql
new file mode 100644
index 0000000..f775b00
--- /dev/null
+++ b/db/ddl/113-example代理事件账本.sql
@@ -0,0 +1,61 @@
+-- 代理事件账本:把任意 Agent 框架(pi/codex/opencode…)子代理执行的事件流统一留痕。
+-- 合同(07-Agent与Skill领域 §2 框架派发):框架适配器把框架原生事件归一为闭集事件类型,
+-- 逐条追加写入;本表只存身份、用量、成本与安全摘要,不存 prompt/正文原文(原文进 raw 表)。
+-- 设计边界:框架名不做闭集(接缝必须开放给任意框架);事件序号由适配器在运行内单调分配。
+
+CREATE TABLE IF NOT EXISTS example_agent_event (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ run_id VARCHAR(64) NOT NULL, -- -> example_run.run_id(软引用)
+ seq INTEGER NOT NULL, -- 运行内事件序号,从 1 单调递增
+ event_type VARCHAR(32) NOT NULL, -- 闭集,见 chk_example_agent_event_type
+ framework VARCHAR(32) NOT NULL, -- pi/codex/opencode…(框架适配器自报,开放集)
+ agent_role VARCHAR(32), -- writer/planner/detector/judge/extractor
+ tool_name VARCHAR(64), -- tool.* 事件的框架工具名
+ status VARCHAR(16), -- ok/error
+ requested_model_id VARCHAR(64), -- 派发策略请求的模型(框架解析前)
+ actual_model_id VARCHAR(64), -- 供应商实际执行模型
+ input_tokens BIGINT,
+ output_tokens BIGINT,
+ cached_tokens BIGINT,
+ cost_usd NUMERIC(14,8), -- 供应商报告或价目表计算;未知为 NULL
+ raw_ref BIGINT, -- -> example_raw_content.id(软引用)
+ details JSONB NOT NULL DEFAULT '{}'::jsonb, -- 安全摘要:usage 细分/stopReason/哈希/sessionId/errorCode
+ creator VARCHAR(64) NOT NULL DEFAULT 'agent-trace',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ tenant_id BIGINT NOT NULL DEFAULT 0,
+ CONSTRAINT uq_example_agent_event_run_seq UNIQUE (run_id, seq),
+ CONSTRAINT chk_example_agent_event_type CHECK (event_type IN
+ ('run.started','agent.started','model.completed','tool.started','tool.completed',
+ 'agent.completed','agent.failed','run.completed','run.failed')),
+ CONSTRAINT chk_example_agent_event_seq CHECK (seq >= 1),
+ CONSTRAINT chk_example_agent_event_status CHECK (status IS NULL OR status IN ('ok','error')),
+ CONSTRAINT chk_example_agent_event_model_named CHECK
+ (event_type <> 'model.completed' OR actual_model_id IS NOT NULL)
+);
+CREATE INDEX IF NOT EXISTS idx_example_agent_event_run ON example_agent_event(tenant_id, run_id, seq);
+CREATE INDEX IF NOT EXISTS idx_example_agent_event_role ON example_agent_event(tenant_id, agent_role, event_type);
+
+-- append-only:禁改禁删禁 TRUNCATE(与 example_run_receipt 同款守护)
+CREATE OR REPLACE FUNCTION reject_example_agent_event_mutation()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ RAISE EXCEPTION 'example_agent_event 是 append-only 表,禁止 UPDATE/DELETE';
+END;
+$$;
+
+CREATE OR REPLACE TRIGGER trg_example_agent_event_append_only
+BEFORE UPDATE OR DELETE ON example_agent_event
+FOR EACH ROW EXECUTE FUNCTION reject_example_agent_event_mutation();
+
+CREATE OR REPLACE TRIGGER trg_example_agent_event_no_truncate
+BEFORE TRUNCATE ON example_agent_event
+FOR EACH STATEMENT EXECUTE FUNCTION example_reject_truncate();
+
+COMMENT ON TABLE example_agent_event IS
+ '代理事件账本(append-only):框架适配器归一后的子代理执行事件流;prompt/正文原文在 raw 表,本表只留身份与用量。';
+COMMENT ON COLUMN example_agent_event.framework IS
+ '执行框架名(pi/codex/opencode…),开放集;模型策略由派发方决定并记录在 requested_model_id。';
+COMMENT ON COLUMN example_agent_event.details IS
+ '安全摘要 JSON:usage 细分、stopReason、spec/prompt/输出哈希、sessionId、errorCode;不含正文原文。';
diff --git a/db/表映射.md b/db/表映射.md
index 8be7c31..00015b2 100644
--- a/db/表映射.md
+++ b/db/表映射.md
@@ -2,7 +2,7 @@
> 口径(创始人拍板③ 2026-07-10):主仓表**原样不改列**;实验私货全进 `example_*` 前缀。
> 建表方式:`db/ddl/` 下文件经 `access-database` skill `apply`,主仓部分为 `muse-cloud/sql/muse/` 原文拷贝或逐字摘录。
-> 已应用顺序:V1 → V3 → V5 → 90-ALTER摘录 → V26 → 91-example(2026-07-13)→ 97/98(2026-07-30)→ 104 AI 味案例(2026-08-14)→ 105/106/107/108/109 先审后入与复利闭环(2026-08-14)→ 110 声音账(2026-08-15)。库内表现状以 `access-database` skill `tables` 实时输出为准。
+> 已应用顺序:V1 → V3 → V5 → 90-ALTER摘录 → V26 → 91-example(2026-07-13)→ 97/98(2026-07-30)→ 104 AI 味案例(2026-08-14)→ 105/106/107/108/109 先审后入与复利闭环(2026-08-14)→ 110 声音账(2026-08-15)-> 112 角色模型策略(2026-08-21)-> 113 代理事件账本(2026-08-22)。库内表现状以 `access-database` skill `tables` 实时输出为准。
> 96 不启用:`96-example参考作品授权快照.sql` 已实现但**决定不 apply**(单用户本地不做多租户授权机制,2026-07-30 拍板,见领域索引 §9);库内无该表。
## 主仓一致表(20 张)
@@ -54,6 +54,7 @@
| example_lesson | 经验升格登记(108):lesson/win 证据绑 run_id+候选哈希;proposed→reviewing→promoted/rejected,DB 触发器禁止跳过评审的自动升格 |
| example_candidate_cas | 候选 CAS 状态链(109):一次运行一条链(DRAFT/CHECKING/PASSED/REJECTED),revision 单调 +1,触发器锁方向闭集与身份不可变 |
| example_voice_baseline | 声音账(110):技能 1 定基线产物,一作品一版本 append + supersede;grounding 门由 establish-voice-baseline 脚本强制;修订门禁与前置预防机械消费 |
+| example_agent_event | 代理事件账本(113):框架适配器归一后的子代理执行事件流(run/agent/model/tool 九类闭集),append-only;prompt/正文原文在 raw 表 |
## 暂缓建表登记(主仓有、实验现阶段未建;需要时按原样加建)
diff --git a/harness/manifests/skills.json b/harness/manifests/skills.json
index 66e6c3d..0ba7707 100644
--- a/harness/manifests/skills.json
+++ b/harness/manifests/skills.json
@@ -321,6 +321,23 @@
],
"skill_path": ".agent/skills/execute-role-task/SKILL.md"
},
+ {
+ "name": "dispatch-agent-task",
+ "lifecycle": "platform",
+ "invocation": "orchestrated",
+ "side_effects": [
+ "external_call",
+ "db_write"
+ ],
+ "compounding": "none",
+ "contract_owner": "平台运行与证据",
+ "collaborates_with": [
+ "规划与作品基础",
+ "写作与候选主权",
+ "质量与回放评测"
+ ],
+ "skill_path": ".agent/skills/dispatch-agent-task/SKILL.md"
+ },
{
"name": "expand-scene",
"lifecycle": "writing",
diff --git a/harness/manifests/test-inventory.json b/harness/manifests/test-inventory.json
index ec08365..71768a7 100644
--- a/harness/manifests/test-inventory.json
+++ b/harness/manifests/test-inventory.json
@@ -1650,7 +1650,7 @@
],
"skill_behavior_eval": false,
"classification_confidence": "medium",
- "classification_basis": "加载 docs/write-chapter/step2_write_chapter.py 并断言机械验收子串(门锚)对写手可见;只测纯函数投影,不产生系统事实。"
+ "classification_basis": "加载 .agent/skills/write-next-chapter/scripts/produce_next_chapter.py 并断言机械验收子串(门锚)对写手可见;只测纯函数投影,不产生系统事实。"
},
{
"path": "tests/skills/write-next-chapter/test_persist_writer_run.py",
@@ -1750,13 +1750,46 @@
"skill_behavior_eval": false,
"classification_confidence": "high",
"classification_basis": "Mocks propose_lesson_dedup for writer mechanical gate lessons; no database."
+ },
+ {
+ "path": "tests/skills/dispatch-agent-task/test_dispatch_agent_task.py",
+ "scope": "runtime_skill",
+ "owner_skill_or_domain": "dispatch-agent-task",
+ "kind": "runtime_contract",
+ "evidence_level": "deterministic_offline",
+ "requires": [
+ "offline",
+ "filesystem"
+ ],
+ "side_effects": [
+ "filesystem"
+ ],
+ "skill_behavior_eval": false,
+ "classification_confidence": "high",
+ "classification_basis": "Exercises portable task spec contract, pi adapter argv/stream normalization and run_dispatch fail-closed chain with fake pi streams and injected DB connections; no network, no real framework binary."
+ },
+ {
+ "path": "tests/skills/record-run-evidence/test_agent_trace.py",
+ "scope": "runtime_skill",
+ "owner_skill_or_domain": "record-run-evidence",
+ "kind": "runtime_contract",
+ "evidence_level": "deterministic_offline",
+ "requires": [
+ "offline"
+ ],
+ "side_effects": [
+ "none"
+ ],
+ "skill_behavior_eval": false,
+ "classification_confidence": "high",
+ "classification_basis": "Fixes agent event ledger writer shapes and atomic framework evidence persistence with fake connections; no DB, no network."
}
],
"summary": {
- "entry_count": 107,
+ "entry_count": 109,
"by_scope": {
"other": 1,
- "runtime_skill": 95,
+ "runtime_skill": 97,
"harness": 3,
"domain": 8
},
@@ -1766,14 +1799,16 @@
"harness_self_test": 3,
"domain_eval": 4,
"integration": 9,
- "tool_unit": 31,
+ "tool_unit": 32,
"fake_pipeline": 22,
- "runtime_probe": 2
+ "runtime_probe": 1,
+ "runtime_contract": 2
},
"by_evidence_level": {
- "deterministic_offline": 91,
+ "deterministic_offline": 93,
"real_dependency_integration": 10,
"static_structure": 6
- }
+ },
+ "total": 109
}
}
diff --git a/muse-llm/pyproject.toml b/muse-llm/pyproject.toml
index 5263b3e..a74bac9 100644
--- a/muse-llm/pyproject.toml
+++ b/muse-llm/pyproject.toml
@@ -3,7 +3,7 @@ name = "muse-llm"
version = "0.1.0"
description = "内容模型治理链、固定 Opus HTTP 适配器与角色执行运行时"
requires-python = ">=3.10"
-dependencies = ["requests", "json-repair", "jsonschema>=4.23,<5", "muse-db"]
+dependencies = ["requests", "json-repair", "jsonschema>=4.23,<5", "pyyaml", "muse-db"]
[build-system]
requires = ["setuptools>=61"]
@@ -11,4 +11,4 @@ build-backend = "setuptools.build_meta"
[tool.setuptools]
package-dir = {"" = "src"}
-py-modules = ["muse_llm", "muse_role"]
+py-modules = ["muse_llm", "muse_role", "muse_role_contract"]
diff --git a/muse-llm/src/muse_llm.py b/muse-llm/src/muse_llm.py
index 8c3a802..7fc7ce2 100644
--- a/muse-llm/src/muse_llm.py
+++ b/muse-llm/src/muse_llm.py
@@ -232,10 +232,97 @@ def governed_max_tokens(max_budget_usd, estimated_input_tokens):
return min(output_cap, 512000)
+def _read_anthropic_sse(response, *, deadline):
+ """按字节解析 Anthropic SSE,避免 UTF-8 的 C1 字节被 splitlines 误切。"""
+
+ message = {}
+ blocks = {}
+ usage = {}
+ raw_events = []
+ stop_reason = None
+ stop_sequence = None
+ saw_message_start = False
+ saw_message_stop = False
+
+ for raw_line in response.iter_lines(decode_unicode=False, delimiter=b"\n"):
+ if _monotonic() >= deadline:
+ raise requests.Timeout("固定 Opus SSE 超过总 deadline")
+ if isinstance(raw_line, str):
+ raw_line = raw_line.encode("utf-8")
+ line = bytes(raw_line).rstrip(b"\r")
+ if not line or line.startswith((b":", b"event:")) or not line.startswith(b"data:"):
+ continue
+ payload = line[5:].lstrip()
+ if not payload or payload == b"[DONE]":
+ continue
+ event = json.loads(payload.decode("utf-8"))
+ if not isinstance(event, dict):
+ raise ValueError("Anthropic SSE data 不是对象")
+ raw_events.append(event)
+ event_type = event.get("type")
+
+ if event_type == "message_start":
+ started_message = event.get("message")
+ if not isinstance(started_message, dict):
+ raise ValueError("Anthropic SSE 缺少 message_start.message")
+ message = dict(started_message)
+ usage.update(started_message.get("usage") or {})
+ for index, block in enumerate(started_message.get("content") or []):
+ if isinstance(block, dict):
+ blocks[index] = dict(block)
+ saw_message_start = True
+ elif event_type == "content_block_start":
+ index = event.get("index")
+ block = event.get("content_block")
+ if type(index) is not int or not isinstance(block, dict):
+ raise ValueError("Anthropic SSE content_block_start 非法")
+ blocks[index] = dict(block)
+ elif event_type == "content_block_delta":
+ index = event.get("index")
+ delta = event.get("delta")
+ if type(index) is not int or not isinstance(delta, dict):
+ raise ValueError("Anthropic SSE content_block_delta 非法")
+ block = blocks.setdefault(index, {})
+ delta_type = delta.get("type")
+ field = {
+ "text_delta": "text",
+ "thinking_delta": "thinking",
+ "signature_delta": "signature",
+ "input_json_delta": "partial_json",
+ }.get(delta_type)
+ if field is not None:
+ block[field] = str(block.get(field) or "") + str(delta.get(field) or "")
+ elif event_type == "message_delta":
+ delta = event.get("delta") or {}
+ if not isinstance(delta, dict):
+ raise ValueError("Anthropic SSE message_delta.delta 非法")
+ stop_reason = delta.get("stop_reason", stop_reason)
+ stop_sequence = delta.get("stop_sequence", stop_sequence)
+ usage.update(event.get("usage") or {})
+ elif event_type == "message_stop":
+ saw_message_stop = True
+ elif event_type == "error":
+ error = event.get("error") or {}
+ error_type = error.get("type") if isinstance(error, dict) else None
+ raise requests.RequestException(
+ f"Anthropic SSE error: {error_type or 'unknown'}"
+ )
+
+ if not saw_message_start:
+ raise requests.RequestException("Anthropic SSE 缺少 message_start")
+ if not saw_message_stop:
+ raise requests.RequestException("Anthropic SSE 提前结束,缺少 message_stop")
+ message["content"] = [blocks[index] for index in sorted(blocks)]
+ message["usage"] = usage
+ message["stop_reason"] = stop_reason
+ message["stop_sequence"] = stop_sequence
+ return message, raw_events
+
+
def chat_fixed_opus(prompt, model="opus", system=None, max_tokens=FIXED_OPUS_MAX_OUTPUT_TOKENS,
temperature=0.2, top_p=None, timeout=900, retries=2, *,
resolved_model_id=None, run_id=None, caller=None, persist_call=None):
- """通过 Anthropic 兼容 HTTP API 调用固定 Opus,不读取客户端配置、不降级模型。"""
+ """通过 Anthropic 兼容 SSE 调用固定 Opus,不读取客户端配置、不降级模型。"""
base = os.environ.get("MUSE_ROLE_OPUS_BASE_URL", "").rstrip("/")
token = os.environ.get("MUSE_ROLE_OPUS_AUTH_TOKEN", "")
@@ -257,6 +344,7 @@ def chat_fixed_opus(prompt, model="opus", system=None, max_tokens=FIXED_OPUS_MAX
"temperature": temperature,
"system": system or "",
"messages": [{"role": "user", "content": prompt}],
+ "stream": True,
}
if top_p is not None:
payload["top_p"] = top_p
@@ -276,18 +364,20 @@ def chat_fixed_opus(prompt, model="opus", system=None, max_tokens=FIXED_OPUS_MAX
if remaining <= 0:
raise RuntimeError("固定 Opus 调用超过总 deadline")
started = time.time()
+ response = None
try:
response = session.post(
f"{base}/v1/messages",
headers=headers,
json=payload,
timeout=(min(10, remaining), remaining),
+ stream=True,
)
if response.status_code == 429 or response.status_code >= 500:
last_error = f"HTTP {response.status_code}: {response.text[:200]}"
raise requests.RequestException(last_error)
response.raise_for_status()
- data = response.json()
+ data, raw_events = _read_anthropic_sse(response, deadline=deadline)
blocks = data.get("content")
if not isinstance(blocks, list):
raise KeyError("content")
@@ -320,12 +410,18 @@ def chat_fixed_opus(prompt, model="opus", system=None, max_tokens=FIXED_OPUS_MAX
"stop_reason": data.get("stop_reason"),
"duration_ms": duration_ms,
"prompt": prompt_raw,
- "response": json.dumps(data, ensure_ascii=False, sort_keys=True,
- separators=(",", ":"), default=str),
+ "response": json.dumps(
+ {"transport": "anthropic-sse", "events": raw_events},
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ default=str,
+ ),
"role": caller,
})
return content, usage, actual_model
- except (requests.RequestException, KeyError, json.JSONDecodeError) as error:
+ except (requests.RequestException, KeyError, json.JSONDecodeError,
+ UnicodeDecodeError, ValueError) as error:
last_error = str(error)
if attempt < retries:
wait = 2 * (2 ** attempt)
@@ -333,6 +429,9 @@ def chat_fixed_opus(prompt, model="opus", system=None, max_tokens=FIXED_OPUS_MAX
raise RuntimeError("固定 Opus 调用超过总 deadline") from error
print(f"[llm] 固定 Opus 第{attempt + 1}次失败,{wait}s 后重试", file=sys.stderr)
time.sleep(wait)
+ finally:
+ if response is not None:
+ response.close()
raise RuntimeError(f"固定 Opus 调用重试耗尽: {last_error}")
diff --git a/muse-llm/src/muse_role.py b/muse-llm/src/muse_role.py
index 866aa8d..077a50b 100644
--- a/muse-llm/src/muse_role.py
+++ b/muse-llm/src/muse_role.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""muse_role:provider-neutral 的受治理角色执行运行时。
-角色执行走 07-Agent与Skill领域 §2 派发合同:角色文件全文作系统提示词、冻结输入、
+角色执行走 07-Agent与Skill领域 §2 派发合同:身份提示与中心角色合同作系统提示词、冻结输入、
输出校验、证据落库。本模块是自动化管线侧的承载:系统提示词 + 冻结 JSON 输入经
muse_llm 的固定 Opus HTTP 适配器或内容模型治理链调用,输出按冻结 schema 校验并
构造回执。writer/planner/judge 的 profile 绑定完整 Opus 模型 ID 且禁止降级;只有明确
@@ -32,7 +32,7 @@ SUPPORTED_ROLES = frozenset(
{"writer", "semantic_detector", "blind_judge", "planner", "extractor"}
)
RUNTIME_ADAPTER = "muse-role"
-RUNTIME_ADAPTER_VERSION = "muse-role-v3"
+RUNTIME_ADAPTER_VERSION = "muse-role-v4"
FIXED_OPUS_POLICY_ALIAS = "opus"
FIXED_OPUS_POLICY_VERSION = "fixed-opus-v1"
FIXED_OPUS_MODEL_ID = muse_llm.FIXED_OPUS_DEFAULT_MODEL
@@ -93,7 +93,7 @@ def sha256_json(value: Any) -> str:
def compose_role_system_prompt(role_prompt: str, task_prompt: str) -> str:
- """把角色文件全文与单次功能合同组合成幂等的 system prompt。"""
+ """把已装配的角色身份/合同提示与单次功能合同组合成幂等 system prompt。"""
role = role_prompt.rstrip()
task = task_prompt.strip()
@@ -103,15 +103,20 @@ def compose_role_system_prompt(role_prompt: str, task_prompt: str) -> str:
return task if task.startswith(prefix) else prefix + task
+def format_schema_contract(json_schema: Mapping[str, Any]) -> str:
+ """把冻结 JSON Schema 渲染成注入 system prompt 的确定性结构化输出合同段。"""
+
+ return (
+ "\n\n--- 结构化输出合同 ---\n"
+ + "只返回一个符合以下 JSON Schema 的 JSON 对象;不要输出 Markdown、解释或额外文本。\n"
+ + canonical_json(json_schema)
+ )
+
+
def build_dispatch_system_prompt(profile: "RoleExecutionProfile") -> str:
"""把冻结角色 prompt 与 schema 组合成模型实际接收的确定性系统提示。"""
- return (
- profile.system_prompt.rstrip()
- + "\n\n--- 结构化输出合同 ---\n"
- + "只返回一个符合以下 JSON Schema 的 JSON 对象;不要输出 Markdown、解释或额外文本。\n"
- + canonical_json(profile.json_schema)
- )
+ return profile.system_prompt.rstrip() + format_schema_contract(profile.json_schema)
# WHY: 路径穿越判定必须是「路径分量级」的精确匹配,而不是 `".." in value` 子串匹配。
@@ -620,6 +625,7 @@ __all__ = [
"canonical_json",
"compose_role_system_prompt",
"contains_path_traversal",
+ "format_schema_contract",
"model_matches_profile",
"run_role",
"sha256_json",
diff --git a/muse-llm/src/muse_role_contract.py b/muse-llm/src/muse_role_contract.py
new file mode 100644
index 0000000..b8faa73
--- /dev/null
+++ b/muse-llm/src/muse_role_contract.py
@@ -0,0 +1,160 @@
+"""从单一角色合同文档加载角色派发合同。"""
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Mapping
+
+import yaml
+
+ROLE_CONTRACT_VERSION = "role-contracts-v1"
+ROLE_CONTRACT_RELATIVE_PATH = Path(".agent") / "docs" / "architecture" / "角色合同.md"
+ROLE_NAMES = frozenset({"writer", "planner", "extractor", "detector", "judge"})
+_ROLE_SECTION = re.compile(
+ r""
+ r"(?P.*?)"
+ r"",
+ re.DOTALL,
+)
+
+
+class RoleContractError(ValueError):
+ """角色合同文档缺失、结构非法或与角色目录不一致。"""
+
+
+@dataclass(frozen=True)
+class RoleContract:
+ name: str
+ display_name: str
+ prompt_file: str
+ model_policy: str
+ model_policy_version: str
+ explicit_model_required: bool
+ tool_policy: str
+ contract_prompt: str
+ contract_sha256: str
+
+
+@dataclass(frozen=True)
+class RoleContractCatalog:
+ version: str
+ source_path: str
+ roles: Mapping[str, RoleContract]
+
+ def for_role(self, role: str) -> RoleContract:
+ try:
+ return self.roles[role]
+ except KeyError as exc:
+ raise RoleContractError(f"角色未登记在角色合同文档: {role}") from exc
+
+
+def _read_frontmatter(text: str, path: Path) -> tuple[Mapping[str, Any], str]:
+ lines = text.splitlines(keepends=True)
+ if not lines or lines[0].strip() != "---":
+ raise RoleContractError(f"角色合同缺少 YAML frontmatter: {path}")
+ end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
+ if end is None:
+ raise RoleContractError(f"角色合同 frontmatter 未闭合: {path}")
+ try:
+ data = yaml.safe_load("".join(lines[1:end])) or {}
+ except yaml.YAMLError as exc:
+ raise RoleContractError(f"角色合同 frontmatter 不是合法 YAML: {path}") from exc
+ if not isinstance(data, Mapping):
+ raise RoleContractError(f"角色合同 frontmatter 必须是对象: {path}")
+ return data, "".join(lines[end + 1 :])
+
+
+def _contract_hash(role: str, raw: Mapping[str, Any], text: str) -> str:
+ payload = json.dumps(
+ {"role": role, "metadata": dict(raw), "contract": text},
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ )
+ return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+
+def load_role_contract_catalog(repo_root: str | Path) -> RoleContractCatalog:
+ """读取并严格校验唯一角色合同文档。"""
+
+ path = Path(repo_root) / ROLE_CONTRACT_RELATIVE_PATH
+ try:
+ text = path.read_text(encoding="utf-8")
+ except (OSError, UnicodeError) as exc:
+ raise RoleContractError(f"角色合同文档不可读: {path}") from exc
+ frontmatter, body = _read_frontmatter(text, path)
+ if frontmatter.get("schemaVersion") != ROLE_CONTRACT_VERSION:
+ raise RoleContractError(
+ f"角色合同版本必须是 {ROLE_CONTRACT_VERSION}: {frontmatter.get('schemaVersion')!r}"
+ )
+ raw_roles = frontmatter.get("roles")
+ if not isinstance(raw_roles, Mapping) or set(raw_roles) != ROLE_NAMES:
+ actual = sorted(raw_roles) if isinstance(raw_roles, Mapping) else raw_roles
+ raise RoleContractError(f"角色合同必须精确登记 {sorted(ROLE_NAMES)},实际 {actual}")
+
+ sections: dict[str, str] = {}
+ for match in _ROLE_SECTION.finditer(body):
+ role = match.group("role")
+ if role in sections:
+ raise RoleContractError(f"角色合同章节重复: {role}")
+ sections[role] = match.group("body").strip()
+ if set(sections) != ROLE_NAMES:
+ raise RoleContractError(
+ f"角色合同正文必须精确包含 {sorted(ROLE_NAMES)},实际 {sorted(sections)}"
+ )
+
+ roles: dict[str, RoleContract] = {}
+ for role in sorted(ROLE_NAMES):
+ raw = raw_roles[role]
+ if not isinstance(raw, Mapping):
+ raise RoleContractError(f"角色合同登记必须是对象: {role}")
+ required = {
+ "displayName",
+ "promptFile",
+ "modelPolicy",
+ "modelPolicyVersion",
+ "explicitModelRequired",
+ "toolPolicy",
+ }
+ if set(raw) != required:
+ raise RoleContractError(
+ f"角色 {role} 的登记字段必须是 {sorted(required)},实际 {sorted(raw)}"
+ )
+ prompt_file = str(raw["promptFile"])
+ if prompt_file != f".agent/agents/{role}.md":
+ raise RoleContractError(f"角色 {role} 的 promptFile 不正确: {prompt_file}")
+ contract_prompt = sections[role]
+ if not contract_prompt:
+ raise RoleContractError(f"角色合同正文为空: {role}")
+ if not isinstance(raw["explicitModelRequired"], bool):
+ raise RoleContractError(f"角色 {role} 的 explicitModelRequired 必须是布尔值")
+ roles[role] = RoleContract(
+ name=role,
+ display_name=str(raw["displayName"]),
+ prompt_file=prompt_file,
+ model_policy=str(raw["modelPolicy"]),
+ model_policy_version=str(raw["modelPolicyVersion"]),
+ explicit_model_required=raw["explicitModelRequired"],
+ tool_policy=str(raw["toolPolicy"]),
+ contract_prompt=contract_prompt,
+ contract_sha256=_contract_hash(role, raw, contract_prompt),
+ )
+ return RoleContractCatalog(
+ version=ROLE_CONTRACT_VERSION,
+ source_path=path.relative_to(Path(repo_root)).as_posix(),
+ roles=roles,
+ )
+
+
+__all__ = [
+ "ROLE_CONTRACT_RELATIVE_PATH",
+ "ROLE_CONTRACT_VERSION",
+ "ROLE_NAMES",
+ "RoleContract",
+ "RoleContractCatalog",
+ "RoleContractError",
+ "load_role_contract_catalog",
+]
diff --git a/tests/architecture/test_import_boundaries.py b/tests/architecture/test_import_boundaries.py
index 6477516..095d0aa 100644
--- a/tests/architecture/test_import_boundaries.py
+++ b/tests/architecture/test_import_boundaries.py
@@ -24,6 +24,11 @@ ACTIVE_RUNTIME_ROOTS = (
ROOT / "muse-embed",
ROOT / "docs" / "write-chapter",
)
+# 框架派发接缝:唯一允许直接调用 Agent 框架二进制的适配器文件(dispatch-agent-task)。
+# 新增框架适配器(codex/opencode…)必须挂在本 Skill scripts/ 下并在此登记。
+FRAMEWORK_ADAPTER_ALLOWLIST = frozenset(
+ {".agent/skills/dispatch-agent-task/scripts/pi_runner.py"}
+)
FORBIDDEN_CLAUDE_RUNTIME_TOKENS = (
"claude_runtime",
"muse-claude-runtime",
@@ -103,15 +108,20 @@ class ImportBoundaryTest(unittest.TestCase):
):
continue
text = path.read_text(encoding="utf-8")
+ rel = path.relative_to(ROOT).as_posix()
if any(token in text for token in FORBIDDEN_CLAUDE_RUNTIME_TOKENS):
- offenders.append(path.relative_to(ROOT).as_posix())
+ offenders.append(rel)
+ continue
+ # 框架适配器是被批准的唯一直接调用 Agent 框架二进制的位置(07 领域框架派发接缝);
+ # 其余任何位置 shell 调模型/框架 CLI 仍被阻断。
+ if rel in FRAMEWORK_ADAPTER_ALLOWLIST:
continue
if re.search(
r"(?:/bin/claude|[\"']claude[\"']\s*,\s*[\"']--version|"
r"[\"']pi[\"']\s*,|\bpi\s+--model)",
text,
):
- offenders.append(path.relative_to(ROOT).as_posix())
+ offenders.append(rel)
self.assertEqual(
sorted(set(offenders)),
[],
diff --git a/tests/skills/access-database/test_skill_catalog.py b/tests/skills/access-database/test_skill_catalog.py
index 2b25e0c..1bf183f 100644
--- a/tests/skills/access-database/test_skill_catalog.py
+++ b/tests/skills/access-database/test_skill_catalog.py
@@ -10,6 +10,7 @@ PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3]
SCRIPTS_DIR = PROJECT_ROOT / ".agent" / "skills" / "access-database" / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
+from muse_role_contract import load_role_contract_catalog
from sync_agent_registry import validate_role_catalog, validate_skill_catalog
@@ -22,13 +23,22 @@ class SkillCatalogTest(unittest.TestCase):
)
self.assertTrue(all("model" not in fm for _, fm in entries))
+ def test_role_contract_is_central_and_complete(self):
+ catalog = load_role_contract_catalog(PROJECT_ROOT)
+ self.assertEqual(
+ set(catalog.roles),
+ {"writer", "planner", "extractor", "detector", "judge"},
+ )
+ self.assertTrue(all(role.contract_prompt for role in catalog.roles.values()))
+ self.assertTrue(all(role.explicit_model_required for role in catalog.roles.values()))
+
def test_role_frontmatter_rejects_static_model_binding(self):
with tempfile.TemporaryDirectory() as tmp:
root = pathlib.Path(tmp)
for role in ("writer", "planner", "extractor", "detector", "judge"):
extra = "model: opus\n" if role == "writer" else ""
(root / f"{role}.md").write_text(
- f"---\nname: {role}\ndescription: test\n{extra}---\n",
+ f"---\nname: {role}\ndescription: test\nskills: test-skill\ntools: read\n{extra}---\n",
encoding="utf-8",
)
with self.assertRaisesRegex(ValueError, "未登记字段"):
diff --git a/tests/skills/decide-candidate/test_next_steps_offline.py b/tests/skills/decide-candidate/test_next_steps_offline.py
index b1b3a50..bde7dea 100644
--- a/tests/skills/decide-candidate/test_next_steps_offline.py
+++ b/tests/skills/decide-candidate/test_next_steps_offline.py
@@ -43,7 +43,7 @@ class NextStepsOfflineTest(unittest.TestCase):
self.assertTrue(all(s.get("auto") is False for s in steps))
def test_generation_entry_cannot_call_accept(self):
- source = (ROOT / "docs" / "write-chapter" / "step2_write_chapter.py").read_text(
+ source = (ROOT / ".agent" / "skills" / "write-next-chapter" / "scripts" / "produce_next_chapter.py").read_text(
encoding="utf-8"
)
tree = ast.parse(source)
diff --git a/tests/skills/dispatch-agent-task/test_dispatch_agent_task.py b/tests/skills/dispatch-agent-task/test_dispatch_agent_task.py
new file mode 100644
index 0000000..907dca2
--- /dev/null
+++ b/tests/skills/dispatch-agent-task/test_dispatch_agent_task.py
@@ -0,0 +1,552 @@
+#!/usr/bin/env python3
+"""dispatch-agent-task 的离线测试(不连库、不连网、不启动真 pi)。
+
+用假 pi 事件流(与 pi --mode json 真实线格式一致)与假数据库连接固定:
+任务包合同、argv 构造、事件归一、run_dispatch 全链失败关闭与回执形状。
+"""
+from __future__ import annotations
+
+import json
+import pathlib
+import stat
+import sys
+import tempfile
+import unittest
+
+
+PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3]
+SKILL_DIR = PROJECT_ROOT / ".agent" / "skills" / "dispatch-agent-task" / "scripts"
+EVIDENCE_DIR = PROJECT_ROOT / ".agent" / "skills" / "record-run-evidence" / "scripts"
+for path in (SKILL_DIR, EVIDENCE_DIR):
+ if str(path) not in sys.path:
+ sys.path.insert(0, str(path))
+
+import agent_task # noqa: E402
+from agent_task import ( # noqa: E402
+ OutputInvalidError,
+ TaskSpecError,
+ build_task_package,
+ load_spec,
+ validate_structured_output,
+)
+from pi_runner import ( # noqa: E402
+ ExecutionPolicy,
+ FrameworkError,
+ PiAgentRunner,
+ build_pi_argv,
+)
+from dispatch_agent_task import EXIT_OUTPUT_INVALID, run_dispatch # noqa: E402
+from agent_trace import AgentTraceWriter # noqa: E402
+
+REPO_ROOT = PROJECT_ROOT
+
+OUTPUT_SCHEMA = {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "title": {"type": "string", "minLength": 1},
+ "beats": {"type": "array", "minItems": 1, "items": {"type": "string"}},
+ },
+ "required": ["title", "beats"],
+ "additionalProperties": False,
+}
+
+
+def make_spec(tmp: pathlib.Path, **overrides) -> pathlib.Path:
+ spec = {
+ "specVersion": "agent-task-v1",
+ "role": "planner",
+ "taskPrompt": "为一幕场景给出三拍结构",
+ "input": {"premise": "深空站失联前的最后八小时"},
+ "outputSchema": OUTPUT_SCHEMA,
+ "outputSchemaId": "planner-mini-outline-v1",
+ "toolAllowlist": [],
+ "maxDurationSeconds": 120,
+ }
+ spec.update(overrides)
+ path = tmp / "task.json"
+ path.write_text(json.dumps(spec, ensure_ascii=False), encoding="utf-8")
+ return path
+
+
+def pi_stream_lines(final_text: str, *, with_tool: bool = False, model: str = "m-a"):
+ """构造与 pi --mode json 一致的假事件流(含 usage/model/stopReason)。"""
+ assistant_final = {
+ "role": "assistant",
+ "content": [{"type": "text", "text": final_text}],
+ "model": model,
+ "provider": "prov",
+ "usage": {"input": 100, "output": 40, "cacheRead": 10, "cost": {"total": 0.012}},
+ "stopReason": "stop",
+ }
+ lines = [
+ {"type": "session", "version": 3, "id": "sess-1", "cwd": "/tmp"},
+ {"type": "agent_start"},
+ {"type": "turn_start"},
+ {"type": "message_end", "message": {"role": "user", "content": []}},
+ ]
+ if with_tool:
+ assistant_tool = {
+ "role": "assistant",
+ "content": [{"type": "tool_call", "id": "t1", "name": "read", "arguments": {}}],
+ "model": model,
+ "provider": "prov",
+ "usage": {"input": 90, "output": 5, "cost": {"total": 0.001}},
+ "stopReason": "tool_use",
+ }
+ lines += [
+ {"type": "message_end", "message": assistant_tool},
+ {"type": "tool_execution_start", "toolCallId": "t1", "toolName": "read", "args": {}},
+ {"type": "tool_execution_end", "toolCallId": "t1", "toolName": "read", "result": "ok", "isError": False},
+ {"type": "turn_start"},
+ ]
+ lines += [
+ {"type": "message_end", "message": assistant_final},
+ {"type": "turn_end", "message": assistant_final, "toolResults": []},
+ {"type": "agent_end", "messages": [{"role": "user", "content": []}, assistant_final]},
+ {"type": "agent_settled"},
+ ]
+ return [json.dumps(line, ensure_ascii=False).encode("utf-8") + b"\n" for line in lines]
+
+
+class FakeStream:
+ def __init__(self, lines, exit_code=0, timed_out=False):
+ self._lines = lines
+ self.exit_code = exit_code
+ self.timed_out = timed_out
+
+ def __iter__(self):
+ yield from self._lines
+
+ def close(self):
+ return None
+
+
+def fake_launcher(lines, exit_code=0, timed_out=False):
+ def _launch(argv, timeout, cwd):
+ assert argv[0] == "pi", argv
+ return FakeStream(list(lines), exit_code=exit_code, timed_out=timed_out)
+
+ return _launch
+
+
+class RecordingConnect:
+ """捕获全部写入语句的假连接工厂(事件 + 证据 + run 登记)。"""
+
+ def __init__(self):
+ self.log = []
+
+ def __call__(self, *args, **kwargs):
+ outer = self
+
+ class _Cursor:
+ def execute(self, sql, params=None):
+ outer.log.append((sql, params))
+ return self
+
+ def fetchone(self):
+ sql = outer.log[-1][0]
+ if sql.startswith("SELECT run_id, work_id"):
+ # start_run 回读:返回与入参一致的绑定行。
+ params = outer.log[-1][1]
+ return ("row", None, None, "running")
+ if sql.startswith("INSERT INTO example_run"):
+ return ("row",)
+ if sql.startswith("UPDATE example_run"):
+ return ("row", "failed" if "failed" in (params := outer.log[-1][1]) else "completed", None)
+ RecordingConnect.next_id += 1
+ return (RecordingConnect.next_id,)
+
+ def fetchall(self):
+ return []
+
+ def commit(self):
+ outer.log.append(("COMMIT", None))
+
+ def rollback(self):
+ outer.log.append(("ROLLBACK", None))
+
+ class _Ctx:
+ def __enter__(self):
+ return _Cursor()
+
+ def __exit__(self, *exc):
+ return False
+
+ return _Ctx()
+
+ next_id = 5000
+
+ def event_rows(self):
+ return [p for sql, p in self.log if sql.startswith("INSERT INTO example_agent_event")]
+
+ def run_rows(self):
+ return [p for sql, p in self.log if sql.startswith("INSERT INTO example_run")]
+
+
+class TaskSpecTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = pathlib.Path(tempfile.mkdtemp())
+
+ def test_load_and_hash_binding(self):
+ path = make_spec(self.tmp)
+ spec = load_spec(path)
+ self.assertEqual(spec.role, "planner")
+ # 写错 input 哈希必须失败关闭。
+ bad = json.loads(path.read_text())
+ bad["inputSha256"] = "sha256:" + "0" * 64
+ path2 = self.tmp / "bad.json"
+ path2.write_text(json.dumps(bad), encoding="utf-8")
+ with self.assertRaises(TaskSpecError):
+ load_spec(path2)
+
+ def test_accepts_role_file_names_and_rejects_unknown_role(self):
+ for role in ("writer", "planner", "detector", "judge", "extractor"):
+ with self.subTest(role=role):
+ spec = load_spec(make_spec(self.tmp, role=role))
+ self.assertEqual(build_task_package(spec, REPO_ROOT).spec.role, role)
+ with self.assertRaises(TaskSpecError):
+ load_spec(make_spec(self.tmp, role="semantic_detector"))
+ with self.assertRaises(TaskSpecError):
+ load_spec(make_spec(self.tmp, role="hacker"))
+
+ def test_rejects_bad_schema_and_empty_prompt(self):
+ with self.assertRaises(TaskSpecError):
+ load_spec(make_spec(self.tmp, outputSchema={"type": "no-such-type"}))
+ with self.assertRaises(TaskSpecError):
+ load_spec(make_spec(self.tmp, taskPrompt=" "))
+
+ def test_rejects_nonportable_or_malformed_fields(self):
+ invalid_overrides = (
+ {"provider": "p"},
+ {"toolAllowlist": "read"},
+ {"toolAllowlist": ["read tool"]},
+ {"toolAllowlist": ["read", "read"]},
+ {"maxDurationSeconds": 0},
+ {"maxDurationSeconds": float("nan")},
+ {"workId": "1"},
+ {"targetChapter": True},
+ )
+ for overrides in invalid_overrides:
+ with self.subTest(overrides=overrides), self.assertRaises(TaskSpecError):
+ load_spec(make_spec(self.tmp, **overrides))
+ malformed = self.tmp / "malformed.json"
+ malformed.write_text("{", encoding="utf-8")
+ with self.assertRaises(TaskSpecError):
+ load_spec(malformed)
+ nonstandard = self.tmp / "nonstandard.json"
+ nonstandard.write_text(json.dumps({"specVersion": float("nan")}), encoding="utf-8")
+ with self.assertRaises(TaskSpecError):
+ load_spec(nonstandard)
+
+ def test_spec_hash_binds_scope_and_deadline(self):
+ base = build_task_package(load_spec(make_spec(self.tmp)), REPO_ROOT)
+ scoped = build_task_package(
+ load_spec(make_spec(self.tmp, workId=7, targetChapter=3)), REPO_ROOT
+ )
+ slower = build_task_package(
+ load_spec(make_spec(self.tmp, maxDurationSeconds=121)), REPO_ROOT
+ )
+ self.assertNotEqual(base.spec_sha256, scoped.spec_sha256)
+ self.assertNotEqual(base.spec_sha256, slower.spec_sha256)
+
+
+class PackageTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = pathlib.Path(tempfile.mkdtemp())
+
+ def test_system_prompt_is_identity_plus_role_contract_plus_schema(self):
+ package = build_task_package(load_spec(make_spec(self.tmp)), REPO_ROOT)
+ role_text = (REPO_ROOT / ".agent" / "agents" / "planner.md").read_text(encoding="utf-8")
+ self.assertTrue(package.system_prompt.startswith(role_text.rstrip()))
+ self.assertIn("--- 角色合同(唯一事实源) ---", package.system_prompt)
+ self.assertIn("## planner:规划师", package.system_prompt)
+ self.assertIn("--- 结构化输出合同 ---", package.system_prompt)
+ self.assertIn('"$schema"', package.system_prompt)
+ self.assertIn("--- 冻结输入 ---", package.user_message)
+ self.assertIn("深空站失联前的最后八小时", package.user_message)
+ identity = package.as_identity()
+ self.assertEqual(identity["toolAllowlist"], [])
+ self.assertEqual(identity["roleContractVersion"], "role-contracts-v1")
+ self.assertEqual(identity["roleContractSource"], ".agent/docs/architecture/角色合同.md")
+ self.assertTrue(all(len(v) == 71 and v.startswith("sha256:") for k, v in identity.items() if k.endswith("Sha256")))
+
+
+class ExecutionPolicyTest(unittest.TestCase):
+ def test_provider_and_model_are_explicit(self):
+ with self.assertRaisesRegex(ValueError, "provider"):
+ ExecutionPolicy(model="m")
+ with self.assertRaisesRegex(ValueError, "model"):
+ ExecutionPolicy(provider="p")
+ self.assertEqual(
+ ExecutionPolicy(provider="p", model="m").requested_model_id,
+ "p/m",
+ )
+
+
+class ArgvTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = pathlib.Path(tempfile.mkdtemp())
+
+ def test_argv_injects_prompt_tools_and_isolation(self):
+ package = build_task_package(load_spec(make_spec(self.tmp)), REPO_ROOT)
+ argv = build_pi_argv(package, ExecutionPolicy(provider="p", model="m", thinking="low"))
+ joined = " ".join(argv)
+ self.assertIn("--system-prompt", argv)
+ self.assertEqual(argv[argv.index("--system-prompt") + 1], package.system_prompt)
+ self.assertEqual(argv[-1], package.user_message)
+ self.assertIn("--no-context-files", joined)
+ self.assertIn("--no-skills", joined)
+ self.assertIn("--no-extensions", joined)
+ self.assertIn("--mode", joined)
+ self.assertEqual(argv[argv.index("--provider") + 1], "p")
+ self.assertEqual(argv[argv.index("--model") + 1], "m")
+ self.assertIn("--no-tools", joined)
+
+ def test_tool_allowlist_maps_to_tools_flag(self):
+ spec_path = make_spec(pathlib.Path(tempfile.mkdtemp()), toolAllowlist=["read", "bash"])
+ package = build_task_package(load_spec(spec_path), REPO_ROOT)
+ argv = build_pi_argv(package, ExecutionPolicy(provider="p", model="m"))
+ self.assertIn("--tools", argv)
+ self.assertEqual(argv[argv.index("--tools") + 1], "read,bash")
+
+
+class RunnerParseTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = pathlib.Path(tempfile.mkdtemp())
+
+ def _sink(self):
+ events = []
+
+ class Sink:
+ emit = AgentTraceWriter(run_id="t", framework="pi", agent_role="planner", connect=object())
+
+ # 直接构造一个记录型 sink,绕开数据库。
+ 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)
+
+ return RecordingSink()
+
+ def test_stream_parses_model_tools_and_final_text(self):
+ package = build_task_package(
+ load_spec(make_spec(self.tmp, toolAllowlist=["read"])), REPO_ROOT
+ )
+ sink = self._sink()
+ outcome = PiAgentRunner(launcher=fake_launcher(pi_stream_lines('{"title":"t","beats":["a"]}', with_tool=True))).run(
+ package, ExecutionPolicy(provider="p", model="m"), sink, timeout_seconds=10
+ )
+ self.assertEqual(outcome.final_text, '{"title":"t","beats":["a"]}')
+ self.assertEqual(len(outcome.model_calls), 2)
+ self.assertEqual(outcome.model_calls[-1].actual_model_id, "prov/m-a")
+ self.assertEqual(outcome.model_calls[-1].cost_usd, 0.012)
+ self.assertEqual(outcome.tool_calls[0].name, "read")
+ self.assertFalse(outcome.tool_calls[0].is_error)
+ types = [row[0] for row in sink.rows]
+ self.assertEqual(types, ["agent.started", "model.completed", "tool.started", "tool.completed", "model.completed", "agent.completed"])
+
+ def test_framework_failures_raise_with_stable_codes(self):
+ package = build_task_package(load_spec(make_spec(self.tmp)), REPO_ROOT)
+ for launcher, code in (
+ (fake_launcher(pi_stream_lines("x"), exit_code=1), "FRAMEWORK_EXIT_NONZERO"),
+ (fake_launcher(pi_stream_lines("x"), timed_out=True), "FRAMEWORK_TIMEOUT"),
+ (fake_launcher([b"not json\n"]), "STREAM_PARSE_ERROR"),
+ (fake_launcher([json.dumps({"type": "agent_end", "messages": []}).encode() + b"\n"]), "NO_MODEL_RESPONSE"),
+ (fake_launcher(pi_stream_lines("x", with_tool=True)), "TOOL_NOT_ALLOWED"),
+ (
+ fake_launcher(
+ pi_stream_lines("x")[:-4]
+ + [
+ json.dumps(
+ {
+ "type": "message_end",
+ "message": {
+ "role": "assistant",
+ "content": [],
+ "model": "m-a",
+ "provider": "prov",
+ "usage": {},
+ "stopReason": "error",
+ "errorMessage": "provider failed",
+ },
+ }
+ ).encode()
+ + b"\n"
+ ]
+ ),
+ "MODEL_TURN_FAILED",
+ ),
+ ):
+ with self.assertRaises(FrameworkError) as ctx:
+ PiAgentRunner(launcher=launcher).run(
+ package,
+ ExecutionPolicy(provider="p", model="m"),
+ self._sink(),
+ timeout_seconds=5,
+ )
+ self.assertEqual(ctx.exception.error_code, code)
+
+
+class RunDispatchTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = pathlib.Path(tempfile.mkdtemp())
+
+ def _dispatch(self, launcher, connect):
+ return run_dispatch(
+ make_spec(self.tmp),
+ repo_root=REPO_ROOT,
+ policy=ExecutionPolicy(provider="p", model="claude-opus-test"),
+ run_id="unittest-agent-dispatch-1",
+ run_dir=self.tmp / "run",
+ connect_factory=connect,
+ launcher=launcher,
+ trigger_source="diagnostic",
+ )
+
+ def test_success_path_events_and_receipt(self):
+ connect = RecordingConnect()
+ receipt, code = self._dispatch(
+ fake_launcher(pi_stream_lines('{"title":"重启","beats":["警报","分歧","决断"]}')), connect
+ )
+ self.assertEqual(code, 0)
+ self.assertEqual(receipt["status"], "completed")
+ self.assertEqual(receipt["requestedModelId"], "p/claude-opus-test")
+ self.assertEqual(receipt["usage"], {"inputTokens": 110, "outputTokens": 40, "cachedTokens": 10, "reasoningTokens": 0})
+ self.assertEqual(receipt["totalCostUsd"], 0.012)
+ self.assertTrue(receipt["costComplete"])
+ self.assertEqual(
+ [p[2] for p in connect.event_rows()],
+ ["run.started", "agent.started", "model.completed", "agent.completed", "run.completed"],
+ )
+ self.assertEqual(receipt["evidence"]["status"], "written")
+ llm_calls = [p for sql, p in connect.log if sql.startswith("INSERT INTO example_llm_call")]
+ self.assertIsNone(llm_calls[0][0]) # window_key 只属于 16 字符额度窗,run_id 走专列。
+ self.assertEqual(llm_calls[0][1], "unittest-agent-dispatch-1")
+ # 运行目录审计件齐全且只对当前用户开放。
+ run_dir = self.tmp / "run"
+ self.assertEqual(stat.S_IMODE(run_dir.stat().st_mode), 0o700)
+ for name in ("task-spec.json", "system-prompt.txt", "user-message.txt", "transcript.jsonl", "output.json", "receipt.json"):
+ path = run_dir / name
+ self.assertTrue(path.exists(), name)
+ self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600, name)
+
+ def test_schema_violation_fails_closed(self):
+ connect = RecordingConnect()
+ receipt, code = self._dispatch(
+ fake_launcher(pi_stream_lines('{"title":"重启"}')), connect # 缺 beats
+ )
+ self.assertEqual(code, EXIT_OUTPUT_INVALID)
+ self.assertEqual(receipt["errorCode"], "OUTPUT_SCHEMA_INVALID")
+ self.assertEqual(receipt["evidence"]["status"], "written")
+ self.assertEqual(
+ [p[2] for p in connect.event_rows()],
+ ["run.started", "agent.started", "model.completed", "agent.completed", "run.failed"],
+ )
+ self.assertTrue(any(sql.startswith("UPDATE example_run") for sql, _ in connect.log))
+
+ def test_secret_in_transcript_fails_without_leaking_receipt(self):
+ connect = RecordingConnect()
+ secret = "sk-abcdef0123456789abcdef012345"
+ receipt, code = self._dispatch(
+ fake_launcher(pi_stream_lines(json.dumps({"title": secret, "beats": ["x"]}))),
+ connect,
+ )
+ self.assertEqual(code, 5)
+ self.assertEqual(receipt["errorCode"], "RAW_SECRET_DETECTED")
+ self.assertNotIn(secret, json.dumps(receipt, ensure_ascii=False))
+ self.assertFalse((self.tmp / "run" / "transcript.jsonl").exists())
+
+ def test_framework_runs_from_repo_root(self):
+ seen = {}
+
+ def launcher(argv, timeout, cwd):
+ seen["cwd"] = cwd
+ return FakeStream(pi_stream_lines('{"title":"t","beats":["b"]}'))
+
+ receipt, code = self._dispatch(launcher, RecordingConnect())
+ self.assertEqual(code, 0)
+ self.assertEqual(receipt["status"], "completed")
+ self.assertEqual(seen["cwd"], str(REPO_ROOT.resolve()))
+
+ def test_framework_failure_marks_run_failed_and_persists_trace(self):
+ connect = RecordingConnect()
+ receipt, code = self._dispatch(fake_launcher([b"garbage\n"]), connect)
+ self.assertEqual(code, 3)
+ self.assertEqual(receipt["errorCode"], "STREAM_PARSE_ERROR")
+ self.assertEqual(receipt["evidence"]["status"], "written")
+ self.assertEqual(receipt["evidence"]["llmCallIds"], [])
+
+ def test_role_model_policy_is_checked_before_run_start(self):
+ connect = RecordingConnect()
+ receipt, code = run_dispatch(
+ make_spec(self.tmp),
+ repo_root=REPO_ROOT,
+ policy=ExecutionPolicy(provider="p", model="gpt-5.6-sol"),
+ run_id="role-policy-test",
+ run_dir=self.tmp / "role-policy-run",
+ connect_factory=connect,
+ launcher=fake_launcher(pi_stream_lines('{"title":"t","beats":["b"]}')),
+ )
+ self.assertEqual(code, 2)
+ self.assertEqual(receipt["errorCode"], "ROLE_MODEL_POLICY_MISMATCH")
+ self.assertFalse(any(sql.startswith("INSERT INTO example_run") for sql, _ in connect.log))
+
+ def test_trigger_detail_secret_is_rejected_before_run_start(self):
+ connect = RecordingConnect()
+ receipt, code = run_dispatch(
+ make_spec(self.tmp),
+ repo_root=REPO_ROOT,
+ policy=ExecutionPolicy(provider="p", model="claude-opus-test"),
+ run_id="safe-trigger-test",
+ run_dir=self.tmp / "trigger-run",
+ connect_factory=connect,
+ launcher=fake_launcher(pi_stream_lines('{"title":"t","beats":["b"]}')),
+ trigger_detail={"api_key": "sk-abcdef0123456789abcdef012345"},
+ )
+ self.assertEqual(code, 2)
+ self.assertEqual(receipt["errorCode"], "TRIGGER_DETAIL_INVALID")
+ self.assertFalse(any(sql.startswith("INSERT INTO example_run") for sql, _ in connect.log))
+
+ def test_run_id_cannot_escape_audit_root(self):
+ receipt, code = run_dispatch(
+ make_spec(self.tmp),
+ repo_root=REPO_ROOT,
+ policy=ExecutionPolicy(provider="p", model="claude-opus-test"),
+ run_id="../escape",
+ run_dir=self.tmp / "should-not-exist",
+ connect_factory=RecordingConnect(),
+ launcher=fake_launcher(pi_stream_lines('{"title":"t","beats":["b"]}')),
+ )
+ self.assertEqual(code, 2)
+ self.assertEqual(receipt["errorCode"], "RUN_ID_INVALID")
+ self.assertFalse((self.tmp / "should-not-exist").exists())
+
+
+class ValidateOutputTest(unittest.TestCase):
+ def test_extract_and_validate(self):
+ from agent_task import AgentTaskSpec
+
+ spec = AgentTaskSpec(
+ role="planner",
+ task_prompt="p",
+ input={},
+ output_schema=OUTPUT_SCHEMA,
+ output_schema_id="s1",
+ tool_allowlist=(),
+ )
+ out = validate_structured_output('前言```json\n{"title":"t","beats":["b"]}\n```', spec)
+ self.assertEqual(out["title"], "t")
+ with self.assertRaises(OutputInvalidError):
+ validate_structured_output('{"title":"t"}', spec)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/tests/skills/execute-role-task/test_muse_role.py b/tests/skills/execute-role-task/test_muse_role.py
index 83bff0b..f13c26d 100644
--- a/tests/skills/execute-role-task/test_muse_role.py
+++ b/tests/skills/execute-role-task/test_muse_role.py
@@ -3,6 +3,7 @@
from __future__ import annotations
+import json
import os
import unittest
from decimal import Decimal
@@ -99,17 +100,65 @@ def _fake_chat(payload, *, actual_model=muse_llm.FIXED_OPUS_CANONICAL_MODEL):
return chat
+class _FakeStreamResponse:
+ def __init__(self, events, *, status_code=200, text=""):
+ self.status_code = status_code
+ self.text = text
+ self._events = events
+ self.iter_lines_kwargs = None
+ self.closed = False
+
+ def raise_for_status(self):
+ if self.status_code >= 400:
+ raise muse_llm.requests.HTTPError(f"HTTP {self.status_code}")
+
+ def iter_lines(self, **kwargs):
+ self.iter_lines_kwargs = kwargs
+ for event in self._events:
+ if event == "[DONE]":
+ yield b"data: [DONE]"
+ else:
+ yield b"data: " + json.dumps(
+ event, ensure_ascii=False, separators=(",", ":")
+ ).encode("utf-8")
+ yield b""
+
+ def close(self):
+ self.closed = True
+
+
class FixedOpusTransportTests(unittest.TestCase):
- def test_fixed_opus_uses_env_http_without_model_fallback(self):
- response = mock.Mock()
- response.status_code = 200
- response.json.return_value = {
- "model": muse_llm.FIXED_OPUS_CANONICAL_MODEL,
- "content": [{"type": "text", "text": '{"candidateBody":"正文"}'}],
- "usage": {"input_tokens": 10, "output_tokens": 5},
- "stop_reason": "end_turn",
- }
- response.raise_for_status.return_value = None
+ def test_fixed_opus_streams_utf8_without_splitting_c1_bytes(self):
+ response = _FakeStreamResponse([
+ {
+ "type": "message_start",
+ "message": {
+ "model": muse_llm.FIXED_OPUS_CANONICAL_MODEL,
+ "usage": {"input_tokens": 10, "output_tokens": 0},
+ },
+ },
+ {
+ "type": "content_block_start",
+ "index": 0,
+ "content_block": {"type": "text", "text": ""},
+ },
+ {
+ "type": "content_block_delta",
+ "index": 0,
+ "delta": {
+ "type": "text_delta",
+ "text": '{"candidateBody":"内文"}',
+ },
+ },
+ {"type": "content_block_stop", "index": 0},
+ {
+ "type": "message_delta",
+ "delta": {"stop_reason": "end_turn"},
+ "usage": {"output_tokens": 5},
+ },
+ {"type": "message_stop"},
+ "[DONE]",
+ ])
session = mock.Mock()
session.post.return_value = response
events = []
@@ -137,13 +186,53 @@ class FixedOpusTransportTests(unittest.TestCase):
self.assertEqual(request.args[0], "https://role.example/v1/messages")
self.assertEqual(request.kwargs["json"]["model"], FIXED_OPUS_MODEL_ID)
self.assertEqual(request.kwargs["json"]["max_tokens"], 123)
- self.assertEqual(content, '{"candidateBody":"正文"}')
+ self.assertIs(request.kwargs["json"]["stream"], True)
+ self.assertIs(request.kwargs["stream"], True)
+ self.assertEqual(
+ response.iter_lines_kwargs,
+ {"decode_unicode": False, "delimiter": b"\n"},
+ )
+ self.assertTrue(response.closed)
+ self.assertEqual(content, '{"candidateBody":"内文"}')
self.assertEqual(usage["output_tokens"], 5)
self.assertEqual(actual, muse_llm.FIXED_OPUS_CANONICAL_MODEL)
self.assertEqual(events[0]["requested_model_id"], FIXED_OPUS_POLICY_ALIAS)
self.assertEqual(events[0]["actual_model_id"], muse_llm.FIXED_OPUS_CANONICAL_MODEL)
self.assertTrue(events[0]["model_match"])
+ def test_fixed_opus_rejects_stream_that_ends_without_message_stop(self):
+ response = _FakeStreamResponse([
+ {
+ "type": "message_start",
+ "message": {
+ "model": muse_llm.FIXED_OPUS_CANONICAL_MODEL,
+ "usage": {"input_tokens": 10, "output_tokens": 0},
+ },
+ },
+ {
+ "type": "content_block_start",
+ "index": 0,
+ "content_block": {"type": "text", "text": ""},
+ },
+ ])
+ session = mock.Mock()
+ session.post.return_value = response
+ env = {
+ "MUSE_ROLE_OPUS_BASE_URL": "https://role.example",
+ "MUSE_ROLE_OPUS_AUTH_TOKEN": "secret",
+ }
+ with (
+ mock.patch.dict(os.environ, env, clear=False),
+ mock.patch.object(muse_llm.requests, "Session", return_value=session),
+ ):
+ with self.assertRaisesRegex(RuntimeError, "message_stop"):
+ muse_llm.chat_fixed_opus(
+ "{}",
+ model=FIXED_OPUS_POLICY_ALIAS,
+ retries=0,
+ )
+ self.assertTrue(response.closed)
+
def test_fixed_opus_retry_respects_single_total_deadline(self):
response = mock.Mock(status_code=429, text="rate limited")
session = mock.Mock()
@@ -240,7 +329,7 @@ class RunRoleContractTests(unittest.TestCase):
receipt["structuredOutputSha256"],
sha256_json({"candidateBody": "正文内容"}),
)
- # 系统提示词必须原样注入治理调用(派发合同:角色文件全文进 system)。
+ # 系统提示词必须原样注入治理调用(派发合同:已装配的身份与角色合同进 system)。
self.assertEqual(chat.calls[0]["model"], profile.model_alias)
self.assertEqual(chat.calls[0]["system"], build_dispatch_system_prompt(profile))
self.assertTrue(chat.calls[0]["system"].startswith("你是测试角色。"))
diff --git a/tests/skills/record-run-evidence/test_agent_trace.py b/tests/skills/record-run-evidence/test_agent_trace.py
new file mode 100644
index 0000000..7d8cf1a
--- /dev/null
+++ b/tests/skills/record-run-evidence/test_agent_trace.py
@@ -0,0 +1,211 @@
+#!/usr/bin/env python3
+"""代理事件账本写路径的离线测试(不连库、不连网)。
+
+用假连接捕获 SQL 与参数,固定 AgentTraceWriter 与 persist_agent_evidence 的
+证据形状:事件闭集校验、序号单调、usage 归一、单事务原子性与密钥拦截。
+"""
+from __future__ import annotations
+
+import json
+import pathlib
+import sys
+import unittest
+
+
+PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3]
+SCRIPT_DIR = PROJECT_ROOT / ".agent" / "skills" / "record-run-evidence" / "scripts"
+if str(SCRIPT_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPT_DIR))
+
+import agent_trace # noqa: E402
+from agent_trace import AgentTraceWriter, model_ids_match, persist_agent_evidence # noqa: E402
+
+
+class FakeCursor:
+ def __init__(self, log: list) -> None:
+ self._log = log
+
+ def execute(self, sql, params=None):
+ self._log.append((sql, params))
+ return self
+
+ def fetchone(self):
+ # RETURNING id 模拟:每次自增。
+ FakeConn.next_id += 1
+ return (FakeConn.next_id,)
+
+ def fetchall(self):
+ return []
+
+ def commit(self):
+ self._log.append(("COMMIT", None))
+
+ def rollback(self):
+ self._log.append(("ROLLBACK", None))
+
+
+class FakeConn:
+ next_id = 1000
+
+ def __init__(self, log: list) -> None:
+ self._log = log
+
+ def execute(self, sql, params=None):
+ return FakeCursor(self._log).execute(sql, params)
+
+ def commit(self):
+ self._log.append(("COMMIT", None))
+
+ def rollback(self):
+ self._log.append(("ROLLBACK", None))
+
+
+class FakeConnect:
+ """返回上下文管理器形态的假连接,记录全部语句。"""
+
+ def __init__(self) -> None:
+ self.log: list = []
+
+ def __call__(self, *args, **kwargs):
+ outer = self
+
+ class _Ctx:
+ def __enter__(self):
+ return FakeConn(outer.log)
+
+ def __exit__(self, *exc):
+ return False
+
+ return _Ctx()
+
+ def sqls(self):
+ return [entry[0] for entry in self.log]
+
+ def params_of(self, sql_head: str):
+ for sql, params in self.log:
+ if sql.startswith(sql_head):
+ return params
+ raise AssertionError(f"未找到语句: {sql_head}")
+
+
+class ModelMatchTest(unittest.TestCase):
+ def test_model_match_normalizes_provider_resolution(self):
+ """框架把模式解析成完整 ID 时,比较模型叶名而不是误报漂移。"""
+ self.assertTrue(model_ids_match("claude-opus-5", "catproxy-anthropic/claude-opus-5"))
+ self.assertTrue(model_ids_match("catproxy-anthropic/claude-opus-5", "claude-opus-5"))
+ self.assertFalse(model_ids_match("catproxy-anthropic/claude-opus-5", "other/claude-opus-5"))
+ self.assertTrue(model_ids_match("gpt-5.6-sol", "GPT-5.6-Sol"))
+ self.assertTrue(model_ids_match("same", "same"))
+ self.assertFalse(model_ids_match("claude-opus-5", "claude-haiku-4-5"))
+ self.assertFalse(model_ids_match("", "x"))
+ self.assertFalse(model_ids_match(None, "x"))
+
+
+class AgentTraceWriterTest(unittest.TestCase):
+ def test_emit_inserts_with_monotonic_seq_and_normalized_usage(self):
+ conn = FakeConnect()
+ writer = AgentTraceWriter(run_id="r1", framework="pi", agent_role="planner", connect=conn)
+ writer.emit("run.started", status="ok", requested_model_id="m-a", details={"a": 1})
+ writer.emit(
+ "model.completed",
+ status="ok",
+ requested_model_id="m-a",
+ actual_model_id="prov/m-a",
+ usage={"input": 10, "cacheRead": 5, "output": 7, "reasoning": 3},
+ cost_usd=0.5,
+ )
+ self.assertEqual(writer.seq, 2)
+ insert = next(sql for sql in conn.sqls() if sql.startswith("INSERT INTO example_agent_event"))
+ rows = [params for sql, params in conn.log if sql.startswith("INSERT INTO example_agent_event")]
+ self.assertEqual(rows[0][1], 1)
+ self.assertEqual(rows[1][1], 2)
+ self.assertEqual(rows[1][9], 15) # input_tokens = input + cacheRead
+ self.assertEqual(rows[1][10], 7) # output_tokens
+ self.assertEqual(rows[1][11], 5) # cached_tokens
+ self.assertEqual(rows[1][12], 0.5) # cost_usd
+ self.assertTrue(insert)
+
+ def test_emit_rejects_unknown_type_and_incomplete_model_event(self):
+ writer = AgentTraceWriter(run_id="r1", framework="pi", agent_role="writer", connect=FakeConnect())
+ with self.assertRaises(ValueError):
+ writer.emit("made.up")
+ with self.assertRaises(ValueError):
+ writer.emit("model.completed", status="ok", actual_model_id=None)
+ with self.assertRaises(ValueError):
+ writer.emit("run.started", status="maybe")
+
+
+class PersistAgentEvidenceTest(unittest.TestCase):
+ BASE = dict(
+ run_id="r-evidence",
+ agent_role="planner",
+ system_prompt="ROLE PROMPT",
+ user_message="TASK + INPUT",
+ final_message='{"ok": true}',
+ transcript='{"type":"agent_start"}\n',
+ requested_model_id="m-a",
+ )
+ CALLS = [
+ {
+ "actual_model_id": "prov/m-a",
+ "usage": {"input": 3, "output": 4, "cacheRead": 5},
+ "stop_reason": "stop",
+ "cost_usd": 0.25,
+ }
+ ]
+
+ def test_success_writes_lease_contents_and_llm_calls_in_one_txn(self):
+ conn = FakeConnect()
+ result = persist_agent_evidence(connect=conn, creator="dispatch-agent-task", model_calls=self.CALLS, **self.BASE)
+ self.assertEqual(result["status"], "written")
+ self.assertIn("COMMIT", conn.sqls())
+ lease_params = next(
+ params for sql, params in conn.log if sql.startswith("INSERT INTO example_raw_lease")
+ )
+ self.assertEqual(set(json.loads(lease_params[2])), {"prompt", "response", "supplier"})
+ contents = [p for sql, p in conn.log if sql.startswith("INSERT INTO example_raw_content")]
+ kinds = {row[1] for row in contents}
+ self.assertEqual(kinds, {"prompt", "response", "supplier"})
+ prompt_text = next(row[5] for sql, row in conn.log if sql.startswith("INSERT INTO example_raw_content") and row[1] == "prompt")
+ self.assertEqual(json.loads(prompt_text), {"system": "ROLE PROMPT", "user": "TASK + INPUT"})
+ calls = [p for sql, p in conn.log if sql.startswith("INSERT INTO example_llm_call")]
+ self.assertEqual(len(calls), 1)
+ self.assertIsNone(calls[0][0]) # window_key 不是 run_id 的替代列
+ self.assertEqual(calls[0][1], "r-evidence")
+ self.assertEqual(calls[0][4], "prov/m-a")
+ self.assertTrue(calls[0][5]) # model_match
+ self.assertEqual(calls[0][6], 8) # in = 3+5
+ self.assertEqual(calls[0][7], 5) # cached
+ self.assertEqual(calls[0][8], 4) # out
+
+ def test_missing_final_message_allowed_and_bad_input_fail_closed(self):
+ conn = FakeConnect()
+ base = dict(self.BASE)
+ base["final_message"] = None
+ result = persist_agent_evidence(connect=conn, model_calls=self.CALLS, **base)
+ self.assertEqual(result["status"], "written")
+ with self.assertRaises(ValueError):
+ persist_agent_evidence(
+ connect=conn, model_calls=self.CALLS, **{**self.BASE, "system_prompt": ""}
+ )
+ with self.assertRaises(ValueError):
+ persist_agent_evidence(connect=conn, model_calls=[{"actual_model_id": ""}], **self.BASE)
+
+ def test_secret_like_content_rejected(self):
+ conn = FakeConnect()
+ bad = dict(self.BASE)
+ bad["system_prompt"] = "api_key = sk-abcdef0123456789abcdef012"
+ with self.assertRaises(ValueError):
+ persist_agent_evidence(connect=conn, model_calls=self.CALLS, **bad)
+
+ def test_dry_run_rolls_back(self):
+ conn = FakeConnect()
+ result = persist_agent_evidence(
+ connect=conn, dry_run=True, model_calls=self.CALLS, **self.BASE
+ )
+ self.assertEqual(result["status"], "dry_run_ok")
+ self.assertIn("ROLLBACK", conn.sqls())
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/tests/skills/write-next-chapter/test_gate_anchor_projection.py b/tests/skills/write-next-chapter/test_gate_anchor_projection.py
index a736dfe..21b20a9 100644
--- a/tests/skills/write-next-chapter/test_gate_anchor_projection.py
+++ b/tests/skills/write-next-chapter/test_gate_anchor_projection.py
@@ -8,9 +8,12 @@ import unittest
from pathlib import Path
-def _load_step2():
- path = Path(__file__).resolve().parents[3] / "docs" / "write-chapter" / "step2_write_chapter.py"
- spec = importlib.util.spec_from_file_location("step2_write_chapter", path)
+def _load_production_entry():
+ path = (
+ Path(__file__).resolve().parents[3]
+ / ".agent" / "skills" / "write-next-chapter" / "scripts" / "produce_next_chapter.py"
+ )
+ spec = importlib.util.spec_from_file_location("produce_next_chapter", path)
mod = importlib.util.module_from_spec(spec)
assert spec.loader is not None
# 只测纯函数:直接 exec 会拉全依赖;改为复制最小导入路径
@@ -22,10 +25,10 @@ def _load_step2():
class GateConstraintProjectionTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
- cls.step2 = _load_step2()
+ cls.entry = _load_production_entry()
def test_ch3_upgrade_anchors_surfaced(self) -> None:
- lines = self.step2.format_mechanical_gate_constraints(self.step2.GATE_ANCHORS[3])
+ lines = self.entry.format_mechanical_gate_constraints(self.entry.GATE_ANCHORS[3])
blob = "\n".join(lines)
self.assertIn("event-3-upgrade", blob)
self.assertIn("升级", blob)
@@ -33,7 +36,7 @@ class GateConstraintProjectionTests(unittest.TestCase):
self.assertIn("机械门验收", blob)
def test_missing_event_group_still_lists_characters(self) -> None:
- lines = self.step2.format_mechanical_gate_constraints(
+ lines = self.entry.format_mechanical_gate_constraints(
{
"requiredEvents": [],
"requiredCharacters": ["林深"],
diff --git a/tests/skills/write-next-chapter/test_run_writer.py b/tests/skills/write-next-chapter/test_run_writer.py
index e8d4b6a..3ec8af0 100644
--- a/tests/skills/write-next-chapter/test_run_writer.py
+++ b/tests/skills/write-next-chapter/test_run_writer.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""无工具正文写手 adapter 的派发合同、合同绑定与失败关闭测试。
-执行器是 muse_role 的无 CLI 固定模型策略:角色合同全文注入系统提示词、冻结输入作
+执行器是 muse_role 的无 CLI 固定模型策略:身份提示与中心角色合同注入系统提示词、冻结输入作
prompt、输出按 schema 校验。测试注入与 muse_llm.chat_governed 同签名的假实现,
不触发真实模型。
"""
@@ -135,7 +135,7 @@ def _success_chat(output: dict | None = None, context: dict | None = None) -> Fa
class RunWriterTest(unittest.TestCase):
def test_dispatch_contract_injects_prompt_and_frozen_input(self):
- """派发合同:角色合同全文进系统提示词,冻结创作输入作 prompt,支架字段不外泄。"""
+ """派发合同:身份提示与中心角色合同进系统提示词,冻结创作输入作 prompt,支架字段不外泄。"""
context = _bound_context()
chat = _success_chat(context=context)
@@ -150,7 +150,7 @@ class RunWriterTest(unittest.TestCase):
self.assertEqual(result["candidateVersion"], 1)
self.assertEqual(result["schemaVersion"], "candidate-envelope-v2")
call = chat.calls[0]
- # 角色合同全文必须原样注入系统提示词,不得裁剪改写。
+ # 已装配的身份提示与角色合同必须原样注入系统提示词,不得裁剪改写。
self.assertEqual(call["system"], build_dispatch_system_prompt(profile))
creative_input = build_writer_creative_input(context)
self.assertEqual(call["prompt"], canonical_json(creative_input))