From 637b91778ee9c74cc3b61f93bdd2cd5ab4706eae Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Sat, 11 Jul 2026 22:12:07 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=8D=95Agent=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AF=BC=E8=88=AA=E4=B8=8E=E5=B1=80=E9=83=A8=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增项目字面搜索、分段文件读取和精确局部补丁工具 提升后台循环与任务上下文预算并增加空响应重试 支持Chat Completions推理强度配置并补齐回归测试 同步技术方案和项目决策记录 --- .../src-tauri/src/agent.rs | 548 ++++++++++++++- .../src-tauri/src/tests.rs | 639 +++++++++++++++++- .../shared-memory/decision-log.md | 5 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 5 + server-rs/crates/platform-llm/src/lib.rs | 9 +- 5 files changed, 1173 insertions(+), 33 deletions(-) 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 16c9cfd07..73d7c72a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -15,6 +15,7 @@ pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION: &str = "confi pub(crate) const AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION: &str = "sha256-serde-json-v1"; const AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE: &str = "agent-delegate-receipt"; const AGENT_RUNTIME_DELEGATE_RECEIPT_TASK_MAX_CHARS: usize = 900; +const AGENT_RUNTIME_TASK_MAX_CHARS: usize = 4_000; #[derive(Clone, Debug, Default, Eq, PartialEq)] struct AgentRuntimeTaskLink { @@ -2204,7 +2205,9 @@ async fn run_game_creator_agent_background_task_with_context( let action_task_context = prepared_action .as_ref() .map(|pending| pending.task.clone()) - .unwrap_or_else(|| sanitize_agent_runtime_text(&task, 1_200)); + .unwrap_or_else(|| { + sanitize_agent_runtime_text(&task, AGENT_RUNTIME_TASK_MAX_CHARS) + }); let policy_block = command_id.and_then(|command_id| { game_creator_agent_runtime_tool_policy_block( &root, @@ -2667,12 +2670,24 @@ async fn run_game_creator_agent_background_task_with_context( AgentBackgroundTaskOutcome::Finished } -const AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT: usize = 3; +pub(crate) const AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT: usize = 6; const AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT: usize = 3; const AGENT_RUNTIME_RECENT_TOOL_CALL_LIMIT: usize = 20; const AGENT_RUNTIME_PLAN_STEP_LIMIT: usize = 8; 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; +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; +const AGENT_RUNTIME_PROJECT_SEARCH_DEFAULT_RESULTS: usize = 20; +const AGENT_RUNTIME_PROJECT_SEARCH_MAX_RESULTS: usize = 50; +const AGENT_RUNTIME_PROJECT_SEARCH_MAX_FILES: usize = 500; +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; pub(crate) const AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS: u64 = 300; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -2797,7 +2812,7 @@ fn build_game_creator_agent_runtime_pending_tool_action( status: &str, observation: Option, ) -> AgentRuntimePendingToolAction { - let task = sanitize_agent_runtime_text(task, 1_200); + let task = sanitize_agent_runtime_text(task, AGENT_RUNTIME_TASK_MAX_CHARS); let action_fingerprint = agent_runtime_tool_action_fingerprint(action, &task); let occurrence_nonce = unix_timestamp_nanos().min(u128::from(u64::MAX)) as u64; let action_index = u32::try_from(action_index).unwrap_or(u32::MAX); @@ -3020,12 +3035,52 @@ fn agent_runtime_tool_action_input_summary( "checkpointId={}", text(&["checkpointId", "checkpoint_id", "id"]) ), - "file.list" | "file.read" => format!("path={}", relative_path(&["path"])), + "project.search" => format!( + "path={} · queryChars={} · maxResults={} · caseSensitive={}", + relative_path(&["path"]), + chars(&["query"]), + input + .get("maxResults") + .or_else(|| input.get("max_results")) + .and_then(|value| value.as_u64()) + .unwrap_or(AGENT_RUNTIME_PROJECT_SEARCH_DEFAULT_RESULTS as u64), + input + .get("caseSensitive") + .or_else(|| input.get("case_sensitive")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + ), + "file.list" => format!("path={}", relative_path(&["path"])), + "file.read" => format!( + "path={} · startLine={} · maxLines={}", + relative_path(&["path"]), + input + .get("startLine") + .or_else(|| input.get("start_line")) + .and_then(|value| value.as_u64()) + .unwrap_or(1), + input + .get("maxLines") + .or_else(|| input.get("max_lines")) + .and_then(|value| value.as_u64()) + .unwrap_or(AGENT_RUNTIME_FILE_READ_DEFAULT_LINES as u64) + ), "file.write" => format!( "path={} · contentChars={}", relative_path(&["path"]), chars(&["content"]) ), + "file.patch" => format!( + "path={} · oldTextChars={} · newTextChars={} · expectedReplacements={}", + relative_path(&["path"]), + chars(&["oldText", "old_text"]), + chars(&["newText", "new_text"]), + input + .get("expectedReplacements") + .or_else(|| input.get("expected_replacements")) + .and_then(|value| value.as_u64()) + .unwrap_or(1) + ), "task.create" => format!( "taskId={} · title={} · group={} · role={} · dependencies={} · artifacts={} · criteria={}", text(&["taskId", "task_id", "id"]), @@ -3248,9 +3303,14 @@ 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_llm_text(&client, &llm, request) - .await - .map_err(|error| format!("{config_path} 后台 Agent 工具计划调用 LLM 失败:{error}"))?; + 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()) } @@ -3273,9 +3333,14 @@ 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_llm_text(&client, &llm, request) - .await - .map_err(|error| format!("{config_path} 后台 Agent 最终回复调用 LLM 失败:{error}"))?; + let response = request_game_creator_agent_llm_text_retrying_empty( + &client, + &llm, + request, + "后台 Agent 最终回复", + ) + .await + .map_err(|error| format!("{config_path} 后台 Agent 最终回复调用 LLM 失败:{error}"))?; let reply = strip_llm_thinking_blocks(response.text.as_str()); if reply.trim().is_empty() { return Err(format!("{config_path} 后台 Agent 最终回复为空")); @@ -3304,14 +3369,16 @@ 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.checkpoint|project.restore|project.diff|file.list|file.read|file.write|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.checkpoint input 可为空,用于在写文件或批量修改前创建本地 checkpoint;project.restore 使用 {{\"checkpointId\":\"checkpoint id\"}},用于在确认后把当前项目恢复到指定 checkpoint;project.diff 使用 {{\"checkpointId\":\"checkpoint id\"}},用于读取当前项目相对路径 diff 摘要;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\"}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"文件内容\"}};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.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 可为空。" ); let request = LlmRunRequest::new(vec![ LlmMessage::system(game_creator_agent_runtime_tool_plan_system_prompt()), LlmMessage::user(prompt), ]) .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS); + .with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS) + .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Low) + .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low); Ok((llm, config_path, request)) } @@ -3338,7 +3405,9 @@ fn build_game_creator_agent_background_final_reply_request( LlmMessage::user(prompt), ]) .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS); + .with_max_output_tokens(AGENT_RUNTIME_FINAL_REPLY_MAX_OUTPUT_TOKENS) + .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Low) + .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low); Ok((llm, config_path, request)) } @@ -3445,12 +3514,14 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_action_i "conversation.read" => observe_agent_runtime_conversation(root, agent_id, run_id), "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.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), "file.list" => observe_agent_runtime_file_list(root, &action.input), "file.read" => observe_agent_runtime_file(root, &action.input), "file.write" => observe_agent_runtime_file_write(root, agent_id, &action.input), + "file.patch" => observe_agent_runtime_file_patch(root, agent_id, &action.input), "task.list" => observe_agent_runtime_task_list(root), "task.create" => observe_agent_runtime_task_create(root, agent_id, &action.input), "task.update" => observe_agent_runtime_task_update(root, agent_id, &action.input), @@ -3487,12 +3558,14 @@ fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str "conversation.read" => Some("conversation.read"), "asset.list" => Some("asset.list"), "project.index" => Some("project.index"), + "project.search" => Some("file.read"), "project.checkpoint" => Some("project.checkpoint"), "project.restore" => Some("project.restore"), "project.diff" => Some("project.diff"), "file.list" => Some("file.list"), "file.read" => Some("file.read"), "file.write" => Some("file.write"), + "file.patch" => Some("file.write"), "task.list" => Some("task.list"), "task.create" => Some("task.create"), "task.update" => Some("task.update"), @@ -3896,12 +3969,14 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "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", @@ -4323,6 +4398,182 @@ fn observe_agent_runtime_project_index(root: &Path) -> AgentRuntimeToolObservati observation_from_text_result("project.index", result, "已读取项目文件索引") } +fn observe_agent_runtime_project_search( + root: &Path, + input: &serde_json::Value, +) -> AgentRuntimeToolObservation { + let query = agent_runtime_tool_input_text(input, &["query", "text", "needle"]); + if query.trim().is_empty() { + return AgentRuntimeToolObservation { + tool: "project.search".to_string(), + status: "failed".to_string(), + summary: "缺少 query".to_string(), + detail: None, + }; + } + if query.contains('\n') || query.contains('\r') || query.chars().count() > 256 { + return AgentRuntimeToolObservation { + tool: "project.search".to_string(), + status: "failed".to_string(), + summary: "query 必须是 1-256 字符的单行字面文本".to_string(), + detail: None, + }; + } + let scope = agent_runtime_tool_input_text(input, &["path", "scope"]); + let max_results = agent_runtime_tool_input_usize(input, &["maxResults", "max_results"]) + .unwrap_or(AGENT_RUNTIME_PROJECT_SEARCH_DEFAULT_RESULTS) + .clamp(1, AGENT_RUNTIME_PROJECT_SEARCH_MAX_RESULTS); + let case_sensitive = input + .get("caseSensitive") + .or_else(|| input.get("case_sensitive")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + match search_agent_runtime_project(root, &scope, &query, max_results, case_sensitive) { + Ok((matches, scanned_files, truncated)) => { + let match_count = matches.len(); + let mut lines = vec![format!("scannedFiles: {scanned_files}")]; + lines.extend(matches); + if match_count == 0 { + lines.push("未找到匹配文本".to_string()); + } else if truncated { + lines.push(format!("结果已限制为前 {max_results} 条")); + } + AgentRuntimeToolObservation { + tool: "project.search".to_string(), + status: "ok".to_string(), + summary: format!( + "已搜索项目:{match_count} 个匹配(扫描 {scanned_files} 个文本文件)" + ), + detail: Some(truncate_agent_runtime_text( + sanitize_prompt_context(&lines.join("\n")).as_str(), + AGENT_RUNTIME_FILE_CONTEXT_MAX_CHARS, + )), + } + } + Err(error) => AgentRuntimeToolObservation { + tool: "project.search".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }, + } +} + +fn search_agent_runtime_project( + root: &Path, + scope: &str, + query: &str, + max_results: usize, + case_sensitive: bool, +) -> Result<(Vec, usize, bool), String> { + validate_project_root(root)?; + let start = if scope.trim().is_empty() || scope.trim() == "." { + root.to_path_buf() + } else { + resolve_local_project_path(root, scope.trim())? + }; + if !start.exists() { + return Err(format!("搜索范围不存在:{}", scope.trim())); + } + + let normalized_query = (!case_sensitive).then(|| query.to_lowercase()); + let mut pending = vec![start]; + let mut matches = Vec::new(); + let mut scanned_files = 0usize; + let mut visited_entries = 0usize; + let mut truncated = false; + + while let Some(path) = pending.pop() { + if visited_entries >= AGENT_RUNTIME_PROJECT_SEARCH_MAX_ENTRIES + || scanned_files >= AGENT_RUNTIME_PROJECT_SEARCH_MAX_FILES + { + truncated = true; + break; + } + visited_entries += 1; + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取搜索路径失败:{}: {error}", path.display()))?; + if metadata.file_type().is_symlink() { + continue; + } + if metadata.is_dir() { + let mut children = fs::read_dir(&path) + .map_err(|error| format!("读取搜索目录失败:{}: {error}", path.display()))? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .collect::>(); + children.sort_by(|left, right| right.cmp(left)); + for child in children { + let Ok(relative_path) = agent_runtime_relative_project_path(root, &child) else { + continue; + }; + if agent_runtime_project_search_ignored_path(&relative_path) { + continue; + } + pending.push(child); + } + continue; + } + if !metadata.is_file() || metadata.len() > AGENT_RUNTIME_PROJECT_SEARCH_MAX_FILE_BYTES { + continue; + } + let relative_path = agent_runtime_relative_project_path(root, &path)?; + if agent_runtime_project_search_ignored_path(&relative_path) { + continue; + } + let Ok(file) = read_local_project_file_at(root, &relative_path) else { + continue; + }; + scanned_files += 1; + for (line_index, line) in file.content.lines().enumerate() { + let is_match = if case_sensitive { + line.contains(query) + } else { + line.to_lowercase() + .contains(normalized_query.as_deref().unwrap_or_default()) + }; + if !is_match { + continue; + } + matches.push(format!( + "{}:{}: {}", + relative_path, + line_index + 1, + sanitize_agent_runtime_text(line.trim(), 320) + )); + if matches.len() >= max_results { + truncated = true; + return Ok((matches, scanned_files, truncated)); + } + } + } + + Ok((matches, scanned_files, truncated)) +} + +fn agent_runtime_relative_project_path(root: &Path, path: &Path) -> Result { + let relative = path + .strip_prefix(root) + .map_err(|_| "搜索路径不在项目目录内".to_string())?; + let normalized = relative + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + normalize_relative_path(&normalized) +} + +fn agent_runtime_project_search_ignored_path(relative_path: &str) -> bool { + relative_path.split('/').any(|part| { + let lower = part.to_ascii_lowercase(); + matches!( + lower.as_str(), + ".agent" | ".git" | "node_modules" | "dist" | "build" | "target" | ".next" | "coverage" + ) || lower == ".env" + || lower.starts_with(".env.") + }) +} + fn observe_agent_runtime_project_checkpoint(root: &Path) -> AgentRuntimeToolObservation { let _lock = match acquire_project_write_lock(root, "project.checkpoint") { Ok(lock) => lock, @@ -4592,11 +4843,7 @@ fn observe_agent_runtime_file( root: &Path, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { - let path = input - .get("path") - .and_then(|value| value.as_str()) - .unwrap_or_default() - .trim(); + let path = agent_runtime_tool_input_text(input, &["path"]); if path.is_empty() { return AgentRuntimeToolObservation { tool: "file.read".to_string(), @@ -4605,8 +4852,83 @@ fn observe_agent_runtime_file( detail: None, }; } - let result = read_local_project_file_at(root, path).map(|result| result.content); - observation_from_text_result("file.read", result, &format!("已读取 {path}")) + let start_line = agent_runtime_tool_input_usize(input, &["startLine", "start_line"]) + .unwrap_or(1) + .max(1); + let max_lines = agent_runtime_tool_input_usize(input, &["maxLines", "max_lines"]) + .unwrap_or(AGENT_RUNTIME_FILE_READ_DEFAULT_LINES) + .clamp(1, AGENT_RUNTIME_FILE_READ_MAX_LINES); + match read_local_project_file_at(root, &path) { + Ok(result) => { + let lines = result.content.lines().collect::>(); + let total_lines = lines.len(); + if total_lines == 0 { + return AgentRuntimeToolObservation { + tool: "file.read".to_string(), + status: "ok".to_string(), + summary: format!("已读取 {}(空文件)", result.path), + detail: Some(format!("{} · lines 0 of 0", result.path)), + }; + } + if start_line > total_lines.max(1) { + return AgentRuntimeToolObservation { + tool: "file.read".to_string(), + status: "failed".to_string(), + summary: format!("startLine {start_line} 超出文件范围(共 {total_lines} 行)"), + detail: None, + }; + } + let selected = lines + .iter() + .skip(start_line.saturating_sub(1)) + .take(max_lines) + .enumerate() + .map(|(index, line)| { + format!( + "{} | {}", + start_line + index, + sanitize_agent_runtime_text(line, 1_000) + ) + }) + .collect::>(); + let end_line = if selected.is_empty() { + 0 + } else { + start_line + selected.len() - 1 + }; + let has_more = end_line < total_lines; + let mut detail = vec![format!( + "{} · lines {}-{} of {}", + result.path, start_line, end_line, total_lines + )]; + detail.extend(selected); + if has_more { + detail.push(format!( + "... 还有 {} 行,可从 startLine={} 继续读取", + total_lines - end_line, + end_line + 1 + )); + } + AgentRuntimeToolObservation { + tool: "file.read".to_string(), + status: "ok".to_string(), + summary: format!( + "已读取 {} 第 {}-{} 行(共 {} 行)", + result.path, start_line, end_line, total_lines + ), + detail: Some(truncate_agent_runtime_text( + sanitize_prompt_context(&detail.join("\n")).as_str(), + AGENT_RUNTIME_FILE_CONTEXT_MAX_CHARS, + )), + } + } + Err(error) => AgentRuntimeToolObservation { + tool: "file.read".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }, + } } fn observe_agent_runtime_file_write( @@ -4678,6 +5000,164 @@ fn observe_agent_runtime_file_write( } } +fn observe_agent_runtime_file_patch( + root: &Path, + agent_id: &str, + input: &serde_json::Value, +) -> AgentRuntimeToolObservation { + let path = agent_runtime_tool_input_text(input, &["path"]); + if path.is_empty() { + return AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "failed".to_string(), + summary: "缺少 path".to_string(), + detail: None, + }; + } + let old_text = input + .get("oldText") + .or_else(|| input.get("old_text")) + .and_then(|value| value.as_str()); + let Some(old_text) = old_text.filter(|value| !value.is_empty()) else { + return AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "failed".to_string(), + summary: "缺少非空 oldText".to_string(), + detail: None, + }; + }; + let Some(new_text) = input + .get("newText") + .or_else(|| input.get("new_text")) + .and_then(|value| value.as_str()) + else { + return AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "failed".to_string(), + summary: "缺少 newText".to_string(), + detail: None, + }; + }; + if old_text.len() > AGENT_RUNTIME_FILE_PATCH_MAX_FRAGMENT_BYTES + || new_text.len() > AGENT_RUNTIME_FILE_PATCH_MAX_FRAGMENT_BYTES + { + return AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "failed".to_string(), + summary: format!( + "oldText/newText 单段不能超过 {} bytes", + AGENT_RUNTIME_FILE_PATCH_MAX_FRAGMENT_BYTES + ), + detail: None, + }; + } + let expected_replacements = + agent_runtime_tool_input_usize(input, &["expectedReplacements", "expected_replacements"]) + .unwrap_or(1); + if expected_replacements == 0 || expected_replacements > 100 { + return AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "failed".to_string(), + summary: "expectedReplacements 必须在 1-100 之间".to_string(), + detail: None, + }; + } + + let _lock = match acquire_project_write_lock(root, "file.patch") { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + let current = match read_local_project_file_at(root, &path) { + Ok(current) => current, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + if current.content.len() > AGENT_RUNTIME_FILE_PATCH_MAX_FILE_BYTES { + return AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "failed".to_string(), + summary: format!( + "目标文件超过局部修改上限:{} bytes", + AGENT_RUNTIME_FILE_PATCH_MAX_FILE_BYTES + ), + detail: None, + }; + } + let actual_replacements = current.content.match_indices(old_text).count(); + if actual_replacements != expected_replacements { + return AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "failed".to_string(), + summary: format!( + "oldText 匹配数不符:期望 {expected_replacements},实际 {actual_replacements};文件未修改" + ), + detail: None, + }; + } + let next_content = current + .content + .replacen(old_text, new_text, expected_replacements); + if next_content.len() > AGENT_RUNTIME_FILE_PATCH_MAX_FILE_BYTES { + return AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "failed".to_string(), + summary: format!( + "修改后文件超过局部修改上限:{} bytes", + AGENT_RUNTIME_FILE_PATCH_MAX_FILE_BYTES + ), + detail: None, + }; + } + let before_bytes = current.content.len(); + let after_bytes = next_content.len(); + let result = write_local_project_file_at(root, &path, &next_content).and_then(|written| { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.file.patch", + "agentId": agent_id, + "path": written.path, + "replacementCount": expected_replacements, + "beforeBytes": before_bytes, + "afterBytes": after_bytes, + }), + ) + .map(|()| written) + }); + match result { + Ok(written) => AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "ok".to_string(), + summary: format!( + "已局部修改 {}({} 处替换)", + written.path, expected_replacements + ), + detail: Some(format!( + "replacements={expected_replacements} · bytes={before_bytes}->{after_bytes}" + )), + }, + Err(error) => AgentRuntimeToolObservation { + tool: "file.patch".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }, + } +} + fn observe_agent_runtime_task_create( root: &Path, agent_id: &str, @@ -6316,7 +6796,7 @@ pub(crate) fn start_game_creator_agent_runtime_task_for_session_at( if task.is_empty() { return Err("Agent Runtime 任务不能为空".to_string()); } - let runtime_task = sanitize_agent_runtime_text(task, 180); + let runtime_task = sanitize_agent_runtime_text(task, AGENT_RUNTIME_TASK_MAX_CHARS); if source.trim() == AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE { ensure_game_creator_agent_delegate_receipt_conversation_at( root, @@ -7511,7 +7991,7 @@ fn append_unique_game_creator_agent_runtime_pending_task( let task_max_chars = if source.trim() == AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE { AGENT_RUNTIME_DELEGATE_RECEIPT_TASK_MAX_CHARS } else { - 180 + AGENT_RUNTIME_TASK_MAX_CHARS }; let record = AgentRuntimeTaskRecord { schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), @@ -8436,7 +8916,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.checkpoint、project.restore、project.diff、file.list、file.read、file.write、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。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.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,不要泄露密钥。" } pub(crate) fn game_creator_agent_role_definition( @@ -9108,6 +9588,28 @@ pub(crate) async fn request_game_creator_llm_text( } } +async fn request_game_creator_agent_llm_text_retrying_empty( + client: &LlmClient, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, + operation: &str, +) -> Result { + const MAX_EMPTY_RETRIES: u32 = 3; + let mut empty_retries = 0u32; + loop { + match request_game_creator_llm_text(client, llm, request.clone()).await { + Ok(response) => return Ok(response), + Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => { + empty_retries += 1; + eprintln!( + "agent.runtime.llm.empty-response: {operation} 自动重试 {empty_retries}/{MAX_EMPTY_RETRIES}" + ); + } + Err(error) => return Err(error), + } + } +} + pub(crate) async fn request_agent_group_briefs_with_client( root: &Path, app_config: &GameCreatorAppConfig, 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 1b6f65338..f223a03be 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -1703,7 +1703,10 @@ async fn role_agent_legacy_alias_maps_to_canonical_task_runtime_and_route() { assert_eq!(runtime_wire["currentGoal"], "排队规划美术资产"); assert_eq!(runtime_wire["waitingOn"], "Agent 输出计划或回复"); assert_eq!(runtime_wire["loopIteration"], 0); - assert_eq!(runtime_wire["maxLoopIterations"], 3); + assert_eq!( + runtime_wire["maxLoopIterations"], + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u64 + ); assert_eq!(runtime_wire["toolActionBudget"], 3); assert_eq!(runtime_wire["activePlanStepIndex"], 0); assert_eq!( @@ -1723,7 +1726,10 @@ async fn role_agent_legacy_alias_maps_to_canonical_task_runtime_and_route() { assert_eq!(alias_read.state.current_goal, "排队规划美术资产"); assert_eq!(alias_read.state.waiting_on, "Agent 输出计划或回复"); assert_eq!(alias_read.state.loop_iteration, 0); - assert_eq!(alias_read.state.max_loop_iterations, 3); + assert_eq!( + alias_read.state.max_loop_iterations, + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32 + ); assert_eq!(alias_read.state.tool_action_budget, 3); assert_eq!(alias_read.state.active_plan_step_index, Some(0)); assert_eq!(alias_read.task_queue.total, 1); @@ -2640,7 +2646,7 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); let (sender, receiver) = mpsc::channel(); - let responses = (1..=3) + let responses = (1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT) .map(|iteration| { serde_json::json!({ "thinkingSummary": format!("第 {iteration} 轮仍要求继续读取项目索引"), @@ -2676,7 +2682,7 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { "design-budget-exhausted-run", ) .expect("start background task"); - for iteration in 1..=3 { + for iteration in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { let request = receiver .recv_timeout(Duration::from_secs(2)) .expect("planning request"); @@ -3459,7 +3465,9 @@ async fn background_agent_runtime_loads_same_agent_continuity_through_tool_obser assert!(second_design_replan_request.contains("conversation.read")); assert!(second_design_replan_request.contains("status: running")); assert!(second_design_replan_request.contains("runId: design-continuity-second")); - assert!(second_design_replan_request.contains("循环轮次: 1/3")); + assert!(second_design_replan_request.contains(&format!( + "循环轮次: 1/{AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT}" + ))); assert!(second_design_replan_request.contains("每轮工具预算: 3")); assert!(second_design_replan_request.contains("当前计划步骤: #1 [active] 读取本 Agent Runtime")); assert!(second_design_replan_request.contains( @@ -7802,7 +7810,7 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() assert_eq!(pending_action.tool, "file.read"); assert_eq!( pending_action.input_summary.as_deref(), - Some("path=game/notes.txt") + Some("path=game/notes.txt · startLine=1 · maxLines=120") ); assert!(root .join(".agent/runtime/pending-actions/design-director/design-confirm-run.json") @@ -7828,7 +7836,7 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() .expect("pending tool call"); assert_eq!( pending_tool_call.input_summary.as_deref(), - Some("path=game/notes.txt") + Some("path=game/notes.txt · startLine=1 · maxLines=120") ); assert_eq!( pending_tool_call @@ -7882,7 +7890,9 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() assert!(agent_db.contains(&format!("\"actionId\":\"{}\"", pending_action.action_id))); assert!(agent_db.contains("\"tool\":\"file.read\"")); assert!(agent_db.contains("\"actionFingerprint\":")); - assert!(agent_db.contains("\"inputSummary\":\"path=game/notes.txt\"")); + assert!( + agent_db.contains("\"inputSummary\":\"path=game/notes.txt · startLine=1 · maxLines=120\"") + ); fs::remove_dir_all(root).ok(); } @@ -8401,6 +8411,615 @@ async fn background_agent_runtime_file_list_respects_project_policy() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn agent_runtime_project_search_is_literal_scoped_and_skips_sensitive_files() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write( + root.join("game/search-notes.txt"), + "moon.*[loop]\nMOON.*[LOOP]\nMOON.*[LOOP]\n", + ) + .expect("write searchable game file"); + fs::write(root.join("assets/search-hints.txt"), "MOON.*[LOOP]\n") + .expect("write searchable asset file"); + fs::write(root.join(".agent/private-search.txt"), "MOON.*[LOOP]\n") + .expect("write private runtime file"); + fs::write(root.join(".env"), "SEARCH_SECRET=MOON.*[LOOP]\n").expect("write env file"); + fs::write( + root.join(GAME_CREATOR_CONFIG_FILE_NAME), + "{\"secret\":\"MOON.*[LOOP]\"}\n", + ) + .expect("write sensitive config file"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow project search"); + + let default_search = execute_game_creator_agent_runtime_tool_action( + &root, + "design-director", + "project-search-default-run", + "搜索字面文本", + &AgentRuntimeToolAction { + tool: "project.search".to_string(), + reason: Some("验证默认大小写不敏感的字面量搜索".to_string()), + input: serde_json::json!({ "query": "MOON.*[LOOP]" }), + }, + ) + .await; + + assert_eq!(default_search.status, "ok"); + let default_detail = default_search.detail.as_deref().expect("search detail"); + assert!(default_detail.contains("game/search-notes.txt:1: moon.*[loop]")); + assert!(default_detail.contains("assets/search-hints.txt:1: MOON.*[LOOP]")); + assert!(!default_detail.contains(".agent/private-search.txt")); + assert!(!default_detail.contains("SEARCH_SECRET")); + assert!(!default_detail.contains(GAME_CREATOR_CONFIG_FILE_NAME)); + + let scoped_search = execute_game_creator_agent_runtime_tool_action( + &root, + "design-director", + "project-search-scoped-run", + "限制搜索范围和结果数量", + &AgentRuntimeToolAction { + tool: "project.search".to_string(), + reason: Some("验证 path、maxResults 和 caseSensitive".to_string()), + input: serde_json::json!({ + "query": "MOON.*[LOOP]", + "path": "game", + "maxResults": 1, + "caseSensitive": true + }), + }, + ) + .await; + + assert_eq!(scoped_search.status, "ok"); + assert!(scoped_search.summary.contains("1 个匹配")); + let scoped_detail = scoped_search.detail.as_deref().expect("scoped detail"); + assert!(scoped_detail.contains("game/search-notes.txt:2: MOON.*[LOOP]")); + assert!(!scoped_detail.contains("game/search-notes.txt:1:")); + assert!(!scoped_detail.contains("game/search-notes.txt:3:")); + assert!(!scoped_detail.contains("assets/search-hints.txt")); + assert!(scoped_detail.contains("结果已限制为前 1 条")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_file_read_returns_numbered_slice_and_total_lines() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write( + root.join("game/read-window.txt"), + "alpha\nbeta\ngamma\ndelta\nepsilon\n", + ) + .expect("write read window"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow file read"); + + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + "design-director", + "file-read-window-run", + "读取文件中间片段", + &AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("只读取第二到第三行".to_string()), + input: serde_json::json!({ + "path": "game/read-window.txt", + "startLine": 2, + "maxLines": 2 + }), + }, + ) + .await; + + assert_eq!(observation.status, "ok"); + assert_eq!( + observation.summary, + "已读取 game/read-window.txt 第 2-3 行(共 5 行)" + ); + let detail = observation.detail.as_deref().expect("file read detail"); + assert!(detail.contains("game/read-window.txt · lines 2-3 of 5")); + assert!(detail.contains("2 | beta")); + assert!(detail.contains("3 | gamma")); + assert!(detail.contains("还有 2 行,可从 startLine=4 继续读取")); + assert!(!detail.contains("1 | alpha")); + assert!(!detail.contains("4 | delta")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_file_patch_replaces_exact_count_and_writes_audit() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write( + root.join("game/patch-notes.txt"), + "mode = draft\nreward = draft\n", + ) + .expect("write patch target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow file patch"); + + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + "design-director", + "file-patch-success-run", + "精确更新草稿状态", + &AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: Some("只替换两个完全相同的 draft 字面文本".to_string()), + input: serde_json::json!({ + "path": "game/patch-notes.txt", + "oldText": "draft", + "newText": "ready", + "expectedReplacements": 2 + }), + }, + ) + .await; + + assert_eq!(observation.status, "ok"); + assert_eq!( + fs::read_to_string(root.join("game/patch-notes.txt")).expect("patched file"), + "mode = ready\nreward = ready\n" + ); + let patch_records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| record["recordType"] == "agent.runtime.file.patch") + .collect::>(); + assert_eq!(patch_records.len(), 1); + assert_eq!(patch_records[0]["agentId"], "design-director"); + assert_eq!(patch_records[0]["path"], "game/patch-notes.txt"); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_file_patch_preserves_file_when_match_count_differs() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let original = "draft\ndraft\n"; + fs::write(root.join("game/patch-mismatch.txt"), original).expect("write patch target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow file patch"); + + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + "design-director", + "file-patch-mismatch-run", + "拒绝不确定的补丁", + &AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: Some("声明只应存在一个匹配".to_string()), + input: serde_json::json!({ + "path": "game/patch-mismatch.txt", + "oldText": "draft", + "newText": "ready", + "expectedReplacements": 1 + }), + }, + ) + .await; + + assert_eq!(observation.status, "failed"); + assert!(observation.summary.contains('1')); + assert!(observation.summary.contains('2')); + assert_eq!( + fs::read_to_string(root.join("game/patch-mismatch.txt")).expect("unchanged file"), + original + ); + assert!(!read_agent_db_records_for_test(&root) + .iter() + .any(|record| record["recordType"] == "agent.runtime.file.patch")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_search_and_patch_inherit_file_read_write_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write(root.join("game/policy-notes.txt"), "mode = draft\n").expect("write policy target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["file.write".to_string()], + confirm_commands: vec!["file.read".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write inherited policy"); + + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "检查搜索和补丁策略", + "search-patch-policy-run", + "agent-chat", + "读取工具策略", + vec!["核对继承关系".to_string()], + ) + .expect("start runtime"); + assert!(runtime + .tool_policy + .confirm_tools + .contains(&"file.read".to_string())); + assert!(runtime + .tool_policy + .confirm_tools + .contains(&"project.search".to_string())); + assert!(runtime + .tool_policy + .denied_tools + .contains(&"file.write".to_string())); + assert!(runtime + .tool_policy + .denied_tools + .contains(&"file.patch".to_string())); + + let search = execute_game_creator_agent_runtime_tool_action( + &root, + "design-director", + "search-policy-direct-run", + "搜索项目文件", + &AgentRuntimeToolAction { + tool: "project.search".to_string(), + reason: Some("验证继承 file.read 确认策略".to_string()), + input: serde_json::json!({ "query": "mode = draft" }), + }, + ) + .await; + assert_eq!(search.status, "waiting-for-confirmation"); + assert!(search.summary.contains("file.read")); + + let patch = execute_game_creator_agent_runtime_tool_action( + &root, + "design-director", + "patch-policy-direct-run", + "修改项目文件", + &AgentRuntimeToolAction { + tool: "file.patch".to_string(), + reason: Some("验证继承 file.write 拒绝策略".to_string()), + input: serde_json::json!({ + "path": "game/policy-notes.txt", + "oldText": "draft", + "newText": "ready", + "expectedReplacements": 1 + }), + }, + ) + .await; + assert_eq!(patch.status, "blocked"); + assert!(patch.summary.contains("file.write")); + assert_eq!( + fs::read_to_string(root.join("game/policy-notes.txt")).expect("unchanged policy target"), + "mode = draft\n" + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_can_search_patch_and_read_in_sequence() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write( + root.join("game/runtime-tool-loop.txt"), + "mode = draft\nscore = 10\n", + ) + .expect("write runtime loop target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow runtime tools"); + let (sender, receiver) = mpsc::channel(); + let plan_json = serde_json::json!({ + "thinkingSummary": "先定位草稿状态,再精确替换并回读验证", + "plan": ["搜索草稿状态", "精确修改文件", "回读修改结果"], + "actions": [ + { + "tool": "project.search", + "reason": "确认待修改文本的位置", + "input": { + "query": "mode = draft", + "path": "game", + "maxResults": 10, + "caseSensitive": true + } + }, + { + "tool": "file.patch", + "reason": "只修改已定位到的一处草稿状态", + "input": { + "path": "game/runtime-tool-loop.txt", + "oldText": "mode = draft", + "newText": "mode = ready", + "expectedReplacements": 1 + } + }, + { + "tool": "file.read", + "reason": "回读文件验证补丁结果", + "input": { + "path": "game/runtime-tool-loop.txt", + "startLine": 1, + "maxLines": 2 + } + } + ], + "response": "" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![plan_json, "已完成搜索、精确补丁和回读验证。".to_string()], + 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" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "把 runtime-tool-loop.txt 从 draft 更新为 ready", + "design-search-patch-read-run", + ) + .expect("start background task"); + + let plan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("plan llm request"); + assert!(plan_request.contains("project.search")); + assert!(plan_request.contains("file.patch")); + assert!(plan_request.contains("file.read")); + assert!(plan_request.contains("caseSensitive")); + assert!(plan_request.contains("startLine")); + assert!(plan_request.contains("expectedReplacements")); + assert!(plan_request.contains("\"max_output_tokens\":4000")); + assert!(plan_request.contains("\"reasoning\":{\"effort\":\"low\"}")); + let verification_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("verification llm request"); + assert!(verification_request.contains("game/runtime-tool-loop.txt:1: mode = draft")); + assert!(verification_request.contains("file.patch")); + assert!(verification_request.contains("1 | mode = ready")); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!( + runtime + .recent_tool_calls + .iter() + .map(|call| (call.tool.as_str(), call.status.as_str())) + .collect::>(), + vec![ + ("project.search", "ok"), + ("file.patch", "ok"), + ("file.read", "ok"), + ] + ); + assert_eq!( + fs::read_to_string(root.join("game/runtime-tool-loop.txt")).expect("updated loop file"), + "mode = ready\nscore = 10\n" + ); + assert!(read_agent_db_records_for_test(&root).iter().any(|record| { + record["recordType"] == "agent.runtime.file.patch" + && record["agentId"] == "design-director" + && record["path"] == "game/runtime-tool-loop.txt" + })); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_retries_empty_plan_and_final_responses() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let converged_plan = serde_json::json!({ + "thinkingSummary": "已有信息足够,可以整理回复", + "plan": ["整理最终回复"], + "actions": [], + "response": "" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![ + String::new(), + converged_plan, + String::new(), + "空响应重试后已恢复。EMPTY_RETRY_OK".to_string(), + ], + 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_chat" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证后台 Agent 空响应恢复", + "design-empty-response-retry-run", + ) + .expect("start background task"); + + for request_index in 0..4 { + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .unwrap_or_else(|_| panic!("captured llm request {}", request_index + 1)); + assert!(request.contains("POST /chat/completions HTTP/1.1")); + if request_index < 2 { + assert!(request.contains("\"max_tokens\":4000")); + } else { + assert!(request.contains("\"max_tokens\":2400")); + } + assert!(request.contains("\"reasoning_effort\":\"low\"")); + } + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!( + runtime.last_response.as_deref(), + Some("空响应重试后已恢复。EMPTY_RETRY_OK") + ); + assert!(runtime.error.is_none()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_preserves_long_task_tail_through_confirmation() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write(root.join("game/long-task.txt"), "mode = draft\n").expect("write patch target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.write".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("require file write confirmation"); + let tail_marker = "FINAL_ACCEPTANCE_TAIL_MUST_SURVIVE"; + let long_task = format!( + "{};最终验收条件:{tail_marker}", + "请逐项保留这段复杂需求并完成局部修改".repeat(24) + ); + assert!(long_task.chars().count() > 180); + let (sender, receiver) = mpsc::channel(); + let plan = serde_json::json!({ + "thinkingSummary": "保留完整任务并请求精确修改", + "plan": ["修改目标文件", "按尾部验收条件回复"], + "actions": [{ + "tool": "file.patch", + "reason": "验证长任务跨确认仍完整", + "input": { + "path": "game/long-task.txt", + "oldText": "mode = draft", + "newText": "mode = ready", + "expectedReplacements": 1 + } + }], + "response": "" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture(vec![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" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + &long_task, + "design-long-task-tail-run", + ) + .expect("start long background task"); + + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("captured planning request"); + assert!(request.contains(tail_marker)); + let runtime = wait_for_agent_runtime_confirmation(&root, "design-director"); + assert!(runtime.current_task.contains(tail_marker)); + assert!(runtime.current_goal.contains(tail_marker)); + let pending: Value = + serde_json::from_str( + &fs::read_to_string(root.join( + ".agent/runtime/pending-actions/design-director/design-long-task-tail-run.json", + )) + .expect("read pending file patch"), + ) + .expect("parse pending file patch"); + assert!(pending["task"] + .as_str() + .is_some_and(|task| task.contains(tail_marker))); + let task_records = fs::read_to_string(root.join(".agent/runtime/tasks/design-director.jsonl")) + .expect("read task records"); + assert!(task_records.lines().any(|line| { + serde_json::from_str::(line) + .ok() + .is_some_and(|record| { + record["runId"] == "design-long-task-tail-run" + && record["task"] + .as_str() + .is_some_and(|task| task.contains(tail_marker)) + }) + })); + assert_eq!( + fs::read_to_string(root.join("game/long-task.txt")).expect("unmodified target"), + "mode = draft\n" + ); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_create_checkpoint_before_file_write() { let root = unique_project_path(); @@ -9042,7 +9661,9 @@ async fn background_agent_runtime_can_read_other_agent_status() { assert!(final_request.contains("agentId: art-director")); assert!(final_request.contains("status: running")); assert!(final_request.contains("phase: action")); - assert!(final_request.contains("循环轮次: 0/3")); + assert!(final_request.contains(&format!( + "循环轮次: 0/{AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT}" + ))); assert!(final_request.contains("每轮工具预算: 3")); assert!(final_request.contains("当前计划步骤: #1 [active] 确认角色设定")); assert!(final_request.contains("当前目标: 生成角色规范图")); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 02421f0fa..c4eb97d75 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -22,6 +22,11 @@ - 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 按轮输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具并记录 `action / observation` 事件,再把已有 observation 放回下一轮 prompt,让 Agent 修正计划、继续行动或用空 actions + response 收束;当前后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加共享黑板,`agent.message` 写目标 Agent 对话,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列;策略拒绝时不执行工具并把 `blocked` observation 回给 Agent;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。每个 Agent 的任务历史落在 `.agent/runtime/tasks/.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,Runtime state 增加 `nextStep`,UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。 - 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 的空 `choices` usage 事件不再报错,事件监听不可用或首个文本片段前流式失败时降级普通回复。 +- 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 的 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-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/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 646ddae8b..3f0cbfa48 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -53,6 +53,11 @@ Agent Runtime 负责: - 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`,开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态卡用同一套前端归一化逻辑合并状态;该事件只做实时 UI 通知,`.agent/runtime/agents`、`events` 和 `tasks` 仍是重开项目后的事实源。 - 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 的 `choices: []` usage 事件按非内容事件跳过,事件监听不可用或流式请求在首个文本片段前失败时自动降级普通回复。 +- 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 调整:后台单 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-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 调度按钮。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index d4abb27c8..0ec372972 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -215,6 +215,8 @@ struct ChatCompletionsRequestBody { #[serde(rename = "max_tokens")] max_output_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] + reasoning_effort: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] web_search_options: Option, } @@ -1344,6 +1346,9 @@ fn build_request_body(request: &LlmRunRequest, config: &LlmConfig, stream: bool) stream, official_fallback, max_output_tokens: request.max_output_tokens, + reasoning_effort: request + .response_reasoning_effort + .map(LlmResponseReasoningEffort::as_str), web_search_options: request .enable_web_search .then_some(ChatCompletionsWebSearchOptions {}), @@ -2371,7 +2376,8 @@ mod tests { }, ]), ]) - .with_openai_chat(), + .with_openai_chat() + .with_response_reasoning_effort(LlmResponseReasoningEffort::Low), ) .await .expect("run should succeed"); @@ -2389,6 +2395,7 @@ mod tests { assert_eq!(response.model, "gpt-4o-mini"); assert_eq!(response.text, r#"{"levelName":"雨夜猫街"}"#); assert_eq!(request_json["official_fallback"], serde_json::json!(true)); + assert_eq!(request_json["reasoning_effort"], "low"); assert_eq!( request_json["messages"][1]["content"], serde_json::json!([