From 3bae4cdbb073380ef749a0ca4d8122c4ea5bab13 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 20 Aug 2026 09:55:56 +0000 Subject: [PATCH] =?UTF-8?q?=E6=BE=84=E6=B8=85=E4=BF=A1=E5=B0=81=E5=9C=A8?= =?UTF-8?q?=E6=95=B4=E6=9D=A1=E5=9B=9E=E6=89=A7=E9=80=9A=E9=81=93=E4=B8=8A?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8C=89=E8=87=AA=E7=94=B1=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E7=9B=B2=E5=88=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮只堵了通道末端,父 run 仍然停在 needs-reconciliation:子 Agent 输出 518 字符的合法问询,在源头 last_response 处就被切成 501 字符,剥掉信封首行后 正好 1120 字节,父 run 解析时在字符串中途 EOF。 信封是结构化协议载荷,只是恰好借用了「子 Agent 自由回复」这条文本通道。定界 本身要保的三件事——状态快照与只增审计日志不被自由文本撑爆、子 Agent 文本不无 限量灌进父 Agent 上下文、摘要行只占一行——对信封都已由问询 schema 逐字段硬性 校验保住,再叠一层盲切只会砍断 JSON。 改为共有一条上限规则 static_delegate_result_detail_max_chars:字面以信封首行 开头时取 schema 推导的上限,否则原样走各自的 500/600/240。通道上四处定界全部 接上——last_response、terminal_detail、回执发布、复用既有终态时的摘要,其中 后者把「给人看的摘要」和「给解析的明细」拆成两个值,不再让一行摘要的长度决定 结构化载荷能不能被解析。last_response 与 terminal_detail 必须同源,两者逐字 相等是 finalization 幂等校验的不变式。 非信封文本走的分支与改动前完全一致,做游戏链路(含 codex)不产出该信封,行为 不变。 回归用例覆盖源头与通道两端,并钉住「普通自由回复仍被截到 500 字符」。 Co-Authored-By: Claude Opus 5 --- .../agent/runtime_driver/main_loop_tests.rs | 43 ++++++++++ .../src-tauri/src/agent/runtime_state.rs | 10 ++- .../src/agent/runtime_tools/delegation.rs | 26 +++--- .../src/agent/runtime_tools/delivery.rs | 23 +++--- .../src-tauri/src/delegation.rs | 46 +++++++++++ .../tests/collaboration/static_deliveries.rs | 79 +++++++++++++++++++ 6 files changed, 202 insertions(+), 25 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 8b17c4955..ee4b7103d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -2559,6 +2559,49 @@ fn game_chat_code_prototype_rejects_tampered_art_delivery_identity_before_claim( drop(child_lane); } +#[test] +fn completed_run_exempts_the_clarification_envelope_from_the_free_text_cap() { + let temporary = tempfile::tempdir().expect("create clarification cap root"); + let root = temporary.path().join("project"); + let (_main, child, _delegation_id, _lane) = game_chat_main_art_child_fixture_with_lane( + &root, + "art-director", + &["art-spec", "core-spritesheet"], + false, + ); + + // 源头:last_response 的定界。这里被盲切,后面每一处修复都补不回来—— + // 现场就是子 Agent 输出 518 字符、切到 501 字符,父 run 在 1120 字节处 EOF。 + let envelope = schema_max_clarification_envelope(); + let completed = + prepare_game_creator_agent_runtime_completed_state(&root, child.clone(), &envelope) + .expect("prepare completed state carrying a clarification envelope"); + assert_eq!( + completed.last_response.as_deref(), + Some(envelope.as_str()), + "澄清信封是结构化协议载荷,不能按自由文本盲切" + ); + // terminal_detail 必须与 last_response 逐字相等,否则 finalization 幂等校验 + // 会判「completed task 与 finalization 回复不匹配」。两处上限规则必须同源。 + assert_eq!( + agent_runtime_terminal_detail(&completed), + completed.last_response + ); + + // 非信封的长自由文本仍然守着原来的 500 字符上限——这条豁免不是把定界取消掉。 + let free_text = "普通自由回复。".repeat(200); + let bounded = prepare_game_creator_agent_runtime_completed_state(&root, child, &free_text) + .expect("prepare completed state carrying ordinary free text"); + assert_eq!( + bounded + .last_response + .as_deref() + .map(|value| value.chars().count()), + Some(501), + "普通自由回复必须仍被截到 500 字符加省略号" + ); +} + #[test] fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() { let temporary = tempfile::tempdir().expect("create game-chat safe-default repair root"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 6bcf86cf8..25e9b5069 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -554,7 +554,10 @@ pub(super) fn prepare_game_creator_agent_runtime_completed_state( state.current_action = "等待下一轮输入".to_string(); state.waiting_on = "开发者下一轮输入".to_string(); state.next_step = "等待下一轮输入".to_string(); - state.last_response = Some(sanitize_agent_runtime_text(response, 500)); + state.last_response = Some(sanitize_agent_runtime_text( + response, + static_delegate_result_detail_max_chars(response, 500), + )); state.error = None; complete_agent_runtime_remaining_plan_steps(&mut state, "本轮 Agent 已生成最终回复。"); state @@ -3416,7 +3419,10 @@ pub(super) fn agent_runtime_terminal_detail(state: &AgentRuntimeState) -> Option "cancelled" => Some(state.current_action.as_str()), _ => None, }?; - let detail = sanitize_agent_runtime_text(detail, 500); + // 必须与 last_response 用同一条上限规则:两者相等是 finalization 幂等校验的 + // 不变式(见 finish_game_creator_agent_background_runtime_turn_idempotently_at)。 + let detail = + sanitize_agent_runtime_text(detail, static_delegate_result_detail_max_chars(detail, 500)); (!detail.trim().is_empty()).then_some(detail) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index c3b421180..358bcc504 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -1053,21 +1053,25 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( } if let Some(terminal_status) = game_creator_agent_runtime_terminal_status(&existing) { - let result_summary = existing + let result_detail = existing .terminal_detail .as_deref() .or(existing.error.as_deref()) .unwrap_or(existing.current_action.as_str()); - let result_summary = truncate_agent_runtime_text( - &redact_agent_runtime_project_paths(root, result_summary, 240), - 140, + // 摘要给人看,明细给回执解析:两者不能共用一次截断,否则一行摘要 + // 的长度会决定结构化载荷能不能被父 run 解析。 + let result_detail = redact_agent_runtime_project_paths( + root, + result_detail, + static_delegate_result_detail_max_chars(result_detail, 240), ); + let result_summary = truncate_agent_runtime_text(&result_detail, 140); let structured_result = match build_static_delegate_result_for_child_at( root, &delivery, &existing, terminal_status, - &result_summary, + &result_detail, ) { Ok(result) => result, Err(error) => { @@ -1329,15 +1333,17 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( if let Some(terminal_status) = game_creator_agent_runtime_terminal_status(&existing) { - let result_summary = existing + let result_detail = existing .terminal_detail .as_deref() .or(existing.error.as_deref()) .unwrap_or(error.as_str()); - let result_summary = truncate_agent_runtime_text( - &redact_agent_runtime_project_paths(root, result_summary, 240), - 140, + let result_detail = redact_agent_runtime_project_paths( + root, + result_detail, + static_delegate_result_detail_max_chars(result_detail, 240), ); + let result_summary = truncate_agent_runtime_text(&result_detail, 140); let ready_result = (|| { let _delivery_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( @@ -1358,7 +1364,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( &delivery, &existing, terminal_status, - &result_summary, + &result_detail, )?; mark_static_delegate_delivery_ready_with_result_at( root, 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 870c2b409..abc6f1d79 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,19 +722,12 @@ pub(crate) fn build_static_delegate_result_for_child_at( .error .as_deref() .or((!result_detail.trim().is_empty()).then_some(result_detail)); - // 澄清信封是结构化协议载荷,只是恰好走了 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) + redact_agent_runtime_error( + root, + value, + static_delegate_result_detail_max_chars(value, 500), + ) }); let mut result = build_static_delegate_structured_result_at( root, @@ -1502,7 +1495,11 @@ pub(crate) fn publish_game_creator_agent_delegate_result( .or(child_task.terminal_detail.as_deref()) .or(child_task.error.as_deref()) .unwrap_or(child_task.current_action.as_str()); - let result_detail = redact_agent_runtime_error(root, result_detail, 600); + let result_detail = redact_agent_runtime_error( + root, + result_detail, + static_delegate_result_detail_max_chars(result_detail, 600), + ); if uses_durable_delivery { let existing_delivery = match read_static_delegate_delivery_at(root, delegation_id) { Ok(Some(delivery)) => delivery, 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 e5235165c..0b8e2d84e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -19,6 +19,52 @@ pub(crate) const STATIC_DELEGATE_USER_INPUT_PREFIX: &str = "AGC_NEEDS_USER_INPUT /// 允许的最大合法问询比 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; +/// 澄清信封是结构化协议载荷,只是恰好借用了「子 Agent 自由回复」这条文本通道。 +/// 通道上每一处按普通自由文本盲切字符的定界都会把 JSON 拦腰砍断,父 run 认领回执 +/// 时解析失败,整条委派链停在 needs-reconciliation。信封本身已由问询 schema 硬性 +/// 定界(问题数、选项数、各字段字符上限逐项校验),不需要再叠一层盲切;凡是可能 +/// 承载信封的定界点都从这里取上限,非信封文本仍走各自原有的上限。 +pub(crate) fn static_delegate_result_detail_max_chars( + value: &str, + default_max_chars: usize, +) -> usize { + if value + .trim_start() + .starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) + { + STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS + } else { + default_max_chars + } +} + +/// 构造一份贴着问询 schema 上限的合法澄清信封,供跨模块的通道用例复用。 +/// 通道必须容得下 schema 允许的最大合法问询,而不只是「碰巧短」的那一条。 +#[cfg(test)] +pub(crate) fn schema_max_clarification_envelope() -> String { + let questions = (0..3) + .map(|index| { + serde_json::json!({ + "id": format!("decision_{index}"), + "header": "关键决定", + "question": "当前要定的规则。".repeat(40), + "options": (0..3) + .map(|option| { + serde_json::json!({ + "label": format!("{} · 平行方案", char::from(b'A' + option as u8)), + "description": "该方案的边界与代价。".repeat(20), + }) + }) + .collect::>(), + }) + }) + .collect::>(); + format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ "questions": questions }) + ) +} + // 澄清轮次上限按 source 区分:诉求只来自策划节点,game-chat 单主路径定位零打扰, // 从未承诺给它 3 轮预算,因此取 1;其它 source(包括 Project Supervisor 常规协作)取 3。 const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_DEFAULT: u32 = 3; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index c4f225782..a722f630b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -2720,6 +2720,85 @@ fn claimed_needs_user_input_delivery_becomes_one_supervisor_wait() { fs::remove_dir_all(root).ok(); } +#[test] +fn schema_max_clarification_envelope_survives_the_delegate_result_channel() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-child-max-clarification", + "满格澄清信封通道测试", + ) + .expect("project init"); + let delivery = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "parent-session", + "parent-run", + "parent-action", + "child-max-clarification-delegation", + "design-director", + "child-session", + "child-run", + &["明确核心规则".to_string()], + &[], + None, + ); + let response = schema_max_clarification_envelope(); + // 通道上历史最紧的一处盲切是 500 字符;schema 允许的满格问询远超过它, + // 被切之后 JSON 只会在末尾 EOF,父 run 拿不到问题只能停在 needs-reconciliation。 + assert!( + response.chars().count() > 600, + "满格信封必须超过通道上所有旧盲切上限:{}", + response.chars().count() + ); + assert!( + response.chars().count() <= STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS, + "满格信封仍须落在 schema 推导的上限内:{}", + response.chars().count() + ); + let child_task = AgentRuntimeTaskRecord { + schema_version: "game-creator-agent-runtime-task.v1".to_string(), + task_id: "child-task".to_string(), + agent_id: delivery.target_agent_id.clone(), + session_id: delivery.target_session_id.clone(), + run_id: delivery.target_run_id.clone(), + source: "agent-delegate".to_string(), + parent_agent_id: Some(delivery.parent_agent_id.clone()), + parent_run_id: Some(delivery.parent_run_id.clone()), + delegation_id: Some(delivery.delegation_id.clone()), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + goal_id: None, + goal_revision: 0, + goal_status: None, + task: "明确核心规则".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "已完成澄清请求".to_string(), + terminal_detail: Some(response.clone()), + error: None, + updated_at: unix_timestamp(), + }; + let result = build_static_delegate_result_for_child_at( + &root, + &delivery, + &child_task, + "completed", + &response, + ) + .expect("满格澄清信封必须能被父 run 解析成结构化回执"); + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsUserInput + ); + assert_eq!(result.user_input_questions.len(), 3); + assert!(result + .user_input_questions + .iter() + .all(|question| question.options.len() == 3)); + + fs::remove_dir_all(root).ok(); +} + #[test] fn completed_child_final_response_becomes_needs_user_input_delivery_result() { let root = unique_project_path();