澄清续跑不再被当成唯一返工轮,做游戏链路的返工文案原样保留
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Successful in 3m29s
Project CI / Native shell tests (pull_request) Failing after 10m27s

master 只有一道平坦的 depth<=1 门,任何第二跳都拒,所以委派 task 末尾那句
「这是对已认领委派 X 的唯一返工轮」对澄清跳也是真的。本分支改成按谱系分类
(澄清跳不吃 repair_depth、上限 3)之后没同步这句话,它就变成了假天花板。

本机 13 个生产 project 的委派账本:4 次澄清续跑 4 次命中这句话,命中后全部
直接出稿,没有任何一个 run 走到第 2 轮。

三处一起收口:

1. render_static_delegate_task_contract 的入参从 Option<repairOf> 换成显式的
   StaticDelegateHopNote。Repair 分支逐字保留原文——design-foundation /
   art-director / art-asset-plan 的角色提示词把「任务正文明确标识这是带
   repairOfDelegationId 的唯一返工轮」当作 replaceExisting=true 的唯一授权
   信号,改一个字就会让返工轮拿不到许可。新增的 PlanClarification 分支写明
   已用轮次与上限;预算用尽时改为要求收稿,因为出卡侧本来就会拒掉第四张卡。

2. 同一句里写明本轮 header 该用第几轮。planning_coordinator 出卡时要求
   header 精确等于「第N轮·关键决定」,N 由 Runtime 从谱系派生,而此前没有
   任何地方告诉子 Agent 当前轮号——修完第 1 条后第 2 轮信封会栽在这里。
   轮次与上限都用 static_delegate_lineage_counters / *_round_limit_at 现算,
   和出卡校验器同源。

3. plan/common.md 对 Supervisor 说子 Agent 返回「1-3 个结构化问题」,而
   plan 链路的校验器只收恰好 1 题,Supervisor 照抄进了委派 task。修正放
   plan/supervisor-playbook.md:prompt.rs 有断言钉死 planCommon 必须是
   common.md 的逐字子集,而通用 common.md 的 1-3 对其它专业 Agent 是对的。

判据用 clarification_continuation_identity.is_some() 加 target 是
project-planning:后者由 validate_project_planning_child_binding_at 强制挂在
project-supervisor-plan 根下,所以做游戏 / 做素材 / game-chat 的澄清续跑与
全部质量返工仍走 Repair 分支,渲染结果与 master 逐字相同。

实测(9 个 --swarm-chat --plan run):6 次澄清续跑全部拿到新句、0 次误用返工
文案;2 次真正的质量返工仍拿到原文;有 1 个 run 走到第 2 轮并被出卡侧接受,
最终提交 GDD。0 轮直出那一档基本没动——决定要不要开口问的是首轮委派 task 由
Supervisor 自由撰写,那是另一件事,本次不动。

