坏信封在 run 内就地重取,不再逃逸成一条委派
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 3m23s
Project CI / Native shell tests (pull_request) Successful in 14m22s

按原型(local-scripts/deisgn_agent)的口径:信封解析失败是「本回合没推进
流程」,注入原因后在同一个 run 里重来,而不是终止 run 让它落成一条
needs-repair delivery。

接入点在 final reply 返回处。解析失败时把原因作为 observation 回灌,用带
后缀的 request slot 重取 final reply,上限 2 次(对齐原型
MAX_WASTED_TURNS=3:前两次注入重试,第三次放行)。用尽后按既有路径落盘,
此时「连写三次都不合法」确实是质量问题,按普通质量返工计费与返工深度、
澄清轮次、session 段落三套不变量全部自洽,不需要放松任何一道。

只覆盖 project-planning。做游戏 / 做素材的静态委派子 Agent 逐字保持既有
行为,它们的坏信封仍按原路落成 needs-repair。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 03:22:41 +00:00
parent b7bc4c4442
commit dc0a168ef1
2 changed files with 139 additions and 15 deletions
@@ -1,5 +1,30 @@
use super::*;
/// 策划子 Agent 的 `AGC_NEEDS_USER_INPUT_V1` 信封在 final reply 里写坏时,允许在
/// **同一个 run 内**重取几次。
///
/// 原型(local-scripts/deisgn_agent)把信封解析失败当成「本回合没推进流程」,注入
/// 错误后在同一条 messages 上重来,`MAX_WASTED_TURNS=3`(前两次注入重试,第三次判
/// 本跳失败)。这里取同一口径:2 次重取,用尽后才让它按既有路径落成 needs-repair。
///
/// 为什么必须在这里拦:信封一旦随 final reply 逃逸,run 就终止并变成一条
/// needs-repair delivery,之后返工深度、澄清轮次、session 段落三套不变量都会把它
/// 当成「新段落」,而它们编码的是同一条假设——新委派 = 新段落。实测一次字节级截断
/// 就能吃掉整条委派唯一的返工额度,两次则直接把父 run 打进 needs-reconciliation。
const AGENT_RUNTIME_PLAN_ENVELOPE_REPAIR_ATTEMPTS: u32 = 2;
/// 返回本条 final reply 里坏掉的信封的解析原因;不是策划子 Agent、或正文压根没有
/// 信封首行时返回 None(后者是「这一轮不提问」的正常收束)。
fn game_creator_agent_runtime_plan_envelope_parse_error(
agent_id: &str,
reply: &str,
) -> Option<String> {
if agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
return None;
}
parse_static_delegate_user_input_request(Some(reply)).err()
}
struct PlanGddBlockerRuntimeProjection {
phase: &'static str,
current_action: &'static str,
@@ -4274,20 +4299,64 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
&format!("持久化最终回复请求上下文失败:{error}"),
);
}
let final_reply_result = request_game_creator_agent_background_final_reply_at(
&root,
&agent_id,
&runtime.session_id,
&runtime.run_id,
&task,
&plan,
final_reply_fallback.as_deref(),
&observations,
runtime.applied_steer_cursor,
&final_reply_request_slot,
response_revision,
)
.await;
// 坏信封在这里就地重取,不让它随 final reply 逃逸成一条 needs-repair 委派。
// 与 provider_tool_plan 对工具计划协议错误的定向修复同构:把解析原因作为
// observation 回灌,下一次 final-reply 请求就带着它。
let mut envelope_repair_attempt = 0u32;
let final_reply_result = loop {
let attempt_slot = if envelope_repair_attempt == 0 {
final_reply_request_slot.clone()
} else {
format!("{final_reply_request_slot}-envelope-repair-{envelope_repair_attempt}")
};
let attempt_result = request_game_creator_agent_background_final_reply_at(
&root,
&agent_id,
&runtime.session_id,
&runtime.run_id,
&task,
&plan,
final_reply_fallback.as_deref(),
&observations,
runtime.applied_steer_cursor,
&attempt_slot,
response_revision,
)
.await;
let parse_error = match &attempt_result {
Ok(RequestedAgentRuntimeFinalReplyOutcome::Ready(Some(requested_reply))) => {
game_creator_agent_runtime_plan_envelope_parse_error(
&agent_id,
&requested_reply.reply,
)
}
_ => None,
};
let Some(parse_error) = parse_error else {
break attempt_result;
};
if envelope_repair_attempt >= AGENT_RUNTIME_PLAN_ENVELOPE_REPAIR_ATTEMPTS {
break attempt_result;
}
envelope_repair_attempt += 1;
let observation = AgentRuntimeToolObservation {
tool: "runtime.plan_envelope".to_string(),
status: "failed".to_string(),
summary: format!(
"AGC_NEEDS_USER_INPUT_V1 信封无法解析(第 {envelope_repair_attempt}/{AGENT_RUNTIME_PLAN_ENVELOPE_REPAIR_ATTEMPTS} 次重取):{parse_error}"
),
detail: Some(
"原样重出同一个问题,不要改写题面或选项;信封必须是首行 AGC_NEEDS_USER_INPUT_V1,下一行严格 JSON 且完整闭合到最外层。不要输出 markdown、代码围栏或第三行正文。".to_string(),
),
};
let observation_summary = observation.summary();
runtime.observations.push(observation_summary);
context_tracker.record(&observation);
observations.push(observation);
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
return AgentBackgroundTaskOutcome::Finished;
}
};
if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) {
return AgentBackgroundTaskOutcome::Finished;
}
@@ -4565,6 +4634,61 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
}
}
#[cfg(test)]
mod plan_envelope_repair_tests {
use super::*;
const TRUNCATED: &str = "AGC_NEEDS_USER_INPUT_V1
{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"\"}]}";
const COMPLETE: &str = "AGC_NEEDS_USER_INPUT_V1
{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"\"},{\"label\":\"B\",\"description\":\"\"}]}]}";
/// 截断的信封必须在 run 内被认出来,否则它会随 final reply 逃逸成一条
/// needs-repair 委派,把返工额度和澄清轮次一起卷进去。
#[test]
fn a_truncated_planning_envelope_is_detected_before_the_run_ends() {
let error = game_creator_agent_runtime_plan_envelope_parse_error(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
TRUNCATED,
)
.expect("truncated envelope must be reported");
assert!(error.contains("JSON"), "{error}");
}
/// 完整信封与普通收尾文本都不能触发重取——后者是「这一轮不提问」的正常终态。
#[test]
fn complete_envelopes_and_plain_replies_do_not_trigger_a_repair() {
assert!(game_creator_agent_runtime_plan_envelope_parse_error(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
COMPLETE
)
.is_none());
assert!(game_creator_agent_runtime_plan_envelope_parse_error(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"已按默认建议补齐剩余空白,GDD 已提交待审批。"
)
.is_none());
}
/// 只覆盖立项策划链路。做游戏 / 做素材的静态委派子 Agent 逐字保持既有行为:
/// 它们的坏信封仍旧按原路落成 needs-repair,不在这里被拦下重取。
#[test]
fn other_agents_keep_the_existing_escape_path() {
for agent_id in ["design-director", "art-director", "project-supervisor"] {
assert!(
game_creator_agent_runtime_plan_envelope_parse_error(agent_id, TRUNCATED).is_none(),
"{agent_id} 不应进入策划信封重取"
);
}
}
/// 重取次数取原型的 MAX_WASTED_TURNS=3 口径:前两次注入重来,第三次放行落盘。
#[test]
fn the_repair_budget_matches_the_prototype() {
assert_eq!(AGENT_RUNTIME_PLAN_ENVELOPE_REPAIR_ATTEMPTS, 2);
}
}
#[cfg(test)]
mod plan_gdd_blocker_projection_tests {
use super::*;
@@ -1812,7 +1812,7 @@ pub(crate) fn build_static_delegate_structured_result_at(
})
}
fn parse_static_delegate_user_input_request(
pub(crate) fn parse_static_delegate_user_input_request(
response: Option<&str>,
) -> Result<(Option<Vec<AgentRuntimeUserInputQuestion>>, Option<String>), String> {
let Some(response) = response.map(str::trim).filter(|value| !value.is_empty()) else {