From 7df033fcde5495182332498ba87082f42dff346a Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 20 Aug 2026 08:49:12 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=BE=84=E6=B8=85=E5=9B=9E?= =?UTF-8?q?=E6=89=A7=E9=80=9A=E9=81=93=E5=AE=B9=E4=B8=8D=E4=B8=8B=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E6=AD=A3=E5=B8=B8=E9=97=AE=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 子 Agent 的澄清信封走的是 error 文本通道,两处把它当普通错误消息处理: 一是中转通道的字符上限写死 500,而它承载的问询 schema 允许 3 个问题、每题 400 字符、每题 2-3 个选项(label 60 + description 240)——单个 schema 合法的问题光内 容就有 1376 字符,通道连一个满格的合法问题都装不下。现场一问三选的正常中文问询 501 字符,正好被拒收。改成由 schema 自己的上限推导,推导常量放在 schema 所在的 user_input.rs;顺带把内联的 options.len() < 2 || > 3 换成具名常量,让推导有据。 二是父 run 认领回执时按错误消息截到 500 字符再补省略号。现场子 Agent 的真实输出 521 字符,截断后 501 字符,JSON 拦腰断在末尾,父 run 解析失败停在 needs-reconciliation。只放宽上限治不了这一处:载荷在到达解析器之前就已经被切了。 识别信封前缀时按信封通道上限放行,其余错误消息仍是 500。 两处都得对,少一处这条链路就断。上限是沿用 master 的(82957fe1d,2026-08-12), 本分支没改过;做游戏链路的专业 Agent 很少提带选项的澄清,所以此前没暴露,而 M1 策划把"先问清楚再出 GDD"做成了必经步骤。 放宽上限不会让原本通过的载荷失败,逐字段复核仍在 parse_game_creator_agent_user_input_questions。新增三条用例:schema 允许的最大合法 问询必须能过通道、现场那条普通中文问询必须能过、同一条信封被截断后必然解析失败。 Co-Authored-By: Claude Opus 5 --- .../src/agent/runtime_tools/delivery.rs | 15 ++- .../src-tauri/src/delegation.rs | 118 +++++++++++++++++- .../src-tauri/src/user_input.rs | 31 ++++- 3 files changed, 159 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 833c188b1..870c2b409 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -722,7 +722,20 @@ pub(crate) fn build_static_delegate_result_for_child_at( .error .as_deref() .or((!result_detail.trim().is_empty()).then_some(result_detail)); - let result_detail = result_detail.map(|value| redact_agent_runtime_error(root, value, 500)); + // 澄清信封是结构化协议载荷,只是恰好走了 error 文本通道。按普通错误消息截到 + // 500 字符会把 JSON 拦腰切断,父 run 随后解析失败并停在 needs-reconciliation: + // 现场子 Agent 的真实输出 521 字符,截断后 501 字符,JSON 在末尾 EOF。 + let result_detail = result_detail.map(|value| { + let max_chars = if value + .trim_start() + .starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) + { + STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS + } else { + 500 + }; + redact_agent_runtime_error(root, value, max_chars) + }); let mut result = build_static_delegate_structured_result_at( root, terminal_status, diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 7f9eec417..e5235165c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -13,8 +13,12 @@ const STATIC_DELEGATE_ACCEPTANCE_CRITERION_MAX_CHARS: usize = 240; const STATIC_DELEGATE_MAX_EXPECTED_ARTIFACTS: usize = 16; const STATIC_DELEGATE_EXPECTED_ARTIFACT_MAX_CHARS: usize = 240; const STATIC_DELEGATE_MAX_EVIDENCE: usize = 16; -const STATIC_DELEGATE_USER_INPUT_PREFIX: &str = "AGC_NEEDS_USER_INPUT_V1\n"; -const STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS: usize = 500; +pub(crate) const STATIC_DELEGATE_USER_INPUT_PREFIX: &str = "AGC_NEEDS_USER_INPUT_V1\n"; +/// 中转通道的上限必须由澄清问询 schema 推导。写死 500 时,一问三选的正常中文 +/// 问询(501 字符)就会在父 run 认领回执时被拒收,整条委派链阻断;而 schema 本身 +/// 允许的最大合法问询比 500 大一个数量级。 +pub(crate) const STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS: usize = + STATIC_DELEGATE_USER_INPUT_PREFIX.len() + AGENT_RUNTIME_USER_INPUT_MAX_WIRE_CHARS; // 澄清轮次上限按 source 区分:诉求只来自策划节点,game-chat 单主路径定位零打扰, // 从未承诺给它 3 轮预算,因此取 1;其它 source(包括 Project Supervisor 常规协作)取 3。 const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_DEFAULT: u32 = 3; @@ -2552,6 +2556,116 @@ fn validate_static_delegate_structured_result( mod tests { use super::*; + /// 中转通道和澄清问询 schema 之间隔着一个字符数上限,两边没有任何东西相连。 + /// 通道窄于 schema 的代价不是「问询被截断」:子 Agent 提了一个完全合法的问题, + /// Runtime 会在父 run 认领回执时整包拒收,委派链就此阻断——现场实测一问三选的 + /// 正常中文问询是 501 字符,而当时的上限恰好是 500。 + /// + /// 所以这里锁的是**包含关系**:schema 允许的最大合法问询,必须能过通道。 + #[test] + fn user_input_relay_channel_admits_the_largest_schema_legal_request() { + let long_text = |count: usize| "问".repeat(count); + let questions = (0..3) + .map(|index| { + serde_json::json!({ + "id": format!("q{index}{}", "a".repeat(60)), + "header": long_text(12), + "question": long_text(400), + "options": (0..3) + .map(|option| serde_json::json!({ + "label": format!("{option}{}", long_text(59)), + "description": long_text(240), + })) + .collect::>(), + }) + }) + .collect::>(); + let response = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ "questions": questions }) + ); + assert!( + response.chars().count() <= STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS, + "schema 允许的最大问询 {} 字符超过了中转通道上限 {}", + response.chars().count(), + STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS + ); + let (parsed, sha256) = parse_static_delegate_user_input_request(Some(&response)) + .expect("largest schema-legal clarification must pass the relay channel"); + assert_eq!(parsed.expect("questions").len(), 3); + assert!(sha256.is_some_and(|value| value.len() == 64)); + } + + /// 澄清信封走的是 error 文本通道,会被按普通错误消息截断。现场子 Agent 的真实 + /// 输出 521 字符,截到 500 再补一个省略号正好 501——JSON 拦腰断在末尾,父 run + /// 解析失败停在 needs-reconciliation。放宽拒收上限治不了这个:载荷在到达解析器 + /// 之前就已经被切了。 + #[test] + fn truncating_a_clarification_envelope_makes_it_unparseable() { + let response = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ + "questions": [{ + "id": "core_loop", + "header": "第1轮·核心", + "question": "影子能力在首个可玩闭环里承担什么作用?这决定关卡布局与原型优先级,也决定第一批谜题按什么规则组合。", + "options": [ + { "label": "A · 暗影分身", "description": "影子沿地面或墙面独立移动,可压机关、挡感应光、穿窄缝;规则直观,代价是要处理可达范围与回收。" }, + { "label": "B · 暗影桥梁", "description": "调整光源与站位让影子延展成短暂平台或连接导电点;偏空间构图,代价是碰撞与落脚可读性更严格。" }, + ], + }] + }) + ); + // 完整信封能过通道。 + parse_static_delegate_user_input_request(Some(&response)).expect("intact envelope parses"); + // 同一条信封被截断后必然解析失败——这正是现场那条 needs-reconciliation。 + let truncated: String = response + .chars() + .take(response.chars().count() - 20) + .collect(); + let error = parse_static_delegate_user_input_request(Some(&truncated)) + .expect_err("a truncated envelope must not parse"); + assert!(error.contains("JSON 无效"), "unexpected error: {error}"); + } + + /// 现场那条 501 字符的真实问询:一个问题、三个选项,没有任何一项接近 schema 上限。 + #[test] + fn user_input_relay_channel_admits_one_ordinary_chinese_question() { + let response = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ + "questions": [{ + "id": "plan_round_1", + "header": "第1轮·关键决定", + "question": "当前要决定:影子能力在首个可玩闭环中的核心作用。它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。", + "options": [ + { + "label": "A · 影子化为可独立移动的暗影分身", + "description": "机器人定位光源后,影子沿地面或墙面移动,可压住机关、挡住感应光或穿过窄缝;规则直观、谜题组合清晰,代价是要处理影子可达范围与回收。", + }, + { + "label": "B · 影子作为可拉伸的暗影桥梁", + "description": "玩家调整光源与站位,让影子延展成短暂平台或连接导电点;更偏空间构图解谜,代价是碰撞、长度和落脚可读性需要更严格。", + }, + { + "label": "需要原型验证", + "description": "先用三十到九十分钟做一个最小原型,把两种方案的操作手感、关卡搭建成本和可读性各跑一遍,再决定首个可玩闭环采用哪一种,避免一开始就压死方向。", + }, + ], + }] + }) + ); + // 修复前这个上限是写死的 500,而这条问询没有任何一个字段接近 schema 上限。 + const PREVIOUS_HARDCODED_CAP: usize = 500; + assert!( + response.chars().count() > PREVIOUS_HARDCODED_CAP, + "现场问询 {} 字符,应当超过旧的写死上限", + response.chars().count() + ); + parse_static_delegate_user_input_request(Some(&response)) + .expect("an ordinary one-question clarification must pass the relay channel"); + } + #[test] fn static_delegate_target_agent_ids_include_claimed_and_exclude_suppressed_or_repair() { let root = std::env::temp_dir().join(format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs index 57abd8f17..099642603 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs @@ -11,11 +11,36 @@ pub(crate) const AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED: &str = "cancelled"; const AGENT_RUNTIME_USER_INPUT_SIDECAR_MAX_BYTES: usize = 128 * 1024; const AGENT_RUNTIME_USER_INPUT_MAX_QUESTIONS: usize = 3; +const AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS: usize = 2; +const AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS: usize = 3; const AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS: usize = 64; const AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS: usize = 12; const AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS: usize = 400; const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS: usize = 60; const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS: usize = 240; + +/// 一份 schema 合法的澄清问询在线上最多可能有多长(字符)。 +/// +/// 存在的意义是给中转通道一个由 schema 推导的上限,而不是让它自己拍一个数。 +/// 通道比 schema 窄的后果不是「模型写短一点」——子 Agent 提了一个完全合法的 +/// 问题,Runtime 会在父 run 认领回执时拒收,整条委派链就此阻断。真正的逐字段 +/// 复核仍在 `parse_game_creator_agent_user_input_questions`,这个上限只是粗筛。 +/// +/// JSON 语法开销按每个标量字段一对引号加冒号逗号、每层括号若干字符宽估。 +pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_WIRE_CHARS: usize = { + const OPTION_SYNTAX_CHARS: usize = 40; + const QUESTION_SYNTAX_CHARS: usize = 64; + const ENVELOPE_SYNTAX_CHARS: usize = 64; + let per_option = AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS + + AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS + + OPTION_SYNTAX_CHARS; + let per_question = AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS + + AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS + + AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS + + AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS * per_option + + QUESTION_SYNTAX_CHARS; + AGENT_RUNTIME_USER_INPUT_MAX_QUESTIONS * per_question + ENVELOPE_SYNTAX_CHARS +}; const AGENT_RUNTIME_USER_INPUT_MAX_ANSWER_CHARS: usize = 4_000; const AGENT_RUNTIME_USER_INPUT_MAX_TOTAL_ANSWER_CHARS: usize = 8_000; const AGENT_RUNTIME_USER_INPUT_MAX_RESPONSE_ID_CHARS: usize = 160; @@ -274,9 +299,11 @@ fn normalize_user_input_questions( question_index + 1 ), )?; - if question.options.len() < 2 || question.options.len() > 3 { + if question.options.len() < AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS + || question.options.len() > AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS + { return Err(format!( - "user.input_request question #{} 必须提供 2-3 个选项", + "user.input_request question #{} 必须提供 {AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS}-{AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS} 个选项", question_index + 1 )); }