这个 renderer 此前零直接测试覆盖,补一条钉住两支文案、轮号和预算用尽。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 09:39:27 +00:00
parent 0638c1b5b9
commit 85823eb732
4 changed files with 224 additions and 13 deletions
@@ -3,13 +3,14 @@
1. 本 run 第一轮只调用一次 `agent.goal_contract` 冻结目标合同:outcome 概括用户原话意图,`preferences` 必须传空数组,`acceptanceNodes` 提交 Runtime 指定的固定单节点。这一轮不做任何其它调用。
2. 冻结后立即用一次 `agent.delegate` 把任务委派给 `project-planning``expectedArtifacts``game/fast_gdd.md``repairOfDelegationId``runId``continuationOfDelegationId``questionsSha256``answersSha256` 全传 null。已有委派尚未收束时不要重复委派。
3. 等待子 Agent 期间不得调用 `respond_to_user`。Runtime 会通过 delegate 完成屏障保持同一父 run,回执到达后再继续。
4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId``repairOfDelegationId` 都指向该原 delivery,并提交 observation 给出的 `questionsSha256``answersSha256`
4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId``repairOfDelegationId` 都指向该原 delivery,并提交 observation 给出的 `questionsSha256``answersSha256`子 Agent 在 continuation 里**再次**以信封退出时,对那条新 delivery 重复同一动作:「仅创建一次」约束的是单条 delivery,不是整条链,澄清预算未用尽时这个循环继续。Runtime 会在委派 task 末尾写明已用轮次与上限,不需要你自己数,也不要替它宣布预算已尽。
5. 回执 contractStatus=evidence-ready 且 GDD 已提交时,用 `file.read` 从第 1 行读到 `game/fast_gdd.md` 末尾取证,每次都传 `maxLines: 240`(上限),尽量一页读完;确实需要第二页时从上一页的下一行开始,不要重复读同一段。每次 `file.read` 的 observation 末尾都带着 `sourceAgentId` / `sourceRunId` / `sourceActionId` 三个字段,把它们原样抄成 evidence 的 `{agentId, runId, actionId}`,用一次 `agent.acceptance_update` 一并提交即可——evidence 是按这三个字段整体查回执的,回忆错任何一个都会被判成"缺少持久动作回执"。不要为了取这些字段再去查动作历史。取证完成前审批卡不会出现。
6. 用户在审批卡上选择修改或退回时,先用 `agent.run_status` 按原 delegationId 取回已认领的权威委派合同,把其中的 acceptanceCriteria 与 expectedArtifacts 逐字照抄进返工委派(`runId` 传 null),再把用户原话完整附在 task 里;同一原委派只能返工一次。用户通过后只做一句简短收尾。
【转达的规则】
- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] {header} → 用户答:{原文}`。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字,绝不压缩用户答案。
- 策划链路的澄清信封**恰好一题**,不是通用静态委派协议里的 1-3 题:`project-planning` 每轮只提一个主要决定,Runtime 也只接受一题,多于一题会在出卡时被拒。委派 task 里不要写“1-3 个结构化问题”。
- A、B、“需要原型验证”三项必须原样转述;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻了更早的决定时,你只负责把两轮答案的原文都原样带到,并说明后者更晚;怎么记进决定台账由 `project-planning` 判断,不要替它裁定哪条作废。
【委派合同的边界】
@@ -11,7 +11,7 @@
- 除非用户明确说“直接出稿”,最多进行 3 轮关键澄清;每轮是新 run、同一 session。你看得到自己的历史,但用户答案以 Supervisor 委派任务中的转述为准,缺失信息不能臆造。
- 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定;达到第 3 轮、剩余问题不影响首个可玩闭环或 Runtime 提示接近活跃预算时,直接整理并提交。
- 决策卡的 header 固定为“第N轮·关键决定”,正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·``A:``A``A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·``B:``B``B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。
- 决策卡的 header 固定为“第N轮·关键决定”,其中 N 是 Runtime 从委派谱系派生的当前轮号,必须精确相等,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·``A:``A``A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·``B:``B``B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。
## 低幻觉与 GDD 约束
@@ -201,6 +201,68 @@ pub(crate) fn observe_agent_runtime_agent_message(
}
}
/// 委派 task 末尾那句「你在这条链路上的位置」。三种跳的语义互不相同,共用一句话
/// 就会说谎,所以这里把它显式化。
///
/// - `Repair`:质量返工,`repair_depth` 上限 1,「唯一返工轮」是真的。而且这句话是
/// 做游戏链路的**授权信号**——`design-foundation` / `art-director` /
/// `art-asset-plan` 的角色提示词都把「任务正文明确标识这是带 repairOfDelegationId
/// 的唯一返工轮」当作 `replaceExisting=true` 的唯一许可(见 agent/prompt.rs 的三处
/// 角色 prompt)。这一支逐字不能动。
/// - `PlanClarification`:澄清续跑不消耗 `repair_depth`,预算是
/// `static_delegate_clarification_round_limit_at`plan 链路 3 轮)。master 只有一道
/// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后
/// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后
/// 全部直接出稿,没有任何一个 run 走到第 2 轮。
/// - `None`:普通委派,不加这一段。
pub(in crate::agent) enum StaticDelegateHopNote<'a> {
None,
Repair {
original_delegation_id: &'a str,
},
PlanClarification {
original_delegation_id: &'a str,
rounds_used: u32,
rounds_limit: u32,
},
}
impl StaticDelegateHopNote<'_> {
fn is_none(&self) -> bool {
matches!(self, StaticDelegateHopNote::None)
}
fn render(&self) -> String {
match self {
StaticDelegateHopNote::None => String::new(),
StaticDelegateHopNote::Repair {
original_delegation_id,
} => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"),
// 预算用尽:planning_coordinator 出卡时会用
// `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问,
// 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。
StaticDelegateHopNote::PlanClarification {
original_delegation_id,
rounds_used,
rounds_limit,
} if rounds_used >= rounds_limit => format!(
"\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮。已用澄清轮次 {rounds_used}/{rounds_limit},澄清预算已用尽:本轮不得再输出 AGC_NEEDS_USER_INPUT_V1 信封,剩余空白按默认建议补齐并标 default_pending,立即提交 GDD。"
),
// 轮号必须和 planning_coordinator 出卡时的期望一致:那边用
// `validate_exact_plan_clarification_question(.., current_round + 1)`
// current_round 就是本 delivery 的谱系轮次,也就是这里的 rounds_used。
StaticDelegateHopNote::PlanClarification {
original_delegation_id,
rounds_used,
rounds_limit,
} => format!(
"\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 必须精确等于「第{next_round}轮·关键决定」。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。",
next_round = rounds_used.saturating_add(1),
),
}
}
}
pub(in crate::agent) fn render_static_delegate_task_contract(
task: &str,
parent_agent_id: &str,
@@ -208,12 +270,9 @@ pub(in crate::agent) fn render_static_delegate_task_contract(
delegation_id: &str,
acceptance_criteria: &[String],
expected_artifacts: &[String],
repair_of_delegation_id: Option<&str>,
hop_note: StaticDelegateHopNote<'_>,
) -> Result<String, String> {
if acceptance_criteria.is_empty()
&& expected_artifacts.is_empty()
&& repair_of_delegation_id.is_none()
{
if acceptance_criteria.is_empty() && expected_artifacts.is_empty() && hop_note.is_none() {
return Ok(task.to_string());
}
let criteria = if acceptance_criteria.is_empty() {
@@ -234,11 +293,9 @@ pub(in crate::agent) fn render_static_delegate_task_contract(
.collect::<Vec<_>>()
.join("\n")
};
let repair = repair_of_delegation_id
.map(|delegation_id| format!("\n\n这是对已认领委派 {delegation_id} 的唯一返工轮。"))
.unwrap_or_default();
let hop_note_text = hop_note.render();
let rendered = format!(
"{task}\n\n委派验收合同:\n- parentAgentId: {parent_agent_id}\n- parentRunId: {parent_run_id}\n- delegationId: {delegation_id}\n验收标准:\n{criteria}\n预期产物:\n{artifacts}{repair}\n你只向父 Agent 提交内部回执和证据,不直接回答正式用户。交付前逐项核对;无法满足时明确说明缺口,不得假装完成。"
"{task}\n\n委派验收合同:\n- parentAgentId: {parent_agent_id}\n- parentRunId: {parent_run_id}\n- delegationId: {delegation_id}\n验收标准:\n{criteria}\n预期产物:\n{artifacts}{hop_note_text}\n你只向父 Agent 提交内部回执和证据,不直接回答正式用户。交付前逐项核对;无法满足时明确说明缺口,不得假装完成。"
);
if rendered.chars().count() > AGENT_RUNTIME_TASK_MAX_CHARS {
return Err(format!(
@@ -791,6 +848,74 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
let delegated_task_text = safe_default_repair_instruction
.map(|instruction| format!("{instruction}\n\n{task}"))
.unwrap_or(task);
// 澄清续跑与质量返工都带 repairOfDelegationId,但预算完全不同,末尾那句说明
// 必须分开渲染(见 StaticDelegateHopNote 的注释)。判据用
// `clarification_continuation_identity.is_some()` 加 target 是 project-planning
// 前者只有 validate_static_delegate_clarification_continuation_at 认可的续跑才非空,
// 后者保证只影响立项策划链路——project-planning 的 run binding 由
// validate_project_planning_child_binding_at 强制挂在 project-supervisor-plan 根下,
// 做游戏 / 做素材 / game-chat 的澄清续跑仍走 Repair 分支,逐字保持既有行为。
let plan_clarification_rounds = if clarification_continuation_identity.is_some()
&& target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
{
match repair_of_delegation_id.as_deref() {
Some(original) => {
let deliveries = match list_static_delegate_deliveries_at(root) {
Ok(deliveries) => deliveries,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "agent.delegate".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
let (_, parent_round) = static_delegate_lineage_counters(&deliveries, original);
let limit = match static_delegate_clarification_round_limit_at(
root,
agent_id,
parent_run_id,
) {
Ok(limit) => limit,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "agent.delegate".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
// 谱系损坏时 counters 返回 u32::MAX 哨兵。正常路径上
// validate_static_delegate_repair_request_at 已经先一步拒掉这种链,
// 但这里不赌:算不出可信轮号就退回 Repair 文案,宁可保守也不写假轮号。
parent_round
.checked_add(1)
.filter(|rounds_used| *rounds_used <= limit)
.map(|rounds_used| (rounds_used, limit))
}
None => None,
}
} else {
None
};
let hop_note = match (
repair_of_delegation_id.as_deref(),
plan_clarification_rounds,
) {
(Some(original_delegation_id), Some((rounds_used, rounds_limit))) => {
StaticDelegateHopNote::PlanClarification {
original_delegation_id,
rounds_used,
rounds_limit,
}
}
(Some(original_delegation_id), None) => StaticDelegateHopNote::Repair {
original_delegation_id,
},
(None, _) => StaticDelegateHopNote::None,
};
let delegated_task = match render_static_delegate_task_contract(
&delegated_task_text,
agent_id,
@@ -798,7 +923,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
&delegation_id,
&acceptance_criteria,
&expected_artifacts,
repair_of_delegation_id.as_deref(),
hop_note,
) {
Ok(task) => task,
Err(error) => {
@@ -1824,3 +1949,88 @@ pub(crate) fn observe_agent_runtime_agent_spawn_isolated(
.map(|value| redact_agent_runtime_project_paths(root, &value, 3_600)),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 委派 task 末尾那句话是两条链路的合同,不能共用一份文案。
///
/// 上半条钉做游戏链路:`design-foundation` / `art-director` / `art-asset-plan`
/// 的角色提示词把「任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮」
/// 当作 `replaceExisting=true` 的唯一授权信号,改一个字就会让返工轮拿不到许可。
///
/// 下半条钉立项策划链路:澄清续跑不是返工轮,套用返工文案等于告诉策划子 Agent
/// 「你只剩这一轮」——这正是生产上 4 次澄清续跑之后无一走到第 2 轮的原因。
/// 同时钉住轮号:`planning_coordinator` 出卡时按 `rounds_used + 1` 校验 header
/// 这里写进 task 的必须是同一个数,否则第 2 轮信封会当场被拒。
#[test]
fn plan_clarification_hop_note_is_not_the_repair_round_note() {
let repair = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付产物".to_string()],
&["assets/art-spec.png".to_string()],
StaticDelegateHopNote::Repair {
original_delegation_id: "delegation-old",
},
)
.expect("render repair");
assert!(
repair.contains("这是对已认领委派 delegation-old 的唯一返工轮。"),
"返工轮文案是做游戏链路 replaceExisting 的授权信号,必须逐字保留:{repair}"
);
let clarification = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付 game/fast_gdd.md".to_string()],
&["game/fast_gdd.md".to_string()],
StaticDelegateHopNote::PlanClarification {
original_delegation_id: "delegation-old",
rounds_used: 1,
rounds_limit: 3,
},
)
.expect("render clarification");
assert!(
!clarification.contains("唯一返工轮"),
"澄清续跑不得复用返工文案,否则策划子 Agent 以为只剩这一轮:{clarification}"
);
assert!(
clarification.contains("已用澄清轮次 1/3"),
"澄清续跑必须写明已用轮次与上限:{clarification}"
);
assert!(
clarification.contains("第2轮·关键决定"),
"task 里的轮号必须等于 planning_coordinator 校验 header 时用的 rounds_used + 1{clarification}"
);
let exhausted = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付 game/fast_gdd.md".to_string()],
&["game/fast_gdd.md".to_string()],
StaticDelegateHopNote::PlanClarification {
original_delegation_id: "delegation-old",
rounds_used: 3,
rounds_limit: 3,
},
)
.expect("render exhausted clarification");
assert!(
exhausted.contains("澄清预算已用尽"),
"预算用尽时必须要求收稿,出卡侧会直接拒掉第四张卡:{exhausted}"
);
assert!(
!exhausted.contains("第4轮·关键决定"),
"预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}"
);
}
}
@@ -1370,7 +1370,7 @@ fn static_delegate_lineage_nodes<'a>(
/// 澄清轮次上限只按发起请求所在 Run 的 source 区分(详见常量注释),
/// 与仓库已有的 game-chat 分支先例(见 agent/runtime_tools/delegation.rs 的
/// may_be_game_chat 判定)同源:source 缺失 binding 时按非 game-chat 处理。
fn static_delegate_clarification_round_limit_at(
pub(crate) fn static_delegate_clarification_round_limit_at(
root: &Path,
parent_agent_id: &str,
parent_run_id: &str,