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 )); }