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 5ea6d68d8..ff02c8485 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -37,6 +37,10 @@ pub(crate) const AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES: usize = 128 * 1024; const AGENT_RUNTIME_CONTEXT_WINDOW_FINGERPRINT_LIMIT: usize = AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT * (AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT + 1); const AGENT_RUNTIME_CONTEXT_CONTENT_DIFF_DETAIL_MAX_CHARS: usize = 24_256; +const AGENT_RUNTIME_GIT_INSPECT_DEFAULT_FILES: usize = 20; +const AGENT_RUNTIME_GIT_INSPECT_MAX_FILES: usize = 50; +const AGENT_RUNTIME_GIT_INSPECT_DEFAULT_CHARS: usize = 24_000; +const AGENT_RUNTIME_GIT_INSPECT_MAX_CHARS: usize = 24_000; const AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE: &str = "agent-delegate-receipt"; const AGENT_RUNTIME_DELEGATE_RECEIPT_TASK_MAX_CHARS: usize = 900; pub(crate) const AGENT_RUNTIME_ISOLATED_CHILD_SOURCE: &str = "agent-isolated-child"; @@ -4171,13 +4175,11 @@ fn sanitize_agent_runtime_context_observation( root: &Path, observation: &AgentRuntimeToolObservation, ) -> AgentRuntimeToolObservation { - let detail_limit = if observation.tool == "project.diff" + let detail_limit = if matches!(observation.tool.as_str(), "project.diff" | "git.inspect") && observation.status == "ok" - && observation - .detail - .as_deref() - .is_some_and(|detail| detail.contains("contentFileCount:")) - { + && observation.detail.as_deref().is_some_and(|detail| { + detail.contains("contentFileCount:") || detail.contains("gitContentFileCount:") + }) { AGENT_RUNTIME_CONTEXT_CONTENT_DIFF_DETAIL_MAX_CHARS } else { 1_600 @@ -4230,12 +4232,11 @@ pub(crate) fn compact_agent_runtime_context_observations( retained_indexes.insert(index); } let latest_content_diff_index = sanitized.iter().rposition(|observation| { - observation.tool == "project.diff" + matches!(observation.tool.as_str(), "project.diff" | "git.inspect") && observation.status == "ok" - && observation - .detail - .as_deref() - .is_some_and(|detail| detail.contains("contentFileCount:")) + && observation.detail.as_deref().is_some_and(|detail| { + detail.contains("contentFileCount:") || detail.contains("gitContentFileCount:") + }) }); if let Some(index) = latest_content_diff_index { retained_indexes.insert(index); @@ -6360,6 +6361,24 @@ pub(crate) fn agent_runtime_tool_action_input_summary( .and_then(serde_json::Value::as_u64) .unwrap_or(AGENT_RUNTIME_PROJECT_DIFF_CONTENT_DEFAULT_CHARS as u64) ), + "git.inspect" => format!( + "includeDiff={} · maxFiles={} · maxChars={}", + input + .get("includeDiff") + .or_else(|| input.get("include_diff")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + input + .get("maxFiles") + .or_else(|| input.get("max_files")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(AGENT_RUNTIME_GIT_INSPECT_DEFAULT_FILES as u64), + input + .get("maxChars") + .or_else(|| input.get("max_chars")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(AGENT_RUNTIME_GIT_INSPECT_DEFAULT_CHARS as u64) + ), "project.patchset" => { let changes = input .get("changes") @@ -6894,7 +6913,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} 个白名单工具。请按后续结构化工具计划协议提交本轮结果。\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|project.patchset|file.list|file.read|file.write|file.patch|file.delete|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\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000}},用于读取路径摘要或有界统一 diff hunks;project.patchset 使用 {{\"changes\":[{{\"operation\":\"create|update|delete\",\"path\":\"项目内相对文件\",\"content\":\"create 内容\",\"expectedSha256\":\"update/delete 必填\",\"oldText\":\"update 必填\",\"newText\":\"update 必填\",\"expectedReplacements\":1}}]}},会自动 checkpoint 并在一把锁内应用多文件变更,成功后必须用返回的 checkpointId 调用 project.diff includeContent=true 审查整体变更;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}},按行读取并返回行号和完整内容 SHA-256;file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}},匹配数不符时不写入;file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件;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} 个白名单工具。请按后续结构化工具计划协议提交本轮结果。\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|git.inspect|project.patchset|file.list|file.read|file.write|file.patch|file.delete|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\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000}},用于读取路径摘要或有界统一 diff hunks;git.inspect 使用 {{\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000}},只读当前项目根的 Git staged / unstaged / untracked 安全路径和有界 staged / unstaged diff,不推进 revision;不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote;project.patchset 使用 {{\"changes\":[{{\"operation\":\"create|update|delete\",\"path\":\"项目内相对文件\",\"content\":\"create 内容\",\"expectedSha256\":\"update/delete 必填\",\"oldText\":\"update 必填\",\"newText\":\"update 必填\",\"expectedReplacements\":1}}]}},会自动 checkpoint 并在一把锁内应用多文件变更,成功后必须用返回的 checkpointId 调用 project.diff includeContent=true 审查整体变更;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}},按行读取并返回行号和完整内容 SHA-256;file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}},匹配数不符时不写入;file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件;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 prompt = prompt .replace( @@ -7227,6 +7246,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ observe_agent_runtime_project_restore(root, agent_id, run_id, &action.input) } "project.diff" => observe_agent_runtime_project_diff(root, &action.input), + "git.inspect" => observe_agent_runtime_git_inspect(root, &action.input), "project.patchset" => observe_agent_runtime_project_patchset( root, agent_id, @@ -7306,6 +7326,7 @@ fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str "project.checkpoint" => Some("project.checkpoint"), "project.restore" => Some("project.restore"), "project.diff" => Some("project.diff"), + "git.inspect" => Some("project.git_inspect"), "project.patchset" => Some("project.patchset"), "file.list" => Some("file.list"), "file.read" => Some("file.read"), @@ -7834,6 +7855,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "project.checkpoint", "project.restore", "project.diff", + "git.inspect", "project.patchset", "file.list", "file.read", @@ -9071,6 +9093,114 @@ fn observe_agent_runtime_project_diff( } } +fn observe_agent_runtime_git_inspect( + root: &Path, + input: &serde_json::Value, +) -> AgentRuntimeToolObservation { + let include_diff = match input + .get("includeDiff") + .or_else(|| input.get("include_diff")) + { + Some(value) => match value.as_bool() { + Some(value) => value, + None => { + return AgentRuntimeToolObservation { + tool: "git.inspect".to_string(), + status: "failed".to_string(), + summary: "includeDiff 必须是布尔值".to_string(), + detail: None, + }; + } + }, + None => true, + }; + let parse_limit = |camel_key: &str, + snake_key: &str, + default_value: usize, + max_value: usize| + -> Result { + let Some(value) = input.get(camel_key).or_else(|| input.get(snake_key)) else { + return Ok(default_value); + }; + let value = value + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| format!("{camel_key} 必须是正整数"))?; + if value == 0 || value > max_value { + return Err(format!("{camel_key} 必须在 1..={max_value} 之间")); + } + Ok(value) + }; + let max_files = match parse_limit( + "maxFiles", + "max_files", + AGENT_RUNTIME_GIT_INSPECT_DEFAULT_FILES, + AGENT_RUNTIME_GIT_INSPECT_MAX_FILES, + ) { + Ok(value) => value, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "git.inspect".to_string(), + status: "failed".to_string(), + summary: error, + detail: None, + }; + } + }; + let max_chars = match parse_limit( + "maxChars", + "max_chars", + AGENT_RUNTIME_GIT_INSPECT_DEFAULT_CHARS, + AGENT_RUNTIME_GIT_INSPECT_MAX_CHARS, + ) { + Ok(value) => value, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "git.inspect".to_string(), + status: "failed".to_string(), + summary: error, + detail: None, + }; + } + }; + match inspect_local_git_worktree_at(root, include_diff, max_files, max_chars) { + Ok(inspect) => { + let detail = format!( + "head: {}\nbranch: {}\nstaged: {}\nunstaged: {}\nuntracked: {}\ngitContentFileCount: {}\ngitContentTruncated: {}\n{}", + inspect.head, + inspect.branch.as_deref().unwrap_or("(detached)"), + inspect.staged.len(), + inspect.unstaged.len(), + inspect.untracked.len(), + inspect.file_count, + inspect.truncated, + inspect.content, + ); + AgentRuntimeToolObservation { + tool: "git.inspect".to_string(), + status: "ok".to_string(), + summary: format!( + "已审阅 Git 工作树:staged {} · unstaged {} · untracked {}", + inspect.staged.len(), + inspect.unstaged.len(), + inspect.untracked.len() + ), + detail: Some(redact_agent_runtime_project_paths( + root, + &detail, + max_chars.saturating_add(512), + )), + } + } + Err(error) => AgentRuntimeToolObservation { + tool: "git.inspect".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 320), + detail: None, + }, + } +} + fn observe_agent_runtime_file_list( root: &Path, input: &serde_json::Value, diff --git a/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs new file mode 100644 index 000000000..d13ebb58f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/git_inspect.rs @@ -0,0 +1,491 @@ +use crate::project::{normalize_relative_path, reject_sensitive_project_file_read}; +use std::collections::BTreeSet; +use std::fmt::Write as _; +use std::io::Read; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +const GIT_INSPECT_TIMEOUT: Duration = Duration::from_secs(3); +const GIT_INSPECT_OUTPUT_MAX_BYTES: usize = 256 * 1024; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct LocalGitWorktreeInspect { + pub(crate) head: String, + pub(crate) branch: Option, + pub(crate) staged: Vec, + pub(crate) unstaged: Vec, + pub(crate) untracked: Vec, + pub(crate) file_count: usize, + pub(crate) truncated: bool, + pub(crate) content: String, +} + +pub(crate) fn inspect_local_git_worktree_at( + root: &Path, + include_diff: bool, + max_files: usize, + max_chars: usize, +) -> Result { + let root = root + .canonicalize() + .map_err(|error| format!("读取项目目录失败:{error}"))?; + ensure_git_top_level(&root)?; + + let head = read_git_head(&root); + let branch = read_git_branch(&root); + + let status = run_git( + &root, + &[ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--ignore-submodules=all", + "--no-renames", + ], + )?; + let (mut staged, mut unstaged, mut untracked) = parse_status(&root, &status); + staged.sort(); + staged.dedup(); + unstaged.sort(); + unstaged.dedup(); + untracked.sort(); + untracked.dedup(); + + let all_paths = staged + .iter() + .chain(&unstaged) + .chain(&untracked) + .cloned() + .collect::>(); + let file_count = all_paths.len(); + let selected_paths = all_paths + .into_iter() + .take(max_files) + .collect::>(); + let mut truncated = file_count > selected_paths.len(); + let mut content = String::new(); + append_path_section(&mut content, "staged files", &staged, &selected_paths); + append_path_section(&mut content, "unstaged files", &unstaged, &selected_paths); + append_path_section(&mut content, "untracked files", &untracked, &selected_paths); + + let mut staged_diff = String::new(); + let mut unstaged_diff = String::new(); + if include_diff { + let staged_paths = selected_paths + .iter() + .filter(|path| staged.contains(path)) + .cloned() + .collect::>(); + let unstaged_paths = selected_paths + .iter() + .filter(|path| unstaged.contains(path)) + .cloned() + .collect::>(); + staged_diff = read_diff(&root, true, &staged_paths)?; + unstaged_diff = read_diff(&root, false, &unstaged_paths)?; + append_diff_section(&mut content, "staged diff", &staged_diff); + append_diff_section(&mut content, "unstaged diff", &unstaged_diff); + } + + let status_after = run_git( + &root, + &[ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--ignore-submodules=all", + "--no-renames", + ], + )?; + let staged_diff_after = if include_diff { + let paths = selected_paths + .iter() + .filter(|path| staged.contains(path)) + .cloned() + .collect::>(); + read_diff(&root, true, &paths)? + } else { + String::new() + }; + let unstaged_diff_after = if include_diff { + let paths = selected_paths + .iter() + .filter(|path| unstaged.contains(path)) + .cloned() + .collect::>(); + read_diff(&root, false, &paths)? + } else { + String::new() + }; + if status_after != status + || read_git_head(&root) != head + || read_git_branch(&root) != branch + || staged_diff_after != staged_diff + || unstaged_diff_after != unstaged_diff + { + return Err("Git 工作树在审阅过程中发生变化,请重试".to_string()); + } + + if content.chars().count() > max_chars { + content = content.chars().take(max_chars).collect(); + content.push_str("\n[git inspect output truncated]\n"); + truncated = true; + } + + Ok(LocalGitWorktreeInspect { + head, + branch, + staged, + unstaged, + untracked, + file_count, + truncated, + content, + }) +} + +fn read_git_head(root: &Path) -> String { + run_git(root, &["rev-parse", "--verify", "HEAD"]) + .map(|output| output.trim().to_string()) + .unwrap_or_else(|_| "(unborn)".to_string()) +} + +fn read_git_branch(root: &Path) -> Option { + run_git(root, &["symbolic-ref", "--quiet", "--short", "HEAD"]) + .ok() + .map(|output| output.trim().to_string()) + .filter(|output| !output.is_empty()) +} + +fn ensure_git_top_level(root: &Path) -> Result<(), String> { + let top_level = run_git(root, &["rev-parse", "--show-toplevel"]) + .map_err(|_| "项目目录必须是 Git 仓库根目录".to_string())?; + let top_level = Path::new(top_level.trim()) + .canonicalize() + .map_err(|error| format!("读取 Git 仓库根目录失败:{error}"))?; + if top_level != root { + return Err("项目目录必须是 Git 仓库根目录".to_string()); + } + Ok(()) +} + +fn parse_status(root: &Path, output: &str) -> (Vec, Vec, Vec) { + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + let mut untracked = Vec::new(); + for record in output.split('\0').filter(|record| !record.is_empty()) { + let bytes = record.as_bytes(); + if bytes.len() < 4 || bytes[2] != b' ' { + continue; + } + let Some(path) = safe_git_path(root, &record[3..]) else { + continue; + }; + if bytes[0] == b'?' && bytes[1] == b'?' { + untracked.push(path); + continue; + } + if bytes[0] != b' ' && bytes[0] != b'?' { + staged.push(path.clone()); + } + if bytes[1] != b' ' && bytes[1] != b'?' { + unstaged.push(path); + } + } + (staged, unstaged, untracked) +} + +fn safe_git_path(root: &Path, path: &str) -> Option { + let normalized = normalize_relative_path(path).ok()?; + reject_sensitive_project_file_read(&normalized).ok()?; + let parts = normalized + .split('/') + .take(2) + .map(str::to_ascii_lowercase) + .collect::>(); + if parts.first().is_some_and(|part| part == ".git") { + return None; + } + if parts.first().is_some_and(|part| part == ".agent") + && parts + .get(1) + .is_some_and(|part| matches!(part.as_str(), "runtime" | "checkpoints")) + { + return None; + } + if !git_worktree_path_is_safe(root, &normalized) { + return None; + } + Some(normalized) +} + +fn git_worktree_path_is_safe(root: &Path, relative_path: &str) -> bool { + let mut current = root.to_path_buf(); + let components = relative_path.split('/').collect::>(); + for (index, component) in components.iter().enumerate() { + current.push(component); + match std::fs::symlink_metadata(¤t) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return false; + } + if index + 1 < components.len() && !metadata.is_dir() { + return false; + } + if index + 1 == components.len() + && (!metadata.is_file() || metadata_has_multiple_hard_links(&metadata)) + { + return false; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return index + 1 == components.len(); + } + Err(_) => return false, + } + } + true +} + +#[cfg(unix)] +fn metadata_has_multiple_hard_links(metadata: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + metadata.nlink() > 1 +} + +#[cfg(not(unix))] +fn metadata_has_multiple_hard_links(_metadata: &std::fs::Metadata) -> bool { + false +} + +fn append_path_section( + output: &mut String, + title: &str, + paths: &[String], + selected_paths: &BTreeSet, +) { + let selected = paths + .iter() + .filter(|path| selected_paths.contains(*path)) + .collect::>(); + if selected.is_empty() { + return; + } + let _ = writeln!(output, "\n## {title}"); + for path in selected { + let _ = writeln!(output, "- {path}"); + } +} + +fn read_diff(root: &Path, cached: bool, paths: &[String]) -> Result { + if paths.is_empty() { + return Ok(String::new()); + } + let mut args = vec![ + "diff".to_string(), + "--no-ext-diff".to_string(), + "--no-color".to_string(), + "--no-renames".to_string(), + "--unified=3".to_string(), + ]; + if cached { + args.push("--cached".to_string()); + } + args.push("--".to_string()); + args.extend(paths.iter().cloned()); + run_git_owned(root, &args) +} + +fn append_diff_section(output: &mut String, title: &str, diff: &str) { + if !diff.is_empty() { + let _ = write!(output, "\n## {title}\n{diff}"); + if !diff.ends_with('\n') { + output.push('\n'); + } + } +} + +fn run_git(root: &Path, args: &[&str]) -> Result { + run_git_owned( + root, + &args + .iter() + .map(|arg| (*arg).to_string()) + .collect::>(), + ) +} + +fn run_git_owned(root: &Path, args: &[String]) -> Result { + let inherited_environment = ["PATH", "SystemRoot", "WINDIR", "PATHEXT"] + .into_iter() + .filter_map(|key| std::env::var_os(key).map(|value| (key, value))) + .collect::>(); + let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" }; + let mut command = Command::new("git"); + command.env_clear(); + for (key, value) in inherited_environment { + command.env(key, value); + } + command + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_SYSTEM", null_device) + .env("GIT_CONFIG_GLOBAL", null_device) + .env("GIT_OPTIONAL_LOCKS", "0") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_PAGER", "cat") + .env("PAGER", "cat") + .env("TERM", "dumb") + .current_dir(root) + .arg("--no-pager") + .arg("--no-optional-locks") + .arg("-c") + .arg("core.fsmonitor=false") + .arg("-c") + .arg(format!("core.hooksPath={null_device}")) + .arg("-c") + .arg("core.pager=cat") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(parent) = root.parent() { + command.env("GIT_CEILING_DIRECTORIES", parent); + } + let mut child = command + .spawn() + .map_err(|error| format!("启动 Git 检查失败:{error}"))?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let stdout_reader = thread::spawn(move || read_bounded(stdout)); + let stderr_reader = thread::spawn(move || read_bounded(stderr)); + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if started.elapsed() < GIT_INSPECT_TIMEOUT => { + thread::sleep(Duration::from_millis(10)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("Git 检查超时".to_string()); + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("等待 Git 检查失败:{error}")); + } + } + }; + let stdout = stdout_reader.join().unwrap_or_default(); + let stderr = stderr_reader.join().unwrap_or_default(); + if !status.success() { + let detail = String::from_utf8_lossy(&stderr).trim().to_string(); + return Err(if detail.is_empty() { + "Git 检查失败".to_string() + } else { + format!("Git 检查失败:{detail}") + }); + } + Ok(String::from_utf8_lossy(&stdout).into_owned()) +} + +fn read_bounded(stream: Option) -> Vec { + let Some(mut stream) = stream else { + return Vec::new(); + }; + let mut collected = Vec::new(); + let mut buffer = [0_u8; 8 * 1024]; + while let Ok(read) = stream.read(&mut buffer) { + if read == 0 { + break; + } + let remaining = GIT_INSPECT_OUTPUT_MAX_BYTES.saturating_sub(collected.len()); + collected.extend_from_slice(&buffer[..read.min(remaining)]); + } + collected +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn git(root: &Path, args: &[&str]) { + let status = Command::new("git") + .current_dir(root) + .args(args) + .status() + .expect("run git fixture command"); + assert!(status.success()); + } + + #[test] + fn inspects_bounded_changes_and_filters_sensitive_paths() { + let fixture = tempfile::tempdir().expect("create fixture"); + let root = fixture.path(); + git(root, &["init", "-q"]); + git(root, &["config", "user.name", "Test"]); + git(root, &["config", "user.email", "test@example.invalid"]); + fs::write(root.join("game.txt"), "first\n").expect("write tracked file"); + git(root, &["add", "game.txt"]); + git(root, &["commit", "-qm", "initial"]); + fs::write(root.join("game.txt"), "first\nsecond\n").expect("update tracked file"); + fs::write(root.join("new.txt"), "new\n").expect("write untracked file"); + fs::write(root.join(".env"), "SECRET=hidden\n").expect("write secret file"); + + let result = + inspect_local_git_worktree_at(root, true, 20, 24_000).expect("inspect git worktree"); + + assert_eq!(result.unstaged, vec!["game.txt"]); + assert_eq!(result.untracked, vec!["new.txt"]); + assert!(result.content.contains("+second")); + assert!(!result.content.contains("SECRET")); + assert!(!result.content.contains(".env")); + } + + #[test] + fn rejects_a_project_nested_inside_another_repository() { + let fixture = tempfile::tempdir().expect("create fixture"); + git(fixture.path(), &["init", "-q"]); + let nested = fixture.path().join("nested"); + fs::create_dir(&nested).expect("create nested directory"); + + let error = inspect_local_git_worktree_at(&nested, false, 20, 24_000) + .expect_err("nested project must not inspect parent repository"); + + assert!(error.contains("仓库根目录")); + } + + #[cfg(unix)] + #[test] + fn omits_hard_linked_and_symlinked_worktree_paths() { + let fixture = tempfile::tempdir().expect("create fixture"); + let outside = tempfile::tempdir().expect("create outside fixture"); + let root = fixture.path(); + git(root, &["init", "-q"]); + fs::write(outside.path().join("shared.txt"), "outside hard link\n") + .expect("write hard link source"); + fs::hard_link( + outside.path().join("shared.txt"), + root.join("hard-linked.txt"), + ) + .expect("create hard link"); + std::os::unix::fs::symlink( + outside.path().join("shared.txt"), + root.join("symlinked.txt"), + ) + .expect("create symlink"); + + let result = + inspect_local_git_worktree_at(root, true, 20, 24_000).expect("inspect worktree"); + + assert!(!result.untracked.contains(&"hard-linked.txt".to_string())); + assert!(!result.untracked.contains(&"symlinked.txt".to_string())); + assert!(!result.content.contains("outside hard link")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 2e53c0ac2..7895d48bf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -49,6 +49,7 @@ mod commands; mod config; #[cfg(all(debug_assertions, not(test)))] mod debug; +mod git_inspect; mod isolated_agent; mod patchset; mod preview; @@ -64,6 +65,7 @@ use cli::*; use command_exec::*; use commands::*; use config::*; +use git_inspect::*; use isolated_agent::*; use patchset::*; use preview::*; diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7687fab7b..49b6a4a19 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -944,17 +944,37 @@ function AgentRuntimeStatusPanel({
Runtime 状态读取失败 -
-

{error}

- {onRefreshRuntime ? ( - ) : null} + + {collapsed ? null : ( +
+

{error}

+ {onRefreshRuntime ? ( +
+ +
+ ) : null} +
+ )}
); } diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index e5fcdd3ed..0befc3e61 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -3599,6 +3599,14 @@ describe('AI 游戏创作 App 界面边界', () => { expect(await screen.findByText(/已读取 0 条/)).not.toBeNull(); expect(await screen.findByText('Runtime 状态读取失败')).not.toBeNull(); expect(screen.getByText('runtime json broken')).not.toBeNull(); + fireEvent.click( + screen.getByRole('button', { name: '折叠 Runtime 详情' }), + ); + expect(screen.queryByText('runtime json broken')).toBeNull(); + fireEvent.click( + screen.getByRole('button', { name: '展开 Runtime 详情' }), + ); + expect(screen.getByText('runtime json broken')).not.toBeNull(); }); it('persists developer agent chat reply failures after saving the user message', async () => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1938b6db5..ce5ad776e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4204,3 +4204,9 @@ - 决策:patchset 预检通过后在同一项目写锁内自动 checkpoint,prepared 审计成功后才推进一次 revision 并应用全部变更。锁内再次校验 policy、pending 身份、revision / verification gate 和 repository fingerprint;应用中途失败必须回滚。revision / gate、回滚或完成审计不完整进入 `needs-reconciliation`,`executing` 恢复不得自动重放。成功审计只保存路径、operation、前后摘要与字节数,不保存源码正文。 - 决策:`project.diff` 增加可选的有界内容 hunks;使用成熟文本 diff 库在项目锁内比较 checkpoint 与当前项目,生成后重算路径差异,不一致时拒绝混合快照;二进制、非 UTF-8、文件 / 总预算截断必须显式标记。最新内容 diff 在 128 KiB context bundle 中作为压缩保护项最多保留 24,256 字符,避免被普通 observation 的 1,600 字符上限截断。Agent 完成 patchset 后先用返回的 checkpointId 审查内容 diff,再执行可验证命令。 - 验证:本地 441 项 Tauri 测试已覆盖多文件成功、预检全失败、大小写重复、敏感 / 链接路径、SHA 漂移、prepared / completed 审计失败、应用中断回滚和一次 revision。真实 `gpt-5.5` 的 `llm-runtime` 套件已形成唯一 patchset,同时更新 / 创建文件并读取绑定 checkpointId 的 2 项未截断内容 diff,通过最终命令、项目验证和桌面 / 移动浏览器验证;Runner 强杀恢复后副作用重放、重复 action / message / receipt、半完成文件和密钥 / 诱饵泄露均为 0。 + +## 2026-07-13 AI 游戏创作 Agent Runtime V1.4 Git 工作树审阅 + +- 决策:新增一等只读 `git.inspect`,共享 command id 为 `project.git_inspect`且默认 `auto`。工具只接受 `includeDiff / maxFiles / maxChars`,返回精确 Git top-level 的 HEAD / branch、staged / unstaged / untracked 安全路径和有界 staged / unstaged unified diff;不改项目 revision 或 verification gate。 +- 决策:Git 读取必须隔离 system/global config、hooks、fsmonitor、pager、external diff、textconv、optional locks、prompt 和网络;项目根必须就是 Git top-level。路径经可移植路径、项目边界、普通文件、硬 / 符号链接和敏感路径过滤;untracked 只列名不读正文,前后快照漂移时整次失败。 +- 决策:本轮明确不开放 Git 写操作。`add / commit / push / pull / fetch`、分支切换、merge / rebase / reset / stash / clean、tag、submodule 和 worktree 继续禁止;后续本地 commit 必须单独设计 HEAD / index / 文件快照、精确确认与 Runner 崩溃不重放,不复用通用 `command.exec`。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index f395ed5e3..34a3d5112 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -315,6 +315,30 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir - 发布 AppData 中配置的真实 `gpt-5.5` 已通过 V1.3 `llm-runtime` 套件:同一 run 先用 `file.read` 取得完整 SHA-256,只执行 1 次 `project.patchset`,同时更新 `game/index.html` 和创建第二个文件;prepared / completed 审计各 1 条,patchset 只推进 1 次 revision,随后用返回的 checkpointId 读取 2 项未截断内容 hunks。Runner 强制终止后恢复原 run / session,最终项目验证、桌面 / 移动浏览器验证、3 个隔离实例和唯一 join 均通过;87 条 task、144 条 event、130 条 Agent DB 记录中副作用重放、重复 action / message / receipt、半完成文件、密钥和诱饵泄露均为 0,临时项目已按 sentinel 清理。 +## V1.4 Git 工作树安全审阅 + +### `git.inspect` + +单 Agent 新增一等只读 `git.inspect`,用于在修改前后读取当前工作树状态和有界 unified diff,不再要求模型为常规 Git 审阅申请一次通用 `command.exec` 确认: + +```json +{ + "includeDiff": true, + "maxFiles": 20, + "maxChars": 24000 +} +``` + +- 共享 command id 为 `project.git_inspect`,默认权限为 `auto`;项目或 per-Agent policy 仍可将它改为 `confirm` 或 `deny`。该工具只读,不推进 project revision,不改变 verification gate。 +- 项目目录必须精确等于 Git top-level;不向父目录探测仓库,不接受 nested root、bare repository 或不可用的 worktree。 +- Git 进程使用项目外受信可执行文件、清空环境和隔离 HOME;固定关闭 system/global config、hooks、fsmonitor、pager、external diff、textconv、optional locks、签名校验、交互 prompt 和网络代理。 +- status 使用 NUL 分隔格式解析,返回 HEAD / branch、staged / unstaged / untracked 的安全相对路径。所有路径必须通过可移植路径、项目边界、普通文件和敏感路径校验;`.agent`、VCS 控制面、`.env*`、密钥、凭据、数据库、dump、依赖、构建和缓存目录不得出现在返回值或 diff 中。 +- `includeDiff=true` 分别对安全 staged 和 unstaged tracked 路径生成内容 diff;untracked 只列路径,不读正文。默认 20 个文件、24,000 字符,公开上限固定为 50 个文件和 24,000 字符;超出上限必须显式返回 `truncated=true`。 +- status 和 diff 前后的安全快照不一致时整次失败,不得把并发混合状态作为完整审阅结果。最新成功 Git diff 在 context bundle 中按内容 diff 保护项最多保留 24,256 字符。 +- 本轮不实现 `git add/commit/push/pull/fetch`、分支切换、merge/rebase/reset/stash/clean、tag、submodule 或 worktree 操作;本地提交需要独立的 HEAD / index / 文件快照、准确确认和崩溃不重放设计,不从只读 inspect 工具顺带放开。 + +真实 Provider E2E 必须在 disposable Git 仓库中证明:Agent 先读取初始工作树,patchset 后读取同时包含 changed / untracked 的安全状态和 tracked content hunk,敏感诱饵路径与正文不出现在 observation、context bundle、Agent DB 或报告中,且 Git 审阅不增加 project revision。 + ## 验收命令 - `npm run ai-game-creator-shell:typecheck` diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 048e1e713..324f976a7 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -19,7 +19,8 @@ describe('AI 游戏创作 App 共享契约', () => { it('keeps command permissions explicit', () => { const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id); - expect(GAME_CREATION_APP_COMMANDS).toHaveLength(53); + expect(GAME_CREATION_APP_COMMANDS).toHaveLength(54); + expect(commandIds).toContain('project.git_inspect'); expect(commandIds).toContain('project.patchset'); expect(commandIds).toContain('command.exec'); expect(commandIds.indexOf('command.exec')).toBe( @@ -39,6 +40,11 @@ describe('AI 游戏创作 App 共享契约', () => { (command) => command.id === 'command.exec', )?.permission, ).toBe('confirm'); + expect( + GAME_CREATION_APP_COMMANDS.find( + (command) => command.id === 'project.git_inspect', + )?.permission, + ).toBe('auto'); expect( GAME_CREATION_APP_COMMANDS.find( (command) => command.id === 'project.patchset', diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index b2a1743c0..c92163c2e 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -19,6 +19,7 @@ export const GAME_CREATION_APP_COMMANDS = [ { id: 'project.index', permission: 'auto' }, { id: 'project.checkpoint', permission: 'confirm' }, { id: 'project.diff', permission: 'auto' }, + { id: 'project.git_inspect', permission: 'auto' }, { id: 'project.patchset', permission: 'confirm' }, { id: 'project.restore', permission: 'confirm' }, { id: 'project.verify', permission: 'confirm' }, 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 a8c607866..749deb6a1 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -21,13 +21,14 @@ pub struct GameCreationAppCommandDescriptor { pub permission: GameCreationAppPermission, } -pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 53] = [ +pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 54] = [ command("help.show", GameCreationAppPermission::Auto), command("project.create", GameCreationAppPermission::Confirm), command("project.status", GameCreationAppPermission::Auto), command("project.index", GameCreationAppPermission::Auto), command("project.checkpoint", GameCreationAppPermission::Confirm), command("project.diff", GameCreationAppPermission::Auto), + command("project.git_inspect", GameCreationAppPermission::Auto), command("project.patchset", GameCreationAppPermission::Confirm), command("project.restore", GameCreationAppPermission::Confirm), command("project.verify", GameCreationAppPermission::Confirm), @@ -638,7 +639,7 @@ mod tests { #[test] fn command_contract_keeps_expected_permissions() { - assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 53); + assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 54); let command_ids = GAME_CREATION_APP_COMMANDS .iter() @@ -681,6 +682,15 @@ mod tests { GameCreationAppPermission::Confirm ); + let project_git_inspect = GAME_CREATION_APP_COMMANDS + .iter() + .find(|command| command.id == "project.git_inspect") + .expect("project.git_inspect command should exist"); + assert_eq!( + project_git_inspect.permission, + GameCreationAppPermission::Auto + ); + let project_verify = GAME_CREATION_APP_COMMANDS .iter() .find(|command| command.id == "project.verify")