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 50d88e804..611a602d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -1,7 +1,16 @@ use super::*; use sha2::{Digest, Sha256}; +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.v1"; +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"; +pub(crate) const AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION: &str = "sha256-serde-json-v1"; pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) { let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app); @@ -245,6 +254,51 @@ pub(crate) fn read_game_creator_agent_runtime_at( } }; normalize_game_creator_agent_runtime_state(&mut state, &agent_id); + if !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() { + match read_game_creator_agent_runtime_pending_tool_action( + root, + &agent_id, + &state.run_id, + ) { + Ok(pending) => { + state.pending_tool_action = if pending.status + == AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING + || state.phase == "needs-reconciliation" + { + Some(pending.summary()) + } else { + None + }; + } + Err(error) => { + state.pending_tool_action = None; + state.error = Some(sanitize_agent_runtime_text(&error, 500)); + } + } + } else if state.status == "waiting-for-confirmation" { + state.pending_tool_action = None; + state.error = + Some("待确认动作执行记录缺失;旧版本任务只能取消或重试,不能直接批准".to_string()); + } + } + if game_creator_agent_runtime_cancel_requested(root, &state) + && matches!( + state.status.as_str(), + "running" | "waiting-for-confirmation" + ) + && !matches!(state.phase.as_str(), "completed" | "cancelled") + { + state.status = "cancelling".to_string(); + state.phase = "cancelling".to_string(); + state.current_action = "正在取消 Agent 后台任务".to_string(); + state.waiting_on = "当前 LLM 或工具调用返回".to_string(); + state.next_step = "取消完成后可重试该任务或提交新任务".to_string(); + } let _ = refresh_game_creator_agent_runtime_tool_policy(root, &mut state); let recent_events = read_recent_game_creator_agent_runtime_events(&event_path)?; let task_snapshot = read_game_creator_agent_runtime_task_snapshot(&task_path)?; @@ -277,13 +331,24 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( validate_project_root(root)?; let mut resumed = Vec::new(); for agent_id in collect_game_creator_agent_runtime_agent_ids(root)? { - let Some(task) = read_recoverable_game_creator_agent_runtime_task(root, &agent_id)? else { - continue; - }; let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? else { continue; }; + let runtime_lock = match resume_game_creator_agent_pending_tool_action_at( + root, + &agent_id, + runtime_lock, + )? { + AgentRuntimePendingActionResume::Handled(result) => { + resumed.push(result); + continue; + } + AgentRuntimePendingActionResume::NotFound(runtime_lock) => runtime_lock, + }; + let Some(task) = read_recoverable_game_creator_agent_runtime_task(root, &agent_id)? else { + continue; + }; let source = if task.source.trim().is_empty() { "agent-background-task" } else { @@ -338,6 +403,134 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( Ok(resumed) } +enum AgentRuntimePendingActionResume { + NotFound(AgentRuntimeTaskLock), + Handled(AgentRuntimeResult), +} + +fn resume_game_creator_agent_pending_tool_action_at( + root: &Path, + agent_id: &str, + runtime_lock: AgentRuntimeTaskLock, +) -> Result { + let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; + 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() { + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); + } + let pending = + read_game_creator_agent_runtime_pending_tool_action(root, agent_id, &runtime.run_id)?; + if matches!(runtime.phase.as_str(), "completed" | "cancelled" | "failed") + && runtime.phase != "needs-reconciliation" + { + remove_game_creator_agent_runtime_pending_tool_action(root, agent_id, &runtime.run_id)?; + remove_game_creator_agent_runtime_confirmations(root, agent_id, &runtime.run_id)?; + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); + } + if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + "Agent 工具动作在上次进程中进入执行但未确认结果,Runtime 不会自动重放", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + if !matches!( + pending.status.as_str(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING + | AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED + ) { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + "Agent Runtime 待确认动作状态无法恢复", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + if game_creator_agent_runtime_cancel_requested(root, &runtime) { + mark_game_creator_agent_runtime_cancelled_at( + root, + &mut runtime, + "Agent 后台任务已按开发者请求取消", + Some("Runtime 恢复时发现尚未完成的取消请求。"), + )?; + return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); + } + runtime.pending_tool_action = Some(pending.summary()); + match pending.status.as_str() { + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING => { + runtime.status = "waiting-for-confirmation".to_string(); + runtime.phase = "waiting-for-confirmation".to_string(); + runtime.current_action = format!("等待确认工具 {}", pending.action.tool); + runtime.waiting_on = "开发者确认 Agent 工具动作".to_string(); + runtime.next_step = format!("确认或拒绝后继续 Agent 工具动作:{}", pending.action.tool); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; + write_game_creator_agent_runtime_state(root, &runtime)?; + append_game_creator_agent_runtime_event( + root, + &runtime, + "tool_confirmation.restored", + "waiting-for-confirmation", + "waiting-for-confirmation", + "Runtime 已恢复待确认动作,后续排队任务保持等待。", + Some(&pending.action_id), + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED => {} + _ => unreachable!("pending status was validated before resume"), + } + runtime.status = "running".to_string(); + runtime.phase = if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED { + "action".to_string() + } else { + "observation".to_string() + }; + runtime.current_action = if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED { + format!("恢复执行已确认工具 {}", pending.action.tool) + } else { + format!("恢复工具观察 {}", pending.action.tool) + }; + runtime.waiting_on = "恢复同一 run 的待确认动作".to_string(); + runtime.next_step = "恢复精确动作或已持久化观察后继续规划".to_string(); + runtime.error = None; + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?; + write_game_creator_agent_runtime_state(root, &runtime)?; + append_game_creator_agent_runtime_event( + root, + &runtime, + "tool_confirmation.resume", + "running", + runtime.phase.as_str(), + "Runtime 正在同一 run 恢复待确认动作。", + Some(&pending.action_id), + )?; + let result = read_game_creator_agent_runtime_at(root, agent_id)?; + let root = root.to_path_buf(); + let agent_id = agent_id.to_string(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + continue_game_creator_agent_pending_tool_action(root, agent_id, pending, runtime).await; + }); + Ok(AgentRuntimePendingActionResume::Handled(result)) +} + fn collect_game_creator_agent_runtime_agent_ids( root: &Path, ) -> Result, String> { @@ -376,20 +569,6 @@ fn start_game_creator_agent_background_task_with_run_id_at( agent_id: &str, task: &str, run_id: &str, -) -> Result<(AgentRuntimeResult, String), String> { - start_game_creator_agent_background_task_with_confirmed_tool_at( - root, agent_id, task, run_id, None, None, "", - ) -} - -fn start_game_creator_agent_background_task_with_confirmed_tool_at( - root: &Path, - agent_id: &str, - task: &str, - run_id: &str, - confirmed_command_id: Option<&str>, - confirmed_action_fingerprint: Option<&str>, - confirmation_note: &str, ) -> Result<(AgentRuntimeResult, String), String> { start_game_creator_agent_background_task_with_source_at( root, @@ -397,9 +576,6 @@ fn start_game_creator_agent_background_task_with_confirmed_tool_at( task, run_id, "agent-background-task", - confirmed_command_id, - confirmed_action_fingerprint, - confirmation_note, ) } @@ -409,9 +585,6 @@ fn start_game_creator_agent_background_task_with_source_at( task: &str, run_id: &str, source: &str, - confirmed_command_id: Option<&str>, - confirmed_action_fingerprint: Option<&str>, - confirmation_note: &str, ) -> Result<(AgentRuntimeResult, String), String> { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; @@ -450,18 +623,14 @@ fn start_game_creator_agent_background_task_with_source_at( "task": pending_task.task, }), )?; - if let Some(command_id) = confirmed_command_id { - let action_fingerprint = confirmed_action_fingerprint - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "Agent Runtime 工具确认缺少动作指纹".to_string())?; - write_game_creator_agent_runtime_tool_confirmation( - root, - &agent_id, - &run_id, - command_id, - action_fingerprint, - confirmation_note, - )?; + if let Ok(result) = read_game_creator_agent_runtime_at(root, &agent_id) { + if matches!( + result.state.status.as_str(), + "waiting-for-confirmation" | "cancelling" + ) { + emit_game_creator_agent_runtime_update(root, &agent_id); + return Ok((result, run_id)); + } } let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? else { @@ -529,9 +698,6 @@ pub(crate) fn schedule_game_creator_agent_ready_tasks_at( &task_text, &run_id, "agent-ready-task-scheduler", - None, - None, - "", ) { Ok((result, actual_run_id)) => { append_agent_db_record( @@ -617,30 +783,11 @@ pub(crate) fn cancel_game_creator_agent_runtime_task_at( if target_run_id.trim().is_empty() { return Err("Agent Runtime runId 不能为空".to_string()); } - let task = - read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &target_run_id)? - .or_else(|| { - if current_result.state.run_id == target_run_id { - Some(AgentRuntimeTaskRecord { - schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), - agent_id: current_result.state.agent_id.clone(), - task_id: current_result.state.task_id.clone(), - session_id: current_result.state.session_id.clone(), - run_id: current_result.state.run_id.clone(), - source: current_result.state.source.clone(), - task: current_result.state.current_task.clone(), - status: game_creator_agent_runtime_task_status(¤t_result.state), - phase: current_result.state.phase.clone(), - current_action: current_result.state.current_action.clone(), - error: current_result.state.error.clone(), - updated_at: current_result.state.updated_at, - }) - } else { - None - } - }) - .ok_or_else(|| format!("未找到 Agent Runtime 任务:{target_run_id}"))?; - if matches!(task.status.as_str(), "completed" | "failed" | "cancelled") { + let (_, task, has_pending_action) = + resolve_game_creator_agent_runtime_cancel_target(root, &agent_id, &target_run_id)?; + if matches!(task.status.as_str(), "completed" | "cancelled") + || (task.status == "failed" && !has_pending_action) + { return Err(format!( "Agent Runtime 任务已结束,不能取消:{target_run_id}" )); @@ -651,6 +798,53 @@ pub(crate) fn cancel_game_creator_agent_runtime_task_at( &target_run_id, "开发者取消后台任务", )?; + + let Some(runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock_with_wait(root, &agent_id)? + else { + // The active worker owns the state transition; the tombstone is its cancellation signal. + let (current_result, task, has_pending_action) = + resolve_game_creator_agent_runtime_cancel_target(root, &agent_id, &target_run_id)?; + if task.status == "cancelled" { + return Ok(current_result); + } + if task.status == "completed" || (task.status == "failed" && !has_pending_action) { + remove_game_creator_agent_runtime_cancel_request(root, &agent_id, &target_run_id); + return Err(format!( + "Agent Runtime 任务已结束,不能取消:{target_run_id}" + )); + } + if current_result.state.run_id != target_run_id { + // A queued run can be cancelled without replacing the active run's public state. + append_game_creator_agent_runtime_queued_cancellation( + root, + &agent_id, + &task, + "开发者已取消排队后台任务", + )?; + spawn_next_game_creator_agent_background_task_drain(root, &agent_id)?; + return read_game_creator_agent_runtime_at(root, &agent_id); + } + emit_game_creator_agent_runtime_update(root, &agent_id); + return read_game_creator_agent_runtime_at(root, &agent_id); + }; + + let (current_result, task, has_pending_action) = + resolve_game_creator_agent_runtime_cancel_target(root, &agent_id, &target_run_id)?; + if task.status == "cancelled" { + spawn_next_game_creator_agent_background_task_drain_with_lock( + root, + &agent_id, + runtime_lock, + ); + return read_game_creator_agent_runtime_at(root, &agent_id); + } + if task.status == "completed" || (task.status == "failed" && !has_pending_action) { + remove_game_creator_agent_runtime_cancel_request(root, &agent_id, &target_run_id); + return Err(format!( + "Agent Runtime 任务已结束,不能取消:{target_run_id}" + )); + } if current_result.state.run_id == target_run_id { let mut state = current_result.state; mark_game_creator_agent_runtime_cancelled_at( @@ -660,49 +854,97 @@ pub(crate) fn cancel_game_creator_agent_runtime_task_at( Some(&task.task), )?; } else { - let cancelled_task = append_game_creator_agent_runtime_cancelled_task_record( + append_game_creator_agent_runtime_queued_cancellation( root, + &agent_id, &task, "开发者已取消排队后台任务", )?; - let mut event_state = - default_game_creator_agent_runtime_state(&agent_id, &cancelled_task.run_id); - event_state.source = cancelled_task.source.clone(); - event_state.current_task = cancelled_task.task.clone(); - event_state.current_goal = cancelled_task.task.clone(); - event_state.status = "cancelled".to_string(); - event_state.phase = "cancelled".to_string(); - event_state.current_action = cancelled_task.current_action.clone(); - event_state.waiting_on = "开发者下一轮输入".to_string(); - event_state.next_step = "可重试该后台任务或提交新任务".to_string(); - event_state.updated_at = cancelled_task.updated_at; - let _ = append_game_creator_agent_runtime_event( - root, - &event_state, - "turn.cancelled", - "cancelled", - "cancelled", - "开发者已取消排队后台任务", - Some(&cancelled_task.task), - ); - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.background_task.cancelled", - "agentId": task.agent_id, - "taskId": task.task_id, - "sessionId": task.session_id, - "runId": task.run_id, - "source": task.source, - "task": task.task, - "summary": "开发者已取消排队后台任务", - }), - )?; - emit_game_creator_agent_runtime_update(root, &agent_id); } + spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock); read_game_creator_agent_runtime_at(root, &agent_id) } +fn resolve_game_creator_agent_runtime_cancel_target( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(AgentRuntimeResult, AgentRuntimeTaskRecord, bool), String> { + let current_result = read_game_creator_agent_runtime_at(root, agent_id)?; + let task = read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + .or_else(|| { + if current_result.state.run_id == run_id { + Some(AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: current_result.state.agent_id.clone(), + task_id: current_result.state.task_id.clone(), + session_id: current_result.state.session_id.clone(), + run_id: current_result.state.run_id.clone(), + source: current_result.state.source.clone(), + task: current_result.state.current_task.clone(), + status: game_creator_agent_runtime_task_status(¤t_result.state), + phase: current_result.state.phase.clone(), + current_action: current_result.state.current_action.clone(), + error: current_result.state.error.clone(), + updated_at: current_result.state.updated_at, + }) + } else { + None + } + }) + .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(); + Ok((current_result, task, has_pending_action)) +} + +fn append_game_creator_agent_runtime_queued_cancellation( + root: &Path, + agent_id: &str, + task: &AgentRuntimeTaskRecord, + summary: &str, +) -> Result<(), String> { + let cancelled_task = + append_game_creator_agent_runtime_cancelled_task_record(root, task, summary)?; + let mut event_state = + default_game_creator_agent_runtime_state(agent_id, &cancelled_task.run_id); + event_state.source = cancelled_task.source.clone(); + event_state.current_task = cancelled_task.task.clone(); + event_state.current_goal = cancelled_task.task.clone(); + event_state.status = "cancelled".to_string(); + event_state.phase = "cancelled".to_string(); + event_state.current_action = cancelled_task.current_action.clone(); + event_state.waiting_on = "开发者下一轮输入".to_string(); + event_state.next_step = "可重试该后台任务或提交新任务".to_string(); + event_state.updated_at = cancelled_task.updated_at; + let _ = append_game_creator_agent_runtime_event( + root, + &event_state, + "turn.cancelled", + "cancelled", + "cancelled", + summary, + Some(&cancelled_task.task), + ); + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.cancelled", + "agentId": task.agent_id.clone(), + "taskId": task.task_id.clone(), + "sessionId": task.session_id.clone(), + "runId": task.run_id.clone(), + "source": task.source.clone(), + "task": task.task.clone(), + "summary": summary, + }), + )?; + remove_game_creator_agent_runtime_pending_tool_action(root, agent_id, &task.run_id)?; + remove_game_creator_agent_runtime_confirmations(root, agent_id, &task.run_id)?; + emit_game_creator_agent_runtime_update(root, agent_id); + Ok(()) +} + pub(crate) fn retry_game_creator_agent_runtime_task_at( root: &Path, agent_id: &str, @@ -718,6 +960,10 @@ pub(crate) fn retry_game_creator_agent_runtime_task_at( let task = 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 game_creator_agent_runtime_pending_tool_action_path(root, &agent_id, &target_run_id).exists() + { + return Err("Agent Runtime 仍保留待核对工具动作,请先核对项目状态并取消原任务".to_string()); + } if matches!( task.status.as_str(), "pending" | "running" | "waiting-for-confirmation" @@ -754,22 +1000,188 @@ pub(crate) fn confirm_game_creator_agent_runtime_task_at( root: &Path, agent_id: &str, run_id: &str, - next_run_id: &str, + action_id: &str, note: &str, ) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + validate_project_root(root)?; + let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, &agent_id)?; + let (agent_id, task, mut runtime, mut pending_action) = + resolve_game_creator_agent_runtime_pending_tool_action(root, &agent_id, run_id, action_id)?; + let command_id = game_creator_agent_runtime_tool_command_id(&pending_action.action.tool) + .ok_or_else(|| format!("待确认工具不在白名单中:{}", pending_action.action.tool))?; + let note = sanitize_agent_runtime_text(note, 240); + write_game_creator_agent_runtime_tool_confirmation( + root, + &agent_id, + &pending_action.run_id, + command_id, + &pending_action.action_fingerprint, + ¬e, + )?; + pending_action.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); + pending_action.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_pending_tool_action(root, &pending_action)?; + runtime.status = "running".to_string(); + runtime.phase = "action".to_string(); + runtime.current_action = format!("执行已确认工具 {}", pending_action.action.tool); + 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) + .and_then(|_| refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)) + .and_then(|_| write_game_creator_agent_runtime_state(root, &runtime)) + .and_then(|_| { + append_game_creator_agent_runtime_event( + root, + &runtime, + "tool_confirmation.approved", + "running", + "action", + "开发者已批准精确工具动作,Runtime 将直接执行原 action。", + pending_action.input_summary.as_deref(), + ) + }) + .and_then(|_| { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.tool_confirmation.approved", + "agentId": task.agent_id, + "taskId": task.task_id, + "sessionId": task.session_id, + "runId": task.run_id, + "confirmedRunId": pending_action.run_id, + "actionId": pending_action.action_id, + "tool": pending_action.action.tool, + "commandId": command_id, + "actionFingerprint": pending_action.action_fingerprint, + "inputSummary": pending_action.input_summary, + "note": note, + }), + ) + }); + 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 { + let _runtime_lock = runtime_lock; + continue_game_creator_agent_pending_tool_action( + root, + background_agent_id, + pending_action, + runtime, + ) + .await; + }); + transition_result?; + result +} + +pub(crate) fn reject_game_creator_agent_runtime_task_at( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: &str, + note: &str, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + validate_project_root(root)?; + let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, &agent_id)?; + let (agent_id, task, mut runtime, mut pending_action) = + resolve_game_creator_agent_runtime_pending_tool_action(root, &agent_id, run_id, action_id)?; + let note = sanitize_agent_runtime_text(note, 240); + pending_action.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string(); + pending_action.observation = Some(AgentRuntimeToolObservation { + tool: pending_action.action.tool.clone(), + status: "blocked".to_string(), + summary: "开发者拒绝待确认工具动作".to_string(), + detail: (!note.trim().is_empty()).then_some(note.clone()), + }); + pending_action.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_pending_tool_action(root, &pending_action)?; + runtime.status = "running".to_string(); + runtime.phase = "observation".to_string(); + runtime.current_action = format!("开发者拒绝工具 {}", pending_action.action.tool); + 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) + .and_then(|_| refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)) + .and_then(|_| write_game_creator_agent_runtime_state(root, &runtime)) + .and_then(|_| { + append_game_creator_agent_runtime_event( + root, + &runtime, + "tool_confirmation.rejected", + "running", + "observation", + "开发者已拒绝待确认工具动作,Runtime 将把拒绝结果交回 Agent。", + pending_action.input_summary.as_deref(), + ) + }) + .and_then(|_| { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.tool_confirmation.rejected", + "agentId": task.agent_id, + "taskId": task.task_id, + "sessionId": task.session_id, + "runId": task.run_id, + "actionId": pending_action.action_id, + "tool": pending_action.action.tool, + "actionFingerprint": pending_action.action_fingerprint, + "inputSummary": pending_action.input_summary, + "note": note, + }), + ) + }); + 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 { + let _runtime_lock = runtime_lock; + continue_game_creator_agent_pending_tool_action( + root, + background_agent_id, + pending_action, + runtime, + ) + .await; + }); + transition_result?; + result +} + +fn resolve_game_creator_agent_runtime_pending_tool_action( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: &str, +) -> Result< + ( + String, + AgentRuntimeTaskRecord, + AgentRuntimeState, + AgentRuntimePendingToolAction, + ), + String, +> { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; validate_project_root(root)?; if run_id.trim().is_empty() { return Err("Agent Runtime runId 不能为空".to_string()); } + if action_id.trim().is_empty() { + return Err("Agent Runtime actionId 不能为空".to_string()); + } let target_run_id = normalize_game_creator_agent_runtime_run_id(&agent_id, run_id); let task = 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.status != "waiting-for-confirmation" { - return Err(format!( - "Agent Runtime 任务不在待确认状态,不能确认继续:{target_run_id}" - )); + return Err(format!("Agent Runtime 任务不在待确认状态:{target_run_id}")); } let runtime = read_game_creator_agent_runtime_at(root, &agent_id)?.state; if runtime.run_id != target_run_id || runtime.status != "waiting-for-confirmation" { @@ -777,82 +1189,290 @@ pub(crate) fn confirm_game_creator_agent_runtime_task_at( "Agent Runtime 当前状态不是该待确认 run:{target_run_id}" )); } - let tool_call = runtime - .recent_tool_calls - .iter() - .rev() - .find(|call| call.status == "waiting-for-confirmation") - .ok_or_else(|| "未找到待确认工具动作".to_string())?; - let command_id = game_creator_agent_runtime_tool_command_id(&tool_call.tool) - .ok_or_else(|| format!("待确认工具不在白名单中:{}", tool_call.tool))?; - let action_fingerprint = tool_call - .action_fingerprint - .as_deref() - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "待确认工具动作缺少精确指纹,请重新提交该任务".to_string())?; - let input_summary = tool_call.input_summary.clone(); - let note = sanitize_agent_runtime_text(note, 240); - let confirmed_task = if note.trim().is_empty() { - format!( - "继续已确认的后台任务:{}\n\n开发者已确认工具动作:{}。", - task.task, tool_call.tool - ) - } else { - format!( - "继续已确认的后台任务:{}\n\n开发者已确认工具动作:{}。确认说明:{}", - task.task, tool_call.tool, note - ) + let pending = + read_game_creator_agent_runtime_pending_tool_action(root, &agent_id, &target_run_id)?; + validate_agent_runtime_pending_tool_action_content(root, &pending.action, &pending.task)?; + if pending.status != AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING { + return Err("Agent Runtime 待确认动作已处理,请刷新状态".to_string()); + } + let action_fingerprint = agent_runtime_tool_action_fingerprint(&pending.action, &pending.task); + let expected_action_id = agent_runtime_tool_action_id( + &pending.run_id, + pending.loop_iteration, + pending.action_index, + pending.occurrence_nonce, + &action_fingerprint, + ); + if pending.action_fingerprint != action_fingerprint + || pending.action_id != expected_action_id + || pending.action_id != action_id.trim() + { + return Err("Agent Runtime 待确认动作已变化,请刷新状态后重试".to_string()); + } + let runtime_pending = runtime + .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 + { + return Err("Agent Runtime 待确认动作摘要与执行记录不一致".to_string()); + } + Ok((agent_id, task, runtime, pending)) +} + +async fn continue_game_creator_agent_pending_tool_action( + root: PathBuf, + agent_id: String, + mut pending: AgentRuntimePendingToolAction, + mut runtime: AgentRuntimeState, +) { + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + drain_next_game_creator_agent_background_tasks(root, agent_id).await; + return; + } + let action = pending.action.clone(); + let approved = pending.approved(); + let observation = match pending.status.as_str() { + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED => { + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); + pending.updated_at = unix_timestamp(); + if let Err(error) = + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &error, + ); + return; + } + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + &agent_id, + &pending.run_id, + &pending.task, + &action, + ) + .await; + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED.to_string(); + pending.observation = Some(observation.clone()); + pending.updated_at = unix_timestamp(); + if let Err(error) = + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + { + let error = + format!("工具动作已返回结果,但 Runtime 无法持久化观察,需人工核对:{error}"); + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &error, + ); + return; + } + observation + } + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED => { + let Some(observation) = pending.observation.clone() else { + let error = "Agent Runtime 待恢复动作缺少已持久化观察"; + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + error, + ); + return; + }; + observation + } + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING => { + let error = + "Agent 工具动作执行结果未知,Runtime 已停止自动重放;请核对项目状态后取消该任务"; + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + error, + ); + return; + } + _ => { + let error = format!("Agent Runtime 待恢复动作状态无效:{}", pending.status); + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &error, + ); + return; + } }; - let requested_run_id = if next_run_id.trim().is_empty() { - format!("{target_run_id}-confirm-{}", unix_timestamp()) - } else { - next_run_id.trim().to_string() - }; - let (result, confirmed_run_id) = - start_game_creator_agent_background_task_with_confirmed_tool_at( - root, - &agent_id, - &confirmed_task, - &requested_run_id, - Some(command_id), - Some(action_fingerprint), - ¬e, - )?; - append_game_creator_agent_runtime_task_record( - root, - &AgentRuntimeTaskRecord { - schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), - agent_id: task.agent_id.clone(), - task_id: task.task_id.clone(), - session_id: task.session_id.clone(), - run_id: task.run_id.clone(), - source: task.source.clone(), - task: task.task.clone(), - status: "completed".to_string(), - phase: "confirmed".to_string(), - current_action: format!("已确认 {},继续 run {}", tool_call.tool, confirmed_run_id), - error: None, - updated_at: unix_timestamp(), + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + drain_next_game_creator_agent_background_tasks(root, agent_id).await; + return; + } + if observation.is_waiting_for_confirmation() { + let error = "已批准的精确工具动作未通过确认 gate"; + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + error, + ); + return; + } + let observation_summary = observation.summary(); + runtime.observations.push(observation_summary.clone()); + append_agent_runtime_tool_call_record( + &root, + &mut runtime, + &pending.task, + &action, + &observation, + ); + complete_agent_runtime_active_plan_step( + &mut runtime, + if observation.status == "ok" { + "completed" + } else { + "failed" }, + &observation_summary, + ); + runtime.status = "running".to_string(); + runtime.phase = "observation".to_string(); + runtime.current_action = if approved { + format!("已执行确认工具 {}", observation.tool) + } else { + format!("已拒绝工具 {}", observation.tool) + }; + runtime.waiting_on = "Agent 根据工具观察修正计划".to_string(); + runtime.next_step = "把工具观察交给 Agent 修正计划".to_string(); + runtime.pending_tool_action = Some(pending.summary()); + runtime.updated_at = unix_timestamp(); + let persisted = 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(|_| { + append_game_creator_agent_runtime_event( + &root, + &runtime, + "observation", + "running", + "observation", + &observation_summary, + observation.detail.as_deref(), + ) + }) + .and_then(|_| { + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "runId": runtime.run_id, + "tool": observation.tool, + "status": observation.status, + "summary": observation.summary, + "actionId": pending.action_id, + "decision": if approved { "approved" } else { "rejected" }, + }), + ) + }); + if let Err(error) = persisted { + let error = format!("持久化已完成工具动作的观察失败:{error}"); + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &error, + ); + return; + } + if let Some(command_id) = game_creator_agent_runtime_tool_command_id(&action.tool) { + let _ = fs::remove_file(game_creator_agent_runtime_tool_confirmation_path( + &root, + &agent_id, + &pending.run_id, + command_id, + )); + } + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + "tool_confirmation.continued", + "running", + "observation", + "待确认动作观察已持久化,Agent 将在同一 run 继续规划。", + Some(&pending.action_id), + ); + let mut observations = pending.observations.clone(); + observations.push(observation); + let outcome = run_game_creator_agent_background_task_with_context( + root.clone(), + agent_id.clone(), + pending.task.clone(), + runtime, + pending.tool_plan(), + observations, + usize::try_from(pending.loop_iteration) + .unwrap_or(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT) + .min(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT), + ) + .await; + if outcome == AgentBackgroundTaskOutcome::Finished { + drain_next_game_creator_agent_background_tasks(root, agent_id).await; + } +} + +fn mark_game_creator_agent_runtime_needs_reconciliation_at( + root: &Path, + runtime: &mut AgentRuntimeState, + pending: &AgentRuntimePendingToolAction, + error: &str, +) -> Result<(), String> { + runtime.status = "failed".to_string(); + runtime.phase = "needs-reconciliation".to_string(); + runtime.current_action = "工具动作结果需要人工核对".to_string(); + runtime.waiting_on = "开发者核对项目副作用".to_string(); + runtime.next_step = "核对项目状态后取消该任务,再决定是否重新投递".to_string(); + runtime.pending_tool_action = Some(pending.summary()); + runtime.error = Some(sanitize_agent_runtime_text(error, 500)); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, runtime)?; + write_game_creator_agent_runtime_state(root, runtime)?; + append_game_creator_agent_runtime_event( + root, + runtime, + "tool_confirmation.needs_reconciliation", + "failed", + "needs-reconciliation", + "Runtime 无法证明待确认工具动作是否完整落盘,已停止自动重放。", + runtime.error.as_deref(), )?; append_agent_db_record( root, serde_json::json!({ - "recordType": "agent.runtime.tool_confirmation.approved", - "agentId": task.agent_id, - "taskId": task.task_id, - "sessionId": task.session_id, - "runId": task.run_id, - "confirmedRunId": confirmed_run_id, - "tool": tool_call.tool, - "commandId": command_id, - "actionFingerprint": action_fingerprint, - "inputSummary": input_summary, - "note": note, + "recordType": "agent.runtime.tool_confirmation.needs_reconciliation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "tool": pending.action.tool, + "pendingStatus": pending.status, + "error": runtime.error, }), )?; - emit_game_creator_agent_runtime_update(root, &agent_id); - read_game_creator_agent_runtime_at(root, &agent_id).or(Ok(result)) + emit_game_creator_agent_runtime_update(root, &runtime.agent_id); + Ok(()) } fn game_creator_agent_background_task_default_plan() -> Vec { @@ -901,6 +1521,10 @@ async fn drain_game_creator_agent_background_tasks( { return; } + drain_next_game_creator_agent_background_tasks(root, agent_id).await; +} + +async fn drain_next_game_creator_agent_background_tasks(root: PathBuf, agent_id: String) { loop { let Some(next_task) = read_next_pending_game_creator_agent_runtime_task(&root, &agent_id) .ok() @@ -940,22 +1564,68 @@ async fn drain_game_creator_agent_background_tasks( } } +fn spawn_next_game_creator_agent_background_task_drain( + root: &Path, + agent_id: &str, +) -> Result<(), String> { + let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, agent_id)? + else { + return Ok(()); + }; + spawn_next_game_creator_agent_background_task_drain_with_lock(root, agent_id, runtime_lock); + Ok(()) +} + +fn spawn_next_game_creator_agent_background_task_drain_with_lock( + root: &Path, + agent_id: &str, + runtime_lock: AgentRuntimeTaskLock, +) { + let root = root.to_path_buf(); + let agent_id = agent_id.to_string(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + drain_next_game_creator_agent_background_tasks(root, agent_id).await; + }); +} + async fn run_game_creator_agent_background_task( root: PathBuf, agent_id: String, task: String, state: AgentRuntimeState, +) -> AgentBackgroundTaskOutcome { + run_game_creator_agent_background_task_with_context( + root, + agent_id, + task, + state, + AgentRuntimeToolPlan::default(), + Vec::new(), + 0, + ) + .await +} + +async fn run_game_creator_agent_background_task_with_context( + root: PathBuf, + agent_id: String, + task: String, + state: AgentRuntimeState, + initial_plan: AgentRuntimeToolPlan, + initial_observations: Vec, + start_loop_index: usize, ) -> AgentBackgroundTaskOutcome { let mut runtime = state; - let mut plan = AgentRuntimeToolPlan::default(); - let mut observations = Vec::new(); + let mut plan = initial_plan; + let mut observations = initial_observations; let mut final_reply = None; if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } - for loop_index in 0..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + for loop_index in start_loop_index..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { runtime.loop_iteration = (loop_index + 1) as u32; runtime.max_loop_iterations = AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32; runtime.tool_action_budget = AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32; @@ -989,6 +1659,9 @@ async fn run_game_creator_agent_background_task( { Ok(plan) => plan, Err(error) => { + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); let _ = append_local_conversation_message_at( @@ -1121,8 +1794,64 @@ async fn run_game_creator_agent_background_task( } let observation_summary = observation.summary(); runtime.observations.push(observation_summary.clone()); - append_agent_runtime_tool_call_record(&root, &mut runtime, action, &observation); + append_agent_runtime_tool_call_record(&root, &mut runtime, &task, action, &observation); if observation.is_waiting_for_confirmation() { + let action_fingerprint = agent_runtime_tool_action_fingerprint(action, &task); + let occurrence_nonce = unix_timestamp_nanos().min(u128::from(u64::MAX)) as u64; + let action_index = u32::try_from(action_index).unwrap_or(u32::MAX); + let action_id = agent_runtime_tool_action_id( + &runtime.run_id, + runtime.loop_iteration, + action_index, + occurrence_nonce, + &action_fingerprint, + ); + let now = unix_timestamp(); + let pending_action = AgentRuntimePendingToolAction { + schema_version: AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION.to_string(), + fingerprint_version: AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION.to_string(), + agent_id: runtime.agent_id.clone(), + task_id: runtime.task_id.clone(), + session_id: runtime.session_id.clone(), + run_id: runtime.run_id.clone(), + source: runtime.source.clone(), + task: runtime.current_task.clone(), + loop_iteration: runtime.loop_iteration, + action_index, + occurrence_nonce, + thinking_summary: sanitize_agent_runtime_text(&plan.thinking_summary, 240), + plan: plan + .plan + .iter() + .map(|item| sanitize_agent_runtime_text(item, 180)) + .collect(), + fallback_response: sanitize_agent_runtime_text(&plan.response, 1_200), + observations: observations.clone(), + action: action.clone(), + action_id, + action_fingerprint, + input_summary: agent_runtime_tool_action_input_summary(&root, action), + status: AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING.to_string(), + observation: None, + created_at: now, + updated_at: now, + }; + if let Err(error) = + write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) + { + let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); + let _ = append_local_conversation_message_at( + &root, + Some(&agent_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: format!("后台任务失败:{error}"), + agent_id: None, + }, + ); + return AgentBackgroundTaskOutcome::Finished; + } + runtime.pending_tool_action = Some(pending_action.summary()); runtime.status = "waiting-for-confirmation".to_string(); runtime.phase = "waiting-for-confirmation".to_string(); runtime.current_action = format!("等待确认工具 {}", observation.tool); @@ -1135,6 +1864,7 @@ async fn run_game_creator_agent_background_task( &observation_summary, ); } else { + runtime.pending_tool_action = None; complete_agent_runtime_active_plan_step( &mut runtime, if observation.status == "ok" { @@ -1148,36 +1878,56 @@ async fn run_game_creator_agent_background_task( runtime.next_step = "把工具观察交给 Agent 修正计划".to_string(); } runtime.updated_at = unix_timestamp(); - let _ = append_game_creator_agent_runtime_task(&root, &runtime); - let _ = refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime); - let _ = write_game_creator_agent_runtime_state(&root, &runtime); - let _ = append_game_creator_agent_runtime_event( - &root, - &runtime, - "observation", - runtime.status.as_str(), + let persistence = 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(|_| { + append_game_creator_agent_runtime_event( + &root, + &runtime, + "observation", + runtime.status.as_str(), + if observation.is_waiting_for_confirmation() { + "waiting-for-confirmation" + } else { + "observation" + }, + observation_summary.as_str(), + observation.detail.as_deref(), + ) + }) + .and_then(|_| { + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "runId": runtime.run_id, + "tool": observation.tool, + "status": observation.status, + "summary": observation.summary, + }), + ) + }); + if let Err(error) = persistence { if observation.is_waiting_for_confirmation() { - "waiting-for-confirmation" - } else { - "observation" - }, - observation_summary.as_str(), - observation.detail.as_deref(), - ); - let _ = append_agent_db_record( - &root, - serde_json::json!({ - "recordType": "agent.runtime.tool_observation", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "runId": runtime.run_id, - "tool": observation.tool, - "status": observation.status, - "summary": observation.summary, - }), - ); + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_confirmation.persistence_failed", + "agentId": runtime.agent_id, + "runId": runtime.run_id, + "error": sanitize_agent_runtime_text(&error, 240), + }), + ); + return AgentBackgroundTaskOutcome::WaitingForConfirmation; + } + let _ = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); + return AgentBackgroundTaskOutcome::Finished; + } if observation.is_waiting_for_confirmation() { - let _ = append_agent_db_record( + if let Err(error) = append_agent_db_record( &root, serde_json::json!({ "recordType": "agent.runtime.tool_confirmation_required", @@ -1185,11 +1935,22 @@ async fn run_game_creator_agent_background_task( "taskId": runtime.task_id, "runId": runtime.run_id, "tool": observation.tool, - "actionFingerprint": agent_runtime_tool_action_fingerprint(action), - "inputSummary": agent_runtime_tool_action_input_summary(&root, action), + "actionId": runtime.pending_tool_action.as_ref().map(|item| item.action_id.clone()), + "actionFingerprint": runtime.pending_tool_action.as_ref().map(|item| item.action_fingerprint.clone()), + "inputSummary": runtime.pending_tool_action.as_ref().and_then(|item| item.input_summary.clone()), "summary": observation.summary, }), - ); + ) { + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + "tool_confirmation.audit_failed", + "waiting-for-confirmation", + "waiting-for-confirmation", + "待确认动作已安全暂停,但审计记录写入失败。", + Some(&error), + ); + } return AgentBackgroundTaskOutcome::WaitingForConfirmation; } observations.push(observation); @@ -1227,15 +1988,18 @@ async fn run_game_creator_agent_background_task( if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } - match request_game_creator_agent_background_final_reply_at( + let final_reply_result = request_game_creator_agent_background_final_reply_at( &root, &agent_id, &task, &plan, &observations, ) - .await - { + .await; + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } + match final_reply_result { Ok(reply) => reply, Err(_) if !plan.response.trim().is_empty() => plan.response.clone(), Err(error) => { @@ -1376,7 +2140,7 @@ pub(crate) struct AgentRuntimeToolAction { pub(crate) input: serde_json::Value, } -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AgentRuntimeToolObservation { pub(crate) tool: String, @@ -1385,6 +2149,70 @@ pub(crate) struct AgentRuntimeToolObservation { pub(crate) detail: Option, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentRuntimePendingToolAction { + pub(crate) schema_version: String, + pub(crate) fingerprint_version: String, + pub(crate) agent_id: String, + pub(crate) task_id: String, + pub(crate) session_id: String, + pub(crate) run_id: String, + pub(crate) source: String, + pub(crate) task: String, + pub(crate) loop_iteration: u32, + pub(crate) action_index: u32, + pub(crate) occurrence_nonce: u64, + pub(crate) thinking_summary: String, + pub(crate) plan: Vec, + pub(crate) fallback_response: String, + pub(crate) observations: Vec, + pub(crate) action: AgentRuntimeToolAction, + pub(crate) action_id: String, + pub(crate) action_fingerprint: String, + pub(crate) input_summary: Option, + pub(crate) status: String, + pub(crate) observation: Option, + pub(crate) created_at: u64, + pub(crate) updated_at: u64, +} + +impl AgentRuntimePendingToolAction { + pub(crate) fn summary(&self) -> AgentRuntimePendingToolActionSummary { + AgentRuntimePendingToolActionSummary { + action_id: self.action_id.clone(), + action_fingerprint: self.action_fingerprint.clone(), + tool: self.action.tool.clone(), + input_summary: self.input_summary.clone(), + reason: self + .action + .reason + .as_deref() + .map(|value| sanitize_agent_runtime_text(value, 240)) + .filter(|value| !value.trim().is_empty()), + requested_at: self.created_at, + } + } + + fn tool_plan(&self) -> AgentRuntimeToolPlan { + AgentRuntimeToolPlan { + thinking_summary: self.thinking_summary.clone(), + plan: self.plan.clone(), + actions: Vec::new(), + response: self.fallback_response.clone(), + } + } + + fn approved(&self) -> bool { + matches!( + self.status.as_str(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + ) + } +} + impl AgentRuntimeToolObservation { fn summary(&self) -> String { format!("{}:{} · {}", self.tool, self.status, self.summary) @@ -1404,13 +2232,14 @@ enum AgentRuntimeToolPolicyBlock { fn append_agent_runtime_tool_call_record( root: &Path, runtime: &mut AgentRuntimeState, + task: &str, action: &AgentRuntimeToolAction, observation: &AgentRuntimeToolObservation, ) { runtime.recent_tool_calls.push(AgentRuntimeToolCallRecord { tool: observation.tool.clone(), status: observation.status.clone(), - action_fingerprint: Some(agent_runtime_tool_action_fingerprint(action)), + action_fingerprint: Some(agent_runtime_tool_action_fingerprint(action, task)), input_summary: agent_runtime_tool_action_input_summary(root, action), reason: action .reason @@ -1434,15 +2263,36 @@ fn append_agent_runtime_tool_call_record( } } -fn agent_runtime_tool_action_fingerprint(action: &AgentRuntimeToolAction) -> String { +pub(crate) fn agent_runtime_tool_action_fingerprint( + action: &AgentRuntimeToolAction, + task: &str, +) -> String { let payload = serde_json::json!({ "tool": action.tool.trim(), "input": &action.input, + "taskContext": task, }); let encoded = serde_json::to_vec(&payload).unwrap_or_default(); format!("{:x}", Sha256::digest(encoded)) } +pub(crate) fn agent_runtime_tool_action_id( + run_id: &str, + loop_iteration: u32, + action_index: u32, + occurrence_nonce: u64, + action_fingerprint: &str, +) -> String { + let encoded = format!( + "{run_id}\n{loop_iteration}\n{action_index}\n{occurrence_nonce}\n{action_fingerprint}" + ); + let occurrence_fingerprint = format!("{:x}", Sha256::digest(encoded.as_bytes())); + format!( + "action-{}", + occurrence_fingerprint.chars().take(24).collect::() + ) +} + fn agent_runtime_tool_action_input_summary( root: &Path, action: &AgentRuntimeToolAction, @@ -1829,7 +2679,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action( action: &AgentRuntimeToolAction, ) -> AgentRuntimeToolObservation { let tool = action.tool.trim(); - let action_fingerprint = agent_runtime_tool_action_fingerprint(action); + let action_fingerprint = agent_runtime_tool_action_fingerprint(action, task); let command_id = game_creator_agent_runtime_tool_command_id(tool); if let Some(command_id) = command_id { if let Some(blocked) = game_creator_agent_runtime_tool_policy_block( @@ -1955,17 +2805,281 @@ fn game_creator_agent_runtime_tool_confirmation_path( agent_id: &str, run_id: &str, command_id: &str, +) -> PathBuf { + game_creator_agent_runtime_confirmation_dir(root, agent_id, run_id).join(format!( + "{}.json", + agent_runtime_confirmation_path_component(command_id, "command") + )) +} + +fn game_creator_agent_runtime_confirmation_dir( + root: &Path, + agent_id: &str, + run_id: &str, ) -> PathBuf { root.join(".agent/runtime/confirmations") .join(agent_runtime_confirmation_path_component(agent_id, "agent")) .join(agent_runtime_confirmation_path_component(run_id, "run")) +} + +fn game_creator_agent_runtime_pending_tool_action_path( + root: &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(command_id, "command") + agent_runtime_confirmation_path_component(run_id, "run") )) } -fn write_game_creator_agent_runtime_tool_confirmation( +fn validate_agent_runtime_pending_tool_action_content( + root: &Path, + action: &AgentRuntimeToolAction, + task: &str, +) -> Result<(), String> { + let content = serde_json::to_string(&serde_json::json!({ + "action": action, + "taskContext": task, + })) + .map_err(|error| format!("序列化待确认工具动作失败:{error}"))?; + validate_agent_runtime_pending_serialized_content(root, &content) +} + +fn validate_agent_runtime_pending_serialized_content( + root: &Path, + content: &str, +) -> Result<(), String> { + let lower = content.to_ascii_lowercase(); + let sensitive_rule = [ + ".env", + "game-creator.config", + "authorization:", + "cookie:", + "api_key", + "apikey", + "api key", + "token=", + "bearer ", + "tnr_sk_", + ] + .into_iter() + .position(|marker| lower.contains(marker)) + .or_else(|| agent_runtime_contains_secret_key_prefix(content, "sk-").then_some(10)); + if let Some(rule) = sensitive_rule { + return Err(format!( + "待确认工具输入命中敏感规则 #{rule},Runtime 已拒绝持久化" + )); + } + let root_display = root.to_string_lossy(); + if !root_display.is_empty() && content.contains(root_display.as_ref()) { + return Err("待确认工具输入包含项目绝对路径,Runtime 已拒绝持久化".to_string()); + } + if let Ok(canonical_root) = root.canonicalize() { + let canonical_display = canonical_root.to_string_lossy(); + if !canonical_display.is_empty() && content.contains(canonical_display.as_ref()) { + return Err("待确认工具输入包含项目绝对路径,Runtime 已拒绝持久化".to_string()); + } + } + Ok(()) +} + +pub(crate) fn agent_runtime_contains_secret_key_prefix(content: &str, prefix: &str) -> bool { + content.match_indices(prefix).any(|(index, _)| { + let starts_at_boundary = content[..index] + .chars() + .next_back() + .map(|character| !character.is_ascii_alphanumeric() && character != '_') + .unwrap_or(true); + let secret_length = content[index + prefix.len()..] + .chars() + .take_while(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) + .count(); + starts_at_boundary && secret_length >= 8 + }) +} + +pub(crate) fn write_game_creator_agent_runtime_pending_tool_action( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + validate_agent_runtime_pending_tool_action_record(root, pending)?; + let path = game_creator_agent_runtime_pending_tool_action_path( + root, + &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() + )) + } + } +} + +fn validate_agent_runtime_pending_tool_action_record( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + if pending.schema_version != AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION { + return Err(format!( + "不支持的 Agent Runtime 待确认动作版本:{}", + pending.schema_version + )); + } + if pending.fingerprint_version != AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION { + return Err(format!( + "不支持的 Agent Runtime 动作指纹版本:{}", + pending.fingerprint_version + )); + } + if !matches!( + pending.status.as_str(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING + | AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED + ) { + return Err(format!( + "Agent Runtime 待确认动作状态无效:{}", + pending.status + )); + } + let serialized = serde_json::to_string(pending) + .map_err(|error| format!("序列化 Agent Runtime 待确认动作失败:{error}"))?; + validate_agent_runtime_pending_serialized_content(root, &serialized)?; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&pending.action, &pending.task); + let action_id = agent_runtime_tool_action_id( + &pending.run_id, + pending.loop_iteration, + pending.action_index, + pending.occurrence_nonce, + &action_fingerprint, + ); + if pending.action_fingerprint != action_fingerprint || pending.action_id != action_id { + return Err("Agent Runtime 待确认动作已变化:指纹校验失败".to_string()); + } + if matches!( + pending.status.as_str(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED + ) && pending.observation.is_none() + { + return Err("Agent Runtime 已观察动作缺少观察结果".to_string()); + } + Ok(()) +} + +fn read_game_creator_agent_runtime_pending_tool_action( + root: &Path, + agent_id: &str, + 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| { + format!( + "读取 Agent Runtime 待确认动作失败:{}: {error}", + path.display() + ) + })?; + let pending = + serde_json::from_str::(&content).map_err(|error| { + format!( + "解析 Agent Runtime 待确认动作失败:{}: {error}", + path.display() + ) + })?; + if pending.agent_id != agent_id || pending.run_id != run_id { + return Err("Agent Runtime 待确认动作身份不匹配".to_string()); + } + validate_agent_runtime_pending_tool_action_record(root, &pending)?; + Ok(pending) +} + +fn remove_game_creator_agent_runtime_pending_tool_action( + root: &Path, + agent_id: &str, + 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(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "删除 Agent Runtime 待确认动作失败:{}: {error}", + path.display() + )), + } +} + +fn remove_game_creator_agent_runtime_confirmations( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(), String> { + let path = game_creator_agent_runtime_confirmation_dir(root, agent_id, run_id); + match fs::remove_dir_all(&path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "删除 Agent Runtime 工具确认目录失败:{}: {error}", + path.display() + )), + } +} + +pub(crate) fn write_game_creator_agent_runtime_tool_confirmation( root: &Path, agent_id: &str, run_id: &str, @@ -3931,6 +5045,7 @@ pub(crate) fn finish_game_creator_agent_runtime_turn_at( mut state: AgentRuntimeState, response: &str, ) -> Result { + state.pending_tool_action = None; state.status = "idle".to_string(); state.phase = "completed".to_string(); state.current_action = "等待下一轮输入".to_string(); @@ -3968,6 +5083,8 @@ pub(crate) fn finish_game_creator_agent_runtime_turn_at( "responsePreview": state.last_response, }), )?; + remove_game_creator_agent_runtime_pending_tool_action(root, &state.agent_id, &state.run_id)?; + remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; Ok(state) } @@ -3976,6 +5093,7 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at( mut state: AgentRuntimeState, error: &str, ) -> Result { + state.pending_tool_action = None; state.status = "failed".to_string(); state.phase = "failed".to_string(); state.current_action = "等待开发者处理失败".to_string(); @@ -3997,6 +5115,8 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at( "Agent Runtime 本轮处理失败。", state.error.as_deref(), )?; + remove_game_creator_agent_runtime_pending_tool_action(root, &state.agent_id, &state.run_id)?; + remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; Ok(state) } @@ -4093,6 +5213,7 @@ fn default_game_creator_agent_runtime_state(agent_id: &str, run_id: &str) -> Age active_plan_step_index: None, observations: Vec::new(), recent_tool_calls: Vec::new(), + pending_tool_action: None, task_queue: AgentRuntimeTaskQueueSummary::default(), allowed_tools: default_game_creator_agent_runtime_allowed_tools(), tool_policy: AgentRuntimeToolPolicySnapshot::default(), @@ -4411,17 +5532,17 @@ fn game_creator_agent_runtime_cancel_requested(root: &Path, state: &AgentRuntime } #[derive(Debug)] -struct AgentRuntimeTaskLock { - path: PathBuf, +pub(crate) struct AgentRuntimeTaskLock { + file: Option, } impl Drop for AgentRuntimeTaskLock { fn drop(&mut self) { - let _ = fs::remove_file(&self.path); + self.file.take(); } } -fn try_acquire_game_creator_agent_runtime_task_lock( +pub(crate) fn try_acquire_game_creator_agent_runtime_task_lock( root: &Path, agent_id: &str, ) -> Result, String> { @@ -4438,55 +5559,138 @@ fn try_acquire_game_creator_agent_runtime_task_lock( ) })?; } + let Some(mut file) = try_open_game_creator_agent_runtime_task_lock_file(&path)? else { + return Ok(None); + }; + let token = format!("{}-{}", std::process::id(), unix_timestamp_nanos()); let payload = serde_json::json!({ "agentId": agent_id, "pid": std::process::id(), + "token": token.clone(), "createdAt": unix_timestamp(), }); let content = serde_json::to_string_pretty(&payload) .map_err(|error| format!("生成 Agent Runtime 锁失败:{error}"))?; - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&path) - { - Ok(mut file) => { - file.write_all(content.as_bytes()).map_err(|error| { - format!("写入 Agent Runtime 锁失败:{}: {error}", path.display()) - })?; - Ok(Some(AgentRuntimeTaskLock { path })) + file.set_len(0) + .and_then(|_| file.seek(SeekFrom::Start(0)).map(|_| ())) + .and_then(|_| file.write_all(content.as_bytes())) + .and_then(|_| file.sync_data()) + .map_err(|error| format!("写入 Agent Runtime 锁失败:{}: {error}", path.display()))?; + Ok(Some(AgentRuntimeTaskLock { file: Some(file) })) +} + +fn acquire_game_creator_agent_runtime_task_lock_with_wait( + root: &Path, + agent_id: &str, +) -> Result { + try_acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id)? + .ok_or_else(|| format!("Agent Runtime 正在执行该 Agent 的其他任务:{agent_id}")) +} + +fn try_acquire_game_creator_agent_runtime_task_lock_with_wait( + root: &Path, + agent_id: &str, +) -> Result, String> { + for attempt in 0..25 { + if let Some(runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock(root, agent_id)? + { + return Ok(Some(runtime_lock)); } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - let is_running = read_game_creator_agent_runtime_at(root, agent_id) - .map(|result| result.state.status == "running") - .unwrap_or(false); - let lock_status = read_game_creator_agent_runtime_lock_status(&path); - if is_running && (!lock_status.belongs_to_previous_process || !lock_status.is_stale) { - return Ok(None); - } - if !lock_status.belongs_to_previous_process && !lock_status.is_stale { - return Ok(None); - } - let _ = fs::remove_file(&path); - let mut file = fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&path) - .map_err(|error| { - format!("创建 Agent Runtime 锁失败:{}: {error}", path.display()) - })?; - file.write_all(content.as_bytes()).map_err(|error| { - format!("写入 Agent Runtime 锁失败:{}: {error}", path.display()) - })?; - Ok(Some(AgentRuntimeTaskLock { path })) + if attempt < 24 { + std::thread::sleep(Duration::from_millis(10)); + } + } + Ok(None) +} + +#[cfg(unix)] +fn try_open_game_creator_agent_runtime_task_lock_file(path: &Path) -> Result, String> { + use std::os::fd::AsRawFd; + + unsafe extern "C" { + fn flock(fd: std::os::raw::c_int, operation: std::os::raw::c_int) -> std::os::raw::c_int; + } + + const LOCK_EXCLUSIVE: std::os::raw::c_int = 2; + const LOCK_NONBLOCKING: std::os::raw::c_int = 4; + let file = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(path) + .map_err(|error| format!("打开 Agent Runtime 锁失败:{}: {error}", path.display()))?; + // SAFETY: flock only observes the valid fd owned by `file`; `file` remains alive on success. + let result = unsafe { flock(file.as_raw_fd(), LOCK_EXCLUSIVE | LOCK_NONBLOCKING) }; + if result == 0 { + return Ok(Some(file)); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(format!( + "获取 Agent Runtime 系统文件锁失败:{}: {error}", + path.display() + )) + } +} + +#[cfg(windows)] +fn try_open_game_creator_agent_runtime_task_lock_file(path: &Path) -> Result, String> { + use std::os::windows::fs::OpenOptionsExt; + + match fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .share_mode(0) + .open(path) + { + Ok(file) => Ok(Some(file)), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) => + { + Ok(None) } Err(error) => Err(format!( - "创建 Agent Runtime 锁失败:{}: {error}", + "获取 Agent Runtime 系统文件锁失败:{}: {error}", path.display() )), } } +#[cfg(not(any(unix, windows)))] +fn try_open_game_creator_agent_runtime_task_lock_file(path: &Path) -> Result, String> { + Err(format!( + "当前平台不支持 Agent Runtime 系统文件锁:{}", + path.display() + )) +} + +pub(crate) fn game_creator_agent_runtime_task_lock_is_available( + root: &Path, + agent_id: &str, +) -> Result { + let path = root + .join(".agent") + .join("runtime") + .join("locks") + .join(format!("{agent_id}.lock")); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent Runtime 锁目录失败:{}: {error}", + parent.display() + ) + })?; + } + Ok(try_open_game_creator_agent_runtime_task_lock_file(&path)?.is_some()) +} + #[derive(Debug)] pub(crate) struct AgentRuntimeTaskLockStatus { pub(crate) is_stale: bool, @@ -4515,15 +5719,16 @@ pub(crate) fn read_game_creator_agent_runtime_lock_status( .unwrap_or(0); let is_stale = created_at == 0 || unix_timestamp().saturating_sub(created_at) > AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS; + let belongs_to_previous_process = pid + .map(|pid| pid != u64::from(std::process::id())) + .unwrap_or(true); AgentRuntimeTaskLockStatus { is_stale, - belongs_to_previous_process: pid - .map(|pid| pid != u64::from(std::process::id())) - .unwrap_or(true), + belongs_to_previous_process, } } -fn write_game_creator_agent_runtime_state( +pub(crate) fn write_game_creator_agent_runtime_state( root: &Path, state: &AgentRuntimeState, ) -> Result<(), String> { @@ -4601,7 +5806,7 @@ fn append_game_creator_agent_runtime_event( Ok(()) } -fn append_game_creator_agent_runtime_task( +pub(crate) fn append_game_creator_agent_runtime_task( root: &Path, state: &AgentRuntimeState, ) -> Result<(), String> { @@ -4651,6 +5856,7 @@ fn mark_game_creator_agent_runtime_cancelled_at( summary: &str, detail: Option<&str>, ) -> Result<(), String> { + state.pending_tool_action = None; state.status = "cancelled".to_string(); state.phase = "cancelled".to_string(); state.current_action = summary.to_string(); @@ -4683,6 +5889,8 @@ fn mark_game_creator_agent_runtime_cancelled_at( "summary": summary, }), )?; + remove_game_creator_agent_runtime_pending_tool_action(root, &state.agent_id, &state.run_id)?; + remove_game_creator_agent_runtime_confirmations(root, &state.agent_id, &state.run_id)?; Ok(()) } @@ -4693,12 +5901,27 @@ fn stop_game_creator_agent_runtime_if_cancel_requested( if !game_creator_agent_runtime_cancel_requested(root, state) { return false; } - let _ = mark_game_creator_agent_runtime_cancelled_at( + let run_id = state.run_id.clone(); + let cancellation = mark_game_creator_agent_runtime_cancelled_at( root, state, "Agent 后台任务已按开发者请求取消", Some("取消请求会在当前 LLM 或工具调用返回后生效。"), ); + if let Err(error) = cancellation { + let error = format!("Agent 后台任务收到取消请求,但取消状态落盘失败:{error}"); + if let Ok(failed) = fail_game_creator_agent_runtime_turn_at(root, state.clone(), &error) { + *state = failed; + remove_game_creator_agent_runtime_cancel_request(root, &state.agent_id, &run_id); + } else { + state.status = "failed".to_string(); + state.phase = "failed".to_string(); + state.current_action = "取消状态落盘失败".to_string(); + state.waiting_on = "开发者处理失败".to_string(); + state.next_step = "刷新状态并重新取消该任务".to_string(); + state.error = Some(sanitize_agent_runtime_text(&error, 500)); + } + } true } @@ -4865,6 +6088,12 @@ fn read_recoverable_game_creator_agent_runtime_task( { return Ok(Some(task)); } + if records + .iter() + .any(|record| record.status == "waiting-for-confirmation") + { + return Ok(None); + } Ok(records .into_iter() .find(|record| record.status == "pending")) 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 257404e92..3d750cc67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -431,7 +431,7 @@ pub(crate) fn confirm_game_creator_agent_runtime_task( project_path: String, agent_id: String, run_id: String, - next_run_id: String, + action_id: String, note: String, ) -> Result { let root = Path::new(project_path.trim()); @@ -444,7 +444,30 @@ pub(crate) fn confirm_game_creator_agent_runtime_task( root, agent_id.trim(), run_id.trim(), - next_run_id.trim(), + action_id.trim(), + note.trim(), + ) +} + +#[tauri::command] +pub(crate) fn reject_game_creator_agent_runtime_task( + project_path: String, + agent_id: String, + run_id: String, + action_id: String, + note: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + // 该命令只由开发者显式点击“拒绝并继续”触发。 + enforce_project_permission_policy(root, "agent.resume")?; + reject_game_creator_agent_runtime_task_at( + root, + agent_id.trim(), + run_id.trim(), + action_id.trim(), note.trim(), ) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 81e97b549..ce3bdbe8d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -178,6 +178,8 @@ struct AgentRuntimeState { #[serde(default)] recent_tool_calls: Vec, #[serde(default)] + pending_tool_action: Option, + #[serde(default)] task_queue: AgentRuntimeTaskQueueSummary, #[serde(default)] allowed_tools: Vec, @@ -239,6 +241,23 @@ struct AgentRuntimeToolCallRecord { updated_at: u64, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimePendingToolActionSummary { + #[serde(default)] + action_id: String, + #[serde(default)] + action_fingerprint: String, + #[serde(default)] + tool: String, + #[serde(default)] + input_summary: Option, + #[serde(default)] + reason: Option, + #[serde(default)] + requested_at: u64, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimePlanStep { @@ -1207,6 +1226,7 @@ fn main() { cancel_game_creator_agent_runtime_task, retry_game_creator_agent_runtime_task, confirm_game_creator_agent_runtime_task, + reject_game_creator_agent_runtime_task, read_game_creator_agent_runtime, read_game_creator_agent_runtimes, resume_game_creator_agent_runtime_tasks, 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 195bea4a6..59b8e94e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -674,6 +674,9 @@ pub(crate) fn list_local_project_files_at( let path = entry.path(); let relative_path = relative_project_path(root, &path)?; + if is_agent_runtime_private_control_path(&relative_path) { + continue; + } let metadata = entry.metadata().map_err(|error| { format!("读取文件元数据失败:{}: {error}", entry.path().display()) })?; @@ -715,6 +718,7 @@ pub(crate) fn read_local_project_file_at( relative_path: &str, ) -> Result { let normalized_path = normalize_relative_path(relative_path)?; + reject_agent_runtime_private_control_path(&normalized_path)?; reject_sensitive_project_file_read(&normalized_path)?; let path = resolve_local_project_path(root, &normalized_path)?; let metadata = fs::metadata(&path) @@ -732,6 +736,19 @@ pub(crate) fn read_local_project_file_at( }) } +fn is_agent_runtime_private_control_path(normalized_path: &str) -> bool { + let mut parts = normalized_path.split('/'); + matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case(".agent")) + && matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("runtime")) +} + +fn reject_agent_runtime_private_control_path(normalized_path: &str) -> Result<(), String> { + if is_agent_runtime_private_control_path(normalized_path) { + return Err("Agent Runtime 私有控制面不可通过通用文件工具访问".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(); @@ -756,7 +773,9 @@ pub(crate) fn write_local_project_file_at( relative_path: &str, content: &str, ) -> Result { - let path = resolve_local_project_path(root, relative_path)?; + let normalized_path = normalize_relative_path(relative_path)?; + reject_agent_runtime_private_control_path(&normalized_path)?; + let path = resolve_local_project_path(root, &normalized_path)?; if path.exists() && !path.is_file() { return Err("只能写入文件".to_string()); } @@ -768,7 +787,7 @@ pub(crate) fn write_local_project_file_at( .map_err(|error| format!("写入项目文件失败:{}: {error}", path.display()))?; Ok(LocalProjectFileMutationResult { - path: normalize_relative_path(relative_path)?, + path: normalized_path, absolute_path: path.to_string_lossy().into_owned(), deleted: false, }) @@ -778,10 +797,12 @@ pub(crate) fn delete_local_project_file_at( root: &Path, relative_path: &str, ) -> Result { - let path = resolve_local_project_path(root, relative_path)?; + let normalized_path = normalize_relative_path(relative_path)?; + reject_agent_runtime_private_control_path(&normalized_path)?; + let path = resolve_local_project_path(root, &normalized_path)?; if !path.exists() { return Ok(LocalProjectFileMutationResult { - path: normalize_relative_path(relative_path)?, + path: normalized_path, absolute_path: path.to_string_lossy().into_owned(), deleted: false, }); @@ -793,7 +814,7 @@ pub(crate) fn delete_local_project_file_at( .map_err(|error| format!("删除项目文件失败:{}: {error}", path.display()))?; Ok(LocalProjectFileMutationResult { - path: normalize_relative_path(relative_path)?, + path: normalized_path, absolute_path: path.to_string_lossy().into_owned(), deleted: true, }) 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 133b710f5..ae6b4c924 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -96,6 +96,139 @@ fn write_agent_runtime_task_record_for_test(root: &Path, record: &AgentRuntimeTa file.write_all(b"\n").expect("write runtime task record"); } +fn pending_tool_action_for_test( + state: &AgentRuntimeState, + action: AgentRuntimeToolAction, + status: &str, + observation: Option, +) -> AgentRuntimePendingToolAction { + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); + let occurrence_nonce = unix_timestamp(); + let action_index = 0; + let now = unix_timestamp(); + AgentRuntimePendingToolAction { + schema_version: AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION.to_string(), + fingerprint_version: AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION.to_string(), + agent_id: state.agent_id.clone(), + task_id: state.task_id.clone(), + session_id: state.session_id.clone(), + run_id: state.run_id.clone(), + source: state.source.clone(), + task: state.current_task.clone(), + loop_iteration: state.loop_iteration.max(1), + action_index, + occurrence_nonce, + thinking_summary: "测试待确认动作".to_string(), + plan: vec!["执行测试工具动作".to_string()], + fallback_response: String::new(), + observations: Vec::new(), + action, + action_id: agent_runtime_tool_action_id( + &state.run_id, + state.loop_iteration.max(1), + action_index, + occurrence_nonce, + &action_fingerprint, + ), + action_fingerprint, + input_summary: Some("path=game/notes.txt".to_string()), + status: status.to_string(), + observation, + created_at: now, + updated_at: now, + } +} + +fn assert_pending_runtime_decision_revalidates_after_lock( + decision: fn(&Path, &str, &str, &str, &str) -> Result, + decision_name: &str, +) { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "待确认锁顺序测试").expect("project init"); + let run_id = format!("design-{decision_name}-lock-order"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "验证待确认动作必须在锁内重读", + &run_id, + "agent-background-task", + "等待测试动作确认", + vec!["执行测试动作".to_string()], + ) + .expect("start runtime state"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("读取测试文件".to_string()), + input: serde_json::json!({ "path": "game/notes.txt" }), + }; + let pending = pending_tool_action_for_test(&state, action, "pending-confirmation", None); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write pending action"); + 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 runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("acquire runtime lock") + .expect("runtime lock owner"); + let (started_sender, started_receiver) = mpsc::channel(); + let decision_root = root.clone(); + let decision_run_id = run_id.clone(); + let action_id = pending.action_id.clone(); + let handle = std::thread::spawn(move || { + started_sender.send(()).expect("signal decision start"); + decision( + &decision_root, + "design-director", + &decision_run_id, + &action_id, + "并发测试", + ) + }); + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("decision starts"); + std::thread::sleep(Duration::from_millis(40)); + + finish_game_creator_agent_runtime_turn_at( + &root, + state, + &format!("锁内操作已先完成待确认任务:{decision_name}"), + ) + .expect("finish waiting task under lock"); + drop(runtime_lock); + + let error = handle + .join() + .expect("decision thread") + .expect_err("stale decision must be rejected"); + assert!( + error.contains("不在待确认状态"), + "unexpected error: {error}" + ); + let result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime"); + assert_eq!(result.state.status, "idle"); + assert_eq!(result.state.phase, "completed"); + assert!(result.recent_tasks.iter().any(|task| { + task.run_id == run_id && task.status == "completed" && task.phase == "completed" + })); + assert!(!root + .join(".agent/runtime/pending-actions/design-director") + .join(format!("{run_id}.json")) + .exists()); + assert!(!root + .join(".agent/runtime/confirmations/design-director") + .join(&run_id) + .join("file.read.json") + .exists()); + + fs::remove_dir_all(root).ok(); +} + fn test_local_config_path() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -253,6 +386,153 @@ fn agent_runtime_lock_status_marks_old_previous_process_locks_stale() { fs::remove_dir_all(root).ok(); } +#[test] +fn agent_runtime_system_lock_allows_only_one_owner() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "锁互斥测试").expect("project init"); + let first = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("first lock") + .expect("first owner acquires lock"); + assert!( + try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("second lock attempt") + .is_none() + ); + drop(first); + let replacement = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("replacement lock") + .expect("replacement owner acquires released lock"); + + assert!(root + .join(".agent/runtime/locks/design-director.lock") + .exists()); + drop(replacement); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_cancel_revalidates_completed_task_after_lock_wait() { + 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", + "验证取消必须在锁内重读", + "design-cancel-lock-order", + "agent-background-task", + "执行并发测试", + vec!["完成当前任务".to_string()], + ) + .expect("start runtime state"); + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("acquire runtime lock") + .expect("runtime lock owner"); + let (started_sender, started_receiver) = mpsc::channel(); + let cancel_root = root.clone(); + let handle = std::thread::spawn(move || { + started_sender.send(()).expect("signal cancel start"); + cancel_game_creator_agent_runtime_task_at( + &cancel_root, + "design-director", + "design-cancel-lock-order", + ) + }); + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("cancel starts"); + let cancel_path = root + .join(".agent/runtime/cancel/design-director") + .join("design-cancel-lock-order.json"); + for _ in 0..50 { + if cancel_path.exists() { + break; + } + std::thread::sleep(Duration::from_millis(2)); + } + assert!( + cancel_path.exists(), + "cancel tombstone should be written first" + ); + + finish_game_creator_agent_runtime_turn_at(&root, state, "锁内任务已完成") + .expect("finish task while owning lock"); + drop(runtime_lock); + + let error = handle + .join() + .expect("cancel thread") + .expect_err("stale cancellation must not overwrite completion"); + assert!(error.contains("任务已结束"), "unexpected error: {error}"); + let result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime"); + assert_eq!(result.state.status, "idle"); + assert_eq!(result.state.phase, "completed"); + assert!(result + .recent_tasks + .iter() + .any(|task| { task.run_id == "design-cancel-lock-order" && task.status == "completed" })); + assert!(!result + .recent_tasks + .iter() + .any(|task| { task.run_id == "design-cancel-lock-order" && task.status == "cancelled" })); + assert!(!cancel_path.exists()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_confirm_revalidates_pending_action_after_lock_wait() { + assert_pending_runtime_decision_revalidates_after_lock( + confirm_game_creator_agent_runtime_task_at, + "confirm", + ); +} + +#[test] +fn agent_runtime_reject_revalidates_pending_action_after_lock_wait() { + assert_pending_runtime_decision_revalidates_after_lock( + reject_game_creator_agent_runtime_task_at, + "reject", + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn agent_runtime_does_not_reclaim_stale_lock_from_live_process() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "锁恢复测试").expect("project init"); + let lock_path = root.join(".agent/runtime/locks/design-director.lock"); + let owner = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("live owner lock") + .expect("live owner acquires lock"); + fs::write( + &lock_path, + serde_json::json!({ + "agentId": "design-director", + "pid": 1, + "token": "live-process-token", + "createdAt": unix_timestamp() + .saturating_sub(AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS + 1), + }) + .to_string(), + ) + .expect("write live process lock"); + + assert!( + try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("inspect live process lock") + .is_none() + ); + drop(owner); + assert!( + try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("lock after owner release") + .is_some() + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn config_file_overrides_defaults_without_env() { let root = unique_project_path(); @@ -2168,6 +2448,66 @@ async fn background_agent_runtime_can_replan_after_observation() { fs::remove_dir_all(root).ok(); } +#[test] +fn background_agent_runtime_resume_does_not_mutate_pending_action_while_lock_is_busy() { + 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-resume-lock-order", + "agent-background-task", + "等待恢复", + vec!["恢复待确认动作".to_string()], + ) + .expect("start runtime state"); + state.loop_iteration = 1; + let pending = pending_tool_action_for_test( + &state, + AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("读取项目笔记".to_string()), + input: serde_json::json!({ "path": "game/notes.txt" }), + }, + "pending-confirmation", + None, + ); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write pending action"); + 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"); + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("acquire runtime lock") + .expect("runtime lock owner"); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("busy runtime resume should be skipped"); + assert!(resumed.is_empty()); + let persisted: AgentRuntimeState = serde_json::from_str( + &fs::read_to_string(root.join(".agent/runtime/agents/design-director.json")) + .expect("read persisted runtime"), + ) + .expect("parse persisted runtime"); + assert_eq!(persisted.status, "running"); + assert_eq!(persisted.phase, "action"); + let persisted_pending: AgentRuntimePendingToolAction = + serde_json::from_str( + &fs::read_to_string(root.join( + ".agent/runtime/pending-actions/design-director/design-resume-lock-order.json", + )) + .expect("read persisted pending action"), + ) + .expect("parse persisted pending action"); + assert_eq!(persisted_pending.status, "pending-confirmation"); + + drop(runtime_lock); + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_recovers_stale_running_task() { let root = unique_project_path(); @@ -2302,6 +2642,64 @@ fn background_agent_runtime_resume_command_requires_auto_resume_policy() { fs::remove_dir_all(root).ok(); } +#[test] +fn background_agent_runtime_legacy_waiting_task_blocks_pending_recovery() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let mut waiting = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "旧版本待确认任务", + "design-legacy-waiting-run", + "agent-background-task", + "等待旧版本确认", + vec!["等待人工处理".to_string()], + ) + .expect("start legacy waiting state"); + waiting.status = "waiting-for-confirmation".to_string(); + waiting.phase = "waiting-for-confirmation".to_string(); + waiting.current_action = "旧版本待确认动作缺少精确账本".to_string(); + waiting.pending_tool_action = None; + append_game_creator_agent_runtime_task(&root, &waiting).expect("append waiting task"); + write_game_creator_agent_runtime_state(&root, &waiting).expect("write waiting state"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: "design-director".to_string(), + session_id: "agent-session-design-director".to_string(), + run_id: "design-after-legacy-waiting-run".to_string(), + source: "agent-background-task".to_string(), + task: "不能越过旧 waiting 的排队任务".to_string(), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待后台执行".to_string(), + error: None, + updated_at: unix_timestamp(), + }, + ); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("inspect legacy waiting runtime"); + assert!(resumed.is_empty()); + let result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime"); + assert_eq!(result.state.run_id, "design-legacy-waiting-run"); + assert_eq!(result.state.status, "waiting-for-confirmation"); + assert!(result.state.pending_tool_action.is_none()); + assert!(result + .state + .error + .as_deref() + .is_some_and(|error| error.contains("执行记录缺失"))); + assert!(result.recent_tasks.iter().any(|task| { + task.run_id == "design-after-legacy-waiting-run" && task.status == "pending" + })); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_recovers_pending_task() { let root = unique_project_path(); @@ -4888,6 +5286,207 @@ async fn background_agent_runtime_tool_action_respects_confirm_policy() { fs::remove_dir_all(root).ok(); } +#[test] +fn pending_tool_action_identity_binds_task_context_and_occurrence() { + assert!(!agent_runtime_contains_secret_key_prefix( + "design-task-create-policy-run", + "sk-" + )); + let secret_like = format!( + r#"{{"apiKey":"{}"}}"#, + ["s", "k-test-secret-value"].concat() + ); + assert!(agent_runtime_contains_secret_key_prefix( + &secret_like, + "sk-" + )); + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("生成角色规范图".to_string()), + input: serde_json::json!({}), + }; + let first_fingerprint = agent_runtime_tool_action_fingerprint(&action, "生成第一版角色规范图"); + let changed_task_fingerprint = + agent_runtime_tool_action_fingerprint(&action, "生成第二版角色规范图"); + assert_ne!(first_fingerprint, changed_task_fingerprint); + + let first_action_id = agent_runtime_tool_action_id("run-1", 1, 0, 100, &first_fingerprint); + let repeated_action_id = agent_runtime_tool_action_id("run-1", 1, 1, 101, &first_fingerprint); + assert_ne!(first_action_id, repeated_action_id); +} + +#[tokio::test] +async fn background_agent_runtime_resumes_approved_pending_action_without_llm_replay() { + 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"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.read".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write policy"); + let (sender, receiver) = mpsc::channel(); + let final_plan = serde_json::json!({ + "thinkingSummary": "已经拿到项目笔记", + "plan": [], + "actions": [], + "response": "恢复后已读取笔记。" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture(vec![final_plan], Some(sender)); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "恢复已批准的文件读取", + "design-approved-recovery-run", + "agent-background-task", + "模拟批准后进程退出", + vec!["读取项目笔记".to_string()], + ) + .expect("start runtime state"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("确认项目笔记".to_string()), + input: serde_json::json!({ "path": "game/notes.txt" }), + }; + let pending = pending_tool_action_for_test( + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + write_game_creator_agent_runtime_tool_confirmation( + &root, + "design-director", + &state.run_id, + "file.read", + &pending.action_fingerprint, + "测试恢复批准", + ) + .expect("write confirmation ticket"); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write approved pending action"); + 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"); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume approved pending action"); + assert!(resumed.iter().any(|runtime| { + runtime.state.run_id == "design-approved-recovery-run" && runtime.state.status == "running" + })); + let replan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("replan after exact action observation"); + assert!(replan_request.contains("核心循环笔记")); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, "design-approved-recovery-run"); + assert_eq!(runtime.last_response.as_deref(), Some("恢复后已读取笔记。")); + assert!(!root + .join(".agent/runtime/pending-actions/design-director/design-approved-recovery-run.json") + .exists()); + + fs::remove_dir_all(root).ok(); +} + +#[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( + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + 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"); + 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"); + let cancel_path = + root.join(".agent/runtime/cancel/design-director/design-executing-recovery-run.json"); + fs::create_dir_all(cancel_path.parent().expect("cancel parent")).expect("cancel dir"); + fs::write(&cancel_path, "{}\n").expect("write pre-restart cancel tombstone"); + + 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("不会自动重放"))); + assert!(cancel_path.exists()); + assert!(!root.join("game/notes.txt").exists()); + 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("请先核对项目状态并取消原任务")); + 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()); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() { let root = unique_project_path(); @@ -4923,10 +5522,8 @@ 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.clone(), read_plan, final_plan], - Some(sender), - ); + let base_url = + spawn_mock_llm_server_responses_with_capture(vec![read_plan, final_plan], Some(sender)); let _config_guard = write_test_local_config(format!( r#"{{ "agentLlm": {{ @@ -4953,6 +5550,31 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() .expect("first plan llm request"); let waiting_runtime = wait_for_agent_runtime_confirmation(&root, "design-director"); assert_eq!(waiting_runtime.status, "waiting-for-confirmation"); + let pending_action = waiting_runtime + .pending_tool_action + .as_ref() + .expect("pending tool action"); + assert_eq!(pending_action.tool, "file.read"); + assert_eq!( + pending_action.input_summary.as_deref(), + Some("path=game/notes.txt") + ); + assert!(root + .join(".agent/runtime/pending-actions/design-director/design-confirm-run.json") + .is_file()); + let runtime_lock_path = root.join(".agent/runtime/locks/design-director.lock"); + fs::create_dir_all(runtime_lock_path.parent().expect("runtime lock parent")) + .expect("runtime lock dir"); + fs::write( + &runtime_lock_path, + serde_json::json!({ + "agentId": "design-director", + "pid": u64::from(std::process::id()) + 1, + "createdAt": unix_timestamp(), + }) + .to_string(), + ) + .expect("simulate fresh lock from previous app process"); let pending_tool_call = waiting_runtime .recent_tool_calls .iter() @@ -4976,17 +5598,16 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() root.to_string_lossy().into_owned(), "design-director".to_string(), "design-confirm-run".to_string(), - "design-confirm-run-approved".to_string(), + pending_action.action_id.clone(), "允许读取项目笔记".to_string(), ) .expect("confirm waiting task"); - assert_eq!(confirmed.state.run_id, "design-confirm-run-approved"); + assert_eq!(confirmed.state.run_id, "design-confirm-run"); + assert_eq!(confirmed.state.status, "running"); receiver .recv_timeout(Duration::from_secs(2)) - .expect("confirmed run plan request"); - receiver - .recv_timeout(Duration::from_secs(2)) - .expect("confirmed run final plan request"); + .expect("continued plan request"); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); let runtime = wait_for_agent_runtime_idle(&root, "design-director"); assert_eq!(runtime.status, "idle"); @@ -5001,17 +5622,18 @@ async fn background_agent_runtime_can_confirm_and_continue_waiting_tool_action() .any(|item| item.contains("file.read:ok"))); let runtime_result = read_game_creator_agent_runtime_at(&root, "design-director").expect("runtime result"); - assert!(runtime_result.recent_tasks.iter().any(|task| { - task.run_id == "design-confirm-run" - && task.status == "completed" - && task.phase == "confirmed" - })); - assert!(runtime_result.recent_tasks.iter().any(|task| { - task.run_id == "design-confirm-run-approved" && task.status == "completed" - })); + assert_eq!(runtime_result.recent_tasks.len(), 1); + assert!(runtime_result + .recent_tasks + .iter() + .any(|task| { task.run_id == "design-confirm-run" && task.status == "completed" })); + assert!(!root + .join(".agent/runtime/pending-actions/design-director/design-confirm-run.json") + .exists()); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("\"recordType\":\"agent.runtime.tool_confirmation.approved\"")); - assert!(agent_db.contains("\"confirmedRunId\":\"design-confirm-run-approved\"")); + assert!(agent_db.contains("\"confirmedRunId\":\"design-confirm-run\"")); + assert!(agent_db.contains(&format!("\"actionId\":\"{}\"", pending_action.action_id))); assert!(agent_db.contains("\"tool\":\"file.read\"")); assert!(agent_db.contains("\"actionFingerprint\":")); assert!(agent_db.contains("\"inputSummary\":\"path=game/notes.txt\"")); @@ -5048,23 +5670,7 @@ async fn background_agent_runtime_confirmation_is_bound_to_exact_tool_input() { "response": "" }) .to_string(); - let changed_plan = serde_json::json!({ - "thinkingSummary": "改为读取另一个文件", - "plan": ["读取未批准目标"], - "actions": [ - { - "tool": "file.read", - "reason": "尝试复用旧确认读取不同目标", - "input": { "path": "game/other.txt" } - } - ], - "response": "" - }) - .to_string(); - let base_url = spawn_mock_llm_server_responses_with_capture( - vec![approved_plan, changed_plan], - Some(sender), - ); + let base_url = spawn_mock_llm_server_responses_with_capture(vec![approved_plan], Some(sender)); let _config_guard = write_test_local_config(format!( r#"{{ "agentLlm": {{ @@ -5091,52 +5697,288 @@ async fn background_agent_runtime_confirmation_is_bound_to_exact_tool_input() { .expect("first plan llm request"); let first_waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); assert_eq!(first_waiting.run_id, "design-exact-confirm-run"); + let pending_action = first_waiting + .pending_tool_action + .as_ref() + .expect("pending tool action"); + let pending_path = + root.join(".agent/runtime/pending-actions/design-director/design-exact-confirm-run.json"); + let mut pending_json: Value = + serde_json::from_str(&fs::read_to_string(&pending_path).expect("read pending action")) + .expect("parse pending action"); + pending_json["action"]["input"]["path"] = Value::String("game/other.txt".to_string()); + fs::write( + &pending_path, + serde_json::to_string_pretty(&pending_json).expect("serialize tampered action"), + ) + .expect("tamper pending action"); - confirm_game_creator_agent_runtime_task_at( + let error = confirm_game_creator_agent_runtime_task_at( &root, "design-director", "design-exact-confirm-run", - "design-exact-confirm-approved", + &pending_action.action_id, "只允许读取 game/notes.txt", ) - .expect("confirm exact action"); - receiver - .recv_timeout(Duration::from_secs(2)) - .expect("changed plan llm request"); + .expect_err("stale action id must be rejected"); + assert!(error.contains("待确认动作已变化")); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); - let changed_waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); - assert_eq!(changed_waiting.run_id, "design-exact-confirm-approved"); - assert_eq!(changed_waiting.status, "waiting-for-confirmation"); - assert!(!changed_waiting + let still_waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); + assert_eq!(still_waiting.run_id, "design-exact-confirm-run"); + assert_eq!(still_waiting.status, "waiting-for-confirmation"); + assert!(still_waiting.pending_tool_action.is_none()); + assert!(still_waiting + .error + .as_deref() + .is_some_and(|error| error.contains("待确认动作已变化"))); + assert!(!still_waiting .observations .iter() .any(|item| item.contains("file.read:ok"))); - let changed_tool_call = changed_waiting - .recent_tool_calls - .iter() - .rev() - .find(|call| call.status == "waiting-for-confirmation") - .expect("changed pending tool call"); - assert_eq!( - changed_tool_call.input_summary.as_deref(), - Some("path=game/other.txt") - ); - assert_ne!( - changed_tool_call.action_fingerprint, - first_waiting - .recent_tool_calls - .iter() - .rev() - .find(|call| call.status == "waiting-for-confirmation") - .and_then(|call| call.action_fingerprint.clone()) - ); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(!agent_db.contains("未批准读取的笔记")); 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(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write(root.join("game/notes.txt"), "不应进入 observation 的笔记").expect("write notes"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.read".to_string(), "agent.resume".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write policy"); + let (sender, receiver) = mpsc::channel(); + let read_plan = serde_json::json!({ + "thinkingSummary": "需要读项目笔记", + "plan": ["读取项目笔记", "根据决定继续"], + "actions": [ + { + "tool": "file.read", + "reason": "确认项目笔记", + "input": { "path": "game/notes.txt" } + } + ], + "response": "" + }) + .to_string(); + let rejected_plan = serde_json::json!({ + "thinkingSummary": "开发者拒绝读取,改为直接说明限制", + "plan": [], + "actions": [], + "response": "已按要求跳过项目笔记读取。" + }) + .to_string(); + let next_task_plan = serde_json::json!({ + "thinkingSummary": "继续处理同 Agent 队列里的后续任务", + "plan": [], + "actions": [], + "response": "后续任务已完成。" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![read_plan, rejected_plan, next_task_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-reject-run", + ) + .expect("start background task"); + + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first plan request"); + let waiting = wait_for_agent_runtime_confirmation(&root, "design-director"); + let action_id = waiting + .pending_tool_action + .as_ref() + .expect("pending action") + .action_id + .clone(); + let queued = start_game_creator_agent_background_task_at( + &root, + "design-director", + "处理拒绝后的后续任务", + "design-after-reject-run", + ) + .expect("queue task while waiting for confirmation"); + assert_eq!(queued.state.run_id, "design-reject-run"); + assert_eq!(queued.state.status, "waiting-for-confirmation"); + assert_eq!(queued.task_queue.pending, 1); + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("restore runtime while first task waits for confirmation"); + let restored_waiting = resumed + .iter() + .find(|runtime| runtime.state.agent_id == "design-director") + .expect("waiting runtime restored"); + assert_eq!(restored_waiting.state.run_id, "design-reject-run"); + assert_eq!( + restored_waiting.state.status, "waiting-for-confirmation", + "restart must not skip the waiting task and start the queued task" + ); + assert_eq!(restored_waiting.task_queue.pending, 1); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + let rejected = reject_game_creator_agent_runtime_task( + root.to_string_lossy().into_owned(), + "design-director".to_string(), + "design-reject-run".to_string(), + action_id, + "不要读取该文件".to_string(), + ) + .expect("reject pending action"); + assert_eq!(rejected.state.run_id, "design-reject-run"); + assert_eq!(rejected.state.status, "running"); + let replan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("replan after rejection"); + assert!(replan_request.contains("开发者拒绝待确认工具动作")); + assert!(!replan_request.contains("不应进入 observation 的笔记")); + let next_task_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("queued task request after rejected run completes"); + assert!(next_task_request.contains("处理拒绝后的后续任务")); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, "design-after-reject-run"); + assert_eq!(runtime.last_response.as_deref(), Some("后续任务已完成。")); + assert!(runtime.pending_tool_action.is_none()); + let runtime_result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("runtime result"); + assert!(runtime_result + .recent_tasks + .iter() + .any(|task| { task.run_id == "design-reject-run" && task.status == "completed" })); + assert!(runtime_result + .recent_tasks + .iter() + .any(|task| { task.run_id == "design-after-reject-run" && task.status == "completed" })); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"recordType\":\"agent.runtime.tool_confirmation.rejected\"")); + assert!(agent_db.contains("开发者拒绝待确认工具动作")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_cancel_waiting_task_drains_queued_task() { + 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"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.read".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write policy"); + let (sender, receiver) = mpsc::channel(); + let waiting_plan = serde_json::json!({ + "thinkingSummary": "需要读项目笔记", + "plan": ["读取项目笔记"], + "actions": [{ + "tool": "file.read", + "reason": "确认项目笔记", + "input": { "path": "game/notes.txt" } + }], + "response": "" + }) + .to_string(); + let queued_plan = serde_json::json!({ + "thinkingSummary": "处理排队任务", + "plan": [], + "actions": [], + "response": "排队任务已完成。" + }) + .to_string(); + let base_url = + spawn_mock_llm_server_responses_with_capture(vec![waiting_plan, queued_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-cancel-waiting-run", + ) + .expect("start waiting task"); + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("waiting plan request"); + wait_for_agent_runtime_confirmation(&root, "design-director"); + start_game_creator_agent_background_task_at( + &root, + "design-director", + "更早排队的后续任务", + "design-after-cancel-run", + ) + .expect("queue next task"); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + cancel_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-cancel-waiting-run", + ) + .expect("cancel waiting task"); + let queued_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("queued task starts after cancellation"); + assert!(queued_request.contains("更早排队的后续任务")); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, "design-after-cancel-run"); + assert_eq!(runtime.last_response.as_deref(), Some("排队任务已完成。")); + let result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("runtime result"); + assert!(result + .recent_tasks + .iter() + .any(|task| { task.run_id == "design-cancel-waiting-run" && task.status == "cancelled" })); + assert!(result + .recent_tasks + .iter() + .any(|task| { task.run_id == "design-after-cancel-run" && task.status == "completed" })); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_list_project_files() { let root = unique_project_path(); @@ -6200,9 +7042,10 @@ async fn background_agent_runtime_queues_same_agent_tasks_and_drains_them() { .messages .iter() .any(|message| message.role == "assistant" && message.content == "第二个后台任务完成。")); - assert!(!root - .join(".agent/runtime/locks/design-director.lock") - .exists()); + assert!( + game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("design runtime lock released") + ); fs::remove_dir_all(root).ok(); } @@ -6483,9 +7326,18 @@ async fn background_agent_runtime_can_cancel_active_task_and_retry_it() { let cancelled = cancel_game_creator_agent_runtime_task_at(&root, "design-director", "design-active-cancel") .expect("cancel active task"); - assert_eq!(cancelled.state.status, "cancelled"); - assert_eq!(cancelled.state.phase, "cancelled"); - assert_eq!(cancelled.task_queue.cancelled, 1); + assert_eq!(cancelled.state.status, "cancelling"); + assert_eq!(cancelled.state.phase, "cancelling"); + assert_eq!(cancelled.task_queue.running, 1); + assert_eq!(cancelled.task_queue.cancelled, 0); + let retry_error = retry_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-active-cancel", + "design-active-cancel-too-early", + ) + .expect_err("cancelling task cannot be retried before worker stops"); + assert!(retry_error.contains("仍在运行")); release_first_sender .send(()) @@ -6493,9 +7345,8 @@ async fn background_agent_runtime_can_cancel_active_task_and_retry_it() { let mut cancelled_result = read_game_creator_agent_runtime_at(&root, "design-director").expect("read cancelled"); for _ in 0..50 { - if !root - .join(".agent/runtime/locks/design-director.lock") - .exists() + if game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("inspect cancelled runtime lock") && cancelled_result.state.status == "cancelled" { break; @@ -6562,6 +7413,78 @@ async fn background_agent_runtime_can_cancel_active_task_and_retry_it() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_cancellation_wins_over_inflight_llm_error() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + 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( + vec!["{invalid-tool-plan}".to_string()], + request_sender, + 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" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "取消正在失败的 LLM 请求", + "design-cancel-llm-error", + ) + .expect("start active task"); + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("inflight llm request"); + let cancelling = cancel_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-cancel-llm-error", + ) + .expect("request cancellation"); + assert_eq!(cancelling.state.status, "cancelling"); + + release_sender.send(()).expect("release invalid response"); + let mut result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime"); + for _ in 0..50 { + if result.state.status == "cancelled" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime"); + } + assert_eq!(result.state.status, "cancelled"); + assert_eq!(result.state.phase, "cancelled"); + assert!(result + .recent_tasks + .iter() + .any(|task| { task.run_id == "design-cancel-llm-error" && task.status == "cancelled" })); + assert!(!result + .recent_tasks + .iter() + .any(|task| { task.run_id == "design-cancel-llm-error" && task.status == "failed" })); + let conversation = + read_local_conversation_at(&root, Some("design-director")).expect("conversation"); + assert!(!conversation.messages.iter().any(|message| { + message.role == "assistant" && message.content.contains("后台任务失败") + })); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies() { let root = unique_project_path(); @@ -6698,10 +7621,14 @@ async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies( assert!(agent_db.contains("\"recordType\":\"agent.runtime.background_task.completed\"")); assert!(agent_db.contains("\"agentId\":\"art-director\"")); assert!(agent_db.contains("\"agentId\":\"design-director\"")); - assert!(!root.join(".agent/runtime/locks/art-director.lock").exists()); - assert!(!root - .join(".agent/runtime/locks/design-director.lock") - .exists()); + assert!( + game_creator_agent_runtime_task_lock_is_available(&root, "art-director") + .expect("art runtime lock released") + ); + assert!( + game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("design runtime lock released") + ); fs::remove_dir_all(root).ok(); } @@ -9831,6 +10758,51 @@ fn local_project_file_commands_reject_unsafe_paths() { assert!(write_local_project_file_at(&root, "C:/secret.txt", "x").is_err()); } +#[test] +fn local_project_file_commands_hide_runtime_private_control_files() { + let root = unique_project_path(); + let pending_path = + root.join(".agent/runtime/pending-actions/design-director/runtime-private.json"); + fs::create_dir_all(pending_path.parent().expect("pending parent")) + .expect("runtime private dir"); + fs::write(&pending_path, r#"{"action":{"tool":"file.write"}}"#) + .expect("runtime private payload"); + + let listed = list_local_project_files_at(&root).expect("list project files"); + assert!(!listed.files.iter().any(|file| file + .path + .to_ascii_lowercase() + .starts_with(".agent/runtime/"))); + + for path in [ + ".agent/runtime/pending-actions/design-director/runtime-private.json", + ".AGENT/RUNTIME/confirmations/design-director/run/file.read.json", + ".agent/runtime/cancel/design-director/run.json", + ".agent/runtime/locks/design-director.lock", + ] { + let read_error = read_local_project_file_at(&root, path) + .expect_err("runtime private path must not be readable"); + assert!(read_error.contains("Runtime 私有控制面")); + let write_error = write_local_project_file_at(&root, path, "tampered") + .expect_err("runtime private path must not be writable"); + assert!(write_error.contains("Runtime 私有控制面")); + let delete_error = delete_local_project_file_at(&root, path) + .expect_err("runtime private path must not be deletable"); + assert!(delete_error.contains("Runtime 私有控制面")); + } + + write_local_project_file_at(&root, ".agent/runtime-backup/notes.txt", "allowed") + .expect("similar non-runtime path remains accessible"); + assert_eq!( + read_local_project_file_at(&root, ".agent/runtime-backup/notes.txt") + .expect("read similar path") + .content, + "allowed" + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_project_file_read_rejects_sensitive_config_files() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 41253d2c6..1672eb50e 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -249,6 +249,7 @@ interface AgentRuntimeState { activePlanStepIndex?: number | null; observations: string[]; recentToolCalls?: AgentRuntimeToolCallRecord[]; + pendingToolAction?: AgentRuntimePendingToolActionSummary | null; taskQueue?: AgentRuntimeTaskQueueSummary; allowedTools: string[]; toolPolicy?: AgentRuntimeToolPolicySnapshot; @@ -278,6 +279,15 @@ interface AgentRuntimeToolCallRecord { updatedAt: number; } +interface AgentRuntimePendingToolActionSummary { + actionId: string; + actionFingerprint: string; + tool: string; + inputSummary: string | null; + reason: string | null; + requestedAt: number; +} + interface AgentRuntimePlanStep { index: number; title: string; @@ -662,6 +672,8 @@ function agentRuntimeWaitingOnFromPhase(phase: string) { return '工具观察结果'; case 'waiting-for-confirmation': return '开发者确认 Agent 工具动作'; + case 'cancelling': + return '当前 LLM 或工具调用返回'; case 'response': return 'Agent 整理最终回复'; case 'completed': @@ -684,6 +696,8 @@ function agentRuntimeNextStepFromPhase(phase: string) { return '等待工具观察结果'; case 'waiting-for-confirmation': return '等待开发者确认工具动作'; + case 'cancelling': + return '取消完成后可重试该任务或提交新任务'; case 'response': return '等待 Agent 整理最终回复'; case 'completed': @@ -785,13 +799,17 @@ function AgentRuntimeStatusPanel({ onCancelRuntimeTask, onRetryRuntimeTask, onConfirmRuntimeTask, + onRejectRuntimeTask, + onRefreshRuntime, }: { runtime: AgentRuntimeState | null; error?: string | null; controlBusy?: boolean; onCancelRuntimeTask?: (runId: string) => void; onRetryRuntimeTask?: (runId: string) => void; - onConfirmRuntimeTask?: (runId: string) => void; + onConfirmRuntimeTask?: (runId: string, actionId: string) => void; + onRejectRuntimeTask?: (runId: string, actionId: string) => void; + onRefreshRuntime?: () => void; }) { if (!runtime && error) { return ( @@ -800,6 +818,11 @@ function AgentRuntimeStatusPanel({ Runtime 状态读取失败

{error}

+ {onRefreshRuntime ? ( + + ) : null} ); } @@ -818,41 +841,70 @@ function AgentRuntimeStatusPanel({ const nextStep = runtime.nextStep ?? agentRuntimeNextStepFromPhase(runtime.phase); const currentGoal = runtime.currentGoal ?? runtime.currentTask; const waitingOn = runtime.waitingOn ?? agentRuntimeWaitingOnFromPhase(runtime.phase); + const pendingToolAction = runtime.pendingToolAction ?? null; const canCancel = Boolean(runtime.runId) && - agentRuntimeCanCancel(runtime.status) && + (agentRuntimeCanCancel(runtime.status) || + (runtime.phase === 'needs-reconciliation' && Boolean(pendingToolAction))) && Boolean(onCancelRuntimeTask); const canRetry = Boolean(runtime.runId) && agentRuntimeCanRetry(runtime.status) && + !pendingToolAction && Boolean(onRetryRuntimeTask); const canConfirm = Boolean(runtime.runId) && + Boolean(pendingToolAction?.actionId) && agentRuntimeCanConfirm(runtime.status) && Boolean(onConfirmRuntimeTask); + const canReject = + Boolean(runtime.runId) && + Boolean(pendingToolAction?.actionId) && + agentRuntimeCanConfirm(runtime.status) && + Boolean(onRejectRuntimeTask); return (
{`${runtime.status} / ${runtime.phase}`} {runtime.sessionId}
- {onCancelRuntimeTask || onRetryRuntimeTask || onConfirmRuntimeTask ? ( + {onCancelRuntimeTask || + onRetryRuntimeTask || + onConfirmRuntimeTask || + onRejectRuntimeTask || + onRefreshRuntime ? (
+ +
) : null} {`task: ${runtime.taskId} · ${runtime.source}`} @@ -870,6 +929,21 @@ function AgentRuntimeStatusPanel({ {runtime.currentAction} {waitingOn ? {`等待:${waitingOn}`} : null} {nextStep ? {`下一步:${nextStep}`} : null} + {pendingToolAction ? ( + + {`${ + runtime.phase === 'needs-reconciliation' + ? '待核对动作' + : '待确认动作' + }:${pendingToolAction.tool}${ + pendingToolAction.inputSummary + ? ` · ${pendingToolAction.inputSummary}` + : '' + }`} + + ) : runtime.status === 'waiting-for-confirmation' ? ( + 待确认动作摘要缺失,请刷新状态 + ) : null} {loopProgress ? {loopProgress} : null} {taskQueueSummary ? {taskQueueSummary} : null} {toolPolicy ? ( @@ -3937,10 +4011,19 @@ export function WorkspaceLauncher({ } } - async function handleAgentChatConfirmRuntimeTask(runId: string) { + async function handleAgentChatConfirmRuntimeTask( + runId: string, + actionId: string, + ) { const projectPathForChat = validateAgentChatProjectPath(); const agent = selectedLauncherAgentChatAgent(); - if (!projectPathForChat || !agent || !runId || agentChatBackgroundBusy) { + if ( + !projectPathForChat || + !agent || + !runId || + !actionId || + agentChatBackgroundBusy + ) { return; } const llmWarning = getCurrentAgentChatLlmWarning(agent); @@ -3964,7 +4047,7 @@ export function WorkspaceLauncher({ projectPath: projectPathForChat, agentId: agent.id, runId, - nextRunId: createAgentChatRunId('launcher-agent-confirm'), + actionId, note: '开发者已确认待执行工具动作', }, ); @@ -3997,6 +4080,75 @@ export function WorkspaceLauncher({ } } + async function handleAgentChatRejectRuntimeTask( + runId: string, + actionId: string, + ) { + const projectPathForChat = validateAgentChatProjectPath(); + const agent = selectedLauncherAgentChatAgent(); + if ( + !projectPathForChat || + !agent || + !runId || + !actionId || + agentChatBackgroundBusy + ) { + return; + } + const llmWarning = getCurrentAgentChatLlmWarning(agent); + if (llmWarning) { + setAgentChatStatus(llmWarning); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setAgentChatStatus('需要在 Tauri App 内运行'); + return; + } + const saveVersion = agentChatLoadVersionRef.current + 1; + agentChatLoadVersionRef.current = saveVersion; + setAgentChatBackgroundBusy(true); + setAgentChatStatus('正在拒绝工具动作并继续 Agent 后台任务'); + try { + const runtime = await invoke( + 'reject_game_creator_agent_runtime_task', + { + projectPath: projectPathForChat, + agentId: agent.id, + runId, + actionId, + note: '开发者拒绝待执行工具动作', + }, + ); + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + setAgentChatRuntime(agentRuntimeStateFromResult(runtime)); + setAgentChatRuntimeError(''); + const conversation = await invoke( + 'read_local_conversation', + { + projectPath: projectPathForChat, + agentId: agent.id, + }, + ); + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + setAgentChatMessages(conversation.messages); + setAgentChatStatus(agentRuntimeStartStatus(runtime)); + } catch (error) { + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + setAgentChatStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (agentChatLoadVersionRef.current === saveVersion) { + setAgentChatBackgroundBusy(false); + } + } + } + const projectRows = recentWorkspaces.map((workspace) => { const directoryStatus = recentWorkspaceStatuses[workspace]; const isPendingStatus = directoryStatus === undefined; @@ -4671,9 +4823,13 @@ export function WorkspaceLauncher({ onRetryRuntimeTask={(runId) => void handleAgentChatRetryRuntimeTask(runId) } - onConfirmRuntimeTask={(runId) => - void handleAgentChatConfirmRuntimeTask(runId) + onConfirmRuntimeTask={(runId, actionId) => + void handleAgentChatConfirmRuntimeTask(runId, actionId) } + onRejectRuntimeTask={(runId, actionId) => + void handleAgentChatRejectRuntimeTask(runId, actionId) + } + onRefreshRuntime={() => void loadAgentChatConversation()} />
@@ -13951,8 +14107,14 @@ export function App() { async function confirmSelectedAgentRuntimeTask( agent: AgentStatusCard, runId: string, + actionId: string, ) { - if (!agent || !runId || agentConversationBackgroundBusyRef.current) { + if ( + !agent || + !runId || + !actionId || + agentConversationBackgroundBusyRef.current + ) { return; } const invoke = resolveTauriInvoke(); @@ -13981,7 +14143,7 @@ export function App() { projectPath: nextProjectPath, agentId: agent.id, runId, - nextRunId: createAgentChatRunId('agent-background-confirm'), + actionId, note: '开发者已确认待执行工具动作', }, ); @@ -14021,6 +14183,85 @@ export function App() { } } + async function rejectSelectedAgentRuntimeTask( + agent: AgentStatusCard, + runId: string, + actionId: string, + ) { + if ( + !agent || + !runId || + !actionId || + agentConversationBackgroundBusyRef.current + ) { + return; + } + const invoke = resolveTauriInvoke(); + const nextProjectPath = resolveChatProjectPath(localProject); + if (!invoke) { + setAgentConversationStatus('需要在 Tauri App 内运行'); + return; + } + if (!nextProjectPath) { + setAgentConversationStatus('请先初始化本地项目'); + return; + } + const llmWarning = formatAgentLlmConfigWarning(llmConfigStatus, agent); + if (llmWarning) { + setAgentConversationStatus(llmWarning); + return; + } + const saveVersion = agentConversationLoadVersionRef.current; + agentConversationBackgroundBusyRef.current = true; + setAgentConversationBackgroundBusy(true); + setAgentConversationStatus('正在拒绝工具动作并继续 Agent 后台任务'); + try { + const runtime = await invoke( + 'reject_game_creator_agent_runtime_task', + { + projectPath: nextProjectPath, + agentId: agent.id, + runId, + actionId, + note: '开发者拒绝待执行工具动作', + }, + ); + if (agentConversationLoadVersionRef.current !== saveVersion) { + return; + } + const nextRuntime = agentRuntimeStateFromResult(runtime); + setAgentConversationRuntime(nextRuntime); + rememberAgentRuntimeState(nextRuntime); + setAgentConversationRuntimeError(''); + const conversation = await invoke( + 'read_local_conversation', + { + projectPath: nextProjectPath, + agentId: agent.id, + }, + ); + if (agentConversationLoadVersionRef.current !== saveVersion) { + return; + } + setAgentConversationMessages(conversation.messages); + setAgentConversationStatus(agentRuntimeStartStatus(runtime)); + setCommandLog((current) => [ + ...current, + 'agent.runtime.tool_confirmation.rejected', + ]); + } catch (error) { + if (agentConversationLoadVersionRef.current !== saveVersion) { + return; + } + setAgentConversationStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + agentConversationBackgroundBusyRef.current = false; + setAgentConversationBackgroundBusy(false); + } + } + async function saveSelectedAgentPrivateMemory( agent: AgentStatusCard, content: string, @@ -21489,9 +21730,27 @@ export function App() { ? void retrySelectedAgentRuntimeTask(selectedAgent, runId) : undefined } - onConfirmRuntimeTask={(runId) => + onConfirmRuntimeTask={(runId, actionId) => selectedAgent - ? void confirmSelectedAgentRuntimeTask(selectedAgent, runId) + ? void confirmSelectedAgentRuntimeTask( + selectedAgent, + runId, + actionId, + ) + : undefined + } + onRejectRuntimeTask={(runId, actionId) => + selectedAgent + ? void rejectSelectedAgentRuntimeTask( + selectedAgent, + runId, + actionId, + ) + : undefined + } + onRefreshRuntime={() => + selectedAgent + ? void openAgentConversation(selectedAgent, true, true) : undefined } /> diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 0595badf9..e3f25fad5 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1670,7 +1670,7 @@ describe('AI 游戏创作 App 界面边界', () => { const runtimeActions = screen.getByLabelText('Agent Runtime 操作'); expect( (within(runtimeActions).getByRole('button', { - name: '取消', + name: '取消任务', }) as HTMLButtonElement).disabled, ).toBe(false); expect( @@ -1735,6 +1735,52 @@ describe('AI 游戏创作 App 界面边界', () => { }, ]); + const cancellingRuntimeState = { + ...runningRuntimeState, + status: 'cancelling', + phase: 'cancelling', + currentAction: '正在取消 Agent 后台任务', + waitingOn: '当前 LLM 或工具调用返回', + nextStep: '取消完成后可重试该任务或提交新任务', + }; + await act(async () => { + runtimeUpdateHandler?.({ + payload: { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + runId: 'launcher-agent-task-test', + status: 'cancelling', + phase: 'cancelling', + runtime: { + state: cancellingRuntimeState, + sessionPath: + '/tmp/authorized-game/.agent/runtime/agents/design-director.json', + eventPath: + '/tmp/authorized-game/.agent/runtime/events/design-director.jsonl', + taskPath: + '/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl', + taskQueue: cancellingRuntimeState.taskQueue, + recentEvents: runningRuntimeEvents, + recentTasks: [runningRuntimeTask], + }, + }, + }); + }); + + expect(await screen.findByText('cancelling / cancelling')).not.toBeNull(); + expect(screen.getByText('等待:当前 LLM 或工具调用返回')).not.toBeNull(); + const cancellingActions = screen.getByLabelText('Agent Runtime 操作'); + expect( + (within(cancellingActions).getByRole('button', { + name: '取消任务', + }) as HTMLButtonElement).disabled, + ).toBe(true); + expect( + (within(cancellingActions).getByRole('button', { + name: '重试', + }) as HTMLButtonElement).disabled, + ).toBe(true); + await act(async () => { runtimeUpdateHandler?.({ payload: { @@ -1784,7 +1830,7 @@ describe('AI 游戏创作 App 界面边界', () => { ).not.toBeNull(); }); - it('confirms the exact pending tool action from the developer agent window', async () => { + it('confirms or rejects the exact pending tool action from the developer agent window', async () => { const waitingRuntimeState = { schemaVersion: 'game-creator-agent-runtime.v1', agentId: 'design-director', @@ -1816,6 +1862,14 @@ describe('AI 游戏创作 App 界面边界', () => { updatedAt: 5000, }, ], + pendingToolAction: { + actionId: `action-${'b'.repeat(24)}`, + actionFingerprint: 'b'.repeat(64), + tool: 'file.read', + inputSummary: 'path=game/notes.txt', + reason: '读取角色规范依据', + requestedAt: 5000, + }, taskQueue: { total: 1, pending: 0, @@ -1895,7 +1949,7 @@ describe('AI 游戏创作 App 界面边界', () => { return runtimeResult; } if (command === 'confirm_game_creator_agent_runtime_task') { - const runId = String(args?.nextRunId ?? 'launcher-agent-confirmed'); + const runId = waitingRuntimeState.runId; const taskQueue = { ...waitingRuntimeState.taskQueue, running: 1, @@ -1908,9 +1962,10 @@ describe('AI 游戏创作 App 界面边界', () => { ...waitingRuntimeState, runId, status: 'running', - phase: 'planning', - currentAction: '生成 Agent 工具计划(第 1 轮)', - waitingOn: 'Agent 输出计划或回复', + phase: 'action', + currentAction: '执行已确认工具 file.read', + waitingOn: '已确认工具执行结果', + pendingToolAction: null, taskQueue, }, taskQueue, @@ -1919,7 +1974,38 @@ describe('AI 游戏创作 App 界面边界', () => { ...waitingTask, runId, status: 'running', - phase: 'planning', + phase: 'action', + }, + ], + }; + } + if (command === 'reject_game_creator_agent_runtime_task') { + const runId = waitingRuntimeState.runId; + const taskQueue = { + ...waitingRuntimeState.taskQueue, + running: 1, + waitingForConfirmation: 0, + latestRunId: runId, + }; + return { + ...runtimeResult, + state: { + ...waitingRuntimeState, + runId, + status: 'running', + phase: 'observation', + currentAction: '开发者拒绝工具 file.read', + waitingOn: 'Agent 根据拒绝结果修正计划', + pendingToolAction: null, + taskQueue, + }, + taskQueue, + recentTasks: [ + { + ...waitingTask, + runId, + status: 'running', + phase: 'observation', }, ], }; @@ -1945,12 +2031,38 @@ describe('AI 游戏创作 App 界面边界', () => { 'file.read · waiting-for-confirmation · 项目权限策略要求用户确认:file.read · 目标:path=game/notes.txt · 读取角色规范依据', ), ).not.toBeNull(); + expect( + screen.getByText('待确认动作:file.read · path=game/notes.txt'), + ).not.toBeNull(); const runtimeActions = screen.getByLabelText('Agent Runtime 操作'); const confirmButton = within(runtimeActions).getByRole('button', { name: '确认继续', }) as HTMLButtonElement; expect(confirmButton.disabled).toBe(false); - fireEvent.click(confirmButton); + expect( + (within(runtimeActions).getByRole('button', { + name: '拒绝并继续', + }) as HTMLButtonElement).disabled, + ).toBe(false); + const runtimeReadsBeforeRefresh = invoke.mock.calls.filter( + ([command]) => command === 'read_game_creator_agent_runtime', + ).length; + fireEvent.click( + within(runtimeActions).getByRole('button', { name: '刷新状态' }), + ); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'read_game_creator_agent_runtime', + ).length, + ).toBeGreaterThan(runtimeReadsBeforeRefresh); + }); + const refreshedRuntimeActions = screen.getByLabelText('Agent Runtime 操作'); + const refreshedConfirmButton = within(refreshedRuntimeActions).getByRole( + 'button', + { name: '确认继续' }, + ); + fireEvent.click(refreshedConfirmButton); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( @@ -1959,11 +2071,45 @@ describe('AI 游戏创作 App 界面边界', () => { projectPath: '/tmp/authorized-game', agentId: 'design-director', runId: 'launcher-agent-waiting', + actionId: `action-${'b'.repeat(24)}`, note: '开发者已确认待执行工具动作', }), ); }); - expect(await screen.findByText('running / planning')).not.toBeNull(); + expect(await screen.findByText('running / action')).not.toBeNull(); + + cleanup(); + invoke.mockClear(); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAgentChatAt('/?agent-chat'); + fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '读取历史' })); + expect( + await screen.findByText( + 'waiting-for-confirmation / waiting-for-confirmation', + ), + ).not.toBeNull(); + const rejectRuntimeActions = screen.getByLabelText('Agent Runtime 操作'); + fireEvent.click( + within(rejectRuntimeActions).getByRole('button', { + name: '拒绝并继续', + }), + ); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'reject_game_creator_agent_runtime_task', + expect.objectContaining({ + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + runId: 'launcher-agent-waiting', + actionId: `action-${'b'.repeat(24)}`, + note: '开发者拒绝待执行工具动作', + }), + ); + }); + expect(await screen.findByText('running / observation')).not.toBeNull(); }); it('shows queued developer agent background tasks when the agent is already running', async () => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1dd9adbfb..1587704eb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -19,7 +19,7 @@ ## 2026-07-09 AI 游戏创作 App Runtime V1 增加单 Agent 后台任务 - 背景:开发用单 Agent 聊天已经能真实调用各 Agent 的 LLM 路由并持久化对话,但 Agent 仍主要表现为同步问答,用户无法明确投递一个任务让某个 Agent 独立运行,也无法同时启动多个 Agent 的工作。 -- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 按轮输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具并记录 `action / observation` 事件,再把已有 observation 放回下一轮 prompt,让 Agent 修正计划、继续行动或用空 actions + response 收束;当前后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加共享黑板,`agent.message` 写目标 Agent 对话,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列;策略要求确认或拒绝时不执行写入、运行或委派,只把策略结果作为 observation 回给 Agent。每个 Agent 的任务历史落在 `.agent/runtime/tasks/.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,Runtime state 增加 `nextStep`,UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。 +- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 按轮输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具并记录 `action / observation` 事件,再把已有 observation 放回下一轮 prompt,让 Agent 修正计划、继续行动或用空 actions + response 收束;当前后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加共享黑板,`agent.message` 写目标 Agent 对话,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列;策略拒绝时不执行工具并把 `blocked` observation 回给 Agent;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。每个 Agent 的任务历史落在 `.agent/runtime/tasks/.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,Runtime state 增加 `nextStep`,UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。 - 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`;开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都只把该事件作为实时 UI 通知并复用前端 runtime 归一化合并,事实源仍是 `.agent/runtime/agents`、`events` 和 `tasks` 文件。 - 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `preview.start`,让 Agent 在完成写盘或静态自检后能按策略自行启动当前项目的 `127.0.0.1` 本地 HTTP 预览。该工具复用 `preview.start` 权限策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑;写入 `.agent/agent.db` 的审计类型为 `agent.runtime.preview.start`。发给 LLM 的 observation 只包含 localhost URL 和端口,不包含用户项目绝对路径。 - 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `canvas.asset_generate`,让美术类 Agent 可在 loop 中自行请求生成首版美术素材。该工具读取 AppData / Tauri 配置中的 `editorApi`,复用 `canvas.asset_generate` 权限策略、项目写锁、External Editor API 生成和下载链路、manifest 资产登记以及 `canvas.asset_generate` 本地索引记录;另写 `agent.runtime.canvas.asset_generate` 记录到 `.agent/agent.db`,标明触发的 agent 与本地素材路径。API Key 不进入 prompt observation、manifest、agent.db 或日志;策略要求确认或拒绝时不会调用外部 API。 @@ -4066,11 +4066,12 @@ - 2026-07-10 调整:Agent Runtime state 新增 `toolPolicy`,从项目权限策略派生工具级 `allowedTools`、`autoTools`、`confirmTools` 和 `deniedTools`。后台 planning prompt 必须带入该快照,让 Agent 在规划阶段知道工具策略;执行阶段仍由 Runtime 白名单和项目权限 gate 决定。`blackboard.write` 继承 `memory.write` 策略,`agent.message` 继承 `conversation.write` 策略,`agent.delegate` 使用独立 `agent.delegate` 策略。 - 2026-07-10 调整:`.agent/policy.json` 支持 `agentPolicies`,用规范 Agent id 保存单个 Agent 的 `deniedCommands / confirmCommands`。Runtime 计算有效工具策略时把项目级策略和 Agent 级策略叠加,项目级策略继续对所有 Agent 生效,Agent 级策略只能进一步拒绝或要求确认,不能放宽项目级策略;拒绝优先于确认。主聊天新增 `/agent-policy-deny Agent 命令`、`/agent-policy-allow Agent 命令`、`/agent-policy-confirm Agent 命令` 和 `/agent-policy-auto Agent 命令`,继续通过 `project.policy_write` 确认卡写入策略。 - 2026-07-10 调整:后台 Agent 工具命中确认策略时不再当作 `blocked` observation 继续收尾,而是把当前 Runtime 写成 `status/phase = waiting-for-confirmation`,`waitingOn` 固定为等待开发者确认工具动作,`recentToolCalls`、事件流、任务记录和 `taskQueue.waitingForConfirmation` 都保留该事实;同一 Agent 的后台 drain 暂停,不继续消费后续 pending 任务。命中拒绝策略仍使用 `blocked` observation 交回 Agent 修正计划。 -- 2026-07-10 调整:Agent Runtime 后台任务支持按 Agent / runId 取消和重试。取消通过 `.agent/runtime/cancel//.json` 写入本地取消请求,并向任务 JSONL、事件流和 `agent.db` 追加 `cancelled` 审计;pending 任务被取消后不会被 drain 消费,running 任务会在当前 LLM 或工具调用返回后的检查点停止,不再继续执行工具或保存最终 assistant 回复。重试只能基于已有非 running / pending / waiting-for-confirmation 任务创建新的 run,并继续走 `agent.resume` 自动权限和同一 Agent 队列锁。 +- 2026-07-10 调整:Agent Runtime 后台任务支持按 Agent / runId 取消和重试。取消先通过 `.agent/runtime/cancel//.json` 写入本地取消请求;pending 任务被取消后不会被 drain 消费,running 任务在原 worker 仍持锁时只投影为 `cancelling`,必须等当前 LLM 或工具调用返回后的检查点真正停下,才由持锁 worker 向任务 JSONL、事件流和 `agent.db` 追加 `cancelled` 审计,不再继续执行工具或保存最终 assistant 回复。`cancelling` 期间禁止重试;重试只能基于已有非 running / pending / waiting-for-confirmation / cancelling 任务创建新的 run,并继续走 `agent.resume` 自动权限和同一 Agent 队列锁。 - 2026-07-10 调整:Agent Runtime 后台任务的 `runId` 是同一 Agent 任务历史的身份,不允许复用覆盖。`start_game_creator_agent_runtime_task`、`agent.delegate` 和 retry 进入后台队列前会读取该 Agent 全量 task JSONL 历史;若调用方传入的规范化 runId 已存在,Runtime 自动追加 `-dup--` 生成实际 runId。任务队列、delegate observation 和 `agent.db` 审计都必须使用实际 runId,避免 `latest_game_creator_agent_runtime_tasks` 按 runId 去重时折叠掉不同任务。 - 2026-07-10 调整:Agent Runtime 的 `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 调整:Agent Runtime 和本地对话使用 append-only JSONL 作为事实源时,进程内必须按目标文件路径串行追加整行。`.agent/agent.db`、`.agent/conversations/**/*.jsonl`、`.agent/runtime/events/*.jsonl`、`.agent/runtime/tasks/*.jsonl`、`.agent/activity.jsonl` 和 `.agent/output.jsonl` 统一走共享追加 helper,避免多个后台 Agent 并行完成时 JSON record 与换行交错。 -- 2026-07-10 调整:Agent Runtime 待确认工具动作支持确认后继续。开发者确认 `waiting-for-confirmation` run 时,Runtime 为新 run 写入一次性 `.agent/runtime/confirmations///.json` 票据并重新入队;票据同时保存工具名与输入 JSON 的 SHA-256 `actionFingerprint`,权限 gate 在 deny 之后、confirm 阶段只放行对应 Agent/run/command/fingerprint 一次,模型若把同一工具改成其他路径、checkpoint 或目标 Agent,旧票据立即失效并重新进入待确认。`recentToolCalls` 和确认审计只额外保存安全 `inputSummary`,例如相对路径、checkpoint id、目标 Agent 或内容字符数,不保存待写正文、消息正文、素材 prompt 或 API Key。开发窗口必须把 `onConfirmRuntimeTask` 接入真实“确认继续”按钮;该显式确认命令允许 `agent.resume` 处于 confirm,只继续服从 deny,自动重启恢复仍要求 `agent.resume` 为 auto。原 waiting run 追加 `completed/confirmed` 任务记录,避免队列长期显示等待确认;审计记录写 `agent.runtime.tool_confirmation.approved`。 +- 2026-07-10 调整:Agent Runtime 待确认工具动作改用 durable `AgentRuntimePendingToolAction`。Runtime 将精确 `action` 输入、当前 task/run、loop 轮次、action 序号、计划、已有 observations 与后续 loop 所需上下文先做敏感内容和项目绝对路径校验,再通过临时文件替换原子写入 `.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 内输入相同的两次动作仍是两个不同发生。确认和拒绝都必须匹配 `runId + actionId`,Runtime 会重算指纹并与私有落盘动作及公共摘要交叉校验,不一致时失败关闭。确认通过后在同一 run 直接执行持久化的原 action,把真实 observation 接回后续 Agent loop,不创建新 run,也不让模型重复生成待确认动作;拒绝不执行工具,写入 `blocked` observation 后在同一 run 继续规划。待确认账本按 `pending-confirmation / approved / executing / observed-approved / observed-rejected` 迁移:重启时 `approved` 可恢复精确动作,已持久化 observation 可直接续 loop,`executing` 表示外部副作用结果未知,Runtime 必须进入 `failed / needs-reconciliation` 并禁止自动重放,开发者核对项目状态后只能先取消原任务。waiting run、完整待确认动作和安全摘要均已落盘,App 重启不会越过该 run 去启动后续任务;等待期间同 Agent 新任务只保持 `pending`,确认、拒绝或取消结束后再由同一 drain 串行排空。`.agent/runtime/` 是 Runtime 私有控制面,通用 `file.list / file.read / file.write / file.delete` 不得列出、读取、修改或删除;checkpoint/index/diff/restore 继续整体排除该目录。每 Agent 锁包含唯一 token,旧持有者析构时只删除自己的锁;Linux 上其他仍存活进程的锁不会因超过固定时长被抢占。确认、拒绝及工具 observation 分别写入 `agent.runtime.tool_confirmation.approved`、`agent.runtime.tool_confirmation.rejected` 和 `agent.runtime.tool_observation` 审计;pending 和 confirmation 文件只在 observation/终态可靠落盘后清理,失败清理会显式报错。 +- 2026-07-10 调整:per-agent 互斥锁最终改用 OS 级文件锁,而不是依赖 JSON token、PID、超时和 `remove + create_new` 竞争所有权。Unix 使用非阻塞独占 `flock`,Windows 使用禁止共享的文件句柄;锁文件只保留诊断元数据并长期存在,进程退出会由 OS 释放所有权。确认、拒绝和取消必须先取得同一系统锁,再重新读取 runtime、latest task 和 durable pending action 后迁移状态;恢复入口也必须先拿锁,再读取 durable pending action 或 recoverable task,禁止用锁外旧快照覆盖并发结果。waiting 取消只短暂等待原 worker 释放系统锁,running 取消拿不到锁时只保留 tombstone,并由原 worker 在 LLM / 工具成功或失败返回后的检查点收束,不得根据 Runtime status 抢锁。这条最终实现取代上一条中的 token 删除和 Linux PID 存活判断描述。 - 2026-07-10 调整:Agent Runtime 工具箱新增 `task.create`,用于让 Agent 把目标拆成新的 manifest 任务,而不只能更新 seed task。该工具默认 `confirm` 权限,写入前要求 taskId 唯一、依赖指向已有任务、列表长度受限,并写 `agent.runtime.task.create` 审计;策略要求确认或拒绝时不修改 `.agent/manifest.json`。 - 2026-07-10 调整:Agent Runtime 新增 `agent.schedule_ready` 调度入口,默认 `confirm` 权限。命令会扫描 `.agent/manifest.json` 中依赖已完成且仍为 `pending` 的 ready task,先把任务标成 `running`,再用 taskId 作为 Agent id 投递到既有后台队列,source 记为 `agent-ready-task-scheduler`,并写 `agent.runtime.ready_task.scheduled` 审计;后续执行仍走原 per-agent 锁、任务 JSONL、LLM loop、工具策略和事件流,不新增独立 worker。默认确认策略下该命令不会静默调度。 - 2026-07-10 调整:Agent Runtime state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、actionFingerprint、inputSummary、reason、summary、detail 和 updatedAt。状态面板展示最近动作与安全目标摘要时使用该字段,不解析 observation 文本;写入前继续过滤敏感上下文,不保存原始密钥、待写正文或任意未过滤输入。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a5dfd66ec..b45aa91e1 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -32,15 +32,16 @@ Agent Runtime 负责: - 开发窗口能力:debug 构建额外打开 `developer` 窗口,走 `index.html?agent-chat`;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/.jsonl`,通过 `agentLlm.` 调用该 Agent 的独立 LLM 路由做真实对话,用于单独调试某个 Agent 的长期对话上下文。这里的 `` 以 manifest taskId 为规范值,旧 `group-role` 别名只作为兼容输入映射到 taskId。 - 命令能力:内置命令调用、权限 gate、执行日志;v1 只允许白名单受限命令,不执行任意 shell。 - 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作;Runtime V1 会为单 Agent 对话和生成 loop 中的角色 brief 写入独立 runtime state / event,先解决“每个 Agent 正在做什么、跑到哪一步、最近一次 task/run 是什么”的可观测性。 -- 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/.json`、`.agent/runtime/events/.jsonl`、`.agent/runtime/tasks/.jsonl` 和 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:每轮让该 Agent 输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具动作,写入 `thinking_summary / plan / action / observation / response / error` 事件,再把 observation 放入下一轮 prompt 让 Agent 修正计划、继续行动或用空 actions + response 收束;后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复并追加回对话。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`file.write`、`task.create`、`task.update`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`project.checkpoint` 可在写入或批量修改前创建本地 checkpoint,`project.restore` 可在确认后把项目恢复到指定 checkpoint,`file.write` 只能写项目内相对路径并记录审计,`task.create` 只能追加经过校验的新 manifest 任务,`task.update` 只能更新已有 manifest 任务状态,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加 `memory/blackboard.md`,`agent.message` 给目标 `.agent/conversations/agents/.jsonl` 写入 tool 留言,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列,策略要求确认或拒绝时不执行写入、运行、恢复或委派,只把策略结果作为 observation 回给 Agent。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务和最近事件,`read_game_creator_agent_runtimes` 批量读取所有规范 taskId 的 runtime;开发窗口和项目内 Agent 对话弹窗的 Runtime 状态面板展示最近事件、最近任务、当前目标、当前任务、当前动作、等待对象、下一步和运行阶段,主窗口 Agent 状态列表展示当前目标、任务、动作、等待对象和运行阶段摘要。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 已有运行任务时,新任务会先写成 `pending / queued`,由当前后台 drain 在完成后串行继续执行。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程或跨重启离线常驻 worker。 +- 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/.json`、`.agent/runtime/events/.jsonl`、`.agent/runtime/tasks/.jsonl` 和 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:每轮让该 Agent 输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具动作,写入 `thinking_summary / plan / action / observation / response / error` 事件,再把 observation 放入下一轮 prompt 让 Agent 修正计划、继续行动或用空 actions + response 收束;后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复并追加回对话。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`file.write`、`task.create`、`task.update`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`project.checkpoint` 可在写入或批量修改前创建本地 checkpoint,`project.restore` 可在确认后把项目恢复到指定 checkpoint,`file.write` 只能写项目内相对路径并记录审计,`task.create` 只能追加经过校验的新 manifest 任务,`task.update` 只能更新已有 manifest 任务状态,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加 `memory/blackboard.md`,`agent.message` 给目标 `.agent/conversations/agents/.jsonl` 写入 tool 留言,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列,策略拒绝时不执行工具并返回 `blocked` observation;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务和最近事件,`read_game_creator_agent_runtimes` 批量读取所有规范 taskId 的 runtime;开发窗口和项目内 Agent 对话弹窗的 Runtime 状态面板展示最近事件、最近任务、当前目标、当前任务、当前动作、等待对象、下一步和运行阶段,主窗口 Agent 状态列表展示当前目标、任务、动作、等待对象和运行阶段摘要。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 已有运行任务时,新任务会先写成 `pending / queued`,由当前后台 drain 在完成后串行继续执行。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程或跨重启离线常驻 worker。 - 2026-07-10 补充:Agent Runtime state 新增 `toolPolicy`,按当前项目 `.agent/policy.json` 派生工具级 `allowedTools / autoTools / confirmTools / deniedTools` 快照;后台 planning prompt 会带入该快照,让 Agent 在规划时知道哪些工具会自动执行、需要确认或被拒绝。`blackboard.write` 继承 `memory.write` 策略,`agent.message` 继承 `conversation.write` 策略,`agent.delegate` 使用独立 `agent.delegate` 策略;实际执行仍以 Runtime 的白名单和项目权限 gate 为准。 - 2026-07-10 补充:`.agent/policy.json` 新增 `agentPolicies`,可按规范 Agent id 分别配置 `deniedCommands / confirmCommands`。有效策略为“项目级策略 + Agent 级策略”的保守叠加:项目级拒绝 / 确认仍对所有 Agent 生效,Agent 级策略只能进一步限制该 Agent,不能放宽项目级策略,拒绝优先于确认。主聊天新增 `/agent-policy-deny Agent 命令`、`/agent-policy-allow Agent 命令`、`/agent-policy-confirm Agent 命令` 和 `/agent-policy-auto Agent 命令`,写入前仍走 `project.policy_write` 确认卡。 - 2026-07-10 补充:后台 Agent 工具命中 `confirmCommands` 时不再继续整理最终回复,而是把本轮 Runtime 停在 `status/phase = waiting-for-confirmation`,`waitingOn` 指向“开发者确认 Agent 工具动作”,`recentToolCalls`、事件流、任务记录和 `taskQueue.waitingForConfirmation` 都保留该待确认事实;同一 Agent 的 drain 会暂停,不继续消费后续 pending 任务。命中拒绝策略仍作为 `blocked` observation 交回 Agent 继续修正计划。 -- 2026-07-10 补充:后台 Agent 任务支持按 Agent / runId 取消和重试。取消会写 `.agent/runtime/cancel//.json`,并把任务 JSONL、事件流和 `agent.db` 记录到 `cancelled`;pending 任务被取消后不会再被同一 Agent drain 消费,running 任务会在当前 LLM / 工具调用返回后的检查点停止,不继续执行工具或保存最终 assistant 回复。重试只能基于已有非 running / pending / waiting-for-confirmation 任务创建新的 run,继续复用同一 Agent 队列锁和 `agent.resume` 自动权限。 +- 2026-07-10 补充:后台 Agent 任务支持按 Agent / runId 取消和重试。取消先写 `.agent/runtime/cancel//.json`;pending 任务被取消后不会再被同一 Agent drain 消费,running 任务在 worker 仍持锁时只对外投影为 `status/phase = cancelling`,必须等当前 LLM / 工具调用返回后的检查点真正停止后,才由持锁 worker 把任务 JSONL、事件流和 `agent.db` 记录到 `cancelled`,期间不允许重试,也不保存最终 assistant 回复。重试只能基于已有非 running / pending / waiting-for-confirmation / cancelling 任务创建新的 run,继续复用同一 Agent 队列锁和 `agent.resume` 自动权限。 - 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 补充:后台 Agent 待确认工具动作支持由开发者确认后继续。确认命令会为新 run 写入一次性工具确认票据,票据绑定 `agentId/runId/commandId/actionFingerprint`;`actionFingerprint` 是工具名和完整输入 JSON 的 SHA-256,权限 gate 在 deny 规则之后只放行完全一致的动作一次,同一工具若改了路径、checkpoint 或目标 Agent 会作废旧票据并重新等待确认。开发窗口的 Runtime 面板接入真实“确认继续”回调;显式确认命令允许 `agent.resume` 保持默认 confirm,只服从 deny,自动重启恢复仍要求 `agent.resume` 为 auto。原 waiting run 会追加 `completed/confirmed` 任务记录,新 run 继续走正常后台队列、LLM 规划和工具 observation 闭环。 +- 2026-07-10 补充:后台 Agent 待确认工具动作改用 durable `AgentRuntimePendingToolAction`。完整记录包含精确工具 action、当前 task/run、loop 轮次、action 序号、计划、已有 observations 和续跑上下文;写入前拒绝密钥、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`,禁止自动重放外部副作用,开发者核对项目状态后先取消原任务。等待期间同 Agent 新任务保持 `pending`,重启不会越过 waiting run,确认、拒绝或取消后再串行排空。`.agent/runtime/` 作为私有控制面,不允许通用文件工具列出、读取、写入或删除;每 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 补充:后台任务工具箱已加入 `task.create`。Agent 可在 loop 中把拆解出的后续工作追加为 manifest 任务;Runtime 复用 `task.create` 策略和项目写锁,写入前校验 taskId 唯一、依赖指向已有任务、任务分组合法、标题 / 角色非空以及列表长度,并写入 `agent.runtime.task.create` 审计记录。策略要求确认或拒绝时不会修改 manifest。 - 2026-07-10 补充:Agent Runtime state 新增 `recentToolCalls`,每次后台工具执行后记录最近 20 条结构化工具动作,包含 tool、status、actionFingerprint、inputSummary、reason、summary、detail 和 updatedAt;开发窗口、项目内 Agent 对话弹窗和主窗口 Agent 状态列表可直接展示“最近动作”和安全目标摘要,不再只能从 observation 字符串里猜测 action / observation 对应关系。`inputSummary` 只保留相对路径、checkpoint id、目标 Agent、内容字符数等确认所需信息,不保存原始 API Key、待写正文、消息正文、素材 prompt 或任意未过滤输入。 - 2026-07-10 补充:Agent Runtime state 新增 `currentGoal` 和 `waitingOn`,把本轮目标与当前等待对象从 `currentTask / currentAction / nextStep` 中显式拆出来;后台任务启动、工具 observation、完成和失败都会刷新该状态,开发窗口、项目内 Agent 对话弹窗、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示同一份目标 / 等待信息,避免开发者只能从动作文本里猜 Agent 卡在 LLM、工具、同伴还是人工输入。 @@ -285,7 +286,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` 并暂停继续消费队列,等待后续确认或策略调整;确认继续会为新 run 写入一次性 `.agent/runtime/confirmations///.json` 票据,票据绑定工具名与输入 JSON 的 SHA-256 `actionFingerprint`,权限 gate 在 deny 之后只放行同一 `agentId/runId/commandId/actionFingerprint` 一次,输入改变时旧票据失效并重新等待确认,原 run 追加 `completed/confirmed` 任务记录;若用户取消 pending 任务,drain 不再消费该 run,若取消 running 任务,则在当前 LLM 或工具调用返回后的检查点停止,不继续执行工具或保存最终 assistant 回复。客户端重开项目时会对当前项目路径自动尝试一次 Runtime 恢复;恢复命令必须通过 `agent.resume` 自动权限,默认确认策略下不会静默启动;同一 Agent 同时存在上一进程遗留 `running` 和 `pending` 时,先重接 `running`,再由 drain 继续 `pending`。开发构建和后台 Agent 工具箱都可通过 `agent.schedule_ready` 权限确认入口把 manifest ready task 投递进同一后台队列,命令会先把 ready task 标成 `running`,再用 taskId 作为 Agent id 入队,source 为 `agent-ready-task-scheduler`;该入口不新增独立 worker。后台任务的核心 loop 最多 3 轮:每轮把已有 observation 带回 LLM 让 Agent 重新规划,只有 actions 为空且 response 非空时提前收束,否则继续执行白名单工具,跑满后再进入最终回复整理。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.background_task.recovered` / `agent.runtime.ready_task.scheduled` / `agent.runtime.tool_observation` / `agent.runtime.tool_confirmation_required` / `agent.runtime.tool_confirmation.approved` / `agent.runtime.memory.write` / `agent.runtime.file.write` / `agent.runtime.task.create` / `agent.runtime.task.update` / `agent.runtime.command.run_limited` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.agent.delegate` / `agent.runtime.background_task.cancelled` / `agent.runtime.background_task.retry` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`file.write`、`task.create`、`task.update`、`command.run_limited`、`blackboard.write`、`agent.message`、`agent.delegate` 和 `agent.schedule_ready`;`memory.write scope=agent` 只允许写当前 Agent 自己的私有记忆,跨 Agent 共享必须改用 `blackboard.write` 或 `agent.message`;`project.checkpoint` 只创建本地 checkpoint,不返回本机绝对路径;`project.restore` 只按 checkpoint id 恢复当前项目,不返回本机绝对路径,默认确认策略下不会静默回滚;`task.create` 只追加新 manifest 任务,`task.update` 只更新已有任务状态;`agent.schedule_ready` 只调度 manifest ready task,不创建平行 runtime;若项目策略拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent 修正计划;若项目策略要求确认且没有匹配精确动作的一次性确认票据,Runtime 会保留待确认状态而不执行该工具。`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 最多 3 轮:每轮把已有 observation 带回 LLM 让 Agent 重新规划,只有 actions 为空且 response 非空时提前收束,否则继续执行白名单工具,跑满后再进入最终回复整理。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.background_task.recovered` / `agent.runtime.ready_task.scheduled` / `agent.runtime.tool_observation` / `agent.runtime.tool_confirmation_required` / `agent.runtime.tool_confirmation.approved` / `agent.runtime.tool_confirmation.rejected` / `agent.runtime.memory.write` / `agent.runtime.file.write` / `agent.runtime.task.create` / `agent.runtime.task.update` / `agent.runtime.command.run_limited` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.agent.delegate` / `agent.runtime.background_task.cancelled` / `agent.runtime.background_task.retry` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`file.write`、`task.create`、`task.update`、`command.run_limited`、`blackboard.write`、`agent.message`、`agent.delegate` 和 `agent.schedule_ready`;`memory.write scope=agent` 只允许写当前 Agent 自己的私有记忆,跨 Agent 共享必须改用 `blackboard.write` 或 `agent.message`;`project.checkpoint` 只创建本地 checkpoint,不返回本机绝对路径;`project.restore` 只按 checkpoint id 恢复当前项目,不返回本机绝对路径,默认确认策略下不会静默回滚;`task.create` 只追加新 manifest 任务,`task.update` 只更新已有任务状态;`agent.schedule_ready` 只调度 manifest ready task,不创建平行 runtime;若项目策略拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent 修正计划;若项目策略要求确认,Runtime 会持久化精确待确认动作并保留 waiting 状态,不执行该工具;只有确认入口通过 `runId + actionId + SHA-256` 校验后才直接执行原 action,拒绝入口则生成 `blocked` observation。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作及安全 `inputSummary`,供状态面板展示最近动作和确认目标;append-only JSONL 写入按目标文件路径在当前进程内串行追加完整行,覆盖 `.agent/agent.db`、对话、Runtime events/tasks、activity 和 output,减少多个 Agent 同时完成时的行交错风险。 - 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` 项目权限策略保护。