diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 41f155eb7..6e225ac31 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -11,7 +11,7 @@ "autoCompactTokenLimit": 64000, "toolOutputTokenLimit": 12000, "requestTimeoutMs": 180000, - "maxRetries": 0, + "maxRetries": 2, "retryBackoffMs": 500 }, "agentLlm": {}, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs index c15f1bee5..2139ddfe1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs @@ -367,10 +367,26 @@ pub(crate) fn has_game_creator_agent_llm_override( config: &GameCreatorAppConfig, agent_id: &str, ) -> bool { - config - .agent_llm - .get(agent_id) - .is_some_and(|patch| !is_empty_game_creator_llm_patch(patch)) + config.agent_llm.get(agent_id).is_some_and(|patch| { + if is_empty_game_creator_llm_patch(patch) { + return false; + } + let only_canonical_reasoning_default = patch.api_key.is_none() + && patch.base_url.is_none() + && patch.model.is_none() + && patch.api_kind.is_none() + && patch.stream.is_none() + && patch.web_search_enabled.is_none() + && patch.context_window_tokens.is_none() + && patch.auto_compact_token_limit.is_none() + && patch.tool_output_token_limit.is_none() + && patch.request_timeout_ms.is_none() + && patch.max_retries.is_none() + && patch.retry_backoff_ms.is_none() + && patch.reasoning_effort.as_deref() + == game_creator_llm_agent_default_reasoning_effort(agent_id); + !only_canonical_reasoning_default + }) } pub(crate) async fn request_agent_role_brief_with_config( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index bc244bcba..0084481cb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -126,6 +126,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( system_prompt.push_str( "\n\n当前 Run Profile 为 autonomous-game-build。不得调用 user.input_request,也不得为了等待确认而中断;对不改变核心目标的缺失细节,直接采用可逆、保守且可试玩的默认值。只使用当前 autoTools 推进项目内实现、委派和验证,不得请求 project.git_commit、command.exec、command.start、command.stdin、command.terminate 或其他仍需确认的动作。Project Supervisor 必须持续编排到最小可玩闭环通过 Runtime 完成门禁;专业 Agent 必须完成自己的合同并把结果交回父 Run。", ); + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + system_prompt.push_str( + "\n\nautonomous-game-build 的正式 manifest 任务图是唯一首轮专业执行链。不得在 manifest 之前另行创建 code-prototype、quality-review、art-director、design-foundation 或 art-asset-plan 的首批 agent.delegate;这些角色会由 Runtime 按 manifest 依赖顺序调度。没有待认领的显式返工合同时也不得额外委派。请直接推进/观察 manifest,Runtime 会在你尝试收束时调度 ready task,并在任务图完成前阻止最终交付。", + ); + } system_prompt.push_str(&format!( "\n\n自主构建专业 Agent 在首次项目修改前最多允许 {AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT} 轮 planning 探索。达到上限后,本响应必须直接调用 file.write、file.patch、file.delete、project.patchset、project.restore、canvas.asset_generate 等实际项目修改工具;若当前专业合同确实只要求只读验收,则必须调用 respond_to_user 交付结论。不得继续只调用 update_agent_plan、读取、搜索、状态查询或空验证。" )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 7de274d2e..14ab4d3cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -1460,6 +1460,7 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c let mut steered_during_actions = false; let mut provider_batch_superseded = false; + let mut preview_infrastructure_blocker = None; let mut parallel_batch_consumed_until = 0_usize; for (action_index, action) in plan .actions @@ -2502,6 +2503,11 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } + if let Some(failure_kind) = agent_runtime_preview_infrastructure_blocker(&observation) { + preview_infrastructure_blocker = Some(failure_kind); + provider_batch_superseded = true; + break; + } if resumed_provider_batch.is_some() && observation.status != "ok" { provider_batch_superseded = true; break; @@ -2672,6 +2678,17 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c return AgentBackgroundTaskOutcome::NeedsReconciliation; } } + if let Some(failure_kind) = preview_infrastructure_blocker { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!( + "preview-infrastructure-unavailable: 浏览器验证基础设施不可用({failure_kind}),已停止当前 run,避免在同一 revision 重复请求 Provider 和启动浏览器" + ), + ); + } if checkpoint == AgentRuntimeContextCheckpoint::Stalled { context_stalled = true; break 'agent_loop; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 9343793d6..cfa5ff689 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -511,6 +511,102 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked( ), )); } + if state.agent_id == "preview-readiness" { + let revision = match read_game_creator_agent_runtime_project_revision(root) { + Ok(revision) => revision, + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-readiness 无法读取当前项目 revision", + error, + )); + } + }; + let gate = match read_game_creator_agent_runtime_verification_gate( + root, + &state.agent_id, + &state.run_id, + ) { + Ok(gate) => gate, + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-readiness 静态验证凭证不可用", + error, + )); + } + }; + if gate.last_verification_tool.as_deref() != Some("game.static_smoke") + || gate.last_verification_status.as_deref() + != Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) + || gate.verified_revision != Some(revision.revision) + { + return Some(autonomous_completion_blocker( + "preview-readiness 尚未通过当前 revision 的 game.static_smoke", + format!( + "currentRevision={}, verifiedRevision={}", + revision.revision, + gate.verified_revision + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + )); + } + } + if state.agent_id == "preview-playtest" { + let contract = match autonomous_playtest_completion_contract_for_state_at(root, state) { + Ok(Some(contract)) => contract, + Ok(None) => { + return Some(autonomous_completion_blocker( + "preview-playtest 缺少自主试玩完成合同", + "当前 child run 无法绑定父 Supervisor 的试玩场景与 revision。", + )); + } + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-playtest 自主试玩完成合同不可用", + error, + )); + } + }; + let revision = match read_game_creator_agent_runtime_project_revision(root) { + Ok(revision) => revision, + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-playtest 无法读取当前项目 revision", + error, + )); + } + }; + let receipt = match read_autonomous_playtest_receipt(root, &contract) { + Ok(Some(receipt)) => receipt, + Ok(None) => { + return Some(autonomous_completion_blocker( + "preview-playtest 尚未形成成功浏览器试玩回执", + "必须由 preview.validate 在当前 revision 生成 passed report 与桌面、移动截图。", + )); + } + Err(error) => { + return Some(autonomous_completion_blocker( + "preview-playtest 浏览器试玩回执不可用", + error, + )); + } + }; + if receipt.revision != revision.revision { + return Some(autonomous_completion_blocker( + "preview-playtest 浏览器试玩回执不属于当前 revision", + format!( + "receiptRevision={}, currentRevision={}", + receipt.revision, revision.revision + ), + )); + } + if let Err(error) = verify_autonomous_playtest_evidence_files_at(root, &receipt) { + return Some(autonomous_completion_blocker( + "preview-playtest 浏览器试玩证据复核未通过", + error, + )); + } + } let parent_contract = match read_autonomous_completion_contract( root, binding @@ -1264,6 +1360,35 @@ pub(in crate::agent) fn autonomous_completion_contract_for_state_at( Ok(Some(contract)) } +pub(in crate::agent) fn autonomous_playtest_completion_contract_for_state_at( + root: &Path, + state: &AgentRuntimeState, +) -> Result, String> { + if let Some(contract) = autonomous_completion_contract_for_state_at(root, state)? { + return Ok(Some(contract)); + } + if state.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + return Ok(None); + } + let binding = + read_game_creator_agent_runtime_run_profile_binding(root, &state.agent_id, &state.run_id)? + .ok_or_else(|| "自主试玩 child Runtime 缺少 Run Profile 绑定".to_string())?; + if binding.parent_agent_id.as_deref() != Some(binding.root_agent_id.as_str()) + || binding.parent_run_id.as_deref() != Some(binding.root_run_id.as_str()) + { + return Err("自主试玩只接受根 Supervisor 的直接 manifest child".to_string()); + } + let contract = + read_autonomous_completion_contract(root, &binding.root_agent_id, &binding.root_run_id)? + .ok_or_else(|| "自主试玩 child Runtime 缺少根 Supervisor 完成合同".to_string())?; + if binding.parent_binding_fingerprint.as_deref() + != Some(contract.run_profile_binding_fingerprint.as_str()) + { + return Err("自主试玩 child Runtime 与根 Supervisor 完成合同绑定不匹配".to_string()); + } + Ok(Some(contract)) +} + pub(in crate::agent) fn autonomous_game_build_completion_blocker_at_locked( root: &Path, state: &AgentRuntimeState, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 93135187f..9c000fef7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -520,6 +520,96 @@ fn autonomous_preview_manifest_roles_keep_their_fixed_read_only_core() { assert!(publish_package.contains("所有 Markdown checklist 必须使用 [x] 或 [X]")); } +#[test] +fn autonomous_preview_manifest_tasks_require_current_revision_receipts_before_completion() { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-preview-task-receipt-parent"); + for (task_id, expected_summary) in [ + ("preview-readiness", "尚未通过当前 revision"), + ("preview-playtest", "preview-playtest"), + ] { + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Running) + .expect("mark preview manifest task running"); + let child = queue_autonomous_manifest_child_fixture(&root, &parent_state, task_id); + let blocker = autonomous_game_build_completion_blocker_at_locked( + &root, + &agent_runtime_state_from_task_record(&child), + ) + .expect("preview task without current receipt must be blocked"); + assert!(blocker.summary.contains(expected_summary)); + update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending) + .expect("reset preview manifest task"); + } +} + +#[test] +fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture( + "做一个完整小游戏", + "autonomous-preview-readiness-receipt-parent", + ); + update_manifest_task_status_at( + &root, + "preview-readiness", + GameCreationAppTaskStatus::Running, + ) + .expect("mark preview readiness running"); + let readiness_child = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness"); + let readiness_state = agent_runtime_state_from_task_record(&readiness_child); + advance_game_index_revision( + &root, + &parent_state, + "静态检查通过", + ); + mark_verification_passed(&root, &readiness_state, "game.static_smoke"); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &readiness_state).is_none()); + + let (_temporary, root, parent_state, contract) = autonomous_fixture( + "做一个完整小游戏", + "autonomous-preview-playtest-receipt-parent", + ); + update_manifest_task_status_at( + &root, + "preview-playtest", + GameCreationAppTaskStatus::Running, + ) + .expect("mark preview playtest running"); + let playtest_child = + queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-playtest"); + let playtest_state = agent_runtime_state_from_task_record(&playtest_child); + let revision = advance_game_index_revision( + &root, + &parent_state, + "浏览器试玩通过", + ); + let result = browser_result_fixture( + &root, + &parent_state, + revision, + BrowserPlaytestScenario::GenericV1, + ); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证当前 revision 的真实可玩闭环".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = + agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task); + let action_id = + agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint); + write_autonomous_playtest_receipt_at( + &root, + &contract, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect("persist child-bound autonomous playtest receipt"); + assert!(autonomous_game_build_completion_blocker_at_locked(&root, &playtest_state).is_none()); +} + #[test] fn autonomous_completion_rejects_formal_artifact_unchanged_from_run_baseline() { let baseline_bytes = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index 490a355e0..6c162f963 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -523,8 +523,14 @@ pub(in crate::agent) fn read_game_creator_agent_runtime_context_bundle_with_supe { return Err("Agent Runtime context bundle 的停滞标记只能出现在上下文窗口边界".to_string()); } + let loop_limit = u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1); + let completed_loop_remainder = bundle.next_loop_index % loop_limit; let max_completed_loops = - bundle.next_loop_index % u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1); + if bundle.next_loop_index > 0 && completed_loop_remainder == 0 && !bundle.context_stalled { + loop_limit.saturating_sub(1) + } else { + completed_loop_remainder + }; if bundle.window_completed_loops > max_completed_loops { return Err(format!( "Agent Runtime context bundle 窗口轮次无效:windowCompletedLoops={} max={max_completed_loops}", @@ -809,3 +815,65 @@ pub(in crate::agent) fn persist_game_creator_agent_runtime_pause_boundary_contex context_tracker, ) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn agent_runtime_context_bundle_restores_pre_checkpoint_window_boundary() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "genarrative-context-window-boundary-{}-{unique}", + std::process::id() + )); + init_local_game_project_at(&root, "project-1", "上下文窗口边界恢复项目") + .expect("project init"); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "验证窗口边界恢复", + "design-context-window-boundary-run", + "agent-background-task", + "窗口边界恢复测试", + vec!["恢复 checkpoint 前的窗口状态".to_string()], + ) + .expect("start window boundary runtime state"); + let mut tracker = AgentRuntimeContextWindowTracker::default(); + for next_loop_index in 1..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + assert_eq!( + tracker.complete_loop(next_loop_index), + AgentRuntimeContextCheckpoint::Continue + ); + } + assert_eq!(tracker.completed_loops, 5); + + let bundle = build_game_creator_agent_runtime_context_bundle( + &root, + &runtime, + &runtime.current_task, + &AgentRuntimeToolPlan::default(), + &[], + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, + &tracker, + ) + .expect("build pre-checkpoint boundary bundle"); + assert_eq!(bundle.next_loop_index, 6); + assert_eq!(bundle.context_window, 2); + assert_eq!(bundle.window_completed_loops, 5); + write_game_creator_agent_runtime_context_bundle(&root, &bundle) + .expect("write pre-checkpoint boundary bundle"); + + runtime.loop_iteration = 6; + let loaded = read_game_creator_agent_runtime_context_bundle(&root, &runtime) + .expect("pre-checkpoint boundary bundle must remain recoverable") + .expect("pre-checkpoint boundary bundle exists"); + assert_eq!(loaded.window_completed_loops, 5); + + fs::remove_dir_all(root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs index d32bb1d3a..dde4e6405 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/preview.rs @@ -1,5 +1,64 @@ use super::*; +const AGENT_RUNTIME_PREVIEW_INFRASTRUCTURE_ERROR_KIND: &str = "preview-infrastructure-unavailable"; + +fn agent_runtime_preview_infrastructure_failure_kind(error: &str) -> Option<&'static str> { + if error.contains("before websocket URL could be resolved") + || error.contains("启动浏览器失败") + || error.contains("启动浏览器超时") + { + Some("browser-launch-failed") + } else if error.contains("未发现可用的 Google Chrome") { + Some("browser-not-found") + } else if error.contains("创建浏览器临时目录失败") + || error.contains("创建浏览器临时 Profile 失败") + || error.contains("构建浏览器配置失败") + { + Some("browser-environment-invalid") + } else { + None + } +} + +fn agent_runtime_preview_infrastructure_observation( + root: &Path, + revision: u64, + failure_kind: &str, + error: &str, +) -> AgentRuntimeToolObservation { + let detail = serde_json::to_string(&serde_json::json!({ + "errorKind": AGENT_RUNTIME_PREVIEW_INFRASTRUCTURE_ERROR_KIND, + "failureKind": failure_kind, + "revision": revision, + "diagnostic": redact_agent_runtime_project_paths(root, error, 500), + })) + .ok(); + AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "blocked".to_string(), + summary: "浏览器验证基础设施不可用,当前任务已停止,未重复重试".to_string(), + detail, + } +} + +pub(in crate::agent) fn agent_runtime_preview_infrastructure_blocker( + observation: &AgentRuntimeToolObservation, +) -> Option { + if observation.tool != "preview.validate" || observation.status != "blocked" { + return None; + } + let detail = serde_json::from_str::(observation.detail.as_deref()?).ok()?; + (detail.get("errorKind").and_then(serde_json::Value::as_str) + == Some(AGENT_RUNTIME_PREVIEW_INFRASTRUCTURE_ERROR_KIND)) + .then(|| { + detail + .get("failureKind") + .and_then(serde_json::Value::as_str) + .unwrap_or("browser-infrastructure") + .to_string() + }) +} + pub(in crate::agent) fn observe_agent_runtime_preview_start( root: &Path, agent_id: &str, @@ -115,17 +174,18 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( }; } }; - let completion_contract = match autonomous_completion_contract_for_state_at(root, &runtime) { - Ok(contract) => contract, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "preview.validate".to_string(), - status: "failed".to_string(), - summary: "自主构建完成合同不可用,未执行浏览器试玩".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }; - } - }; + let completion_contract = + match autonomous_playtest_completion_contract_for_state_at(root, &runtime) { + Ok(contract) => contract, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "preview.validate".to_string(), + status: "failed".to_string(), + summary: "自主构建完成合同不可用,未执行浏览器试玩".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; if let (Some(contract), Some(requested)) = ( completion_contract.as_ref(), input.playtest_scenario.as_ref(), @@ -219,6 +279,14 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( let result = match validation { Ok(result) => result, Err(error) => { + if let Some(failure_kind) = agent_runtime_preview_infrastructure_failure_kind(&error) { + return agent_runtime_preview_infrastructure_observation( + root, + revision_before.revision, + failure_kind, + &error, + ); + } return AgentRuntimeToolObservation { tool: "preview.validate".to_string(), status: "failed".to_string(), @@ -399,3 +467,38 @@ pub(in crate::agent) async fn observe_agent_runtime_preview_validate( detail, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browser_websocket_launch_exit_is_classified_as_infrastructure_failure() { + let error = "启动浏览器失败:Browser process exited with status ExitStatus(0) before websocket URL could be resolved, stderr=\"\""; + assert_eq!( + agent_runtime_preview_infrastructure_failure_kind(error), + Some("browser-launch-failed") + ); + let observation = agent_runtime_preview_infrastructure_observation( + Path::new("/project"), + 19, + "browser-launch-failed", + error, + ); + assert_eq!(observation.status, "blocked"); + assert_eq!( + agent_runtime_preview_infrastructure_blocker(&observation).as_deref(), + Some("browser-launch-failed") + ); + } + + #[test] + fn gameplay_validation_failure_is_not_an_infrastructure_failure() { + assert_eq!( + agent_runtime_preview_infrastructure_failure_kind( + "浏览器验证未通过,请根据诊断修复后重试" + ), + None + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 9d60ee983..9f08cf712 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -309,6 +309,9 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) "llm.toolOutputTokenLimit={}", status.tool_output_token_limit ), + format!("llm.requestTimeoutMs={}", status.request_timeout_ms), + format!("llm.maxRetries={}", status.max_retries), + format!("llm.retryBackoffMs={}", status.retry_backoff_ms), ]; for agent in &status.agents { lines.push(format!( @@ -357,6 +360,18 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) "llm.agent.{}.toolOutputTokenLimit={}", agent.agent_id, agent.tool_output_token_limit )); + lines.push(format!( + "llm.agent.{}.requestTimeoutMs={}", + agent.agent_id, agent.request_timeout_ms + )); + lines.push(format!( + "llm.agent.{}.maxRetries={}", + agent.agent_id, agent.max_retries + )); + lines.push(format!( + "llm.agent.{}.retryBackoffMs={}", + agent.agent_id, agent.retry_backoff_ms + )); if let Some(error) = agent.error.as_deref() { lines.push(format!("llm.agent.{}.error={error}", agent.agent_id)); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs index 82ad1f376..0c1a01ee5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs @@ -22,8 +22,6 @@ const SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_CURRENT: &str = const SUPERVISOR_COLLABORATION_MAX_STATIC_DELEGATES: usize = 3; const SUPERVISOR_COLLABORATION_MAX_ISOLATED_CHILDREN: usize = 3; const SUPERVISOR_COLLABORATION_MAX_ISOLATED_GROUPS_BEFORE_CLAIM: usize = 16; -const AUTONOMOUS_GAME_BUILD_BASE_REQUIRED_STATIC_AGENT_IDS: [&str; 2] = - ["code-prototype", "quality-review"]; #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -67,93 +65,10 @@ impl Default for SupervisorCollaborationPolicy { } } -fn autonomous_game_build_has_canonical_art_asset(root: &Path) -> bool { - read_existing_manifest_for_project(root) - .ok() - .is_some_and(|manifest| { - manifest_has_required_visual_asset(root, &manifest, "art-asset-plan") - }) -} - -fn autonomous_game_build_has_canonical_art_spec(root: &Path) -> bool { - read_existing_manifest_for_project(root) - .ok() - .is_some_and(|manifest| manifest_has_required_visual_asset(root, &manifest, "art-director")) -} - -fn autonomous_game_build_has_canonical_ui_prototype(root: &Path) -> bool { - read_existing_manifest_for_project(root) - .ok() - .is_some_and(|manifest| { - manifest_has_required_visual_asset(root, &manifest, "design-foundation") - }) -} - fn autonomous_game_build_supervisor_collaboration_policy( - root: &Path, + _root: &Path, ) -> SupervisorCollaborationPolicy { - let mut required_static_agent_ids = AUTONOMOUS_GAME_BUILD_BASE_REQUIRED_STATIC_AGENT_IDS - .into_iter() - .map(str::to_string) - .collect::>(); - if editor_api_key_is_configured() { - if !autonomous_game_build_has_canonical_art_spec(root) { - required_static_agent_ids.push("art-director".to_string()); - } else if !autonomous_game_build_has_canonical_ui_prototype(root) { - required_static_agent_ids.push("design-foundation".to_string()); - } else if !autonomous_game_build_has_canonical_art_asset(root) { - required_static_agent_ids.push("art-asset-plan".to_string()); - } - } - SupervisorCollaborationPolicy { - required_initial_wave: SupervisorInitialCollaborationWave::Static, - min_static_delegates: required_static_agent_ids.len(), - required_static_agent_ids, - ..SupervisorCollaborationPolicy::default() - } -} - -fn apply_autonomous_game_build_required_static_agents( - root: &Path, - mut policy: SupervisorCollaborationPolicy, -) -> Result { - if editor_api_key_is_configured() { - if !autonomous_game_build_has_canonical_art_spec(root) - && !policy - .required_static_agent_ids - .iter() - .any(|existing| existing == "art-director") - { - policy - .required_static_agent_ids - .push("art-director".to_string()); - } else if autonomous_game_build_has_canonical_art_spec(root) - && !autonomous_game_build_has_canonical_ui_prototype(root) - && !policy - .required_static_agent_ids - .iter() - .any(|existing| existing == "design-foundation") - { - policy - .required_static_agent_ids - .push("design-foundation".to_string()); - } else if autonomous_game_build_has_canonical_art_spec(root) - && autonomous_game_build_has_canonical_ui_prototype(root) - && !autonomous_game_build_has_canonical_art_asset(root) - && !policy - .required_static_agent_ids - .iter() - .any(|existing| existing == "art-asset-plan") - { - policy - .required_static_agent_ids - .push("art-asset-plan".to_string()); - } - } - policy.min_static_delegates = policy - .min_static_delegates - .max(policy.required_static_agent_ids.len()); - normalize_supervisor_collaboration_policy(policy) + SupervisorCollaborationPolicy::default() } #[derive(Clone, Debug)] @@ -176,10 +91,7 @@ fn read_supervisor_collaboration_unbound_policy_for_run_at( let policy_path = root.join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH); match fs::symlink_metadata(&policy_path) { Ok(_) => { - let mut policy = read_supervisor_collaboration_policy_at(root)?; - if autonomous_supervisor { - policy = apply_autonomous_game_build_required_static_agents(root, policy)?; - } + let policy = read_supervisor_collaboration_policy_at(root)?; return Ok(SupervisorCollaborationUnboundPolicy { policy, source: "project-policy-unbound", diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 19fded1e3..1b6a1996d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1,5 +1,37 @@ use super::*; +pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [ + (GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"), + ("planner", "high"), + ("orchestrator", "medium"), + ("generator", "high"), + ("evaluator", "high"), + ("design-director", "medium"), + ("design-foundation", "high"), + ("balance-director", "medium"), + ("balance-seed", "medium"), + ("art-director", "high"), + ("art-asset-plan", "high"), + ("art-polish", "medium"), + ("audio-director", "low"), + ("audio-asset-plan", "medium"), + ("code-director", "medium"), + ("code-prototype", "high"), + ("quality-review", "high"), + ("preview-readiness", "low"), + ("preview-playtest", "low"), + ("publish-strategy", "low"), + ("publish-package", "medium"), +]; + +pub(crate) fn game_creator_llm_agent_default_reasoning_effort( + agent_id: &str, +) -> Option<&'static str> { + GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS + .iter() + .find_map(|(candidate, effort)| (*candidate == agent_id).then_some(*effort)) +} + pub(crate) fn build_game_creator_llm_client_from_llm_config( llm: &GameCreatorLlmConfig, config_path: &str, @@ -137,6 +169,9 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS, auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT, tool_output_token_limit: DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT, + request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS, + max_retries: DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES, + retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, error: Some(error), agents: Vec::new(), } @@ -248,6 +283,9 @@ pub(crate) fn check_game_creator_llm_config_values( context_window_tokens: config.context_window_tokens, auto_compact_token_limit: config.auto_compact_token_limit, tool_output_token_limit: config.tool_output_token_limit, + request_timeout_ms: config.request_timeout_ms, + max_retries: config.max_retries, + retry_backoff_ms: config.retry_backoff_ms, error, agents: Vec::new(), } @@ -287,6 +325,9 @@ pub(crate) fn check_game_creator_agent_llm_config_values( context_window_tokens: config.context_window_tokens, auto_compact_token_limit: config.auto_compact_token_limit, tool_output_token_limit: config.tool_output_token_limit, + request_timeout_ms: config.request_timeout_ms, + max_retries: config.max_retries, + retry_backoff_ms: config.retry_backoff_ms, error: status.error, } } @@ -1367,6 +1408,9 @@ pub(crate) fn resolve_game_creator_llm_config_for_agent( agent_id: &str, ) -> GameCreatorLlmConfig { let mut llm = config.llm.clone(); + if let Some(reasoning_effort) = game_creator_llm_agent_default_reasoning_effort(agent_id) { + llm.reasoning_effort = reasoning_effort.to_string(); + } if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { if let Some(patch) = config .agent_llm diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 8f72f2bcc..0e247a357 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -647,6 +647,9 @@ struct GameCreatorLlmConfigStatus { context_window_tokens: u64, auto_compact_token_limit: u64, tool_output_token_limit: u64, + request_timeout_ms: u64, + max_retries: u32, + retry_backoff_ms: u64, error: Option, agents: Vec, } @@ -667,6 +670,9 @@ struct GameCreatorAgentLlmConfigStatus { context_window_tokens: u64, auto_compact_token_limit: u64, tool_output_token_limit: u64, + request_timeout_ms: u64, + max_retries: u32, + retry_backoff_ms: u64, error: Option, } @@ -1969,6 +1975,52 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { } } +#[derive(Debug, Eq, PartialEq)] +enum GameChatReleaseClientExitOutcome { + Shutdown, + Busy, + Failed(String), +} + +fn resolve_game_chat_release_client_exit(shutdown: F) -> GameChatReleaseClientExitOutcome +where + F: FnOnce() -> Result, +{ + match shutdown() { + Ok(true) => GameChatReleaseClientExitOutcome::Shutdown, + Ok(false) => GameChatReleaseClientExitOutcome::Busy, + Err(error) => GameChatReleaseClientExitOutcome::Failed(error), + } +} + +fn show_game_chat_release_client_exit_blocked(app: &tauri::AppHandle) { + app.dialog() + .message("当前仍有游戏创作任务或 Provider 请求在运行。为避免结果丢失,已阻止关闭;请先等待任务完成,或在任务页暂停/取消后再退出。") + .title("游戏创作任务仍在运行") + .show(|_| {}); +} + +#[cfg(test)] +mod game_chat_release_client_exit_tests { + use super::*; + + #[test] + fn client_exit_resolution_distinguishes_shutdown_busy_and_failure() { + assert_eq!( + resolve_game_chat_release_client_exit(|| Ok(true)), + GameChatReleaseClientExitOutcome::Shutdown + ); + assert_eq!( + resolve_game_chat_release_client_exit(|| Ok(false)), + GameChatReleaseClientExitOutcome::Busy + ); + assert_eq!( + resolve_game_chat_release_client_exit(|| Err("runner unavailable".to_string())), + GameChatReleaseClientExitOutcome::Failed("runner unavailable".to_string()) + ); + } +} + fn main() { let mut args = std::env::args().skip(1).collect::>(); #[cfg(target_os = "linux")] @@ -2351,17 +2403,28 @@ fn main() { let _ = append_bounded_diagnostic_line(path, "startup.run.begin"); } let shutdown_log = startup_log.clone(); - app.run(move |_, event| { + app.run(move |app_handle, event| { let game_chat_release = cfg!(all(not(debug_assertions), feature = "game-chat-release")); - if game_chat_release && should_shutdown_runner_on_tauri_event(true, &event) { + let game_chat_exit_requested = game_chat_release + && matches!( + &event, + tauri::RunEvent::WindowEvent { + event: tauri::WindowEvent::CloseRequested { .. }, + .. + } | tauri::RunEvent::ExitRequested { .. } + ); + if game_chat_exit_requested { if let Some(path) = shutdown_log.as_deref() { let _ = append_bounded_diagnostic_line( path, "startup.runner.shutdown-for-client-exit.begin", ); } - match shutdown_external_agent_runner_for_client_exit() { - Ok(()) => { + let outcome = resolve_game_chat_release_client_exit( + shutdown_external_agent_runner_for_client_exit, + ); + match &outcome { + GameChatReleaseClientExitOutcome::Shutdown => { if let Some(path) = shutdown_log.as_deref() { let _ = append_bounded_diagnostic_line( path, @@ -2369,7 +2432,15 @@ fn main() { ); } } - Err(error) => { + GameChatReleaseClientExitOutcome::Busy => { + if let Some(path) = shutdown_log.as_deref() { + let _ = append_bounded_diagnostic_line( + path, + "startup.runner.shutdown-for-client-exit.busy", + ); + } + } + GameChatReleaseClientExitOutcome::Failed(error) => { if let Some(path) = shutdown_log.as_deref() { let details = sanitize_diagnostic_message(&error, path.parent()); let _ = append_bounded_diagnostic_line( @@ -2382,6 +2453,17 @@ fn main() { eprintln!("game-chat 客户端退出协议关闭 Agent Runner 失败:{error}") } } + if outcome != GameChatReleaseClientExitOutcome::Shutdown { + match &event { + tauri::RunEvent::WindowEvent { + event: tauri::WindowEvent::CloseRequested { api, .. }, + .. + } => api.prevent_close(), + tauri::RunEvent::ExitRequested { api, .. } => api.prevent_exit(), + _ => {} + } + show_game_chat_release_client_exit_blocked(app_handle); + } } else if !game_chat_release { handle_game_creator_gui_run_event(&event); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 975968661..69c7da1c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -931,11 +931,11 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { pub(super) fn shutdown_external_agent_runner_for_client_exit_at( config_dir: &Path, -) -> Result<(), String> { +) -> Result { let Some((endpoint_path, endpoint)) = read_external_agent_runner_endpoint_for_shutdown(config_dir)? else { - return Ok(()); + return Ok(true); }; let request_id = random_identifier(b"genarrative-agent-runner-client-exit-request-id")?; let result = match send_external_agent_runner_request_with_protocol_and_id( @@ -949,7 +949,7 @@ pub(super) fn shutdown_external_agent_runner_for_client_exit_at( Err(error) => { return match read_external_agent_runner_endpoint(&endpoint_path) { Ok(current) if current.boot_id == endpoint.boot_id => Err(error), - _ => Ok(()), + _ => Ok(true), }; } }; @@ -957,25 +957,32 @@ pub(super) fn shutdown_external_agent_runner_for_client_exit_at( .get("accepted") .and_then(Value::as_bool) .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 accepted".to_string())?; + let busy = result + .get("busy") + .and_then(Value::as_bool) + .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 busy".to_string())?; let will_shutdown = result .get("willShutdown") .and_then(Value::as_bool) .ok_or_else(|| "Agent Runner shutdown_for_client_exit 响应缺少 willShutdown".to_string())?; - if !accepted || !will_shutdown { - return Err("Agent Runner 拒绝按客户端退出协议关闭".to_string()); + match (accepted, busy, will_shutdown) { + (false, true, false) => return Ok(false), + (true, false, true) => {} + _ => return Err("Agent Runner shutdown_for_client_exit 响应状态不一致".to_string()), } wait_for_external_agent_runner_boot_exit( &endpoint_path, &endpoint, AGENT_RUNNER_CLIENT_EXIT_TIMEOUT, "Agent Runner 未在客户端退出期限内停止", - ) + )?; + Ok(true) } -pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result<(), String> { +pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let Some(config_dir) = external_agent_runner_config_dir() else { - return Ok(()); + return Ok(true); }; shutdown_external_agent_runner_for_client_exit_at(&config_dir) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index f0be74db5..5bc8667ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -618,12 +618,53 @@ pub(super) fn dispatch_external_agent_runner_runtime_request( ) } "runner.shutdown_for_client_exit" if cfg!(any(test, feature = "game-chat-release")) => { - state.draining.store(true, Ordering::Release); - state.shutdown_requested.store(true, Ordering::Release); - ExternalAgentRunnerResponse::success( - &request.request_id, - json!({ "accepted": true, "willShutdown": true }), - ) + if state.shutdown_requested.load(Ordering::Acquire) { + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true, "busy": false, "willShutdown": true }), + ) + } else if state + .draining + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + ExternalAgentRunnerResponse::failure( + &request.request_id, + "runner-draining", + "Agent Runner 已在排空", + ) + } else if state.active_connections.load(Ordering::Acquire) > 1 { + state.draining.store(false, Ordering::Release); + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": false, "busy": true, "willShutdown": false }), + ) + } else { + match external_agent_runner_known_roots_are_idle(state) { + Ok(false) => { + state.draining.store(false, Ordering::Release); + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": false, "busy": true, "willShutdown": false }), + ) + } + Ok(true) => { + state.shutdown_requested.store(true, Ordering::Release); + ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true, "busy": false, "willShutdown": true }), + ) + } + Err(error) => { + state.draining.store(false, Ordering::Release); + ExternalAgentRunnerResponse::failure( + &request.request_id, + "runtime-state-unreadable", + redact_runner_secret(&error, &token), + ) + } + } + } } "runner.shutdown_if_idle" | "shutdown_if_idle" => { if request.params.root.is_some() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index be0931424..98edcf5f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -150,6 +150,91 @@ fn legacy_runner_force_migration_requires_authenticated_exact_ping_identity() { server.join().expect("join mismatched identity fixture"); } +#[test] +fn client_exit_client_returns_busy_without_waiting_and_accepts_idle_shutdown() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let endpoint_path = external_agent_runner_endpoint_path(&config_dir); + let token = "client-exit-response-token-client-exit-response-token"; + + let busy_listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .expect("bind busy client-exit fixture"); + let busy_endpoint = test_endpoint( + token, + "client-exit-busy-response-boot", + busy_listener + .local_addr() + .expect("busy fixture address") + .port(), + ); + write_external_agent_runner_endpoint_atomic(&endpoint_path, &busy_endpoint) + .expect("write busy client-exit endpoint"); + let busy_server = std::thread::spawn(move || { + let (mut stream, _) = busy_listener.accept().expect("accept busy client exit"); + let payload = read_external_agent_runner_frame(&mut stream).expect("read busy client exit"); + let request = serde_json::from_slice::(&payload) + .expect("parse busy client exit"); + assert_eq!(request.method, "runner.shutdown_for_client_exit"); + let response = ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": false, "busy": true, "willShutdown": false }), + ); + write_external_agent_runner_frame( + &mut stream, + &serde_json::to_vec(&response).expect("serialize busy client-exit response"), + ) + .expect("write busy client-exit response"); + }); + + let started = Instant::now(); + assert!( + !shutdown_external_agent_runner_for_client_exit_at(&config_dir) + .expect("busy client exit remains a successful refusal") + ); + assert!( + started.elapsed() < Duration::from_secs(2), + "busy client exit must not wait for Runner boot shutdown" + ); + busy_server.join().expect("join busy client-exit fixture"); + + let idle_listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .expect("bind idle client-exit fixture"); + let idle_endpoint = test_endpoint( + token, + "client-exit-idle-response-boot", + idle_listener + .local_addr() + .expect("idle fixture address") + .port(), + ); + write_external_agent_runner_endpoint_atomic(&endpoint_path, &idle_endpoint) + .expect("write idle client-exit endpoint"); + let idle_endpoint_path = endpoint_path.clone(); + let idle_server = std::thread::spawn(move || { + let (mut stream, _) = idle_listener.accept().expect("accept idle client exit"); + let payload = read_external_agent_runner_frame(&mut stream).expect("read idle client exit"); + let request = serde_json::from_slice::(&payload) + .expect("parse idle client exit"); + assert_eq!(request.method, "runner.shutdown_for_client_exit"); + let response = ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "accepted": true, "busy": false, "willShutdown": true }), + ); + write_external_agent_runner_frame( + &mut stream, + &serde_json::to_vec(&response).expect("serialize idle client-exit response"), + ) + .expect("write idle client-exit response"); + fs::remove_file(idle_endpoint_path).expect("remove idle endpoint after shutdown response"); + }); + + assert!( + shutdown_external_agent_runner_for_client_exit_at(&config_dir) + .expect("idle client exit must complete Runner shutdown") + ); + idle_server.join().expect("join idle client-exit fixture"); +} + #[test] fn endpoint_shape_accepts_legacy_missing_fingerprint_but_rejects_malformed_values() { let endpoint = test_endpoint( @@ -1248,7 +1333,7 @@ fn forced_shutdown_is_accepted_even_when_runtime_is_busy() { } #[test] -fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { +fn shutdown_for_client_exit_rejects_busy_then_closes_idle_runner_idempotently() { let directory = unique_test_directory(); let root = directory.0.join("project"); let pending = root.join(".agent/runtime/pending-actions/code-prototype/run-client-exit.json"); @@ -1309,10 +1394,50 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { assert!(!state.shutdown_requested.load(Ordering::Acquire)); assert!(!state.draining.load(Ordering::Acquire)); + let busy_response = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "shutdown-client-exit-busy-1".to_string(), + token: token.to_string(), + method: "runner.shutdown_for_client_exit".to_string(), + params: ExternalAgentRunnerRequestParams::default(), + }, + &state, + ); + assert!(busy_response.ok); + assert_eq!( + busy_response + .result + .as_ref() + .and_then(|value| value["accepted"].as_bool()), + Some(false) + ); + assert_eq!( + busy_response + .result + .as_ref() + .and_then(|value| value["busy"].as_bool()), + Some(true) + ); + assert_eq!( + busy_response + .result + .as_ref() + .and_then(|value| value["willShutdown"].as_bool()), + Some(false) + ); + assert!(!state.shutdown_requested.load(Ordering::Acquire)); + assert!(!state.draining.load(Ordering::Acquire)); + assert_eq!( + fs::read(&pending).expect("read pending action"), + durable_bytes + ); + + fs::remove_file(&pending).expect("clear pending action before idle client exit"); let shutdown_response = handle_external_agent_runner_request( ExternalAgentRunnerRequest { protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, - request_id: "shutdown-client-exit-force-1".to_string(), + request_id: "shutdown-client-exit-idle-1".to_string(), token: token.to_string(), method: "runner.shutdown_for_client_exit".to_string(), params: ExternalAgentRunnerRequestParams::default(), @@ -1327,6 +1452,13 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { .and_then(|value| value["accepted"].as_bool()), Some(true) ); + assert_eq!( + shutdown_response + .result + .as_ref() + .and_then(|value| value["busy"].as_bool()), + Some(false) + ); assert_eq!( shutdown_response .result @@ -1336,35 +1468,6 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { ); assert!(state.shutdown_requested.load(Ordering::Acquire)); assert!(state.draining.load(Ordering::Acquire)); - assert_eq!( - fs::read(&pending).expect("read pending action"), - durable_bytes - ); - - let write_response = handle_external_agent_runner_request( - ExternalAgentRunnerRequest { - protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, - request_id: "shutdown-client-exit-write-after-drain".to_string(), - token: token.to_string(), - method: "runtime.continue_action".to_string(), - params: ExternalAgentRunnerRequestParams { - root: Some(root.to_string_lossy().into_owned()), - agent: Some("code-prototype".to_string()), - run_id: Some("run-client-exit".to_string()), - action_id: Some("action-client-exit".to_string()), - ..ExternalAgentRunnerRequestParams::default() - }, - }, - &state, - ); - assert!(!write_response.ok); - assert_eq!( - write_response - .error - .as_ref() - .map(|error| error.code.as_str()), - Some("runner-draining") - ); let repeated_response = handle_external_agent_runner_request( ExternalAgentRunnerRequest { @@ -1384,6 +1487,13 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { .and_then(|value| value["accepted"].as_bool()), Some(true) ); + assert_eq!( + repeated_response + .result + .as_ref() + .and_then(|value| value["busy"].as_bool()), + Some(false) + ); assert_eq!( repeated_response .result @@ -1391,10 +1501,7 @@ fn shutdown_for_client_exit_preserves_busy_durable_state_and_is_idempotent() { .and_then(|value| value["willShutdown"].as_bool()), Some(true) ); - assert_eq!( - fs::read(&pending).expect("reread pending action"), - durable_bytes - ); + assert!(!pending.exists()); } #[test] 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 d04162240..2ec2dcfc3 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 @@ -281,7 +281,7 @@ fn project_supervisor_llm_config_prefers_specific_patch_and_falls_back_to_legacy assert_eq!(fallback.api_key, "legacy-chat-key"); assert_eq!(fallback.base_url, "https://legacy-chat.example.test/v1"); assert_eq!(fallback.model, "legacy-chat-model"); - assert_eq!(fallback.reasoning_effort, "medium"); + assert_eq!(fallback.reasoning_effort, "high"); assert!(fallback.stream); config.agent_llm.insert( 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 d4448c8d7..f9b5ad9d3 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 @@ -1428,6 +1428,19 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio "总控首轮只读逃逸修复测试", ) .expect("project init"); + write_supervisor_collaboration_policy_at( + &root, + SupervisorCollaborationPolicy { + required_initial_wave: SupervisorInitialCollaborationWave::Static, + min_static_delegates: 2, + required_static_agent_ids: vec![ + "code-prototype".to_string(), + "quality-review".to_string(), + ], + ..SupervisorCollaborationPolicy::default() + }, + ) + .expect("write explicit collaboration repair policy"); let (sender, receiver) = mpsc::channel(); let delegate_function = native_runtime_function_name("agent.delegate").expect("delegate function"); @@ -1614,7 +1627,8 @@ async fn supervisor_collaboration_partial_initial_wave_repairs_with_collaboratio } #[test] -fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_reviewer() { +fn supervisor_autonomous_game_build_without_project_policy_uses_manifest_as_the_only_initial_wave() +{ let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1657,13 +1671,10 @@ fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_ assert_eq!(resolution.project_policy_status, "absent"); assert_eq!( resolution.policy.required_initial_wave, - SupervisorInitialCollaborationWave::Static - ); - assert_eq!(resolution.policy.min_static_delegates, 2); - assert_eq!( - resolution.policy.required_static_agent_ids, - vec!["code-prototype".to_string(), "quality-review".to_string()] + SupervisorInitialCollaborationWave::Auto ); + assert_eq!(resolution.policy.min_static_delegates, 0); + assert!(resolution.policy.required_static_agent_ids.is_empty()); assert!(!root .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) .exists()); @@ -1673,8 +1684,7 @@ fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_ } #[test] -fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_in_dependency_order() -{ +fn supervisor_autonomous_game_build_with_editor_api_key_keeps_visual_agents_in_manifest_order() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1717,18 +1727,10 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i assert_eq!(resolution.project_policy_status, "absent"); assert_eq!( resolution.policy.required_initial_wave, - SupervisorInitialCollaborationWave::Static - ); - assert_eq!(resolution.policy.min_static_delegates, 3); - assert_eq!( - resolution - .policy - .required_static_agent_ids - .iter() - .map(String::as_str) - .collect::>(), - BTreeSet::from(["code-prototype", "quality-review", "art-director"]) + SupervisorInitialCollaborationWave::Auto ); + assert_eq!(resolution.policy.min_static_delegates, 0); + assert!(resolution.policy.required_static_agent_ids.is_empty()); assert!(!root .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) .exists()); @@ -1750,16 +1752,11 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i design_run_id, ) .expect("resolve autonomous collaboration policy after art spec delivery"); - assert_eq!(design_resolution.policy.min_static_delegates, 3); - assert_eq!( - design_resolution - .policy - .required_static_agent_ids - .iter() - .map(String::as_str) - .collect::>(), - BTreeSet::from(["code-prototype", "quality-review", "design-foundation"]) - ); + assert_eq!(design_resolution.policy.min_static_delegates, 0); + assert!(design_resolution + .policy + .required_static_agent_ids + .is_empty()); register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); let art_run_id = "supervisor-autonomous-art-after-ui-run"; @@ -1778,16 +1775,8 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i art_run_id, ) .expect("resolve autonomous collaboration policy after UI delivery"); - assert_eq!(art_resolution.policy.min_static_delegates, 3); - assert_eq!( - art_resolution - .policy - .required_static_agent_ids - .iter() - .map(String::as_str) - .collect::>(), - BTreeSet::from(["code-prototype", "quality-review", "art-asset-plan"]) - ); + assert_eq!(art_resolution.policy.min_static_delegates, 0); + assert!(art_resolution.policy.required_static_agent_ids.is_empty()); register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); let complete_run_id = "supervisor-autonomous-after-all-visual-assets-run"; @@ -1806,18 +1795,18 @@ fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_i complete_run_id, ) .expect("resolve autonomous collaboration policy after all visual deliveries"); - assert_eq!(complete_resolution.policy.min_static_delegates, 2); - assert_eq!( - complete_resolution.policy.required_static_agent_ids, - vec!["code-prototype".to_string(), "quality-review".to_string()] - ); + assert_eq!(complete_resolution.policy.min_static_delegates, 0); + assert!(complete_resolution + .policy + .required_static_agent_ids + .is_empty()); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); } #[test] -fn supervisor_autonomous_game_build_augments_existing_project_policy_with_required_art_director() { +fn supervisor_autonomous_game_build_preserves_explicit_project_policy_without_hidden_agents() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1839,8 +1828,16 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir "自主构建已有策略美术协作测试", ) .expect("project init"); - write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) - .expect("write default project collaboration policy"); + write_supervisor_collaboration_policy_at( + &root, + SupervisorCollaborationPolicy { + required_initial_wave: SupervisorInitialCollaborationWave::Static, + min_static_delegates: 1, + required_static_agent_ids: vec!["code-prototype".to_string()], + ..SupervisorCollaborationPolicy::default() + }, + ) + .expect("write explicit project collaboration policy"); let run_id = "supervisor-autonomous-existing-policy-art-run"; bind_game_creator_agent_runtime_run_profile_at( &root, @@ -1857,18 +1854,17 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, ) - .expect("resolve augmented project collaboration policy"); + .expect("resolve explicit project collaboration policy"); assert_eq!(resolution.source, "project-policy-unbound"); assert_eq!(resolution.project_policy_status, "current"); + assert_eq!( + resolution.policy.required_initial_wave, + SupervisorInitialCollaborationWave::Static + ); assert_eq!(resolution.policy.min_static_delegates, 1); assert_eq!( - resolution - .policy - .required_static_agent_ids - .iter() - .map(String::as_str) - .collect::>(), - BTreeSet::from(["art-director"]) + resolution.policy.required_static_agent_ids, + vec!["code-prototype".to_string()] ); fs::remove_dir_all(root).ok(); @@ -1876,8 +1872,7 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir } #[test] -fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_skips_visual_delegate( -) { +fn supervisor_autonomous_game_build_visual_asset_state_does_not_add_hidden_delegates() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1923,13 +1918,10 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_sk assert_eq!(resolution.project_policy_status, "absent"); assert_eq!( resolution.policy.required_initial_wave, - SupervisorInitialCollaborationWave::Static - ); - assert_eq!(resolution.policy.min_static_delegates, 2); - assert_eq!( - resolution.policy.required_static_agent_ids, - vec!["code-prototype".to_string(), "quality-review".to_string()] + SupervisorInitialCollaborationWave::Auto ); + assert_eq!(resolution.policy.min_static_delegates, 0); + assert!(resolution.policy.required_static_agent_ids.is_empty()); assert!(!root .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) .exists()); @@ -1952,19 +1944,22 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_sk corrupt_run_id, ) .expect("resolve autonomous collaboration policy with corrupt art asset"); - assert_eq!(corrupt_resolution.policy.min_static_delegates, 3); + assert_eq!( + corrupt_resolution.policy.required_initial_wave, + SupervisorInitialCollaborationWave::Auto + ); + assert_eq!(corrupt_resolution.policy.min_static_delegates, 0); assert!(corrupt_resolution .policy .required_static_agent_ids - .iter() - .any(|agent_id| agent_id == "art-asset-plan")); + .is_empty()); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); } #[test] -fn supervisor_autonomous_legacy_visual_assets_do_not_skip_the_visual_delegate() { +fn supervisor_autonomous_legacy_visual_assets_do_not_add_hidden_delegate() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -2012,12 +2007,12 @@ fn supervisor_autonomous_legacy_visual_assets_do_not_skip_the_visual_delegate() run_id, ) .expect("resolve legacy visual collaboration policy"); - assert_eq!(resolution.policy.min_static_delegates, 3); - assert!(resolution - .policy - .required_static_agent_ids - .iter() - .any(|agent_id| agent_id == "art-director")); + assert_eq!( + resolution.policy.required_initial_wave, + SupervisorInitialCollaborationWave::Auto + ); + assert_eq!(resolution.policy.min_static_delegates, 0); + assert!(resolution.policy.required_static_agent_ids.is_empty()); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index a8c3ba393..d529116c8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -92,6 +92,7 @@ fn config_file_overrides_defaults_without_env() { assert_eq!(generator_llm.base_url, "https://generator.example.test/v1"); assert_eq!(generator_llm.model, "generator-model"); assert_eq!(generator_llm.api_kind, "openai_chat"); + assert_eq!(generator_llm.reasoning_effort, "high"); assert!(generator_llm.web_search_enabled); assert_eq!(config.editor_api.base_url, "http://127.0.0.1:8099"); assert_eq!(config.editor_api.api_key, "editor-key"); @@ -129,6 +130,199 @@ fn legacy_llm_config_deserialization_supplies_context_budget_defaults() { ); } +#[test] +fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() { + let expected = BTreeMap::from([ + ("project-supervisor", "high"), + ("planner", "high"), + ("orchestrator", "medium"), + ("generator", "high"), + ("evaluator", "high"), + ("design-director", "medium"), + ("design-foundation", "high"), + ("balance-director", "medium"), + ("balance-seed", "medium"), + ("art-director", "high"), + ("art-asset-plan", "high"), + ("art-polish", "medium"), + ("audio-director", "low"), + ("audio-asset-plan", "medium"), + ("code-director", "medium"), + ("code-prototype", "high"), + ("quality-review", "high"), + ("preview-readiness", "low"), + ("preview-playtest", "low"), + ("publish-strategy", "low"), + ("publish-package", "medium"), + ]); + let rust_defaults = GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS + .iter() + .copied() + .collect::>(); + assert_eq!(rust_defaults, expected); + assert_eq!( + GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS.len(), + rust_defaults.len(), + "规范 Agent 默认映射不能包含重复 ID" + ); + + let status_agent_ids = game_creator_llm_agent_status_definitions() + .into_iter() + .map(|definition| definition.agent_id) + .collect::>(); + assert_eq!( + status_agent_ids, + expected + .keys() + .map(|agent_id| (*agent_id).to_string()) + .collect::>(), + "新增规范 Agent 时必须先显式选择 reasoning effort,不能静默继承全局" + ); + for (agent_id, effort) in &expected { + assert_eq!( + game_creator_llm_agent_default_reasoning_effort(agent_id), + Some(*effort) + ); + parse_game_creator_llm_reasoning_effort(effort).expect("canonical reasoning effort"); + } + + let template = + serde_json::from_str::(DEFAULT_GAME_CREATOR_APP_CONFIG_JSON) + .expect("parse bundled runtime config template"); + assert_eq!( + template.llm.as_ref().and_then(|llm| llm.max_retries), + Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES) + ); + assert!( + template.agent_llm.unwrap_or_default().is_empty(), + "bundled template must not persist canonical defaults as explicit overrides" + ); + + let ui_source = include_str!("../../../src/features/runtime-config/RuntimeConfigDialog.tsx"); + let ui_mapping = ui_source + .split("const runtimeAgentReasoningEffortDefaults = {") + .nth(1) + .and_then(|source| source.split("} as const satisfies").next()) + .expect("frontend Agent reasoning effort contract") + .lines() + .filter_map(|line| { + let line = line.trim().trim_end_matches(','); + let (agent_id, effort) = line.split_once(": ")?; + Some(( + agent_id.trim_matches(&['\'', '"'][..]).to_string(), + effort.trim_matches(&['\'', '"'][..]).to_string(), + )) + }) + .collect::>(); + assert_eq!( + ui_mapping, + expected + .iter() + .map(|(agent_id, effort)| ((*agent_id).to_string(), (*effort).to_string())) + .collect::>() + ); +} + +#[test] +fn canonical_reasoning_only_patch_does_not_activate_role_llm_override() { + let mut config = GameCreatorAppConfig::default(); + for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS { + config.agent_llm.insert( + agent_id.to_string(), + GameCreatorLlmConfigFile { + reasoning_effort: Some(effort.to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + assert!( + !has_game_creator_agent_llm_override(&config, agent_id), + "canonical reasoning-only default must not activate {agent_id} role LLM" + ); + } + + config.agent_llm.insert( + "design-director".to_string(), + GameCreatorLlmConfigFile { + reasoning_effort: Some("high".to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + assert!(has_game_creator_agent_llm_override( + &config, + "design-director" + )); +} + +#[test] +fn empty_legacy_agent_llm_uses_agent_defaults_and_explicit_patch_wins() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + fs::write( + root.join(GAME_CREATOR_CONFIG_FILE_NAME), + r#"{ + "llm": { + "apiKey": "global-key", + "baseUrl": "https://global.example.test/v1", + "model": "global-model", + "reasoningEffort": "default" + }, + "agentLlm": {} +} +"#, + ) + .expect("write legacy empty Agent config"); + let _guard = use_test_runtime_config_dir(root.clone()); + + let config = load_game_creator_app_config().expect("load legacy empty Agent config"); + assert!(config.agent_llm.is_empty()); + for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS { + assert_eq!( + resolve_game_creator_llm_config_for_agent(&config, agent_id).reasoning_effort, + effort + ); + } + assert_eq!( + resolve_game_creator_llm_config_for_agent(&config, "non-canonical-agent").reasoning_effort, + "default" + ); + + let status = check_game_creator_llm_config_from_config(); + assert!(status.configured, "{:?}", status.error); + for (agent_id, effort) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS { + let agent = status + .agents + .iter() + .find(|agent| agent.agent_id == agent_id) + .expect("canonical Agent status"); + assert_eq!(agent.reasoning_effort, effort, "{agent_id}"); + } + + let mut overridden = config; + overridden.agent_llm.insert( + GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID.to_string(), + GameCreatorLlmConfigFile { + reasoning_effort: Some("medium".to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + for (agent_id, _) in GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS { + overridden.agent_llm.insert( + agent_id.to_string(), + GameCreatorLlmConfigFile { + reasoning_effort: Some("default".to_string()), + ..GameCreatorLlmConfigFile::default() + }, + ); + assert_eq!( + resolve_game_creator_llm_config_for_agent(&overridden, agent_id).reasoning_effort, + "default", + "显式 agentLlm.{agent_id} patch 必须覆盖规范默认值" + ); + } + + fs::remove_dir_all(root).ok(); +} + #[test] fn runtime_config_dir_supplies_app_config_file() { let root = unique_project_path(); @@ -576,6 +770,19 @@ fn llm_config_check_reports_per_agent_status_without_leaking_keys() { assert!(art.configured); assert_eq!(art.label, "美术组 / Asset"); assert_eq!(art.model.as_deref(), Some("art-model")); + assert_eq!(art.reasoning_effort, "high"); + let orchestrator = status + .agents + .iter() + .find(|agent| agent.agent_id == "orchestrator") + .expect("orchestrator status"); + assert_eq!(orchestrator.reasoning_effort, "medium"); + let preview = status + .agents + .iter() + .find(|agent| agent.agent_id == "preview-readiness") + .expect("preview status"); + assert_eq!(preview.reasoning_effort, "low"); let serialized = serde_json::to_string(&status).expect("status json"); assert!(!serialized.contains("planner-secret-key")); assert!(!serialized.contains("generator-secret-key")); @@ -639,6 +846,9 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { context_window_tokens: 128_000, auto_compact_token_limit: 64_000, tool_output_token_limit: 12_000, + request_timeout_ms: 180_000, + max_retries: 2, + retry_backoff_ms: 500, error: Some("Generator:缺少 API Key".to_string()), agents: vec![GameCreatorAgentLlmConfigStatus { agent_id: "generator".to_string(), @@ -654,6 +864,9 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { context_window_tokens: 96_000, auto_compact_token_limit: 48_000, tool_output_token_limit: 8_000, + request_timeout_ms: 90_000, + max_retries: 1, + retry_backoff_ms: 250, error: Some("LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key".to_string()), }], }; @@ -666,6 +879,8 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { assert!(lines.contains("llm.agent.generator.webSearchEnabled=false")); assert!(lines.contains("llm.reasoningEffort=high")); assert!(lines.contains("llm.agent.generator.reasoningEffort=medium")); + assert!(lines.contains("llm.maxRetries=2")); + assert!(lines.contains("llm.agent.generator.maxRetries=1")); assert!(lines.contains("llm.error=Generator:缺少 API Key")); assert!(!lines.contains("sk-")); assert!(!lines.contains("secret")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index 51c511cde..cd234fa8e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -1017,7 +1017,7 @@ async fn background_agent_runtime_can_search_patch_and_read_in_sequence() { assert!(plan_request.contains("startLine")); assert!(plan_request.contains("expectedReplacements")); assert!(plan_request.contains("\"max_output_tokens\":4000")); - assert!(plan_request.contains("\"reasoning\":{\"effort\":\"high\"}")); + assert!(plan_request.contains("\"reasoning\":{\"effort\":\"medium\"}")); let verification_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("verification llm request"); diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 2603b72de..3079ffa4d 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -26,6 +26,30 @@ import { type RuntimeMcpStructuredDraft, } from '../../app/types'; +const runtimeAgentReasoningEffortDefaults = { + 'project-supervisor': 'high', + planner: 'high', + orchestrator: 'medium', + generator: 'high', + evaluator: 'high', + 'design-director': 'medium', + 'design-foundation': 'high', + 'balance-director': 'medium', + 'balance-seed': 'medium', + 'art-director': 'high', + 'art-asset-plan': 'high', + 'art-polish': 'medium', + 'audio-director': 'low', + 'audio-asset-plan': 'medium', + 'code-director': 'medium', + 'code-prototype': 'high', + 'quality-review': 'high', + 'preview-readiness': 'low', + 'preview-playtest': 'low', + 'publish-strategy': 'low', + 'publish-package': 'medium', +} as const satisfies Record; + const defaultRuntimeConfigDraft: GameCreatorAppConfig = { llm: { apiKey: '', @@ -1068,6 +1092,10 @@ export function RuntimeConfigDialog({ {runtimeAgentLlmRows.map((agent) => { const agentLlm = runtimeConfigDraft.agentLlm?.[agent.id] ?? {}; + const defaultReasoningEffort = + runtimeAgentReasoningEffortDefaults[ + agent.id as keyof typeof runtimeAgentReasoningEffortDefaults + ]; return (