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 a959e5023..ada7af6d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -1812,6 +1812,51 @@ pub(crate) fn build_static_delegate_structured_result_at( }) } +/// 从信封载荷里切出第一个括号配平的 JSON 对象。 +/// +/// serde 的 from_str 要求整段输入就是一个值,尾部多一个字节就整包拒收。而模型在长 +/// 嵌套 JSON 字符串的尾部会退化:27 个历史 run 的 68 条真实澄清信封里,有 2 条把信封 +/// 写完整之后继续吐垃圾(「 马会」「સwerhu рҭ. 北京赛车? тру. [ ]」),信封本身 +/// 一个字节都没坏,却和真正写坏的信封一样被判死,整条委派链停在 needs-repair。 +/// +/// 信封是终态协议载荷,配平的那个对象之后不存在任何协议内容,按配平定界即可。 +/// 反过来,少写闭合符的那一类(同一批里 5 条,结尾是 `}]}` 而非 `}]}]}`)在这里 +/// 仍然失败:那是模型真的没把结构写完,补括号只是替它猜一个它没表达的形状。 +fn static_delegate_user_input_balanced_object(payload: &str) -> Result<&str, String> { + if !payload.starts_with('{') { + return Err("信封标记后不是 JSON 对象".to_string()); + } + let mut depth = 0usize; + let mut in_string = false; + let mut escaped = false; + for (index, character) in payload.char_indices() { + if in_string { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + in_string = false; + } + continue; + } + match character { + '"' => in_string = true, + '{' | '[' => depth += 1, + '}' | ']' => { + depth = depth + .checked_sub(1) + .ok_or_else(|| "信封 JSON 括号不闭合".to_string())?; + if depth == 0 { + return Ok(&payload[..index + character.len_utf8()]); + } + } + _ => {} + } + } + Err("信封 JSON 括号不闭合".to_string()) +} + pub(crate) fn parse_static_delegate_user_input_request( response: Option<&str>, ) -> Result<(Option>, Option), String> { @@ -1821,10 +1866,16 @@ pub(crate) fn parse_static_delegate_user_input_request( if !response.starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) { return Ok((None, None)); } - if response.chars().count() > STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS { + let payload = response[STATIC_DELEGATE_USER_INPUT_PREFIX.len()..].trim(); + // 长度上限约束的是协议载荷本身,所以按配平后的切片算:退化尾巴既然不进解析器, + // 也就不该替一条合法信封把通道撑爆。 + let payload = static_delegate_user_input_balanced_object(payload) + .map_err(|reason| format!("子 Agent 用户澄清请求 JSON 无效:{reason}"))?; + if STATIC_DELEGATE_USER_INPUT_PREFIX.chars().count() + payload.chars().count() + > STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS + { return Err("子 Agent 用户澄清请求超过回执长度上限".to_string()); } - let payload = response[STATIC_DELEGATE_USER_INPUT_PREFIX.len()..].trim(); let value = serde_json::from_str::(payload) .map_err(|error| format!("子 Agent 用户澄清请求 JSON 无效:{error}"))?; let questions = parse_game_creator_agent_user_input_questions(&value)?; @@ -2737,6 +2788,74 @@ mod tests { .expect("an ordinary one-question clarification must pass the relay channel"); } + /// 现场原样抓来的两条退化信封:模型把信封写完整之后,在同一个字符串里继续吐了 + /// 一段垃圾。函数调用的 arguments JSON 一次成型(repairAttempt 全为 0),信封本体 + /// 一个字节都没坏,坏的只是尾巴——这不是截断,是长嵌套 JSON 尾部的解码退化。 + /// + /// 27 个历史 run 的 68 条信封里这类占 2 条。修复前它们和真正写坏的信封同样被 + /// serde 的 trailing characters 判死,整条委派链停在 needs-repair。 + #[test] + fn a_degenerated_tail_after_a_complete_envelope_does_not_kill_the_envelope() { + let cases = [ + // abtest-tide2A-2:尾巴是「 马会」。 + concat!( + "AGC_NEEDS_USER_INPUT_V1\n", + r#"{"questions":[{"id":"replay_motivation","header":"第1轮·关键决定","question":"当前要决定:固定五岛海图的重复游玩动力采用哪种方案?现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用30~90分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#, + ), + // verify-farm-4:尾巴是古吉拉特语字母、西里尔字母和中文垃圾词的混合物。 + concat!( + "AGC_NEEDS_USER_INPUT_V1\n", + r#"{"questions":[{"id":"replay_progression","header":"第2轮·关键决定","question":"当前要决定:自由经营农场的长期目标采用哪种组合?这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 30–90 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, + ), + ]; + for response in cases { + let (questions, sha256) = parse_static_delegate_user_input_request(Some(response)) + .expect("a complete envelope followed by garbage must still parse"); + let questions = questions.expect("questions present"); + assert_eq!(questions.len(), 1); + assert_eq!(questions[0].options.len(), 3); + assert_eq!(sha256.as_deref().map(str::len), Some(64)); + } + } + + /// 同一批里另外 5 条是另一种形态:结尾少了最外面的 `]}`,括号差 2 层。 + /// 那是模型真的没把结构写完,不能靠补括号替它猜一个没表达出来的形状—— + /// 这条必须继续失败,交给 run 内重取。 + #[test] + fn an_envelope_missing_its_closing_brackets_still_fails() { + // verify-farm-2 现场原文,结尾是 `}]}` 而非 `}]}]}`。 + let response = concat!( + "AGC_NEEDS_USER_INPUT_V1\n", + r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·关键决定","question":"当前要决定:这款农场经营游戏的一局,玩家主要通过什么目标获得满足?现在先定核心闭环,才能控制 MVP 范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#, + ); + let error = parse_static_delegate_user_input_request(Some(response)) + .expect_err("an envelope that stops short of closing must not parse"); + assert!(error.contains("括号不闭合"), "unexpected error: {error}"); + } + + /// 定界只认字符串外的括号。信封正文里出现的括号字符必须被跳过,否则一条完全 + /// 合法的信封会因为问题文案里写了 `}` 而被提前切断。 + #[test] + fn balanced_object_scanning_ignores_brackets_inside_strings() { + let response = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ + "questions": [{ + "id": "brace_heavy", + "header": "括号", + "question": "存档格式写成 {\"slot\": [1]} 还是二进制?", + "options": [ + {"label": "A · JSON", "description": "形如 {\"slot\": [1]} 的文本存档,可读但体积大。"}, + {"label": "B · 二进制", "description": "紧凑但要自己写工具才能看,形如 ]}]} 的字节序列。"} + ] + }] + }) + ); + let (questions, _) = parse_static_delegate_user_input_request(Some(&response)) + .expect("brackets inside strings must not terminate the scan"); + assert_eq!(questions.expect("questions present").len(), 1); + } + #[test] fn static_delegate_target_agent_ids_include_claimed_and_exclude_suppressed_or_repair() { let root = std::env::temp_dir().join(format!(