From b1db88c114ff1d97bbe425106de8c221e4c85d7c Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Jul 2026 19:56:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E6=80=BB=E6=8E=A7?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E4=B8=8E=E5=B7=A5=E5=85=B7=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E4=BA=A4=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复Windows下tool-plan账本按句柄原子安装失败。 补齐项目总控空态、持久Runtime水合与核对后重试交互。 修正Responses多角色内容映射与steer审计幂等身份。 修复首批协作repair累积、缺失Agent约束和isolated单槽替换。 收紧API Key安全持久化检测并覆盖装饰赋值与自然语言边界。 修复Provider retry恢复扫描与无画布密钥时的视觉产物降级。 补充定向回归测试和项目技术文档。 --- .../src-tauri/Cargo.toml | 2 +- .../pending_confirmation_ledger.rs | 222 +++++++++++- .../runtime_actions/provider_tool_plan.rs | 173 +++++++++ .../src-tauri/src/agent/runtime_driver.rs | 2 + .../agent/runtime_driver/lifecycle_control.rs | 53 ++- .../agent/runtime_driver/main_loop_tests.rs | 25 ++ .../src/agent/runtime_driver/task_start.rs | 10 +- .../src/agent/runtime_tools/delegation.rs | 12 +- .../src-tauri/src/project/agent_db.rs | 65 +++- .../src/project/agent_db/security_tests.rs | 32 ++ .../src-tauri/src/provider_retry.rs | 12 +- .../src/tests/collaboration/delegation.rs | 57 +++ .../collaboration/supervisor_planning.rs | 78 ++-- .../src-tauri/src/tests/mod.rs | 48 +++ .../src-tauri/src/tests/provider.rs | 97 +++++ .../src/tests/runtime_actions/support.rs | 1 + .../tests/runtime_actions/task_lifecycle.rs | 62 ++++ .../src-tauri/src/tool_plan_handoff.rs | 1 + .../src/tool_plan_handoff/storage_windows.rs | 33 +- .../src-tauri/src/tool_plan_handoff/tests.rs | 8 + apps/ai-game-creator-shell/src/App.tsx | 99 ++++- .../src/features/agent-runtime/model.ts | 3 + .../src/features/agent-runtime/panels.tsx | 93 +++-- apps/ai-game-creator-shell/src/styles.css | 15 + .../appSurface/project-development.suite.ts | 341 ++++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 15 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 7 + server-rs/crates/platform-llm/src/lib.rs | 119 +++++- 28 files changed, 1571 insertions(+), 114 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 47ef56504..b58fc65b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -37,4 +37,4 @@ tauri-plugin-clipboard-manager = "2.3.2" libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_JobObjects"] } +windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects"] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index fcdaa4bd6..82b6476b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -28,18 +28,18 @@ pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content( ) -> Result<(), String> { let lower = content.to_ascii_lowercase(); let sensitive_rule = [ - ".env", - "game-creator.config", - "authorization:", - "cookie:", - "api_key", - "apikey", - "api key", - "token=", - "bearer ", + (0, ".env"), + (1, "game-creator.config"), + (2, "authorization:"), + (3, "cookie:"), + (4, "api_key"), + (5, "apikey"), + (7, "token="), + (8, "bearer "), ] .into_iter() - .position(|marker| lower.contains(marker)) + .find_map(|(rule, marker)| lower.contains(marker).then_some(rule)) + .or_else(|| agent_runtime_contains_api_key_assignment(&lower).then_some(6)) .or_else(|| (redact_secret_tokens(content) != content).then_some(9)); if let Some(rule) = sensitive_rule { return Err(format!( @@ -59,6 +59,154 @@ pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content( Ok(()) } +fn agent_runtime_contains_api_key_assignment(lower: &str) -> bool { + lower.match_indices("api key").any(|(index, marker)| { + let Some(value) = + agent_runtime_api_key_assignment_value(lower[index + marker.len()..].trim_start()) + else { + return false; + }; + let value = value + .trim_start() + .trim_start_matches(|character| matches!(character, '"' | '\\')) + .trim_start(); + !agent_runtime_api_key_assignment_is_safe_status(value) + }) +} + +fn agent_runtime_api_key_assignment_value(remainder: &str) -> Option<&str> { + let mut cursor = 0usize; + loop { + if remainder[..cursor].chars().count() > 64 { + return None; + } + let current = &remainder[cursor..]; + let trimmed = current.trim_start(); + cursor += current.len().saturating_sub(trimmed.len()); + let current = &remainder[cursor..]; + let character = current.chars().next()?; + if matches!(character, ':' | '=' | ':') { + return Some(¤t[character.len_utf8()..]); + } + if matches!(character, '*' | '`' | '_' | '~' | '\\' | '\'' | '"' | ']') { + cursor += character.len_utf8(); + continue; + } + + let closing = match character { + '(' => Some(')'), + '(' => Some(')'), + '[' => Some(']'), + '【' => Some('】'), + _ => None, + }; + if let Some(closing) = closing { + let after_open = ¤t[character.len_utf8()..]; + let closing_index = after_open.find(closing)?; + let qualifier = &after_open[..closing_index]; + if qualifier.chars().count() > 32 + || qualifier + .chars() + .any(|value| matches!(value, ':' | '=' | ':' | '"' | '\n' | '\r')) + { + return None; + } + cursor += character.len_utf8() + closing_index + closing.len_utf8(); + continue; + } + + let qualifier = [ + "value", + "production", + "development", + "prod", + "test", + "dev", + "值", + "生产", + "测试", + "开发", + ] + .into_iter() + .find(|qualifier| { + current.strip_prefix(qualifier).is_some_and(|suffix| { + suffix.chars().next().is_none_or(|next| { + next.is_whitespace() + || matches!( + next, + ':' | '=' | ':' | '*' | '`' | '_' | '~' | '(' | '(' | '[' | '【' + ) + }) + }) + })?; + cursor += qualifier.len(); + } +} + +fn agent_runtime_api_key_assignment_is_safe_status(value: &str) -> bool { + let value = agent_runtime_serialized_string_value(value) + .trim() + .trim_end_matches(['。', '.']); + if [ + "当前未配置", + "未配置", + "没有配置", + "未提供", + "缺失", + "不存在", + "不可用", + "为空", + "禁止", + "不要", + "不得", + "无需", + "not configured", + "unconfigured", + "not available", + "unavailable", + "missing", + "absent", + "none", + "empty", + "not provided", + "do not", + "never", + "disabled", + ] + .into_iter() + .any(|safe_status| value == safe_status) + { + return true; + } + + matches!( + value, + "当前未配置,请按无密钥路径降级" + | "未配置,请按无密钥路径降级" + | "not configured; use the text-only fallback" + ) +} + +fn agent_runtime_serialized_string_value(value: &str) -> &str { + for (index, character) in value.char_indices() { + if character != '"' { + continue; + } + let escaped = value[..index] + .as_bytes() + .iter() + .rev() + .take_while(|byte| **byte == b'\\') + .count() + % 2 + == 1; + if !escaped { + return &value[..index]; + } + } + value +} + pub(crate) fn agent_runtime_contains_secret_key_prefix(content: &str, prefix: &str) -> bool { content .match_indices(prefix) @@ -531,3 +679,57 @@ pub(in crate::agent) fn consume_game_creator_agent_runtime_tool_confirmation( })?; Ok(!action_fingerprint.trim().is_empty() && confirmed_fingerprint == action_fingerprint) } + +#[cfg(test)] +mod tests { + use super::validate_agent_runtime_pending_serialized_content; + use std::path::Path; + + #[test] + fn pending_content_allows_api_key_security_guidance_without_secret_material() { + for task in [ + "继续修复失败的视觉任务;不要读取或暴露 External Editor API Key。", + "External Editor API Key:当前未配置,请按无密钥路径降级。", + "External Editor API Key: not configured; use the text-only fallback.", + "不要读取或暴露 External Editor API Key;失败时:改走文本降级。", + "不要暴露 API Key,说明见 https://example.test/docs", + ] { + let content = serde_json::json!({ + "action": { + "tool": "agent.delegate", + "input": { "task": task } + } + }) + .to_string(); + + validate_agent_runtime_pending_serialized_content(Path::new("C:\\workspace"), &content) + .expect("natural-language API Key guidance is not secret material"); + } + } + + #[test] + fn pending_content_still_rejects_api_key_fields_and_secret_tokens() { + let root = Path::new("C:\\workspace"); + for (content, rule) in [ + (r#"{"apiKey":"plain-secret-material"}"#, 5), + (r#"{"api_key":"plain-secret-material"}"#, 4), + (r#"{"task":"API Key: plain-secret-material"}"#, 6), + (r#"{"task":"API Key = plain-secret-material"}"#, 6), + (r#"{"task":"API Key: none-but-real-secret-material"}"#, 6), + ( + r#"{"task":"API Key: not configured; actual value plain-secret-material"}"#, + 6, + ), + (r#"{"task":"API Key: disabled plain-secret-material"}"#, 6), + (r#"{"task":"**API Key**: plain-secret-material"}"#, 6), + (r#"{"task":"`API Key`: plain-secret-material"}"#, 6), + (r#"{"task":"API Key(生产): plain-secret-material"}"#, 6), + (r#"{"token":"token=plain-secret-material"}"#, 7), + (r#"{"note":"sk-prohibitedsecret"}"#, 9), + ] { + let error = validate_agent_runtime_pending_serialized_content(root, content) + .expect_err("sensitive content must remain rejected"); + assert!(error.contains(&format!("#{rule}")), "{content}: {error}"); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 4c3285ec7..02cf1f9ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -1,5 +1,91 @@ use super::*; +fn supervisor_collaboration_repair_action_key(action: &AgentRuntimeToolAction) -> Option { + match action.tool.trim() { + "agent.delegate" => action + .input + .get("agentId") + .or_else(|| action.input.get("agent_id")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|agent_id| format!("delegate:{agent_id}")), + "agent.spawn_isolated" => Some("isolated".to_string()), + _ => None, + } +} + +fn merge_supervisor_collaboration_repair_actions( + accumulated: &[AgentRuntimeToolAction], + current: &[AgentRuntimeToolAction], +) -> Vec { + let mut merged = accumulated.to_vec(); + for action in current { + let Some(key) = supervisor_collaboration_repair_action_key(action) else { + continue; + }; + if let Some(existing) = merged.iter_mut().find(|candidate| { + supervisor_collaboration_repair_action_key(candidate).as_deref() == Some(key.as_str()) + }) { + *existing = action.clone(); + } else { + merged.push(action.clone()); + } + } + merged +} + +fn supervisor_collaboration_missing_agent_ids(error: &str) -> Vec { + let mut missing = error + .split_once("missingStaticAgents=") + .map(|(_, suffix)| { + suffix + .split(['·', ';', ';', '\n']) + .next() + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|value| { + !value.is_empty() && *value != "-" && !value.eq_ignore_ascii_case("none") + }) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + for agent_id in ["code-prototype", "quality-review", "art-asset-plan"] { + if error.contains(&format!("缺少 {agent_id} 委派")) + && !missing.iter().any(|value| value == agent_id) + { + missing.push(agent_id.to_string()); + } + } + missing +} + +fn restrict_supervisor_collaboration_repair_to_missing_agents( + request: &mut LlmRunRequest, + error: &str, +) -> Result<(), String> { + let missing = supervisor_collaboration_missing_agent_ids(error); + if missing.is_empty() { + return Ok(()); + } + let delegate_function = native_runtime_function_name("agent.delegate") + .ok_or_else(|| "无法生成 Supervisor 首批委派修复工具名".to_string())?; + let delegate = request + .function_tools + .iter_mut() + .find(|tool| tool.name == delegate_function) + .ok_or_else(|| "Supervisor 首批委派修复工具目录缺少 agent.delegate".to_string())?; + let agent_id = delegate + .parameters + .pointer_mut("/properties/input/properties/agentId") + .and_then(serde_json::Value::as_object_mut) + .ok_or_else(|| "Supervisor 首批委派修复 agent.delegate schema 缺少 agentId".to_string())?; + agent_id.insert("enum".to_string(), serde_json::json!(missing)); + Ok(()) +} + pub(in crate::agent) fn append_game_creator_agent_tool_plan_audit_idempotent( root: &Path, record: serde_json::Value, @@ -27,6 +113,10 @@ pub(in crate::agent) fn append_game_creator_agent_tool_plan_audit_idempotent( .get("repairAttempt") .and_then(serde_json::Value::as_u64) .ok_or_else(|| "tool-plan 审计缺少 repairAttempt".to_string())?; + record + .get("appliedSteerCursor") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| "tool-plan 审计缺少 appliedSteerCursor".to_string())?; if request_slot != format!("loop-{loop_iteration}-repair-{repair_attempt}") { return Err("tool-plan 审计 requestSlot 与 loop/repair 身份不匹配".to_string()); } @@ -182,6 +272,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD || agent_runtime_autonomous_project_verify_available(root); let mut autonomous_scaffold_repair_active = false; + let mut supervisor_collaboration_repair_active = false; + let mut supervisor_collaboration_repair_actions = Vec::new(); for repair_attempt in 0..=format_repair_attempts { if game_creator_agent_runtime_cancel_requested_for(root, agent_id, run_id) { return Err("Agent 后台任务已收到取消请求".to_string()); @@ -275,10 +367,29 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at "{:x}", Sha256::digest(response_handoff.provider_request_id.as_bytes()) ); + let mut supervisor_collaboration_candidate_actions = None; let parsed = parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified( &response, &mcp_catalog, ) + .map(|mut parsed| { + let merged = merge_supervisor_collaboration_repair_actions( + if supervisor_collaboration_repair_active { + &supervisor_collaboration_repair_actions + } else { + &[] + }, + &parsed.plan.actions, + ); + supervisor_collaboration_candidate_actions = Some(merged.clone()); + if supervisor_collaboration_repair_active { + parsed.plan.plan_update = None; + parsed.plan.plan.clear(); + parsed.plan.response.clear(); + parsed.plan.actions = merged; + } + parsed + }) .and_then(|parsed| { let source_payload = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { let verification_gate = read_game_creator_agent_runtime_verification_gate( @@ -522,6 +633,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at "loopIteration": loop_index, "repairAttempt": repair_attempt, "requestSlot": request_slot, + "appliedSteerCursor": request_snapshot.applied_steer_cursor, "responseFingerprint": response_fingerprint, "providerRequestIdSha256": provider_request_id_sha256, "protocol": protocol, @@ -586,6 +698,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at "loopIteration": loop_index, "repairAttempt": repair_attempt, "requestSlot": request_slot, + "appliedSteerCursor": request_snapshot.applied_steer_cursor, "responseFingerprint": response_fingerprint, "providerRequestIdSha256": provider_request_id_sha256, "attempt": next_attempt, @@ -728,7 +841,15 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at } } if force_supervisor_initial_collaboration { + supervisor_collaboration_repair_active = true; + if let Some(actions) = supervisor_collaboration_candidate_actions.take() { + supervisor_collaboration_repair_actions = actions; + } restrict_agent_runtime_supervisor_collaboration_repair_tools(&mut request)?; + restrict_supervisor_collaboration_repair_to_missing_agents( + &mut request, + &protocol_error, + )?; let instruction = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { @@ -939,3 +1060,55 @@ pub(crate) async fn request_game_creator_agent_background_tool_plan_for_test( } } } + +#[cfg(test)] +mod supervisor_collaboration_repair_tests { + use super::*; + + fn collaboration_action(tool: &str, input: serde_json::Value) -> AgentRuntimeToolAction { + AgentRuntimeToolAction { + tool: tool.to_string(), + reason: None, + input, + } + } + + #[test] + fn missing_static_agents_ignores_none_sentinel() { + assert!(supervisor_collaboration_missing_agent_ids( + "Project Supervisor 首批协作不满足项目合同:static=1/2 · missingStaticAgents=none" + ) + .is_empty()); + assert_eq!( + supervisor_collaboration_missing_agent_ids( + "Project Supervisor 首批协作不满足项目合同:missingStaticAgents=code-prototype,quality-review · isolatedChildrenTotal=0" + ), + vec!["code-prototype".to_string(), "quality-review".to_string()] + ); + } + + #[test] + fn isolated_repair_replaces_the_single_accumulated_slot() { + let accumulated = vec![collaboration_action( + "agent.spawn_isolated", + serde_json::json!({ + "children": [{"templateAgentId": "quality-review", "task": "旧检查任务"}], + "joinMode": "all" + }), + )]; + let replacement = collaboration_action( + "agent.spawn_isolated", + serde_json::json!({ + "children": [{"templateAgentId": "quality-review", "task": "修正后的检查任务"}], + "joinMode": "all" + }), + ); + + let merged = merge_supervisor_collaboration_repair_actions( + &accumulated, + std::slice::from_ref(&replacement), + ); + + assert_eq!(merged, vec![replacement]); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 99919b715..45289ff7e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -238,6 +238,8 @@ pub(crate) use interaction::{ answer_game_creator_agent_runtime_user_input_at, confirm_game_creator_agent_runtime_task_at, pending_repository_context_drift_observation, reject_game_creator_agent_runtime_task_at, }; +#[cfg(test)] +pub(crate) use lifecycle_control::resolve_game_creator_agent_runtime_retry_configuration_at; pub(crate) use lifecycle_control::{ append_game_creator_agent_runtime_queued_cancellation, cancel_game_creator_agent_runtime_task_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs index cc5f5ca82..26a309b6d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs @@ -738,6 +738,44 @@ pub(crate) fn append_game_creator_agent_runtime_queued_cancellation( Ok(()) } +pub(crate) fn resolve_game_creator_agent_runtime_retry_configuration_at( + root: &Path, + task: &AgentRuntimeTaskRecord, + delegated: bool, +) -> Result<(String, String), String> { + let (run_profile, _) = agent_runtime_run_profile_identity_at( + root, + &task.agent_id, + &task.run_id, + Some(&task.run_profile), + Some(&task.run_profile_binding_fingerprint), + )?; + let source = if delegated { + "agent-delegate-retry".to_string() + } else if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + let binding = read_game_creator_agent_runtime_run_profile_binding( + root, + &task.agent_id, + &task.run_id, + )? + .ok_or_else(|| "自主构建 Agent Runtime 重试缺少 Run Profile 绑定".to_string())?; + if binding.parent_run_id.is_some() + || binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || binding.root_run_id != task.run_id + || !matches!( + binding.source.as_str(), + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE + ) + { + return Err("自主构建 Agent Runtime 重试绑定不是可信 Supervisor 根 Run".to_string()); + } + binding.source + } else { + "agent-background-task".to_string() + }; + Ok((run_profile, source)) +} + pub(crate) fn retry_game_creator_agent_runtime_task_at( root: &Path, agent_id: &str, @@ -848,11 +886,12 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( }), _ => None, }; - let retry_source = if retry_link.is_some() { - "agent-delegate-retry" - } else { - "agent-background-task" - }; + let (retry_run_profile, retry_source) = + resolve_game_creator_agent_runtime_retry_configuration_at( + root, + &task, + retry_link.is_some(), + )?; let (mut result, actual_retry_run_id) = with_agent_conversation_session_lane_at( root, &agent_id, @@ -864,8 +903,8 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( Some(&task.session_id), &task.task, &retry_run_id, - retry_source, - Some(&task.run_profile), + &retry_source, + Some(&retry_run_profile), retry_link.as_ref(), ) }, 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 092785609..3da956099 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 @@ -205,6 +205,31 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState revision } +#[test] +fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_configured() { + { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + assert!(!autonomous_manifest_ready_task_requires_visual_asset( + "design-foundation" + )); + assert!(!autonomous_manifest_ready_task_requires_visual_asset( + "art-asset-plan" + )); + } + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"visual-ready-task-test-key"}}"#.to_string(), + ); + assert!(autonomous_manifest_ready_task_requires_visual_asset( + "design-foundation" + )); + assert!(autonomous_manifest_ready_task_requires_visual_asset( + "art-asset-plan" + )); + assert!(!autonomous_manifest_ready_task_requires_visual_asset( + "code-prototype" + )); +} + #[test] fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 8f8fb1844..bd776a517 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -1117,10 +1117,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke &task_text, )?; if status == GameCreationAppTaskStatus::Completed - && matches!( - manifest_task.id.as_str(), - "design-foundation" | "art-asset-plan" - ) + && autonomous_manifest_ready_task_requires_visual_asset(&manifest_task.id) && !manifest_has_required_visual_asset(root, &manifest, &manifest_task.id) { status = GameCreationAppTaskStatus::Failed; @@ -1199,6 +1196,11 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke Ok(true) } +pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool { + editor_api_key_is_configured() + && matches!(task_id, "design-foundation" | "art-asset-plan") +} + pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( task: &GameCreationAppTaskState, ) -> String { 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 ce7888497..f5aa2c596 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 @@ -318,10 +318,14 @@ pub(crate) fn observe_agent_runtime_agent_delegate( agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]); let repair_of_delegation_id = (!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id); - let required_visual_artifact = match target_agent_id.as_str() { - "design-foundation" => Some("assets/ui-prototype.png"), - "art-asset-plan" => Some("assets/art-spritesheet.png"), - _ => None, + let required_visual_artifact = if editor_api_key_is_configured() { + match target_agent_id.as_str() { + "design-foundation" => Some("assets/ui-prototype.png"), + "art-asset-plan" => Some("assets/art-spritesheet.png"), + _ => None, + } + } else { + None }; if repair_of_delegation_id.is_none() && required_visual_artifact.is_some_and(|required| { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index ae125af23..b34c59124 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -1610,6 +1610,7 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( "loopIteration", "repairAttempt", "requestSlot", + "appliedSteerCursor", "responseFingerprint", "providerRequestIdSha256", "protocol", @@ -1637,6 +1638,7 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( "loopIteration", "repairAttempt", "requestSlot", + "appliedSteerCursor", "responseFingerprint", "providerRequestIdSha256", "protocol", @@ -1706,6 +1708,13 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( return Err(format!("Agent DB tool-plan 幂等审计字段无效:{field}")); } } + if record + .get("appliedSteerCursor") + .and_then(serde_json::Value::as_u64) + .is_none() + { + return Err("Agent DB tool-plan 幂等审计字段无效:appliedSteerCursor".to_string()); + } let is_null_or_sha256 = |field: &str| match record.get(field) { Some(serde_json::Value::Null) => true, Some(serde_json::Value::String(value)) => is_valid_agent_db_sha256(value), @@ -2398,6 +2407,10 @@ fn validate_agent_db_tool_plan_audit_records_unlocked( .and_then(serde_json::Value::as_str) .expect("validated tool-plan audit identity") }); + let applied_steer_cursor = expected + .get("appliedSteerCursor") + .and_then(serde_json::Value::as_u64) + .expect("validated tool-plan audit applied steer cursor"); file.seek(SeekFrom::Start(0)) .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; let mut reader = BufReader::new(file); @@ -2423,7 +2436,7 @@ fn validate_agent_db_tool_plan_audit_records_unlocked( } let record = serde_json::from_slice::(&line.content) .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; - let matches_key = [ + let matches_legacy_key = [ "recordType", "agentId", "taskId", @@ -2437,20 +2450,32 @@ fn validate_agent_db_tool_plan_audit_records_unlocked( .all(|(field, value)| { record.get(field).and_then(serde_json::Value::as_str) == Some(*value) }); - if !matches_key { + if !matches_legacy_key { continue; } - if !agent_db_stored_record_matches_expected_payload(&record, expected) { + let stored_applied_steer_cursor = match record.get("appliedSteerCursor") { + None => 0, + Some(value) => value.as_u64().ok_or_else(|| { + format!( + "Agent 本地索引 tool-plan 审计 appliedSteerCursor 无效:{}", + path.display() + ) + })?, + }; + if stored_applied_steer_cursor != applied_steer_cursor { + continue; + } + if !agent_db_tool_plan_stored_record_matches_expected_payload(&record, expected) { return Err(format!( - "Agent 本地索引 tool-plan 幂等审计内容冲突:{}/{}/{}/{}", - identity[0], identity[1], identity[4], identity[6] + "Agent 本地索引 tool-plan 幂等审计内容冲突:{}/{}/{}/{}/steer-{}", + identity[0], identity[1], identity[4], identity[6], applied_steer_cursor )); } exact_matches = exact_matches.saturating_add(1); if exact_matches > 1 { return Err(format!( - "Agent 本地索引 tool-plan 幂等审计重复:{}/{}/{}/{}", - identity[0], identity[1], identity[4], identity[6] + "Agent 本地索引 tool-plan 幂等审计重复:{}/{}/{}/{}/steer-{}", + identity[0], identity[1], identity[4], identity[6], applied_steer_cursor )); } } @@ -2464,6 +2489,32 @@ fn validate_agent_db_tool_plan_audit_records_unlocked( Ok(exact_matches == 1) } +fn agent_db_tool_plan_stored_record_matches_expected_payload( + stored: &serde_json::Value, + expected: &serde_json::Value, +) -> bool { + if stored.get("appliedSteerCursor").is_some() { + return agent_db_stored_record_matches_expected_payload(stored, expected); + } + if expected + .get("appliedSteerCursor") + .and_then(serde_json::Value::as_u64) + != Some(0) + { + return false; + } + + let mut normalized = stored.clone(); + let Some(object) = normalized.as_object_mut() else { + return false; + }; + object.insert( + "appliedSteerCursor".to_string(), + serde_json::Value::Number(serde_json::Number::from(0)), + ); + agent_db_stored_record_matches_expected_payload(&normalized, expected) +} + fn validate_agent_db_action_records_unlocked( file: &mut File, path: &Path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 59a92fdb9..46aa7822f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -48,6 +48,7 @@ fn tool_plan_protocol_audit_record( "loopIteration": 0, "repairAttempt": 0, "requestSlot": request_slot, + "appliedSteerCursor": 0, "responseFingerprint": "1".repeat(64), "providerRequestIdSha256": "2".repeat(64), "protocol": "native_runtime_tools", @@ -82,6 +83,7 @@ fn tool_plan_repair_audit_record( "loopIteration": 0, "repairAttempt": 0, "requestSlot": request_slot, + "appliedSteerCursor": 0, "responseFingerprint": "1".repeat(64), "providerRequestIdSha256": "2".repeat(64), "protocol": "native_runtime_tools", @@ -760,6 +762,16 @@ fn tool_plan_audit_append_is_atomic_conflict_checked_and_agent_scoped() { .expect_err("same tool-plan audit identity with new payload must conflict"); assert!(error.contains("内容冲突"), "{error}"); + let mut steered = record.clone(); + steered["appliedSteerCursor"] = serde_json::json!(1); + steered["responseFingerprint"] = serde_json::json!("3".repeat(64)); + assert!( + append_agent_db_tool_plan_audit_idempotent(&root, steered.clone()) + .expect("a new steer cursor must own a distinct tool-plan audit slot") + ); + assert!(!append_agent_db_tool_plan_audit_idempotent(&root, steered) + .expect("same steered tool-plan audit remains idempotent")); + let same_run_other_agent = tool_plan_protocol_audit_record("art-director", "shared-run-id", "loop-0-repair-0"); assert!( @@ -808,6 +820,26 @@ fn tool_plan_audit_append_is_atomic_conflict_checked_and_agent_scoped() { fs::remove_dir_all(&root).ok(); } +#[test] +fn legacy_tool_plan_audit_without_steer_cursor_matches_cursor_zero() { + let root = unique_agent_db_test_root("legacy-tool-plan-audit-cursor-zero"); + fs::create_dir_all(root.join(".agent")).expect("create legacy Agent DB directory"); + let current = tool_plan_repair_audit_record("design-director", "legacy-run", "loop-0-repair-0"); + let mut legacy = current.clone(); + legacy + .as_object_mut() + .expect("legacy audit object") + .remove("appliedSteerCursor"); + let line = serialize_agent_db_record(legacy).expect("serialize legacy tool-plan audit"); + fs::write(root.join(".agent/agent.db"), format!("{line}\n")) + .expect("write legacy tool-plan audit"); + + assert!(!append_agent_db_tool_plan_audit_idempotent(&root, current) + .expect("cursor zero must reuse the matching legacy audit")); + + fs::remove_dir_all(&root).ok(); +} + #[test] fn tool_plan_protocol_audit_is_idempotent_across_processes() { let record_type = "agent.runtime.tool_plan.protocol"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs index 132f9e1a5..24cbfb575 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/provider_retry.rs @@ -197,11 +197,17 @@ pub(crate) fn list_at(root: &Path) -> Result, _>>()? + .join("/"); let record = read_agent_runtime_json_sidecar_with_max_bytes( root, - relative_path_text, + &relative_path_text, PROVIDER_RETRY_LABEL, PROVIDER_RETRY_SIDECAR_MAX_BYTES, )? diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index 67c225b94..b9a041327 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -2039,6 +2039,9 @@ fn agent_native_delegate_contract_flows_through_parser_executor_and_delivery() { #[test] fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allows_none() { + let _config_guard = crate::tests::write_test_local_config( + r#"{"editorApi":{"apiKey":"visual-delegation-contract-key"}}"#.to_string(), + ); let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "视觉委派合同测试").expect("project init"); let parent_run_id = "visual-delegate-contract-parent-run"; @@ -2120,3 +2123,57 @@ fn visual_specialist_delegations_require_image_artifacts_but_read_only_work_allo fs::remove_dir_all(root).ok(); } + +#[test] +fn visual_specialist_delegation_degrades_to_text_artifacts_without_editor_api_key() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "无图片密钥委派合同测试") + .expect("project init"); + let parent_run_id = "text-only-design-delegate-parent-run"; + start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "委派文本玩法规格", + parent_run_id, + "agent-chat", + "准备委派", + vec!["允许无图片密钥降级".to_string()], + ) + .expect("start supervisor parent runtime"); + let target_agent_id = "design-foundation"; + let action_id = "allow-text-only-design-artifacts"; + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire design target lane") + .expect("design target lane available"); + let observation = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(action_id), + &serde_json::json!({ + "agentId": target_agent_id, + "task": "完成玩法规格与双视口界面说明", + "acceptanceCriteria": ["玩法规格可直接指导程序实现"], + "expectedArtifacts": ["memory/project.md", "game/game_design.md"], + "repairOfDelegationId": null, + "runId": null + }), + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + target_agent_id, + action_id, + ); + let delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read text-only design delivery") + .expect("text-only design delivery exists"); + assert_eq!( + delivery.expected_artifacts, + vec!["memory/project.md", "game/game_design.md"] + ); + drop(target_lock); + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs index f8365d780..148120d25 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs @@ -1421,7 +1421,7 @@ async fn supervisor_collaboration_empty_initial_plan_repairs_into_required_stati } #[tokio::test] -async fn supervisor_collaboration_read_only_first_window_repairs_with_collaboration_tools_only() { +async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboration_tools_only() { let root = unique_project_path(); init_local_game_project_at( &root, @@ -1430,15 +1430,6 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat ) .expect("project init"); let (sender, receiver) = mpsc::channel(); - let update_arguments = serde_json::json!({ - "explanation": "继续读取项目后再决定委派", - "steps": [ - {"step": "读取项目入口", "status": "in_progress"}, - {"step": "建立专业协作", "status": "pending"} - ] - }) - .to_string(); - let read_arguments = serde_json::json!({"reason": "继续读取项目索引", "input": {}}).to_string(); let delegate_function = native_runtime_function_name("agent.delegate").expect("delegate function"); let isolated_function = @@ -1469,32 +1460,16 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat .to_string(); let base_url = spawn_mock_llm_raw_responses_with_capture( vec![ - native_agent_tool_plan_chat_response_with_calls(vec![ - ( - "call-supervisor-read-only-plan", - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, - update_arguments, - ), - ( - "call-supervisor-read-only-index", - native_runtime_function_name("project.index") - .expect("index function") - .as_str(), - read_arguments, - ), - ]), - native_agent_tool_plan_chat_response_with_calls(vec![ - ( - "call-supervisor-read-only-code-delegate", - delegate_function.as_str(), - code_arguments, - ), - ( - "call-supervisor-read-only-quality-delegate", - delegate_function.as_str(), - quality_arguments, - ), - ]), + native_agent_tool_plan_chat_response( + "call-supervisor-initial-code-delegate", + delegate_function.as_str(), + code_arguments, + ), + native_agent_tool_plan_chat_response( + "call-supervisor-read-only-quality-delegate", + delegate_function.as_str(), + quality_arguments, + ), ], Some(sender), ); @@ -1563,8 +1538,7 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat let repair_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("supervisor read-only collaboration repair request"); - assert!(repair_request.contains("当前已到第 7 轮")); - assert!(repair_request.contains("不得继续只更新计划")); + assert!(repair_request.contains("missingStaticAgents=quality-review")); let repair_request_json = mock_http_request_json(&repair_request); let repair_function_names = repair_request_json["tools"] .as_array() @@ -1584,6 +1558,28 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat repair_function_names, BTreeSet::from([delegate_function.as_str(), isolated_function.as_str()]) ); + let delegate_schema = repair_request_json["tools"] + .as_array() + .and_then(|tools| { + tools.iter().find(|tool| { + tool.get("name").and_then(serde_json::Value::as_str) + == Some(delegate_function.as_str()) + || tool + .get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + == Some(delegate_function.as_str()) + }) + }) + .expect("missing-quality repair keeps agent.delegate"); + let parameters = delegate_schema + .get("parameters") + .or_else(|| delegate_schema.pointer("/function/parameters")) + .expect("delegate parameters"); + assert_eq!( + parameters["properties"]["input"]["properties"]["agentId"]["enum"], + serde_json::json!(["quality-review"]) + ); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); let records = read_agent_db_records_for_test(&root); @@ -1594,7 +1590,9 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat }) .collect::>(); assert_eq!(repairs.len(), 1); - assert_eq!(repairs[0]["protocolErrorKind"], "plan-semantics"); + assert!(repairs + .iter() + .all(|repair| repair["protocolErrorKind"] == "plan-semantics")); assert_eq!(repairs[0]["repairAttempt"], 0); let protocol = records .iter() @@ -1603,7 +1601,7 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat }) .expect("repaired collaboration protocol audit"); assert_eq!(protocol["repairAttempt"], 1); - assert_eq!(protocol["functionCallCount"], 2); + assert_eq!(protocol["functionCallCount"], 1); let collaboration_state = read_supervisor_collaboration_state_at( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 1fa809253..5eabb36ac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1385,6 +1385,54 @@ fn spawn_mock_llm_server_responses_with_capture( base_url } +fn spawn_mock_llm_scripted_responses_with_capture( + response_contents: Vec>, + request_sender: mpsc::Sender, +) -> String { + let listener = bind_test_tcp_listener("mock scripted llm bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + for response_content in response_contents { + let (mut stream, _) = listener.accept().expect("mock scripted llm accept"); + let request_text = read_mock_http_request(&mut stream); + let _ = request_sender.send(request_text.clone()); + let Some(response_content) = response_content else { + drop(stream); + continue; + }; + let body = if request_text.contains("POST /responses HTTP/1.1") { + serde_json::json!({ + "id": "resp_game_creator_scripted_mock", + "model": "mock-game-model", + "output_text": response_content, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + } else { + serde_json::json!({ + "id": "chatcmpl_game_creator_scripted_mock", + "model": "mock-game-model", + "choices": [{ + "message": { "content": response_content }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } + }) + } + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("mock scripted llm response"); + } + }); + base_url +} + fn spawn_interactive_mock_llm_server_with_capture( response_count: usize, request_sender: mpsc::Sender, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index e441dc806..038d5bfe3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -4411,6 +4411,103 @@ async fn provider_retry_waiting_steer_supersedes_old_attempt_and_wakes_same_run( fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn provider_repair_retry_waiting_steer_uses_distinct_audit_cursor() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Provider repair 等待 steer 测试") + .expect("project init"); + let (request_sender, request_receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_scripted_responses_with_capture( + vec![ + Some("first-invalid-tool-plan".to_string()), + None, + Some("steered-invalid-tool-plan".to_string()), + Some(final_tool_plan_response("steer 后 repair 已完成")), + ], + request_sender, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 1, + "retryBackoffMs": 30000 + }} + }} +}}"# + )); + let run_id = "design-provider-repair-wait-steer-run"; + let started = start_game_creator_agent_background_task_at( + &root, + "design-director", + "先写 repair 审计,再进入 Provider retry 等待", + run_id, + ) + .expect("start repair retry steer task"); + + request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("initial invalid tool-plan request"); + request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("repair transport failure request"); + wait_for_agent_runtime_phase(&root, "design-director", "waiting-for-provider-retry"); + + steer_game_creator_agent_runtime_task_at( + &root, + "design-director", + &started.state.session_id, + run_id, + "provider-repair-retry-steer-1", + "用新指令重新规划,不要复用旧 repair 审计槽", + "test", + ) + .expect("steer waiting repair Provider retry"); + + request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("fresh invalid tool-plan request after steer"); + request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("fresh repair request after steer"); + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.run_id, run_id); + assert_eq!(completed.applied_steer_cursor, 1); + assert_eq!( + completed.last_response.as_deref(), + Some("steer 后 repair 已完成") + ); + + let records = read_agent_db_records_for_test(&root); + let repair_audits = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" + && record["runId"] == run_id + && record["requestSlot"] == "loop-1-repair-0" + }) + .collect::>(); + assert_eq!(repair_audits.len(), 2); + assert_eq!( + repair_audits + .iter() + .filter_map(|record| record["appliedSteerCursor"].as_u64()) + .collect::>(), + BTreeSet::from([0, 1]) + ); + assert!(records.iter().all(|record| { + record["recordType"] != "agent.runtime.background_task.failed" || record["runId"] != run_id + })); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn provider_retry_waiting_exhaustion_fails_and_removes_sidecar() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 5af24a99a..fd53d1831 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -75,6 +75,7 @@ pub(super) use crate::{ read_recent_game_creator_agent_runtime_events, redact_secret_tokens, reject_game_creator_agent_runtime_task, reject_game_creator_agent_runtime_task_at, render_evaluator_findings, request_game_creator_agent_background_tool_plan_for_test, + resolve_game_creator_agent_runtime_retry_configuration_at, resume_game_creator_agent_background_tasks_at, resume_game_creator_agent_runtime_tasks, retry_game_creator_agent_runtime_task_at, schedule_game_creator_agent_ready_tasks_at, start_game_creator_agent_background_task_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs index c95e284f3..905bfc1fa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/task_lifecycle.rs @@ -272,6 +272,68 @@ async fn background_agent_runtime_can_cancel_active_task_and_retry_it() { fs::remove_dir_all(root).ok(); } +#[test] +fn autonomous_supervisor_retry_restores_trusted_source_from_run_profile_binding() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-autonomous-retry-source", "自主重试来源测试") + .expect("project init"); + let original_run_id = "supervisor-autonomous-retry-source"; + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + original_run_id, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous Supervisor run"); + let task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + session_id: "agent-session-project-supervisor".to_string(), + run_id: original_run_id.to_string(), + source: "agent-background-task".to_string(), + run_profile: binding.profile, + run_profile_binding_fingerprint: binding.binding_fingerprint, + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "构建并验证一个完整的自主游戏".to_string(), + status: "failed".to_string(), + phase: "failed".to_string(), + current_action: "测试失败".to_string(), + terminal_detail: Some("测试失败".to_string()), + error: Some("测试失败".to_string()), + updated_at: unix_timestamp(), + }; + let (profile, source) = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &task, false) + .expect("resolve autonomous Supervisor retry configuration"); + assert_eq!(profile, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD); + assert_eq!(source, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE); + + let standard_task = AgentRuntimeTaskRecord { + run_id: "standard-retry-source".to_string(), + run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(), + run_profile_binding_fingerprint: String::new(), + ..task + }; + let (_, standard_source) = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &standard_task, false) + .expect("resolve standard retry configuration"); + assert_eq!(standard_source, "agent-background-task"); + let (_, delegated_source) = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &standard_task, true) + .expect("resolve delegated retry configuration"); + assert_eq!(delegated_source, "agent-delegate-retry"); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_cancel_pending_task_before_drain() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs index fabd6acd3..18dd3373e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs @@ -46,6 +46,7 @@ pub(crate) fn failure_kind(error: &str) -> &'static str { } else if error.contains("写入") || error.contains("读取") || error.contains("创建") + || error.contains("安装") || error.contains("目录") || error.contains("文件") || error.contains("权限") diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs index 8b695b239..b7cef4e2d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs @@ -751,9 +751,10 @@ fn rename_windows_tool_plan_file_at( ) -> Result<(), String> { use std::os::windows::ffi::OsStrExt; use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - FileRenameInfo, SetFileInformationByHandle, FILE_RENAME_INFO, + use windows_sys::Wdk::Storage::FileSystem::{ + FileRenameInformation, NtSetInformationFile, FILE_RENAME_INFORMATION, }; + use windows_sys::Win32::System::IO::IO_STATUS_BLOCK; let wide_name = std::ffi::OsStr::new(new_name) .encode_wide() @@ -763,13 +764,13 @@ fn rename_windows_tool_plan_file_at( .checked_mul(2) .and_then(|value| u32::try_from(value).ok()) .ok_or_else(|| format!("{label} 目标名称过长"))?; - let header_bytes = std::mem::offset_of!(FILE_RENAME_INFO, FileName); + let header_bytes = std::mem::offset_of!(FILE_RENAME_INFORMATION, FileName); let total_bytes = header_bytes .checked_add(name_bytes as usize) .ok_or_else(|| format!("{label} 重命名缓冲区过大"))?; let word_bytes = std::mem::size_of::(); let mut buffer = vec![0usize; total_bytes.div_ceil(word_bytes)]; - let information = buffer.as_mut_ptr().cast::(); + let information = buffer.as_mut_ptr().cast::(); // SAFETY: buffer is aligned and sized for the fixed header plus the complete UTF-16 name. unsafe { (*information).Anonymous.ReplaceIfExists = replace; @@ -781,19 +782,29 @@ fn rename_windows_tool_plan_file_at( wide_name.len(), ); } - // SAFETY: file owns a DELETE-capable handle and information spans total_bytes bytes. - if unsafe { - SetFileInformationByHandle( + let mut io_status = IO_STATUS_BLOCK::default(); + // SAFETY: file owns a DELETE-capable handle, parent is a verified directory handle, + // and information spans the fixed header plus the complete relative UTF-16 name. + // NtSetInformationFile is required here because SetFileInformationByHandle rejects + // a non-null RootDirectory with ERROR_INVALID_PARAMETER on Windows. + let status = unsafe { + NtSetInformationFile( file.as_raw_handle().cast(), - FileRenameInfo, + &mut io_status, information.cast(), total_bytes as u32, + FileRenameInformation, ) - } == 0 - { + }; + if status < 0 { + unsafe extern "system" { + fn RtlNtStatusToDosError(status: i32) -> u32; + } + // SAFETY: conversion accepts any NTSTATUS and returns a Win32 error code. + let code = unsafe { RtlNtStatusToDosError(status) }; return Err(format!( "按句柄安装 {label} 失败:{}", - std::io::Error::last_os_error() + std::io::Error::from_raw_os_error(code as i32) )); } Ok(()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index dc88d02b2..5d2711eb9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -45,6 +45,14 @@ use crate::agent::{ }; use crate::provider_retry::{self, AgentRuntimeProviderRetryIdentity}; +#[test] +fn tool_plan_handoff_classifies_windows_handle_install_failure_as_storage() { + assert_eq!( + failure_kind("按句柄安装 tool-plan 成功响应交接账本 失败:参数错误。 (os error 87)"), + "tool-plan-storage" + ); +} + fn identity(slot: &str) -> AgentRuntimeProviderRetryIdentity { identity_for(slot, "project-supervisor", "run-tool-plan-handoff") } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index cb13791ee..ae45796e9 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4884,11 +4884,82 @@ export function App({ if (!invoke || !nextProjectPath || !currentRuntime || chatAgentBusy) { throw new Error('项目总控状态已变化,请等待刷新后重试'); } + const needsReconciliation = + currentRuntime.status === 'needs-reconciliation' || + currentRuntime.phase === 'needs-reconciliation'; + if (needsReconciliation) { + if ( + currentRuntime.runId !== runtime.runId || + currentRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID + ) { + throw new Error('项目总控待核对任务已变化,请等待刷新'); + } + const previousRunId = currentRuntime.runId; + setChatAgentBusy(true); + setProjectSupervisorRuntimeError(''); + try { + const result = await invoke( + 'cancel_game_creator_agent_runtime_task', + { + projectPath: nextProjectPath, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + runId: previousRunId, + }, + ); + if ( + localProjectPathRef.current !== nextProjectPath || + projectSupervisorRuntimeRef.current?.runId !== previousRunId + ) { + return '项目已切换,未把旧项目的取消状态合并到当前界面'; + } + const nextRuntime = agentRuntimeStateFromResult(result, currentRuntime); + if ( + nextRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || + nextRuntime.runId !== previousRunId || + nextRuntime.sessionId !== currentRuntime.sessionId + ) { + throw new Error('项目总控取消后的 Runtime 身份不匹配'); + } + updateProjectSupervisorRuntime(nextRuntime); + updateProjectSupervisorResponseStream( + result.responseStream, + nextRuntime, + ); + setCommandLog((current) => [ + ...current, + 'agent.runtime.cancel project-supervisor reconciliation', + ]); + const queuePending = nextRuntime.taskQueue?.pending ?? 0; + if ( + nextRuntime.status === 'cancelled' || + nextRuntime.phase === 'cancelled' + ) { + return queuePending > 0 + ? `旧任务已结束,队列中还有 ${queuePending} 个待处理任务,队列将继续处理` + : '旧任务已结束,当前队列为空,可重新启动项目总控'; + } + return '已提交结束旧任务请求,正在同步取消状态'; + } catch (error) { + throw new Error( + `项目总控旧任务结束失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } finally { + setChatAgentBusy(false); + } + } + const cancelledWithEmptyQueue = + (currentRuntime.status === 'cancelled' || + currentRuntime.phase === 'cancelled') && + (currentRuntime.taskQueue?.pending ?? 0) === 0; if ( currentRuntime.runId !== runtime.runId || currentRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || !( - currentRuntime.status === 'failed' || currentRuntime.phase === 'failed' + currentRuntime.status === 'failed' || + currentRuntime.phase === 'failed' || + cancelledWithEmptyQueue ) || currentRuntime.pendingToolAction ) { @@ -9025,6 +9096,32 @@ export function App({ for (const runtimeResult of runtimes) { nextRuntimes.push(agentRuntimeStateFromResult(runtimeResult)); } + const supervisorRuntimeIndex = nextRuntimes.findIndex( + (runtime) => runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID, + ); + const persistedSupervisorRuntime = + supervisorRuntimeIndex >= 0 + ? nextRuntimes[supervisorRuntimeIndex]! + : null; + if (projectSupervisorOnly && persistedSupervisorRuntime) { + const currentRuntime = projectSupervisorRuntimeRef.current; + if ( + !currentRuntime || + persistedSupervisorRuntime.updatedAt >= currentRuntime.updatedAt + ) { + if (persistedSupervisorRuntime.sessionId) { + projectSupervisorSessionIdRef.current = + persistedSupervisorRuntime.sessionId; + setProjectSupervisorSessionId(persistedSupervisorRuntime.sessionId); + } + updateProjectSupervisorRuntime(persistedSupervisorRuntime); + updateProjectSupervisorResponseStream( + runtimes[supervisorRuntimeIndex]?.responseStream, + persistedSupervisorRuntime, + ); + setProjectSupervisorRuntimeError(''); + } + } setAgentRuntimeById((current) => nextRuntimes.reduce( (next, runtime) => mergeAgentRuntimeStateIntoMap(next, runtime, true), diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index fb76f67b7..eccdd06ce 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -1268,6 +1268,9 @@ export function isAgentFinalizationMessageId( } export function projectRuntimeStatusPresentation(runtime: AgentRuntimeState) { + if (runtime.phase === 'needs-reconciliation') { + return { label: '待核对', tone: 'failed' }; + } if ( runtime.userInputRequest || runtime.status === 'waiting-for-user-input' || diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx index fb2095acd..492556ce3 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx @@ -699,7 +699,8 @@ export function ProjectSupervisorRuntimePanel({ setSupervisorRetryFeedback(''); } }, [runtime?.phase, runtime?.runId, runtime?.status]); - const status = projectSupervisorRuntimeStatusLabel(runtime, error); + const status = + projectSupervisorRuntimeStatusLabel(runtime, error) ?? '尚未开始'; const collaboratingRuntimes = projectSupervisorCollaboratingAgentRuntimes( runtime, runtimeByAgentId, @@ -727,9 +728,6 @@ export function ProjectSupervisorRuntimePanel({ panelRef.current.scrollTop = 0; } }, [visibleSnapshotKey]); - if (!status) { - return null; - } const rawStatusDetail = error || runtime?.error || ''; const statusDetail = rawStatusDetail ? projectRuntimeVisibleError(rawStatusDetail, '项目总控 Agent', true) @@ -752,10 +750,22 @@ export function ProjectSupervisorRuntimePanel({ : null; const userInputRequest = runtime?.userInputRequest ?? null; const needsUserInput = agentRuntimeNeedsUserInput(runtime); + const needsSupervisorReconciliation = Boolean( + runtime && + (runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation'), + ); + const cancelledSupervisorQueuePending = + runtime && (runtime.status === 'cancelled' || runtime.phase === 'cancelled') + ? (runtime.taskQueue?.pending ?? 0) + : 0; const canRetrySupervisor = Boolean( runtime && !pendingToolAction && - (runtime.status === 'failed' || runtime.phase === 'failed') && + (runtime.status === 'failed' || + runtime.phase === 'failed' || + ((runtime.status === 'cancelled' || runtime.phase === 'cancelled') && + cancelledSupervisorQueuePending === 0)) && agentRuntimeCanRetry(runtime.status), ); const activeCollaboratingAgents = collaboratingRuntimes.filter( @@ -788,6 +798,15 @@ export function ProjectSupervisorRuntimePanel({ ) : null} {statusDetail ? {statusDetail} : null} + {!runtime && !statusDetail ? ( +
+
+ ) : null} {runtime ? (
{projectRuntimeVisibleCurrentWork(runtime)} @@ -808,22 +827,35 @@ export function ProjectSupervisorRuntimePanel({ {compactProgress ? ( {compactProgress} ) : null} - {canRetrySupervisor && runtime ? ( + {(needsSupervisorReconciliation || canRetrySupervisor) && runtime ? (
- {activeCollaboratingAgents.length > 0 - ? `${activeCollaboratingAgents - .map((professionalRuntime) => - projectProfessionalAgentLabel( - professionalRuntime.agentId, - ), - ) - .join('、')}仍在运行。` - : '本轮项目总控已停止。'} - 在当前项目重新启动总控,不会新建项目。 + {needsSupervisorReconciliation ? ( + <> + 本轮工具动作的结果不确定,需要先结束旧任务。 + 不会直接重试,避免重复执行未核对的动作。 + + ) : ( + <> + {activeCollaboratingAgents.length > 0 + ? `${activeCollaboratingAgents + .map((professionalRuntime) => + projectProfessionalAgentLabel( + professionalRuntime.agentId, + ), + ) + .join('、')}仍在运行。` + : '本轮项目总控已停止。'} + 在当前项目重新启动总控,不会新建项目。 + + )}
) : null} + {cancelledSupervisorQueuePending > 0 && !supervisorRetryFeedback ? ( + + {`旧任务已结束,队列中还有 ${cancelledSupervisorQueuePending} 个待处理任务,队列将继续处理`} + + ) : null} {supervisorRetryFeedback ? ( {supervisorRetryFeedback} ) : null} - {pendingToolAction && pendingActionPresentation ? ( + {pendingToolAction && + pendingActionPresentation && + !needsSupervisorReconciliation ? (
small[aria-label='项目总控 Agent 进度'] { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 3f129ef63..3a3080251 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -702,6 +702,347 @@ export function registerUserSurfaceBoundaryTests() { } export function registerProjectSupervisorSurfaceTests() { + it('keeps the Project Supervisor welcome and empty runtime surface before the first message', async () => { + const projectPath = '/tmp/launcher-empty-supervisor-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-empty-supervisor-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-empty-supervisor-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: projectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + const supervisorSurface = await screen.findByLabelText('项目总控对话'); + const messageList = + within(supervisorSurface).getByLabelText('项目总控消息'); + expect( + await within(messageList).findByText('想做什么游戏?'), + ).not.toBeNull(); + expect( + within(supervisorSurface).getByLabelText('项目总控 Agent 状态'), + ).not.toBeNull(); + expect( + within(supervisorSurface).getByText('项目总控 Agent · 尚未开始'), + ).not.toBeNull(); + expect( + within(supervisorSurface).getByText('告诉陶泥儿你想做什么游戏'), + ).not.toBeNull(); + expect( + within(supervisorSurface).getByRole('button', { name: '发送' }), + ).toHaveProperty('disabled', false); + }); + + it('hydrates a persisted needs-reconciliation Supervisor runtime without an active Session index', async () => { + const projectPath = '/tmp/launcher-reconciliation-supervisor-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-reconciliation-supervisor-game', + ); + const reconciliationRuntime = { + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'project-supervisor', + taskId: 'project-supervisor', + sessionId: 'persisted-supervisor-session', + runId: 'persisted-reconciliation-run', + source: 'project-supervisor', + status: 'needs-reconciliation', + phase: 'needs-reconciliation', + currentTask: '帮我生成一个贪吃蛇', + currentGoal: '完成贪吃蛇原型', + currentAction: '等待核对 Provider 回复交接', + waitingOn: '人工核对', + nextStep: '核对后继续或取消', + plan: [], + observations: [], + allowedTools: [], + pendingToolAction: null, + lastResponse: null, + error: 'tool-plan-unknown', + updatedAt: 7000, + }; + const cancelledQueue = { + total: 2, + pending: 1, + running: 0, + waitingForConfirmation: 0, + waitingForUserInput: 0, + cancelled: 1, + completed: 0, + failed: 0, + latestRunId: 'persisted-reconciliation-run', + updatedAt: 8000, + }; + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + initialSessionExists: false, + initialRuntime: reconciliationRuntime, + runtimeMapLoader: async () => [reconciliationRuntime], + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-reconciliation-supervisor-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'cancel_game_creator_agent_runtime_task') { + const state = supervisorHarness.runtimeState({ + ...reconciliationRuntime, + status: 'cancelled', + phase: 'cancelled', + currentAction: '待核对的旧任务已结束', + waitingOn: '队列中的下一个任务', + error: null, + taskQueue: cancelledQueue, + updatedAt: 8000, + }); + return { + ...supervisorHarness.runtimeResult(state), + taskQueue: cancelledQueue, + }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: projectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + const supervisorSurface = await screen.findByLabelText('项目总控对话'); + expect( + await within(supervisorSurface).findByText('项目总控 Agent · 失败'), + ).not.toBeNull(); + expect( + within(supervisorSurface).getByText('当前阶段:待核对'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_game_creator_agent_runtimes', { + projectPath, + }); + const reconcileButton = within(supervisorSurface).getByRole('button', { + name: '已核对,结束旧任务', + }); + fireEvent.click(reconcileButton); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'cancel_game_creator_agent_runtime_task', + { + projectPath, + agentId: 'project-supervisor', + runId: 'persisted-reconciliation-run', + }, + ); + }); + expect(invoke).not.toHaveBeenCalledWith( + 'confirm_retry_game_creator_agent_runtime_task', + expect.anything(), + ); + expect( + await within(supervisorSurface).findByText( + '旧任务已结束,队列中还有 1 个待处理任务,队列将继续处理', + ), + ).not.toBeNull(); + expect( + within(supervisorSurface).queryByRole('button', { + name: '在当前项目重试总控', + }), + ).toBeNull(); + }); + + it('allows retrying a cancelled reconciled Supervisor only after its queue is empty', async () => { + const projectPath = '/tmp/launcher-reconciliation-empty-queue'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-reconciliation-empty-queue', + ); + const runId = 'reconciliation-empty-queue-run'; + const reconciliationRuntime = { + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'project-supervisor', + taskId: 'project-supervisor', + sessionId: 'reconciliation-empty-queue-session', + runId, + source: 'project-supervisor', + status: 'needs-reconciliation', + phase: 'needs-reconciliation', + currentTask: '生成贪吃蛇原型', + currentGoal: '完成可玩原型', + currentAction: '等待核对 Provider 回复交接', + waitingOn: '人工核对', + nextStep: '核对后结束旧任务', + plan: [], + observations: [], + allowedTools: [], + pendingToolAction: null, + lastResponse: null, + error: 'tool-plan-unknown', + updatedAt: 7000, + }; + const emptyQueue = { + total: 1, + pending: 0, + running: 0, + waitingForConfirmation: 0, + waitingForUserInput: 0, + cancelled: 1, + completed: 0, + failed: 0, + latestRunId: runId, + updatedAt: 8000, + }; + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + sessionId: 'reconciliation-empty-queue-session', + initialSessionExists: false, + initialRuntime: reconciliationRuntime, + runtimeMapLoader: async () => [reconciliationRuntime], + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-reconciliation-empty-queue', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'cancel_game_creator_agent_runtime_task') { + const state = supervisorHarness.runtimeState({ + ...reconciliationRuntime, + status: 'cancelled', + phase: 'cancelled', + currentAction: '待核对的旧任务已结束', + waitingOn: '', + error: null, + taskQueue: emptyQueue, + updatedAt: 8000, + }); + return { + ...supervisorHarness.runtimeResult(state), + taskQueue: emptyQueue, + }; + } + if (command === 'confirm_retry_game_creator_agent_runtime_task') { + const nextRunId = String(args?.nextRunId ?? ''); + const state = supervisorHarness.runtimeState({ + ...reconciliationRuntime, + runId: nextRunId, + status: 'running', + phase: 'planning', + currentAction: '重新生成项目总控计划', + waitingOn: 'Agent 输出计划或回复', + error: null, + updatedAt: 9000, + }); + return { + ...supervisorHarness.runtimeResult(state), + acceptedRunId: nextRunId, + }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: projectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + const supervisorSurface = await screen.findByLabelText('项目总控对话'); + fireEvent.click( + await within(supervisorSurface).findByRole('button', { + name: '已核对,结束旧任务', + }), + ); + expect( + await within(supervisorSurface).findByText( + '旧任务已结束,当前队列为空,可重新启动项目总控', + ), + ).not.toBeNull(); + const retryButton = await within(supervisorSurface).findByRole('button', { + name: '在当前项目重试总控', + }); + fireEvent.click(retryButton); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'confirm_retry_game_creator_agent_runtime_task', + { + projectPath, + agentId: 'project-supervisor', + runId, + nextRunId: expect.stringMatching(/^project-supervisor-retry-/), + }, + ); + }); + const cancelCallIndex = invoke.mock.calls.findIndex( + ([command]) => command === 'cancel_game_creator_agent_runtime_task', + ); + const retryCallIndex = invoke.mock.calls.findIndex( + ([command]) => + command === 'confirm_retry_game_creator_agent_runtime_task', + ); + expect(cancelCallIndex).toBeGreaterThanOrEqual(0); + expect(retryCallIndex).toBeGreaterThan(cancelCallIndex); + }); + it('loads and continues the active Project Supervisor Session in the standalone chat surface', async () => { const projectPath = '/tmp/supervisor-chat-only-game'; const historyMessage = '已持久化的项目总控历史'; diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d80dbac5f..eb506176a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3709,12 +3709,27 @@ - 关联:`apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs`、`apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts`。 - 真实验收状态:外部 Provider 与画布 API 均可调用不等于全链路验收通过。2026-07-27 新起的独立轮次使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**:同一轮完成固定 `16` 个 manifest task exactly-once、七份基础产物、两张真实画布 PNG、当前 revision 静态检查、desktop / mobile `lane-defense-v1` playtest、唯一终态回复和安全清理;`turn.report` 的 busy / pending / running / confirmation / user-input / reconciliation 均为 `0`。此前失败轮、部分产物、单项接口成功和确定性结果仍不得与本轮拼接。 +## 项目总控空态和持久 Runtime 不能依赖同一份 Session 索引 + +- 现象:新项目尚未发消息时右侧总控区域只剩整块空白;已有 `needs-reconciliation` Runtime 的项目重新打开后,也可能看不到失败状态卡。 +- 原因:空 Runtime 直接返回 `null`,没有稳定空态;总控首轮水合又只在 active Session 索引存在时读取单 Agent Runtime。若 Provider 成功响应交接失败并留下 Runtime 文件、但 Session 索引未完成持久化,专业 Agent 列表能读到总控状态,专用总控面板却仍保持 `runtime=null`。 +- 处理:总控面板在无 Runtime 时显示“尚未开始”入口;项目级 Runtime 列表中的 `project-supervisor` 作为缺失 Session 索引时的恢复来源,并同步其 Session、响应流和 Runtime 状态。`needs-reconciliation` 显示为“待核对”,不伪装成执行中。 +- Windows 根因补充:`tool-plan` 成功响应在相对目录句柄下原子安装账本时,不能把非空 `FILE_RENAME_INFO.RootDirectory` 传给 `SetFileInformationByHandle(FileRenameInfo)`;该组合会稳定返回 `ERROR_INVALID_PARAMETER (87)`,导致每轮首个 Provider 响应都进入 `needs-reconciliation`。应使用支持相对根目录句柄的 `NtSetInformationFile(FileRenameInformation)`,继续保留目录句柄锚定,不能退化成可受路径换绑影响的绝对路径 rename。“按句柄安装”失败应归类为 `tool-plan-storage`,不能落入 `tool-plan-unknown`。 +- Responses 协议补充:格式修复会把上一次 Provider 输出作为 `assistant` 消息追加到新请求。OpenAI Responses API 中 system / user 文本使用 `input_text`,assistant 文本必须使用 `output_text`;不区分 role 会收到 `Invalid value: 'input_text'` 的 HTTP 400。assistant 图片不能继续序列化为 `input_image`,应在本地请求校验中失败关闭。 +- Steer 后审计补充:等待持久 Provider retry 时用户 steer 会让同一 run、同一 loop 重新使用 `loop-N-repair-0`;tool-plan protocol / repair 审计的幂等身份必须包含 `appliedSteerCursor`,否则新 cursor 的合法响应会与旧响应误报“内容冲突”。旧审计没有该字段时只按 cursor `0` 兼容;不能删库、忽略冲突或改用 response fingerprint 作逻辑槽唯一键。 +- 单调用 Provider 协作修复补充:若 Provider 每轮只返回一个 function call,Project Supervisor 的首批协作 repair 必须从触发首次协作缺口的响应开始,跨文本 JSON、OpenAI Chat tool call 与 OpenAI Responses function call 等格式修复轮次累积合法的 `agent.delegate / agent.spawn_isolated`。同一 `agentId` 后出现的 action 覆盖较早 action;`agent.spawn_isolated` 是单批唯一槽位,修正版必须覆盖旧 action,不能因输入变化追加第二个 spawn。每轮再按累计结果计算缺失的静态 Agent,并把下一轮 `agentId` enum 收窄到明确缺失集合;`missingStaticAgents=none` 表示没有指定 ID 缺口,不得生成 `enum=["none"]`,避免已满足的委派被重复生成或首批协作永远无法成批提交。 +- Provider action 安全持久化补充:pending / provider action 的泄密检测不能因裸自然语言短语 `api key` 直接拒绝,否则 `agent.delegate` 中“不要暴露 External Editor API Key”等安全约束会被误报并阻断首批协作。赋值形式只允许完整匹配受控的“未配置 / 不可用 / 禁止读取”等状态或固定无密钥降级说明,不能用 `starts_with` 放行 `none-but-secret`、`not configured; actual value ...` 等安全前缀后的凭据;`**API Key**:`、`` `API Key`: ``、`API Key(生产):` 等装饰或限定标签也必须识别为赋值。结构化字段标记 `apiKey / api_key`、`Authorization / Cookie`、`token / Bearer` 以及已知 secret token 形状仍必须检测并失败关闭。 +- Windows retry 扫描补充:`Path::strip_prefix(root)` 在 Windows 上得到的相对 `Path` 转字符串后使用反斜杠,不能直接传给只接受 portable `/` 的 Runtime JSON sidecar 读取器;否则 Runner 重启或显式 `--agent-resume` 扫描已到期 retry 时会报“项目文件路径不能包含反斜杠”,任务持续停在 `waiting-for-provider-retry`。目录扫描应按路径组件重组成 `/` 分隔的 UTF-8 相对路径,不要放宽全局路径校验。 +- 恢复交互:`needs-reconciliation` 即使没有 `pendingToolAction`,也必须提供显式“已核对,结束旧任务”;它只取消旧 run,不直接 retry。若取消后仍有 pending task,由 Runner 自动继续;只有队列为空且旧 run 已取消时,才允许创建新的 retry run,避免重复执行同一用户输入。自主构建 Supervisor 的 retry 不能改写为普通 `agent-background-task` source,必须从已验证的原 Run Profile 绑定恢复 `project-supervisor-gui / project-supervisor-cli` 可信来源;不得只信可追加的 task journal。 +- 验证:前端回归同时覆盖零历史、无 Session 的初始空态、无 active Session 索引但存在持久 `needs-reconciliation` 总控 Runtime 的恢复展示,以及“先取消、队列为空后才重试”;真实 Windows 运行全部 tool-plan handoff 测试,确保相对句柄 rename、覆盖安装、回读和清理均通过。Responses 回归覆盖 system / user / assistant 文本分别序列化,并保留 user `input_text + input_image`;Runtime 回归覆盖“无效计划 → repair transport 等待 → steer → 新 cursor 再修复”,断言 cursor `0 / 1` 各有一条审计且不冲突。 + ## 固定画布产物返工不能变成任意覆盖,design-foundation 不能越权修程序 - 现象:视觉 Agent 发现候选图不合格后,可能先删除 `assets/ui-prototype.png` 或 `assets/art-spritesheet.png`,再用猜测的尺寸、比例或另一条路径重新生成;远端生成期间项目文件又可能被其它 Agent 更新,迟到结果覆盖较新的文件。`design-foundation` 为了让静态或浏览器检查通过,也可能顺手改写 `game/index.html` 或自行启动 preview。 - 原因:把“允许一次语义返工”误解成“视觉 Agent 可以任意覆盖”,且只在 prompt 中描述角色职责,没有在 replacement 授权、文件写入、工具策略和提交时 fingerprint 上强制执行。 - 处理:固定 UI 与 spritesheet 路径、比例、尺寸、kind 和 label;普通生成 `replaceExisting=false`。只有 Project Supervisor 对已认领原 delivery 建立的唯一静态 repair,且父 run、目标 Agent 与 `expectedArtifacts` 全部匹配时,才允许 `replaceExisting=true` 原位替换;不得先删除固定正式产物,也不得对 repair 再 repair。请求外部生成前记录原路径 SHA-256,取得写锁准备提交时复算;不一致即按 stale fingerprint 失败关闭并保留当前文件。 - 职责隔离:`design-foundation` 只写 `memory/project.md`、`game/game_design.md` 和可选固定 UI 原型。Runtime 必须同时在单文件写入、patchset、delete 与工具 policy 层拒绝其修改 `game/index.html`、其它实现文件、启动 preview / playtest、运行进程、调用 `game.static_smoke` 或整项目恢复;只有 `preview-readiness` 可执行固定 smoke,只有 `preview-playtest` 可执行浏览器验收。 +- 画布配置一致性:未配置 External Editor API Key 时,`design-foundation / art-asset-plan` 的委派合同与 manifest 终态投影必须一起降级为文本产物,不能仍把 `assets/ui-prototype.png / assets/art-spritesheet.png` 作为完成条件;配置 Key 时两张固定图片继续是严格必需产物。委派、完成合同和 manifest 投影必须读取同一配置事实,禁止一层降级、另一层仍要求图片。 - 验证与状态:当前回归已覆盖固定合同拒绝漂移、已登记 spritesheet 删除保护、静态 repair 授权、并发修改触发 stale fingerprint、`design-foundation` 的 write / patchset / delete 和 preview 工具拒绝。2026-07-27 的独立 75 分钟上限外部 E2E 已在同一轮完成两张真实画布图片、固定 `16` 任务、当前 revision 静态与双视口试玩并安全清理,当前状态为 **PASS**;后续改动仍须新轮复验。 ## 完成合同不能只绑定一个入口摘要,公开资源审计不能保存完整 prompt diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 244a40416..0423fdf40 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -726,10 +726,17 @@ game-project/ - 2026-07-25 已落地:`autonomous-game-build` 的完成合同从“交付可玩原型”升级为“交付正式项目产物”。Runtime 按 seed manifest 的 16 个 task 依赖分波推进:先设计打底,再并行完成数值、美术和音频需求,然后程序整合,之后执行质量审查、当前 revision 静态检查与真实试玩,最后生成发布包装。每个新根 run 先重置本轮 seed task;普通 preview / smoke bookkeeping 不得代替自主 task 的真实执行和终态投影。 - DAG 只能在 Project Supervisor 的 `agent.run_status` 回执认领已可靠观察、静态委派屏障清空后启动;正常执行、pending 续跑和重启恢复复用同一入口。调度等待项目写锁,不能因短暂锁竞争进入 reconciliation。专业子 run 的验证允许 `verifiedRevision >= mutationRevision`,但 Supervisor 的最终静态和试玩证据仍必须精确覆盖最新全局 revision。 - 正式项目基础产物固定为 `memory/project.md`、`game/game_design.md`、`game/balance.json`、`assets/manifest.art.json`、`assets/manifest.audio.json`、`game/index.html` 和 `exports/README.md`。未配置画布 API Key 时,设计与美术 Agent 交付明确记录界面结构、素材需求和“尚未生成”状态的文本 / JSON,不暴露 `canvas.asset_generate`,也不得伪造图片;配置 Key 时额外强制生成、登记并验收 `assets/ui-prototype.png` 与 `assets/art-spritesheet.png`,生成失败不得完成。`assets/manifest.audio.json` 当前只表示 BGM 与关键音效需求,不得宣称已生成真实音频文件。 +- 2026-07-28 画布配置最终一致性补充:未配置 External Editor API Key 时,`design-foundation / art-asset-plan` 的委派 `expectedArtifacts` 与对应 manifest task 终态投影必须同步采用上述文本产物降级,不得继续强制 `assets/ui-prototype.png / assets/art-spritesheet.png`;配置 Key 时两张固定图片仍为严格完成条件。委派合同、完成合同与 manifest 投影必须以同一配置事实派生。 - Project Supervisor 只有在本轮必需 manifest tasks 全部 `completed`、当前配置对应的正式路径齐全且通过类型 / 可解析性检查、最新 project revision 的 `game.static_smoke` 与 `preview.validate` 都通过后,才能写入唯一最终回复。delivery 的 `completed / evidence-ready`、历史 revision 成功或单个文件存在都不能替代最终集成验收。已 `ready / claimed-by-parent` 的相同终态 delivery 在恢复扫描中按幂等重放,保留首次冻结结果,不再制造重复 `agent.delegate.result_failed`;真实终态冲突仍失败关闭。 - 验证:`npm run agc:test` 已通过确定性 loopback Provider、真实 Runtime、项目写入和浏览器链路验收:同一父 Run 下 16 个 manifest task 均只有一个 logical run、一次 start、一次 completed 和一次 manifest projection,且无 failed / cancelled;父 run 与全部子 run 完成,最终 revision 为 `11`,基础正式产物、静态 smoke、桌面 / 移动 `37/37` 试玩通过,pending、reconciliation、Provider 失败、重复和泄漏计数均为 `0`。该结果不替代独立外部 Provider 验收。 - 2026-07-26 本轮已验证 `npm run agc:config` 的终端配置链路。向导与 GUI 使用同一 Tauri identifier 对应的系统 AppData 和同名 `game-creator.config.json` / 可选 local overlay;读取已有配置时只更新有效 LLM 层,保留 `agentLlm`、`editorApi`、`mcpServers` 等其它配置。API Key 只从隐藏输入读取,拒绝 `--api-key`、仓库内目录、Git 已跟踪配置、符号链接,以及不是以 `world.genarrative.ai-game-creator` 为独立叶目录的 `--config-dir`,防止把任意父目录整体改成私有权限。保存使用同目录 `0600` 临时文件原子替换,POSIX AppData 目录保持 `0700`,Windows 使用当前用户独占 DACL,写后复用真实 `--llm-status` 检查;隐藏输入收到 `SIGINT / SIGTERM / SIGHUP` 时先恢复 raw mode 和 pause 状态再重发原信号,向导启动的 Cargo / npm 使用独立进程组并在信号路径有界收束整棵子进程树。 - 2026-07-27 Windows DACL 启动回归修正:`powershell.exe -Command` 后追加的位置参数会被 PowerShell 5.1 拼接进命令文本,不能用 `$args` 安全接收包含空格的 AppData / 临时目录。DACL 脚本改为从仅传给该子进程的环境变量读取目标绝对路径和目录标记;`npm run agc:typecheck` 必须在真实 Windows 上执行配置回归,保证 `npm run agc` 的 `beforeDevCommand` 不因路径解析失败退出。 +- 2026-07-27 项目总控右栏空态与持久状态水合修正:项目尚未产生 Runtime 时仍显示“尚未开始”状态块和创作入口,不把消息列表的弹性剩余空间裸露为空白;若 active Session 索引缺失但项目内已有 `project-supervisor` Runtime,工作台必须从 `read_game_creator_agent_runtimes` 的权威项目列表恢复总控 Session 与状态。`needs-reconciliation` 统一显示为“失败 / 待核对”,不能因对话索引缺失隐藏已落盘的失败事实。 +- 2026-07-28 Windows `tool-plan` 成功响应交接修正:相对目录句柄下安装 handoff 账本改用 `NtSetInformationFile(FileRenameInformation)`;`SetFileInformationByHandle(FileRenameInfo)` 不接受当前实现所需的非空 `RootDirectory`,会稳定返回 `ERROR_INVALID_PARAMETER (87)` 并让总控首轮进入 `needs-reconciliation`。实现继续绑定已验证的父目录句柄和相对 hash 文件名,不退化为绝对路径 rename;“按句柄安装”归入 `tool-plan-storage`。总控对 reconciliation 提供“已核对,结束旧任务”,取消后有 pending task 时只等待 Runner 续跑,队列为空时才允许显式 retry;自主构建 Supervisor 的 retry source 从原 Run Profile 绑定恢复并重新验证为可信 GUI / CLI 根入口,不降级成普通后台任务来源。 +- 2026-07-28 `tool-plan` 格式修复协议与 steer 审计修正:OpenAI Responses 请求按角色映射内容块,system / user 文本为 `input_text`,assistant 计划预览为 `output_text`,assistant `input_image` 在本地校验阶段拒绝,避免 repair 请求因非法 `input_text` 被上游以 HTTP 400 拒绝。tool-plan protocol / repair 审计增加 `appliedSteerCursor`,幂等键也纳入该 cursor;等待 Provider retry 期间 steer 后,同一 run / loop 可在新 cursor 下合法重用 `repair-0` 逻辑槽。旧记录缺少 cursor 时仅视为 `0`,保持升级后 replay 幂等。回归必须覆盖 Responses 多角色序列化、user 多模态,以及“无效计划 → repair retry 等待 → steer → 新 cursor repair”链路。 +- 2026-07-28 Project Supervisor 首批协作 repair 累积约定:针对每轮只返回单个 function call 的 Provider,Runtime 从首次触发协作缺口的响应开始,跨文本 JSON、OpenAI Chat tool call 与 OpenAI Responses function call 修复轮次累积合法的 `agent.delegate / agent.spawn_isolated`;同一 `agentId` 以最新响应覆盖旧 action,唯一 `agent.spawn_isolated` 槽位也以最新响应覆盖,禁止把修正版追加成同批第二个 spawn。每轮根据累计结果计算尚缺的静态 Agent,并仅在缺失集合含明确 Agent ID 时收窄下一轮 function schema 的 `agentId` enum;`missingStaticAgents=none` 是空集合哨兵,不是 Agent ID。只有累计首批满足完整协作合同时才成批提交,已满足的 Agent 不得因后续修复重复派发。 +- 2026-07-28 pending / provider action 安全持久化约定:自然语言任务中的裸短语 `api key` 不是泄密证据,不能据此拒绝 action;否则 `agent.delegate` 的“不要暴露 External Editor API Key”等安全指令会被误判。API Key 赋值只允许完整受控状态或固定无密钥降级说明,禁止用安全状态前缀放行后续任意内容;`none-but-secret`、`not configured; actual value ...` 等必须失败关闭。Markdown 装饰、反引号或环境限定标签不能改变赋值语义,`**API Key**:`、`` `API Key`: ``、`API Key(生产):` 仍必须进入同一检测。持久化前继续检测结构化 `apiKey / api_key`、`Authorization / Cookie`、`token / Bearer` 标记和已知 secret token 形状,命中真实凭据时仍失败关闭。 +- 2026-07-28 Windows Provider retry 恢复修正:`provider_retry::list_at` 从绝对路径剥离项目 root 后,按路径组件重组成 `/` 分隔的 portable UTF-8 相对路径,再交给 Runtime JSON sidecar 读取器。不能直接使用 Windows `Path::to_str()` 的反斜杠文本,否则应用重启、Runner recovery scan 和正式 `--agent-resume` 都无法推进已到期的 `waiting-for-provider-retry` run。全部 provider retry 列举、previous 恢复、去重和路径冲突回归必须在真实 Windows 通过。 - `npm run agc:test:chat` 未显式指定配置且找不到 AppData 配置时,只在 stdin / stdout 都是 TTY 时询问并启动同一 `agc:config --configure-only` 向导,非 TTY 或显式无效 `--config-dir` 直接失败。测试环境只把主配置和存在时的 local overlay 复制到带随机 sentinel 的单次隔离 AppData;副本必须是独立的无符号链接普通文件,POSIX 权限为目录 `0700` / 文件 `0600`,不复制正式 Runner endpoint、lock 或其它 AppData。自动任务默认 50 分钟且可用 `--timeout-minutes` 显式设置;超时或信号会终止独立子进程树,POSIX 先向进程组发送 `SIGTERM`、等待 10 秒后发送 `SIGKILL` 并再等待 5 秒,Windows 使用 `taskkill /T` 并在强制阶段追加 `/F`。超时和信号分别以 `124 / 130 / 143` 失败退出,隔离 Runner 收束另有 20 秒上限;Runner 未空闲或收束失败时保留隔离配置和项目,验收未完成但 Runner 已安全退出时只保留一次性项目证据,不把中断报告为成功,也不误删正式 AppData。 - 自动验收现在严格要求 manifest 恰好包含固定 16 个不重复 task ID 且全部为 `completed`,并逐任务核对当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物存在并满足文件 / JSON / 非占位入口检查,配置画布 API Key 时再增加两张图片。PNG 验收不止检查 magic / IHDR / 比例,还会校验 chunk CRC、zlib 解压、scanline 长度、索引色 PLTE 和未知 critical chunk。Runtime 根 Supervisor 的完成合同已升级为 `game-creator-autonomous-completion-contract.v2`,`baselineArtifacts` 必填并纳入指纹,旧 v1 或缺基线合同失败关闭;最终门禁要求最后一次验证工具是 `game.static_smoke`、状态通过且 `verifiedRevision == currentRevision`。`preview.validate` 回执必须绑定同一 Agent、run、current revision、当前 `game/index.html` 摘要、固定试玩场景、持久浏览器报告以及 desktop / mobile 两张截图的路径、摘要和 PNG 身份,任一证据缺失、变化、过期或来自其它 run / revision 都阻止最终回复。确定性 `npm run agc:test` 已证明 revision `0 -> 11`、双视口试玩 `37/37` 和终局零残留;该证据仍不替代外部 Provider 单轮验收。 - `design-foundation` 已增加专属职责边界:项目文件只允许写 `memory/project.md` 与 `game/game_design.md`;配置 External Editor API Key 且合同要求界面原型时,只额外允许固定 `assets/ui-prototype.png`。它不得创建、修改、删除或补丁 `game/index.html`,不得改动其它程序实现、发布、音频或美术素材,也不得调用 `preview.start`、`preview.validate`、`game.static_smoke`,或借 `command.exec / command.start / command.run_limited` 启动预览服务、浏览器、Playwright 和桌面 / 移动试玩。程序和质量 Agent 的共享 Runtime 工具合同不因此缩减;有 / 无画布配置和其它 Agent 不受影响的聚焦回归为 `3/3` 通过。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 4f9a03b8b..216fe26b2 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -360,6 +360,7 @@ struct ResponsesInputMessage { #[serde(tag = "type", rename_all = "snake_case")] enum ResponsesInputContentPart { InputText { text: String }, + OutputText { text: String }, InputImage { image_url: String }, } @@ -1240,6 +1241,18 @@ impl LlmRunRequest { "LLM message content part 不能为空".to_string(), )); } + + if self.api_kind == LlmApiKind::OpenAiResponses + && message.role == LlmMessageRole::Assistant + && message + .content_parts + .iter() + .any(|part| matches!(part, LlmMessageContentPart::InputImage { .. })) + { + return Err(LlmError::InvalidRequest( + "OpenAI Responses assistant 消息不支持 input_image".to_string(), + )); + } } if let Some(model) = &self.model @@ -2375,9 +2388,10 @@ fn message_text_for_anthropic(message: &LlmMessage) -> Option { fn map_responses_content_parts(message: &LlmMessage) -> Vec { if message.content_parts.is_empty() { - return vec![ResponsesInputContentPart::InputText { - text: message.content.clone(), - }]; + return vec![map_responses_text_content_part( + message.role, + message.content.clone(), + )]; } message @@ -2385,7 +2399,7 @@ fn map_responses_content_parts(message: &LlmMessage) -> Vec { - ResponsesInputContentPart::InputText { text: text.clone() } + map_responses_text_content_part(message.role, text.clone()) } LlmMessageContentPart::InputImage { image_url } => { ResponsesInputContentPart::InputImage { @@ -2396,6 +2410,18 @@ fn map_responses_content_parts(message: &LlmMessage) -> Vec ResponsesInputContentPart { + match role { + LlmMessageRole::System | LlmMessageRole::User => { + ResponsesInputContentPart::InputText { text } + } + LlmMessageRole::Assistant => ResponsesInputContentPart::OutputText { text }, + } +} + fn log_llm_raw_failure( config: &LlmConfig, request: &LlmRunRequest, @@ -3567,6 +3593,26 @@ mod tests { assert_eq!(error, LlmError::EmptyResponse); } + #[test] + fn responses_request_rejects_assistant_input_image() { + let error = LlmRunRequest::new(vec![LlmMessage::multimodal( + LlmMessageRole::Assistant, + vec![LlmMessageContentPart::InputImage { + image_url: "https://example.com/assistant.png".to_string(), + }], + )]) + .with_openai_responses() + .validate() + .expect_err("Responses assistant image should fail locally"); + + assert_eq!( + error, + LlmError::InvalidRequest( + "OpenAI Responses assistant 消息不支持 input_image".to_string() + ) + ); + } + #[tokio::test] async fn run_sends_official_fallback_for_openai_compatible_clients() { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); @@ -4283,6 +4329,71 @@ mod tests { ); } + #[tokio::test] + async fn responses_request_maps_assistant_text_to_output_text() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + let server_handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("request should connect"); + let request_text = read_request(&mut stream); + write_response( + &mut stream, + MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"resp_repair","model":"gpt-5","output_text":"修复成功","status":"completed"}"# + .to_string(), + extra_headers: Vec::new(), + }, + ); + request_text + }); + + let client = build_test_client(format!("http://{address}"), 0); + client + .run( + LlmRunRequest::new(vec![ + LlmMessage::system("系统约束"), + LlmMessage::user("原始请求"), + LlmMessage::assistant("需要修复的计划预览"), + LlmMessage::user("请修复格式"), + ]) + .with_openai_responses(), + ) + .await + .expect("Responses repair request should succeed"); + + let request_text = server_handle.join().expect("server thread should join"); + let request_body = request_text + .split("\r\n\r\n") + .nth(1) + .expect("request body should exist"); + let request_json: serde_json::Value = + serde_json::from_str(request_body).expect("request body should be json"); + + assert_eq!( + request_json["input"], + serde_json::json!([ + { + "role": "system", + "content": [{ "type": "input_text", "text": "系统约束" }] + }, + { + "role": "user", + "content": [{ "type": "input_text", "text": "原始请求" }] + }, + { + "role": "assistant", + "content": [{ "type": "output_text", "text": "需要修复的计划预览" }] + }, + { + "role": "user", + "content": [{ "type": "input_text", "text": "请修复格式" }] + } + ]) + ); + } + #[tokio::test] async fn run_accepts_responses_function_call_without_output_text() { let server_url = spawn_mock_server(vec![MockResponse {