diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 74f901d6f..992c477c2 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -9,6 +9,7 @@ "dev-stack": "node scripts/start-dev-stack.mjs", "build": "npm --prefix ../.. exec tauri -- build", "llm-status": "node scripts/run-cli-with-config.mjs --llm-status", + "agent-task": "node scripts/run-cli-with-config.mjs --agent-task", "agent-run": "node scripts/run-cli-with-config.mjs --agent-run", "agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs", "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs" diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 60fd9a2ef..7f7e1d770 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -374,6 +374,15 @@ if ( ); } +if ( + packageConfig.scripts?.['agent-task'] !== + 'node scripts/run-cli-with-config.mjs --agent-task' +) { + throw new Error( + 'AI game creator shell agent-task must use client config before starting the single Agent runtime', + ); +} + if (tauriConfig.productName !== 'Genarrative AI Game Creator') { throw new Error('AI game creator shell productName drifted'); } @@ -677,6 +686,7 @@ for (const script of [ 'ai-game-creator-shell:dev', 'ai-game-creator-shell:dev-server', 'ai-game-creator-shell:build', + 'ai-game-creator-shell:agent-task', 'ai-game-creator-shell:typecheck', 'ai-game-creator-shell:agent-run:smoke', 'ai-game-creator-shell:check', diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 76beeef19..f30b7dd61 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1309,6 +1309,7 @@ dependencies = [ name = "genarrative-ai-game-creator-shell" version = "0.1.0" dependencies = [ + "libc", "platform-agent", "platform-llm", "reqwest 0.12.28", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index ef895cd29..591558eff 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -18,5 +18,8 @@ shared-contracts = { path = "../../../server-rs/crates/shared-contracts", defaul tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" tauri-plugin-opener = "2.5.4" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "time"] } zip = { version = "2", default-features = false, features = ["deflate"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 73d7c72a5..5ccb7a532 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -2105,6 +2105,26 @@ async fn run_game_creator_agent_background_task_with_context( } if plan.actions.is_empty() { + if let Some(blocker) = project_verification_completion_blocker(&observations) { + let blocker_summary = blocker.summary(); + runtime.current_action = "拒绝在验证未闭环时完成任务".to_string(); + runtime.waiting_on = "修复动作和通过的 project.verify".to_string(); + runtime.next_step = "根据验证诊断继续修复并重新执行 project.verify".to_string(); + runtime.observations.push(blocker_summary.clone()); + runtime.updated_at = unix_timestamp(); + let _ = write_game_creator_agent_runtime_state(&root, &runtime); + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + "observation", + runtime.status.as_str(), + runtime.phase.as_str(), + &blocker_summary, + blocker.detail.as_deref(), + ); + observations.push(blocker); + continue; + } converged = true; if !plan.response.trim().is_empty() { activate_agent_runtime_response_plan_step( @@ -2678,6 +2698,7 @@ const AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS: usize = 900; const AGENT_RUNTIME_TOOL_WRITE_MAX_CHARS: usize = 12_000; const AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS: u32 = 4_000; const AGENT_RUNTIME_FINAL_REPLY_MAX_OUTPUT_TOKENS: u32 = 2_400; +pub(crate) const AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS: usize = 2; const AGENT_RUNTIME_FILE_CONTEXT_MAX_CHARS: usize = 8_000; const AGENT_RUNTIME_FILE_READ_DEFAULT_LINES: usize = 120; const AGENT_RUNTIME_FILE_READ_MAX_LINES: usize = 240; @@ -2688,6 +2709,7 @@ const AGENT_RUNTIME_PROJECT_SEARCH_MAX_ENTRIES: usize = 5_000; const AGENT_RUNTIME_PROJECT_SEARCH_MAX_FILE_BYTES: u64 = 512 * 1024; const AGENT_RUNTIME_FILE_PATCH_MAX_FRAGMENT_BYTES: usize = 64 * 1024; const AGENT_RUNTIME_FILE_PATCH_MAX_FILE_BYTES: usize = 2 * 1024 * 1024; +const AGENT_RUNTIME_PROJECT_VERIFY_DEFAULT_TIMEOUT_SECONDS: usize = 120; pub(crate) const AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS: u64 = 300; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -2698,16 +2720,12 @@ enum AgentBackgroundTaskOutcome { } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct AgentRuntimeToolPlan { - #[serde(default)] - thinking_summary: String, - #[serde(default)] - plan: Vec, - #[serde(default)] - actions: Vec, - #[serde(default)] - response: String, +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AgentRuntimeToolPlan { + pub(crate) thinking_summary: String, + pub(crate) plan: Vec, + pub(crate) actions: Vec, + pub(crate) response: String, } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -2907,6 +2925,46 @@ impl AgentRuntimeToolObservation { } } +fn project_verification_completion_blocker( + observations: &[AgentRuntimeToolObservation], +) -> Option { + let latest_verification_index = observations + .iter() + .rposition(|observation| observation.tool == "project.verify")?; + let latest_verification = &observations[latest_verification_index]; + if latest_verification.status != "ok" { + return Some(AgentRuntimeToolObservation { + tool: "runtime.verification".to_string(), + status: "blocked".to_string(), + summary: "最新 project.verify 未通过,不能把任务标记为完成".to_string(), + detail: Some(format!( + "请根据验证诊断继续修复并再次执行 project.verify:{}", + latest_verification.summary + )), + }); + } + + let mutation_after_verification = observations + .iter() + .skip(latest_verification_index + 1) + .find(|observation| { + observation.status == "ok" + && matches!( + observation.tool.as_str(), + "file.write" | "file.patch" | "project.restore" + ) + }); + mutation_after_verification.map(|mutation| AgentRuntimeToolObservation { + tool: "runtime.verification".to_string(), + status: "blocked".to_string(), + summary: "最近一次 project.verify 之后项目又被修改,不能把任务标记为完成".to_string(), + detail: Some(format!( + "请在 {} 后重新执行 project.verify,并以新的验证结果收束。", + mutation.tool + )), + }) +} + #[derive(Clone, Debug, Eq, PartialEq)] enum AgentRuntimeToolPolicyBlock { Denied(String), @@ -3050,6 +3108,26 @@ fn agent_runtime_tool_action_input_summary( .and_then(|value| value.as_bool()) .unwrap_or(false) ), + "project.verify" => { + let expected_command = text(&["expectedCommand", "expected_command"]); + let command_chars = expected_command.chars().count(); + let command_head = expected_command.chars().take(48).collect::(); + let mut command_tail = expected_command.chars().rev().take(96).collect::>(); + command_tail.reverse(); + format!( + "script={} · expectedCommandSha256={:x} · expectedCommandChars={} · head={} · tail={} · timeoutSeconds={}", + text(&["script"]), + Sha256::digest(expected_command.as_bytes()), + command_chars, + command_head, + command_tail.into_iter().collect::(), + input + .get("timeoutSeconds") + .or_else(|| input.get("timeout_seconds")) + .and_then(|value| value.as_u64()) + .unwrap_or(AGENT_RUNTIME_PROJECT_VERIFY_DEFAULT_TIMEOUT_SECONDS as u64) + ) + } "file.list" => format!("path={}", relative_path(&["path"])), "file.read" => format!( "path={} · startLine={} · maxLines={}", @@ -3293,7 +3371,7 @@ async fn request_game_creator_agent_background_tool_plan_at( observations: &[AgentRuntimeToolObservation], loop_index: usize, ) -> Result { - let (llm, config_path, request) = build_game_creator_agent_background_tool_plan_request( + let (llm, config_path, mut request) = build_game_creator_agent_background_tool_plan_request( root, agent_id, session_id, @@ -3303,15 +3381,62 @@ async fn request_game_creator_agent_background_tool_plan_at( loop_index, )?; let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; - let response = request_game_creator_agent_llm_text_retrying_empty( - &client, - &llm, - request, - "后台 Agent 工具计划", - ) - .await - .map_err(|error| format!("{config_path} 后台 Agent 工具计划调用 LLM 失败:{error}"))?; - parse_game_creator_agent_tool_plan_response(response.text.as_str()) + for repair_attempt in 0..=AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS { + let operation = if repair_attempt == 0 { + "后台 Agent 工具计划".to_string() + } else { + format!( + "后台 Agent 工具计划格式修复 {repair_attempt}/{}", + AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS + ) + }; + let response = request_game_creator_agent_llm_text_retrying_recoverable( + &client, + &llm, + request.clone(), + operation.as_str(), + ) + .await + .map_err(|error| format!("{config_path} {operation}调用 LLM 失败:{error}"))?; + match parse_game_creator_agent_tool_plan_response(response.text.as_str()) { + Ok(plan) => return Ok(plan), + Err(error) if repair_attempt < AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS => { + if game_creator_agent_runtime_cancel_requested_for(root, agent_id, run_id) { + return Err("Agent 后台任务已收到取消请求".to_string()); + } + let next_attempt = repair_attempt + 1; + let response_preview = sanitize_agent_runtime_text(response.text.as_str(), 2_400); + let protocol_error = sanitize_agent_runtime_text(&error, 400); + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.tool_plan.repair", + "agentId": agent_id, + "sessionId": session_id, + "runId": run_id, + "loopIteration": loop_index, + "attempt": next_attempt, + "maxAttempts": AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS, + "protocolError": protocol_error, + "responsePreview": response_preview, + }), + )?; + request + .messages + .push(LlmMessage::assistant(response_preview)); + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n请修复格式,只返回一个完整 JSON object。不要解释,不要 markdown,不要代码围栏,也不要在 JSON 前后添加任何文本。" + ))); + } + Err(error) => { + return Err(format!( + "{error};已自动修复格式 {} 次仍失败", + AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS + )); + } + } + } + unreachable!("工具计划格式修复循环必须返回结果") } async fn request_game_creator_agent_background_final_reply_at( @@ -3333,7 +3458,7 @@ async fn request_game_creator_agent_background_final_reply_at( observations, )?; let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; - let response = request_game_creator_agent_llm_text_retrying_empty( + let response = request_game_creator_agent_llm_text_retrying_recoverable( &client, &llm, request, @@ -3369,7 +3494,7 @@ fn build_game_creator_agent_background_tool_plan_request( let tool_policy_json = serde_json::to_string_pretty(&tool_policy) .map_err(|error| format!("序列化 Agent 工具策略失败:{error}"))?; let prompt = format!( - "当前工具策略:\n{tool_policy_json}\n\n运行上下文如下。你正在执行后台 Agent loop 第 {loop_index} 轮。项目记忆、对话、资产和文件内容不会预加载,只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。请基于目标和已有工具观察修正计划,再决定是否调用最多 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个白名单工具。只输出 JSON 对象,不要 markdown。\n\n{context}\n\n后台任务:\n{task}\n\n已有工具观察:\n{observations_json}\n\nJSON schema:{{\"thinkingSummary\":\"一句话理解\",\"plan\":[\"步骤\"],\"actions\":[{{\"tool\":\"memory.read|memory.write|conversation.read|asset.list|project.index|project.search|project.checkpoint|project.restore|project.diff|file.list|file.read|file.write|file.patch|task.list|task.create|task.update|command.run_limited|preview.start|canvas.asset_generate|blackboard.write|agent.message|agent.delegate|agent.schedule_ready|agent.run_status\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}},其中 agent scope 只能写当前 Agent 自己的私有记忆,跨 Agent 共享请用 blackboard.write 或 agent.message;project.search 使用 {{\"query\":\"要查找的字面文本\",\"path\":\"可选项目内相对范围\",\"maxResults\":20,\"caseSensitive\":false}},返回 path:line 和匹配行;project.checkpoint input 可为空,用于在写文件或批量修改前创建本地 checkpoint;project.restore 使用 {{\"checkpointId\":\"checkpoint id\"}},用于在确认后把当前项目恢复到指定 checkpoint;project.diff 使用 {{\"checkpointId\":\"checkpoint id\"}},用于读取当前项目相对路径 diff 摘要;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}},按行读取并返回行号;file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}},匹配数不符时不写入,批量修改前应先调用 project.checkpoint;task.list input 可为空,用于读取 manifest 任务图、状态和 readyTaskIds;task.create 使用 {{\"taskId\":\"可选自定义 taskId\",\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[\"已有 taskId\"],\"artifacts\":[\"预期产物\"],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},用于把 Agent 拆出的新任务追加到 manifest;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;canvas.asset_generate 使用 {{\"prompt\":\"要生成的美术素材描述\"}},通过配置的 External Editor API 生成首版素材并登记到 assets;blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"}},用于把任务投递到另一个 Agent 的独立队列;agent.schedule_ready input 可为空或 {{\"limit\":1}},用于把 manifest 中依赖已完成的 ready task 投递到对应 Agent 后台队列;agent.run_status 使用 {{\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\"}},用于读取自己或其他 Agent 的 Runtime 状态摘要;如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。" + "当前工具策略:\n{tool_policy_json}\n\n运行上下文如下。你正在执行后台 Agent loop 第 {loop_index} 轮。项目记忆、对话、资产和文件内容不会预加载,只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。请基于目标和已有工具观察修正计划,再决定是否调用最多 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个白名单工具。只输出 JSON 对象,不要 markdown。\n\n{context}\n\n后台任务:\n{task}\n\n已有工具观察:\n{observations_json}\n\nJSON schema:{{\"thinkingSummary\":\"一句话理解\",\"plan\":[\"步骤\"],\"actions\":[{{\"tool\":\"memory.read|memory.write|conversation.read|asset.list|project.index|project.search|project.verify|project.checkpoint|project.restore|project.diff|file.list|file.read|file.write|file.patch|task.list|task.create|task.update|command.run_limited|preview.start|canvas.asset_generate|blackboard.write|agent.message|agent.delegate|agent.schedule_ready|agent.run_status\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}},其中 agent scope 只能写当前 Agent 自己的私有记忆,跨 Agent 共享请用 blackboard.write 或 agent.message;project.search 使用 {{\"query\":\"要查找的字面文本\",\"path\":\"可选项目内相对范围\",\"maxResults\":20,\"caseSensitive\":false}},返回 path:line 和匹配行;project.verify 使用 {{\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120}},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint input 可为空,用于在写文件或批量修改前创建本地 checkpoint;project.restore 使用 {{\"checkpointId\":\"checkpoint id\"}},用于在确认后把当前项目恢复到指定 checkpoint;project.diff 使用 {{\"checkpointId\":\"checkpoint id\"}},用于读取当前项目相对路径 diff 摘要;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}},按行读取并返回行号;file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}},匹配数不符时不写入,批量修改前应先调用 project.checkpoint;task.list input 可为空,用于读取 manifest 任务图、状态和 readyTaskIds;task.create 使用 {{\"taskId\":\"可选自定义 taskId\",\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[\"已有 taskId\"],\"artifacts\":[\"预期产物\"],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},用于把 Agent 拆出的新任务追加到 manifest;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;canvas.asset_generate 使用 {{\"prompt\":\"要生成的美术素材描述\"}},通过配置的 External Editor API 生成首版素材并登记到 assets;blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"}},用于把任务投递到另一个 Agent 的独立队列;agent.schedule_ready input 可为空或 {{\"limit\":1}},用于把 manifest 中依赖已完成的 ready task 投递到对应 Agent 后台队列;agent.run_status 使用 {{\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\"}},用于读取自己或其他 Agent 的 Runtime 状态摘要;如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。" ); let request = LlmRunRequest::new(vec![ LlmMessage::system(game_creator_agent_runtime_tool_plan_system_prompt()), @@ -3436,18 +3561,24 @@ fn build_game_creator_background_agent_context( Ok((llm, format!("agentLlm.{agent_id}"), context)) } -fn parse_game_creator_agent_tool_plan_response( +pub(crate) fn parse_game_creator_agent_tool_plan_response( content: &str, ) -> Result { let stripped = strip_llm_thinking_blocks(content); - let Some(payload) = extract_json_payload(stripped.as_str()) else { - return Ok(AgentRuntimeToolPlan { - response: truncate_agent_runtime_text(stripped.as_str(), 1_200), - ..AgentRuntimeToolPlan::default() - }); - }; + let payload = extract_json_payload(stripped.as_str()) + .ok_or_else(|| "Agent 工具计划协议错误:未返回完整 JSON 对象".to_string())?; let mut plan = serde_json::from_str::(payload) .map_err(|error| format!("解析 Agent 工具计划失败:{error}"))?; + if plan.thinking_summary.trim().is_empty() { + return Err("Agent 工具计划协议错误:thinkingSummary 不能为空".to_string()); + } + if plan + .actions + .iter() + .any(|action| action.tool.trim().is_empty()) + { + return Err("Agent 工具计划协议错误:action.tool 不能为空".to_string()); + } plan.thinking_summary = truncate_agent_runtime_text(&plan.thinking_summary, 240); plan.plan = plan .plan @@ -3456,11 +3587,6 @@ fn parse_game_creator_agent_tool_plan_response( .filter(|item| !item.trim().is_empty()) .take(5) .collect(); - plan.actions = plan - .actions - .into_iter() - .filter(|action| !action.tool.trim().is_empty()) - .collect(); plan.response = truncate_agent_runtime_text(&plan.response, 1_200); Ok(plan) } @@ -3515,6 +3641,17 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_action_i "asset.list" => observe_agent_runtime_assets(root), "project.index" => observe_agent_runtime_project_index(root), "project.search" => observe_agent_runtime_project_search(root, &action.input), + "project.verify" => { + observe_agent_runtime_project_verify( + root, + agent_id, + run_id, + action_id, + &action_fingerprint, + &action.input, + ) + .await + } "project.checkpoint" => observe_agent_runtime_project_checkpoint(root), "project.restore" => observe_agent_runtime_project_restore(root, &action.input), "project.diff" => observe_agent_runtime_project_diff(root, &action.input), @@ -3559,6 +3696,7 @@ fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str "asset.list" => Some("asset.list"), "project.index" => Some("project.index"), "project.search" => Some("file.read"), + "project.verify" => Some("project.verify"), "project.checkpoint" => Some("project.checkpoint"), "project.restore" => Some("project.restore"), "project.diff" => Some("project.diff"), @@ -3970,6 +4108,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "asset.list", "project.index", "project.search", + "project.verify", "project.checkpoint", "project.restore", "project.diff", @@ -5484,6 +5623,135 @@ fn observe_agent_runtime_limited_command( } } +async fn observe_agent_runtime_project_verify( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: Option<&str>, + action_fingerprint: &str, + input: &serde_json::Value, +) -> AgentRuntimeToolObservation { + let script = agent_runtime_tool_input_text(input, &["script"]); + let expected_command = input + .get("expectedCommand") + .or_else(|| input.get("expected_command")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let timeout_seconds = + agent_runtime_tool_input_usize(input, &["timeoutSeconds", "timeout_seconds"]) + .unwrap_or(AGENT_RUNTIME_PROJECT_VERIFY_DEFAULT_TIMEOUT_SECONDS); + if script.is_empty() { + return AgentRuntimeToolObservation { + tool: "project.verify".to_string(), + status: "failed".to_string(), + summary: "缺少 script".to_string(), + detail: None, + }; + } + if expected_command.trim().is_empty() { + return AgentRuntimeToolObservation { + tool: "project.verify".to_string(), + status: "failed".to_string(), + summary: "缺少 expectedCommand;请先读取 package.json 后再请求验证".to_string(), + detail: None, + }; + } + let Ok(timeout_seconds) = u64::try_from(timeout_seconds) else { + return AgentRuntimeToolObservation { + tool: "project.verify".to_string(), + status: "failed".to_string(), + summary: "timeoutSeconds 超出支持范围".to_string(), + detail: None, + }; + }; + let _lock = match acquire_project_write_lock(root, "project.verify") { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "project.verify".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + let result = run_project_verification_at( + root, + script.as_str(), + expected_command.as_str(), + timeout_seconds, + ) + .await + .and_then(|verification| { + let audit_output = truncate_agent_runtime_text_preserving_tail( + sanitize_prompt_context(&verification.output).as_str(), + 4_000, + ); + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.project.verify", + "agentId": agent_id, + "runId": run_id, + "actionId": action_id, + "actionFingerprint": action_fingerprint, + "commandId": verification.command_id, + "script": verification.script, + "expectedCommand": truncate_agent_runtime_text( + &sanitize_project_verification_output(&verification.expected_command), + 1_000, + ), + "packageManager": verification.package_manager, + "status": verification.status, + "exitCode": verification.exit_code, + "timedOut": verification.timed_out, + "durationMs": verification.duration_ms, + "logPath": verification.log_path, + "output": audit_output, + }), + ) + .map(|()| verification) + }); + match result { + Ok(verification) => { + let detail = redact_agent_runtime_project_paths_preserving_tail( + root, + &verification.output, + AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, + ); + if verification.status == "completed" { + AgentRuntimeToolObservation { + tool: "project.verify".to_string(), + status: "ok".to_string(), + summary: format!("{} 已通过", verification.script), + detail: Some(detail), + } + } else { + let reason = if verification.timed_out { + format!("{} 验证超时", verification.script) + } else if let Some(exit_code) = verification.exit_code { + format!("{} 验证失败,退出码 {exit_code}", verification.script) + } else { + format!("{} 验证启动失败", verification.script) + }; + AgentRuntimeToolObservation { + tool: "project.verify".to_string(), + status: "failed".to_string(), + summary: reason, + detail: Some(detail), + } + } + } + Err(error) => AgentRuntimeToolObservation { + tool: "project.verify".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }, + } +} + fn observe_agent_runtime_preview_start(root: &Path, agent_id: &str) -> AgentRuntimeToolObservation { let registry = game_creator_preview_registry(); let result = start_local_game_preview_at(root, ®istry).and_then(|preview| { @@ -7448,10 +7716,18 @@ fn remove_game_creator_agent_runtime_cancel_request(root: &Path, agent_id: &str, } fn game_creator_agent_runtime_cancel_requested(root: &Path, state: &AgentRuntimeState) -> bool { - if state.run_id.trim().is_empty() { + game_creator_agent_runtime_cancel_requested_for(root, &state.agent_id, &state.run_id) +} + +fn game_creator_agent_runtime_cancel_requested_for( + root: &Path, + agent_id: &str, + run_id: &str, +) -> bool { + if run_id.trim().is_empty() { return false; } - game_creator_agent_runtime_cancel_path(root, &state.agent_id, &state.run_id).exists() + game_creator_agent_runtime_cancel_path(root, agent_id, run_id).exists() } #[derive(Debug)] @@ -8489,6 +8765,28 @@ fn redact_agent_runtime_project_paths(root: &Path, value: &str, max_chars: usize sanitize_agent_runtime_text(&redacted, max_chars) } +fn redact_agent_runtime_project_paths_preserving_tail( + root: &Path, + value: &str, + max_chars: usize, +) -> String { + let mut redacted = value.to_string(); + let root_display = root.to_string_lossy(); + if !root_display.is_empty() { + redacted = redacted.replace(root_display.as_ref(), "$PROJECT_ROOT"); + } + if let Ok(canonical_root) = root.canonicalize() { + let canonical_display = canonical_root.to_string_lossy(); + if !canonical_display.is_empty() && canonical_display != root_display { + redacted = redacted.replace(canonical_display.as_ref(), "$PROJECT_ROOT"); + } + } + truncate_agent_runtime_text_preserving_tail( + sanitize_prompt_context(&redacted).trim(), + max_chars, + ) +} + fn render_agent_runtime_tool_names(values: &[String], limit: usize) -> String { if values.is_empty() { return "无".to_string(); @@ -8916,7 +9214,7 @@ pub(crate) fn game_creator_role_agent_chat_system_prompt() -> &'static str { } pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> &'static str { - "你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须在白名单工具内规划行动:先给一句 thinkingSummary,再给短计划,再决定是否请求工具。只能请求 memory.read、memory.write、conversation.read、asset.list、project.index、project.search、project.checkpoint、project.restore、project.diff、file.list、file.read、file.write、file.patch、task.list、task.create、task.update、command.run_limited、preview.start、canvas.asset_generate、blackboard.write、agent.message、agent.delegate、agent.schedule_ready、agent.run_status。处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;优先使用 file.patch 做精确局部修改,批量修改前创建 project.checkpoint,修改后再次读取或运行受限检查验证。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。只输出 JSON 对象,不要 markdown,不要泄露密钥。" + "你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须在白名单工具内规划行动:先给一句 thinkingSummary,再给短计划,再决定是否请求工具。只能请求 memory.read、memory.write、conversation.read、asset.list、project.index、project.search、project.verify、project.checkpoint、project.restore、project.diff、file.list、file.read、file.write、file.patch、task.list、task.create、task.update、command.run_limited、preview.start、canvas.asset_generate、blackboard.write、agent.message、agent.delegate、agent.schedule_ready、agent.run_status。处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;优先使用 file.patch 做精确局部修改,批量修改前创建 project.checkpoint,修改后再次读取验证。需要执行 package.json 中的 check、typecheck、test、lint 或 build 时,先读取 package.json,再把脚本名和读到的完整命令原样提交给 project.verify;不得猜测或改写 expectedCommand。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。只输出 JSON 对象,不要 markdown,不要泄露密钥。" } pub(crate) fn game_creator_agent_role_definition( @@ -9588,14 +9886,33 @@ pub(crate) async fn request_game_creator_llm_text( } } -async fn request_game_creator_agent_llm_text_retrying_empty( +pub(crate) fn is_game_creator_agent_llm_transient_error(error: &platform_llm::LlmError) -> bool { + match error { + platform_llm::LlmError::Timeout { .. } + | platform_llm::LlmError::Connectivity { .. } + | platform_llm::LlmError::Transport(_) => true, + platform_llm::LlmError::Upstream { status_code, .. } => { + matches!(*status_code, 408 | 429 | 500..=599) + } + platform_llm::LlmError::InvalidConfig(_) + | platform_llm::LlmError::InvalidRequest(_) + | platform_llm::LlmError::StreamUnavailable + | platform_llm::LlmError::EmptyResponse + | platform_llm::LlmError::Deserialize(_) => false, + } +} + +async fn request_game_creator_agent_llm_text_retrying_recoverable( client: &LlmClient, llm: &GameCreatorLlmConfig, request: LlmRunRequest, operation: &str, ) -> Result { const MAX_EMPTY_RETRIES: u32 = 3; + const MAX_TRANSIENT_RETRIES: u32 = 2; + const TRANSIENT_RETRY_BACKOFF_MS: u64 = 500; let mut empty_retries = 0u32; + let mut transient_retries = 0u32; loop { match request_game_creator_llm_text(client, llm, request.clone()).await { Ok(response) => return Ok(response), @@ -9605,6 +9922,19 @@ async fn request_game_creator_agent_llm_text_retrying_empty( "agent.runtime.llm.empty-response: {operation} 自动重试 {empty_retries}/{MAX_EMPTY_RETRIES}" ); } + Err(error) + if is_game_creator_agent_llm_transient_error(&error) + && transient_retries < MAX_TRANSIENT_RETRIES => + { + transient_retries += 1; + eprintln!( + "agent.runtime.llm.transient-error: {operation} 自动重试 {transient_retries}/{MAX_TRANSIENT_RETRIES}: {error}" + ); + tokio::time::sleep(Duration::from_millis( + TRANSIENT_RETRY_BACKOFF_MS.saturating_mul(u64::from(transient_retries)), + )) + .await; + } Err(error) => return Err(error), } } @@ -12476,11 +12806,34 @@ pub(crate) fn extract_json_payload(content: &str) -> Option<&str> { .map(str::trim) .unwrap_or(trimmed); let start = without_fence.find('{')?; - let end = without_fence.rfind('}')?; - if start > end { - return None; + let mut depth = 0usize; + let mut inside_string = false; + let mut escaped = false; + for (offset, character) in without_fence[start..].char_indices() { + if inside_string { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + inside_string = false; + } + continue; + } + match character { + '"' => inside_string = true, + '{' => depth += 1, + '}' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + let end = start + offset + character.len_utf8(); + return Some(&without_fence[start..end]); + } + } + _ => {} + } } - Some(&without_fence[start..=end]) + None } pub(crate) fn validate_llm_game_draft(prompt: &str, draft: &LlmGameDraft) -> Result<(), String> { 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 31f24d59f..91caaca05 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -8,6 +8,12 @@ pub(crate) enum CliCommand { agent_id: String, prompt: String, }, + AgentTask { + project_path: PathBuf, + agent_id: String, + task: String, + initialize: bool, + }, AgentRun { project_path: PathBuf, prompt: String, @@ -89,6 +95,37 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S prompt: prompt.to_string(), })); } + if args.first().map(String::as_str) == Some("--agent-task") { + let mut rest = args[1..].to_vec(); + let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { + rest.remove(index); + true + } else { + false + }; + let project_path = rest.first().map(String::as_str).ok_or_else(|| { + "用法:--agent-task [--init] <本地项目绝对路径> <任务>".to_string() + })?; + let agent_id = rest.get(1).map(String::as_str).ok_or_else(|| { + "用法:--agent-task [--init] <本地项目绝对路径> <任务>".to_string() + })?; + if rest.len() < 3 { + return Err( + "用法:--agent-task [--init] <本地项目绝对路径> <任务>".to_string(), + ); + } + let task = rest[2..].join(" "); + let task = task.trim(); + if task.is_empty() { + return Err("Agent 任务不能为空".to_string()); + } + return Ok(Some(CliCommand::AgentTask { + project_path: PathBuf::from(project_path), + agent_id: agent_id.trim().to_string(), + task: task.to_string(), + initialize, + })); + } if args.first().map(String::as_str) != Some("--agent-run") { return Ok(None); } @@ -151,6 +188,89 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { println!("replyText={}", reply.reply_text); Ok(()) } + CliCommand::AgentTask { + project_path, + agent_id, + task, + initialize, + } => { + if initialize && !project_path.join(".agent/manifest.json").is_file() { + let project_name = project_path + .file_name() + .and_then(|value| value.to_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("CLI Agent 项目"); + init_local_game_project_at( + &project_path, + &format!("cli-agent-{}", unix_millis()), + project_name, + )?; + } + if !project_path.join(".agent/manifest.json").is_file() { + return Err("项目尚未初始化;请先在 App 中创建项目,或显式传入 --init".to_string()); + } + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; + let run_id = format!("cli-{agent_id}-{}", unix_millis()); + let terminal = runtime.block_on(async { + let started = start_game_creator_agent_background_task_at( + &project_path, + &agent_id, + &task, + &run_id, + )?; + let canonical_run_id = started.state.run_id.clone(); + let deadline = std::time::Instant::now() + Duration::from_secs(600); + loop { + let current = read_game_creator_agent_runtime_at(&project_path, &agent_id)?; + if current.state.run_id == canonical_run_id + && (current.state.status == "idle" + || current.state.status == "failed" + || current.state.status == "waiting-for-confirmation") + { + break Ok::(current.state); + } + if std::time::Instant::now() >= deadline { + break Err(format!( + "等待单 Agent 任务超时:agentId={agent_id} runId={canonical_run_id}" + )); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + })?; + println!("agent.task.terminal"); + println!("projectPath={}", project_path.display()); + println!("agentId={agent_id}"); + println!("runId={}", terminal.run_id); + println!("status={}", terminal.status); + println!("phase={}", terminal.phase); + if let Some(reply) = terminal.last_response.as_deref() { + println!("replyText={reply}"); + } + if let Some(pending) = terminal.pending_tool_action.as_ref() { + println!("pendingActionId={}", pending.action_id); + println!("pendingTool={}", pending.tool); + if let Some(summary) = pending.input_summary.as_deref() { + println!("pendingInput={summary}"); + } + } + if let Some(error) = terminal.error.as_deref() { + println!("error={error}"); + } + if terminal.status == "idle" && terminal.phase == "completed" { + Ok(()) + } else if terminal.status == "waiting-for-confirmation" { + Err("单 Agent 任务正在等待开发者确认,请在开发窗口继续".to_string()) + } else { + Err(format!( + "单 Agent 任务未完成:{} / {}", + terminal.status, terminal.phase + )) + } + } CliCommand::AgentRun { project_path, prompt, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 2689706c5..1f8e47448 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -91,6 +91,10 @@ pub(crate) fn append_agent_db_record( } static PROJECT_APPEND_LOCKS: OnceLock>>>> = OnceLock::new(); +static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(1); +const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600; +const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; fn project_append_locks() -> &'static Mutex>>> { PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())) @@ -128,20 +132,75 @@ pub(crate) fn append_jsonl_line(path: &Path, line: &str, error_label: &str) -> R #[derive(Debug)] pub(crate) struct ProjectWriteLock { path: PathBuf, + content: String, } impl Drop for ProjectWriteLock { fn drop(&mut self) { - let _ = fs::remove_file(&self.path); + if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { + let _ = fs::remove_file(&self.path); + } } } +#[cfg(unix)] +fn project_write_lock_process_is_alive(process_id: u64) -> Option { + let process_id = i32::try_from(process_id).ok().filter(|value| *value > 0)?; + let result = unsafe { libc::kill(process_id, 0) }; + if result == 0 { + return Some(true); + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => Some(false), + Some(libc::EPERM) => Some(true), + _ => None, + } +} + +fn project_write_lock_age_seconds(path: &Path, metadata: &fs::Metadata) -> u64 { + let created_at = fs::read_to_string(path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .and_then(|payload| payload.get("createdAt").and_then(serde_json::Value::as_u64)); + if let Some(created_at) = created_at { + return unix_timestamp().saturating_sub(created_at); + } + metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or_default() +} + +fn project_write_lock_can_be_reclaimed(path: &Path) -> bool { + let Ok(metadata) = fs::symlink_metadata(path) else { + return false; + }; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES + { + return false; + } + let content = fs::read_to_string(path).ok(); + let owner_pid = content + .as_deref() + .and_then(|content| serde_json::from_str::(content).ok()) + .and_then(|payload| payload.get("pid").and_then(serde_json::Value::as_u64)); + #[cfg(unix)] + if let Some(owner_alive) = owner_pid.and_then(project_write_lock_process_is_alive) { + return !owner_alive; + } + project_write_lock_age_seconds(path, &metadata) > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS +} + pub(crate) fn acquire_project_write_lock( root: &Path, command_id: &str, ) -> Result { validate_project_root(root)?; - let path = root.join(PROJECT_WRITE_LOCK_PATH); + let path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建项目锁目录失败:{}: {error}", parent.display()))?; @@ -150,23 +209,44 @@ pub(crate) fn acquire_project_write_lock( "commandId": command_id, "pid": std::process::id(), "createdAt": unix_timestamp(), + "nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), }); let content = serde_json::to_string_pretty(&payload) .map_err(|error| format!("生成项目写锁失败:{error}"))?; - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&path) - { - Ok(mut file) => { - file.write_all(content.as_bytes()) - .map_err(|error| format!("写入项目写锁失败:{}: {error}", path.display()))?; - Ok(ProjectWriteLock { path }) + let mut retried_after_reclaim = false; + loop { + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + { + Ok(mut file) => { + if let Err(error) = file.write_all(content.as_bytes()) { + let _ = fs::remove_file(&path); + return Err(format!("写入项目写锁失败:{}: {error}", path.display())); + } + return Ok(ProjectWriteLock { + path, + content: content.clone(), + }); + } + Err(error) + if error.kind() == std::io::ErrorKind::AlreadyExists + && !retried_after_reclaim + && project_write_lock_can_be_reclaimed(&path) => + { + fs::remove_file(&path).map_err(|error| { + format!("清理失效项目写锁失败:{}: {error}", path.display()) + })?; + retried_after_reclaim = true; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(format!("项目正在被其他写操作占用:{}", path.display())); + } + Err(error) => { + return Err(format!("创建项目写锁失败:{}: {error}", path.display())); + } } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - Err(format!("项目正在被其他写操作占用:{}", path.display())) - } - Err(error) => Err(format!("创建项目写锁失败:{}: {error}", path.display())), } } @@ -1220,6 +1300,551 @@ pub(crate) fn run_limited_local_command_at( }) } +pub(crate) const PROJECT_VERIFICATION_OUTPUT_MAX_BYTES: usize = 24 * 1024; +const PROJECT_VERIFICATION_PACKAGE_MAX_BYTES: u64 = 512 * 1024; +const PROJECT_VERIFICATION_MIN_TIMEOUT_SECONDS: u64 = 1; +const PROJECT_VERIFICATION_MAX_TIMEOUT_SECONDS: u64 = 300; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectVerificationSpec { + pub(crate) script: String, + pub(crate) expected_command: String, + pub(crate) package_manager: String, + pub(crate) program: String, + pub(crate) arguments: Vec, + pub(crate) timeout_seconds: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectVerificationResult { + pub(crate) command_id: String, + pub(crate) script: String, + pub(crate) expected_command: String, + pub(crate) package_manager: String, + pub(crate) status: String, + pub(crate) exit_code: Option, + pub(crate) timed_out: bool, + pub(crate) duration_ms: u64, + pub(crate) output: String, + pub(crate) log_path: String, + pub(crate) updated_at: u64, +} + +#[derive(Debug)] +struct ProjectVerificationProcessResult { + exit_code: Option, + timed_out: bool, + output: String, +} + +#[derive(Debug)] +struct BoundedProcessBytes { + head: Vec, + tail: std::collections::VecDeque, + total: usize, + max_bytes: usize, +} + +impl BoundedProcessBytes { + fn new(max_bytes: usize) -> Self { + Self { + head: Vec::new(), + tail: std::collections::VecDeque::new(), + total: 0, + max_bytes, + } + } + + fn push(&mut self, chunk: &[u8]) { + self.total = self.total.saturating_add(chunk.len()); + let head_limit = self.max_bytes / 3; + let tail_limit = self.max_bytes.saturating_sub(head_limit); + let head_remaining = head_limit.saturating_sub(self.head.len()); + let head_len = head_remaining.min(chunk.len()); + self.head.extend_from_slice(&chunk[..head_len]); + self.tail.extend(&chunk[head_len..]); + while self.tail.len() > tail_limit { + self.tail.pop_front(); + } + } + + fn finish(self) -> String { + let tail = self.tail.into_iter().collect::>(); + if self.total <= self.max_bytes { + let mut bytes = self.head; + bytes.extend(tail); + return String::from_utf8_lossy(&bytes).into_owned(); + } + let omitted = self.total.saturating_sub(self.head.len() + tail.len()); + format!( + "{}\n...<{} output bytes omitted>...\n{}", + String::from_utf8_lossy(&self.head), + omitted, + String::from_utf8_lossy(&tail) + ) + } +} + +pub(crate) fn project_verification_npm_program() -> &'static str { + if cfg!(windows) { + "npm.cmd" + } else { + "npm" + } +} + +fn project_verification_script_allowed(script: &str) -> bool { + matches!(script, "check" | "typecheck" | "test" | "lint" | "build") +} + +fn project_verification_package_manager_at( + root: &Path, + package: &serde_json::Value, +) -> Result<&'static str, String> { + let declared = package + .get("packageManager") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if let Some(declared) = declared { + let manager = declared.split('@').next().unwrap_or(declared); + if manager != "npm" { + return Err(format!( + "project.verify 当前只支持 npm 项目,packageManager 声明为 {manager}" + )); + } + return Ok("npm"); + } + for (lock_file, manager) in [ + ("pnpm-lock.yaml", "pnpm"), + ("yarn.lock", "yarn"), + ("bun.lock", "bun"), + ("bun.lockb", "bun"), + ] { + if root.join(lock_file).exists() { + return Err(format!( + "project.verify 当前只支持 npm 项目,检测到 {manager} 锁文件 {lock_file}" + )); + } + } + Ok("npm") +} + +pub(crate) fn resolve_project_verification_spec_at( + root: &Path, + script: &str, + expected_command: &str, + timeout_seconds: u64, +) -> Result { + validate_project_root(root)?; + let script = script.trim(); + if !project_verification_script_allowed(script) { + return Err("project.verify 只允许 check、typecheck、test、lint、build 脚本".to_string()); + } + if expected_command.trim().is_empty() { + return Err("project.verify 缺少 expectedCommand".to_string()); + } + if expected_command.chars().count() > 2_000 + || expected_command + .chars() + .any(|character| matches!(character, '\n' | '\r')) + { + return Err("project.verify expectedCommand 必须是最多 2,000 字符的单行脚本".to_string()); + } + if !(PROJECT_VERIFICATION_MIN_TIMEOUT_SECONDS..=PROJECT_VERIFICATION_MAX_TIMEOUT_SECONDS) + .contains(&timeout_seconds) + { + return Err(format!( + "project.verify timeoutSeconds 必须在 {PROJECT_VERIFICATION_MIN_TIMEOUT_SECONDS}-{PROJECT_VERIFICATION_MAX_TIMEOUT_SECONDS} 之间" + )); + } + + let package_path = root.join("package.json"); + let metadata = fs::symlink_metadata(&package_path).map_err(|error| { + format!( + "读取 package.json 失败:{}: {error}", + package_path.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("project.verify 要求项目根 package.json 是普通文件".to_string()); + } + if metadata.len() > PROJECT_VERIFICATION_PACKAGE_MAX_BYTES { + return Err(format!( + "project.verify package.json 超过 {} 字节上限", + PROJECT_VERIFICATION_PACKAGE_MAX_BYTES + )); + } + let package_content = fs::read_to_string(&package_path).map_err(|error| { + format!( + "读取 package.json 失败:{}: {error}", + package_path.display() + ) + })?; + let package: serde_json::Value = serde_json::from_str(&package_content) + .map_err(|error| format!("解析 package.json 失败:{error}"))?; + let package_manager = project_verification_package_manager_at(root, &package)?; + let actual_command = package + .get("scripts") + .and_then(serde_json::Value::as_object) + .and_then(|scripts| scripts.get(script)) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("package.json 未定义 {script} 脚本"))?; + if actual_command != expected_command { + return Err(format!( + "package.json 中的 {script} 脚本已变化,请重新读取后再确认执行" + )); + } + + Ok(ProjectVerificationSpec { + script: script.to_string(), + expected_command: expected_command.to_string(), + package_manager: package_manager.to_string(), + program: project_verification_npm_program().to_string(), + arguments: vec![ + "run".to_string(), + "--silent".to_string(), + "--ignore-scripts".to_string(), + script.to_string(), + ], + timeout_seconds, + }) +} + +fn truncate_project_verification_output(value: &str) -> String { + if value.len() <= PROJECT_VERIFICATION_OUTPUT_MAX_BYTES { + return value.trim().to_string(); + } + let head_limit = PROJECT_VERIFICATION_OUTPUT_MAX_BYTES / 3; + let tail_limit = PROJECT_VERIFICATION_OUTPUT_MAX_BYTES - head_limit; + let mut head_end = head_limit.min(value.len()); + while head_end > 0 && !value.is_char_boundary(head_end) { + head_end -= 1; + } + let mut tail_start = value.len().saturating_sub(tail_limit); + while tail_start < value.len() && !value.is_char_boundary(tail_start) { + tail_start += 1; + } + let omitted = tail_start.saturating_sub(head_end); + format!( + "{}\n......\n{}", + &value[..head_end], + &value[tail_start..] + ) +} + +pub(crate) fn sanitize_project_verification_output(value: &str) -> String { + let printable = value + .chars() + .filter(|character| !character.is_control() || matches!(character, '\n' | '\t')) + .collect::(); + let sanitized = sanitize_prompt_context(&printable); + let mut output = String::with_capacity(sanitized.len()); + let bytes = sanitized.as_bytes(); + let mut index = 0; + while index < bytes.len() { + let prefix_len = if bytes[index..].starts_with(b"tnr_sk_") { + Some(7) + } else if bytes[index..].starts_with(b"sk-") { + Some(3) + } else { + None + }; + let Some(prefix_len) = prefix_len else { + let character = sanitized[index..] + .chars() + .next() + .expect("index stays on a char boundary"); + output.push(character); + index += character.len_utf8(); + continue; + }; + let mut end = index + prefix_len; + while end < bytes.len() + && (bytes[end].is_ascii_alphanumeric() || matches!(bytes[end], b'-' | b'_' | b'.')) + { + end += 1; + } + output.push_str("[redacted-secret]"); + index = end; + } + truncate_project_verification_output(&output) +} + +async fn read_bounded_project_process_output( + mut reader: R, + max_bytes: usize, +) -> Result +where + R: tokio::io::AsyncRead + Unpin, +{ + use tokio::io::AsyncReadExt; + + let mut output = BoundedProcessBytes::new(max_bytes); + let mut buffer = [0_u8; 4 * 1024]; + loop { + let read = reader + .read(&mut buffer) + .await + .map_err(|error| format!("读取 project.verify 子进程输出失败:{error}"))?; + if read == 0 { + break; + } + output.push(&buffer[..read]); + } + Ok(output.finish()) +} + +fn configure_project_verification_process_group(command: &mut tokio::process::Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.as_std_mut().process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + command + .as_std_mut() + .creation_flags(CREATE_NEW_PROCESS_GROUP); + } +} + +#[cfg(unix)] +fn terminate_project_verification_process_group(process_id: u32) { + // npm may exit while a script leaves non-detached descendants behind. + unsafe { + libc::kill(-(process_id as i32), libc::SIGKILL); + } +} + +async fn terminate_project_verification_process_tree(child: &mut tokio::process::Child) { + if let Some(process_id) = child.id() { + #[cfg(unix)] + terminate_project_verification_process_group(process_id); + #[cfg(windows)] + { + let _ = tokio::process::Command::new("taskkill") + .args(["/PID", &process_id.to_string(), "/T", "/F"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await; + } + } + let _ = child.kill().await; + let _ = child.wait().await; +} + +async fn collect_project_verification_output_task( + mut task: tokio::task::JoinHandle>, + stream_name: &str, +) -> Result { + match tokio::time::timeout(Duration::from_secs(2), &mut task).await { + Ok(result) => { + result.map_err(|error| format!("收集 project.verify {stream_name} 失败:{error}"))? + } + Err(_) => { + task.abort(); + Err(format!( + "project.verify {stream_name} 收集超时,验证结果不能判定为成功" + )) + } + } +} + +async fn run_project_verification_process( + root: &Path, + spec: &ProjectVerificationSpec, +) -> Result { + let isolated_home = resolve_local_project_path(root, ".agent/runtime/verify-home")?; + let isolated_tmp = resolve_local_project_path(root, ".agent/runtime/verify-tmp")?; + let isolated_cache = resolve_local_project_path(root, ".agent/runtime/npm-cache")?; + fs::create_dir_all(&isolated_home) + .and_then(|()| fs::create_dir_all(&isolated_tmp)) + .and_then(|()| fs::create_dir_all(&isolated_cache)) + .map_err(|error| format!("创建 project.verify 隔离目录失败:{error}"))?; + + let mut command = tokio::process::Command::new(&spec.program); + command + .args(&spec.arguments) + .current_dir(root) + .env_clear() + .env("CI", "1") + .env("NO_COLOR", "1") + .env("FORCE_COLOR", "0") + .env("HOME", &isolated_home) + .env("USERPROFILE", &isolated_home) + .env("TMPDIR", &isolated_tmp) + .env("TEMP", &isolated_tmp) + .env("TMP", &isolated_tmp) + .env("npm_config_audit", "false") + .env("npm_config_fund", "false") + .env("npm_config_ignore_scripts", "true") + .env("npm_config_update_notifier", "false") + .env("npm_config_cache", &isolated_cache) + .env( + "npm_config_userconfig", + isolated_home.join("empty-user.npmrc"), + ) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + if let Some(path) = std::env::var_os("PATH") { + command.env("PATH", path); + } + for name in ["SystemRoot", "ComSpec", "PATHEXT"] { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + configure_project_verification_process_group(&mut command); + + let mut child = command + .spawn() + .map_err(|error| format!("启动 {} 失败:{error}", spec.program))?; + let process_id = child.id(); + let stdout = child + .stdout + .take() + .ok_or_else(|| "读取 project.verify stdout 失败".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "读取 project.verify stderr 失败".to_string())?; + let stream_limit = PROJECT_VERIFICATION_OUTPUT_MAX_BYTES; + let stdout_task = tokio::spawn(read_bounded_project_process_output(stdout, stream_limit)); + let stderr_task = tokio::spawn(read_bounded_project_process_output(stderr, stream_limit)); + let timeout = Duration::from_secs(spec.timeout_seconds); + let wait = tokio::time::timeout(timeout, child.wait()).await; + let (exit_code, timed_out) = match wait { + Ok(Ok(status)) => { + #[cfg(unix)] + if let Some(process_id) = process_id { + terminate_project_verification_process_group(process_id); + } + (status.code(), false) + } + Ok(Err(error)) => { + terminate_project_verification_process_tree(&mut child).await; + stdout_task.abort(); + stderr_task.abort(); + return Err(format!("等待 project.verify 子进程失败:{error}")); + } + Err(_) => { + terminate_project_verification_process_tree(&mut child).await; + (None, true) + } + }; + let (stdout, stderr) = tokio::join!( + collect_project_verification_output_task(stdout_task, "stdout"), + collect_project_verification_output_task(stderr_task, "stderr"), + ); + let stdout = stdout?; + let stderr = stderr?; + let mut sections = Vec::new(); + if !stdout.trim().is_empty() { + sections.push(format!("stdout:\n{}", stdout.trim())); + } + if !stderr.trim().is_empty() { + sections.push(format!("stderr:\n{}", stderr.trim())); + } + if timed_out { + sections.push(format!( + "project.verify 在 {} 秒后超时,已终止进程树", + spec.timeout_seconds + )); + } else if let Some(exit_code) = exit_code.filter(|code| *code != 0) { + sections.push(format!("project.verify 退出码:{exit_code}")); + } + if sections.is_empty() { + sections.push("project.verify 未产生输出".to_string()); + } + Ok(ProjectVerificationProcessResult { + exit_code, + timed_out, + output: sanitize_project_verification_output(§ions.join("\n\n")), + }) +} + +pub(crate) async fn run_project_verification_at( + root: &Path, + script: &str, + expected_command: &str, + timeout_seconds: u64, +) -> Result { + let spec = + resolve_project_verification_spec_at(root, script, expected_command, timeout_seconds)?; + let started_at = std::time::Instant::now(); + let process = match run_project_verification_process(root, &spec).await { + Ok(process) => process, + Err(error) => ProjectVerificationProcessResult { + exit_code: None, + timed_out: false, + output: sanitize_project_verification_output(&error), + }, + }; + let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + let completed = !process.timed_out && process.exit_code == Some(0); + let status = if completed { "completed" } else { "failed" }; + let command_id = format!("project.verify.{}", spec.script); + let updated_at = unix_timestamp(); + let log_path = resolve_local_project_path(root, ".agent/logs/command.log")?; + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?; + } + let log_entry = format!( + "{updated_at} project.verify {} {status} manager={} exitCode={} timedOut={} durationMs={}\n{}\n", + spec.script, + spec.package_manager, + process + .exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "none".to_string()), + process.timed_out, + duration_ms, + process.output + ); + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .and_then(|mut file| file.write_all(log_entry.as_bytes())) + .map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display()))?; + record_command_run( + root, + GameCreationAppCommandRunState { + command_id: command_id.clone(), + status: if completed { + GameCreationAppCommandRunStatus::Completed + } else { + GameCreationAppCommandRunStatus::Failed + }, + output: process.output.clone(), + log_path: log_path.to_string_lossy().into_owned(), + updated_at, + }, + )?; + + Ok(ProjectVerificationResult { + command_id, + script: spec.script, + expected_command: spec.expected_command, + package_manager: spec.package_manager, + status: status.to_string(), + exit_code: process.exit_code, + timed_out: process.timed_out, + duration_ms, + output: process.output, + log_path: log_path.to_string_lossy().into_owned(), + updated_at, + }) +} + impl Default for ProjectPermissionPolicy { fn default() -> Self { Self { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index f223a03be..bc7ad0668 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -54,7 +54,7 @@ fn wait_for_agent_runtime_idle(root: &Path, agent_id: &str) -> AgentRuntimeState let mut runtime = read_game_creator_agent_runtime_at(root, agent_id) .expect("read runtime while waiting") .state; - for _ in 0..50 { + for _ in 0..250 { if runtime.status == "idle" { return runtime; } @@ -70,7 +70,7 @@ fn wait_for_agent_runtime_confirmation(root: &Path, agent_id: &str) -> AgentRunt let mut runtime = read_game_creator_agent_runtime_at(root, agent_id) .expect("read runtime while waiting for confirmation") .state; - for _ in 0..50 { + for _ in 0..250 { if runtime.status == "waiting-for-confirmation" { return runtime; } @@ -388,7 +388,9 @@ fn replace_test_local_config(path: &Path, content: impl AsRef<[u8]>) { } fn write_test_local_config(content: String) -> TestConfigGuard { - let lock = TEST_CONFIG_LOCK.lock().expect("test config lock"); + let lock = TEST_CONFIG_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let path = test_local_config_path(); let previous = fs::read(&path).ok(); replace_test_local_config(&path, content); @@ -1143,6 +1145,52 @@ fn spawn_mock_llm_server_responses(response_contents: Vec) -> String { spawn_mock_llm_server_responses_with_capture(response_contents, None) } +fn final_tool_plan_response(response: impl Into) -> String { + serde_json::json!({ + "thinkingSummary": "已有工具观察足够,可以收束后台任务", + "plan": [], + "actions": [], + "response": response.into(), + }) + .to_string() +} + +fn mock_http_request_total_bytes(request: &[u8]) -> Option { + let header_end = request + .windows(4) + .position(|window| window == b"\r\n\r\n")?; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .and_then(|(_, value)| value.trim().parse::().ok()) + .unwrap_or_default(); + Some(header_end + 4 + content_length) +} + +fn read_mock_http_request(stream: &mut std::net::TcpStream) -> String { + const MAX_REQUEST_BYTES: usize = 1024 * 1024; + let mut request = Vec::new(); + loop { + let mut chunk = [0_u8; 8192]; + let read = stream.read(&mut chunk).expect("mock llm request read"); + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + assert!( + request.len() <= MAX_REQUEST_BYTES, + "mock llm request exceeds test limit" + ); + if mock_http_request_total_bytes(&request).is_some_and(|expected| request.len() >= expected) + { + break; + } + } + String::from_utf8_lossy(&request).into_owned() +} + fn spawn_mock_llm_server_responses_with_capture( response_contents: Vec, request_sender: Option>, @@ -1152,13 +1200,10 @@ fn spawn_mock_llm_server_responses_with_capture( std::thread::spawn(move || { for response_content in response_contents { let (mut stream, _) = listener.accept().expect("mock llm accept"); - let mut request_buffer = [0_u8; 65_536]; - let read_len = stream.read(&mut request_buffer).unwrap_or(0); + let request_text = read_mock_http_request(&mut stream); if let Some(sender) = request_sender.as_ref() { - let _ = - sender.send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned()); + let _ = sender.send(request_text.clone()); } - let request_text = String::from_utf8_lossy(&request_buffer[..read_len]); let body = if request_text.contains("POST /responses HTTP/1.1") { serde_json::json!({ "id": "resp_game_creator_mock", @@ -2271,8 +2316,9 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() { let base_url = spawn_mock_llm_server_responses_with_capture( vec![ plan_json, - "我读到了项目笔记和黑板:核心循环应围绕月光食材收集,并先确认这条玩法闭环。" - .to_string(), + final_tool_plan_response( + "我读到了项目笔记和黑板:核心循环应围绕月光食材收集,并先确认这条玩法闭环。", + ), ], Some(sender), ); @@ -2466,7 +2512,7 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read runtime") .state; - for _ in 0..50 { + for _ in 0..250 { if runtime.status == "failed" { break; } @@ -2547,8 +2593,9 @@ async fn background_agent_runtime_can_replan_after_observation() { vec![ first_plan_json, second_plan_json, - "我先读了项目笔记,再根据观察补读了项目黑板:核心循环已有,后续美术要围绕月光围裙和暗影厨具推进。" - .to_string(), + final_tool_plan_response( + "我先读了项目笔记,再根据观察补读了项目黑板:核心循环已有,后续美术要围绕月光围裙和暗影厨具推进。", + ), ], Some(sender), ); @@ -3372,7 +3419,7 @@ async fn background_agent_runtime_loads_same_agent_continuity_through_tool_obser let base_url = spawn_mock_llm_server_responses_with_capture( vec![ first_plan_json, - "首轮完成:已经读取连续上下文笔记。".to_string(), + final_tool_plan_response("首轮完成:已经读取连续上下文笔记。"), art_plan_json, second_design_read_plan_json, second_design_final_plan_json, @@ -3536,7 +3583,7 @@ async fn background_agent_runtime_can_write_blackboard_and_message_other_agent() let base_url = spawn_mock_llm_server_responses_with_capture( vec![ plan_json, - "已把核心循环写入项目黑板,并给美术资产 Agent 留了后续消息。".to_string(), + final_tool_plan_response("已把核心循环写入项目黑板,并给美术资产 Agent 留了后续消息。"), ], Some(sender), ); @@ -3659,7 +3706,7 @@ async fn background_agent_runtime_can_delegate_task_to_other_agent() { let design_base_url = spawn_mock_llm_server_responses_with_capture( vec![ design_plan_json, - "已把角色规范图任务委派给美术 Agent。".to_string(), + final_tool_plan_response("已把角色规范图任务委派给美术 Agent。"), design_receipt_json, ], Some(design_sender), @@ -4793,7 +4840,7 @@ async fn background_agent_runtime_can_schedule_ready_tasks_from_tool() { let design_base_url = spawn_mock_llm_server_responses_with_capture( vec![ design_plan_json, - "已完成设计拆解,并调度下一个 ready 任务。".to_string(), + final_tool_plan_response("已完成设计拆解,并调度下一个 ready 任务。"), ], Some(design_sender), ); @@ -4812,7 +4859,10 @@ async fn background_agent_runtime_can_schedule_ready_tasks_from_tool() { }) .to_string(); let foundation_base_url = spawn_mock_llm_server_responses_with_capture( - vec![foundation_plan_json, "已完成玩法规格任务。".to_string()], + vec![ + foundation_plan_json, + final_tool_plan_response("已完成玩法规格任务。"), + ], Some(foundation_sender), ); let _config_guard = write_test_local_config(format!( @@ -5037,7 +5087,10 @@ async fn background_agent_runtime_can_write_memory_and_project_files() { }) .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture( - vec![plan_json, "记忆和项目说明都已经写好了。".to_string()], + vec![ + plan_json, + final_tool_plan_response("记忆和项目说明都已经写好了。"), + ], Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -5183,7 +5236,7 @@ async fn background_agent_runtime_blocks_cross_agent_private_memory_write() { let base_url = spawn_mock_llm_server_responses_with_capture( vec![ plan_json, - "我会改用黑板或定向消息共享给美术 Agent。".to_string(), + final_tool_plan_response("我会改用黑板或定向消息共享给美术 Agent。"), ], Some(sender), ); @@ -5451,7 +5504,7 @@ async fn background_agent_runtime_can_list_manifest_tasks() { let base_url = spawn_mock_llm_server_responses_with_capture( vec![ plan_json, - "任务图已经读取,下一步应围绕 readyTaskIds 推进。".to_string(), + final_tool_plan_response("任务图已经读取,下一步应围绕 readyTaskIds 推进。"), ], Some(sender), ); @@ -5651,7 +5704,10 @@ async fn background_agent_runtime_can_create_manifest_task() { }) .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture( - vec![plan_json, "已创建补充角色设定卡任务。".to_string()], + vec![ + plan_json, + final_tool_plan_response("已创建补充角色设定卡任务。"), + ], Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -5833,7 +5889,10 @@ async fn background_agent_runtime_can_update_manifest_task_status() { }) .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture( - vec![plan_json, "美术资产计划任务已经标记完成。".to_string()], + vec![ + plan_json, + final_tool_plan_response("美术资产计划任务已经标记完成。"), + ], Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -5997,7 +6056,10 @@ async fn background_agent_runtime_can_schedule_ready_manifest_tasks() { }) .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture( - vec![plan_json, "已完成 ready manifest 任务。".to_string()], + vec![ + plan_json, + final_tool_plan_response("已完成 ready manifest 任务。"), + ], Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -6102,7 +6164,10 @@ async fn background_agent_runtime_can_run_limited_static_smoke() { }) .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture( - vec![plan_json, "静态自检已经通过,可以进入预览。".to_string()], + vec![ + plan_json, + final_tool_plan_response("静态自检已经通过,可以进入预览。"), + ], Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -6244,6 +6309,468 @@ async fn background_agent_runtime_limited_command_respects_project_policy() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_can_verify_project_script() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let check_command = r#"node -e "process.stdout.write('PROJECT_VERIFY_OK')""#; + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "project-verify-fixture", + "private": true, + "packageManager": "npm@10.0.0", + "scripts": { "check": check_command } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow project verify"); + let (sender, receiver) = mpsc::channel(); + let plan_json = serde_json::json!({ + "thinkingSummary": "代码修改后必须运行项目检查", + "plan": ["运行 check", "根据结果回复"], + "actions": [{ + "tool": "project.verify", + "reason": "验证当前项目代码", + "input": { + "script": "check", + "expectedCommand": check_command, + "timeoutSeconds": 15 + } + }], + "response": "" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![plan_json, final_tool_plan_response("项目 check 已通过。")], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "code-key", + "baseUrl": {base_url:?}, + "model": "code-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "code-prototype", + "修改完成后运行真实项目检查", + "code-project-verify-run", + ) + .expect("start background task"); + + let plan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("plan llm request"); + assert!(plan_request.contains("project.verify")); + assert!(plan_request.contains("expectedCommand")); + let final_request = receiver + .recv_timeout(Duration::from_secs(10)) + .expect("final reply llm request"); + assert!(final_request.contains("project.verify")); + assert!(final_request.contains("PROJECT_VERIFY_OK")); + + let runtime = wait_for_agent_runtime_idle(&root, "code-prototype"); + assert_eq!(runtime.status, "idle"); + assert!(runtime + .observations + .iter() + .any(|item| item.contains("project.verify:ok · check 已通过"))); + let command_log = + fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); + assert!(command_log.contains("project.verify check")); + assert!(command_log.contains("PROJECT_VERIFY_OK")); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"recordType\":\"agent.runtime.project.verify\"")); + assert!(agent_db.contains("\"script\":\"check\"")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_project_verify_uses_independent_permission_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let check_command = r#"node -e "process.stdout.write('SHOULD_NOT_RUN')""#; + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "project-verify-policy-fixture", + "private": true, + "scripts": { "check": check_command } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["project.verify".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("require verification confirmation"); + let (sender, receiver) = mpsc::channel(); + let plan_json = serde_json::json!({ + "thinkingSummary": "尝试运行项目检查", + "plan": ["运行 check"], + "actions": [{ + "tool": "project.verify", + "reason": "测试确认策略", + "input": { + "script": "check", + "expectedCommand": check_command, + "timeoutSeconds": 15 + } + }], + "response": "" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan_json], Some(sender)); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "code-key", + "baseUrl": {base_url:?}, + "model": "code-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "code-prototype", + "等待确认后运行项目检查", + "code-project-verify-policy-run", + ) + .expect("start background task"); + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("plan llm request"); + let runtime = wait_for_agent_runtime_confirmation(&root, "code-prototype"); + assert_eq!(runtime.status, "waiting-for-confirmation"); + assert!(runtime.observations.iter().any(|item| item.contains( + "project.verify:waiting-for-confirmation · 项目权限策略要求用户确认:project.verify" + ))); + let pending = runtime.pending_tool_action.expect("pending verify action"); + assert_eq!(pending.tool, "project.verify"); + assert!(pending + .input_summary + .as_deref() + .is_some_and(|summary| summary.contains("script=check"))); + assert!(!root.join(".agent/logs/command.log").exists()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_confirms_project_verify_and_replans_with_output() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let check_command = r#"node -e "process.stdout.write('PROJECT_VERIFY_CONFIRMED')""#; + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "project-verify-confirm-fixture", + "private": true, + "scripts": { "check": check_command } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["project.verify".to_string(), "agent.resume".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("require verification confirmation"); + let (sender, receiver) = mpsc::channel(); + let verify_plan = serde_json::json!({ + "thinkingSummary": "修改完成后运行项目检查", + "plan": ["运行 check", "根据真实输出收束"], + "actions": [{ + "tool": "project.verify", + "reason": "确认当前修改通过项目检查", + "input": { + "script": "check", + "expectedCommand": check_command, + "timeoutSeconds": 15 + } + }], + "response": "" + }) + .to_string(); + let final_plan = serde_json::json!({ + "thinkingSummary": "项目检查已经通过", + "plan": [], + "actions": [], + "response": "已根据 project.verify 的真实输出确认 check 通过。" + }) + .to_string(); + let base_url = + spawn_mock_llm_server_responses_with_capture(vec![verify_plan, final_plan], Some(sender)); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "code-key", + "baseUrl": {base_url:?}, + "model": "code-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "code-prototype", + "运行项目检查并依据结果回复", + "code-project-verify-confirm-run", + ) + .expect("start background task"); + + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial plan request"); + let waiting_runtime = wait_for_agent_runtime_confirmation(&root, "code-prototype"); + let pending_action = waiting_runtime + .pending_tool_action + .as_ref() + .expect("pending project verify action"); + assert_eq!(pending_action.tool, "project.verify"); + assert!(!root.join(".agent/logs/command.log").exists()); + + let confirmed = confirm_game_creator_agent_runtime_task( + root.to_string_lossy().into_owned(), + "code-prototype".to_string(), + "code-project-verify-confirm-run".to_string(), + pending_action.action_id.clone(), + "允许执行项目 check".to_string(), + ) + .expect("confirm project verify"); + assert_eq!(confirmed.state.status, "running"); + + let continued_request = receiver + .recv_timeout(Duration::from_secs(4)) + .expect("continued plan request"); + assert!(continued_request.contains("PROJECT_VERIFY_CONFIRMED")); + let runtime = wait_for_agent_runtime_idle(&root, "code-prototype"); + assert_eq!(runtime.status, "idle"); + assert_eq!( + runtime.last_response.as_deref(), + Some("已根据 project.verify 的真实输出确认 check 通过。") + ); + assert!(runtime + .observations + .iter() + .any(|item| item.contains("project.verify:ok · check 已通过"))); + let command_log = + fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); + assert!(command_log.contains("PROJECT_VERIFY_CONFIRMED")); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"recordType\":\"agent.runtime.tool_confirmation.approved\"")); + assert!(agent_db.contains("\"recordType\":\"agent.runtime.project.verify\"")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_repairs_failed_verification_before_completing() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "验证恢复项目").expect("project init"); + fs::create_dir_all(root.join("src")).expect("create source dir"); + fs::create_dir_all(root.join("test")).expect("create test dir"); + fs::write(root.join("src/answer.mjs"), "export const answer = 1;\n") + .expect("write broken source"); + fs::write( + root.join("test/answer.test.mjs"), + concat!( + "import assert from 'node:assert/strict';\n", + "import { answer } from '../src/answer.mjs';\n", + "assert.equal(answer, 2, 'answer should be repaired');\n", + "console.log('PROJECT_VERIFY_RECOVERY_PASS');\n", + ), + ) + .expect("write verification test"); + let test_command = "node test/answer.test.mjs"; + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "project-verify-recovery-fixture", + "private": true, + "type": "module", + "scripts": { "test": test_command } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow recovery tools"); + + let failed_verify_plan = serde_json::json!({ + "thinkingSummary": "先复现项目测试失败", + "plan": ["运行 test", "修复源码", "重新验证"], + "actions": [{ + "tool": "project.verify", + "reason": "获取真实失败诊断", + "input": { + "script": "test", + "expectedCommand": test_command, + "timeoutSeconds": 15 + } + }], + "response": "" + }) + .to_string(); + let premature_completion = serde_json::json!({ + "thinkingSummary": "忽略失败并提前结束", + "plan": [], + "actions": [], + "response": "测试失败但我仍然声称任务完成。" + }) + .to_string(); + let patch_plan = serde_json::json!({ + "thinkingSummary": "Runtime 拒绝假完成,继续修复源码", + "plan": ["局部修复", "重新验证"], + "actions": [{ + "tool": "file.patch", + "reason": "修正错误值", + "input": { + "path": "src/answer.mjs", + "oldText": "export const answer = 1;\n", + "newText": "export const answer = 2;\n", + "expectedReplacements": 1 + } + }], + "response": "" + }) + .to_string(); + let passing_verify_plan = serde_json::json!({ + "thinkingSummary": "修复后重新执行同一测试", + "plan": ["运行 test"], + "actions": [{ + "tool": "project.verify", + "reason": "确认修复真实通过", + "input": { + "script": "test", + "expectedCommand": test_command, + "timeoutSeconds": 15 + } + }], + "response": "" + }) + .to_string(); + let final_plan = serde_json::json!({ + "thinkingSummary": "失败已修复且重新验证通过", + "plan": [], + "actions": [], + "response": "已修复 answer,并以 PROJECT_VERIFY_RECOVERY_PASS 完成验证。" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses(vec![ + failed_verify_plan, + premature_completion, + patch_plan, + passing_verify_plan, + final_plan, + ]); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "code-key", + "baseUrl": {base_url:?}, + "model": "code-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "code-prototype", + "修复失败测试并在验证通过后收束", + "code-project-verify-recovery-run", + ) + .expect("start recovery task"); + + let runtime = wait_for_agent_runtime_idle(&root, "code-prototype"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.loop_iteration, 5); + assert_eq!( + runtime.last_response.as_deref(), + Some("已修复 answer,并以 PROJECT_VERIFY_RECOVERY_PASS 完成验证。") + ); + assert_ne!( + runtime.last_response.as_deref(), + Some("测试失败但我仍然声称任务完成。") + ); + assert!(runtime.observations.iter().any(|item| item.contains( + "runtime.verification:blocked · 最新 project.verify 未通过,不能把任务标记为完成" + ))); + assert_eq!( + fs::read_to_string(root.join("src/answer.mjs")).expect("read repaired source"), + "export const answer = 2;\n" + ); + let command_log = + fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); + let failed_index = command_log + .find("project.verify test failed") + .expect("failed verification log"); + let completed_index = command_log + .find("project.verify test completed") + .expect("completed verification log"); + assert!(failed_index < completed_index); + assert!(command_log.contains("PROJECT_VERIFY_RECOVERY_PASS")); + let manifest: serde_json::Value = serde_json::from_str( + &fs::read_to_string(root.join(".agent/manifest.json")).expect("manifest"), + ) + .expect("parse manifest"); + let verification_statuses = manifest["commandRuns"] + .as_array() + .expect("command runs") + .iter() + .filter(|run| run["commandId"] == "project.verify.test") + .map(|run| run["status"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!(verification_statuses, vec!["failed", "completed"]); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_start_local_preview() { let root = unique_project_path(); @@ -6277,7 +6804,10 @@ async fn background_agent_runtime_can_start_local_preview() { }) .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture( - vec![plan_json, "本地预览已经启动,请打开链接试玩。".to_string()], + vec![ + plan_json, + final_tool_plan_response("本地预览已经启动,请打开链接试玩。"), + ], Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -6451,7 +6981,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { let llm_base_url = spawn_mock_llm_server_responses_with_capture( vec![ plan_json, - "首版主角素材已经生成并登记到本地项目。".to_string(), + final_tool_plan_response("首版主角素材已经生成并登记到本地项目。"), ], Some(sender), ); @@ -8270,7 +8800,7 @@ async fn background_agent_runtime_can_list_project_files() { let base_url = spawn_mock_llm_server_responses_with_capture( vec![ plan_json, - "我看到了 game 目录里的笔记和关卡文件。".to_string(), + final_tool_plan_response("我看到了 game 目录里的笔记和关卡文件。"), ], Some(sender), ); @@ -8785,7 +9315,10 @@ async fn background_agent_runtime_can_search_patch_and_read_in_sequence() { }) .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture( - vec![plan_json, "已完成搜索、精确补丁和回读验证。".to_string()], + vec![ + plan_json, + final_tool_plan_response("已完成搜索、精确补丁和回读验证。"), + ], Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -8923,6 +9456,151 @@ async fn background_agent_runtime_retries_empty_plan_and_final_responses() { fs::remove_dir_all(root).ok(); } +#[test] +fn agent_llm_transient_error_classification_matches_retry_contract() { + let transient_errors = [ + platform_llm::LlmError::Timeout { attempts: 1 }, + platform_llm::LlmError::Connectivity { + attempts: 1, + message: "connection reset".to_string(), + }, + platform_llm::LlmError::Transport("TLS bad record mac".to_string()), + ]; + for error in &transient_errors { + assert!( + is_game_creator_agent_llm_transient_error(error), + "expected transient error: {error:?}" + ); + } + + for status_code in [408_u16, 429].into_iter().chain(500..=599) { + let error = platform_llm::LlmError::Upstream { + status_code, + message: "retryable upstream status".to_string(), + }; + assert!( + is_game_creator_agent_llm_transient_error(&error), + "expected transient upstream status: {status_code}" + ); + } + + let permanent_errors = [ + platform_llm::LlmError::InvalidConfig("bad config".to_string()), + platform_llm::LlmError::InvalidRequest("bad request".to_string()), + platform_llm::LlmError::Deserialize("bad response json".to_string()), + platform_llm::LlmError::StreamUnavailable, + platform_llm::LlmError::EmptyResponse, + ]; + for error in &permanent_errors { + assert!( + !is_game_creator_agent_llm_transient_error(error), + "expected permanent error: {error:?}" + ); + } + + for status_code in (400_u16..=499).filter(|status| !matches!(*status, 408 | 429)) { + let error = platform_llm::LlmError::Upstream { + status_code, + message: "non-retryable upstream status".to_string(), + }; + assert!( + !is_game_creator_agent_llm_transient_error(&error), + "expected permanent upstream status: {status_code}" + ); + } +} + +#[tokio::test] +async fn background_agent_runtime_repairs_malformed_tool_plan_in_same_run() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let malformed_plan = r#"{"thinkingSummary":"格式损坏","plan":[}"#.to_string(); + let repaired_plan = serde_json::json!({ + "thinkingSummary": "已按协议修复工具计划", + "plan": ["直接回复开发者"], + "actions": [], + "response": "工具计划格式已自动修复。TOOL_PLAN_REPAIR_OK" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![malformed_plan.clone(), repaired_plan], + Some(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_responses" + }} + }} +}}"# + )); + let run_id = "design-malformed-tool-plan-repair-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证 malformed 工具计划可在同一 run 自动修复", + run_id, + ) + .expect("start background task"); + + let initial_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial tool plan request"); + assert!(initial_request.contains(run_id)); + assert!(!initial_request.contains("上一条输出不符合工具计划协议")); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("tool plan repair request"); + assert!(repair_request.contains(run_id)); + assert!(repair_request.contains("\"role\":\"assistant\"")); + assert!(repair_request.contains("格式损坏")); + assert!(repair_request.contains("上一条输出不符合工具计划协议")); + assert!(repair_request.contains("只返回一个完整 JSON object")); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.loop_iteration, 1); + assert_eq!( + runtime.last_response.as_deref(), + Some("工具计划格式已自动修复。TOOL_PLAN_REPAIR_OK") + ); + assert!(runtime.error.is_none()); + + let records = read_agent_db_records_for_test(&root); + let repair_records = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(repair_records.len(), 1); + let repair_record = repair_records[0]; + assert_eq!(repair_record["agentId"], "design-director"); + assert_eq!(repair_record["attempt"], 1); + assert_eq!( + repair_record["maxAttempts"], + AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS + ); + assert!(repair_record["protocolError"] + .as_str() + .is_some_and(|error| error.contains("解析 Agent 工具计划失败"))); + assert_eq!(repair_record["responsePreview"], malformed_plan); + assert!(!records.iter().any(|record| { + record["recordType"] == "agent.runtime.background_task.failed" && record["runId"] == run_id + })); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_preserves_long_task_tail_through_confirmation() { let root = unique_project_path(); @@ -9055,7 +9733,10 @@ async fn background_agent_runtime_can_create_checkpoint_before_file_write() { }) .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture( - vec![plan_json, "checkpoint 已创建,文件也已更新。".to_string()], + vec![ + plan_json, + final_tool_plan_response("checkpoint 已创建,文件也已更新。"), + ], Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -9245,7 +9926,10 @@ async fn background_agent_runtime_can_diff_checkpoint() { }) .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture( - vec![plan_json, "checkpoint 差异已经确认。".to_string()], + vec![ + plan_json, + final_tool_plan_response("checkpoint 差异已经确认。"), + ], Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -9421,7 +10105,7 @@ async fn background_agent_runtime_can_restore_checkpoint() { }) .to_string(); let base_url = spawn_mock_llm_server_responses_with_capture( - vec![plan_json, "checkpoint 已恢复。".to_string()], + vec![plan_json, final_tool_plan_response("checkpoint 已恢复。")], Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -9623,7 +10307,7 @@ async fn background_agent_runtime_can_read_other_agent_status() { let base_url = spawn_mock_llm_server_responses_with_capture( vec![ plan_json, - "美术 Agent 正在生成规范图,我会先等待结果。".to_string(), + final_tool_plan_response("美术 Agent 正在生成规范图,我会先等待结果。"), ], Some(sender), ); @@ -10606,13 +11290,13 @@ async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies( let barrier = Arc::new((StdMutex::new(0_usize), Condvar::new())); let (sender, receiver) = mpsc::channel(); let art_base_url = spawn_barrier_mock_llm_server( - "美术后台任务完成:先给主角规范图。".to_string(), + final_tool_plan_response("美术后台任务完成:先给主角规范图。"), barrier.clone(), 2, sender.clone(), ); let design_base_url = spawn_barrier_mock_llm_server( - "策划后台任务完成:先收敛核心循环。".to_string(), + final_tool_plan_response("策划后台任务完成:先收敛核心循环。"), barrier, 2, sender, @@ -12150,6 +12834,51 @@ fn strip_llm_thinking_blocks_removes_reasoning_wrappers() { ); } +#[test] +fn agent_tool_plan_parser_uses_first_complete_json_object_before_explanation() { + let thinking_summary = r#"先解析字符串里的 {大括号}、引号 "quoted" 和反斜杠 \path"#; + let plan_json = serde_json::json!({ + "thinkingSummary": thinking_summary, + "plan": ["回复开发者"], + "actions": [], + "response": "首个对象解析成功" + }) + .to_string(); + let content = + format!("{plan_json}\n补充解释:后面的示例对象 {{\"ignored\":true}} 不属于工具计划。"); + + let plan = parse_game_creator_agent_tool_plan_response(&content) + .expect("trailing explanation must not cause a trailing characters error"); + + assert_eq!(plan.thinking_summary, thinking_summary); + assert_eq!(plan.plan, vec!["回复开发者"]); + assert!(plan.actions.is_empty()); + assert_eq!(plan.response, "首个对象解析成功"); +} + +#[test] +fn agent_tool_plan_parser_rejects_protocol_violations() { + let invalid_plans = [ + "{}".to_string(), + serde_json::json!({ + "thinkingSummary": "包含未知字段", + "plan": ["回复开发者"], + "actions": [], + "response": "不应接受", + "unknownField": true, + }) + .to_string(), + "这不是 JSON 工具计划".to_string(), + ]; + + for content in invalid_plans { + assert!( + parse_game_creator_agent_tool_plan_response(&content).is_err(), + "invalid tool plan should be rejected: {content}" + ); + } +} + #[test] fn validate_llm_game_draft_requires_playable_states() { let mut draft = fake_llm_game_draft(); @@ -14305,6 +15034,53 @@ fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() { fs::remove_dir_all(root).ok(); } +#[cfg(unix)] +#[test] +fn project_write_lock_reclaims_dead_process_owner() { + let root = unique_project_path(); + fs::create_dir_all(root.join(".agent")).expect("create agent dir"); + fs::write( + root.join(PROJECT_WRITE_LOCK_PATH), + serde_json::json!({ + "commandId": "project.verify", + "pid": i32::MAX as u64 - 1, + "createdAt": unix_timestamp(), + "nonce": 1, + }) + .to_string(), + ) + .expect("write dead owner lock"); + + let lock = acquire_project_write_lock(&root, "file.patch").expect("reclaim dead owner lock"); + let lock_content = + fs::read_to_string(root.join(PROJECT_WRITE_LOCK_PATH)).expect("read replacement lock"); + assert!(lock_content.contains(&std::process::id().to_string())); + drop(lock); + assert!(!root.join(PROJECT_WRITE_LOCK_PATH).exists()); + + fs::remove_dir_all(root).ok(); +} + +#[cfg(unix)] +#[test] +fn project_write_lock_rejects_symlinked_agent_directory() { + use std::os::unix::fs::symlink; + + let root = unique_project_path(); + let outside = unique_project_path(); + fs::create_dir_all(&root).expect("create project root"); + fs::create_dir_all(&outside).expect("create outside dir"); + symlink(&outside, root.join(".agent")).expect("symlink agent dir"); + + let error = acquire_project_write_lock(&root, "project.verify") + .expect_err("symlinked control directory should be rejected"); + assert!(error.contains("符号链接")); + assert!(!outside.join("project.lock").exists()); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(outside).ok(); +} + #[test] fn project_permission_policy_can_deny_mutating_commands() { let root = unique_project_path(); @@ -14556,6 +15332,170 @@ fn limited_local_command_runs_static_game_smoke_and_writes_log() { fs::remove_dir_all(root).ok(); } +#[test] +fn project_verification_resolves_npm_script_and_rejects_command_drift() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "代码验证项目").expect("project init"); + let check_command = "node scripts/check.mjs"; + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "verify-contract-fixture", + "private": true, + "packageManager": "npm@10.0.0", + "scripts": { + "check": check_command, + "deploy": "node scripts/deploy.mjs" + } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + + let spec = resolve_project_verification_spec_at(&root, "check", check_command, 30) + .expect("resolve check script"); + assert_eq!(spec.script, "check"); + assert_eq!(spec.expected_command, check_command); + assert_eq!(spec.program, project_verification_npm_program()); + assert_eq!( + spec.arguments, + vec!["run", "--silent", "--ignore-scripts", "check"] + ); + assert_eq!(spec.timeout_seconds, 30); + + let drift = resolve_project_verification_spec_at(&root, "check", "node stale.mjs", 30) + .expect_err("changed package script should fail closed"); + assert!(drift.contains("package.json 中的 check 脚本已变化")); + let unsupported = + resolve_project_verification_spec_at(&root, "deploy", "node scripts/deploy.mjs", 30) + .expect_err("deploy is outside verification allowlist"); + assert!(unsupported.contains("只允许 check、typecheck、test、lint、build")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_verification_output_redacts_secrets_and_keeps_failure_tail() { + let secret = concat!("sk-", "1234567890abcdefghijkl"); + let output = format!( + "head\n{}\nprovider-error({secret});\nVERIFY_FAILURE_TAIL", + "x".repeat(PROJECT_VERIFICATION_OUTPUT_MAX_BYTES * 2), + ); + let sanitized = sanitize_project_verification_output(&output); + + assert!(sanitized.len() <= PROJECT_VERIFICATION_OUTPUT_MAX_BYTES + 256); + assert!(sanitized.contains(" require('fs').writeFileSync('verify-background-leak.txt', 'leaked'), 1200)", +], { stdio: 'ignore' }); +child.unref(); +"#, + ) + .expect("write background process fixture"); + let check_command = "node spawn-background.cjs"; + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "verify-process-group-fixture", + "private": true, + "scripts": { "check": check_command } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + + let result = run_project_verification_at(&root, "check", check_command, 15) + .await + .expect("run check script"); + assert_eq!(result.status, "completed"); + std::thread::sleep(Duration::from_millis(1_500)); + assert!(!root.join("verify-background-leak.txt").exists()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn project_verification_runs_without_prepost_and_records_failure_and_timeout() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "代码验证项目").expect("project init"); + let check_command = r#"node -e "process.stdout.write('VERIFY_PROCESS_OK')""#; + let precheck_command = + r#"node -e "require('fs').writeFileSync('precheck-ran.txt','unexpected')""#; + let test_command = r#"node -e "console.error('VERIFY_EXIT_7');process.exit(7)""#; + let lint_command = r#"node -e "const {spawn}=require('child_process');spawn(process.execPath,['-e','setTimeout(()=>{},5000)'],{stdio:'inherit'});setTimeout(()=>{},5000)""#; + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "verify-process-fixture", + "private": true, + "scripts": { + "precheck": precheck_command, + "check": check_command, + "test": test_command, + "lint": lint_command + } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + + let completed = run_project_verification_at(&root, "check", check_command, 15) + .await + .expect("run check script"); + assert_eq!(completed.status, "completed"); + assert_eq!(completed.exit_code, Some(0)); + assert!(!completed.timed_out); + assert!(completed.output.contains("VERIFY_PROCESS_OK")); + assert!(!root.join("precheck-ran.txt").exists()); + + let failed = run_project_verification_at(&root, "test", test_command, 15) + .await + .expect("record failed test script"); + assert_eq!(failed.status, "failed"); + assert_eq!(failed.exit_code, Some(7)); + assert!(!failed.timed_out); + assert!(failed.output.contains("VERIFY_EXIT_7")); + + let timed_out = run_project_verification_at(&root, "lint", lint_command, 1) + .await + .expect("record timed out lint script"); + assert_eq!(timed_out.status, "failed"); + assert!(timed_out.timed_out); + assert!(timed_out.output.contains("1 秒后超时")); + + let log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); + assert!(log.contains("project.verify check completed")); + assert!(log.contains("project.verify test failed")); + assert!(log.contains("project.verify lint failed")); + let manifest: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) + .expect("manifest json"); + assert!(manifest["commandRuns"] + .as_array() + .is_some_and(|runs| runs.iter().any(|run| { + run["commandId"] == "project.verify.check" && run["status"] == "completed" + }))); + assert!(manifest["commandRuns"].as_array().is_some_and(|runs| runs + .iter() + .any(|run| { run["commandId"] == "project.verify.test" && run["status"] == "failed" }))); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_permission_log_appends_to_command_log() { let root = unique_project_path(); @@ -15477,6 +16417,25 @@ fn cli_agent_run_requires_project_and_prompt() { prompt: "我要生成一个开罗风格的 dota".to_string(), } ); + let agent_task = parse_cli_command(&[ + "--agent-task".to_string(), + "--init".to_string(), + "/tmp/genarrative-cli-game".to_string(), + "code-prototype".to_string(), + "修复失败测试".to_string(), + "并完成验证".to_string(), + ]) + .expect("parse agent task") + .expect("agent task command"); + assert_eq!( + agent_task, + CliCommand::AgentTask { + project_path: PathBuf::from("/tmp/genarrative-cli-game"), + agent_id: "code-prototype".to_string(), + task: "修复失败测试 并完成验证".to_string(), + initialize: true, + } + ); let no_wait = parse_cli_command(&[ "--agent-run".to_string(), "--no-wait".to_string(), @@ -15496,6 +16455,71 @@ fn cli_agent_run_requires_project_and_prompt() { assert!(parse_cli_command(&[]).expect("parse no cli").is_none()); assert!(parse_cli_command(&["--agent-run".to_string()]).is_err()); assert!(parse_cli_command(&["--agent-chat".to_string()]).is_err()); + assert!(parse_cli_command(&["--agent-task".to_string()]).is_err()); +} + +#[test] +fn cli_agent_task_drives_existing_runtime_to_completion() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "CLI 单 Agent 项目").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow cli task"); + let final_plan = serde_json::json!({ + "thinkingSummary": "CLI 已进入同一 Runtime", + "plan": [], + "actions": [], + "response": "CLI 单 Agent 任务已完成。" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses(vec![final_plan]); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "code-key", + "baseUrl": {base_url:?}, + "model": "code-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + run_cli_command(CliCommand::AgentTask { + project_path: root.clone(), + agent_id: "code-prototype".to_string(), + task: "通过 CLI 完成单 Agent 任务".to_string(), + initialize: false, + }) + .expect("run cli agent task"); + + let runtime = read_game_creator_agent_runtime_at(&root, "code-prototype") + .expect("read cli runtime") + .state; + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!( + runtime.last_response.as_deref(), + Some("CLI 单 Agent 任务已完成。") + ); + let conversation = + read_local_conversation_at(&root, Some("code-prototype")).expect("read cli conversation"); + assert!(conversation + .messages + .iter() + .any(|message| message.role == "user" && message.content == "通过 CLI 完成单 Agent 任务")); + assert!(conversation.messages.iter().any(|message| { + message.role == "assistant" && message.content == "CLI 单 Agent 任务已完成。" + })); + + fs::remove_dir_all(root).ok(); } #[test] diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9877c7a9e..9208de6f7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -23,10 +23,14 @@ - 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`;开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都只把该事件作为实时 UI 通知并复用前端 runtime 归一化合并,事实源仍是 `.agent/runtime/agents`、`events` 和 `tasks` 文件。 - 2026-07-11 补充:开发单 Agent 聊天页保留整页纵向滚动,聊天消息区固定响应式高度并在内部滚动;Runtime 恢复确认区使用独立布局行,避免与 Runtime 详情或聊天内容重叠。Runtime 面板详情可折叠且折叠时不渲染详情 DOM,但状态标题与任务控制按钮继续保留;等待 LLM 时在消息区显示动态状态,连续流式 delta 合并到动画帧更新并跳过重复 Runtime state。OpenAI Chat SSE 会收集 usage-only 尾包、保留 finish reason 与上游 error message,收到 `[DONE]` 后立即结束;持久事件订阅失败时显示非致命错误,聊天事件监听不可用或首个文本片段前流式失败时降级普通回复并继续落盘。 - 2026-07-11 补充:为缩小单 Agent 与 Codex CLI 在代码任务上的差距,Runtime 工具箱新增 `project.search` 和 `file.patch`,并扩展 `file.read` 的按行分页。`project.search` 在项目内执行有界字面量检索,默认忽略大小写,返回相对路径、行号和匹配行,跳过 `.agent`、敏感配置、依赖和构建目录;权限继承 `file.read`。`file.read` 接受 `startLine / maxLines`,返回带行号的最多 240 行、8,000 字符上下文,允许 Agent 继续分页而不是只看到文件开头约 900 字符。`file.patch` 只做 `oldText -> newText` 精确替换,必须声明预期匹配数,匹配数不符时不写入;它继承 `file.write` 权限,复用项目写锁和 Runtime 动作账本,并追加不含代码正文的 `agent.runtime.file.patch` 审计记录。三者组成“搜索定位 -> 分段读取 -> 局部修改 -> 再次读取验证”的最小代码工作闭环,不开放任意 shell。 -- 2026-07-11 补充:后台 Agent 的工具规划和最终回复请求对 `LlmError::EmptyResponse` 最多自动重试 3 次(含首次共 4 次请求),与既有 Generator 对上游 HTTP 成功但空 content 的恢复策略一致;连接、协议、鉴权、解析等其他错误不在此处重试,仍按原错误路径失败并落 Runtime 事件。该重试不会重复执行工具动作,只会原样重发尚未得到有效文本的 LLM 请求。 +- 2026-07-11 补充:单 Agent 代码闭环新增开发专用 `project.verify`,用于在修改后执行项目根 `package.json` 已定义的 `check / typecheck / test / lint / build` 之一;当前只支持 npm,不接受自由命令、参数或工作目录。Agent 必须先读取 `package.json`,再把脚本名、完整原始脚本文本 `expectedCommand` 和 1-300 秒超时一起提交;Runtime 在真正执行前重新解析 JSON 并做精确一致性校验,脚本漂移时拒绝执行。该工具使用独立、默认 `confirm` 的 `project.verify` 权限,不再与 `command.run_limited` / `game.static_smoke` 共用授权;确认指纹覆盖脚本正文和超时。执行时不经过 App 自行拼接的 `bash -c`,而由 npm 执行已确认的项目脚本,并附加 `--ignore-scripts` 阻止 `pre/post` 生命周期旁路;继承环境被清理到 PATH 与必要平台变量,HOME/TMP/npm cache 隔离,stdin 关闭,输出保留有界头尾。Unix 下验证根进程正常结束或超时都会清理同进程组残留后代;项目写锁会按持有 PID 回收崩溃遗留锁,并拒绝 `.agent` 符号链接逃逸。进入进程执行后的成功、非零退出、启动失败和超时会写 `.agent/logs/command.log`、manifest command run 与 `agent.runtime.project.verify` 审计;输入预检拒绝则只进入 Runtime observation / error 事件。输出先过滤敏感内容再进入 observation。只要最新 `project.verify` 未通过,或通过后又发生 `file.write / file.patch / project.restore`,Runtime 就拒绝模型用空 actions 假完成,继续要求修复和重新验证;耗尽 loop 仍未通过时保持失败。该能力会执行用户项目自身脚本,环境隔离不等同于 OS 沙箱,不能把不可信项目脚本视为安全代码;它不是自由 shell 代理,也不进入普通用户命令入口。 +- 2026-07-11 补充:开发侧新增 headless 单 Agent Runtime 入口 `npm run ai-game-creator-shell:agent-task -- [--init] `。该入口不实现第二套 Agent,只复用 Tauri App 的持久任务队列、per-agent 锁、LLM 路由、权限策略、工具 action / observation loop、对话和审计文件,并轮询到 `completed / failed / waiting-for-confirmation` 后用稳定键值行退出;`--init` 只在显式传入且 manifest 不存在时初始化项目。遇到待确认动作时 CLI 返回非零并打印 actionId、tool 和脱敏摘要,后续仍由开发窗口完成确认,不提供静默 `--yes` 绕过。 +- 2026-07-11 补充:后台 Agent 的工具规划和最终回复请求对 `LlmError::EmptyResponse` 最多自动重试 3 次(含首次共 4 次请求),与既有 Generator 对上游 HTTP 成功但空 content 的恢复策略一致;对 `Timeout / Connectivity / Transport` 及上游 `408 / 429 / 5xx` 最多额外自动重试 2 次并做线性退避,配置、请求、流能力、反序列化错误及其他 `4xx` 仍立即失败。重试发生在工具计划被解析和执行前,或最终回复尚未落盘时,不会重复执行已经落盘的工具动作。 - 2026-07-11 调整:后台单 Agent 的 planning loop 上限从 3 轮提升到 6 轮,每轮工具动作上限仍为 3;真实代码任务已证明“搜索定位、分段读取、等待写入确认、写后复读”可能在第 3 轮才进入待确认状态,原上限会让确认后的同 run 没有继续验证余量。`maxLoopIterations` 随新上限写入 Runtime,跨重启待确认动作按已完成轮次继续使用剩余轮次;6 轮后 actions 仍未收束时继续进入 `failed / budget-exhausted`,不会伪装完成。该调整只作用于后台单 Agent Runtime,不改变游戏草案 Generator/Evaluator 的 3 轮上限;本文件和旧实施摘要中“后台 3 轮后整理最终回复”的历史描述由本条取代。 - 2026-07-11 调整:开发者投递的后台任务从队列记录、Runtime `currentTask/currentGoal` 到待确认动作私有账本统一保留最多 4,000 字符,不再在入队时截成 180 字符。180 字符只用于 UI、事件和审计预览;LLM planning、失败重试、确认续跑和重启恢复必须使用完整任务字段,避免位于长需求末尾的验收条件、禁止项或输出格式在真正执行前丢失。 - 2026-07-11 调整:后台结构化 planning 使用独立的 4,000 输出 token 上限,最终回复使用 2,400;两者显式请求 low reasoning effort 和 low text verbosity。`platform-llm` 会把 reasoning effort 同时映射到 OpenAI Responses 的 `reasoning.effort` 与 Chat Completions 的 `reasoning_effort`,未设置时不新增字段。真实 gpt-5.5 Chat 响应曾连续消耗约 1,000-1,400 completion tokens 却不返回 message content,低推理强度、较大的可见输出余量和 EmptyResponse 重试共同构成恢复策略。 +- 2026-07-11 补充:后台单 Agent 的工具计划响应只接受可反序列化为计划 schema 的 JSON object。解析器提取模型输出中的首个完整对象并允许对象后带普通说明;未找到完整 JSON 对象,或提取对象无法反序列化为工具计划时,Runtime 最多追加 2 次自动格式修复请求。每次修复只携带限长、脱敏后的上一次无效输出,并写入 `agent.runtime.tool_plan.repair` 审计。两次修复后仍无有效对象则按工具规划失败处理;工具规划阶段的普通文本不得转换为默认的空 actions + response,也不得据此把任务标记为完成。 +- 2026-07-11 调整:工具计划顶层 `thinkingSummary / plan / actions / response` 四个字段必须同时存在,未知顶层字段、空 thinkingSummary 和空 tool 均属于协议错误并进入同一格式修复预算,`{}` 或前置无关 JSON 对象不能再触发空计划收束。空 actions 表示 planning 收束;response 非空时直接采用,response 为空时进入独立的最终回复生成。`agent.runtime.project.verify` 审计同时保存 `runId / actionId / actionFingerprint`,使并行 Agent 的失败与通过记录能够精确归属到发起动作。 - 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `preview.start`,让 Agent 在完成写盘或静态自检后能按策略自行启动当前项目的 `127.0.0.1` 本地 HTTP 预览。该工具复用 `preview.start` 权限策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑;写入 `.agent/agent.db` 的审计类型为 `agent.runtime.preview.start`。发给 LLM 的 observation 只包含 localhost URL 和端口,不包含用户项目绝对路径。 - 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `canvas.asset_generate`,让美术类 Agent 可在 loop 中自行请求生成首版美术素材。该工具读取 AppData / Tauri 配置中的 `editorApi`,复用 `canvas.asset_generate` 权限策略、项目写锁、External Editor API 生成和下载链路、manifest 资产登记以及 `canvas.asset_generate` 本地索引记录;另写 `agent.runtime.canvas.asset_generate` 记录到 `.agent/agent.db`,标明触发的 agent 与本地素材路径。API Key 不进入 prompt observation、manifest、agent.db 或日志;策略要求确认或拒绝时不会调用外部 API。 - 补充:规范 Agent ID 统一使用 manifest taskId,例如 `art-asset-plan` 和 `code-prototype`;历史前端曾使用的 `group-role` 别名只在 Tauri command 层兼容并映射到规范 taskId。主窗口 Agent 状态列表通过 `read_game_creator_agent_runtimes` 批量读取 `.agent/runtime/agents/.json` 和最近任务,把每个 Agent 的 Runtime 状态、当前动作和最近 task 直接显示在状态卡片和 `/agents` 汇总里。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index e86bff5f3..d83a89eaf 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -57,6 +57,14 @@ AI 游戏创作独立客户端常用短命令: npm run agc ``` +开发侧需要无 UI 验收某个单 Agent 的完整 Runtime 时使用: + +```bash +npm run ai-game-creator-shell:agent-task -- --init /absolute/project code-prototype "修复失败测试并完成验证" +``` + +省略 `--init` 时项目必须已经由客户端初始化;遇到权限确认会返回非零并保留待确认动作,继续操作应回到开发窗口,不能用 CLI 静默绕过。 + `npm run agc` 会启动 Tauri 开发客户端;其 `beforeDevCommand` 通过 `npm run agc:serve` 先完成壳 typecheck,再启动或复用配套 SpacetimeDB、`api-server` 和固定 `127.0.0.1:3080` Vite。只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database `。 Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start` 到 `start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 仍沿用原有端口探测与漂移逻辑。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e118da0c1..cb34d2079 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -54,10 +54,14 @@ Agent Runtime 负责: - 2026-07-10 补充:后台 Agent loop 的统一语义事件类型为 `thinking_summary / plan / action / observation / response / error`。普通失败和 loop 预算耗尽都会追加 `error` 事件,并继续保留 `turn.failed / turn.budget_exhausted` 生命周期事件兼容既有读取方;开发窗口、项目内 Agent 对话弹窗和主窗口状态卡通过现有最近事件列表直接展示统一错误事件及其安全详情。状态面板默认保持最新 4 条的紧凑视图,当前后端返回的最近事件超过 4 条时可展开查看全部返回记录,确保同一 run 的六类语义事件不会因 UI 硬截断而无法检查。 - 2026-07-11 补充:开发单 Agent 聊天页继续使用整页纵向滚动,不把 Runtime 锁进固定视口;聊天消息区使用固定响应式高度并在内部滚动,避免历史消息持续撑高聊天面板。可选的 Runtime 恢复确认区始终占据独立布局行,不能与 Runtime 详情或聊天消息重叠。Runtime 状态面板支持折叠详情,折叠时只卸载目标、计划、事件、动作和任务等详情 DOM,仍保留状态标题与取消、重试、确认、拒绝、刷新操作;等待 LLM 时在消息区显示连接 / 等待首包 / 接收中的动态状态。流式聊天的连续 delta 通过 `requestAnimationFrame` 合并为每帧最多一次消息更新,delta 不重复提交未变化的 Runtime state;OpenAI Chat SSE 的 usage-only 事件会回填最终 token usage,finish-only 事件会把结束原因送入状态流,上游 error 保留真实消息,`[DONE]` 立即结束读取;持久事件订阅失败时显示非致命 Runtime 错误,聊天事件监听不可用或流式请求在首个文本片段前失败时自动降级普通回复。 - 2026-07-11 补充:后台单 Agent 新增 Codex 风格的代码导航与局部编辑闭环。`project.search` 接受 `query / path / maxResults / caseSensitive`,在项目边界内做字面量搜索并返回 `path:line`,最多扫描 500 个、单个不超过 512 KiB 的文本文件,跳过 `.agent`、`.git`、`node_modules`、`dist`、`build`、`target`、`.next`、`coverage` 和 `.env*`;该工具映射到 `file.read` 权限。`file.read` 接受 `startLine / maxLines`,返回带行号的指定片段、总行数和下一页提示,单次最多 240 行、8,000 字符。`file.patch` 接受 `path / oldText / newText / expectedReplacements`,只在实际匹配数与预期一致时持锁写入,目标文件和修改后文件最大 2 MiB,成功后写 `agent.runtime.file.patch` 审计;该工具映射到 `file.write` 权限。Agent planning prompt 明确要求批量修改前创建 checkpoint,并可在修改后再次 `file.read` 验证;本轮不开放任意 shell 命令。 -- 2026-07-11 补充:后台工具规划与最终回复的 LLM 请求新增空 content 恢复,只对 `LlmError::EmptyResponse` 原样自动重试最多 3 次,其他错误不重试。重试发生在任何工具动作执行前或已有 observation 后的下一次规划请求,因此不会因空响应重复执行已经落盘的工具副作用;重试耗尽后仍写入原有 `error / turn.failed` 事件并把失败消息追加到当前 Agent 会话。 +- 2026-07-11 补充:代码修改后的真实验证由开发专用 `project.verify` 承接。输入固定为 `script / expectedCommand / timeoutSeconds`,其中 script 只允许项目根 `package.json` 中的 `check / typecheck / test / lint / build`,expectedCommand 必须与执行时重新读取的脚本正文完全一致,timeoutSeconds 为 1-300;当前执行器只支持 npm,其他 packageManager 或锁文件明确失败。工具映射到独立且默认需确认的 `project.verify` 权限,确认动作指纹绑定完整输入,不再因为放行验证而同时放行 `command.run_limited` 静态 smoke。执行器由 npm 运行已确认脚本,使用 `--ignore-scripts`、空 stdin、隔离 HOME/TMP/cache、清理后的环境、独立进程组和有界脱敏输出;Unix 下无论根进程正常结束还是超时都会清理同组残留后代。项目写锁记录 PID 和唯一 nonce,活进程继续持锁,Unix 死进程锁或跨平台超过安全时限的无效锁可回收,且控制路径拒绝符号链接。进入进程执行后的终态写命令日志和 manifest command run,Agent 触发时另写 `agent.runtime.project.verify`;输入预检拒绝只写 Runtime observation / error 事件。失败输出作为 observation 回到下一轮 planning。最新验证失败,或验证通过后又执行 `file.write / file.patch / project.restore` 时,空 actions 不再代表完成,Runtime 会注入 `runtime.verification: blocked` 并继续 replan;loop 耗尽仍未形成新通过结果时保持失败。该能力会执行用户项目脚本,环境隔离不是 OS 沙箱;普通用户 `/smoke` 与 `game.static_smoke` 保持原边界,不暴露该开发工具。 +- 2026-07-11 补充:开发验证可用 `npm run ai-game-creator-shell:agent-task -- [--init] ` 无 UI 启动单 Agent 后台任务。CLI 只负责可选初始化、调用现有 Runtime、按 runId 轮询终态并打印 `status / phase / replyText / pendingActionId`,不复制 planning 或工具执行逻辑;默认 10 分钟轮询上限。`waiting-for-confirmation` 会以非零状态退出并要求转到开发窗口确认,CLI 不提供跳过项目权限的自动确认参数。该入口用于真实 provider 的可重复端到端验收,不进入普通用户界面。 +- 2026-07-11 补充:后台工具规划与最终回复的 LLM 请求新增可恢复错误重试:`LlmError::EmptyResponse` 原样自动重试最多 3 次;`Timeout / Connectivity / Transport` 及上游 `408 / 429 / 5xx` 最多额外重试 2 次并按 `500ms / 1000ms` 退避。配置、请求、流能力、反序列化错误及其他 `4xx` 不重试。重试发生在工具计划被解析和执行前,或最终回复尚未落盘时,因此不会重复执行已经落盘的工具副作用;重试耗尽后仍写入原有 `error / turn.failed` 事件并把失败消息追加到当前 Agent 会话。 - 2026-07-11 调整:后台单 Agent planning loop 上限提升为 6 轮、每轮最多 3 个工具动作,`loopIteration / maxLoopIterations / toolActionBudget` 继续向 UI 和 `agent.run_status` 暴露真实进度。待确认动作在第 N 轮暂停时,确认或重启恢复后从下一轮继续,最多使用剩余的 `6-N` 轮完成修改后复读和自检;6 轮仍返回非空 actions 时终态保持 `failed / budget-exhausted`。这只调整后台单 Agent Runtime;游戏草案 Generator/Evaluator 仍保持独立的 3 轮修复预算。旧摘要中“后台最多 3 轮、耗尽后生成最终回复”的描述不再有效,以本条和 budget-exhausted 决策为准。 - 2026-07-11 调整:后台任务的可执行正文上限统一为 4,000 字符。入队 JSONL、启动后的 `currentTask/currentGoal`、planning prompt、待确认动作 task context、确认续跑和重启恢复都保留同一份正文;对话仍保存用户原始消息。状态事件、列表卡片和 `agent.db` 摘要可继续使用较短安全预览,但不能再反向作为后续 LLM 执行输入。这样长任务末尾的验收标记和输出格式要求不会在队列边界被 180 字符截断。 - 2026-07-11 调整:后台 planning 不再复用普通聊天的 1,800 输出 token 上限,而是使用 4,000;最终回复使用 2,400。两类请求均设置 low reasoning effort / low text verbosity;OpenAI Responses 序列化为 `reasoning.effort=low`,OpenAI Chat Completions 序列化为可选 `reasoning_effort=low`。该设置用于避免推理模型把全部 completion 预算消耗在不可见 reasoning 后留下空 content,并继续叠加最多 3 次 EmptyResponse 重试。 +- 2026-07-11 补充:后台单 Agent 的工具 planning 响应必须提供可反序列化为 `thinkingSummary / plan / actions / response` schema 的 JSON object。Runtime 从模型输出中解析首个完整对象,因此对象后的尾随说明可以忽略;只有普通文本、没有完整对象,或对象无法反序列化时都不构成有效工具计划。对于这两类无效输出,Runtime 最多追加 2 次自动格式修复请求,每次只把限长且经过统一敏感信息过滤的上一次输出作为修复上下文,并把修复尝试写入 `.agent/agent.db` 的 `agent.runtime.tool_plan.repair` 审计。修复预算耗尽后进入既有工具规划失败路径,不得把普通文本折算为空 actions + response,也不得因此进入 completed;最终回复阶段仍按其独立的普通文本契约处理。 +- 2026-07-11 调整:工具计划四个顶层字段均为必填并拒绝未知顶层字段;thinkingSummary 与 action.tool 必须非空。这样 `{}`、前置无关 JSON 或结构不完整对象会触发格式修复,不会成为假完成信号。空 actions 表示 planning 收束;response 非空时直接采用,response 为空时进入独立最终回复生成。`agent.runtime.project.verify` 记录补充 `runId / actionId / actionFingerprint`,用于在多 Agent 并行验证时把命令终态与具体 Runtime 动作关联。 - 2026-07-10 补充:Agent Runtime state / result 新增 `taskQueue`,从 `.agent/runtime/tasks/.jsonl` 中每个 `runId` 的最新记录汇总 `total / pending / running / completed / failed / latestRunId`;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都读取该摘要,用于判断同一 Agent 是否仍有排队任务。该字段是运行观测摘要,不新增调度器、SQLite 或独立 worker。 - 2026-07-10 补充:Runtime 新增 `agent.schedule_ready` 调度入口。开发构建可在权限确认后扫描 manifest ready task,把依赖已完成且仍为 `pending` 的任务标成 `running`,并按 taskId 投递到对应 Agent 的既有后台队列;source 固定为 `agent-ready-task-scheduler`,审计记录写 `agent.runtime.ready_task.scheduled`。该入口只把 manifest ready task 接入现有 per-agent 队列、锁、JSONL、LLM loop、工具策略和事件流,不新增独立 worker,也不会在默认确认策略下静默启动。 - 2026-07-10 补充:主窗口 Agent 状态栏的“调度 Ready”只在开发模式显示。点击后复用项目策略确认弹窗,确认通过才调用 `schedule_game_creator_agent_ready_tasks`,并把返回的 Runtime 合并回 Agent 状态卡;普通用户窗口继续只展示状态和单 Agent 对话入口,不直接暴露 ready-task 调度按钮。 @@ -300,7 +304,7 @@ game-project/ - Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`,Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`,Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,旧窗口兼容命令放在 `windows.rs`,Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。 - 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`。 - v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion`、`role`、`content`、`agentId` 和 `updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。 -- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动或排队单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果,也可对当前 run 执行取消 / 重试,待确认 run 还可执行“确认继续”或“拒绝并继续”。后台任务会向 `.agent/runtime/tasks/.jsonl` 追加任务视角记录,任务状态使用 `pending / running / waiting-for-confirmation / cancelled / completed / failed`,读取时按 `runId` 去重返回最近任务;`runId` 在同一 Agent 内是单个 run 的身份,后台入队会自动把重复 runId 改写为唯一实际 runId,防止不同任务互相覆盖;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行;若某个工具动作命中确认策略,该 Agent 会停在 `waiting-for-confirmation` 并暂停继续消费队列,等待后续确认或策略调整;Runtime 会把完整 `AgentRuntimePendingToolAction` 经过敏感内容和项目绝对路径校验后原子写入 `.agent/runtime/pending-actions//.json`,公共 `pendingToolAction` 只公开安全摘要;确认或拒绝必须匹配 `runId + actionId` 并通过工具名与完整输入 JSON 的 SHA-256 校验。确认在同一 run 直接执行原 action 并把 observation 接回后续 loop;拒绝不执行工具,而是写入 `blocked` observation 后在同一 run 继续规划。待确认状态可跨 App 重启读取并回收上一进程锁;等待期间同 Agent 新任务保持 pending,确认/拒绝续跑结束后由同一 drain 串行排空;若用户取消 pending 任务,drain 不再消费该 run,若取消 running 任务,则在当前 LLM 或工具调用返回后的检查点停止,不继续执行工具或保存最终 assistant 回复。客户端重开项目时会对当前项目路径自动尝试一次 Runtime 恢复;恢复命令必须通过 `agent.resume` 自动权限,默认确认策略下不会静默启动;同一 Agent 同时存在上一进程遗留 `running` 和 `pending` 时,先重接 `running`,再由 drain 继续 `pending`。开发构建和后台 Agent 工具箱都可通过 `agent.schedule_ready` 权限确认入口把 manifest ready task 投递进同一后台队列,命令会先把 ready task 标成 `running`,再用 taskId 作为 Agent id 入队,source 为 `agent-ready-task-scheduler`;该入口不新增独立 worker。后台任务的核心 loop 最多 3 轮:每轮把已有 observation 带回 LLM 让 Agent 重新规划,只有 actions 为空且 response 非空时提前收束,否则继续执行白名单工具,跑满后再进入最终回复整理。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.background_task.recovered` / `agent.runtime.ready_task.scheduled` / `agent.runtime.tool_observation` / `agent.runtime.tool_confirmation_required` / `agent.runtime.tool_confirmation.approved` / `agent.runtime.tool_confirmation.rejected` / `agent.runtime.memory.write` / `agent.runtime.file.write` / `agent.runtime.task.create` / `agent.runtime.task.update` / `agent.runtime.command.run_limited` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.agent.delegate` / `agent.runtime.background_task.cancelled` / `agent.runtime.background_task.retry` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`file.write`、`task.create`、`task.update`、`command.run_limited`、`blackboard.write`、`agent.message`、`agent.delegate` 和 `agent.schedule_ready`;`memory.write scope=agent` 只允许写当前 Agent 自己的私有记忆,跨 Agent 共享必须改用 `blackboard.write` 或 `agent.message`;`project.checkpoint` 只创建本地 checkpoint,不返回本机绝对路径;`project.restore` 只按 checkpoint id 恢复当前项目,不返回本机绝对路径,默认确认策略下不会静默回滚;`task.create` 只追加新 manifest 任务,`task.update` 只更新已有任务状态;`agent.schedule_ready` 只调度 manifest ready task,不创建平行 runtime;若项目策略拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent 修正计划;若项目策略要求确认,Runtime 会持久化精确待确认动作并保留 waiting 状态,不执行该工具;只有确认入口通过 `runId + actionId + SHA-256` 校验后才直接执行原 action,拒绝入口则生成 `blocked` observation。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作及安全 `inputSummary`,供状态面板展示最近动作和确认目标;append-only JSONL 写入按目标文件路径在当前进程内串行追加完整行,覆盖 `.agent/agent.db`、对话、Runtime events/tasks、activity 和 output,减少多个 Agent 同时完成时的行交错风险。 +- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动或排队单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果,也可对当前 run 执行取消 / 重试,待确认 run 还可执行“确认继续”或“拒绝并继续”。后台任务会向 `.agent/runtime/tasks/.jsonl` 追加任务视角记录,任务状态使用 `pending / running / waiting-for-confirmation / cancelled / completed / failed`,读取时按 `runId` 去重返回最近任务;`runId` 在同一 Agent 内是单个 run 的身份,后台入队会自动把重复 runId 改写为唯一实际 runId,防止不同任务互相覆盖;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行;若某个工具动作命中确认策略,该 Agent 会停在 `waiting-for-confirmation` 并暂停继续消费队列,等待后续确认或策略调整;Runtime 会把完整 `AgentRuntimePendingToolAction` 经过敏感内容和项目绝对路径校验后原子写入 `.agent/runtime/pending-actions//.json`,公共 `pendingToolAction` 只公开安全摘要;确认或拒绝必须匹配 `runId + actionId` 并通过工具名与完整输入 JSON 的 SHA-256 校验。确认在同一 run 直接执行原 action 并把 observation 接回后续 loop;拒绝不执行工具,而是写入 `blocked` observation 后在同一 run 继续规划。待确认状态可跨 App 重启读取并回收上一进程锁;等待期间同 Agent 新任务保持 pending,确认/拒绝续跑结束后由同一 drain 串行排空;若用户取消 pending 任务,drain 不再消费该 run,若取消 running 任务,则在当前 LLM 或工具调用返回后的检查点停止,不继续执行工具或保存最终 assistant 回复。客户端重开项目时会对当前项目路径自动尝试一次 Runtime 恢复;恢复命令必须通过 `agent.resume` 自动权限,默认确认策略下不会静默启动;同一 Agent 同时存在上一进程遗留 `running` 和 `pending` 时,先重接 `running`,再由 drain 继续 `pending`。开发构建和后台 Agent 工具箱都可通过 `agent.schedule_ready` 权限确认入口把 manifest ready task 投递进同一后台队列,命令会先把 ready task 标成 `running`,再用 taskId 作为 Agent id 入队,source 为 `agent-ready-task-scheduler`;该入口不新增独立 worker。后台任务的核心 loop 最多 6 轮:每轮把已有 observation 带回 LLM 让 Agent 重新规划;只有合法工具计划返回空 actions,且不存在未通过或项目修改后未重跑的 `project.verify` 时才收束;response 为空时进入独立最终回复生成,6 轮仍未收束则以 `budget-exhausted` 失败。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.background_task.recovered` / `agent.runtime.ready_task.scheduled` / `agent.runtime.tool_observation` / `agent.runtime.tool_confirmation_required` / `agent.runtime.tool_confirmation.approved` / `agent.runtime.tool_confirmation.rejected` / `agent.runtime.memory.write` / `agent.runtime.project.verify` / `agent.runtime.file.write` / `agent.runtime.file.patch` / `agent.runtime.tool_plan.repair` / `agent.runtime.task.create` / `agent.runtime.task.update` / `agent.runtime.command.run_limited` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.agent.delegate` / `agent.runtime.background_task.cancelled` / `agent.runtime.background_task.retry` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.search`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`project.verify`、`file.write`、`file.patch`、`task.create`、`task.update`、`command.run_limited`、`preview.start`、`canvas.asset_generate`、`blackboard.write`、`agent.message`、`agent.delegate` 和 `agent.schedule_ready`;`memory.write scope=agent` 只允许写当前 Agent 自己的私有记忆,跨 Agent 共享必须改用 `blackboard.write` 或 `agent.message`;`project.checkpoint` 只创建本地 checkpoint,不返回本机绝对路径;`project.restore` 只按 checkpoint id 恢复当前项目,不返回本机绝对路径,默认确认策略下不会静默回滚;`task.create` 只追加新 manifest 任务,`task.update` 只更新已有任务状态;`agent.schedule_ready` 只调度 manifest ready task,不创建平行 runtime;若项目策略拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent 修正计划;若项目策略要求确认,Runtime 会持久化精确待确认动作并保留 waiting 状态,不执行该工具;只有确认入口通过 `runId + actionId + SHA-256` 校验后才直接执行原 action,拒绝入口则生成 `blocked` observation。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作及安全 `inputSummary`,供状态面板展示最近动作和确认目标;append-only JSONL 写入按目标文件路径在当前进程内串行追加完整行,覆盖 `.agent/agent.db`、对话、Runtime events/tasks、activity 和 output,减少多个 Agent 同时完成时的行交错风险。 - 2026-07-10 补充:当前工具箱还开放 `preview.start`,审计记录类型为 `agent.runtime.preview.start`;该工具不会打开任意 URL,只启动当前授权项目的 `127.0.0.1` 本地预览,并和 Tauri 用户命令共用同一个 `PreviewRegistry`。 - 2026-07-10 补充:当前工具箱还开放 `canvas.asset_generate`,审计记录类型为 `agent.runtime.canvas.asset_generate`;该工具只通过配置好的 External Editor API 生成并回流素材,不暴露任意上传 / 任意网络请求能力。 - 2026-07-10 补充:当前工具箱还开放 `task.list`;该工具只读取 manifest 任务图、状态、依赖、产物和 `readyTaskIds`,并受 `task.list` 项目权限策略保护。 diff --git a/package.json b/package.json index 237b43c0e..938ae39e7 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "ai-game-creator-shell:dev-server": "npm --prefix apps/ai-game-creator-shell run dev-server", "ai-game-creator-shell:build": "npm --prefix apps/ai-game-creator-shell run build --", "ai-game-creator-shell:llm-status": "npm --prefix apps/ai-game-creator-shell run llm-status --", + "ai-game-creator-shell:agent-task": "npm --prefix apps/ai-game-creator-shell run agent-task --", "ai-game-creator-shell:agent-run": "npm --prefix apps/ai-game-creator-shell run agent-run --", "ai-game-creator-shell:agent-run:smoke": "npm --prefix apps/ai-game-creator-shell run agent-run:smoke", "ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck", diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 1ae306734..7b257b167 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -26,6 +26,11 @@ describe('AI 游戏创作 App 共享契约', () => { (command) => command.id === 'command.run_limited', )?.permission, ).toBe('confirm'); + expect( + GAME_CREATION_APP_COMMANDS.find( + (command) => command.id === 'project.verify', + )?.permission, + ).toBe('confirm'); expect( GAME_CREATION_APP_COMMANDS.find( (command) => command.id === 'game.generate_draft', diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 7fe9d38f2..d994ac250 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -20,6 +20,7 @@ export const GAME_CREATION_APP_COMMANDS = [ { id: 'project.checkpoint', permission: 'confirm' }, { id: 'project.diff', permission: 'auto' }, { id: 'project.restore', permission: 'confirm' }, + { id: 'project.verify', permission: 'confirm' }, { id: 'project.export_package', permission: 'confirm' }, { id: 'project.export_list', permission: 'auto' }, { id: 'project.policy_read', permission: 'auto' }, diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 28601d62e..38486e5aa 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -21,7 +21,7 @@ pub struct GameCreationAppCommandDescriptor { pub permission: GameCreationAppPermission, } -pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 48] = [ +pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 49] = [ command("help.show", GameCreationAppPermission::Auto), command("project.create", GameCreationAppPermission::Confirm), command("project.status", GameCreationAppPermission::Auto), @@ -29,6 +29,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 48] = [ command("project.checkpoint", GameCreationAppPermission::Confirm), command("project.diff", GameCreationAppPermission::Auto), command("project.restore", GameCreationAppPermission::Confirm), + command("project.verify", GameCreationAppPermission::Confirm), command("project.export_package", GameCreationAppPermission::Confirm), command("project.export_list", GameCreationAppPermission::Auto), command("project.policy_read", GameCreationAppPermission::Auto), @@ -632,6 +633,15 @@ mod tests { .expect("command should exist"); assert_eq!(command.permission, GameCreationAppPermission::Confirm); + let project_verify = GAME_CREATION_APP_COMMANDS + .iter() + .find(|command| command.id == "project.verify") + .expect("project.verify command should exist"); + assert_eq!( + project_verify.permission, + GameCreationAppPermission::Confirm + ); + let generate = GAME_CREATION_APP_COMMANDS .iter() .find(|command| command.id == "game.generate_draft")