From de2335dbfee8c0072989052f22b8573d3e74b854 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Sun, 12 Jul 2026 10:35:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84Agent=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E8=BF=87=E6=9C=9F=E8=87=AA=E6=84=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 区分最终完成、过期凭证和致命错误,保持同一运行重新规划 为最终回复绑定项目版本并覆盖只读任务的并发漂移 持久化上下文停滞状态并过滤动态验证输出 补充并发、重启和停滞回归测试及长期文档 --- .../src-tauri/src/agent.rs | 383 +++++++++-- .../src-tauri/src/tests.rs | 601 +++++++++++++++++- .../shared-memory/decision-log.md | 4 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 +- 4 files changed, 917 insertions(+), 79 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 542c09a9c..40ede74c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -1939,7 +1939,7 @@ async fn continue_game_creator_agent_pending_tool_action( continuation, ) .await; - if outcome == AgentBackgroundTaskOutcome::Finished { + if matches!(outcome, AgentBackgroundTaskOutcome::Finished) { drain_next_game_creator_agent_background_tasks(root, agent_id).await; } } @@ -2034,15 +2034,16 @@ async fn drain_game_creator_agent_background_tasks( first_task: String, first_state: AgentRuntimeState, ) { - if run_game_creator_agent_background_task( - root.clone(), - agent_id.clone(), - first_task, - first_state, - ) - .await - != AgentBackgroundTaskOutcome::Finished - { + if !matches!( + run_game_creator_agent_background_task( + root.clone(), + agent_id.clone(), + first_task, + first_state, + ) + .await, + AgentBackgroundTaskOutcome::Finished + ) { return; } drain_next_game_creator_agent_background_tasks(root, agent_id).await; @@ -2084,15 +2085,16 @@ async fn drain_next_game_creator_agent_background_tasks(root: PathBuf, agent_id: } }; let _ = append_game_creator_agent_background_task_started_record(&root, &state); - if run_game_creator_agent_background_task( - root.clone(), - agent_id.clone(), - next_task.task, - state, - ) - .await - != AgentBackgroundTaskOutcome::Finished - { + if !matches!( + run_game_creator_agent_background_task( + root.clone(), + agent_id.clone(), + next_task.task, + state, + ) + .await, + AgentBackgroundTaskOutcome::Finished + ) { break; } } @@ -2192,7 +2194,36 @@ async fn run_game_creator_agent_background_task( .await } -async fn run_game_creator_agent_background_task_with_context( +pub(crate) async fn run_game_creator_agent_background_task_with_context( + root: PathBuf, + agent_id: String, + task: String, + mut state: AgentRuntimeState, + mut continuation: AgentRuntimeContinuationContext, +) -> AgentBackgroundTaskOutcome { + loop { + match run_game_creator_agent_background_task_pass_with_context( + root.clone(), + agent_id.clone(), + task.clone(), + state, + continuation, + ) + .await + { + AgentBackgroundTaskOutcome::ContinueSameRun { + state: next_state, + continuation: next_continuation, + } => { + state = next_state; + continuation = next_continuation; + } + outcome => return outcome, + } + } +} + +async fn run_game_creator_agent_background_task_pass_with_context( root: PathBuf, agent_id: String, task: String, @@ -2206,6 +2237,7 @@ async fn run_game_creator_agent_background_task_with_context( let start_loop_index = continuation.next_loop_index; let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation); let mut final_reply = None; + let mut final_reply_revision = None; let mut converged = false; let mut context_stalled = continuation.context_stalled; @@ -2261,6 +2293,19 @@ async fn run_game_creator_agent_background_task_with_context( } }; + let planning_request_revision = + match read_game_creator_agent_runtime_project_revision(&root) { + Ok(revision) => revision.revision, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取 Agent 工具计划请求的项目 revision 失败:{error}"), + ); + } + }; plan = match request_game_creator_agent_background_tool_plan_at( &root, &agent_id, @@ -2421,6 +2466,7 @@ async fn run_game_creator_agent_background_task_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); } observations = compact_agent_runtime_context_observations(&root, &observations); let _ = context_tracker.complete_loop(loop_index + 1); @@ -2912,8 +2958,17 @@ async fn run_game_creator_agent_background_task_with_context( return AgentBackgroundTaskOutcome::Finished; } - let final_reply = if let Some(reply) = final_reply { - reply + let (final_reply, final_reply_revision) = if let Some(reply) = final_reply { + let Some(response_revision) = final_reply_revision else { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + "最终回复缺少项目 revision 快照", + ); + }; + (reply, response_revision) } else { activate_agent_runtime_response_plan_step( &mut runtime, @@ -2936,6 +2991,18 @@ async fn run_game_creator_agent_background_task_with_context( if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } + let response_revision = match read_game_creator_agent_runtime_project_revision(&root) { + Ok(revision) => revision.revision, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取最终回复请求的项目 revision 失败:{error}"), + ); + } + }; let final_reply_result = request_game_creator_agent_background_final_reply_at( &root, &agent_id, @@ -2949,7 +3016,7 @@ async fn run_game_creator_agent_background_task_with_context( if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } - match final_reply_result { + let reply = match final_reply_result { Ok(reply) => reply, Err(_) if !plan.response.trim().is_empty() => plan.response.clone(), Err(error) => { @@ -2981,7 +3048,8 @@ async fn run_game_creator_agent_background_task_with_context( } return AgentBackgroundTaskOutcome::Finished; } - } + }; + (reply, response_revision) }; if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { @@ -2992,9 +3060,40 @@ async fn run_game_creator_agent_background_task_with_context( &root, runtime.clone(), &final_reply, + final_reply_revision, &observations, ) { - Ok(_) => {} + Ok(AgentBackgroundFinalizationOutcome::Completed(completed)) => { + debug_assert_eq!(completed.run_id, runtime.run_id); + debug_assert_eq!(completed.session_id, runtime.session_id); + AgentBackgroundTaskOutcome::Finished + } + Ok(AgentBackgroundFinalizationOutcome::Stale(blocker)) => { + let continuation = match prepare_game_creator_agent_background_stale_continuation_at( + &root, + &mut runtime, + &task, + &mut plan, + &mut observations, + &mut context_tracker, + blocker, + ) { + Ok(continuation) => continuation, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化过期最终回复的续跑上下文失败:{error}"), + ); + } + }; + AgentBackgroundTaskOutcome::ContinueSameRun { + state: runtime, + continuation, + } + } Err(error) => { let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); if let Ok(runtime) = failed_runtime { @@ -3011,10 +3110,9 @@ async fn run_game_creator_agent_background_task_with_context( }), ); } - return AgentBackgroundTaskOutcome::Finished; + AgentBackgroundTaskOutcome::Finished } } - AgentBackgroundTaskOutcome::Finished } pub(crate) const AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT: usize = 6; @@ -3040,11 +3138,21 @@ const AGENT_RUNTIME_FILE_PATCH_MAX_FILE_BYTES: usize = 2 * 1024 * 1024; const AGENT_RUNTIME_PROJECT_VERIFY_DEFAULT_TIMEOUT_SECONDS: usize = 120; pub(crate) const AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS: u64 = 300; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum AgentBackgroundTaskOutcome { +#[derive(Debug)] +pub(crate) enum AgentBackgroundTaskOutcome { Finished, WaitingForConfirmation, NeedsReconciliation, + ContinueSameRun { + state: AgentRuntimeState, + continuation: AgentRuntimeContinuationContext, + }, +} + +#[derive(Debug)] +pub(crate) enum AgentBackgroundFinalizationOutcome { + Completed(AgentRuntimeState), + Stale(AgentRuntimeToolObservation), } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -3133,6 +3241,8 @@ pub(crate) struct AgentRuntimeContextBundle { #[serde(default)] pub(crate) window_observation_fingerprints: Vec, pub(crate) last_window_fingerprint: Option, + #[serde(default)] + pub(crate) context_stalled: bool, pub(crate) updated_at: u64, } @@ -3141,6 +3251,7 @@ pub(crate) struct AgentRuntimeContextWindowTracker { completed_loops: usize, observation_signatures: std::collections::BTreeSet, last_window_fingerprint: Option, + stalled: bool, } #[derive(Clone, Debug, Default)] @@ -3165,6 +3276,7 @@ impl AgentRuntimeContextWindowTracker { .cloned() .collect(), last_window_fingerprint: continuation.last_window_fingerprint.clone(), + stalled: continuation.context_stalled, } } @@ -3177,6 +3289,7 @@ impl AgentRuntimeContextWindowTracker { .cloned() .collect(); continuation.last_window_fingerprint = self.last_window_fingerprint.clone(); + continuation.context_stalled = self.stalled; } pub(crate) fn record(&mut self, observation: &AgentRuntimeToolObservation) { @@ -3191,6 +3304,9 @@ impl AgentRuntimeContextWindowTracker { &mut self, next_loop_index: usize, ) -> AgentRuntimeContextCheckpoint { + if self.stalled { + return AgentRuntimeContextCheckpoint::Stalled; + } self.completed_loops = self.completed_loops.saturating_add(1); if next_loop_index == 0 || next_loop_index % AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT != 0 { return AgentRuntimeContextCheckpoint::Continue; @@ -3206,6 +3322,7 @@ impl AgentRuntimeContextWindowTracker { self.completed_loops = 0; self.observation_signatures.clear(); if stalled { + self.stalled = true; return AgentRuntimeContextCheckpoint::Stalled; } self.last_window_fingerprint = fingerprint; @@ -3238,6 +3355,14 @@ fn agent_runtime_context_observation_fingerprint_detail( observation: &AgentRuntimeToolObservation, ) -> Option { let detail = observation.detail.as_deref()?; + if observation.status == "ok" + && matches!( + observation.tool.as_str(), + "project.verify" | "command.run_limited" + ) + { + return None; + } let normalized = match observation.tool.as_str() { "project.index" => detail .lines() @@ -3270,6 +3395,11 @@ fn agent_runtime_context_observation_fingerprint_detail( }) .collect::>() .join("\n"), + "runtime.verification" => detail + .split_once(';') + .filter(|(revision_detail, _)| revision_detail.contains("currentRevision=")) + .map(|(_, guidance)| guidance.trim().to_string()) + .unwrap_or_else(|| detail.to_string()), _ => detail.to_string(), }; (!normalized.trim().is_empty()).then_some(normalized) @@ -3827,6 +3957,7 @@ fn sanitize_game_creator_agent_runtime_context_bundle( .as_deref() .map(|value| redact_agent_runtime_project_paths(root, value, 160)) .filter(|value| !value.trim().is_empty()), + context_stalled: bundle.context_stalled, updated_at: bundle.updated_at, } } @@ -3914,6 +4045,7 @@ pub(crate) fn build_game_creator_agent_runtime_context_bundle( .cloned() .collect(), last_window_fingerprint: context_tracker.last_window_fingerprint.clone(), + context_stalled: context_tracker.stalled, updated_at: unix_timestamp(), }) } @@ -4131,6 +4263,14 @@ pub(crate) fn read_game_creator_agent_runtime_context_bundle( bundle.context_window )); } + if bundle.context_stalled + && (bundle.next_loop_index == 0 + || bundle.next_loop_index + % u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1) + != 0) + { + return Err("Agent Runtime context bundle 的停滞标记只能出现在上下文窗口边界".to_string()); + } let expected_completed_loops = bundle.next_loop_index % u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1); if bundle.window_completed_loops != expected_completed_loops { @@ -4181,7 +4321,7 @@ pub(crate) fn continuation_from_game_creator_agent_runtime_context_bundle( .unwrap_or(usize::MAX), window_observation_fingerprints: bundle.window_observation_fingerprints, last_window_fingerprint: bundle.last_window_fingerprint, - context_stalled: false, + context_stalled: bundle.context_stalled, } } @@ -4714,43 +4854,42 @@ fn project_verification_completion_blocker_at_locked( run_id: &str, observations: &[AgentRuntimeToolObservation], ) -> Option { - if let Some(blocker) = project_verification_completion_blocker(observations) { - return Some(blocker); + match evaluate_project_verification_completion_at_locked(root, agent_id, run_id, observations) { + Ok(blocker) => blocker, + Err(error) => Some(agent_runtime_verification_blocker( + "无法读取项目 revision 或当前 run 的 verification gate,不能把任务标记为完成", + error, + )), } - let revision = match read_game_creator_agent_runtime_project_revision(root) { - Ok(revision) => revision, - Err(error) => { - return Some(agent_runtime_verification_blocker( - "无法读取项目 revision,不能把任务标记为完成", - error, - )); - } - }; - let gate = match read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id) { - Ok(gate) => gate, - Err(error) => { - return Some(agent_runtime_verification_blocker( - "无法读取当前 run 的 verification gate,不能把任务标记为完成", - error, - )); - } - }; +} + +fn evaluate_project_verification_completion_at_locked( + root: &Path, + agent_id: &str, + run_id: &str, + observations: &[AgentRuntimeToolObservation], +) -> Result, String> { + if let Some(blocker) = project_verification_completion_blocker(observations) { + return Ok(Some(blocker)); + } + let revision = read_game_creator_agent_runtime_project_revision(root)?; + let gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; if let Some(status) = gate.last_verification_status.as_deref() { if status == AGENT_RUNTIME_VERIFICATION_STATUS_RUNNING { - return Some(agent_runtime_verification_blocker( + return Ok(Some(agent_runtime_verification_blocker( "项目验证尚未形成可用结果,不能把任务标记为完成", "请等待当前验证结束,或重新执行并通过 project.verify / game.static_smoke。", - )); + ))); } if status == AGENT_RUNTIME_VERIFICATION_STATUS_FAILED { - return Some(agent_runtime_verification_blocker( + return Ok(Some(agent_runtime_verification_blocker( "最近一次项目验证未通过,不能把任务标记为完成", "请根据验证诊断继续修复,并重新执行 project.verify / game.static_smoke。", - )); + ))); } } if !gate.requires_verification { - return None; + return Ok(None); } if gate.last_verification_status.as_deref() != Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) || gate.verified_revision != Some(revision.revision) @@ -4759,7 +4898,7 @@ fn project_verification_completion_blocker_at_locked( .verified_revision .map(|value| value.to_string()) .unwrap_or_else(|| "none".to_string()); - return Some(agent_runtime_verification_blocker( + return Ok(Some(agent_runtime_verification_blocker( "项目在当前 revision 上尚未通过验证,不能把任务标记为完成", format!( "currentRevision={}, mutationRevision={}, verifiedRevision={};请重新执行并通过 project.verify 或 game.static_smoke。", @@ -4769,9 +4908,9 @@ fn project_verification_completion_blocker_at_locked( .unwrap_or_else(|| "none".to_string()), verified_revision ), - )); + ))); } - None + Ok(None) } pub(crate) fn project_verification_completion_blocker_at( @@ -5111,6 +5250,22 @@ fn complete_agent_runtime_active_plan_step( runtime.active_plan_step_index = None; } +fn retry_agent_runtime_active_plan_step(runtime: &mut AgentRuntimeState, detail: &str) { + let Some(active_index) = runtime.active_plan_step_index else { + return; + }; + let now = unix_timestamp(); + for step in runtime.plan_steps.iter_mut() { + if step.index == active_index { + step.status = "pending".to_string(); + step.detail = Some(sanitize_agent_runtime_text(detail, 220)); + step.updated_at = now; + break; + } + } + runtime.active_plan_step_index = None; +} + fn activate_agent_runtime_response_plan_step(runtime: &mut AgentRuntimeState, detail: &str) { let target_index = runtime .plan_steps @@ -9417,19 +9572,113 @@ pub(crate) fn finish_game_creator_agent_runtime_turn_at( Ok(state) } +pub(crate) fn prepare_game_creator_agent_background_stale_continuation_at( + root: &Path, + state: &mut AgentRuntimeState, + task: &str, + plan: &mut AgentRuntimeToolPlan, + observations: &mut Vec, + context_tracker: &mut AgentRuntimeContextWindowTracker, + blocker: AgentRuntimeToolObservation, +) -> Result { + let blocker_summary = blocker.summary(); + let blocker_detail = blocker.detail.clone(); + let runtime_blocker_summary = blocker_detail + .as_deref() + .filter(|detail| !detail.trim().is_empty()) + .map(|detail| format!("{blocker_summary}:{detail}")) + .unwrap_or_else(|| blocker_summary.clone()); + retry_agent_runtime_active_plan_step( + state, + "最终回复生成期间项目 revision 已变化,旧回复已丢弃并等待重新规划。", + ); + state.status = "running".to_string(); + state.phase = "observation".to_string(); + state.current_action = "丢弃过期最终回复并回到 planning".to_string(); + state.waiting_on = "Agent 重新规划并验证当前项目 revision".to_string(); + state.next_step = "根据 runtime.verification blocker 重新规划、验证并生成当前回复".to_string(); + state.error = None; + state.observations.push(runtime_blocker_summary); + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, state)?; + refresh_game_creator_agent_runtime_task_queue(root, state)?; + write_game_creator_agent_runtime_state(root, state)?; + append_game_creator_agent_runtime_event( + root, + state, + "response.stale", + state.status.as_str(), + state.phase.as_str(), + "最终回复生成期间项目 revision 已变化,已丢弃旧回复并在同一 run 重新规划。", + blocker_detail.as_deref(), + )?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.response_stale", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "summary": blocker_summary, + "detail": blocker_detail, + }), + )?; + + plan.actions.clear(); + plan.response.clear(); + context_tracker.record(&blocker); + observations.push(blocker); + *observations = compact_agent_runtime_context_observations(root, observations); + let next_loop_index = usize::try_from(state.loop_iteration).unwrap_or(usize::MAX); + persist_game_creator_agent_runtime_context( + root, + state, + task, + plan, + observations, + next_loop_index, + context_tracker, + )?; + + let mut continuation = AgentRuntimeContinuationContext { + plan: plan.clone(), + observations: observations.clone(), + next_loop_index, + context_stalled: false, + ..AgentRuntimeContinuationContext::default() + }; + context_tracker.apply_to_continuation(&mut continuation); + Ok(continuation) +} + pub(crate) fn finish_game_creator_agent_background_runtime_turn_at( root: &Path, state: AgentRuntimeState, response: &str, + response_revision: u64, observations: &[AgentRuntimeToolObservation], -) -> Result { +) -> Result { let _lock = acquire_project_write_lock(root, "runtime.background.complete")?; - if let Some(blocker) = project_verification_completion_blocker_at_locked( - root, - &state.agent_id, - &state.run_id, - observations, - ) { + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + let blocker = if current_revision.revision != response_revision { + Some(agent_runtime_verification_blocker( + "最终回复基于的项目 revision 已过期,不能把任务标记为完成", + format!( + "responseRevision={response_revision}, currentRevision={};请根据最新项目状态重新规划后再生成最终回复。", + current_revision.revision + ), + )) + } else { + evaluate_project_verification_completion_at_locked( + root, + &state.agent_id, + &state.run_id, + observations, + )? + }; + if let Some(blocker) = blocker { let detail = blocker.detail.as_deref().unwrap_or_default(); let _ = append_agent_db_record( root, @@ -9444,7 +9693,7 @@ pub(crate) fn finish_game_creator_agent_background_runtime_turn_at( "detail": detail, }), ); - return Err(format!("{}:{}", blocker.summary, detail)); + return Ok(AgentBackgroundFinalizationOutcome::Stale(blocker)); } append_local_conversation_message_for_session_at( root, @@ -9484,7 +9733,7 @@ pub(crate) fn finish_game_creator_agent_background_runtime_turn_at( "responsePreview": completed.last_response, }), ); - Ok(completed) + Ok(AgentBackgroundFinalizationOutcome::Completed(completed)) } pub(crate) fn fail_game_creator_agent_runtime_turn_at( 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 fcbb75e56..016b78f09 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -1389,10 +1389,7 @@ fn spawn_releasable_mock_llm_server_responses_with_capture_at( std::thread::spawn(move || { for (index, response_content) in response_contents.into_iter().enumerate() { let (mut stream, _) = listener.accept().expect("mock llm accept"); - let mut request_buffer = [0_u8; 8192]; - let read_len = stream.read(&mut request_buffer).unwrap_or(0); - let _ = request_sender - .send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned()); + let _ = request_sender.send(read_mock_http_request(&mut stream)); if index == release_index { release_receiver .recv_timeout(Duration::from_secs(10)) @@ -3842,6 +3839,125 @@ fn agent_runtime_context_window_restores_progress_and_counts_dynamic_detail_chan fs::remove_dir_all(root).ok(); } +#[test] +fn agent_runtime_context_window_persists_stall_across_revision_drift_and_restart() { + fn verification_blocker(current_revision: u64) -> AgentRuntimeToolObservation { + AgentRuntimeToolObservation { + tool: "runtime.verification".to_string(), + status: "blocked".to_string(), + summary: "项目在当前 revision 上尚未通过验证,不能把任务标记为完成" + .to_string(), + detail: Some(format!( + "currentRevision={current_revision}, mutationRevision=1, verifiedRevision=1;请重新执行并通过 project.verify 或 game.static_smoke。" + )), + } + } + let mut tracker = AgentRuntimeContextWindowTracker::default(); + for next_loop_index in 1..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + tracker.record(&verification_blocker(next_loop_index as u64)); + assert_eq!( + tracker.complete_loop(next_loop_index), + AgentRuntimeContextCheckpoint::Continue + ); + } + tracker.record(&verification_blocker( + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u64, + )); + assert_eq!( + tracker.complete_loop(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT), + AgentRuntimeContextCheckpoint::Stalled, + "revision numbers alone must not manufacture independent progress" + ); + + 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", + "验证 revision 漂移不能绕过停滞预算", + "design-verification-stall-restart-run", + "agent-background-task", + "验证停滞恢复测试", + vec!["保持停滞预算".to_string()], + ) + .expect("start verification stall runtime"); + state.loop_iteration = AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32; + state.max_loop_iterations = AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32; + write_game_creator_agent_runtime_state(&root, &state) + .expect("persist verification stall runtime"); + let observations = vec![verification_blocker( + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u64, + )]; + let bundle = build_game_creator_agent_runtime_context_bundle( + &root, + &state, + &state.current_task, + &AgentRuntimeToolPlan::default(), + &observations, + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, + &tracker, + ) + .expect("build stalled context bundle"); + assert!(bundle.context_stalled); + write_game_creator_agent_runtime_context_bundle(&root, &bundle) + .expect("write stalled context bundle"); + + let loaded = read_game_creator_agent_runtime_context_bundle(&root, &state) + .expect("read stalled context bundle") + .expect("stalled context bundle exists"); + assert!(loaded.context_stalled); + let continuation = continuation_from_game_creator_agent_runtime_context_bundle(loaded); + let mut restored = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + assert_eq!( + restored.complete_loop(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1), + AgentRuntimeContextCheckpoint::Stalled, + "restart must not reset an exhausted context window" + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_context_window_ignores_dynamic_successful_verification_output() { + let mut tracker = AgentRuntimeContextWindowTracker::default(); + for next_loop_index in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT * 2 { + let observation = if next_loop_index % 2 == 1 { + AgentRuntimeToolObservation { + tool: "project.verify".to_string(), + status: "ok".to_string(), + summary: "npm run check:agent 已通过".to_string(), + detail: Some(format!( + "AGENT_RUNTIME_CURRENT_REVISION_OK timestamp={next_loop_index}" + )), + } + } else { + AgentRuntimeToolObservation { + tool: "runtime.verification".to_string(), + status: "blocked".to_string(), + summary: "最终回复基于的项目 revision 已过期,不能把任务标记为完成" + .to_string(), + detail: Some(format!( + "responseRevision={}, currentRevision={next_loop_index};请根据最新项目状态重新规划后再生成最终回复。", + next_loop_index - 1 + )), + } + }; + tracker.record(&observation); + let checkpoint = tracker.complete_loop(next_loop_index); + if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Compacted); + } else if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT * 2 { + assert_eq!( + checkpoint, + AgentRuntimeContextCheckpoint::Stalled, + "dynamic verification output and revision values must not keep stale replanning alive" + ); + } else { + assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Continue); + } + } +} + #[test] fn agent_runtime_context_bundle_preserves_project_verification_gate_evidence() { let root = unique_project_path(); @@ -8722,14 +8838,24 @@ fn background_finalization_rechecks_stale_credential_before_assistant_persistenc "code-finalization-race-run", "file.patch", ); - let error = finish_game_creator_agent_background_runtime_turn_at( + let blocker = match finish_game_creator_agent_background_runtime_turn_at( &root, state.clone(), "不应落盘的过期回复", + 1, &[], ) - .expect_err("stale credential must block final completion"); - assert!(error.contains("currentRevision=2")); + .expect("stale credential is a recoverable finalization outcome") + { + AgentBackgroundFinalizationOutcome::Stale(blocker) => blocker, + AgentBackgroundFinalizationOutcome::Completed(_) => { + panic!("stale credential must not complete the run") + } + }; + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("currentRevision=2"))); let conversation = read_local_conversation_for_session_at( &root, Some("design-director"), @@ -8747,6 +8873,467 @@ fn background_finalization_rechecks_stale_credential_before_assistant_persistenc fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_finalization_replans_same_run_after_cross_agent_revision_drift() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复同 Run 自愈项目") + .expect("project init"); + let check_command = write_agent_runtime_verification_fixture(&root); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow verification tools"); + let run_id = "design-finalization-replan-run"; + assert_eq!( + advance_project_revision_for_test(&root, "design-director", run_id, "file.write"), + 1 + ); + persist_project_verification_for_test(&root, "design-director", run_id, "project.verify", true); + + let converge_without_reply = serde_json::json!({ + "thinkingSummary": "当前观察已经收束,可以整理最终回复", + "plan": [], + "actions": [], + "response": "" + }) + .to_string(); + let (request_sender, request_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let base_url = spawn_releasable_mock_llm_server_responses_with_capture_at( + vec![ + converge_without_reply.clone(), + "过期回复".to_string(), + agent_runtime_verification_plan(check_command), + converge_without_reply, + "当前回复".to_string(), + ], + request_sender, + 1, + release_receiver, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + let initial = start_game_creator_agent_background_task_at( + &root, + "design-director", + "在当前项目 revision 上验证后给出最终结论", + run_id, + ) + .expect("start stale finalization task"); + let session_id = initial.state.session_id.clone(); + assert_eq!(initial.state.run_id, run_id); + + let initial_plan_request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial convergence plan request"); + assert!(initial_plan_request.contains(run_id)); + let stale_final_reply_request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("blocked stale final reply request"); + assert!(stale_final_reply_request.contains(run_id)); + assert!( + !game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("inspect lock while final reply is blocked"), + "per-Agent lock must remain held while the final reply is in flight" + ); + assert_eq!( + advance_project_revision_for_test( + &root, + "code-prototype", + "code-finalization-race-run", + "file.patch", + ), + 2 + ); + release_sender + .send(()) + .expect("release stale final reply response"); + + let replanned_request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("same run replanning request"); + assert!(replanned_request.contains(run_id)); + assert!(replanned_request.contains(&session_id)); + assert!(replanned_request.contains("runtime.verification")); + assert!(replanned_request.contains("currentRevision=2")); + assert!( + !game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("inspect lock during same-run replanning"), + "stale replanning must not release the per-Agent lock" + ); + let verified_request = request_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("request after current revision verification"); + assert!(verified_request.contains(run_id)); + assert!(verified_request.contains("project.verify")); + assert!(verified_request.contains("AGENT_RUNTIME_CURRENT_REVISION_OK")); + let current_final_reply_request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("current final reply request"); + assert!(current_final_reply_request.contains(run_id)); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.session_id, session_id); + assert_eq!(runtime.loop_iteration, 3); + assert_eq!(runtime.last_response.as_deref(), Some("当前回复")); + assert_ne!(runtime.last_response.as_deref(), Some("过期回复")); + assert!( + game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("inspect lock after current reply completed") + ); + assert!(runtime + .observations + .iter() + .any(|item| item.contains("currentRevision=2"))); + + let conversation = + read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id)) + .expect("read finalization conversation"); + let assistant_messages = conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(); + assert_eq!(assistant_messages, vec!["当前回复"]); + + let gate = read_game_creator_agent_runtime_verification_gate(&root, "design-director", run_id) + .expect("read current finalization gate"); + assert_eq!(gate.verified_revision, Some(2)); + assert_eq!(gate.last_verification_status.as_deref(), Some("passed")); + let records = read_agent_db_records_for_test(&root); + assert!(records.iter().any(|record| { + record["recordType"] == "agent.runtime.background_task.completion_blocked" + && record["runId"] == run_id + })); + assert!(records.iter().any(|record| { + record["recordType"] == "agent.runtime.background_task.response_stale" + && record["runId"] == run_id + })); + assert!(!records.iter().any(|record| { + record["recordType"] == "agent.runtime.background_task.failed" && record["runId"] == run_id + })); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn read_only_background_finalization_replans_when_reply_revision_becomes_stale() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "只读最终回复 revision 自愈项目") + .expect("project init"); + let run_id = "design-read-only-finalization-replan-run"; + let converge_without_reply = serde_json::json!({ + "thinkingSummary": "只读观察已经收束,可以整理最终回复", + "plan": [], + "actions": [], + "response": "" + }) + .to_string(); + let current_reply_plan = serde_json::json!({ + "thinkingSummary": "已按当前项目 revision 重新规划", + "plan": [], + "actions": [], + "response": "当前只读回复" + }) + .to_string(); + let (request_sender, request_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let base_url = spawn_releasable_mock_llm_server_responses_with_capture_at( + vec![ + converge_without_reply, + "过期只读回复".to_string(), + current_reply_plan, + ], + request_sender, + 1, + release_receiver, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + let initial = start_game_creator_agent_background_task_at( + &root, + "design-director", + "只读取项目并给出当前结论", + run_id, + ) + .expect("start read-only stale finalization task"); + let session_id = initial.state.session_id.clone(); + let initial_gate = + read_game_creator_agent_runtime_verification_gate(&root, "design-director", run_id) + .expect("read read-only gate"); + assert!(!initial_gate.requires_verification); + + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial read-only convergence request"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("blocked read-only final reply request"); + assert!( + !game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("inspect read-only lock while final reply is blocked") + ); + assert_eq!( + advance_project_revision_for_test( + &root, + "code-prototype", + "code-read-only-finalization-race-run", + "file.patch", + ), + 1 + ); + release_sender + .send(()) + .expect("release stale read-only final reply"); + + let replanned_request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("read-only same-run replanning request"); + assert!(replanned_request.contains(run_id)); + assert!(replanned_request.contains(&session_id)); + assert!(replanned_request.contains("responseRevision=0")); + assert!(replanned_request.contains("currentRevision=1")); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.session_id, session_id); + assert_eq!(runtime.last_response.as_deref(), Some("当前只读回复")); + assert_ne!(runtime.last_response.as_deref(), Some("过期只读回复")); + assert!( + game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("inspect read-only lock after replan") + ); + assert!(request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err()); + + let conversation = + read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id)) + .expect("read read-only finalization conversation"); + let assistant_messages = conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(); + assert_eq!(assistant_messages, vec!["当前只读回复"]); + let records = read_agent_db_records_for_test(&root); + assert!(records.iter().any(|record| { + record["recordType"] == "agent.runtime.background_task.response_stale" + && record["runId"] == run_id + })); + assert!(!records.iter().any(|record| { + record["recordType"] == "agent.runtime.background_task.failed" && record["runId"] == run_id + })); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn stale_finalization_context_survives_restart_before_same_run_replanning() { + 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); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow verification tools"); + let run_id = "design-stale-restart-run"; + advance_project_revision_for_test(&root, "design-director", run_id, "file.write"); + persist_project_verification_for_test(&root, "design-director", run_id, "project.verify", true); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "重启后继续验证当前 revision", + run_id, + "agent-background-task", + "准备生成最终回复", + vec!["生成最终回复".to_string()], + ) + .expect("start stale restart state"); + state.loop_iteration = 1; + let mut plan = AgentRuntimeToolPlan { + thinking_summary: "旧 revision 已经收束".to_string(), + plan: Vec::new(), + actions: Vec::new(), + response: "重启后不得复用的旧回复".to_string(), + }; + let mut observations = Vec::new(); + let mut context_tracker = AgentRuntimeContextWindowTracker::default(); + assert_eq!( + context_tracker.complete_loop(1), + AgentRuntimeContextCheckpoint::Continue + ); + + advance_project_revision_for_test( + &root, + "code-prototype", + "code-stale-restart-race-run", + "file.patch", + ); + let blocker = match finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + "重启后不得复用的旧回复", + 1, + &observations, + ) + .expect("revision drift is recoverable") + { + AgentBackgroundFinalizationOutcome::Stale(blocker) => blocker, + AgentBackgroundFinalizationOutcome::Completed(_) => { + panic!("stale final reply must not complete before restart") + } + }; + prepare_game_creator_agent_background_stale_continuation_at( + &root, + &mut state, + "重启后继续验证当前 revision", + &mut plan, + &mut observations, + &mut context_tracker, + blocker, + ) + .expect("persist stale continuation before restart"); + + let restored_state = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read stale runtime after simulated restart") + .state; + let restored_bundle = read_game_creator_agent_runtime_context_bundle(&root, &restored_state) + .expect("read stale context after simulated restart") + .expect("stale context exists"); + assert_eq!(restored_state.run_id, run_id); + assert_eq!(restored_state.session_id, state.session_id); + assert_eq!(restored_state.status, "running"); + assert_eq!(restored_state.phase, "observation"); + assert_eq!(restored_bundle.next_loop_index, 1); + assert_eq!(restored_bundle.window_completed_loops, 1); + assert!(restored_bundle.fallback_response.is_empty()); + assert!(restored_bundle + .observations + .iter() + .any(|observation| observation.tool == "runtime.verification" + && observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("currentRevision=2")))); + let restored_continuation = + continuation_from_game_creator_agent_runtime_context_bundle(restored_bundle); + + let converge_without_reply = serde_json::json!({ + "thinkingSummary": "重启后的当前 revision 已验证", + "plan": [], + "actions": [], + "response": "" + }) + .to_string(); + let (request_sender, request_receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![ + agent_runtime_verification_plan(check_command), + converge_without_reply, + "恢复后的当前回复".to_string(), + ], + Some(request_sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let session_id = restored_state.session_id.clone(); + let outcome = run_game_creator_agent_background_task_with_context( + root.clone(), + "design-director".to_string(), + "重启后继续验证当前 revision".to_string(), + restored_state, + restored_continuation, + ) + .await; + assert!(matches!(outcome, AgentBackgroundTaskOutcome::Finished)); + let replanned_request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("restart replanning request"); + assert!(replanned_request.contains(run_id)); + assert!(replanned_request.contains("currentRevision=2")); + let verified_request = request_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("restart request after verification"); + assert!(verified_request.contains("project.verify")); + let final_reply_request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("restart final reply request"); + assert!(final_reply_request.contains(run_id)); + + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read completed restart runtime") + .state; + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.run_id, run_id); + assert_eq!(runtime.session_id, session_id); + assert_eq!(runtime.last_response.as_deref(), Some("恢复后的当前回复")); + let conversation = + read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id)) + .expect("read restart conversation"); + let assistant_messages = conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(); + assert_eq!(assistant_messages, vec!["恢复后的当前回复"]); + let gate = read_game_creator_agent_runtime_verification_gate(&root, "design-director", run_id) + .expect("read restart verification gate"); + assert_eq!(gate.verified_revision, Some(2)); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_start_local_preview() { 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 15f02f283..73e924f5f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4154,7 +4154,9 @@ - 决策:重启恢复继续遵守 `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 或进入明确失败,不能用锁外旧快照收束。 +- 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。 - 决策:每 6 轮只形成上下文压缩窗口,不是整个 run 的固定预算。窗口产生新的独立 observation 时压缩上下文并继续同一 run;最近 6 轮没有新进展或相邻窗口重复时写 `failed / budget-exhausted` 和 `loop-budget-exhausted`,不再生成总结后记成 completed。解析阶段保留 action 总数,超过单轮预算时写 `runtime.tool_budget` 并只执行前三个;Runtime 默认工具列表必须直接从可执行白名单派生。 +- 2026-07-12 修正:`contextStalled` 是跨 same-run replan 和进程重启持久化的锁存状态,只能出现在非零上下文窗口边界,一旦成立不得在恢复时清除。`runtime.verification` observation 的窗口指纹忽略 `currentRevision / mutationRevision / verifiedRevision` 动态数值前缀,成功 `project.verify / game.static_smoke` 也不把动态命令输出计为新指纹;revision 数字和时间戳变化本身不构成独立进展,不能借此绕过停滞预算。 - 决策:`agent.delegate` 子任务必须 durable 保存 `parentAgentId / parentRunId / delegationId`,其中 `delegationId` 从已持久化工具动作的 `actionId` 派生,不能使用执行时随机值;终态任务记录必须保存经过统一凭据清洗和安全截断的 `terminalDetail`,不能依赖可能被后续 run 覆盖的 Agent 全局 state。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态后,Runtime 必须在 delegation 级 OS 文件锁内按固定 receipt runId 幂等生成且至多生成一次 `agent.delegate.result` 回执;不同委派并发写同一目标 Agent 时,runId 分配与 pending 追加还必须在目标 Agent 任务账本 OS 锁内原子完成。失败、排队或活跃取消、预算耗尽与成功同等需要回执,`needs-reconciliation` 只有最终取消后才回执。父 Agent 通过既有队列接收 `source=agent-delegate-receipt` 的续跑任务,回执 prompt 禁止重复同一委派,并携带完整的已清洗 `terminalDetail`,不能只保留 UI 摘要;排队期间不提前写入父会话,真正执行时才幂等落盘,用户消息或回执消息落盘失败时不得进入 LLM。回执任务必须保留父 run 关联,真正开始或恢复前再次核验父 run,关联缺失或父 run 不存在时失败关闭;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活。父 Session 存在未结束委派时禁止切换或归档,极端竞态下回执回落到父 Agent 当前可写 Session。续跑继续遵守同 Agent FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障,不允许直接重入、插队或重复投递;恢复必须先恢复 pending action / reconciliation 屏障,再补齐“子终态已落盘、回执未入队”的崩溃窗口。 - 决策:Agent loop 的语义事件类型固定为 `thinking_summary / plan / action / observation / response / error`。普通失败和预算耗尽必须先追加统一 `error` 事件,同时保留 `turn.failed / turn.budget_exhausted` 生命周期事件供旧读取方兼容;状态、phase 和清洗后的错误详情必须在两类事件中一致。Runtime 状态面板默认展示最新 4 条事件,但在当前后端最近事件窗口大于 4 条时必须允许展开全部返回记录,不能让 `plan`、早期 observation 或 thinking summary 永久不可见。 -- 验证:Rust 覆盖首轮上下文不泄露、工具后 observation 可见、六类语义事件、确认前后内容边界、前后台同 Agent 串行、前台结束后队列 drain、恢复确认 gate、预算耗尽失败、默认工具白名单一致性,以及 delegate 成功 / 失败 / 取消 / 预算耗尽终态回执、`delegationId` 幂等去重和父 Agent receipt 续跑仍受 FIFO / 锁 / 确认 / 恢复门禁;revision / verification gate 还要覆盖修改前推进、失败不回退、`requiresVerification` 单向持久化、验证只绑定当前 revision、v1 context bundle / pending action 恢复失败关闭,以及最终 assistant / completed 在项目写锁内复核后才落盘;前端覆盖统一事件展示、主工作区和独立开发 Agent 聊天窗口的默认恢复确认条与显式恢复 command。 +- 验证:Rust 覆盖首轮上下文不泄露、工具后 observation 可见、六类语义事件、确认前后内容边界、前后台同 Agent 串行、前台结束后队列 drain、恢复确认 gate、预算耗尽失败、默认工具白名单一致性,以及 delegate 成功 / 失败 / 取消 / 预算耗尽终态回执、`delegationId` 幂等去重和父 Agent receipt 续跑仍受 FIFO / 锁 / 确认 / 恢复门禁;revision / verification gate 还要覆盖修改前推进、失败不回退、`requiresVerification` 单向持久化、验证只绑定当前 revision、v1 context bundle / pending action 恢复失败关闭、修改 run 与只读 run 的 stale 回复不落盘、跨 Agent 漂移在同 run 自愈、stale context 重启恢复、动态验证输出不能绕过 stall、stall 跨 revision / restart 保持,以及最终 assistant / completed 在项目写锁内复核后才落盘;前端覆盖统一事件展示、主工作区和独立开发 Agent 聊天窗口的默认恢复确认条与显式恢复 command。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index cf4baf87b..b236f77ec 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -18,7 +18,7 @@ Agent Runtime 负责: -- 2026-07-12 安全边界补充:`project.verify` 的 script 最多 160 个字符,固定使用系统 script shell,并在解析和执行前拒绝项目级 `.npmrc` 改写 npm 语义。Runtime context bundle 绑定 `projectId / agentId / taskId / sessionId / runId / source / task`,结尾换行计入 64 KiB 上限;恢复时同时校验 `nextLoopIndex`、context window、当前窗口已完成轮数、观察指纹、计划和 observation 数量。bundle 通过项目内无符号链接路径原子写入,并从同一文件句柄最多读取 64 KiB;项目路径和常见 `sk- / GitHub / npm / AWS / JWT` 凭据统一脱敏。工具 observation 进入 context checkpoint 后才删除 `observed-*` 动作账本,避免重启时旧 ledger 抢占有效 bundle。 +- 2026-07-12 安全边界补充:`project.verify` 的 script 最多 160 个字符,固定使用系统 script shell,并在解析和执行前拒绝项目级 `.npmrc` 改写 npm 语义。Runtime context bundle 绑定 `projectId / agentId / taskId / sessionId / runId / source / task`,结尾换行计入 64 KiB 上限;恢复时同时校验 `nextLoopIndex`、context window、当前窗口已完成轮数、观察指纹、计划、observation 数量,以及 `contextStalled` 只能位于非零上下文窗口边界。bundle 通过项目内无符号链接路径原子写入,并从同一文件句柄最多读取 64 KiB;项目路径和常见 `sk- / GitHub / npm / AWS / JWT` 凭据统一脱敏。工具 observation 进入 context checkpoint 后才删除 `observed-*` 动作账本,避免重启时旧 ledger 抢占有效 bundle。 - 总任务拆分和任务图状态流转。 - 6 个专业组调度:策划组、美术组、程序组、数值组、音乐组、运营组。 @@ -55,13 +55,13 @@ Agent Runtime 负责: - 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`,开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态卡用同一套前端归一化逻辑合并状态;该事件只做实时 UI 通知,`.agent/runtime/agents`、`events` 和 `tasks` 仍是重开项目后的事实源。 - 2026-07-10 补充:后台 Agent loop 的统一语义事件类型为 `thinking_summary / plan / action / observation / response / error`。普通失败和 loop 预算耗尽都会追加 `error` 事件,并继续保留 `turn.failed / turn.budget_exhausted` 生命周期事件兼容既有读取方;开发窗口、项目内 Agent 对话弹窗和主窗口状态卡通过现有最近事件列表直接展示统一错误事件及其安全详情。状态面板默认保持最新 4 条的紧凑视图,当前后端返回的最近事件超过 4 条时可展开查看全部返回记录,确保同一 run 的六类语义事件不会因 UI 硬截断而无法检查。 - 2026-07-11 补充:开发单 Agent 聊天页继续使用整页纵向滚动,不把 Runtime 锁进固定视口;聊天消息区使用固定响应式高度并在内部滚动,避免历史消息持续撑高聊天面板。可选的 Runtime 恢复确认区始终占据独立布局行,不能与 Runtime 详情或聊天消息重叠。Runtime 状态面板支持折叠详情,折叠时只卸载目标、计划、事件、动作和任务等详情 DOM,仍保留状态标题与取消、重试、确认、拒绝、刷新操作;等待 LLM 时在消息区持续显示连接 / 等待首包 / 接收中的动态状态和“请求仍在进行中”提示。流式聊天的连续 delta 通过 `requestAnimationFrame` 合并为每帧最多一次消息更新,delta 不重复提交未变化的 Runtime state;OpenAI Chat SSE 的空数组或 `null` `choices` 心跳 / 元数据事件会跳过,usage-only 尾包会回填最终 token usage,finish-only 事件会把结束原因送入状态流,上游 error 保留真实消息,`[DONE]` 立即结束读取;正文与 finish reason 已接收后即使尾包异常也保存完整正文,不再改判整轮失败。持久事件订阅失败时显示非致命 Runtime 错误,聊天事件监听不可用或流式请求在首个文本片段前失败时自动降级普通回复。 -- 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 终态并广播事件。缺失、损坏、旧版本、验证未通过或 revision 漂移都必须失败关闭,不能保存最终 assistant 或产生 completed;对话写入失败时本轮同样进入 failed。前端只对当前项目、Agent、Session 和 runId 匹配的终态事件自动重读对话,直到看到新 assistant 消息或重试结束,切换 Session 后旧 run 不得污染当前聊天记录。 +- 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 终态并广播事件。每次可形成最终回复的 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。前端只对当前项目、Agent、Session 和 runId 匹配的终态事件自动重读对话,直到看到新 assistant 消息或重试结束,切换 Session 后旧 run 不得污染当前聊天记录。 - 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-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 轮”的描述不再有效。 -- 2026-07-12 补充并冻结:后台 Agent 每个 run 的可恢复 planning 上下文通过临时文件替换原子写入 `.agent/runtime/context-bundles//.json`,绑定 Agent、Task、Session、Run、任务正文和 revision / verification gate 关联,schema 固定升级为 `game-creator-runtime-context-bundle.v2`;保存 `nextLoopIndex`、当前窗口、计划、fallback response、压缩后的 observation 与上一窗口指纹。单文件最多 64 KiB、最多 12 条 observation;写入前统一截断并过滤敏感内容和项目绝对路径,安全校验失败时拒绝落盘。读取时要求普通文件并校验 schema、Agent、Session、Run、任务正文、observation 数量和 revision / gate 关联,身份不一致时拒绝续跑。v1 context bundle 恢复必须失败关闭,不自动迁移,也不能把缺失 gate 当成 `requiresVerification=false`;revision 与验证资格仍以锁内重读的独立持久化文件为准,bundle 只保存恢复上下文。该文件属于 Runtime 私有控制面,不等同于根级 `.agent/context.bundle.json`,不得由通用文件工具暴露。 +- 2026-07-12 补充并冻结:后台 Agent 每个 run 的可恢复 planning 上下文通过临时文件替换原子写入 `.agent/runtime/context-bundles//.json`,绑定 Agent、Task、Session、Run、任务正文和 revision / verification gate 关联,schema 固定升级为 `game-creator-runtime-context-bundle.v2`;保存 `nextLoopIndex`、当前窗口、计划、fallback response、压缩后的 observation、上一窗口指纹和 `contextStalled`。stale continuation 必须清空旧 actions 与 fallback response,保留 blocker、loop 位置和窗口进度;`contextStalled` 一旦在窗口边界成立,同 run 重规划和进程重启都不得清除。`runtime.verification` 的上下文指纹忽略动态 revision 数值前缀,仅保留稳定处置指引;成功 `project.verify / game.static_smoke` 的动态命令输出不进入窗口指纹。revision 数字或时间戳持续变化本身不算独立进展,重复 stale 最迟在相邻窗口指纹重复时以 `loop-budget-exhausted` 终止。单文件最多 64 KiB、最多 12 条 observation;写入前统一截断并过滤敏感内容和项目绝对路径,安全校验失败时拒绝落盘。读取时要求普通文件并校验 schema、Agent、Session、Run、任务正文、observation 数量和 revision / gate 关联,身份不一致时拒绝续跑。v1 context bundle 恢复必须失败关闭,不自动迁移,也不能把缺失 gate 当成 `requiresVerification=false`;revision 与验证资格仍以锁内重读的独立持久化文件为准,bundle 只保存恢复上下文。该文件属于 Runtime 私有控制面,不等同于根级 `.agent/context.bundle.json`,不得由通用文件工具暴露。 - 2026-07-11 调整:后台任务的可执行正文上限统一为 4,000 字符。入队 JSONL、启动后的 `currentTask/currentGoal`、planning prompt、待确认动作 task context、确认续跑和重启恢复都保留同一份正文;对话仍保存用户原始消息。状态事件、列表卡片和 `agent.db` 摘要可继续使用较短安全预览,但不能再反向作为后续 LLM 执行输入。这样长任务末尾的验收标记和输出格式要求不会在队列边界被 180 字符截断。 - 2026-07-11 调整:后台 planning 不再复用普通聊天的 1,800 输出 token 上限,而是使用 4,000;最终回复使用 2,400。两类请求均设置 low reasoning effort / low text verbosity;OpenAI Responses 序列化为 `reasoning.effort=low`,OpenAI Chat Completions 序列化为可选 `reasoning_effort=low`。该设置用于避免推理模型把全部 completion 预算消耗在不可见 reasoning 后留下空 content,并继续叠加最多 3 次 EmptyResponse 重试。 - 2026-07-11 补充:后台单 Agent 的工具 planning 响应必须提供可反序列化为 `thinkingSummary / plan / actions / response` schema 的 JSON object。Runtime 从模型输出中解析首个完整对象,因此对象后的尾随说明可以忽略;只有普通文本、没有完整对象,或对象无法反序列化时都不构成有效工具计划。对于这两类无效输出,Runtime 最多追加 2 次自动格式修复请求,每次只把限长且经过统一敏感信息过滤的上一次输出作为修复上下文,并把修复尝试写入 `.agent/agent.db` 的 `agent.runtime.tool_plan.repair` 审计。修复预算耗尽后进入既有工具规划失败路径,不得把普通文本折算为空 actions + response,也不得因此进入 completed;最终回复阶段仍按其独立的普通文本契约处理。 @@ -463,6 +463,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;未修改项目的只读任务不强制验证。per-run context bundle 与 pending action 使用 v2,v1 恢复失败关闭;最终 assistant 和 completed 必须在项目写锁内重读 revision / gate 后依次落盘,文件回读、observation 或锁外旧快照都不能替代验证凭证。 +- 后台 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/manifest.json` 会记录当前 `preview` 状态和 `commandRuns` 受限命令运行结果,作为本地产物索引的最小真相源。 - 开发模式可通过本地项目文件面板执行 `file.list/read/write/delete`,普通用户界面不暴露文件面板。