坏信封重投不再吃掉唯一的返工额度

子 Agent 终态首行是 AGC_NEEDS_USER_INPUT_V1 但信封 JSON 解析不了时,
Runtime 把它降级成 needs-repair,于是下一跳按质量返工计费,吃掉整条委派
唯一的 repair_depth。

生产实测这不是质量问题:农场经营项目里被截断那次的正文与重投那次逐字节
相同(445 vs 447 字符,前者是后者的严格前缀),只少了收尾的 `]}`,
报错是 EOF while parsing a list at line 1 column 904。一次字节级截断把
返工额度用光,之后真出现方案质量问题时已经没有返工可用。

structured result 新增可持久化标记 userInputEnvelopeUnparsable(旧记录
反序列化为 false,按原样当质量返工处理)。谱系计数遇到带这个标记的父节点
时既不加 repair_depth 也不重置 clarification_round,额度上限 1:连续第二次
落回返工分支,由既有的 repair_depth <= 1 兜住,不会无限重投。

任务正文同步加 EnvelopeRetry 一支。原来那句「唯一返工轮」对重投跳既是
假天花板,又是做游戏链路三个视觉角色 replaceExisting=true 的授权信号,
不能复用。Repair 分支逐字不变。

顺带把 cargo fmt 对前两个提交里新增代码的重排一并带上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 17:05:30 +00:00
parent 998ebbf0e7
commit f38ce456d1
7 changed files with 193 additions and 4 deletions
@@ -1587,8 +1587,10 @@ mod tests {
assert!(plan.contains(required), "回灌格式缺少 {required}{plan}"); assert!(plan.contains(required), "回灌格式缺少 {required}{plan}");
} }
let planning = let planning = game_creator_agent_runtime_role_overlay_prompt(
game_creator_agent_runtime_role_overlay_prompt(GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, None); GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
None,
);
assert!( assert!(
planning.contains("已确认决定关掉的那条轴不得重问"), planning.contains("已确认决定关掉的那条轴不得重问"),
"子 Agent 侧缺少重复提问的兜底约束:{planning}" "子 Agent 侧缺少重复提问的兜底约束:{planning}"
@@ -214,12 +214,19 @@ pub(crate) fn observe_agent_runtime_agent_message(
/// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后 /// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后
/// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后 /// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后
/// 全部直接出稿,没有任何一个 run 走到第 2 轮。 /// 全部直接出稿,没有任何一个 run 走到第 2 轮。
/// - `EnvelopeRetry`:上一跳的 AGC_NEEDS_USER_INPUT_V1 信封解析不了(实测形态是
/// 输出被截断,字节级前缀与重投那次完全相同、只少了收尾的 `]}`)。它不消耗
/// `repair_depth`,所以这里也不能说「唯一返工轮」——那既是假天花板,又会在做游戏
/// 链路上误触发三个视觉角色的 `replaceExisting=true` 授权。
/// - `None`:普通委派,不加这一段。 /// - `None`:普通委派,不加这一段。
pub(in crate::agent) enum StaticDelegateHopNote<'a> { pub(in crate::agent) enum StaticDelegateHopNote<'a> {
None, None,
Repair { Repair {
original_delegation_id: &'a str, original_delegation_id: &'a str,
}, },
EnvelopeRetry {
original_delegation_id: &'a str,
},
PlanClarification { PlanClarification {
original_delegation_id: &'a str, original_delegation_id: &'a str,
rounds_used: u32, rounds_used: u32,
@@ -238,6 +245,11 @@ impl StaticDelegateHopNote<'_> {
StaticDelegateHopNote::Repair { StaticDelegateHopNote::Repair {
original_delegation_id, original_delegation_id,
} => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"), } => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"),
StaticDelegateHopNote::EnvelopeRetry {
original_delegation_id,
} => format!(
"\n\n上一轮对已认领委派 {original_delegation_id} 的 AGC_NEEDS_USER_INPUT_V1 信封没能解析完整,多半是那一轮输出被截断。这是同一轮的重投,不是返工轮,不消耗返工深度,也不改变已用澄清轮次。请原样重出上一轮那个问题,并确保信封 JSON 完整闭合到最外层的 `]}}`。"
),
// 预算用尽:planning_coordinator 出卡时会用 // 预算用尽:planning_coordinator 出卡时会用
// `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问, // `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问,
// 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。 // 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。
@@ -900,6 +912,16 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
} else { } else {
None None
}; };
// 坏信封重投在谱系计数里不吃返工深度,任务正文就不能继续叫它「唯一返工轮」。
// 读不到 delivery 时退回 Repair 文案:宁可保守,也不凭空宣布这是免费重投。
let envelope_retry = repair_of_delegation_id.as_deref().is_some_and(|original| {
list_static_delegate_deliveries_at(root).is_ok_and(|deliveries| {
deliveries
.iter()
.find(|delivery| delivery.delegation_id == original)
.is_some_and(static_delegate_original_envelope_was_unparsable)
})
});
let hop_note = match ( let hop_note = match (
repair_of_delegation_id.as_deref(), repair_of_delegation_id.as_deref(),
plan_clarification_rounds, plan_clarification_rounds,
@@ -911,6 +933,11 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
rounds_limit, rounds_limit,
} }
} }
(Some(original_delegation_id), None) if envelope_retry => {
StaticDelegateHopNote::EnvelopeRetry {
original_delegation_id,
}
}
(Some(original_delegation_id), None) => StaticDelegateHopNote::Repair { (Some(original_delegation_id), None) => StaticDelegateHopNote::Repair {
original_delegation_id, original_delegation_id,
}, },
@@ -2033,4 +2060,38 @@ mod tests {
"预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}" "预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}"
); );
} }
/// 坏信封重投既不吃返工深度,任务正文也不能说它是返工轮——那句话同时是做游戏
/// 链路三个视觉角色 `replaceExisting=true` 的授权信号。
#[test]
fn envelope_retry_hop_note_is_neither_a_repair_round_nor_a_clarification_round() {
let retry = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付 game/fast_gdd.md".to_string()],
&["game/fast_gdd.md".to_string()],
StaticDelegateHopNote::EnvelopeRetry {
original_delegation_id: "delegation-old",
},
)
.expect("render envelope retry");
assert!(
!retry.contains("唯一返工轮"),
"坏信封重投不是返工轮,复用这句话会误触发 replaceExisting 授权:{retry}"
);
assert!(
!retry.contains("的澄清续跑"),
"重投是同一轮的重发,不得写成推进一轮的澄清续跑:{retry}"
);
assert!(
retry.contains("不改变已用澄清轮次"),
"必须写明重投不推进轮次,否则子 Agent 会把 header 的 N 加一:{retry}"
);
assert!(
retry.contains("delegation-old") && retry.contains("输出被截断"),
"重投必须指出原委派和真实原因,子 Agent 才知道要原样重出并闭合 JSON{retry}"
);
}
} }
@@ -2087,7 +2087,9 @@ mod tests {
#[test] #[test]
fn native_agent_delegate_rejects_partial_or_invalid_clarification_binding() { fn native_agent_delegate_rejects_partial_or_invalid_clarification_binding() {
let mut single_digest = valid_delegate_input(json!("delegation-id"), Value::Null); let mut single_digest = valid_delegate_input(json!("delegation-id"), Value::Null);
let object = single_digest.as_object_mut().expect("delegate input object"); let object = single_digest
.as_object_mut()
.expect("delegate input object");
object.insert( object.insert(
"continuationOfDelegationId".to_string(), "continuationOfDelegationId".to_string(),
json!("delegation-id"), json!("delegation-id"),
@@ -2099,7 +2101,9 @@ mod tests {
.contains("必须成对提供")); .contains("必须成对提供"));
let mut orphan_digests = valid_delegate_input(json!("delegation-id"), Value::Null); let mut orphan_digests = valid_delegate_input(json!("delegation-id"), Value::Null);
let object = orphan_digests.as_object_mut().expect("delegate input object"); let object = orphan_digests
.as_object_mut()
.expect("delegate input object");
object.insert("questionsSha256".to_string(), json!("a".repeat(64))); object.insert("questionsSha256".to_string(), json!("a".repeat(64)));
object.insert("answersSha256".to_string(), json!("b".repeat(64))); object.insert("answersSha256".to_string(), json!("b".repeat(64)));
assert!(validate_native_agent_delegate_input(&orphan_digests) assert!(validate_native_agent_delegate_input(&orphan_digests)
@@ -69,6 +69,10 @@ pub(crate) fn schema_max_clarification_envelope() -> String {
// 从未承诺给它 3 轮预算,因此取 1;其它 source(包括 Project Supervisor 常规协作)取 3。 // 从未承诺给它 3 轮预算,因此取 1;其它 source(包括 Project Supervisor 常规协作)取 3。
const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_DEFAULT: u32 = 3; const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_DEFAULT: u32 = 3;
const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_GAME_CHAT: u32 = 1; const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_GAME_CHAT: u32 = 1;
// 一条谱系上最多给几次「坏信封重投」的免费额度。取 1:信封写坏一次通常是这一轮
// 输出没写完,重投就能拿到同一份内容;连续两次多半是子 Agent 真的不会写这个契约,
// 那就该按质量返工计费,让既有的 repair_depth <= 1 把它兜住。
const STATIC_DELEGATE_ENVELOPE_RETRY_LIMIT: u32 = 1;
// 链上重放的防环 / 防越界上限,远大于设计允许的最大 7 跳,纯粹是安全阀。 // 链上重放的防环 / 防越界上限,远大于设计允许的最大 7 跳,纯粹是安全阀。
const STATIC_DELEGATE_LINEAGE_MAX_HOPS: usize = 32; const STATIC_DELEGATE_LINEAGE_MAX_HOPS: usize = 32;
@@ -173,6 +177,11 @@ pub(crate) struct StaticDelegateStructuredResult {
pub(crate) user_input_questions: Vec<AgentRuntimeUserInputQuestion>, pub(crate) user_input_questions: Vec<AgentRuntimeUserInputQuestion>,
#[serde(default)] #[serde(default)]
pub(crate) user_input_questions_sha256: Option<String>, pub(crate) user_input_questions_sha256: Option<String>,
/// 本次终态带了 AGC_NEEDS_USER_INPUT_V1 首行但信封 JSON 解析不了。
/// 这是「本轮输出没写完/写坏」,不是子 Agent 的方案质量问题,所以谱系计数
/// 不把它当返工跳。旧记录反序列化为 false,按质量返工照常处理。
#[serde(default)]
pub(crate) user_input_envelope_unparsable: bool,
} }
impl Default for StaticDelegateStructuredResult { impl Default for StaticDelegateStructuredResult {
@@ -187,6 +196,7 @@ impl Default for StaticDelegateStructuredResult {
error: None, error: None,
user_input_questions: Vec::new(), user_input_questions: Vec::new(),
user_input_questions_sha256: None, user_input_questions_sha256: None,
user_input_envelope_unparsable: false,
} }
} }
} }
@@ -430,6 +440,7 @@ pub(crate) fn mark_static_delegate_delivery_ready_at(
error: (terminal_status != "completed").then(|| result_summary.to_string()), error: (terminal_status != "completed").then(|| result_summary.to_string()),
user_input_questions: Vec::new(), user_input_questions: Vec::new(),
user_input_questions_sha256: None, user_input_questions_sha256: None,
user_input_envelope_unparsable: false,
}; };
mark_static_delegate_delivery_ready_with_result_at( mark_static_delegate_delivery_ready_with_result_at(
root, root,
@@ -1275,6 +1286,18 @@ fn static_delegate_original_is_user_revision_requested(
}) })
} }
/// 上一跳的终态带了信封前缀但 JSON 解析不了——实测的形态是输出被截断,字节级
/// 前缀完全相同、只少了收尾的 `]}`。这属于「这一轮没写完」,重投一次就能拿到
/// 同一份内容,不该按方案质量返工计费。
pub(crate) fn static_delegate_original_envelope_was_unparsable(
delivery: &StaticDelegateDeliveryRecord,
) -> bool {
delivery
.structured_result
.as_ref()
.is_some_and(|result| result.user_input_envelope_unparsable)
}
/// A newer durable contract status is intentionally not a repairable contract. /// A newer durable contract status is intentionally not a repairable contract.
/// The old client may preserve and report it, but must not manufacture a /// The old client may preserve and report it, but must not manufacture a
/// mutation under semantics it does not understand. /// mutation under semantics it does not understand.
@@ -1311,11 +1334,19 @@ pub(crate) fn static_delegate_lineage_counters(
chain.reverse(); chain.reverse();
let mut depth = 0u32; let mut depth = 0u32;
let mut round = 0u32; let mut round = 0u32;
let mut envelope_retries = 0u32;
for parent in &chain[..chain.len().saturating_sub(1)] { for parent in &chain[..chain.len().saturating_sub(1)] {
if static_delegate_original_is_awaiting_clarification(*parent) { if static_delegate_original_is_awaiting_clarification(*parent) {
round += 1; round += 1;
} else if static_delegate_original_is_user_revision_requested(*parent) { } else if static_delegate_original_is_user_revision_requested(*parent) {
// 用户明确触发的修订不是 runaway-agent 返工;保留两个运行时派生计数。 // 用户明确触发的修订不是 runaway-agent 返工;保留两个运行时派生计数。
} else if static_delegate_original_envelope_was_unparsable(*parent)
&& envelope_retries < STATIC_DELEGATE_ENVELOPE_RETRY_LIMIT
{
// 坏信封重投既不吃返工深度,也不重置澄清轮次:它没有推进也没有否定
// 任何已确认的决定。给的额度是有限的——超出后落回下面的返工分支,
// 由 `repair_depth >= 1` 这道既有的门兜住,不会无限重投。
envelope_retries += 1;
} else { } else {
depth += 1; depth += 1;
round = 0; round = 0;
@@ -1744,6 +1775,9 @@ pub(crate) fn build_static_delegate_structured_result_at(
Ok((questions, sha256)) => (questions, sha256, None), Ok((questions, sha256)) => (questions, sha256, None),
Err(parse_error) => (None, None, Some(parse_error)), Err(parse_error) => (None, None, Some(parse_error)),
}; };
// parse_static_delegate_user_input_request 只在首行确实是信封前缀时才会报错,
// 所以这个标记不会被普通文本终态误置。
let user_input_envelope_unparsable = user_input_parse_error.is_some();
let needs_user_input = completed && user_input_questions.is_some(); let needs_user_input = completed && user_input_questions.is_some();
let contract_status = if needs_user_input { let contract_status = if needs_user_input {
StaticDelegateContractStatus::NeedsUserInput StaticDelegateContractStatus::NeedsUserInput
@@ -1809,6 +1843,7 @@ pub(crate) fn build_static_delegate_structured_result_at(
error: derived_error, error: derived_error,
user_input_questions: user_input_questions.unwrap_or_default(), user_input_questions: user_input_questions.unwrap_or_default(),
user_input_questions_sha256, user_input_questions_sha256,
user_input_envelope_unparsable,
}) })
} }
@@ -3310,6 +3345,89 @@ mod tests {
delivery delivery
} }
/// 坏信封(首行是 AGC_NEEDS_USER_INPUT_V1 但 JSON 解析不了)重投不吃返工深度,
/// 但额度有限:连续第二次就落回质量返工,由既有的 depth<=1 门兜住。
///
/// 生产实测的形态是输出被截断——重投那次的正文与被截断那次逐字节相同,只多出
/// 收尾的 `]}`。原实现把它记成质量返工,一次字节级截断就把整条委派唯一的返工
/// 额度吃光,之后真出现方案质量问题时已经没有返工可用。
#[test]
fn static_delegate_unparsable_envelope_retry_is_free_once_then_costs_repair_depth() {
let root = std::env::temp_dir().join(format!(
"genarrative-static-envelope-retry-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time after unix epoch")
.as_nanos()
));
init_local_game_project_at(&root, "m1c0-envelope-retry", "坏信封重投计费测试")
.expect("project init");
let parent_run_id = "m1c0-envelope-retry-parent-run";
let mut mark_unparsable = |delegation_id: &str, repair_of: Option<&str>| {
let mut delivery = claimed_static_delegate_for_lineage_test(
parent_run_id,
delegation_id,
repair_of,
StaticDelegateContractStatus::NeedsRepair,
);
delivery
.structured_result
.as_mut()
.expect("structured result")
.user_input_envelope_unparsable = true;
write_static_delegate_delivery_at(&root, &delivery).expect("write delivery");
delivery
};
let d0 = mark_unparsable("m1c0-envelope-retry-d0", None);
let d1 = mark_unparsable("m1c0-envelope-retry-d1", Some(&d0.delegation_id));
let deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries");
assert_eq!(
static_delegate_lineage_counters(&deliveries, &d1.delegation_id),
(0, 0),
"第一次坏信封重投既不吃返工深度,也不动澄清轮次"
);
validate_static_delegate_repair_request_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
"m1c0-envelope-retry-d2",
"design-director",
&[],
&[],
Some(&d1.delegation_id),
)
.expect("first envelope retry must leave the quality-repair budget intact");
let d2 = claimed_static_delegate_for_lineage_test(
parent_run_id,
"m1c0-envelope-retry-d2",
Some(&d1.delegation_id),
StaticDelegateContractStatus::NeedsRepair,
);
write_static_delegate_delivery_at(&root, &d2).expect("write d2");
let deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries");
assert_eq!(
static_delegate_lineage_counters(&deliveries, &d2.delegation_id),
(1, 0),
"免费额度只有一次,第二次坏信封按质量返工计费"
);
let blocked = validate_static_delegate_repair_request_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
"m1c0-envelope-retry-d3",
"design-director",
&[],
&[],
Some(&d2.delegation_id),
)
.expect_err("repeated bad envelopes must fall back to the depth gate");
assert!(blocked.contains("深度最多为 1"), "{blocked}");
}
#[test] #[test]
fn static_delegate_user_revision_preserves_counters_and_bypasses_depth_gate() { fn static_delegate_user_revision_preserves_counters_and_bypasses_depth_gate() {
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
@@ -1007,6 +1007,7 @@ fn project_supervisor_static_ready_receipt_over_budget_is_non_mutating() {
error: None, error: None,
user_input_questions: Vec::new(), user_input_questions: Vec::new(),
user_input_questions_sha256: None, user_input_questions_sha256: None,
user_input_envelope_unparsable: false,
}; };
mark_static_delegate_delivery_ready_with_result_at( mark_static_delegate_delivery_ready_with_result_at(
&root, &root,
@@ -1467,6 +1467,7 @@ fn canvas_replacement_requires_the_claimed_original_and_unique_repair_contract()
error: None, error: None,
user_input_questions: Vec::new(), user_input_questions: Vec::new(),
user_input_questions_sha256: None, user_input_questions_sha256: None,
user_input_envelope_unparsable: false,
}); });
original.claimed_by_action_id = Some("canvas-replacement-claim-action".to_string()); original.claimed_by_action_id = Some("canvas-replacement-claim-action".to_string());
create_or_read_static_delegate_delivery_at(&root, &original) create_or_read_static_delegate_delivery_at(&root, &original)
@@ -1694,6 +1695,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r
error: None, error: None,
user_input_questions: Vec::new(), user_input_questions: Vec::new(),
user_input_questions_sha256: None, user_input_questions_sha256: None,
user_input_envelope_unparsable: false,
}); });
original.claimed_by_action_id = Some("canvas-repair-claim-action".to_string()); original.claimed_by_action_id = Some("canvas-repair-claim-action".to_string());
create_or_read_static_delegate_delivery_at(&root, &original) create_or_read_static_delegate_delivery_at(&root, &original)
@@ -6739,6 +6739,7 @@ fn legacy_pre_clarification_delivery_repair_counts_toward_depth() {
error: Some("缺少 1 个预期产物".to_string()), error: Some("缺少 1 个预期产物".to_string()),
user_input_questions: Vec::new(), user_input_questions: Vec::new(),
user_input_questions_sha256: None, user_input_questions_sha256: None,
user_input_envelope_unparsable: false,
}); });
legacy_delivery.claimed_by_action_id = Some("legacy-delivery-claim-action".to_string()); legacy_delivery.claimed_by_action_id = Some("legacy-delivery-claim-action".to_string());
write_static_delegate_delivery_at(&root, &legacy_delivery).expect("persist legacy delivery"); write_static_delegate_delivery_at(&root, &legacy_delivery).expect("persist legacy delivery");