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 e915b808f..75e95d8cc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -4,12 +4,13 @@ use std::io::{Seek, SeekFrom}; static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock = OnceLock::new(); pub(crate) const AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION: &str = - "game-creator-pending-action.v2"; + "game-creator-pending-action.v3"; const AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING: &str = "pending-confirmation"; pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED: &str = "approved"; pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING: &str = "executing"; const AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED: &str = "observed-approved"; const AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED: &str = "observed-rejected"; +const AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION: &str = "needs-reconciliation"; pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO: &str = "auto"; pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION: &str = "confirmation"; pub(crate) const AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION: &str = "sha256-serde-json-v1"; @@ -21,6 +22,7 @@ pub(crate) const AGENT_RUNTIME_VERIFICATION_GATE_SCHEMA_VERSION: &str = "game-creator-verification-gate.v1"; const AGENT_RUNTIME_PROJECT_REVISION_RELATIVE_PATH: &str = ".agent/runtime/project-revision.json"; const AGENT_RUNTIME_SIDECAR_MAX_BYTES: usize = 16 * 1024; +const AGENT_RUNTIME_PENDING_ACTION_SIDECAR_MAX_BYTES: usize = 512 * 1024; const AGENT_RUNTIME_FINALIZATION_SIDECAR_MAX_BYTES: usize = 512 * 1024; const AGENT_RUNTIME_VERIFICATION_STATUS_RUNNING: &str = "running"; const AGENT_RUNTIME_VERIFICATION_STATUS_PASSED: &str = "passed"; @@ -213,7 +215,8 @@ pub(crate) async fn chat_with_game_creator_role_agent_for_session_at( prompt, )?; let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; - let response = request_game_creator_llm_text(&client, &llm, request) + let response = client + .run(request) .await .map_err(|error| format!("{config_path} 单 Agent 聊天调用 LLM 失败:{error}"))?; let reply_text = strip_llm_thinking_blocks(response.text.as_str()); @@ -417,9 +420,7 @@ fn read_game_creator_agent_runtime_with_session_filter_at( && !state.run_id.trim().is_empty() && !matches!(state.phase.as_str(), "completed" | "cancelled" | "failed") { - let pending_path = - game_creator_agent_runtime_pending_tool_action_path(root, &agent_id, &state.run_id); - if pending_path.exists() { + if game_creator_agent_runtime_pending_tool_action_exists(root, &agent_id, &state.run_id) { match read_game_creator_agent_runtime_pending_tool_action( root, &agent_id, @@ -834,8 +835,7 @@ fn resume_game_creator_agent_pending_tool_action_at( if runtime.run_id.trim().is_empty() { return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } - let path = game_creator_agent_runtime_pending_tool_action_path(root, agent_id, &runtime.run_id); - if !path.exists() { + if !game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, &runtime.run_id) { return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } let pending = match read_game_creator_agent_runtime_pending_tool_action( @@ -1545,7 +1545,7 @@ fn resolve_game_creator_agent_runtime_cancel_target( }) .ok_or_else(|| format!("未找到 Agent Runtime 任务:{run_id}"))?; let has_pending_action = - game_creator_agent_runtime_pending_tool_action_path(root, agent_id, run_id).exists(); + game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, run_id); Ok((current_result, task, has_pending_action)) } @@ -1603,8 +1603,7 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &target_run_id)? .ok_or_else(|| format!("未找到 Agent Runtime 任务:{target_run_id}"))?; if task.phase == "needs-reconciliation" - || game_creator_agent_runtime_pending_tool_action_path(root, &agent_id, &target_run_id) - .exists() + || game_creator_agent_runtime_pending_tool_action_exists(root, &agent_id, &target_run_id) { return Err("Agent Runtime 仍保留待核对工具动作,请先核对项目状态并取消原任务".to_string()); } @@ -1705,7 +1704,7 @@ pub(crate) fn confirm_game_creator_agent_runtime_task_at( runtime.waiting_on = "已确认工具执行结果".to_string(); runtime.next_step = "执行原工具动作并把观察交回 Agent".to_string(); runtime.updated_at = unix_timestamp(); - let transition_result = append_game_creator_agent_runtime_task(root, &runtime) + append_game_creator_agent_runtime_task(root, &runtime) .and_then(|_| refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)) .and_then(|_| write_game_creator_agent_runtime_state(root, &runtime)) .and_then(|_| { @@ -1737,8 +1736,8 @@ pub(crate) fn confirm_game_creator_agent_runtime_task_at( "note": note, }), ) - }); - let result = read_game_creator_agent_runtime_at(root, &agent_id); + })?; + let result = read_game_creator_agent_runtime_at(root, &agent_id)?; let root = root.to_path_buf(); let background_agent_id = agent_id.clone(); tauri::async_runtime::spawn(async move { @@ -1751,8 +1750,7 @@ pub(crate) fn confirm_game_creator_agent_runtime_task_at( ) .await; }); - transition_result?; - result + Ok(result) } pub(crate) fn reject_game_creator_agent_runtime_task_at( @@ -1783,7 +1781,7 @@ pub(crate) fn reject_game_creator_agent_runtime_task_at( runtime.waiting_on = "Agent 根据拒绝结果修正计划".to_string(); runtime.next_step = "把拒绝结果交给 Agent 修正计划".to_string(); runtime.updated_at = unix_timestamp(); - let transition_result = append_game_creator_agent_runtime_task(root, &runtime) + append_game_creator_agent_runtime_task(root, &runtime) .and_then(|_| refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)) .and_then(|_| write_game_creator_agent_runtime_state(root, &runtime)) .and_then(|_| { @@ -1813,8 +1811,8 @@ pub(crate) fn reject_game_creator_agent_runtime_task_at( "note": note, }), ) - }); - let result = read_game_creator_agent_runtime_at(root, &agent_id); + })?; + let result = read_game_creator_agent_runtime_at(root, &agent_id)?; let root = root.to_path_buf(); let background_agent_id = agent_id.clone(); tauri::async_runtime::spawn(async move { @@ -1827,8 +1825,7 @@ pub(crate) fn reject_game_creator_agent_runtime_task_at( ) .await; }); - transition_result?; - result + Ok(result) } fn resolve_game_creator_agent_runtime_pending_tool_action( @@ -1896,10 +1893,7 @@ fn resolve_game_creator_agent_runtime_pending_tool_action( .pending_tool_action .as_ref() .ok_or_else(|| "Agent Runtime 状态缺少待确认动作摘要".to_string())?; - if runtime_pending.action_id != pending.action_id - || runtime_pending.action_fingerprint != pending.action_fingerprint - || runtime_pending.tool != pending.action.tool - { + if runtime_pending != &pending.summary() { return Err("Agent Runtime 待确认动作摘要与执行记录不一致".to_string()); } Ok((agent_id, task, runtime, pending)) @@ -1958,13 +1952,14 @@ async fn continue_game_creator_agent_pending_tool_action( &root, &pending, ); } - let observation = execute_game_creator_agent_runtime_tool_action_with_action_id( + let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action( &root, &agent_id, &pending.run_id, &pending.task, &action, Some(&pending.action_id), + Some(&pending), ) .await; if observation.is_waiting_for_confirmation() && auto_execution { @@ -2035,6 +2030,15 @@ async fn continue_game_creator_agent_pending_tool_action( return; } }; + if observation.requires_reconciliation() { + let _ = mark_game_creator_agent_runtime_tool_observation_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &observation, + ); + return; + } if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { drain_next_game_creator_agent_background_tasks(root, agent_id).await; return; @@ -2278,6 +2282,31 @@ async fn continue_game_creator_agent_pending_tool_action( } } +fn mark_game_creator_agent_runtime_tool_observation_needs_reconciliation_at( + root: &Path, + runtime: &mut AgentRuntimeState, + pending: &AgentRuntimePendingToolAction, + observation: &AgentRuntimeToolObservation, +) -> Result<(), String> { + let observation_summary = observation.summary(); + runtime.observations.push(observation_summary.clone()); + append_agent_runtime_tool_call_record( + root, + runtime, + &pending.task, + &pending.action, + observation, + ); + complete_agent_runtime_active_plan_step(runtime, "failed", &observation_summary); + let error = observation + .detail + .as_deref() + .filter(|detail| !detail.trim().is_empty()) + .map(|detail| format!("{observation_summary};{detail}")) + .unwrap_or(observation_summary); + mark_game_creator_agent_runtime_needs_reconciliation_at(root, runtime, pending, &error) +} + fn mark_game_creator_agent_runtime_needs_reconciliation_at( root: &Path, runtime: &mut AgentRuntimeState, @@ -2629,7 +2658,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( let planning_request_revision = match read_game_creator_agent_runtime_project_revision(&root) { - Ok(revision) => revision.revision, + Ok(revision) => revision, Err(error) => { return fail_game_creator_agent_background_context_at( &root, @@ -2800,7 +2829,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( runtime.updated_at = unix_timestamp(); let _ = write_game_creator_agent_runtime_state(&root, &runtime); final_reply = Some(plan.response.clone()); - final_reply_revision = Some(planning_request_revision); + final_reply_revision = Some(planning_request_revision.revision); } observations = compact_agent_runtime_context_observations(&root, &observations); let _ = context_tracker.complete_loop(loop_index + 1); @@ -2898,6 +2927,7 @@ async fn run_game_creator_agent_background_task_pass_with_context( &task, &plan, &observations, + &planning_request_revision, action, action_index, AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION, @@ -2997,15 +3027,17 @@ async fn run_game_creator_agent_background_task_pass_with_context( &root, &pending_action, ); - let observation = execute_game_creator_agent_runtime_tool_action_with_action_id( - &root, - &agent_id, - runtime.run_id.as_str(), - &pending_action.task, - action, - Some(&pending_action.action_id), - ) - .await; + let observation = + execute_game_creator_agent_runtime_tool_action_with_pending_action( + &root, + &agent_id, + runtime.run_id.as_str(), + &pending_action.task, + action, + Some(&pending_action.action_id), + Some(&pending_action), + ) + .await; if observation.is_waiting_for_confirmation() { pending_action.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); @@ -3033,6 +3065,16 @@ async fn run_game_creator_agent_background_task_pass_with_context( &pending_action, &observation, ); + if observation.requires_reconciliation() { + let _ = + mark_game_creator_agent_runtime_tool_observation_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &observation, + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } durable_action = Some(pending_action); observation } else { @@ -3866,7 +3908,7 @@ pub(crate) fn compact_agent_runtime_context_observations( && sanitized[*index].status == "ok" && !matches!( sanitized[*index].tool.as_str(), - "file.write" | "file.patch" | "project.restore" + "file.write" | "file.patch" | "file.delete" | "project.restore" ) }) .or_else(|| { @@ -4263,11 +4305,12 @@ fn validate_agent_runtime_verification_gate( { return Err("Agent Runtime verification gate 的 verifiedRevision 早于修改".to_string()); } - if gate - .last_mutation_tool - .as_deref() - .is_some_and(|tool| !matches!(tool, "file.write" | "file.patch" | "project.restore")) - { + if gate.last_mutation_tool.as_deref().is_some_and(|tool| { + !matches!( + tool, + "file.write" | "file.patch" | "file.delete" | "project.restore" + ) + }) { return Err("Agent Runtime verification gate 的修改工具无效".to_string()); } if gate @@ -5166,6 +5209,7 @@ pub(crate) struct AgentRuntimePendingToolAction { pub(crate) plan: Vec, pub(crate) fallback_response: String, pub(crate) observations: Vec, + pub(crate) project_revision_before: AgentRuntimeProjectRevision, pub(crate) verification_gate_before: AgentRuntimeVerificationGate, pub(crate) action: AgentRuntimeToolAction, pub(crate) action_id: String, @@ -5225,6 +5269,7 @@ fn build_game_creator_agent_runtime_pending_tool_action( task: &str, plan: &AgentRuntimeToolPlan, observations: &[AgentRuntimeToolObservation], + project_revision_before: &AgentRuntimeProjectRevision, action: &AgentRuntimeToolAction, action_index: usize, execution_mode: &str, @@ -5263,6 +5308,7 @@ fn build_game_creator_agent_runtime_pending_tool_action( .collect(), fallback_response: sanitize_agent_runtime_text(&plan.response, 1_200), observations: observations.to_vec(), + project_revision_before: project_revision_before.clone(), verification_gate_before: read_game_creator_agent_runtime_verification_gate( root, &runtime.agent_id, @@ -5329,6 +5375,23 @@ impl AgentRuntimeToolObservation { fn is_waiting_for_confirmation(&self) -> bool { self.status == "waiting-for-confirmation" } + + fn requires_reconciliation(&self) -> bool { + self.status == AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } +} + +pub(crate) fn advance_agent_runtime_project_revision_locked(root: &Path) -> Result { + let mut revision = read_game_creator_agent_runtime_project_revision(root)?; + let next_revision = revision + .revision + .checked_add(1) + .ok_or_else(|| "Agent Runtime 项目 revision 已达到上限".to_string())?; + revision.revision = next_revision; + revision.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_project_revision(root, &revision) + .map_err(|error| format!("项目 revision 持久化失败,禁止继续写入:{error}"))?; + Ok(next_revision) } pub(crate) fn prepare_agent_runtime_project_mutation_locked( @@ -5443,6 +5506,27 @@ pub(crate) fn validate_agent_runtime_pending_verification_gate_before( root: &Path, pending: &AgentRuntimePendingToolAction, ) -> Result<(), String> { + validate_agent_runtime_project_revision(root, &pending.project_revision_before)?; + let prior_action_count = usize::try_from(pending.action_index).unwrap_or(usize::MAX); + let prior_revision_advances = pending + .observations + .iter() + .rev() + .take(prior_action_count) + .filter(|observation| agent_runtime_observation_advances_project_revision(observation)) + .count(); + let expected_revision = pending + .project_revision_before + .revision + .checked_add(u64::try_from(prior_revision_advances).unwrap_or(u64::MAX)) + .ok_or_else(|| "Agent Runtime 待执行动作的预期项目 revision 已达到上限".to_string())?; + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + if current_revision.revision != expected_revision { + return Err(format!( + "Agent Runtime 待执行动作创建后的项目 revision 已变化,禁止执行旧动作:预期 {expected_revision},当前 {}", + current_revision.revision + )); + } validate_agent_runtime_verification_gate( root, &pending.verification_gate_before, @@ -5487,16 +5571,47 @@ fn agent_runtime_mutation_gate_failure_observation( } } +fn agent_runtime_revision_advance_failure_observation( + root: &Path, + tool: &str, + error: &str, +) -> AgentRuntimeToolObservation { + AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "failed".to_string(), + summary: "项目 revision 推进失败,未执行写入".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, error, 500)), + } +} + fn is_agent_runtime_project_mutation_observation( observation: &AgentRuntimeToolObservation, ) -> bool { matches!(observation.status.as_str(), "ok" | "verification-failed") && matches!( observation.tool.as_str(), - "file.write" | "file.patch" | "project.restore" + "file.write" | "file.patch" | "file.delete" | "project.restore" ) } +fn agent_runtime_observation_advances_project_revision( + observation: &AgentRuntimeToolObservation, +) -> bool { + if observation.status != "ok" { + return false; + } + match observation.tool.as_str() { + "file.write" + | "file.patch" + | "file.delete" + | "project.restore" + | "blackboard.write" + | "canvas.asset_generate" => true, + "memory.write" => true, + _ => false, + } +} + fn is_agent_runtime_static_smoke_observation(observation: &AgentRuntimeToolObservation) -> bool { observation.tool == "command.run_limited" && matches!( @@ -5700,7 +5815,7 @@ fn acquire_game_creator_agent_runtime_project_write_lock_with_wait( } #[derive(Clone, Debug, Eq, PartialEq)] -enum AgentRuntimeToolPolicyBlock { +pub(crate) enum AgentRuntimeToolPolicyBlock { Denied(String), RequiresConfirmation(String), } @@ -5787,7 +5902,7 @@ pub(crate) fn agent_runtime_tool_action_id( ) } -fn agent_runtime_tool_action_input_summary( +pub(crate) fn agent_runtime_tool_action_input_summary( root: &Path, action: &AgentRuntimeToolAction, ) -> Option { @@ -5893,6 +6008,7 @@ fn agent_runtime_tool_action_input_summary( .and_then(|value| value.as_u64()) .unwrap_or(1) ), + "file.delete" => format!("path={}", relative_path(&["path"])), "task.create" => format!( "taskId={} · title={} · group={} · role={} · dependencies={} · artifacts={} · criteria={}", text(&["taskId", "task_id", "id"]), @@ -6276,10 +6392,10 @@ 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|file.list|file.read|file.write|file.patch|task.list|task.create|task.update|command.run_limited|preview.start|canvas.asset_generate|blackboard.write|agent.message|agent.delegate|agent.schedule_ready|agent.run_status\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}},其中 agent scope 只能写当前 Agent 自己的私有记忆,跨 Agent 共享请用 blackboard.write 或 agent.message;project.search 使用 {{\"query\":\"要查找的字面文本\",\"path\":\"可选项目内相对范围\",\"maxResults\":20,\"caseSensitive\":false}},返回 path:line 和匹配行;project.verify 使用 {{\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120}},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint input 可为空,用于在写文件或批量修改前创建本地 checkpoint;project.restore 使用 {{\"checkpointId\":\"checkpoint id\"}},用于在确认后把当前项目恢复到指定 checkpoint;project.diff 使用 {{\"checkpointId\":\"checkpoint id\"}},用于读取当前项目相对路径 diff 摘要;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}},按行读取并返回行号;file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}},匹配数不符时不写入,批量修改前应先调用 project.checkpoint;task.list input 可为空,用于读取 manifest 任务图、状态和 readyTaskIds;task.create 使用 {{\"taskId\":\"可选自定义 taskId\",\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[\"已有 taskId\"],\"artifacts\":[\"预期产物\"],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},用于把 Agent 拆出的新任务追加到 manifest;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;canvas.asset_generate 使用 {{\"prompt\":\"要生成的美术素材描述\"}},通过配置的 External Editor API 生成首版素材并登记到 assets;blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"}},用于把任务投递到另一个 Agent 的独立队列;agent.schedule_ready input 可为空或 {{\"limit\":1}},用于把 manifest 中依赖已完成的 ready task 投递到对应 Agent 后台队列;agent.run_status 使用 {{\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\"}},用于读取自己或其他 Agent 的 Runtime 状态摘要;如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。" + "当前工具策略:\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|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\"}},用于读取当前项目相对路径 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;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 = format!( - "{prompt}\n\n补充协议:project.verify 的 script 除 check、typecheck、test、lint、build 外,还可使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 script 与 expectedCommand 都必须原样来自项目根 package.json。每次成功执行 file.write、file.patch 或 project.restore 都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify,或成功执行 command.run_limited 的 game.static_smoke,才能返回空 actions 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一个上下文压缩窗口,不是 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。" + "{prompt}\n\n补充协议:project.verify 的 script 除 check、typecheck、test、lint、build 外,还可使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 script 与 expectedCommand 都必须原样来自项目根 package.json。每次成功执行 file.write、file.patch、file.delete 或 project.restore 都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify,或成功执行 command.run_limited 的 game.static_smoke,才能返回空 actions 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一个上下文压缩窗口,不是 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。" ); let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; let mut request = LlmRunRequest::new(vec![ @@ -6510,6 +6626,21 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_action_i task: &str, action: &AgentRuntimeToolAction, action_id: Option<&str>, +) -> AgentRuntimeToolObservation { + execute_game_creator_agent_runtime_tool_action_with_pending_action( + root, agent_id, run_id, task, action, action_id, None, + ) + .await +} + +pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_action( + root: &Path, + agent_id: &str, + run_id: &str, + task: &str, + action: &AgentRuntimeToolAction, + action_id: Option<&str>, + pending_action: Option<&AgentRuntimePendingToolAction>, ) -> AgentRuntimeToolObservation { let tool = action.tool.trim(); let action_fingerprint = agent_runtime_tool_action_fingerprint(action, task); @@ -6560,6 +6691,9 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_action_i "file.read" => observe_agent_runtime_file(root, &action.input), "file.write" => observe_agent_runtime_file_write(root, agent_id, run_id, &action.input), "file.patch" => observe_agent_runtime_file_patch(root, agent_id, run_id, &action.input), + "file.delete" => { + observe_agent_runtime_file_delete(root, agent_id, run_id, pending_action, &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), @@ -6605,6 +6739,7 @@ fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str "file.read" => Some("file.read"), "file.write" => Some("file.write"), "file.patch" => Some("file.write"), + "file.delete" => Some("file.delete"), "task.list" => Some("task.list"), "task.create" => Some("task.create"), "task.update" => Some("task.update"), @@ -6671,12 +6806,27 @@ pub(crate) fn game_creator_agent_runtime_pending_tool_action_path( agent_id: &str, run_id: &str, ) -> PathBuf { - root.join(".agent/runtime/pending-actions") - .join(agent_runtime_confirmation_path_component(agent_id, "agent")) - .join(format!( - "{}.json", - agent_runtime_confirmation_path_component(run_id, "run") - )) + root.join(game_creator_agent_runtime_pending_tool_action_relative_path(agent_id, run_id)) +} + +fn game_creator_agent_runtime_pending_tool_action_relative_path( + agent_id: &str, + run_id: &str, +) -> String { + format!( + ".agent/runtime/pending-actions/{}/{}.json", + agent_runtime_confirmation_path_component(agent_id, "agent"), + agent_runtime_confirmation_path_component(run_id, "run") + ) +} + +fn game_creator_agent_runtime_pending_tool_action_exists( + root: &Path, + agent_id: &str, + run_id: &str, +) -> bool { + let path = game_creator_agent_runtime_pending_tool_action_path(root, agent_id, run_id); + path.exists() || agent_runtime_json_sidecar_backup_path(&path).exists() } fn validate_agent_runtime_pending_tool_action_content( @@ -6810,63 +6960,17 @@ pub(crate) fn write_game_creator_agent_runtime_pending_tool_action( pending: &AgentRuntimePendingToolAction, ) -> Result<(), String> { validate_agent_runtime_pending_tool_action_record(root, pending)?; - let path = game_creator_agent_runtime_pending_tool_action_path( - root, + let relative_path = game_creator_agent_runtime_pending_tool_action_relative_path( &pending.agent_id, &pending.run_id, ); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 Agent Runtime 待确认动作目录失败:{}: {error}", - parent.display() - ) - })?; - } - let content = serde_json::to_string_pretty(pending) - .map_err(|error| format!("序列化 Agent Runtime 待确认动作失败:{error}"))?; - let temp_path = path.with_file_name(format!( - ".{}.tmp.{}.{}", - path.file_name() - .and_then(|value| value.to_str()) - .unwrap_or("pending-action.json"), - std::process::id(), - unix_timestamp_nanos() - )); - fs::write(&temp_path, format!("{content}\n")).map_err(|error| { - format!( - "写入 Agent Runtime 待确认动作临时文件失败:{}: {error}", - temp_path.display() - ) - })?; - match fs::rename(&temp_path, &path) { - Ok(()) => Ok(()), - Err(_) if path.exists() => { - fs::remove_file(&path).map_err(|error| { - let _ = fs::remove_file(&temp_path); - format!( - "替换 Agent Runtime 待确认动作前删除旧文件失败:{}: {error}", - path.display() - ) - })?; - fs::rename(&temp_path, &path).map_err(|error| { - let _ = fs::remove_file(&temp_path); - format!( - "替换 Agent Runtime 待确认动作失败:{} -> {}: {error}", - temp_path.display(), - path.display() - ) - }) - } - Err(error) => { - let _ = fs::remove_file(&temp_path); - Err(format!( - "替换 Agent Runtime 待确认动作失败:{} -> {}: {error}", - temp_path.display(), - path.display() - )) - } - } + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "Agent Runtime 待确认动作", + pending, + AGENT_RUNTIME_PENDING_ACTION_SIDECAR_MAX_BYTES, + ) } fn validate_agent_runtime_pending_tool_action_record( @@ -6885,6 +6989,7 @@ fn validate_agent_runtime_pending_tool_action_record( pending.fingerprint_version )); } + validate_agent_runtime_project_revision(root, &pending.project_revision_before)?; if pending.verification_gate_before.project_id != game_creator_agent_runtime_context_project_id(root)? || pending.verification_gate_before.agent_id != pending.agent_id @@ -6925,6 +7030,9 @@ fn validate_agent_runtime_pending_tool_action_record( if pending.action_fingerprint != action_fingerprint || pending.action_id != action_id { return Err("Agent Runtime 待确认动作已变化:指纹校验失败".to_string()); } + if pending.input_summary != agent_runtime_tool_action_input_summary(root, &pending.action) { + return Err("Agent Runtime 待确认动作的输入摘要与实际动作不一致".to_string()); + } if matches!( pending.status.as_str(), AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED @@ -6942,15 +7050,17 @@ pub(crate) fn read_game_creator_agent_runtime_pending_tool_action( run_id: &str, ) -> Result { let path = game_creator_agent_runtime_pending_tool_action_path(root, agent_id, run_id); - let content = fs::read_to_string(&path).map_err(|error| { + let relative_path = + game_creator_agent_runtime_pending_tool_action_relative_path(agent_id, run_id); + let raw = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &relative_path, + "Agent Runtime 待确认动作", + AGENT_RUNTIME_PENDING_ACTION_SIDECAR_MAX_BYTES, + )? + .ok_or_else(|| { format!( - "读取 Agent Runtime 待确认动作失败:{}: {error}", - path.display() - ) - })?; - let raw = serde_json::from_str::(&content).map_err(|error| { - format!( - "解析 Agent Runtime 待确认动作失败:{}: {error}", + "读取 Agent Runtime 待确认动作失败:{}: 文件不存在", path.display() ) })?; @@ -6988,11 +7098,21 @@ fn remove_game_creator_agent_runtime_pending_tool_action( run_id: &str, ) -> Result<(), String> { let path = game_creator_agent_runtime_pending_tool_action_path(root, agent_id, run_id); - match fs::remove_file(&path) { - Ok(()) => Ok(()), + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 待确认动作")?; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("Agent Runtime 待确认动作必须是普通文件".to_string()) + } + Ok(_) => fs::remove_file(&path).map_err(|error| { + format!( + "删除 Agent Runtime 待确认动作失败:{}: {error}", + path.display() + ) + }), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => Err(format!( - "删除 Agent Runtime 待确认动作失败:{}: {error}", + "读取 Agent Runtime 待确认动作元数据失败:{}: {error}", path.display() )), } @@ -7018,12 +7138,11 @@ fn clear_game_creator_agent_runtime_observed_action_ledger( root: &Path, runtime: &mut AgentRuntimeState, ) -> Result<(), String> { - let path = game_creator_agent_runtime_pending_tool_action_path( + if !game_creator_agent_runtime_pending_tool_action_exists( root, &runtime.agent_id, &runtime.run_id, - ); - if !path.exists() { + ) { runtime.pending_tool_action = None; return Ok(()); } @@ -7144,6 +7263,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "file.read", "file.write", "file.patch", + "file.delete", "task.list", "task.create", "task.update", @@ -7236,12 +7356,10 @@ fn agent_runtime_effective_tool_policy_at( }) } -fn game_creator_agent_runtime_tool_policy_block( +fn game_creator_agent_runtime_tool_policy_rule( root: &Path, agent_id: &str, - run_id: &str, command_id: &str, - action_fingerprint: &str, ) -> Option { let view = match read_project_permission_policy_at(root) { Ok(view) => view, @@ -7283,17 +7401,6 @@ fn game_creator_agent_runtime_tool_policy_block( .iter() .any(|command| command == command_id) { - match consume_game_creator_agent_runtime_tool_confirmation( - root, - &agent_id, - run_id, - command_id, - action_fingerprint, - ) { - Ok(true) => return None, - Ok(false) => {} - Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)), - } return Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(format!( "项目权限策略要求用户确认:{command_id}" ))); @@ -7310,17 +7417,6 @@ fn game_creator_agent_runtime_tool_policy_block( }) .unwrap_or(false) { - match consume_game_creator_agent_runtime_tool_confirmation( - root, - &agent_id, - run_id, - command_id, - action_fingerprint, - ) { - Ok(true) => return None, - Ok(false) => {} - Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)), - } return Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(format!( "Agent 权限策略要求用户确认:{agent_id} / {command_id}" ))); @@ -7328,6 +7424,58 @@ fn game_creator_agent_runtime_tool_policy_block( None } +fn game_creator_agent_runtime_tool_policy_block( + root: &Path, + agent_id: &str, + run_id: &str, + command_id: &str, + action_fingerprint: &str, +) -> Option { + let blocked = game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id)?; + if !matches!( + &blocked, + AgentRuntimeToolPolicyBlock::RequiresConfirmation(_) + ) { + return Some(blocked); + } + let agent_id = match normalize_game_creator_runtime_agent_id(agent_id) { + Ok(agent_id) => agent_id, + Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)), + }; + match consume_game_creator_agent_runtime_tool_confirmation( + root, + &agent_id, + run_id, + command_id, + action_fingerprint, + ) { + Ok(true) => None, + Ok(false) => Some(blocked), + Err(error) => Some(AgentRuntimeToolPolicyBlock::Denied(error)), + } +} + +pub(crate) fn game_creator_agent_runtime_tool_policy_block_after_lock( + root: &Path, + agent_id: &str, + command_id: &str, + pending_action: Option<&AgentRuntimePendingToolAction>, +) -> Option { + let blocked = game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id)?; + let confirmation_approved = pending_action + .map(|pending| !pending.is_auto() && pending.approved()) + .unwrap_or(false); + if matches!( + &blocked, + AgentRuntimeToolPolicyBlock::RequiresConfirmation(_) + ) && confirmation_approved + { + None + } else { + Some(blocked) + } +} + fn observe_agent_runtime_memory( root: &Path, agent_id: &str, @@ -7416,6 +7564,9 @@ fn observe_agent_runtime_memory_write( }; } }; + if let Err(error) = advance_agent_runtime_project_revision_locked(root) { + return agent_runtime_revision_advance_failure_observation(root, "memory.write", &error); + } let result = if scope == "agent" { let target_agent_id = target_agent_id.unwrap_or_else(|| agent_id.to_string()); read_local_agent_memory_at(root, &target_agent_id) @@ -8180,6 +8331,114 @@ fn observe_agent_runtime_file_write( } } +fn observe_agent_runtime_file_delete( + root: &Path, + agent_id: &str, + run_id: &str, + pending_action: Option<&AgentRuntimePendingToolAction>, + input: &serde_json::Value, +) -> AgentRuntimeToolObservation { + let path = agent_runtime_tool_input_text(input, &["path"]); + if path.is_empty() { + return AgentRuntimeToolObservation { + tool: "file.delete".to_string(), + status: "failed".to_string(), + summary: "缺少 path".to_string(), + detail: None, + }; + } + let _lock = match acquire_project_write_lock(root, "file.delete") { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "file.delete".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + if let Some(blocked) = game_creator_agent_runtime_tool_policy_block_after_lock( + root, + agent_id, + "file.delete", + pending_action, + ) { + return agent_runtime_tool_policy_block_observation("file.delete", blocked); + } + if let Some(pending_action) = pending_action { + if pending_action.agent_id != agent_id || pending_action.run_id != run_id { + return agent_runtime_mutation_gate_failure_observation( + root, + "file.delete", + "Agent Runtime file.delete 的 pending action 身份不匹配", + ); + } + if let Err(error) = + validate_agent_runtime_pending_verification_gate_before(root, pending_action) + { + return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error); + } + } + if let Err(error) = + prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.delete") + { + return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error); + } + let deleted = match delete_local_project_file_at(root, &path) { + Ok(deleted) => deleted, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "file.delete".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + let audit_result = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.file.delete", + "agentId": agent_id, + "path": deleted.path, + "deleted": deleted.deleted, + }), + ); + if let Err(error) = audit_result { + let audit_error = redact_agent_runtime_project_paths(root, &error, 240); + if deleted.deleted { + return AgentRuntimeToolObservation { + tool: "file.delete".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: format!( + "文件已删除但 Agent DB 审计失败,需要人工核对:{}", + deleted.path + ), + detail: Some(format!( + "sideEffectApplied=true; deleted=true; auditError={audit_error}" + )), + }; + } + return AgentRuntimeToolObservation { + tool: "file.delete".to_string(), + status: "failed".to_string(), + summary: audit_error, + detail: None, + }; + } + AgentRuntimeToolObservation { + tool: "file.delete".to_string(), + status: "ok".to_string(), + summary: if deleted.deleted { + format!("已删除 {}", deleted.path) + } else { + format!("目标文件已不存在:{}", deleted.path) + }, + detail: Some(format!("deleted={}", deleted.deleted)), + } +} + fn observe_agent_runtime_file_patch( root: &Path, agent_id: &str, @@ -8916,6 +9175,13 @@ async fn observe_agent_runtime_platform_art_asset_generation( }; } }; + if let Err(error) = advance_agent_runtime_project_revision_locked(root) { + return agent_runtime_revision_advance_failure_observation( + root, + "canvas.asset_generate", + &error, + ); + } match generate_platform_art_asset_at(root, prompt.trim(), &[]).await { Ok(generated) => { let _ = append_agent_db_record( @@ -8980,6 +9246,13 @@ fn observe_agent_runtime_blackboard_write( }; } }; + if let Err(error) = advance_agent_runtime_project_revision_locked(root) { + return agent_runtime_revision_advance_failure_observation( + root, + "blackboard.write", + &error, + ); + } let title_input = agent_runtime_tool_input_text(input, &["title", "topic"]); let title_text = if title_input.trim().is_empty() { "共享结论" @@ -12827,7 +13100,7 @@ pub(crate) fn game_creator_role_agent_chat_system_prompt() -> &'static str { } pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> &'static str { - "你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须在白名单工具内规划行动:先给一句 thinkingSummary,再给短计划,再决定是否请求工具。只能请求 memory.read、memory.write、conversation.read、asset.list、project.index、project.search、project.verify、project.checkpoint、project.restore、project.diff、file.list、file.read、file.write、file.patch、task.list、task.create、task.update、command.run_limited、preview.start、canvas.asset_generate、blackboard.write、agent.message、agent.delegate、agent.schedule_ready、agent.run_status。处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;优先使用 file.patch 做精确局部修改,批量修改前创建 project.checkpoint,修改后再次读取验证。每次成功执行 file.write、file.patch 或 project.restore 都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify,或成功执行 command.run_limited 的 game.static_smoke,才能返回空 actions 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verify;script 可以是 check、typecheck、test、lint、build,或使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。每 6 轮只是一个上下文压缩窗口,不是 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。优先调用 submit_agent_tool_plan function tool 提交结构化计划;只有上游不支持 function tool 时才返回同结构的单个 JSON 对象。不要 markdown,不要泄露密钥。" + "你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须在白名单工具内规划行动:先给一句 thinkingSummary,再给短计划,再决定是否请求工具。只能请求 memory.read、memory.write、conversation.read、asset.list、project.index、project.search、project.verify、project.checkpoint、project.restore、project.diff、file.list、file.read、file.write、file.patch、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。处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;优先使用 file.patch 做精确局部修改,只有确认文件已废弃时才请求 file.delete,批量修改前创建 project.checkpoint,修改后再次读取验证。每次成功执行 file.write、file.patch、file.delete 或 project.restore 都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify,或成功执行 command.run_limited 的 game.static_smoke,才能返回空 actions 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verify;script 可以是 check、typecheck、test、lint、build,或使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。每 6 轮只是一个上下文压缩窗口,不是 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。优先调用 submit_agent_tool_plan function tool 提交结构化计划;只有上游不支持 function tool 时才返回同结构的单个 JSON 对象。不要 markdown,不要泄露密钥。" } pub(crate) fn game_creator_agent_role_definition( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a5b5f3047..d660982d0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -70,7 +70,7 @@ pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool { return false; } let manifest_path = root.join(".agent/manifest.json"); - manifest_path.is_file() && read_manifest(&manifest_path).is_ok() + read_manifest(&manifest_path).is_ok() } pub(crate) fn game_creator_project_name(root: &Path) -> Option { @@ -89,7 +89,7 @@ pub(crate) fn game_creator_project_manifest_error(root: &Path) -> Option return None; } let manifest_path = root.join(".agent/manifest.json"); - if !manifest_path.is_file() { + if !manifest_storage_exists(&manifest_path).unwrap_or(true) { return None; } read_manifest(&manifest_path).err() @@ -219,6 +219,7 @@ pub(crate) async fn generate_local_game_draft( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "game.generate_draft")?; let _lock = acquire_project_write_lock(root, "game.generate_draft")?; + advance_agent_runtime_project_revision_locked(root)?; generate_local_game_draft_at( root, prompt.trim(), @@ -608,6 +609,7 @@ pub(crate) fn upload_local_asset( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "asset.upload")?; let _lock = acquire_project_write_lock(root, "asset.upload")?; + advance_agent_runtime_project_revision_locked(root)?; upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes) } @@ -628,6 +630,7 @@ pub(crate) fn register_local_asset( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "asset.register")?; let _lock = acquire_project_write_lock(root, "asset.register")?; + advance_agent_runtime_project_revision_locked(root)?; register_local_asset_at( root, local_path.trim(), @@ -662,6 +665,7 @@ pub(crate) fn import_canvas_asset( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "canvas.asset_import")?; let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; + advance_agent_runtime_project_revision_locked(root)?; import_canvas_asset_at( root, local_path.trim(), @@ -685,6 +689,7 @@ pub(crate) fn import_canvas_export( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "canvas.export_import")?; let _lock = acquire_project_write_lock(root, "canvas.export_import")?; + advance_agent_runtime_project_revision_locked(root)?; import_canvas_export_at( root, Path::new(export_path.trim()), @@ -702,6 +707,7 @@ pub(crate) async fn sync_canvas_project_assets( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "canvas.project_sync")?; let _lock = acquire_project_write_lock(root, "canvas.project_sync")?; + advance_agent_runtime_project_revision_locked(root)?; sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await } @@ -713,6 +719,7 @@ pub(crate) async fn generate_platform_art_asset( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "canvas.asset_generate")?; let _lock = acquire_project_write_lock(root, "canvas.asset_generate")?; + advance_agent_runtime_project_revision_locked(root)?; let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?; Ok(generated.asset) } @@ -811,6 +818,7 @@ pub(crate) fn write_local_project_file( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "file.write")?; let _lock = acquire_project_write_lock(root, "file.write")?; + advance_agent_runtime_project_revision_locked(root)?; write_local_project_file_at(root, relative_path.trim(), &content) } @@ -822,6 +830,7 @@ pub(crate) fn delete_local_project_file( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "file.delete")?; let _lock = acquire_project_write_lock(root, "file.delete")?; + advance_agent_runtime_project_revision_locked(root)?; delete_local_project_file_at(root, relative_path.trim()) } @@ -856,6 +865,7 @@ pub(crate) fn write_local_agent_memory( let task_id = normalize_game_creator_runtime_agent_id(task_id.trim())?; enforce_project_permission_policy(root, "memory.write")?; let _lock = acquire_project_write_lock(root, "memory.write")?; + advance_agent_runtime_project_revision_locked(root)?; write_local_agent_memory_at(root, &task_id, &content) } @@ -868,6 +878,7 @@ pub(crate) fn write_local_game_memory( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "memory.write")?; let _lock = acquire_project_write_lock(root, "memory.write")?; + advance_agent_runtime_project_revision_locked(root)?; write_local_game_memory_at(root, scope.trim(), &content) } @@ -879,6 +890,7 @@ pub(crate) fn delete_local_game_memory( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "memory.delete")?; let _lock = acquire_project_write_lock(root, "memory.delete")?; + advance_agent_runtime_project_revision_locked(root)?; delete_local_game_memory_at(root, scope.trim()) } @@ -987,6 +999,7 @@ pub(crate) fn export_local_project_package( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.export_package")?; let _lock = acquire_project_write_lock(root, "project.export_package")?; + advance_agent_runtime_project_revision_locked(root)?; export_local_project_package_at(root) } @@ -1017,6 +1030,7 @@ pub(crate) fn restore_local_project_checkpoint( let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.restore")?; let _lock = acquire_project_write_lock(root, "project.restore")?; + advance_agent_runtime_project_revision_locked(root)?; restore_local_project_checkpoint_at(root, checkpoint_id.trim()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index e7babf6aa..376b1cdec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -56,7 +56,7 @@ pub(crate) fn init_local_game_project_at( } let manifest_path = root.join(".agent/manifest.json"); - if !manifest_path.exists() { + if !manifest_storage_exists(&manifest_path)? { let manifest = new_game_creation_app_manifest(project_id, name); write_manifest(&manifest_path, &manifest)?; } @@ -2438,6 +2438,16 @@ fn reject_agent_runtime_private_control_path(normalized_path: &str) -> Result<() Ok(()) } +fn reject_agent_control_path_delete(normalized_path: &str) -> Result<(), String> { + if matches!( + normalized_path.split('/').next(), + Some(part) if part.eq_ignore_ascii_case(".agent") + ) { + return Err("Agent 控制面不可通过 file.delete 删除".to_string()); + } + Ok(()) +} + pub(crate) fn reject_sensitive_project_file_read(normalized_path: &str) -> Result<(), String> { for part in normalized_path.split('/') { let lower = part.to_ascii_lowercase(); @@ -2488,6 +2498,7 @@ pub(crate) fn delete_local_project_file_at( ) -> Result { let normalized_path = normalize_relative_path(relative_path)?; reject_agent_runtime_private_control_path(&normalized_path)?; + reject_agent_control_path_delete(&normalized_path)?; let path = resolve_local_project_path(root, &normalized_path)?; if !path.exists() { return Ok(LocalProjectFileMutationResult { @@ -3139,14 +3150,23 @@ pub(crate) fn resolve_local_project_path( validate_project_root(root)?; let normalized = normalize_relative_path(relative_path)?; let mut path = root.to_path_buf(); + let mut should_check_metadata = true; for part in normalized.split('/') { path.push(part); - if path.exists() { - let metadata = fs::symlink_metadata(&path) - .map_err(|error| format!("读取路径失败:{}: {error}", path.display()))?; - if metadata.file_type().is_symlink() { + if !should_check_metadata { + continue; + } + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => { return Err("项目文件路径不能包含符号链接".to_string()); } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + should_check_metadata = false; + } + Err(error) => { + return Err(format!("读取路径失败:{}: {error}", path.display())); + } } } Ok(path) @@ -3162,11 +3182,15 @@ pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> { if project_path_has_control_chars(root) { return Err("项目目录不能包含控制字符".to_string()); } - if root.exists() { - let metadata = fs::symlink_metadata(root) - .map_err(|error| format!("读取项目目录失败:{}: {error}", root.display()))?; - if metadata.file_type().is_symlink() { - return Err("项目目录不能是符号链接".to_string()); + match fs::symlink_metadata(root) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err("项目目录不能是符号链接".to_string()); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!("读取项目目录失败:{}: {error}", root.display())); } } Ok(()) @@ -3531,7 +3555,7 @@ pub(crate) fn read_or_create_manifest( } let manifest_path = root.join(".agent/manifest.json"); - let manifest = if manifest_path.exists() { + let manifest = if manifest_storage_exists(&manifest_path)? { read_manifest(&manifest_path)? } else { new_game_creation_app_manifest("local-project-draft", "未命名游戏原型") @@ -3560,11 +3584,173 @@ pub(crate) fn memory_file_path<'a>( } } +fn manifest_backup_path(path: &Path) -> PathBuf { + path.with_file_name(format!( + ".{}.previous", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("manifest.json") + )) +} + +pub(crate) fn manifest_storage_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let backup_path = manifest_backup_path(path); + match fs::symlink_metadata(&backup_path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!( + "读取 manifest 恢复副本元数据失败:{}: {error}", + backup_path.display() + )), + } + } + Err(error) => Err(format!( + "读取 manifest 元数据失败:{}: {error}", + path.display() + )), + } +} + +fn remove_manifest_backup(path: &Path) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("manifest 恢复副本必须是普通文件".to_string()) + } + Ok(_) => fs::remove_file(path) + .map_err(|error| format!("删除 manifest 恢复副本失败:{}: {error}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "读取 manifest 恢复副本元数据失败:{}: {error}", + path.display() + )), + } +} + pub(crate) fn read_manifest(path: &Path) -> Result { - let payload = fs::read_to_string(path) - .map_err(|error| format!("读取 manifest 失败:{}: {error}", path.display()))?; + let backup_path = manifest_backup_path(path); + let (source_path, metadata, is_backup) = match fs::symlink_metadata(path) { + Ok(metadata) => (path, metadata, false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match fs::symlink_metadata(&backup_path) { + Ok(metadata) => (backup_path.as_path(), metadata, true), + Err(backup_error) if backup_error.kind() == std::io::ErrorKind::NotFound => { + return Err(format!("读取 manifest 失败:{}: {error}", path.display())); + } + Err(backup_error) => { + return Err(format!( + "读取 manifest 恢复副本元数据失败:{}: {backup_error}", + backup_path.display() + )); + } + } + } + Err(error) => { + return Err(format!( + "读取 manifest 元数据失败:{}: {error}", + path.display() + )); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(if is_backup { + "manifest 恢复副本必须是普通文件".to_string() + } else { + "manifest 必须是普通文件".to_string() + }); + } + let label = if is_backup { + "manifest 恢复副本" + } else { + "manifest" + }; + + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut payload = String::new(); + options + .open(source_path) + .and_then(|mut file| file.read_to_string(&mut payload)) + .map_err(|error| format!("读取 {label} 失败:{}: {error}", source_path.display()))?; serde_json::from_str(&payload) - .map_err(|error| format!("解析 manifest 失败:{}: {error}", path.display())) + .map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display())) +} + +fn install_manifest_temp_with( + path: &Path, + temp_path: &Path, + mut rename_file: F, +) -> Result<(), String> +where + F: FnMut(&Path, &Path) -> std::io::Result<()>, +{ + let backup_path = manifest_backup_path(path); + match rename_file(temp_path, path) { + Ok(()) => remove_manifest_backup(&backup_path), + Err(replace_error) => { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return match rename_file(temp_path, path) { + Ok(()) => remove_manifest_backup(&backup_path), + Err(retry_error) => { + let _ = fs::remove_file(temp_path); + Err(format!( + "替换 manifest 失败:{} -> {}: {replace_error};重试失败:{retry_error}", + temp_path.display(), + path.display() + )) + } + }; + } + Err(error) => { + let _ = fs::remove_file(temp_path); + return Err(format!( + "读取 manifest 元数据失败:{}: {error}", + path.display() + )); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + let _ = fs::remove_file(temp_path); + return Err("manifest 必须是普通文件".to_string()); + } + if let Err(error) = remove_manifest_backup(&backup_path) { + let _ = fs::remove_file(temp_path); + return Err(error); + } + rename_file(path, &backup_path).map_err(|error| { + let _ = fs::remove_file(temp_path); + format!( + "准备替换 manifest 失败:{} -> {}: {error}", + path.display(), + backup_path.display() + ) + })?; + match rename_file(temp_path, path) { + Ok(()) => remove_manifest_backup(&backup_path), + Err(error) => { + let restore_error = rename_file(&backup_path, path).err(); + let _ = fs::remove_file(temp_path); + let restore_detail = restore_error + .map(|error| format!(";恢复旧文件失败:{error}")) + .unwrap_or_default(); + Err(format!( + "替换 manifest 失败:{} -> {}: {error}{restore_detail}", + temp_path.display(), + path.display() + )) + } + } + } + } } pub(crate) fn write_manifest( @@ -3577,8 +3763,37 @@ pub(crate) fn write_manifest( fs::create_dir_all(parent) .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; } - fs::write(path, format!("{payload}\n")) - .map_err(|error| format!("写入 manifest 失败:{}: {error}", path.display())) + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err("manifest 必须是普通文件".to_string()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 manifest 元数据失败:{}: {error}", + path.display() + )); + } + } + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("manifest.json"), + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + fs::write(&temp_path, format!("{payload}\n")).map_err(|error| { + format!( + "写入 manifest 临时文件失败:{}: {error}", + temp_path.display() + ) + })?; + install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to)) } pub(crate) fn sanitize_file_name(file_name: &str) -> String { @@ -3632,6 +3847,180 @@ pub(crate) fn unix_millis() -> u128 { .unwrap_or(0) } +#[cfg(test)] +mod manifest_recovery_tests { + use super::*; + + fn unique_manifest_test_root(test_name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-manifest-{test_name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) + } + + fn write_manifest_fixture(path: &Path, manifest: &GameCreationAppManifest) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create manifest fixture directory"); + } + let payload = serde_json::to_string_pretty(manifest).expect("serialize manifest fixture"); + fs::write(path, format!("{payload}\n")).expect("write manifest fixture"); + } + + #[test] + fn manifest_read_and_project_write_recover_previous_file() { + let root = unique_manifest_test_root("recover-previous"); + let manifest_path = root.join(".agent/manifest.json"); + let backup_path = root.join(".agent/.manifest.json.previous"); + let mut manifest = new_game_creation_app_manifest("project-recovered", "恢复项目"); + manifest.goal = Some("保留恢复副本内容".to_string()); + write_manifest_fixture(&backup_path, &manifest); + + let recovered = read_manifest(&manifest_path).expect("read manifest recovery file"); + assert_eq!(recovered.project_id, "project-recovered"); + assert_eq!(recovered.goal.as_deref(), Some("保留恢复副本内容")); + + let recovered = read_manifest_for_project(&root).expect("rewrite recovered manifest"); + assert_eq!(recovered.project_id, "project-recovered"); + assert!(manifest_path.is_file()); + assert!(!backup_path.exists()); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn manifest_install_uses_previous_when_direct_replace_fails() { + let root = unique_manifest_test_root("replace-fallback"); + let manifest_path = root.join(".agent/manifest.json"); + let temp_path = root.join(".agent/.manifest.json.tmp.test"); + let original = new_game_creation_app_manifest("project-original", "原项目"); + let replacement = new_game_creation_app_manifest("project-replacement", "替换项目"); + write_manifest_fixture(&manifest_path, &original); + write_manifest_fixture(&temp_path, &replacement); + let mut direct_install_attempts = 0; + + install_manifest_temp_with(&manifest_path, &temp_path, |from, to| { + if from == temp_path && to == manifest_path { + direct_install_attempts += 1; + if direct_install_attempts == 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "simulate Windows rename without replacement", + )); + } + } + fs::rename(from, to) + }) + .expect("replace manifest through recovery file"); + + assert_eq!(direct_install_attempts, 2); + assert_eq!( + read_manifest(&manifest_path) + .expect("read replacement manifest") + .project_id, + "project-replacement" + ); + assert!(!manifest_backup_path(&manifest_path).exists()); + assert!(!temp_path.exists()); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn manifest_install_restores_previous_when_new_file_install_fails() { + let root = unique_manifest_test_root("replace-restore"); + let manifest_path = root.join(".agent/manifest.json"); + let temp_path = root.join(".agent/.manifest.json.tmp.test"); + let original = new_game_creation_app_manifest("project-original", "原项目"); + let replacement = new_game_creation_app_manifest("project-replacement", "替换项目"); + write_manifest_fixture(&manifest_path, &original); + write_manifest_fixture(&temp_path, &replacement); + + let error = install_manifest_temp_with(&manifest_path, &temp_path, |from, to| { + if from == temp_path && to == manifest_path { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "simulate manifest install failure", + )); + } + fs::rename(from, to) + }) + .expect_err("manifest install must fail after restoring previous file"); + + assert!(error.contains("替换 manifest 失败")); + assert_eq!( + read_manifest(&manifest_path) + .expect("read restored manifest") + .project_id, + "project-original" + ); + assert!(!manifest_backup_path(&manifest_path).exists()); + assert!(!temp_path.exists()); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn manifest_read_rejects_directories() { + let root = unique_manifest_test_root("reject-directories"); + let manifest_path = root.join(".agent/manifest.json"); + fs::create_dir_all(&manifest_path).expect("create manifest directory"); + let error = read_manifest(&manifest_path).expect_err("reject manifest directory"); + assert!(error.contains("manifest 必须是普通文件")); + + fs::remove_dir_all(&manifest_path).expect("remove manifest directory"); + let backup_path = root.join(".agent/.manifest.json.previous"); + fs::create_dir_all(&backup_path).expect("create manifest recovery directory"); + let error = read_manifest(&manifest_path).expect_err("reject manifest recovery directory"); + assert!(error.contains("manifest 恢复副本必须是普通文件"), "{error}"); + + fs::remove_dir_all(root).ok(); + } + + #[cfg(unix)] + #[test] + fn manifest_read_and_write_reject_symbolic_links() { + use std::os::unix::fs::symlink; + + let root = unique_manifest_test_root("reject-symbolic-links"); + let manifest_path = root.join(".agent/manifest.json"); + let target_path = root.join("manifest-target.json"); + let original = new_game_creation_app_manifest("project-original", "原项目"); + write_manifest_fixture(&target_path, &original); + fs::create_dir_all(manifest_path.parent().expect("manifest parent")) + .expect("create manifest directory"); + symlink(&target_path, &manifest_path).expect("create manifest symbolic link"); + + let error = read_manifest(&manifest_path).expect_err("reject manifest symbolic link"); + assert!(error.contains("manifest 必须是普通文件")); + let replacement = new_game_creation_app_manifest("project-replacement", "替换项目"); + let error = write_manifest(&manifest_path, &replacement) + .expect_err("reject replacing manifest symbolic link"); + assert!(error.contains("manifest 必须是普通文件")); + assert!(fs::symlink_metadata(&manifest_path) + .expect("read manifest symbolic link metadata") + .file_type() + .is_symlink()); + assert_eq!( + read_manifest(&target_path) + .expect("read untouched manifest target") + .project_id, + "project-original" + ); + + fs::remove_file(&manifest_path).expect("remove manifest symbolic link"); + let backup_path = root.join(".agent/.manifest.json.previous"); + symlink(&target_path, &backup_path).expect("create recovery symbolic link"); + let error = read_manifest(&manifest_path).expect_err("reject recovery symbolic link"); + assert!(error.contains("manifest 恢复副本必须是普通文件"), "{error}"); + + fs::remove_dir_all(root).ok(); + } +} + #[cfg(test)] mod idempotent_conversation_tests { use super::*; 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 962812be3..6e31babc4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -139,6 +139,7 @@ fn pending_tool_action_for_test( observation: Option, ) -> AgentRuntimePendingToolAction { let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); + let input_summary = agent_runtime_tool_action_input_summary(root, &action); let occurrence_nonce = unix_timestamp(); let action_index = 0; let now = unix_timestamp(); @@ -158,6 +159,8 @@ fn pending_tool_action_for_test( plan: vec!["执行测试工具动作".to_string()], fallback_response: String::new(), observations: Vec::new(), + project_revision_before: read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision before pending action"), verification_gate_before: read_game_creator_agent_runtime_verification_gate( root, &state.agent_id, @@ -173,7 +176,7 @@ fn pending_tool_action_for_test( &action_fingerprint, ), action_fingerprint, - input_summary: Some("path=game/notes.txt".to_string()), + input_summary, execution_mode: AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(), status: status.to_string(), observation, @@ -215,8 +218,6 @@ fn persist_needs_reconciliation_runtime_for_test( None, ); pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); - pending.input_summary = - Some("path=game/reconciliation-side-effect.txt, contentChars=12".to_string()); write_game_creator_agent_runtime_pending_tool_action(root, &pending) .expect("write reconciliation pending ledger"); state.pending_tool_action = Some(pending.summary()); @@ -241,6 +242,27 @@ fn read_agent_db_records_for_test(root: &Path) -> Vec { .collect() } +async fn execute_agent_runtime_file_delete_for_test( + root: &Path, + run_id: &str, + path: Option<&str>, +) -> AgentRuntimeToolObservation { + execute_game_creator_agent_runtime_tool_action( + root, + "design-director", + run_id, + "删除不再需要的项目文件", + &AgentRuntimeToolAction { + tool: "file.delete".to_string(), + reason: Some("清理废弃项目文件".to_string()), + input: path + .map(|path| serde_json::json!({ "path": path })) + .unwrap_or_else(|| serde_json::json!({})), + }, + ) + .await +} + fn assert_auto_tool_action_audit_pair( records: &[Value], run_id: &str, @@ -1234,6 +1256,13 @@ fn read_mock_http_request(stream: &mut std::net::TcpStream) -> String { String::from_utf8_lossy(&request).into_owned() } +fn mock_http_request_json(request: &str) -> Value { + let (_, body) = request + .split_once("\r\n\r\n") + .expect("mock llm request body"); + serde_json::from_str(body).expect("mock llm request json") +} + fn spawn_mock_llm_server_responses_with_capture( response_contents: Vec, request_sender: Option>, @@ -2099,6 +2128,47 @@ async fn chat_with_game_creator_role_agent_uses_agent_context_and_route() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn chat_with_game_creator_role_agent_plain_entry_ignores_stream_config() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "普通回复回退项目").expect("project init"); + + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec!["普通回复回退成功。".to_string()], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "art-director": {{ + "apiKey": "art-key", + "baseUrl": {base_url:?}, + "model": "art-chat-model", + "apiKind": "openai_chat", + "stream": true, + "maxRetries": 0 + }} + }} +}}"# + )); + + let reply = + chat_with_game_creator_role_agent_at(&root, "art-director", "事件监听不可用,改用普通回复") + .await + .expect("plain role chat reply"); + + assert_eq!(reply.reply_text, "普通回复回退成功。"); + let request = receiver + .recv_timeout(Duration::from_secs(1)) + .expect("captured plain role chat request"); + assert!(request.contains("POST /chat/completions HTTP/1.1")); + assert!(request.contains("\"stream\":false")); + assert!(!request.contains("\"stream\":true")); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn chat_with_game_creator_role_agent_stream_emits_deltas() { let root = unique_project_path(); @@ -3552,7 +3622,7 @@ fn background_agent_runtime_rejects_cross_session_context_bundle_on_resume() { } #[test] -fn legacy_v1_context_and_pending_records_fail_closed() { +fn legacy_context_and_pending_records_fail_closed() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "旧版恢复失败关闭项目").expect("project init"); let state = start_game_creator_agent_runtime_task_at( @@ -3591,30 +3661,36 @@ fn legacy_v1_context_and_pending_records_fail_closed() { ); fs::create_dir_all(pending_path.parent().expect("pending parent")) .expect("create pending parent"); - fs::write( - &pending_path, - serde_json::json!({ - "schemaVersion": "game-creator-pending-action.v1", - "agentId": "design-director", - "runId": "legacy-v1-run" - }) - .to_string(), - ) - .expect("write legacy pending action"); - let pending_error = read_game_creator_agent_runtime_pending_tool_action( - &root, - "design-director", - "legacy-v1-run", - ) - .expect_err("legacy pending action must not execute without a gate snapshot"); - assert!(pending_error.contains("不支持的 Agent Runtime 待确认动作版本")); + for version in [ + "game-creator-pending-action.v1", + "game-creator-pending-action.v2", + ] { + fs::write( + &pending_path, + serde_json::json!({ + "schemaVersion": version, + "agentId": "design-director", + "runId": "legacy-v1-run" + }) + .to_string(), + ) + .expect("write legacy pending action"); + let pending_error = read_game_creator_agent_runtime_pending_tool_action( + &root, + "design-director", + "legacy-v1-run", + ) + .expect_err("legacy pending action must not execute without revision snapshots"); + assert!(pending_error.contains("不支持的 Agent Runtime 待确认动作版本")); + assert!(pending_error.contains(version)); + } let resumed = resume_game_creator_agent_background_tasks_at(&root) .expect("legacy pending action should fail only its own run"); assert!(resumed.iter().any(|runtime| { runtime.state.run_id == "legacy-v1-run" && runtime.state.status == "failed" && runtime.state.error.as_deref().is_some_and(|error| { - error.contains("待确认动作恢复失败") && error.contains("pending-action.v1") + error.contains("待确认动作恢复失败") && error.contains("pending-action.v2") }) })); @@ -4367,6 +4443,7 @@ fn agent_runtime_default_allowed_tools_match_executable_whitelist() { .collect::>(); assert_eq!(default_game_creator_agent_runtime_allowed_tools(), expected); assert!(expected.contains(&"project.index".to_string())); + assert!(expected.contains(&"file.delete".to_string())); assert!(!expected.contains(&"conversation.write".to_string())); } @@ -6819,6 +6896,12 @@ async fn background_agent_runtime_can_write_memory_and_project_files() { .observations .iter() .any(|item| item.contains("project.verify:ok · check:agent 已通过"))); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read memory and file write revision") + .revision, + 3 + ); let agent_memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); assert!(agent_memory.content.contains("主角是戴月光围裙的小厨师。")); let project_memory = read_local_game_memory_at(&root, "long").expect("project memory"); @@ -11375,7 +11458,6 @@ async fn background_agent_runtime_resumes_approved_auto_action_once_without_llm_ None, ); pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); - pending.input_summary = Some("scope=agent, title=自动恢复唯一标记".to_string()); write_game_creator_agent_runtime_pending_tool_action(&root, &pending) .expect("write approved auto action"); state.status = "running".to_string(); @@ -11506,7 +11588,6 @@ async fn background_agent_runtime_resumes_observed_auto_action_without_reexecuti Some(observation.clone()), ); pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); - pending.input_summary = Some("scope=agent, title=已观察恢复唯一标记".to_string()); write_game_creator_agent_runtime_pending_tool_action(&root, &pending) .expect("write observed auto action"); append_auto_tool_action_audit_pair_for_test(&root, &pending, &observation); @@ -11562,171 +11643,212 @@ async fn background_agent_runtime_resumes_observed_auto_action_without_reexecuti #[test] fn background_agent_runtime_does_not_replay_interrupted_tool_execution() { - let root = unique_project_path(); - init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); - let mut state = start_game_creator_agent_runtime_task_at( - &root, - "design-director", - "核对中断的文件写入", - "design-executing-recovery-run", - "agent-background-task", - "模拟工具执行中进程退出", - vec!["写入项目文件".to_string()], - ) - .expect("start runtime state"); - state.loop_iteration = 1; - let action = AgentRuntimeToolAction { - tool: "file.write".to_string(), - reason: Some("写入可能产生副作用的内容".to_string()), - input: serde_json::json!({ - "path": "game/notes.txt", - "content": "不应被自动重放" - }), - }; - let mut pending = pending_tool_action_for_test( - &root, - &state, - action, - AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, - None, - ); - pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); - pending.input_summary = Some("path=game/notes.txt, contentChars=7".to_string()); - write_game_creator_agent_runtime_pending_tool_action(&root, &pending) - .expect("write executing pending action"); - append_agent_db_record( - &root, - serde_json::json!({ - "recordType": "agent.runtime.tool_action.executing", - "agentId": pending.agent_id, - "taskId": pending.task_id, - "runId": pending.run_id, - "actionId": pending.action_id, - "actionFingerprint": pending.action_fingerprint, - "tool": pending.action.tool, - "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, - "inputSummary": pending.input_summary, - }), - ) - .expect("append executing audit fixture"); - state.status = "running".to_string(); - state.phase = "action".to_string(); - state.pending_tool_action = Some(pending.summary()); - append_game_creator_agent_runtime_task(&root, &state).expect("append running task"); - write_game_creator_agent_runtime_state(&root, &state).expect("write running state"); - write_agent_runtime_task_record_for_test( - &root, - &AgentRuntimeTaskRecord { - schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), - agent_id: "art-director".to_string(), - task_id: "art-director".to_string(), - session_id: "agent-session-art-director".to_string(), - run_id: "reconciliation-child-terminal-run".to_string(), - source: "agent-delegate".to_string(), - parent_agent_id: Some("design-director".to_string()), - parent_run_id: Some("design-executing-recovery-run".to_string()), - delegation_id: Some("reconciliation-child-delegation".to_string()), - task: "父任务核对期间已完成的子任务".to_string(), - status: "completed".to_string(), - phase: "completed".to_string(), - current_action: "等待下一轮输入".to_string(), - terminal_detail: Some("子任务结果已安全落盘".to_string()), - error: None, - updated_at: unix_timestamp(), - }, - ); - - let resumed = resume_game_creator_agent_background_tasks_at(&root) - .expect("inspect interrupted execution"); - let reconciled = resumed - .iter() - .find(|runtime| runtime.state.agent_id == "design-director") - .expect("reconciliation runtime"); - assert_eq!(reconciled.state.status, "failed"); - assert_eq!(reconciled.state.phase, "needs-reconciliation"); - assert!(reconciled - .state - .error - .as_deref() - .is_some_and(|error| error.contains("不会自动重放"))); - let parent_after_receipt_reconcile = - read_game_creator_agent_runtime_at(&root, "design-director") - .expect("read parent after receipt reconciliation"); - assert_eq!( - parent_after_receipt_reconcile.state.run_id, - "design-executing-recovery-run" - ); - assert_eq!( - parent_after_receipt_reconcile.state.phase, - "needs-reconciliation" - ); - assert!(parent_after_receipt_reconcile - .recent_tasks - .iter() - .any(|task| { task.source == "agent-delegate-receipt" && task.status == "pending" })); - assert!(!root.join("game/notes.txt").exists()); - let pending_path = root - .join(".agent/runtime/pending-actions/design-director/design-executing-recovery-run.json"); - let persisted: AgentRuntimePendingToolAction = - serde_json::from_str(&fs::read_to_string(&pending_path).expect("executing ledger remains")) - .expect("parse executing ledger"); - assert_eq!( - persisted.status, - AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING - ); - assert_eq!( - persisted.execution_mode, - AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO - ); - assert_eq!(persisted.action_id, pending.action_id); - assert_eq!( - read_game_creator_agent_runtime_verification_gate( + fn assert_interrupted_action_is_not_replayed(tool: &str, run_id: &str, with_child: bool) { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow interrupted action fixture"); + let target = root.join(if tool == "file.delete" { + "game/interrupted-delete-target.txt" + } else { + "game/notes.txt" + }); + if tool == "file.delete" { + fs::write(&target, "中断恢复不得自动删除\n").expect("write interrupted delete target"); + } + let mut state = start_game_creator_agent_runtime_task_at( &root, "design-director", - "design-executing-recovery-run", + if tool == "file.delete" { + "核对中断的文件删除" + } else { + "核对中断的文件写入" + }, + run_id, + "agent-background-task", + "模拟工具执行中进程退出", + vec![format!("执行 {tool}")], ) - .expect("read executing recovery gate"), - pending.verification_gate_before, - "executing recovery must not fabricate or restore mutation credentials" - ); - assert!(retry_game_creator_agent_runtime_task_at( - &root, - "design-director", - "design-executing-recovery-run", - "unsafe-retry-run", - ) - .expect_err("reconciliation task cannot be retried before cancellation") - .contains("请先核对项目状态并取消原任务")); - let records = read_agent_db_records_for_test(&root); - assert_eq!( - records - .iter() - .filter(|record| { - record["recordType"] == "agent.runtime.tool_action.executing" - && record["actionId"] == pending.action_id - }) - .count(), - 1 - ); - assert!(!records.iter().any(|record| { - record["recordType"] == "agent.runtime.tool_action.observed" - && record["actionId"] == pending.action_id - })); - assert!(records.iter().any(|record| { - record["recordType"] == "agent.runtime.tool_action.needs_reconciliation" - && record["actionId"] == pending.action_id - })); - cancel_game_creator_agent_runtime_task_at( - &root, - "design-director", - "design-executing-recovery-run", - ) - .expect("cancel reconciled task"); - assert!(!root - .join(".agent/runtime/pending-actions/design-director/design-executing-recovery-run.json") - .exists()); + .expect("start runtime state"); + state.loop_iteration = 1; + let action = if tool == "file.delete" { + AgentRuntimeToolAction { + tool: tool.to_string(), + reason: Some("删除可能产生副作用的文件".to_string()), + input: serde_json::json!({ + "path": "game/interrupted-delete-target.txt" + }), + } + } else { + AgentRuntimeToolAction { + tool: tool.to_string(), + reason: Some("写入可能产生副作用的内容".to_string()), + input: serde_json::json!({ + "path": "game/notes.txt", + "content": "不应被自动重放" + }), + } + }; + let mut pending = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write executing pending action"); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_action.executing", + "agentId": pending.agent_id, + "taskId": pending.task_id, + "runId": pending.run_id, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "tool": pending.action.tool, + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "inputSummary": pending.input_summary, + }), + ) + .expect("append executing audit fixture"); + state.status = "running".to_string(); + state.phase = "action".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append running task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write running state"); + if with_child { + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "agent-session-art-director".to_string(), + run_id: "reconciliation-child-terminal-run".to_string(), + source: "agent-delegate".to_string(), + parent_agent_id: Some("design-director".to_string()), + parent_run_id: Some(run_id.to_string()), + delegation_id: Some("reconciliation-child-delegation".to_string()), + task: "父任务核对期间已完成的子任务".to_string(), + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "等待下一轮输入".to_string(), + terminal_detail: Some("子任务结果已安全落盘".to_string()), + error: None, + updated_at: unix_timestamp(), + }, + ); + } - fs::remove_dir_all(root).ok(); + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("inspect interrupted execution"); + let reconciled = resumed + .iter() + .find(|runtime| runtime.state.agent_id == "design-director") + .expect("reconciliation runtime"); + assert_eq!(reconciled.state.status, "failed", "{tool}"); + assert_eq!(reconciled.state.phase, "needs-reconciliation", "{tool}"); + assert!(reconciled + .state + .error + .as_deref() + .is_some_and(|error| error.contains("不会自动重放"))); + let runtime_after_reconcile = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read runtime after reconciliation"); + assert_eq!(runtime_after_reconcile.state.run_id, run_id); + assert_eq!(runtime_after_reconcile.state.phase, "needs-reconciliation"); + if with_child { + assert!(runtime_after_reconcile.recent_tasks.iter().any(|task| { + task.source == "agent-delegate-receipt" && task.status == "pending" + })); + } + if tool == "file.delete" { + assert_eq!( + fs::read_to_string(&target).expect("read preserved interrupted delete target"), + "中断恢复不得自动删除\n" + ); + } else { + assert!(!target.exists()); + } + let pending_path = root.join(format!( + ".agent/runtime/pending-actions/design-director/{run_id}.json" + )); + let persisted: AgentRuntimePendingToolAction = serde_json::from_str( + &fs::read_to_string(&pending_path).expect("executing ledger remains"), + ) + .expect("parse executing ledger"); + assert_eq!( + persisted.status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + ); + assert_eq!( + persisted.execution_mode, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO + ); + assert_eq!(persisted.action_id, pending.action_id); + assert_eq!(persisted.action.tool, tool); + assert_eq!( + read_game_creator_agent_runtime_verification_gate(&root, "design-director", run_id,) + .expect("read executing recovery gate"), + pending.verification_gate_before, + "executing recovery must not fabricate or restore mutation credentials" + ); + assert!(retry_game_creator_agent_runtime_task_at( + &root, + "design-director", + run_id, + &format!("unsafe-{tool}-retry-run"), + ) + .expect_err("reconciliation task cannot be retried before cancellation") + .contains("请先核对项目状态并取消原任务")); + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_action.executing" + && record["actionId"] == pending.action_id + }) + .count(), + 1 + ); + assert!(!records.iter().any(|record| { + record["recordType"] == "agent.runtime.tool_action.observed" + && record["actionId"] == pending.action_id + })); + assert!(records.iter().any(|record| { + record["recordType"] == "agent.runtime.tool_action.needs_reconciliation" + && record["actionId"] == pending.action_id + })); + if tool == "file.delete" { + assert!(!records.iter().any(|record| { + record["recordType"] == "agent.runtime.file.delete" + && record["path"] == "game/interrupted-delete-target.txt" + })); + } + cancel_game_creator_agent_runtime_task_at(&root, "design-director", run_id) + .expect("cancel reconciled task"); + assert!(!pending_path.exists()); + + fs::remove_dir_all(root).ok(); + } + + assert_interrupted_action_is_not_replayed("file.write", "design-executing-recovery-run", true); + assert_interrupted_action_is_not_replayed( + "file.delete", + "design-delete-executing-recovery-run", + false, + ); } #[test] @@ -11761,7 +11883,6 @@ fn background_agent_runtime_needs_reconciliation_blocks_approved_auto_recovery() None, ); pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); - pending.input_summary = Some("scope=agent, title=不应执行的恢复动作".to_string()); write_game_creator_agent_runtime_pending_tool_action(&root, &pending) .expect("write approved disk ledger"); state.status = "failed".to_string(); @@ -12023,7 +12144,6 @@ fn background_agent_runtime_pauses_auto_recovery_when_policy_becomes_stricter() None, ); pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); - pending.input_summary = Some("path=game/notes.txt, contentChars=12".to_string()); write_game_creator_agent_runtime_pending_tool_action(&root, &pending) .expect("write approved auto action"); state.status = "running".to_string(); @@ -12122,10 +12242,11 @@ fn background_agent_runtime_pauses_auto_recovery_when_policy_becomes_stricter() } #[tokio::test] -async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() { +async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_actions() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); fs::write(root.join("game/notes.txt"), "核心循环笔记").expect("write notes"); + let check_command = write_agent_runtime_verification_fixture(&root); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -12156,8 +12277,28 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() "response": "确认后已读取笔记:核心循环笔记。" }) .to_string(); - let base_url = - spawn_mock_llm_server_responses_with_capture(vec![read_plan, final_plan], Some(sender)); + let delete_plan = serde_json::json!({ + "thinkingSummary": "需要删除已废弃的项目文件", + "plan": ["删除废弃文件", "验证当前项目 revision"], + "actions": [ + { + "tool": "file.delete", + "reason": "清理已确认废弃的项目文件", + "input": { "path": "game/confirmed-delete-target.txt" } + } + ], + "response": "" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![ + read_plan, + final_plan, + delete_plan, + agent_runtime_verification_plan(check_command), + ], + Some(sender), + ); let _config_guard = write_test_local_config(format!( r#"{{ "agentLlm": {{ @@ -12276,6 +12417,124 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() agent_db.contains("\"inputSummary\":\"path=game/notes.txt · startLine=1 · maxLines=120\"") ); + let delete_target = root.join("game/confirmed-delete-target.txt"); + fs::write(&delete_target, "确认前必须保留\n").expect("write confirmed delete target"); + let default_policy = ProjectPermissionPolicy::default(); + assert!(default_policy + .confirm_commands + .contains(&"file.delete".to_string())); + assert!(default_policy + .confirm_commands + .contains(&"project.verify".to_string())); + write_project_permission_policy_at(&root, default_policy).expect("restore default policy"); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "删除已确认废弃的项目文件", + "design-delete-confirm-run", + ) + .expect("start delete confirmation task"); + + let delete_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("delete plan llm request"); + assert!(delete_request.contains("删除已确认废弃的项目文件")); + let delete_waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); + assert_eq!(delete_waiting.run_id, "design-delete-confirm-run"); + assert_eq!(delete_waiting.status, "waiting-for-confirmation"); + let delete_pending = delete_waiting + .pending_tool_action + .as_ref() + .expect("pending delete action"); + assert_eq!(delete_pending.tool, "file.delete"); + assert_eq!( + delete_pending.input_summary.as_deref(), + Some("path=game/confirmed-delete-target.txt") + ); + let delete_pending_path = + root.join(".agent/runtime/pending-actions/design-director/design-delete-confirm-run.json"); + let persisted_delete: AgentRuntimePendingToolAction = serde_json::from_str( + &fs::read_to_string(&delete_pending_path).expect("read persisted delete action"), + ) + .expect("parse persisted delete action"); + assert_eq!( + persisted_delete.schema_version, + AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION + ); + assert_eq!(persisted_delete.action.tool, "file.delete"); + assert_eq!(persisted_delete.action_id, delete_pending.action_id); + assert_eq!( + persisted_delete.project_revision_before, + read_game_creator_agent_runtime_project_revision(&root) + .expect("read project revision while delete waits") + ); + assert_eq!( + fs::read_to_string(&delete_target).expect("read target before confirmation"), + "确认前必须保留\n" + ); + assert!(!read_agent_db_records_for_test(&root).iter().any(|record| { + record["recordType"] == "agent.runtime.file.delete" + && record["path"] == "game/confirmed-delete-target.txt" + })); + + let confirmed_delete = confirm_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-delete-confirm-run", + &delete_pending.action_id, + "允许删除这份废弃文件", + ) + .expect("confirm pending delete action"); + assert_eq!(confirmed_delete.state.run_id, "design-delete-confirm-run"); + assert_eq!(confirmed_delete.state.status, "running"); + let verification_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("verification plan request after confirmed delete"); + assert!(verification_request.contains("file.delete")); + assert!(verification_request.contains("已删除 game/confirmed-delete-target.txt")); + let verification_waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); + assert_eq!(verification_waiting.run_id, "design-delete-confirm-run"); + assert_eq!(verification_waiting.status, "waiting-for-confirmation"); + assert_eq!( + verification_waiting + .pending_tool_action + .as_ref() + .map(|action| action.tool.as_str()), + Some("project.verify") + ); + assert!(!delete_target.exists()); + assert!(verification_waiting + .observations + .iter() + .any(|item| item.contains("file.delete:ok"))); + assert!(verification_waiting.recent_tool_calls.iter().any(|call| { + call.tool == "file.delete" + && call.status == "ok" + && call.action_fingerprint.as_deref() + == Some(delete_pending.action_fingerprint.as_str()) + })); + + let records = read_agent_db_records_for_test(&root); + assert!(records.iter().any(|record| { + record["recordType"] == "agent.runtime.tool_confirmation.approved" + && record["confirmedRunId"] == "design-delete-confirm-run" + && record["actionId"] == delete_pending.action_id + })); + assert!(records.iter().any(|record| { + record["recordType"] == "agent.runtime.file.delete" + && record["path"] == "game/confirmed-delete-target.txt" + && record["deleted"] == true + })); + + let cancelled = cancel_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-delete-confirm-run", + ) + .expect("cancel pending verification after delete evidence"); + assert_eq!(cancelled.state.status, "cancelled"); + fs::remove_dir_all(root).ok(); } @@ -12380,6 +12639,336 @@ async fn background_agent_runtime_confirmation_is_bound_to_exact_tool_input() { fs::remove_dir_all(root).ok(); } +#[test] +fn background_agent_runtime_confirmation_rejects_tampered_input_summary() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "待确认摘要校验").expect("project init"); + let target = root.join("game/summary-bound-delete-target.txt"); + fs::write(&target, "待确认摘要被篡改时必须保留\n").expect("write delete target"); + + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "删除已废弃的摘要绑定文件", + "design-summary-bound-confirm-run", + "agent-background-task", + "等待确认 file.delete", + vec!["确认后删除目标文件".to_string()], + ) + .expect("start runtime state"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.delete".to_string(), + reason: Some("删除已废弃文件".to_string()), + input: serde_json::json!({ + "path": "game/summary-bound-delete-target.txt" + }), + }; + let pending = pending_tool_action_for_test(&root, &state, action, "pending-confirmation", None); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write pending action"); + let pending_path = root.join( + ".agent/runtime/pending-actions/design-director/design-summary-bound-confirm-run.json", + ); + let mut pending_json = serde_json::to_value(&pending).expect("serialize pending action"); + pending_json["inputSummary"] = Value::String("path=game/other.txt".to_string()); + fs::write( + &pending_path, + serde_json::to_string_pretty(&pending_json).expect("serialize tampered summary"), + ) + .expect("tamper persisted input summary"); + state.status = "waiting-for-confirmation".to_string(); + state.phase = "waiting-for-confirmation".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append waiting task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write waiting state"); + + let error = confirm_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-summary-bound-confirm-run", + &pending.action_id, + "批准界面展示的目标", + ) + .expect_err("tampered public summary must fail closed"); + + assert!(error.contains("输入摘要与实际动作不一致")); + assert_eq!( + fs::read_to_string(&target).expect("read preserved target"), + "待确认摘要被篡改时必须保留\n" + ); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_file_delete_revalidates_revision_after_acquiring_project_lock() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "删除锁内复核").expect("project init"); + let target = root.join("game/lock-revalidated-delete-target.txt"); + fs::write(&target, "revision 漂移后必须保留\n").expect("write delete target"); + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "删除等待执行的文件", + "design-lock-revalidated-delete-run", + "agent-background-task", + "准备执行 file.delete", + vec!["删除目标文件".to_string()], + ) + .expect("start runtime state"); + let action = AgentRuntimeToolAction { + tool: "file.delete".to_string(), + reason: Some("删除已废弃文件".to_string()), + input: serde_json::json!({ + "path": "game/lock-revalidated-delete-target.txt" + }), + }; + let pending = pending_tool_action_for_test( + &root, + &state, + action.clone(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow direct delete execution in test"); + assert_eq!( + advance_project_revision_for_test( + &root, + "art-director", + "art-concurrent-lock-revalidation-run", + "file.write", + ), + 1 + ); + + let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action( + &root, + "design-director", + "design-lock-revalidated-delete-run", + &state.current_task, + &action, + Some(&pending.action_id), + Some(&pending), + ) + .await; + + assert_eq!(observation.status, "verification-failed"); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("项目 revision 已变化"))); + assert_eq!( + fs::read_to_string(&target).expect("read preserved target"), + "revision 漂移后必须保留\n" + ); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_file_delete_reports_reconciliation_when_audit_fails_after_delete() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "删除审计失败核对").expect("project init"); + let target = root.join("game/delete-audit-failure-target.txt"); + fs::write(&target, "副作用已执行\n").expect("write delete target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow direct delete execution in test"); + let agent_db_path = root.join(".agent/agent.db"); + fs::remove_file(&agent_db_path).expect("remove agent db file"); + fs::create_dir(&agent_db_path).expect("replace agent db with directory"); + let action = AgentRuntimeToolAction { + tool: "file.delete".to_string(), + reason: Some("删除目标并模拟审计失败".to_string()), + input: serde_json::json!({ + "path": "game/delete-audit-failure-target.txt" + }), + }; + + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + "design-director", + "design-delete-audit-failure-run", + "删除目标并验证审计失败语义", + &action, + ) + .await; + + assert_eq!(observation.status, "needs-reconciliation"); + assert!(observation + .summary + .contains("文件已删除但 Agent DB 审计失败")); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("sideEffectApplied=true"))); + assert!(!target.exists()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_file_delete_confirmation_rejects_stale_project_revision() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let target = root.join("game/stale-confirm-delete-target.txt"); + fs::write(&target, "revision 漂移后必须保留\n").expect("write stale delete target"); + let default_policy = ProjectPermissionPolicy::default(); + assert!(default_policy + .confirm_commands + .contains(&"file.delete".to_string())); + write_project_permission_policy_at(&root, default_policy).expect("write default policy"); + + let delete_plan = serde_json::json!({ + "thinkingSummary": "删除已废弃的项目文件", + "plan": ["等待确认后删除目标文件"], + "actions": [{ + "tool": "file.delete", + "reason": "清理废弃文件", + "input": { "path": "game/stale-confirm-delete-target.txt" } + }], + "response": "" + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture(vec![delete_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", + "删除等待确认期间可能已经变化的文件", + "design-stale-delete-confirm-run", + ) + .expect("start stale delete confirmation task"); + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("stale delete plan request"); + let waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); + assert_eq!(waiting.run_id, "design-stale-delete-confirm-run"); + let pending_summary = waiting + .pending_tool_action + .as_ref() + .expect("pending stale delete action"); + assert_eq!(pending_summary.tool, "file.delete"); + let pending_path = root.join( + ".agent/runtime/pending-actions/design-director/design-stale-delete-confirm-run.json", + ); + let pending: AgentRuntimePendingToolAction = serde_json::from_str( + &fs::read_to_string(&pending_path).expect("read stale delete pending action"), + ) + .expect("parse stale delete pending action"); + assert_eq!(pending.action_id, pending_summary.action_id); + assert_eq!(pending.project_revision_before.revision, 0); + assert_eq!( + fs::read_to_string(&target).expect("read target while confirmation waits"), + "revision 漂移后必须保留\n" + ); + + assert_eq!( + advance_project_revision_for_test( + &root, + "art-director", + "art-concurrent-mutation-run", + "file.write", + ), + 1 + ); + assert_ne!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read advanced project revision"), + pending.project_revision_before + ); + + let approved = confirm_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-stale-delete-confirm-run", + &pending_summary.action_id, + "批准原删除动作", + ) + .expect("accept approval decision before asynchronous revision reconciliation"); + assert_eq!(approved.state.run_id, "design-stale-delete-confirm-run"); + assert_eq!(approved.state.status, "running"); + + let mut reconciled = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read stale delete runtime") + .state; + for _ in 0..250 { + if reconciled.phase == "needs-reconciliation" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + reconciled = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read stale delete reconciliation") + .state; + } + assert_eq!(reconciled.run_id, "design-stale-delete-confirm-run"); + assert_eq!(reconciled.status, "failed"); + assert_eq!(reconciled.phase, "needs-reconciliation"); + assert!(reconciled + .error + .as_deref() + .is_some_and(|error| error.contains("项目 revision 已变化"))); + assert_eq!( + fs::read_to_string(&target).expect("read target after stale approval"), + "revision 漂移后必须保留\n" + ); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + let persisted: AgentRuntimePendingToolAction = serde_json::from_str( + &fs::read_to_string(&pending_path).expect("stale delete ledger must remain"), + ) + .expect("parse reconciled stale delete ledger"); + assert_eq!( + persisted.status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + ); + let records = read_agent_db_records_for_test(&root); + assert!(records.iter().any(|record| { + record["recordType"] == "agent.runtime.tool_confirmation.approved" + && record["actionId"] == pending_summary.action_id + })); + assert!(!records.iter().any(|record| { + record["recordType"] == "agent.runtime.tool_action.executing" + && record["actionId"] == pending_summary.action_id + })); + assert!(!records.iter().any(|record| { + record["recordType"] == "agent.runtime.file.delete" + && record["path"] == "game/stale-confirm-delete-target.txt" + })); + + cancel_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-stale-delete-confirm-run", + ) + .expect("cancel reconciled stale delete task after manual check"); + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_reject_pending_tool_action_and_replan() { let root = unique_project_path(); @@ -13048,6 +13637,523 @@ async fn agent_runtime_file_patch_preserves_file_when_match_count_differs() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn agent_runtime_file_delete_removes_file_and_advances_verification_gate() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let target = root.join("game/obsolete-notes.txt"); + fs::write(&target, "这份旧笔记应被删除\n").expect("write delete target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow file delete"); + + let observation = execute_agent_runtime_file_delete_for_test( + &root, + "file-delete-success-run", + Some("game/obsolete-notes.txt"), + ) + .await; + + assert_eq!(observation.tool, "file.delete"); + assert_eq!(observation.status, "ok"); + assert_eq!(observation.summary, "已删除 game/obsolete-notes.txt"); + assert_eq!(observation.detail.as_deref(), Some("deleted=true")); + assert!(!target.exists()); + let delete_records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| record["recordType"] == "agent.runtime.file.delete") + .collect::>(); + assert_eq!(delete_records.len(), 1); + assert_eq!(delete_records[0]["agentId"], "design-director"); + assert_eq!(delete_records[0]["path"], "game/obsolete-notes.txt"); + assert_eq!(delete_records[0]["deleted"], true); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read delete revision") + .revision, + 1 + ); + let gate = read_game_creator_agent_runtime_verification_gate( + &root, + "design-director", + "file-delete-success-run", + ) + .expect("read delete verification gate"); + assert!(gate.requires_verification); + assert_eq!(gate.mutation_revision, Some(1)); + assert_eq!(gate.verified_revision, None); + assert_eq!(gate.last_mutation_tool.as_deref(), Some("file.delete")); + assert!(project_verification_completion_blocker_at( + &root, + "design-director", + "file-delete-success-run", + std::slice::from_ref(&observation), + ) + .is_some()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_file_delete_is_idempotent_when_target_is_already_missing() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow idempotent file delete"); + assert!(!root.join("game/already-missing.txt").exists()); + + let observation = execute_agent_runtime_file_delete_for_test( + &root, + "file-delete-already-missing-run", + Some("game/already-missing.txt"), + ) + .await; + + assert_eq!(observation.tool, "file.delete"); + assert_eq!(observation.status, "ok"); + assert_eq!( + observation.summary, + "目标文件已不存在:game/already-missing.txt" + ); + assert_eq!(observation.detail.as_deref(), Some("deleted=false")); + let delete_records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| record["recordType"] == "agent.runtime.file.delete") + .collect::>(); + assert_eq!(delete_records.len(), 1); + assert_eq!(delete_records[0]["agentId"], "design-director"); + assert_eq!(delete_records[0]["path"], "game/already-missing.txt"); + assert_eq!(delete_records[0]["deleted"], false); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read idempotent delete revision") + .revision, + 1 + ); + let gate = read_game_creator_agent_runtime_verification_gate( + &root, + "design-director", + "file-delete-already-missing-run", + ) + .expect("read idempotent delete verification gate"); + assert!(gate.requires_verification); + assert_eq!(gate.mutation_revision, Some(1)); + assert_eq!(gate.verified_revision, None); + assert_eq!(gate.last_mutation_tool.as_deref(), Some("file.delete")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_file_delete_uses_independent_permission_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let target = root.join("game/policy-delete-target.txt"); + fs::write(&target, "权限测试文件\n").expect("write policy target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["file.write".to_string()], + confirm_commands: vec!["file.delete".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("require file delete confirmation"); + + let waiting = execute_agent_runtime_file_delete_for_test( + &root, + "file-delete-confirm-policy-run", + Some("game/policy-delete-target.txt"), + ) + .await; + assert_eq!(waiting.status, "waiting-for-confirmation"); + assert!(waiting.summary.contains("file.delete")); + assert!(!waiting.summary.contains("file.write")); + assert!(target.exists()); + + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["file.delete".to_string()], + confirm_commands: vec!["file.write".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("deny file delete"); + let blocked = execute_agent_runtime_file_delete_for_test( + &root, + "file-delete-deny-policy-run", + Some("game/policy-delete-target.txt"), + ) + .await; + assert_eq!(blocked.status, "blocked"); + assert!(blocked.summary.contains("file.delete")); + assert!(!blocked.summary.contains("file.write")); + assert!(target.exists()); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read policy-blocked revision") + .revision, + 0 + ); + assert!(!read_agent_db_records_for_test(&root) + .iter() + .any(|record| record["recordType"] == "agent.runtime.file.delete")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_file_delete_rechecks_permission_policy_after_project_lock() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "删除废弃说明", + "file-delete-policy-recheck-run", + "agent-background-task", + "准备删除文件", + vec!["删除废弃说明".to_string()], + ) + .expect("runtime state"); + let action = AgentRuntimeToolAction { + tool: "file.delete".to_string(), + reason: Some("删除废弃说明".to_string()), + input: serde_json::json!({ "path": "game/obsolete.txt" }), + }; + let mut pending = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["file.delete".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("deny file delete while action waits for project lock"); + assert!(matches!( + game_creator_agent_runtime_tool_policy_block_after_lock( + &root, + "design-director", + "file.delete", + Some(&pending), + ), + Some(AgentRuntimeToolPolicyBlock::Denied(_)) + )); + + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.delete".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("require confirmation while automatic action waits for project lock"); + assert!(matches!( + game_creator_agent_runtime_tool_policy_block_after_lock( + &root, + "design-director", + "file.delete", + Some(&pending), + ), + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) + )); + + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); + assert!(game_creator_agent_runtime_tool_policy_block_after_lock( + &root, + "design-director", + "file.delete", + Some(&pending), + ) + .is_none()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn agent_runtime_file_delete_rejects_unsafe_and_agent_control_paths() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let directory = root.join("game/delete-protected-dir"); + fs::create_dir_all(&directory).expect("create directory target"); + fs::write(directory.join("keep.txt"), "目录内容必须保留\n").expect("write directory file"); + let backslash_target = root.join("game/backslash-target.txt"); + fs::write(&backslash_target, "反斜杠路径不能删除这个文件\n").expect("write backslash target"); + let outside_target = unique_project_path().with_extension("txt"); + fs::write(&outside_target, "项目外文件必须保留\n").expect("write outside target"); + let parent_traversal_path = format!( + "../{}", + outside_target + .file_name() + .expect("outside target file name") + .to_string_lossy() + ); + let absolute_path = outside_target.to_string_lossy().into_owned(); + let private_target = root.join(".agent/runtime/delete-protected.json"); + fs::create_dir_all(private_target.parent().expect("runtime private parent")) + .expect("create runtime private dir"); + fs::write(&private_target, r#"{"state":"must-survive"}"#) + .expect("write runtime private target"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow file delete validation"); + let policy_target = root.join(".agent/policy.json"); + let policy_before = fs::read(&policy_target).expect("read protected policy"); + let manifest_target = root.join(".agent/manifest.json"); + let manifest_before = fs::read(&manifest_target).expect("read protected manifest"); + let agent_db_target = root.join(".agent/agent.db"); + let agent_db_marker = r#"{"recordType":"test.file.delete.control-marker"}"#; + let mut agent_db = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&agent_db_target) + .expect("open protected agent db"); + writeln!(agent_db, "{agent_db_marker}").expect("append agent db marker"); + drop(agent_db); + + let missing = + execute_agent_runtime_file_delete_for_test(&root, "file-delete-missing-path-run", None) + .await; + assert_eq!(missing.status, "failed"); + assert_eq!(missing.summary, "缺少 path"); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read missing-path revision") + .revision, + 0 + ); + assert!(!game_creator_agent_runtime_verification_gate_path( + &root, + "design-director", + "file-delete-missing-path-run" + ) + .exists()); + + for (run_id, path, expected_error) in [ + ( + "file-delete-absolute-path-run", + absolute_path.as_str(), + "不能是绝对路径", + ), + ( + "file-delete-parent-traversal-run", + parent_traversal_path.as_str(), + "项目文件路径非法", + ), + ( + "file-delete-backslash-run", + "game\\backslash-target.txt", + "不能包含反斜杠", + ), + ] { + let observation = + execute_agent_runtime_file_delete_for_test(&root, run_id, Some(path)).await; + assert_eq!(observation.status, "failed", "{path}"); + assert!(observation.summary.contains(expected_error), "{path}"); + assert_eq!( + fs::read_to_string(&outside_target).expect("read preserved outside target"), + "项目外文件必须保留\n" + ); + assert_eq!( + fs::read_to_string(&backslash_target).expect("read preserved backslash target"), + "反斜杠路径不能删除这个文件\n" + ); + } + + let directory_observation = execute_agent_runtime_file_delete_for_test( + &root, + "file-delete-directory-run", + Some("game/delete-protected-dir"), + ) + .await; + assert_eq!(directory_observation.status, "failed"); + assert!(directory_observation.summary.contains("只能删除文件")); + assert!(directory.join("keep.txt").exists()); + + for (run_id, path, expected_error) in [ + ( + "file-delete-runtime-private-run", + ".agent/runtime/delete-protected.json", + "Runtime 私有控制面", + ), + ( + "file-delete-agent-db-run", + ".agent/agent.db", + "Agent 控制面", + ), + ( + "file-delete-policy-run", + ".agent/policy.json", + "Agent 控制面", + ), + ( + "file-delete-manifest-run", + ".agent/manifest.json", + "Agent 控制面", + ), + ] { + let observation = + execute_agent_runtime_file_delete_for_test(&root, run_id, Some(path)).await; + assert_eq!(observation.status, "failed", "{path}"); + assert!(observation.summary.contains(expected_error), "{path}"); + } + assert!(private_target.exists()); + assert_eq!( + fs::read_to_string(&private_target).expect("read protected runtime target"), + r#"{"state":"must-survive"}"# + ); + assert!(fs::read_to_string(&agent_db_target) + .expect("read protected agent db") + .contains(agent_db_marker)); + assert_eq!( + fs::read(&policy_target).expect("read protected policy after delete attempts"), + policy_before + ); + assert_eq!( + fs::read(&manifest_target).expect("read protected manifest after delete attempts"), + manifest_before + ); + + let project_lock_target = root.join(".agent/project.lock"); + fs::write(&project_lock_target, "project lock marker").expect("write protected project lock"); + let project_lock_error = delete_local_project_file_at(&root, ".agent/project.lock") + .expect_err("file.delete must reject the project lock itself"); + assert!(project_lock_error.contains("Agent 控制面")); + assert_eq!( + fs::read_to_string(&project_lock_target).expect("read protected project lock"), + "project lock marker" + ); + fs::remove_file(&project_lock_target).expect("remove project lock fixture"); + + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read conservative delete revision") + .revision, + 8 + ); + let directory_gate = read_game_creator_agent_runtime_verification_gate( + &root, + "design-director", + "file-delete-directory-run", + ) + .expect("read directory delete gate"); + let private_gate = read_game_creator_agent_runtime_verification_gate( + &root, + "design-director", + "file-delete-runtime-private-run", + ) + .expect("read runtime private delete gate"); + assert!(directory_gate.requires_verification); + assert_eq!(directory_gate.mutation_revision, Some(4)); + assert_eq!( + directory_gate.last_mutation_tool.as_deref(), + Some("file.delete") + ); + assert!(private_gate.requires_verification); + assert_eq!(private_gate.mutation_revision, Some(5)); + assert_eq!( + private_gate.last_mutation_tool.as_deref(), + Some("file.delete") + ); + assert!(!read_agent_db_records_for_test(&root) + .iter() + .any(|record| record["recordType"] == "agent.runtime.file.delete")); + + fs::remove_dir_all(root).ok(); + fs::remove_file(outside_target).ok(); +} + +#[cfg(unix)] +#[tokio::test] +async fn agent_runtime_file_delete_rejects_live_and_dangling_symlinks() { + use std::os::unix::fs::symlink; + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow file delete symlink validation"); + + let outside_target = unique_project_path().with_extension("txt"); + fs::write(&outside_target, "符号链接外目标必须保留\n").expect("write symlink outside target"); + let live_link = root.join("game/live-delete-link.txt"); + symlink(&outside_target, &live_link).expect("create live symlink"); + + let dangling_target = unique_project_path().with_extension("missing"); + assert!(!dangling_target.exists()); + let dangling_link = root.join("game/dangling-delete-link.txt"); + symlink(&dangling_target, &dangling_link).expect("create dangling symlink"); + + for (run_id, path) in [ + ("file-delete-live-symlink-run", "game/live-delete-link.txt"), + ( + "file-delete-dangling-symlink-run", + "game/dangling-delete-link.txt", + ), + ] { + let observation = + execute_agent_runtime_file_delete_for_test(&root, run_id, Some(path)).await; + assert_eq!(observation.status, "failed", "{path}"); + assert!(observation.summary.contains("符号链接"), "{path}"); + } + + assert!(fs::symlink_metadata(&live_link) + .expect("live link metadata") + .file_type() + .is_symlink()); + assert_eq!( + fs::read_to_string(&outside_target).expect("read preserved symlink target"), + "符号链接外目标必须保留\n" + ); + assert!(fs::symlink_metadata(&dangling_link) + .expect("dangling link metadata") + .file_type() + .is_symlink()); + assert!(!dangling_target.exists()); + assert!(!read_agent_db_records_for_test(&root) + .iter() + .any(|record| record["recordType"] == "agent.runtime.file.delete")); + + fs::remove_dir_all(root).ok(); + fs::remove_file(outside_target).ok(); +} + #[tokio::test] async fn agent_runtime_search_and_patch_inherit_file_read_write_policy() { let root = unique_project_path(); @@ -13270,6 +14376,141 @@ async fn background_agent_runtime_can_search_patch_and_read_in_sequence() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_deletes_file_then_verifies_before_completion() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let check_command = write_agent_runtime_verification_fixture(&root); + let target = root.join("game/obsolete-runtime-file.txt"); + fs::write(&target, "这个废弃文件应由 Agent Runtime 删除\n").expect("write obsolete file"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow delete and verification tools"); + let delete_plan = serde_json::json!({ + "thinkingSummary": "先删除废弃文件,再验证当前项目 revision", + "plan": ["删除废弃文件", "验证当前 revision", "确认后收束"], + "actions": [{ + "tool": "file.delete", + "reason": "项目不再需要这份旧文件", + "input": { "path": "game/obsolete-runtime-file.txt" } + }], + "response": "" + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![ + delete_plan, + agent_runtime_verification_plan(check_command), + final_tool_plan_response("废弃文件已删除,当前 revision 验证通过。"), + ], + 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", + "删除废弃项目文件并完成验证", + "design-file-delete-loop-run", + ) + .expect("start file delete background task"); + + let plan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("file delete plan request"); + let plan_request_json = mock_http_request_json(&plan_request); + let submit_plan_tool = plan_request_json["tools"] + .as_array() + .expect("planning function tools") + .iter() + .find(|tool| tool["name"] == AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME) + .expect("submit tool plan schema"); + let executable_tool_enum = submit_plan_tool["parameters"]["properties"]["actions"]["items"] + ["properties"]["tool"]["enum"] + .as_array() + .expect("executable tool enum"); + assert!(executable_tool_enum + .iter() + .any(|tool| tool.as_str() == Some("file.delete"))); + let prompt_input = plan_request_json["input"].to_string(); + assert!(prompt_input.contains("file.delete 使用")); + assert!(prompt_input.contains("只删除项目内普通文件")); + + let verification_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("verification plan request"); + assert!(verification_request.contains("file.delete")); + assert!(verification_request.contains("已删除 game/obsolete-runtime-file.txt")); + assert!(verification_request.contains("deleted=true")); + let final_request = receiver + .recv_timeout(Duration::from_secs(10)) + .expect("final plan request after verification"); + assert!(final_request.contains("project.verify")); + assert!(final_request.contains("AGENT_RUNTIME_CURRENT_REVISION_OK")); + + 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("废弃文件已删除,当前 revision 验证通过。") + ); + assert!(!target.exists()); + assert_eq!( + runtime + .recent_tool_calls + .iter() + .map(|call| (call.tool.as_str(), call.status.as_str())) + .collect::>(), + vec![("file.delete", "ok"), ("project.verify", "ok")] + ); + let delete_records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| record["recordType"] == "agent.runtime.file.delete") + .collect::>(); + assert_eq!(delete_records.len(), 1); + assert_eq!(delete_records[0]["agentId"], "design-director"); + assert_eq!(delete_records[0]["path"], "game/obsolete-runtime-file.txt"); + assert_eq!(delete_records[0]["deleted"], true); + let gate = read_game_creator_agent_runtime_verification_gate( + &root, + "design-director", + "design-file-delete-loop-run", + ) + .expect("read completed delete verification gate"); + assert!(gate.requires_verification); + assert_eq!(gate.mutation_revision, Some(1)); + assert_eq!(gate.verified_revision, Some(1)); + assert_eq!(gate.last_mutation_tool.as_deref(), Some("file.delete")); + assert!(project_verification_completion_blocker_at( + &root, + "design-director", + "design-file-delete-loop-run", + &[], + ) + .is_none()); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_retries_empty_plan_and_final_responses() { let root = unique_project_path(); @@ -19078,6 +20319,44 @@ fn local_project_file_commands_read_write_list_and_delete_text_files() { fs::remove_dir_all(root).ok(); } +#[test] +fn developer_project_file_and_memory_mutations_advance_project_revision() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "开发面板 revision").expect("project init"); + let project_path = root.to_string_lossy().into_owned(); + + write_local_project_file( + project_path.clone(), + "game/revision.txt".to_string(), + "v1".to_string(), + ) + .expect("write project file through command"); + write_local_agent_memory( + project_path.clone(), + "design-director".to_string(), + "私有记忆".to_string(), + ) + .expect("write agent memory through command"); + write_local_game_memory( + project_path.clone(), + "long".to_string(), + "项目记忆".to_string(), + ) + .expect("write project memory through command"); + delete_local_game_memory(project_path.clone(), "long".to_string()) + .expect("delete project memory through command"); + delete_local_project_file(project_path, "game/revision.txt".to_string()) + .expect("delete project file through command"); + + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read developer mutation revision") + .revision, + 5 + ); + fs::remove_dir_all(root).ok(); +} + #[test] fn local_project_checkpoint_diff_restore_and_index_are_recorded() { let root = unique_project_path(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ac91e215a..f6fd5cf57 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -24,7 +24,9 @@ - 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-only 尾包、保留 finish reason 与上游 error message,收到 `[DONE]` 后立即结束;正文与 finish reason 已接收后出现尾包异常时保存已完成正文,不把整轮改写成失败。持久事件订阅失败时显示非致命错误,聊天事件监听不可用或首个文本片段前流式失败时降级普通回复并继续落盘。 - 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 补充,2026-07-12 更新:单 Agent 代码闭环新增开发专用 `project.verify`,用于在修改后执行项目根 `package.json` 已定义的固定脚本 `check / typecheck / test / lint / build`,或以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本;当前只支持 npm,不接受自由命令、参数或工作目录。Agent 必须先读取普通文件 `package.json`,再把真实存在的脚本名、完整原始脚本文本 `expectedCommand` 和 1-300 秒超时一起提交;Runtime 在真正执行前重新解析 JSON,要求脚本仍存在且正文精确一致,脚本漂移时拒绝执行。非 npm `packageManager` 或 pnpm / yarn / bun 锁文件必须失败关闭;`pre* / post*` 生命周期脚本名不在允许范围,npm 执行再附加 `--ignore-scripts`,阻止所选脚本关联的 pre/post lifecycle。该工具使用独立、默认 `confirm` 的 `project.verify` 权限,不再与 `command.run_limited` / `game.static_smoke` 共用授权;确认指纹覆盖脚本正文和超时。执行时不经过 App 自行拼接的 `bash -c`;继承环境被清理到 PATH 与必要平台变量,HOME/TMP/npm cache 隔离,stdin 关闭,输出保留有界头尾。Unix 下验证根进程正常结束或超时都会清理同进程组残留后代;项目写锁会按持有 PID 回收崩溃遗留锁,并拒绝 `.agent` 符号链接逃逸。进入进程执行后的成功、非零退出、启动失败和超时会写 `.agent/logs/command.log`、manifest command run 与 `agent.runtime.project.verify` 审计;输入预检拒绝则只进入 Runtime observation / error 事件。输出先过滤敏感内容再进入 observation。只要最新 `project.verify` 未通过,或通过后又发生 `file.write / file.patch / project.restore`,Runtime 就拒绝模型用空 actions 假完成,继续要求修复和重新验证;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成新通过结果则保持失败。该能力会执行用户项目自身脚本,环境隔离不等同于 OS 沙箱,不能把不可信项目脚本视为安全代码;它不是自由 shell 代理,也不进入普通用户命令入口。 +- 2026-07-12 补充:单 Agent Runtime 工具箱新增受策略保护的 `file.delete`,补齐项目文件的完整生命周期。该工具只接受项目内相对 `path`,使用独立且默认 `confirm` 的 `file.delete` 权限,不继承 `file.write`;绝对路径、`..`、反斜杠、有效或悬空符号链接、目录和整个 `.agent/**` 控制面都失败关闭。Runtime 在项目写锁内、删除前先推进 project revision 并锁存当前 run 的 verification gate,成功后写 `agent.runtime.file.delete` 审计而不记录文件正文;目标已不存在时返回幂等 observation,但不撤销保守推进的 revision。自动与确认删除都经过 durable action ledger;pending action v3 额外绑定创建时的全局 project revision,等待确认期间任一 Agent 推进 revision 后旧动作必须进入 `needs-reconciliation`,不得删除漂移后的目标。崩溃停在 `executing` 时同样进入 `needs-reconciliation`,不得自动重放。删除后必须通过当前 revision 的 `project.verify` 或 `game.static_smoke` 才能收束;普通用户聊天和正式用户窗口不新增删除入口,也不因此开放任意 shell。 +- 2026-07-12 加固:`file.delete` 取得项目写锁后必须重新读取当前项目和 Agent 权限策略;锁竞争期间从 allow 改为 deny 时立即阻断,从 allow 改为 confirm 时自动动作退回待确认,只有已确认动作可继续。Agent 私有记忆以及客户端开发面板的文件、记忆、资产、草案、导出和 checkpoint 恢复写入都在同一项目锁内保守推进全局 revision,确保等待中的旧删除动作不会作用于客户端刚改写的内容。manifest 持久化使用同目录临时文件;平台不能覆盖既有文件时先移动到 `.manifest.json.previous` 恢复副本,主文件缺失时从副本读取,安装新文件失败时恢复旧文件。 +- 2026-07-11 补充,2026-07-12 更新:单 Agent 代码闭环新增开发专用 `project.verify`,用于在修改后执行项目根 `package.json` 已定义的固定脚本 `check / typecheck / test / lint / build`,或以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本;当前只支持 npm,不接受自由命令、参数或工作目录。Agent 必须先读取普通文件 `package.json`,再把真实存在的脚本名、完整原始脚本文本 `expectedCommand` 和 1-300 秒超时一起提交;Runtime 在真正执行前重新解析 JSON,要求脚本仍存在且正文精确一致,脚本漂移时拒绝执行。非 npm `packageManager` 或 pnpm / yarn / bun 锁文件必须失败关闭;`pre* / post*` 生命周期脚本名不在允许范围,npm 执行再附加 `--ignore-scripts`,阻止所选脚本关联的 pre/post lifecycle。该工具使用独立、默认 `confirm` 的 `project.verify` 权限,不再与 `command.run_limited` / `game.static_smoke` 共用授权;确认指纹覆盖脚本正文和超时。执行时不经过 App 自行拼接的 `bash -c`;继承环境被清理到 PATH 与必要平台变量,HOME/TMP/npm cache 隔离,stdin 关闭,输出保留有界头尾。Unix 下验证根进程正常结束或超时都会清理同进程组残留后代;项目写锁会按持有 PID 回收崩溃遗留锁,并拒绝 `.agent` 符号链接逃逸。进入进程执行后的成功、非零退出、启动失败和超时会写 `.agent/logs/command.log`、manifest command run 与 `agent.runtime.project.verify` 审计;输入预检拒绝则只进入 Runtime observation / error 事件。输出先过滤敏感内容再进入 observation。只要最新 `project.verify` 未通过,或通过后又发生 `file.write / file.patch / file.delete / project.restore`,Runtime 就拒绝模型用空 actions 假完成,继续要求修复和重新验证;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成新通过结果则保持失败。该能力会执行用户项目自身脚本,环境隔离不等同于 OS 沙箱,不能把不可信项目脚本视为安全代码;它不是自由 shell 代理,也不进入普通用户命令入口。 - 2026-07-11 补充:开发侧新增 headless 单 Agent Runtime 入口 `npm run ai-game-creator-shell:agent-task -- [--init] `。该入口不实现第二套 Agent,只复用 Tauri App 的持久任务队列、per-agent 锁、LLM 路由、权限策略、工具 action / observation loop、对话和审计文件,并轮询到 `completed / failed / waiting-for-confirmation` 后用稳定键值行退出;`--init` 只在显式传入且 manifest 不存在时初始化项目。遇到待确认动作时 CLI 返回非零并打印 actionId、tool 和脱敏摘要,后续仍由开发窗口完成确认,不提供静默 `--yes` 绕过。 - 2026-07-11 补充:后台 Agent 的工具规划和最终回复请求对 `LlmError::EmptyResponse` 最多自动重试 3 次(含首次共 4 次请求),与既有 Generator 对上游 HTTP 成功但空 content 的恢复策略一致;对 `Timeout / Connectivity / Transport` 及上游 `408 / 429 / 5xx` 最多额外自动重试 2 次并做线性退避,配置、请求、流能力、反序列化错误及其他 `4xx` 仍立即失败。重试发生在工具计划被解析和执行前,或最终回复尚未落盘时,不会重复执行已经落盘的工具动作。 - 2026-07-11 调整,2026-07-12 更新:后台单 Agent 的 planning loop 每 6 轮形成一个上下文压缩窗口,每轮工具动作上限仍为 3;6 轮不再是整个 run 的固定上限。`loopIteration` 在同一 run 内连续递增,`maxLoopIterations` 指向当前窗口结束轮次,跨重启待确认动作按 context bundle 的 `nextLoopIndex` 继续。每个窗口结束时压缩已有 observation;窗口产生新的独立观察时继续同一 run,最近 6 轮没有独立进展或相邻窗口指纹重复时才进入 `failed / budget-exhausted`,并记录 `loop-budget-exhausted`,不会伪装完成。该调整只作用于后台单 Agent Runtime,不改变游戏草案 Generator/Evaluator 的 3 轮上限;旧实施摘要中“后台 3 轮后整理最终回复”或“整个 run 最多 6 轮”的描述由本条取代。 @@ -4149,11 +4151,13 @@ - 决策:开发单 Agent 对话默认使用可执行 Runtime,输入区通过 `执行 / 聊天` 分段控件显式区分;`执行` 调用 `start_game_creator_agent_runtime_task` 并保留工具策略、确认、取消、排队和状态事件,`聊天` 才使用无工具流式回复,不再保留并列的“后台运行”按钮。消息区使用固定响应式网格行和内部滚动,并在 Runtime 非终态期间显示当前等待对象。Runtime 完成前必须先把 assistant 回复写入发起 Session,再写 completed 终态和广播;落盘失败只能进入 failed。前端收到匹配当前项目、Agent、Session 和 runId 的终态后自动重读对话,切换 Session 会清除当前等待投影,旧 run 事件不得覆盖新 Session。 - 2026-07-12 修正:Runtime 状态为空时也要保留其网格行位,消息区和输入区显式固定到第 5、6 行,禁止空 Runtime 容器通过 `display:none` 让长消息落入 `auto` 行并撑高页面;等待 LLM 期间消息区同步使用 `aria-busy` 暴露忙碌状态。消息区只在用户仍接近底部时自动跟随最新片段,用户向上查看历史后暂停跟随,切换会话、重新读取或主动发送时再恢复。 - 2026-07-12 修正:OpenAI-compatible 流式响应中 `choices` 为空数组或 `null` 的 usage / metadata 包不得再报缺少 `choices[0]`,必须跳过元数据并继续等待正文。首个 delta 前只有 `StreamUnavailable / EmptyResponse / Deserialize` 协议兼容错误允许由 Rust 单 Agent 流式入口回退一次非流式请求;上游状态、鉴权、额度、超时、连接和请求错误直接保留原错误,前端不得再次发起普通 LLM 请求。已收到正文和完成原因后继续保留完整流式回复,不能被尾部坏包覆盖。 +- 2026-07-12 修正:Tauri 聊天事件监听被拒绝后,前端选择的普通回复入口必须固定调用 `client.run`,即使 Agent 路由保留 `stream=true` 也不得再内部发 SSE。pending action 的 project revision 快照改为绑定 planning 请求发出前的版本;`file.delete` 取得项目写锁后必须再次校验 revision / verification gate,公共 pending 摘要必须与私有 ledger 完整相等,confirm / reject 只在迁移状态可靠落盘后启动 continuation。manifest 和 pending ledger 禁止 truncate/remove 旧文件后再替换,统一使用同目录临时文件的原子替换及可恢复 backup。 +- 2026-07-12 修正:pending ledger 的 `inputSummary` 不是可独立修改的显示文本,每次读写都必须从完整 tool action 重新计算并全等校验。`file.delete` 已删除文件但 Agent DB 审计失败时,observation 必须标记 `needs-reconciliation`,Runtime state / event 先落为 `failed / needs-reconciliation` 再尝试追加核对审计,禁止当作普通删除失败继续规划或重放。 - 决策:后台 Agent 首轮不得预加载任何需要工具权限控制的项目内容。planning 与 final reply 只拿身份、session/run 元数据、任务、工具策略和已获准 observation;记忆、黑板、对话、资产和文件内容必须通过对应工具进入。最新黑板、记忆和对话采用尾部保留截断。 - 决策:同一 Agent 的前台聊天与后台队列共享 per-Agent OS 执行锁,前台 LLM 等待期间不持有项目写锁;同 Agent 后台投递保持 pending,前台结束后把当前锁直接移交给 drain,drain 异常不得反写已经完成的聊天结果,不同 Agent 继续并行。 - 决策:重启恢复继续遵守 `agent.resume` 默认确认策略。自动 command 只允许 auto;默认 confirm 由主工作区或独立开发 Agent 聊天窗口的 UI 明确确认后调用独立 command,确认绑定发起项目,切换项目取消旧确认且旧项目异步结果不得污染新项目状态;独立 command 只忽略 confirm、不允许绕过 deny,临时失败必须允许重试。 -- 决策:后台 Agent loop 只有空 actions 且不存在验证 blocker 才算收束。项目级 revision 的唯一事实源固定为 `.agent/runtime/project-revision.json`,每个 run 的验证门禁固定为 `.agent/runtime/verification//.json`。Runtime 在项目写锁内、执行 `file.write / file.patch / project.restore` 之前先保守推进 revision,并把当前 run 的 `requiresVerification` 单向置为 `true`;即使修改随后失败或进程中断也不得回退 revision 或门禁,只允许因此多做一次验证,不能留下漏验证窗口。`requiresVerification` 一旦为 `true`,在该 run 生命周期内永久保留;成功验证只记录其绑定的 revision,不把门禁改回 `false`。`project.verify` 或 `command.run_limited / game.static_smoke` 只有成功且绑定当前 revision 才能作为完成凭证,后续任一修改会让旧凭证失效;未修改项目的只读 run 可保持 `requiresVerification=false`。observation、压缩上下文和 UI 摘要只用于规划与展示,不再作为 revision 或验证门禁的权威真相。 -- 决策:per-run context bundle 的 schema 固定升级为 `game-creator-runtime-context-bundle.v2`,durable pending action 的 schema 固定升级为 `game-creator-pending-action.v2`,两者都携带并校验 revision / gate 关联;v1 文件恢复必须失败关闭,不得把缺失字段解释为 `requiresVerification=false`,不得自动重放动作或写 completed。准备写最终 assistant 回复或 completed 终态时,Runtime 必须先取得项目写锁,再重读 `.agent/runtime/project-revision.json` 与当前 run 的 verification gate;只有 `requiresVerification=false`,或成功验证绑定的 revision 与锁内重读到的当前 revision 完全一致,才允许在同一把锁内依次写入发起 Session 的 assistant 消息和 completed 终态。缺失、损坏、版本不支持、revision 漂移或验证未通过一律失败关闭,并追加 `runtime.verification` blocker 后继续同一 run 或进入明确失败,不能用锁外旧快照收束。 +- 决策:后台 Agent loop 只有空 actions 且不存在验证 blocker 才算收束。项目级 revision 的唯一事实源固定为 `.agent/runtime/project-revision.json`,每个 run 的验证门禁固定为 `.agent/runtime/verification//.json`。Runtime 在项目写锁内、执行 `file.write / file.patch / file.delete / project.restore` 之前先保守推进 revision,并把当前 run 的 `requiresVerification` 单向置为 `true`;即使修改随后失败或进程中断也不得回退 revision 或门禁,只允许因此多做一次验证,不能留下漏验证窗口。`requiresVerification` 一旦为 `true`,在该 run 生命周期内永久保留;成功验证只记录其绑定的 revision,不把门禁改回 `false`。`project.verify` 或 `command.run_limited / game.static_smoke` 只有成功且绑定当前 revision 才能作为完成凭证,后续任一修改会让旧凭证失效;未修改项目的只读 run 可保持 `requiresVerification=false`。observation、压缩上下文和 UI 摘要只用于规划与展示,不再作为 revision 或验证门禁的权威真相。 +- 决策:per-run context bundle 的 schema 固定为 `game-creator-runtime-context-bundle.v2`;durable pending action 的 schema 升级为 `game-creator-pending-action.v3`,除 per-run verification gate 外还绑定动作创建时的全局 project revision。旧版 pending action 和 v1 context bundle 恢复必须失败关闭,不得把缺失字段解释为可执行,不得自动重放动作或写 completed。批准或自动执行 pending action 前,当前全局 revision 与 gate 必须同时等于创建快照,任一漂移都进入 `needs-reconciliation`。准备写最终 assistant 回复或 completed 终态时,Runtime 必须先取得项目写锁,再重读 `.agent/runtime/project-revision.json` 与当前 run 的 verification gate;只有 `requiresVerification=false`,或成功验证绑定的 revision 与锁内重读到的当前 revision 完全一致,才允许在同一把锁内依次写入发起 Session 的 assistant 消息和 completed 终态。缺失、损坏、版本不支持、revision 漂移或验证未通过一律失败关闭,并追加 `runtime.verification` blocker 后继续同一 run 或进入明确失败,不能用锁外旧快照收束。 - 2026-07-12 修正:后台 finalization 的锁内复核结果区分 `Completed`、可恢复 `Stale` 和真正错误。每次可形成最终回复的 planning 或 final reply LLM 请求开始前都记录项目 `responseRevision`;锁内当前 revision 与它不一致即为 `Stale`,包括 `requiresVerification=false` 的只读 run。`Stale` 必须丢弃旧回复、把 response plan step 恢复为 pending、注入完整 `runtime.verification` blocker,并保持原 Agent、Task、Session、Run、loop 计数和 per-Agent 锁继续 planning;不得创建 retry run,不得写 assistant、completed 或 `background_task.failed`。revision / gate 无法读取或 stale continuation 无法持久化时才进入 failed;一旦 finalization journal 已进入 `prepared`,后续对话或终态落盘失败必须保持可恢复 `finalizing`,不得把当前 run 误记为 failed。 - 2026-07-12 修正:完成预检、finalization、恢复和取消收束遇到同项目另一个 Agent 的短暂项目写锁时,最多等待约 1 秒并重试;锁持续占用才返回 blocker。毫秒级并发收束不能被误判为验证缺失、不能因此回到 planning 或重新请求 LLM。 - 决策:最终回复固定使用 `.agent/runtime/finalizations//.json` 的 `game-creator-runtime-finalization.v1` journal 跨越多文件落盘,状态严格按 `prepared -> assistant-persisted -> runtime-completed` 推进。journal 单独使用 512 KiB 上限,必须容纳 32,000 字符的最大合法回复及元数据;跨平台替换在不能原子覆盖旧文件时,先把旧 journal 原子移动为同目录 `.previous` 恢复副本,再安装新文件,主文件缺失时读取恢复副本,成功推进或终态清理时同时删除副本,不得先删除唯一旧 journal。`resume` 必须在 pending action、普通 running/pending task 和 delegate receipt 修复之前优先恢复 finalization,只按 journal 补齐 assistant 与终态,不重新请求 LLM、不重放工具或 receipt 任务。Runtime state 只是可重建投影;状态文件缺失或损坏但 task ledger 仍能唯一定位主 journal 或恢复副本时,从 task ledger 重建同一 run 后继续恢复。journal 处于 `prepared` 且 assistant 尚未落盘时,若任务已取消,或 revision / verification gate 漂移已使回复过期,必须丢弃 journal 并分别保持取消终态或回到同 run planning。assistant JSONL 是用户可见提交点;取消 command 必须在 per-Agent 锁内检查 journal,assistant 尚未存在时立即删除 prepared journal 再取消,assistant 已存在时完成原 finalization 并忽略迟到取消,不能留下孤儿 journal 或把可见回复改判为 cancelled。journal 损坏、版本不支持、身份/回复指纹/幂等 ID 冲突一律失败关闭,并将同一 live run 投影为 `status=running / phase=finalizing` 供 UI 明确显示,不得降级走普通任务恢复。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 5c19f8f23..4469c46ad 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -42,7 +42,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台 Agent 任务的 `runId` 在同一 Agent 内必须唯一,因为任务快照按 runId 去重表示同一 run 的最新状态。`start_game_creator_agent_runtime_task`、`agent.delegate` 和 retry 入队前会读取该 Agent 全量 task JSONL 历史;如果调用方传入的 runId 已存在,Runtime 自动追加 `-dup--` 后缀生成实际 runId,并在任务队列、delegate observation 和 `agent.db` 审计中使用该实际值,避免两个独立任务互相折叠。 - 2026-07-10 补充:后台 Agent 任务的 `memory.write scope=agent` 只允许写当前 Agent 自己的私有记忆。若 action 指定其他 `agentId / targetAgentId`,Runtime 返回 `blocked` observation,不写目标 Agent 私有记忆,也不写 `agent.runtime.memory.write` 审计;跨 Agent 共享稳定结论必须使用 `blackboard.write`,给单个 Agent 留上下文必须使用 `agent.message`。 - 2026-07-10 补充:本地 append-only JSONL 追加写入按目标文件路径做进程内串行化。`.agent/agent.db`、项目 / Agent 对话、Runtime events、Runtime tasks、Agent activity 和 output 都通过共享 helper 写入完整 JSON 行,防止多个后台 Agent 并行运行时 record 内容与换行交错;该约束服务于当前单客户端进程内并行,不把跨进程同项目写入作为 v1 支持目标。 -- 2026-07-10 补充,2026-07-12 冻结:后台 Agent 待确认工具动作改用 durable `AgentRuntimePendingToolAction`。完整记录包含精确工具 action、当前 task/run、loop 轮次、action 序号、计划、已有 observations、续跑上下文和 revision / verification gate 关联;schema 固定升级为 `game-creator-pending-action.v2`。写入前拒绝密钥、Token、Cookie、App 配置痕迹和项目绝对路径,再通过临时文件替换原子写入 `.agent/runtime/pending-actions//.json`。公共 runtime state 的 `pendingToolAction` 只暴露 `actionId / actionFingerprint / tool / inputSummary / reason / requestedAt` 安全摘要。`actionFingerprint` 绑定工具名、完整输入 JSON 与实际执行 task context;`actionId` 再绑定 run、loop、action 序号和 occurrence nonce,防止同一 run 内相同输入的旧 UI 点击批准后一次动作。开发窗口和项目内 Agent 面板的“确认继续 / 拒绝并继续”都提交当前 `runId + actionId`,Runtime 与私有动作、公共摘要交叉校验后才迁移账本状态。确认后在同一 run 直接执行原 action 并把 observation 接回后续 loop,不创建新 run、不要求模型重复动作;拒绝不执行工具,写 `blocked` observation 后在同一 run 继续规划。账本状态使用 `pending-confirmation / approved / executing / observed-approved / observed-rejected`:重启可恢复 waiting、未执行的 approved action 或已落盘 observation;若进程中断在 `executing`,Runtime 进入 `failed / needs-reconciliation`,禁止自动重放外部副作用,开发者核对项目状态后先取消原任务。v1 pending action 或缺失 revision / gate 关联的记录恢复时必须失败关闭,不得按默认值补齐、自动重放外部副作用或写 completed。等待期间同 Agent 新任务保持 `pending`,重启不会越过 waiting run,确认、拒绝或取消后再串行排空。`.agent/runtime/` 作为私有控制面,不允许通用文件工具列出、读取、写入或删除;每 Agent 锁使用唯一 token,旧持有者不会删除替换后的新锁,Linux 上仍存活的其他进程锁不会按超时强占。 +- 2026-07-10 补充,2026-07-12 冻结并更新:后台 Agent 待确认工具动作改用 durable `AgentRuntimePendingToolAction`。完整记录包含精确工具 action、当前 task/run、loop 轮次、action 序号、计划、已有 observations、续跑上下文、创建时的全局 project revision 和 per-run verification gate;schema 升级为 `game-creator-pending-action.v3`。写入前拒绝密钥、Token、Cookie、App 配置痕迹和项目绝对路径,再通过临时文件替换原子写入 `.agent/runtime/pending-actions//.json`。公共 runtime state 的 `pendingToolAction` 只暴露 `actionId / actionFingerprint / tool / inputSummary / reason / requestedAt` 安全摘要。`actionFingerprint` 绑定工具名、完整输入 JSON 与实际执行 task context;`actionId` 再绑定 run、loop、action 序号和 occurrence nonce,防止同一 run 内相同输入的旧 UI 点击批准后一次动作。开发窗口和项目内 Agent 面板的“确认继续 / 拒绝并继续”都提交当前 `runId + actionId`,Runtime 与私有动作、公共摘要交叉校验后才迁移账本状态。批准或自动执行前必须重读并匹配创建时的全局 revision 与 gate;任一漂移都进入 `needs-reconciliation`,不得执行旧动作。确认后在同一 run 直接执行原 action 并把 observation 接回后续 loop,不创建新 run、不要求模型重复动作;拒绝不执行工具,写 `blocked` observation 后在同一 run 继续规划。账本状态使用 `pending-confirmation / approved / executing / observed-approved / observed-rejected`:重启可恢复 waiting、未执行的 approved action 或已落盘 observation;若进程中断在 `executing`,Runtime 进入 `failed / needs-reconciliation`,禁止自动重放外部副作用,开发者核对项目状态后先取消原任务。旧版 pending action 或缺失 revision / gate 关联的记录恢复时必须失败关闭,不得按默认值补齐、自动重放外部副作用或写 completed。等待期间同 Agent 新任务保持 `pending`,重启不会越过 waiting run,确认、拒绝或取消后再串行排空。`.agent/runtime/` 作为私有控制面,不允许通用文件工具列出、读取或写入;`file.delete` 进一步禁止整个 `.agent/**`。每 Agent 锁使用唯一 token,旧持有者不会删除替换后的新锁,Linux 上仍存活的其他进程锁不会按超时强占。 - 2026-07-10 补充:per-agent 锁最终采用 OS 级文件锁,取代上一条末尾的 token/PID/超时抢占方案。Unix 使用非阻塞独占 `flock`,Windows 使用禁止共享的文件句柄;`.agent/runtime/locks/.lock` 只保存诊断元数据并可长期存在,真正所有权随文件句柄和进程生命周期释放。任何确认、拒绝、恢复、取消和队列 drain 都必须使用同一系统锁;确认、拒绝和取消只能在拿锁后重新读取当前 runtime、task 与待确认动作再迁移状态,恢复也必须先拿锁再读取 durable pending action 或 recoverable task,不能用拿锁前的旧快照覆盖并发结果。waiting 状态只允许短暂等待原 worker 正常释放,不得按状态删除并重建锁文件;running 取消在拿不到锁时只保留取消 tombstone,由原 worker 在 LLM / 工具成功或失败返回后的检查点收束。 - 2026-07-10 补充:白名单自动工具也必须使用 durable `AgentRuntimePendingToolAction`,并以 `executionMode = auto` 区别待开发者确认的动作。Runtime 在副作用前依次落盘 `approved`、`executing`,返回后落盘 `observed-approved` 和 observation;该账本继续覆盖下一轮 LLM planning,直到下一条精确动作接管或 completed / failed / cancelled 终态可靠落盘,不能在 observation 刚落盘时提前删除。恢复 `approved + auto` 时执行同一 action 一次,恢复 `observed-approved + auto` 时只把 observation 交回 Agent,恢复 `executing + auto` 时停止在 `failed / needs-reconciliation`;该核对阶段是硬屏障,即使磁盘仍是 `approved` 也禁止继续。恢复时策略由 auto 收紧为 confirm,则保留原 actionId / fingerprint 并转换成 `pending-confirmation + confirmation`。自动动作在 `.agent/agent.db` 写 `agent.runtime.tool_action.executing`、`agent.runtime.tool_action.observed` 和 `agent.runtime.tool_action.needs_reconciliation` 审计。 - 2026-07-10 补充:`needs-reconciliation` 按 Agent 队列级屏障处理。该 Agent 的新聊天后台任务、delegate 和 ready-task 调度仍可入队,但只能保持 `pending`;恢复、正常 drain 和取消其他排队 run 后触发的 drain 都不得越过当前核对 run。新 run 只能在取得 per-agent OS 锁后重新读取 waiting / cancelling / reconciliation 状态并通过准入检查,不能在锁外检查后直接启动;锁内通过检查后统一从 task JSONL 选择最早 pending run,保证并发投递时仍按 FIFO 启动。屏障判定同时读取当前 Runtime state 与 task JSONL 最新记录,因此 pending ledger 缺失时也不放行、不允许 retry;开发者核对外部副作用后必须显式取消该 run,后续队列才继续。 @@ -54,12 +54,14 @@ Agent Runtime 负责: - 2026-07-10 补充:`recentEvents` 接入前端归一态和 Runtime 状态面板,事件事实源仍是 `.agent/runtime/events/.jsonl`;面板按时间展示最近 `thinking_summary / plan / action / observation / response / error` 事件,现在能同时看到 Agent 的计划、最近观察、最近事件、最近工具动作和任务队列。 - 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 的空数组或 `null` `choices` 心跳 / 元数据事件会跳过,usage-only 尾包会回填最终 token usage,finish-only 事件会把结束原因送入状态流,上游 error 保留真实消息,`[DONE]` 立即结束读取;正文与 finish reason 已接收后即使尾包异常也保存完整正文,不再改判整轮失败。持久事件订阅失败时显示非致命 Runtime 错误,聊天事件监听不可用或流式请求在首个文本片段前失败时自动降级普通回复。 +- 2026-07-11 补充:开发单 Agent 聊天页继续使用整页纵向滚动,不把 Runtime 锁进固定视口;聊天消息区使用固定响应式高度并在内部滚动,避免历史消息持续撑高聊天面板。可选的 Runtime 恢复确认区始终占据独立布局行,不能与 Runtime 详情或聊天消息重叠。Runtime 状态面板支持折叠详情,折叠时只卸载目标、计划、事件、动作和任务等详情 DOM,仍保留状态标题与取消、重试、确认、拒绝、刷新操作;等待 LLM 时在消息区持续显示连接 / 等待首包 / 接收中的动态状态和“请求仍在进行中”提示。流式聊天的连续 delta 通过 `requestAnimationFrame` 合并为每帧最多一次消息更新,delta 不重复提交未变化的 Runtime state;OpenAI Chat SSE 的空数组或 `null` `choices` 心跳 / 元数据事件会跳过,usage-only 尾包会回填最终 token usage,finish-only 事件会把结束原因送入状态流,上游 error 保留真实消息,`[DONE]` 立即结束读取;正文与 finish reason 已接收后即使尾包异常也保存完整正文,不再改判整轮失败。持久事件订阅失败时显示非致命 Runtime 错误;聊天事件监听不可用时必须调用真正的非流式入口,不得因 Agent 配置仍为 `stream=true` 在 Rust 内部再发 SSE 请求;流式请求在首个文本片段前遇到允许的协议兼容错误时只降级一次普通回复。 - 2026-07-12 调整:开发单 Agent 对话框新增 `执行 / 聊天` 分段模式,默认 `执行`。默认发送直接调用 `start_game_creator_agent_runtime_task`,复用工具规划、权限确认、取消、队列和 Runtime 实时状态;`聊天` 作为显式模式继续走不执行工具的流式回复。消息区在 Runtime 启动、排队、等待 LLM、执行工具、等待确认和同步终态回复期间持续显示当前状态,不再要求开发者从页头文案猜测请求是否仍在运行;原独立“后台运行”按钮移除。Runtime 必须先取得项目写锁并重读项目 revision 与当前 run 的 verification gate;只有 run 从未要求验证,或成功验证绑定的 revision 与锁内当前 revision 完全一致,才允许在同一把锁内先把最终 assistant 回复可靠写入当前 Agent Session,再写 completed 终态并广播事件。完成预检、finalization、恢复和取消收束遇到同项目另一个 Agent 的短暂写锁时,最多等待约 1 秒后重试;锁持续占用才返回 blocker,不能把毫秒级竞争误判为验证缺失并重新请求 LLM。每次可形成最终回复的 planning 请求或独立 final reply 请求开始前都记录 `responseRevision`;回复完成后锁内当前 revision 与它不同即返回可恢复 `Stale`,该规则同样覆盖 `requiresVerification=false` 的只读 run。旧回复不得进入会话或 completed,Runtime 记录 completion blocker、`response.stale` 事件与审计,并保持原 Agent、Task、Session、Run、loop 计数和 per-Agent 锁回到 planning,重新读取或验证当前项目状态后再生成回复。revision / gate 读取失败或 stale continuation 持久化失败仍进入 failed;一旦 finalization journal 已进入 `prepared`,后续对话或终态落盘失败改为保持可恢复 `finalizing`,不得把当前 run 误记为 failed。前端只对当前项目、Agent、Session 和 runId 匹配的终态事件自动重读对话,直到看到新 assistant 消息或重试结束,切换 Session 后旧 run 不得污染当前聊天记录。 - 2026-07-12 补充并冻结:后台最终回复通过 `.agent/runtime/finalizations//.json` 的 `game-creator-runtime-finalization.v1` journal 跨越多文件落盘,状态严格按 `prepared -> assistant-persisted -> runtime-completed` 推进,终态可靠投影后删除 journal。journal 绑定项目、Agent、Task、Session、Run、source、父委派身份、任务正文、回复指纹、`responseRevision`、verification gate、`finalizationId` 和稳定 `messageId`;finalization 单独使用 512 KiB 上限,必须容纳 32,000 字符的最大合法回复及元数据。跨平台替换先写临时文件;目标平台不能原子覆盖旧文件时,先把旧 journal 原子移动为同目录 `.previous` 恢复副本,再安装新文件,读取时主文件缺失必须回退恢复副本,成功推进或终态清理时同时删除副本,禁止先删除唯一旧 journal。`resume` 在 pending action、普通 running/pending task 和 delegate receipt 修复之前优先恢复 finalization,只按 journal 补齐 assistant 与终态,不重新请求 LLM、不重放工具或 receipt 任务;Runtime state 只是可重建投影,状态文件缺失或损坏但 task ledger 仍能唯一定位主 journal 或恢复副本时,必须从 task ledger 重建同一 run 后继续恢复。`prepared` 且 assistant 尚未落盘时若任务已取消,或当前 revision / verification gate 已使回复过期,则丢弃 journal,分别保持取消终态或回到同 run planning;assistant JSONL 是用户可见提交点,取消 command 必须在持有 per-Agent 锁后检查 journal,assistant 尚未存在时立即删除 prepared journal 再取消,assistant 已存在时则完成原 finalization 并忽略迟到取消,不能留下孤儿 journal 或把可见回复改判为 cancelled。journal 损坏、版本不支持、身份/回复指纹/幂等 ID 冲突必须 fail closed,并把同一 live run 投影为 `status=running / phase=finalizing` 供 UI 明确显示,不能降级为普通任务恢复。 - 2026-07-12 补充并冻结:本地 conversation JSONL 的 `messageId` 是可选向后兼容字段,旧记录无需迁移仍可读取。finalization 使用稳定 `messageId` 幂等追加 assistant;同一 Agent / Session 下相同 ID 且 role/content 一致时不得重复写 JSONL,若消息已存在但 `.agent/agent.db` 缺少对应 `conversation.message` audit,重试必须在 audit 追加锁内补写一次,已有 audit 不重复;同一 Agent / Session 作用域下相同 ID 对应不同 role 或 content 时继续按冲突失败关闭。completed task JSONL、Runtime state、`turn.completed / response` 事件、`agent.runtime.completed / background_task.completed` audit、pending/confirmation 清理和 delegate result 发布均按既有身份幂等补齐,重启不得制造第二份终态投影。 - 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 补充,2026-07-12 更新:代码修改后的真实验证由开发专用 `project.verify` 承接。输入固定为 `script / expectedCommand / timeoutSeconds`;`script` 允许项目根 `package.json` 中的固定脚本 `check / typecheck / test / lint / build`,以及以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本。脚本必须真实存在于项目根普通文件 `package.json` 的 `scripts` 中,`expectedCommand` 必须与执行时重新读取的脚本正文完全一致,`timeoutSeconds` 为 1-300;当前执行器只支持 npm,非 npm `packageManager` 或 pnpm / yarn / bun 锁文件明确失败,不接受自由命令、参数或工作目录。`pre* / post*` 生命周期脚本名不在允许范围,执行器再通过 npm `--ignore-scripts` 禁止所选脚本关联的 pre/post lifecycle。工具映射到独立且默认需确认的 `project.verify` 权限,确认动作指纹绑定完整输入,不再因为放行验证而同时放行 `command.run_limited` 静态 smoke。执行器由 npm 运行已确认脚本,使用空 stdin、隔离 HOME/TMP/cache、清理后的环境、独立进程组和有界脱敏输出;Unix 下无论根进程正常结束还是超时都会清理同组残留后代。项目写锁记录 PID 和唯一 nonce,活进程继续持锁,Unix 死进程锁或跨平台超过安全时限的无效锁可回收,且控制路径拒绝符号链接。进入进程执行后的终态写命令日志和 manifest command run,Agent 触发时另写 `agent.runtime.project.verify`;输入预检拒绝只写 Runtime observation / error 事件。失败输出作为 observation 回到下一轮 planning。项目级 revision 独立持久化到 `.agent/runtime/project-revision.json`;每个 run 的 gate 与验证结果持久化到 `.agent/runtime/verification//.json`。`file.write / file.patch / project.restore` 在项目写锁内、实际修改前先保守推进 revision,并把 `requiresVerification` 单向置为 `true`,操作失败或崩溃也不回退;成功的 `project.verify` 或 `command.run_limited / game.static_smoke` 只为执行时的当前 revision 写入凭证。空 actions 前如果门禁仍要求验证、验证失败或凭证 revision 已过期,Runtime 注入 `runtime.verification: blocked` 并继续 replan;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成当前 revision 的通过结果则保持失败。该能力会执行用户项目脚本,环境隔离不是 OS 沙箱;普通用户 `/smoke` 与 `game.static_smoke` 保持原边界,不暴露该开发工具。 +- 2026-07-12 补充:后台单 Agent 文件生命周期加入 `file.delete`。输入只接受项目内相对 `path`,工具使用独立且默认需确认的 `file.delete` 权限,不继承 `file.write`;Runtime 复用通用文件工具的绝对路径、父目录、反斜杠、有效或悬空符号链接和目录防护,并额外禁止删除整个 `.agent/**` 控制面。pending action v3 绑定本轮 planning 请求发出前的全局 project revision;`file.delete` 取得项目写锁后会在实际副作用前再次校验该 revision 和 verification gate,再保守推进新 revision 与 gate 并删除文件。项目/session memory、blackboard 和 canvas asset 的 Runtime 写入同样推进全局 revision,防止它们改写待删除目标却不触发漂移。删除成功后写入不含文件正文的 `agent.runtime.file.delete` 审计;目标已不存在时返回幂等 observation,但仍保留已经推进的验证门禁。等待期间发生进程内 Agent 修改时,旧动作进入 `needs-reconciliation`,不能删除漂移后的路径;进程中断在 `executing` 时同样不得自动重放删除。v1 威胁模型只承诺客户端自身遵守项目锁的并发写入;外部进程在路径校验后把父目录替换为符号链接的 TOCTOU 攻击不在本轮承诺内,如需防御必须升级为目录句柄与 no-follow `unlinkat` 级别的平台实现。该工具只进入开发单 Agent Runtime,普通用户聊天和正式用户窗口不增加文件删除入口。 +- 2026-07-12 加固:删除动作在取得项目写锁后重新判定当前权限策略,锁竞争期间新增 deny 必须阻断,新增 confirm 必须让未获人工确认的自动动作退回待确认。Agent 私有记忆和客户端开发面板可修改项目内容的命令同样在项目锁内保守推进全局 revision;这样文件、记忆、资产、草案、导出或 checkpoint 恢复改写待删除目标后,旧 pending action 会在实际删除前因 revision 漂移失败关闭。`.agent/manifest.json` 的临时文件替换兼容 Windows:不能直接覆盖时先保留 `.manifest.json.previous`,主文件缺失可从恢复副本读取,安装新文件失败则恢复旧 manifest。 +- 2026-07-11 补充,2026-07-12 更新:代码修改后的真实验证由开发专用 `project.verify` 承接。输入固定为 `script / expectedCommand / timeoutSeconds`;`script` 允许项目根 `package.json` 中的固定脚本 `check / typecheck / test / lint / build`,以及以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本。脚本必须真实存在于项目根普通文件 `package.json` 的 `scripts` 中,`expectedCommand` 必须与执行时重新读取的脚本正文完全一致,`timeoutSeconds` 为 1-300;当前执行器只支持 npm,非 npm `packageManager` 或 pnpm / yarn / bun 锁文件明确失败,不接受自由命令、参数或工作目录。`pre* / post*` 生命周期脚本名不在允许范围,执行器再通过 npm `--ignore-scripts` 禁止所选脚本关联的 pre/post lifecycle。工具映射到独立且默认需确认的 `project.verify` 权限,确认动作指纹绑定完整输入,不再因为放行验证而同时放行 `command.run_limited` 静态 smoke。执行器由 npm 运行已确认脚本,使用空 stdin、隔离 HOME/TMP/cache、清理后的环境、独立进程组和有界脱敏输出;Unix 下无论根进程正常结束还是超时都会清理同组残留后代。项目写锁记录 PID 和唯一 nonce,活进程继续持锁,Unix 死进程锁或跨平台超过安全时限的无效锁可回收,且控制路径拒绝符号链接。进入进程执行后的终态写命令日志和 manifest command run,Agent 触发时另写 `agent.runtime.project.verify`;输入预检拒绝只写 Runtime observation / error 事件。失败输出作为 observation 回到下一轮 planning。项目级 revision 独立持久化到 `.agent/runtime/project-revision.json`;每个 run 的 gate 与验证结果持久化到 `.agent/runtime/verification//.json`。`file.write / file.patch / file.delete / project.restore` 在项目写锁内、实际修改前先保守推进 revision,并把 `requiresVerification` 单向置为 `true`,操作失败或崩溃也不回退;成功的 `project.verify` 或 `command.run_limited / game.static_smoke` 只为执行时的当前 revision 写入凭证。空 actions 前如果门禁仍要求验证、验证失败或凭证 revision 已过期,Runtime 注入 `runtime.verification: blocked` 并继续 replan;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成当前 revision 的通过结果则保持失败。该能力会执行用户项目脚本,环境隔离不是 OS 沙箱;普通用户 `/smoke` 与 `game.static_smoke` 保持原边界,不暴露该开发工具。 - 2026-07-11 补充:开发验证可用 `npm run ai-game-creator-shell:agent-task -- [--init] ` 无 UI 启动单 Agent 后台任务。CLI 只负责可选初始化、调用现有 Runtime、按 runId 轮询终态并打印 `status / phase / replyText / pendingActionId`,不复制 planning 或工具执行逻辑;默认 10 分钟轮询上限。`waiting-for-confirmation` 会以非零状态退出并要求转到开发窗口确认,CLI 不提供跳过项目权限的自动确认参数。该入口用于真实 provider 的可重复端到端验收,不进入普通用户界面。 - 2026-07-11 补充:后台工具规划与最终回复的 LLM 请求新增可恢复错误重试:`LlmError::EmptyResponse` 原样自动重试最多 3 次;`Timeout / Connectivity / Transport` 及上游 `408 / 429 / 5xx` 最多额外重试 2 次并按 `500ms / 1000ms` 退避。配置、请求、流能力、反序列化错误及其他 `4xx` 不重试。重试发生在工具计划被解析和执行前,或最终回复尚未落盘时,因此不会重复执行已经落盘的工具副作用;重试耗尽后仍写入原有 `error / turn.failed` 事件并把失败消息追加到当前 Agent 会话。 - 2026-07-11 调整,2026-07-12 更新:后台单 Agent planning loop 每 6 轮形成一个上下文压缩窗口,每轮最多 3 个工具动作;6 轮是窗口大小,不是单个 run 的固定上限。`loopIteration` 在同一 run 内连续递增,`maxLoopIterations` 指向当前窗口的结束轮次;待确认或重启恢复后按 context bundle 的 `nextLoopIndex` 在同一 run 继续。每个窗口结束时压缩已有 observation;窗口产生新的独立观察时继续下一窗口,最近 6 轮没有独立进展或相邻窗口指纹重复时才写入 `failed / budget-exhausted` 和 `loop-budget-exhausted`,不生成总结伪装完成。这只调整后台单 Agent Runtime;游戏草案 Generator/Evaluator 仍保持独立的 3 轮修复预算。旧摘要中“后台最多 3 轮”或“整个 run 最多 6 轮”的描述不再有效。 @@ -244,6 +246,7 @@ game-project/ ## v1 验收证据矩阵 - `npm run ai-game-creator-shell:check`:覆盖壳 typecheck、聊天命令单测、用户 / 开发窗口 UI 边界 smoke、主窗口命令按钮复用 `/help` 命令列表、主窗口项目摘要从 manifest / trace 派生任务完成数、ready 数、资产来源分布和最近命令且未选项目时不显示、灵感草稿只填充输入框不提交、能力按钮复用 `/capabilities`、LLM状态按钮复用 `/llm-status` 且结果回填 Agent 状态列表、聊天侧 `/agents` 汇总和单 Agent 对话的 provider / 模型 / 流式 / API Key 读取状态、开发日志面板只读读取 `.agent/logs/command.log` / `preview.log` / `agent.log`、项目状态按钮复用 `/status`、权限按钮复用 `/policy` 且策略草稿按钮只填入 `/policy-confirm project.index` / `/policy-confirm asset.register` / `/policy-confirm memory.write` / `/policy-confirm preview.start` / `/policy-confirm preview.open` / `/policy-confirm preview.stop` / `/policy-confirm agent.run_status` / `/policy-confirm conversation.read` / `/policy-confirm conversation.write`、审计按钮复用 `/audit`、资产按钮复用 `/assets` 且资产结果可一键复用 `/read`、任务按钮复用 `/tasks`、聊天侧 `/agents` 汇总每个 Agent 的当前状态、聊天侧 `/agent-conversations` 列出 Agent 对话读取命令、聊天侧 `/agent-memories` 列出 Agent 私有记忆读取命令、Trace 按钮复用 `/trace`、文件按钮复用 `/files` 且文件结果可一键复用 `/read`、索引按钮复用 `/index`、记忆 / 短期记忆 / 黑板按钮复用 `/memory long|short|blackboard`、快照按钮复用 `/checkpoint`、快照列表按钮复用 `/checkpoints` 且 checkpoint 结果可一键复用 `/diff` / `/restore`、历史按钮复用 `/history`、受限命令白名单按钮复用 `/commands` 且无需项目初始化、静态自检快捷按钮复用 `/smoke`、预览状态快捷按钮复用 `/preview-status`、主窗口运行时配置面板读写 Tauri 配置目录中的 `game-creator.config.json`、支持全局与每个 agent 单独选择 LLM Provider 且不把 API Key 写入聊天、单 Agent 对话面板可手动追加私有记忆且走 `memory.write` 策略、主窗口提供音效登记、画板音频导入和常用生成产物读取草稿入口,聊天侧 `/art` 可盘点美术素材且不直接触发平台生成或画板同步,聊天侧 `/context` 可盘点生成上下文来源且不直接读取上下文文件,聊天侧 `/timeline` 可汇总项目活动时间线且不直接读取日志或 trace 文件,聊天侧 `/artifacts` 可列出常用生成产物读取命令,聊天侧 `/run-artifacts` 可列出最近 run 产物读取命令,聊天侧 `/run-files` 可列出 Agent 运行辅助文件读取命令,聊天侧 `/logs` 可列出固定日志读取命令且不直接读取日志,聊天侧 `/brief` 只基于当前已加载的 manifest / 最近 run trace / 预览状态 / 资产数量 / 最近命令生成项目简报,聊天侧 `/goal` 只基于当前 manifest.goal / 最近 run goal / taskGraph.goal 汇总创作目标来源,提供 `/next` 或 `/agent-resume 细化目标:` 后续草稿且不直接触发 Tauri 读写、文件读取、预览启动或新增面板,聊天侧 `/mvp` 只基于当前 manifest / 最近 run trace / preview / 任务 / 资产 / 最近命令汇总本轮最小可玩范围,提供 `/run` 等后续草稿且不直接触发 Tauri 读写、文件读取、预览启动、导出或新增面板,聊天侧 `/audience` 只基于当前 manifest / 最近 run trace / preview 准备首批试玩对象和观察重点且不直接触发 Tauri 读写、预览启动、导出或继续 run,聊天侧 `/feedback` 只基于当前 manifest / 最近 run trace / preview 准备试玩反馈模板和修改说明草稿且不直接触发 Tauri 读写、预览启动或继续 run,聊天侧 `/next` 基于当前已加载的 manifest / 最近 run trace 输出下一步建议和 `/goal` / `/mvp` / `/accessibility` / `/performance` / `/tasks` / `/criteria` / `/groups` / `/budget` / `/qa` / `/changes` / `/trace` / `/run` / `/open-preview` / `/test-plan` / `/audience` / `/feedback` / `/assets` / `/art` / `/context` / `/timeline` / `/artifacts` / `/run-artifacts` / `/run-files` / `/logs` / `/agent-resume ` 等安全命令草稿方向,提供一个首选草稿且不直接触发 Tauri 读写、预览启动或文件读取,聊天侧 `/capabilities` 展示标准 Agent 能力清单且不打开开发面板、聊天侧 `/audit` 从 manifest / 本地文件 / `.agent/run.latest.json` 分别汇总用户面、6 组任务配置、6 组协作证据、任务编排、loop、记忆、本地产物、HTTP 预览、画板回流和权限日志证据且不打开开发面板;未生成 `.agent/run.latest.json` 前,`/audit` 只能标记任务配置通过,不能把 6 组协作证据误判为通过;trace 已存在但状态为 `failed`、`needs-revision`、`running`、`max-passes-exhausted` 或缺少 `Evaluator passed` 步骤时,`/audit` 不能把 loop 误判为通过。聊天侧 `/llm-status` 只显示 base_url / model / API Key 已读取状态且不泄露密钥本体、聊天侧长期记忆查看 / 追加 / 覆盖 / 删除的授权本地项目路径、上传资产写入后的 manifest 刷新和 `/assets` 聊天可见性、`/smoke` 聊天侧确认后只通过授权本地项目路径执行白名单 `game.static_smoke`、`/run` 聊天侧确认后通过授权本地项目路径执行 `game.static_smoke`、启动 `127.0.0.1` 本地预览并交给外部浏览器、`/preview` 聊天侧确认后通过授权本地项目路径启动 `127.0.0.1` 本地预览并交给外部浏览器、`/status` 聊天侧项目 / 任务 / 资产 / 预览 / 最近命令汇总、`/files` 聊天侧本地项目文件列表、`/read` 聊天侧文件读取的授权本地项目路径、`/tasks` 聊天侧任务拆分与下一步专业组展示的授权本地项目路径、聊天确认生成后实时展示 Planner / Orchestrator / 角色 brief / Generator / Evaluator / 写盘 / 自检进度,并自动读取 `.agent/run.latest.json` 在普通聊天消息里展示 Run、LLM 对话、loop 轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照、`/trace` 聊天侧读取 `.agent/run.latest.json` 并展示 loop 轮次 / active 任务 / 返工路线 / dependency waves 的授权本地项目路径、`platform-agent` 编排测试、共享契约测试、Tauri 本地能力测试和无密钥本地 provider 端到端 smoke;用于证明独立 App、真实 LLM-compatible loop、本地落盘、自检和 HTTP 预览闭环,并覆盖 loop 跑满 3 轮失败时不会写入最终游戏产物。 +- `file.delete` 的 Runtime 验收必须覆盖:删除普通文件与缺失文件的幂等结果、缺少路径、目录、绝对路径、父目录、反斜杠、有效与悬空符号链接和整个 `.agent/**` 控制面拒绝、独立 `confirm / deny` 策略、确认前无副作用、确认期间全局 revision 漂移失败关闭、durable action ledger 的 approved / executing / observed 恢复边界、`agent.runtime.file.delete` 审计,以及删除前 revision 推进、删除后必须通过当前 revision 的 `project.verify` 或 `game.static_smoke` 才能收束。另用完整后台 loop 和开发 CLI 真实任务证明 Agent 能自主选择删除并完成验证;普通用户窗口继续没有文件写入或删除入口。 - `/risks` 聊天入口由 `appSurface.test.ts` 的主窗口 smoke 覆盖:只基于当前已加载的 manifest / trace / 预览 / 任务 / 资产 / 最近命令生成风险摘要,提供首个风险处理草稿,不触发 Tauri 读写、文件读取、预览启动或新增普通用户面板。 - `/goal` 聊天入口由 `appSurface.test.ts` 主窗口 smoke 覆盖:只基于当前已加载 manifest.goal、最近 run goal 和 taskGraph.goal 汇总创作目标来源,提供 `/agent-resume 细化目标:` 或 `/next` 草稿,不触发 Tauri 读写、不读取 spec / 上下文 / trace 文件、不新增普通用户目标面板。 - `/guide` 聊天入口由 `appSurface.test.ts` 主窗口 smoke 覆盖:只基于当前已加载 manifest、最近 run trace、preview 和已加载命令状态判断普通用户当前阶段,给出最多 3 个推荐命令和首选草稿,不触发 Tauri 读写、不读取文件、不启动 run、不启动预览、不写项目、不新增普通用户导引面板。 @@ -321,7 +324,7 @@ game-project/ - Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`,Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`,Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,旧窗口兼容命令放在 `windows.rs`,Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。 - 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`。 - v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion`、`role`、`content`、`agentId` 和 `updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。 -- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动或排队单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果,也可对当前 run 执行取消 / 重试,待确认 run 还可执行“确认继续”或“拒绝并继续”。后台任务会向 `.agent/runtime/tasks/.jsonl` 追加任务视角记录,任务状态使用 `pending / running / waiting-for-confirmation / cancelled / completed / failed`,读取时按 `runId` 去重返回最近任务;`runId` 在同一 Agent 内是单个 run 的身份,后台入队会自动把重复 runId 改写为唯一实际 runId,防止不同任务互相覆盖;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行;若某个工具动作命中确认策略,该 Agent 会停在 `waiting-for-confirmation` 并暂停继续消费队列,等待后续确认或策略调整;Runtime 会把完整 `AgentRuntimePendingToolAction` 经过敏感内容和项目绝对路径校验后原子写入 `.agent/runtime/pending-actions//.json`,公共 `pendingToolAction` 只公开安全摘要;确认或拒绝必须匹配 `runId + actionId` 并通过工具名与完整输入 JSON 的 SHA-256 校验。确认在同一 run 直接执行原 action 并把 observation 接回后续 loop;拒绝不执行工具,而是写入 `blocked` observation 后在同一 run 继续规划。待确认状态可跨 App 重启读取并回收上一进程锁;等待期间同 Agent 新任务保持 pending,确认/拒绝续跑结束后由同一 drain 串行排空;若用户取消 pending 任务,drain 不再消费该 run,若取消 running 任务,则在当前 LLM 或工具调用返回后的检查点停止,不继续执行工具或保存最终 assistant 回复。客户端重开项目时会对当前项目路径自动尝试一次 Runtime 恢复;恢复命令必须通过 `agent.resume` 自动权限,默认确认策略下不会静默启动;同一 Agent 同时存在上一进程遗留 `running` 和 `pending` 时,先重接 `running`,再由 drain 继续 `pending`。开发构建和后台 Agent 工具箱都可通过 `agent.schedule_ready` 权限确认入口把 manifest ready task 投递进同一后台队列,命令会先把 ready task 标成 `running`,再用 taskId 作为 Agent id 入队,source 为 `agent-ready-task-scheduler`;该入口不新增独立 worker。后台任务的核心 loop 每 6 轮形成一个上下文压缩窗口:每轮把已有 observation 带回 LLM 让 Agent 重新规划;只有合法工具计划返回空 actions,且不存在未通过或项目修改后未重跑的 `project.verify` 时才收束,response 为空时进入独立最终回复生成。窗口边界会压缩 observation;有新的独立观察时在同一 run 继续下一窗口,最近窗口重复无进展时才以 `budget-exhausted / loop-budget-exhausted` 失败。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.background_task.recovered` / `agent.runtime.ready_task.scheduled` / `agent.runtime.tool_observation` / `agent.runtime.tool_confirmation_required` / `agent.runtime.tool_confirmation.approved` / `agent.runtime.tool_confirmation.rejected` / `agent.runtime.memory.write` / `agent.runtime.project.verify` / `agent.runtime.file.write` / `agent.runtime.file.patch` / `agent.runtime.tool_plan.repair` / `agent.runtime.task.create` / `agent.runtime.task.update` / `agent.runtime.command.run_limited` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.agent.delegate` / `agent.runtime.background_task.cancelled` / `agent.runtime.background_task.retry` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.search`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`project.verify`、`file.write`、`file.patch`、`task.create`、`task.update`、`command.run_limited`、`preview.start`、`canvas.asset_generate`、`blackboard.write`、`agent.message`、`agent.delegate` 和 `agent.schedule_ready`;`memory.write scope=agent` 只允许写当前 Agent 自己的私有记忆,跨 Agent 共享必须改用 `blackboard.write` 或 `agent.message`;`project.checkpoint` 只创建本地 checkpoint,不返回本机绝对路径;`project.restore` 只按 checkpoint id 恢复当前项目,不返回本机绝对路径,默认确认策略下不会静默回滚;`task.create` 只追加新 manifest 任务,`task.update` 只更新已有任务状态;`agent.schedule_ready` 只调度 manifest ready task,不创建平行 runtime;若项目策略拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent 修正计划;若项目策略要求确认,Runtime 会持久化精确待确认动作并保留 waiting 状态,不执行该工具;只有确认入口通过 `runId + actionId + SHA-256` 校验后才直接执行原 action,拒绝入口则生成 `blocked` observation。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作及安全 `inputSummary`,供状态面板展示最近动作和确认目标;append-only JSONL 写入按目标文件路径在当前进程内串行追加完整行,覆盖 `.agent/agent.db`、对话、Runtime events/tasks、activity 和 output,减少多个 Agent 同时完成时的行交错风险。 +- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动或排队单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果,也可对当前 run 执行取消 / 重试,待确认 run 还可执行“确认继续”或“拒绝并继续”。后台任务会向 `.agent/runtime/tasks/.jsonl` 追加任务视角记录,任务状态使用 `pending / running / waiting-for-confirmation / cancelled / completed / failed`,读取时按 `runId` 去重返回最近任务;`runId` 在同一 Agent 内是单个 run 的身份,后台入队会自动把重复 runId 改写为唯一实际 runId,防止不同任务互相覆盖;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行;若某个工具动作命中确认策略,该 Agent 会停在 `waiting-for-confirmation` 并暂停继续消费队列,等待后续确认或策略调整;Runtime 会把完整 `AgentRuntimePendingToolAction` 经过敏感内容和项目绝对路径校验后原子写入 `.agent/runtime/pending-actions//.json`,公共 `pendingToolAction` 只公开安全摘要;确认或拒绝必须匹配 `runId + actionId` 并通过工具名与完整输入 JSON 的 SHA-256 校验。确认在同一 run 直接执行原 action 并把 observation 接回后续 loop;拒绝不执行工具,而是写入 `blocked` observation 后在同一 run 继续规划。待确认状态可跨 App 重启读取并回收上一进程锁;等待期间同 Agent 新任务保持 pending,确认/拒绝续跑结束后由同一 drain 串行排空;若用户取消 pending 任务,drain 不再消费该 run,若取消 running 任务,则在当前 LLM 或工具调用返回后的检查点停止,不继续执行工具或保存最终 assistant 回复。客户端重开项目时会对当前项目路径自动尝试一次 Runtime 恢复;恢复命令必须通过 `agent.resume` 自动权限,默认确认策略下不会静默启动;同一 Agent 同时存在上一进程遗留 `running` 和 `pending` 时,先重接 `running`,再由 drain 继续 `pending`。开发构建和后台 Agent 工具箱都可通过 `agent.schedule_ready` 权限确认入口把 manifest ready task 投递进同一后台队列,命令会先把 ready task 标成 `running`,再用 taskId 作为 Agent id 入队,source 为 `agent-ready-task-scheduler`;该入口不新增独立 worker。后台任务的核心 loop 每 6 轮形成一个上下文压缩窗口:每轮把已有 observation 带回 LLM 让 Agent 重新规划;只有合法工具计划返回空 actions,且不存在未通过或项目修改后未重跑的 `project.verify` 时才收束,response 为空时进入独立最终回复生成。窗口边界会压缩 observation;有新的独立观察时在同一 run 继续下一窗口,最近窗口重复无进展时才以 `budget-exhausted / loop-budget-exhausted` 失败。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.background_task.recovered` / `agent.runtime.ready_task.scheduled` / `agent.runtime.tool_observation` / `agent.runtime.tool_confirmation_required` / `agent.runtime.tool_confirmation.approved` / `agent.runtime.tool_confirmation.rejected` / `agent.runtime.memory.write` / `agent.runtime.project.verify` / `agent.runtime.file.write` / `agent.runtime.file.patch` / `agent.runtime.file.delete` / `agent.runtime.tool_plan.repair` / `agent.runtime.task.create` / `agent.runtime.task.update` / `agent.runtime.command.run_limited` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.agent.delegate` / `agent.runtime.background_task.cancelled` / `agent.runtime.background_task.retry` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.search`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`project.verify`、`file.write`、`file.patch`、`file.delete`、`task.create`、`task.update`、`command.run_limited`、`preview.start`、`canvas.asset_generate`、`blackboard.write`、`agent.message`、`agent.delegate` 和 `agent.schedule_ready`;`memory.write scope=agent` 只允许写当前 Agent 自己的私有记忆,跨 Agent 共享必须改用 `blackboard.write` 或 `agent.message`;`project.checkpoint` 只创建本地 checkpoint,不返回本机绝对路径;`project.restore` 只按 checkpoint id 恢复当前项目,不返回本机绝对路径,默认确认策略下不会静默回滚;`file.delete` 只删除项目内普通文件,默认确认且不能访问 `.agent/**`;`task.create` 只追加新 manifest 任务,`task.update` 只更新已有任务状态;`agent.schedule_ready` 只调度 manifest ready task,不创建平行 runtime;若项目策略拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent 修正计划;若项目策略要求确认,Runtime 会持久化精确待确认动作并保留 waiting 状态,不执行该工具;只有确认入口通过 `runId + actionId + SHA-256` 校验后才直接执行原 action,拒绝入口则生成 `blocked` observation。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作及安全 `inputSummary`,供状态面板展示最近动作和确认目标;append-only JSONL 写入按目标文件路径在当前进程内串行追加完整行,覆盖 `.agent/agent.db`、对话、Runtime events/tasks、activity 和 output,减少多个 Agent 同时完成时的行交错风险。 - 2026-07-10 补充:当前工具箱还开放 `preview.start`,审计记录类型为 `agent.runtime.preview.start`;该工具不会打开任意 URL,只启动当前授权项目的 `127.0.0.1` 本地预览,并和 Tauri 用户命令共用同一个 `PreviewRegistry`。 - 2026-07-10 补充:当前工具箱还开放 `canvas.asset_generate`,审计记录类型为 `agent.runtime.canvas.asset_generate`;该工具只通过配置好的 External Editor API 生成并回流素材,不暴露任意上传 / 任意网络请求能力。 - 2026-07-10 补充:当前工具箱还开放 `task.list`;该工具只读取 manifest 任务图、状态、依赖、产物和 `readyTaskIds`,并受 `task.list` 项目权限策略保护。 @@ -465,6 +468,6 @@ game-project/ - 共享契约提供 manifest task schema 和 ready-task 选择器,用于记录任务拆分、专业组、角色模板、依赖、产物、验收条件和当前可执行任务。 - 开发模式可读取、保存、删除短期记忆和长期记忆文件;普通用户通过聊天命令完成同类能力。 - 共享契约提供 `GAME_CREATION_APP_LIMITED_RUN_COMMANDS`;当前真实命令为 `game.static_smoke`,用于检查 `game/index.html` 的可玩原型门槛并写入 `.agent/logs/command.log`。 -- 后台 Agent 的项目 revision 以 `.agent/runtime/project-revision.json` 为唯一事实源,per-run 验证门禁以 `.agent/runtime/verification//.json` 为事实源。每次 `file.write`、`file.patch` 或 `project.restore` 都必须在实际修改前保守推进 revision,并永久记住当前 run 的 `requiresVerification=true`;失败或崩溃不回退。只有成功且绑定当前 revision 的 `project.verify` 或 `command.run_limited / game.static_smoke` 才能放行空 actions;未修改项目的只读任务不强制验证,但最终回复仍必须绑定请求开始时的 `responseRevision`。per-run context bundle 与 pending action 使用 v2,v1 恢复失败关闭;最终 assistant 和 completed 必须在项目写锁内重读 revision / gate 后依次落盘,文件回读、observation 或锁外旧快照都不能替代验证凭证。验收必须分别模拟修改 run 与只读 run 在最终回复在途时的跨 Agent revision 漂移,证明旧回复不落盘、不产生 completed 或 failed、per-Agent 锁不释放、原 run/session 在收到 blocker 后重新规划并只保存当前回复;stale continuation 经重启仍从原 `nextLoopIndex` 续跑,revision 数值或成功验证输出中的动态时间戳不能绕过 context stall。 +- 后台 Agent 的项目 revision 以 `.agent/runtime/project-revision.json` 为唯一事实源,per-run 验证门禁以 `.agent/runtime/verification//.json` 为事实源。每次 `file.write`、`file.patch`、`file.delete` 或 `project.restore` 都必须在实际修改前保守推进 revision,并永久记住当前 run 的 `requiresVerification=true`;失败或崩溃不回退。只有成功且绑定当前 revision 的 `project.verify` 或 `command.run_limited / game.static_smoke` 才能放行空 actions;未修改项目的只读任务不强制验证,但最终回复仍必须绑定请求开始时的 `responseRevision`。per-run context bundle 使用 v2,pending action 使用 v3 并绑定创建时的全局 revision;旧版恢复失败关闭。最终 assistant 和 completed 必须在项目写锁内重读 revision / gate 后依次落盘,文件回读、observation 或锁外旧快照都不能替代验证凭证。验收必须分别模拟待执行动作、修改 run 与只读 run 的跨 Agent revision 漂移,证明旧动作不执行、旧回复不落盘、不产生 completed 或 failed、per-Agent 锁不提前释放、原 run/session 在收到 blocker 后保持可恢复;stale continuation 经重启仍从原 `nextLoopIndex` 续跑,revision 数值或成功验证输出中的动态时间戳不能绕过 context stall。 - `.agent/manifest.json` 会记录当前 `preview` 状态和 `commandRuns` 受限命令运行结果,作为本地产物索引的最小真相源。 - 开发模式可通过本地项目文件面板执行 `file.list/read/write/delete`,普通用户界面不暴露文件面板。 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 7b257b167..392587f79 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -107,6 +107,10 @@ describe('AI 游戏创作 App 共享契约', () => { GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'file.read') ?.permission, ).toBe('auto'); + expect( + GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'file.delete') + ?.permission, + ).toBe('confirm'); expect( GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'memory.read') ?.permission, 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 38486e5aa..0e8ad73eb 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -770,6 +770,12 @@ mod tests { .expect("command should exist"); assert_eq!(read.permission, GameCreationAppPermission::Auto); + let delete = GAME_CREATION_APP_COMMANDS + .iter() + .find(|command| command.id == "file.delete") + .expect("file.delete command should exist"); + assert_eq!(delete.permission, GameCreationAppPermission::Confirm); + let memory_read = GAME_CREATION_APP_COMMANDS .iter() .find(|command| command.id == "memory.read")