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 ecee1d7b0..179f4fb2f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -433,6 +433,155 @@ pub(crate) fn start_game_creator_agent_background_task_at( Ok(result) } +pub(crate) fn cancel_game_creator_agent_runtime_task_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + validate_project_root(root)?; + let current_result = read_game_creator_agent_runtime_at(root, &agent_id)?; + let target_run_id = if run_id.trim().is_empty() { + current_result.state.run_id.clone() + } else { + normalize_game_creator_agent_runtime_run_id(&agent_id, run_id) + }; + 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") { + return Err(format!( + "Agent Runtime 任务已结束,不能取消:{target_run_id}" + )); + } + write_game_creator_agent_runtime_cancel_request( + root, + &agent_id, + &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( + root, + &mut state, + "开发者已取消 Agent 后台任务", + Some(&task.task), + )?; + } else { + let cancelled_task = append_game_creator_agent_runtime_cancelled_task_record( + root, + &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); + } + read_game_creator_agent_runtime_at(root, &agent_id) +} + +pub(crate) fn retry_game_creator_agent_runtime_task_at( + root: &Path, + agent_id: &str, + run_id: &str, + next_run_id: &str, +) -> Result { + 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()); + } + 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 matches!( + task.status.as_str(), + "pending" | "running" | "waiting-for-confirmation" + ) { + return Err(format!( + "Agent Runtime 任务仍在运行,不能重试:{target_run_id}" + )); + } + remove_game_creator_agent_runtime_cancel_request(root, &agent_id, &target_run_id); + let retry_run_id = if next_run_id.trim().is_empty() { + format!("{target_run_id}-retry-{}", unix_timestamp()) + } else { + next_run_id.trim().to_string() + }; + let result = + start_game_creator_agent_background_task_at(root, &agent_id, &task.task, &retry_run_id)?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.retry", + "agentId": task.agent_id, + "taskId": task.task_id, + "sessionId": task.session_id, + "runId": task.run_id, + "retryRunId": normalize_game_creator_agent_runtime_run_id(&agent_id, &retry_run_id), + "source": task.source, + "task": task.task, + }), + )?; + Ok(result) +} + fn game_creator_agent_background_task_default_plan() -> Vec { vec![ "记录开发者投递的后台任务".to_string(), @@ -529,6 +678,10 @@ async fn run_game_creator_agent_background_task( let mut observations = Vec::new(); 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 { runtime.loop_iteration = (loop_index + 1) as u32; runtime.max_loop_iterations = AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32; @@ -592,6 +745,10 @@ async fn run_game_creator_agent_background_task( } }; + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } + if !plan.thinking_summary.trim().is_empty() { runtime.observations.push(format!( "第 {} 轮思考摘要:{}", @@ -644,6 +801,9 @@ async fn run_game_creator_agent_background_task( .take(AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT) .enumerate() { + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } activate_agent_runtime_plan_step( &mut runtime, action_index, @@ -672,9 +832,15 @@ async fn run_game_creator_agent_background_task( runtime.current_action.as_str(), action.reason.as_deref(), ); + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } let observation = execute_game_creator_agent_runtime_tool_action(&root, &agent_id, &task, action) .await; + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } let observation_summary = observation.summary(); runtime.observations.push(observation_summary.clone()); append_agent_runtime_tool_call_record(&mut runtime, action, &observation); @@ -747,9 +913,16 @@ async fn run_game_creator_agent_background_task( return AgentBackgroundTaskOutcome::WaitingForConfirmation; } observations.push(observation); + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } } } + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } + let final_reply = if let Some(reply) = final_reply { reply } else { @@ -771,6 +944,9 @@ async fn run_game_creator_agent_background_task( return AgentBackgroundTaskOutcome::Finished; } }; + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } match request_game_creator_agent_background_final_reply_at( &root, &agent_id, @@ -813,6 +989,10 @@ async fn run_game_creator_agent_background_task( } }; + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } + match finish_game_creator_agent_runtime_turn_at(&root, runtime.clone(), &final_reply) { Ok(completed_runtime) => { runtime = completed_runtime; @@ -2644,11 +2824,12 @@ fn format_agent_runtime_active_plan_step_observation(state: &AgentRuntimeState) fn format_agent_runtime_task_queue_observation(queue: &AgentRuntimeTaskQueueSummary) -> String { format!( - "total={} pending={} running={} waiting={} completed={} failed={} latest={}", + "total={} pending={} running={} waiting={} cancelled={} completed={} failed={} latest={}", queue.total, queue.pending, queue.running, queue.waiting_for_confirmation, + queue.cancelled, queue.completed, queue.failed, queue.latest_run_id.as_deref().unwrap_or("-") @@ -3149,6 +3330,7 @@ fn agent_runtime_next_step_for_phase(phase: &str) -> &'static str { "waiting-for-confirmation" => "等待开发者确认工具动作", "response" => "等待 Agent 整理最终回复", "completed" | "idle" => "等待下一轮输入", + "cancelled" => "可重试该后台任务或提交新任务", "failed" => "等待开发者处理失败", _ => "继续推进当前任务", } @@ -3162,6 +3344,7 @@ fn agent_runtime_waiting_on_for_phase(phase: &str) -> &'static str { "waiting-for-confirmation" => "开发者确认 Agent 工具动作", "response" => "Agent 整理最终回复", "completed" | "idle" => "开发者下一轮输入", + "cancelled" => "开发者下一轮输入", "failed" => "开发者处理失败", _ => "当前任务推进", } @@ -3257,6 +3440,58 @@ fn game_creator_agent_runtime_task_path(root: &Path, agent_id: &str) -> PathBuf .join(format!("{agent_id}.jsonl")) } +fn game_creator_agent_runtime_cancel_path(root: &Path, agent_id: &str, run_id: &str) -> PathBuf { + root.join(".agent") + .join("runtime") + .join("cancel") + .join(agent_id) + .join(format!("{run_id}.json")) +} + +fn write_game_creator_agent_runtime_cancel_request( + root: &Path, + agent_id: &str, + run_id: &str, + reason: &str, +) -> Result<(), String> { + let path = game_creator_agent_runtime_cancel_path(root, agent_id, run_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent Runtime 取消目录失败:{}: {error}", + parent.display() + ) + })?; + } + let payload = serde_json::json!({ + "agentId": agent_id, + "runId": run_id, + "reason": sanitize_agent_runtime_text(reason, 180), + "updatedAt": unix_timestamp(), + }); + let content = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("序列化 Agent Runtime 取消请求失败:{error}"))?; + fs::write(&path, format!("{content}\n")).map_err(|error| { + format!( + "写入 Agent Runtime 取消请求失败:{}: {error}", + path.display() + ) + }) +} + +fn remove_game_creator_agent_runtime_cancel_request(root: &Path, agent_id: &str, run_id: &str) { + let _ = fs::remove_file(game_creator_agent_runtime_cancel_path( + root, agent_id, run_id, + )); +} + +fn game_creator_agent_runtime_cancel_requested(root: &Path, state: &AgentRuntimeState) -> bool { + if state.run_id.trim().is_empty() { + return false; + } + game_creator_agent_runtime_cancel_path(root, &state.agent_id, &state.run_id).exists() +} + #[derive(Debug)] struct AgentRuntimeTaskLock { path: PathBuf, @@ -3475,6 +3710,86 @@ fn append_game_creator_agent_runtime_task( append_game_creator_agent_runtime_task_record(root, &record) } +fn append_game_creator_agent_runtime_cancelled_task_record( + root: &Path, + record: &AgentRuntimeTaskRecord, + current_action: &str, +) -> Result { + let cancelled = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: record.agent_id.clone(), + task_id: record.task_id.clone(), + session_id: record.session_id.clone(), + run_id: record.run_id.clone(), + source: record.source.clone(), + task: record.task.clone(), + status: "cancelled".to_string(), + phase: "cancelled".to_string(), + current_action: current_action.to_string(), + error: None, + updated_at: unix_timestamp(), + }; + append_game_creator_agent_runtime_task_record(root, &cancelled)?; + Ok(cancelled) +} + +fn mark_game_creator_agent_runtime_cancelled_at( + root: &Path, + state: &mut AgentRuntimeState, + summary: &str, + detail: Option<&str>, +) -> Result<(), String> { + state.status = "cancelled".to_string(); + state.phase = "cancelled".to_string(); + state.current_action = summary.to_string(); + state.waiting_on = "开发者下一轮输入".to_string(); + state.next_step = "可重试该后台任务或提交新任务".to_string(); + state.error = None; + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, state)?; + refresh_game_creator_agent_runtime_task_queue(root, state)?; + write_game_creator_agent_runtime_state(root, state)?; + append_game_creator_agent_runtime_event( + root, + state, + "turn.cancelled", + "cancelled", + "cancelled", + summary, + detail, + )?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.cancelled", + "agentId": state.agent_id.clone(), + "taskId": state.task_id.clone(), + "sessionId": state.session_id.clone(), + "runId": state.run_id.clone(), + "source": state.source.clone(), + "task": state.current_task.clone(), + "summary": summary, + }), + )?; + Ok(()) +} + +fn stop_game_creator_agent_runtime_if_cancel_requested( + root: &Path, + state: &mut AgentRuntimeState, +) -> bool { + if !game_creator_agent_runtime_cancel_requested(root, state) { + return false; + } + let _ = mark_game_creator_agent_runtime_cancelled_at( + root, + state, + "Agent 后台任务已按开发者请求取消", + Some("取消请求会在当前 LLM 或工具调用返回后生效。"), + ); + true +} + fn append_game_creator_agent_runtime_pending_task( root: &Path, agent_id: &str, @@ -3535,6 +3850,8 @@ fn refresh_game_creator_agent_runtime_task_queue( fn game_creator_agent_runtime_task_status(state: &AgentRuntimeState) -> String { if state.error.is_some() || state.status == "failed" || state.phase == "failed" { "failed".to_string() + } else if state.status == "cancelled" || state.phase == "cancelled" { + "cancelled".to_string() } else if state.phase == "completed" { "completed".to_string() } else if state.status == "running" { @@ -3615,6 +3932,18 @@ fn read_next_pending_game_creator_agent_runtime_task( .find(|record| record.status == "pending")) } +fn read_latest_game_creator_agent_runtime_task_by_run_id( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + let path = game_creator_agent_runtime_task_path(root, agent_id); + let run_id = run_id.trim(); + let records = + latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks(&path)?); + Ok(records.into_iter().find(|record| record.run_id == run_id)) +} + fn read_recoverable_game_creator_agent_runtime_task( root: &Path, agent_id: &str, @@ -3696,6 +4025,7 @@ fn summarize_game_creator_agent_runtime_task_queue( "pending" => summary.pending += 1, "running" => summary.running += 1, "waiting-for-confirmation" => summary.waiting_for_confirmation += 1, + "cancelled" => summary.cancelled += 1, "completed" => summary.completed += 1, "failed" => summary.failed += 1, _ => {} 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 e7fa05225..2a9217ba7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -393,6 +393,39 @@ pub(crate) fn start_game_creator_agent_runtime_task( start_game_creator_agent_background_task_at(root, agent_id.trim(), task.trim(), run_id.trim()) } +#[tauri::command] +pub(crate) fn cancel_game_creator_agent_runtime_task( + project_path: String, + agent_id: String, + run_id: 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")?; + cancel_game_creator_agent_runtime_task_at(root, agent_id.trim(), run_id.trim()) +} + +#[tauri::command] +pub(crate) fn retry_game_creator_agent_runtime_task( + project_path: String, + agent_id: String, + run_id: String, + next_run_id: 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_auto_permission_policy(root, "agent.resume")?; + retry_game_creator_agent_runtime_task_at( + root, + agent_id.trim(), + run_id.trim(), + next_run_id.trim(), + ) +} + #[tauri::command] pub(crate) fn read_game_creator_agent_runtime( project_path: String, 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 8c55f7e36..140a0b23d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -262,6 +262,8 @@ struct AgentRuntimeTaskQueueSummary { #[serde(default)] waiting_for_confirmation: u32, #[serde(default)] + cancelled: u32, + #[serde(default)] completed: u32, #[serde(default)] failed: u32, @@ -278,6 +280,7 @@ impl Default for AgentRuntimeTaskQueueSummary { pending: 0, running: 0, waiting_for_confirmation: 0, + cancelled: 0, completed: 0, failed: 0, latest_run_id: None, @@ -1197,6 +1200,8 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, start_game_creator_agent_runtime_task, + cancel_game_creator_agent_runtime_task, + retry_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/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 4e7d8e39a..7137be254 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -2515,7 +2515,7 @@ async fn background_agent_runtime_plan_request_includes_same_agent_continuity_co assert!(second_design_request.contains("计划进度:")); assert!(second_design_request.contains("#1 [active] 记录开发者投递的后台任务")); assert!(second_design_request.contains( - "任务队列:total=2 pending=0 running=1 waiting=0 completed=1 failed=0 latest=design-continuity-second" + "任务队列:total=2 pending=0 running=1 waiting=0 cancelled=0 completed=1 failed=0 latest=design-continuity-second" )); assert!(second_design_request.contains("最近回复:首轮完成:已经读取连续上下文笔记。")); assert!(second_design_request.contains("最近工具动作")); @@ -4889,6 +4889,241 @@ async fn background_agent_runtime_queues_same_agent_tasks_and_drains_them() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_can_cancel_pending_task_before_drain() { + 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_first_sender, release_first_receiver) = mpsc::channel(); + let base_url = spawn_releasable_mock_llm_server_responses_with_capture( + vec![serde_json::json!({ + "thinkingSummary": "只处理第一个后台任务", + "plan": ["回复第一个任务"], + "actions": [], + "response": "第一个后台任务完成。" + }) + .to_string()], + request_sender, + release_first_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", + "第一个后台任务", + "design-cancel-pending-first", + ) + .expect("start first background task"); + let _first_request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first request"); + start_game_creator_agent_background_task_at( + &root, + "design-director", + "第二个后台任务", + "design-cancel-pending-second", + ) + .expect("queue second background task"); + + let cancelled = cancel_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-cancel-pending-second", + ) + .expect("cancel pending task"); + assert_eq!(cancelled.task_queue.cancelled, 1); + assert!(cancelled + .recent_tasks + .iter() + .any(|task| task.run_id == "design-cancel-pending-second" + && task.status == "cancelled" + && task.phase == "cancelled")); + + release_first_sender + .send(()) + .expect("release first request"); + assert!(request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_err()); + + let mut runtime_result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime"); + for _ in 0..50 { + if runtime_result + .recent_tasks + .iter() + .any(|task| task.run_id == "design-cancel-pending-first" && task.status == "completed") + && runtime_result.recent_tasks.iter().any(|task| { + task.run_id == "design-cancel-pending-second" && task.status == "cancelled" + }) + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + runtime_result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime"); + } + assert_eq!(runtime_result.task_queue.completed, 1); + assert_eq!(runtime_result.task_queue.cancelled, 1); + let conversation = + read_local_conversation_at(&root, Some("design-director")).expect("conversation"); + assert!(conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == "第一个后台任务完成。")); + 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_can_cancel_active_task_and_retry_it() { + 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_first_sender, release_first_receiver) = mpsc::channel(); + let base_url = spawn_releasable_mock_llm_server_responses_with_capture( + vec![ + serde_json::json!({ + "thinkingSummary": "这个回复不应保存", + "plan": ["不应保存"], + "actions": [], + "response": "不应保存的后台回复。" + }) + .to_string(), + serde_json::json!({ + "thinkingSummary": "重试后完成", + "plan": ["回复重试任务"], + "actions": [], + "response": "重试后台任务完成。" + }) + .to_string(), + ], + request_sender, + release_first_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", + "需要取消后重试的后台任务", + "design-active-cancel", + ) + .expect("start active task"); + let first_request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first request"); + assert!(first_request.contains("需要取消后重试的后台任务")); + + 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); + + release_first_sender + .send(()) + .expect("release first request"); + 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() + && cancelled_result.state.status == "cancelled" + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + cancelled_result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read cancelled"); + } + assert_eq!(cancelled_result.state.status, "cancelled"); + let conversation = + read_local_conversation_at(&root, Some("design-director")).expect("conversation"); + assert!(!conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content.contains("不应保存"))); + + let retry = retry_game_creator_agent_runtime_task_at( + &root, + "design-director", + "design-active-cancel", + "design-active-cancel-retry", + ) + .expect("retry cancelled task"); + assert_eq!(retry.state.run_id, "design-active-cancel-retry"); + let retry_request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("retry request"); + assert!(retry_request.contains("需要取消后重试的后台任务")); + + let mut runtime_result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read retry runtime"); + for _ in 0..50 { + if runtime_result.state.status == "idle" + && runtime_result.recent_tasks.iter().any(|task| { + task.run_id == "design-active-cancel-retry" && task.status == "completed" + }) + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + runtime_result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read retry"); + } + assert_eq!(runtime_result.state.status, "idle"); + assert_eq!( + runtime_result.state.last_response.as_deref(), + Some("重试后台任务完成。") + ); + assert!(runtime_result + .recent_tasks + .iter() + .any(|task| task.run_id == "design-active-cancel" && task.status == "cancelled")); + assert!(runtime_result + .recent_tasks + .iter() + .any(|task| task.run_id == "design-active-cancel-retry" && task.status == "completed")); + let conversation = + read_local_conversation_at(&root, Some("design-director")).expect("conversation"); + assert!(conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == "重试后台任务完成。")); + + 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(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 15bd46a7c..6d92f2d0b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -289,6 +289,7 @@ interface AgentRuntimeTaskQueueSummary { pending: number; running: number; waitingForConfirmation?: number; + cancelled?: number; completed: number; failed: number; latestRunId: string | null; @@ -623,6 +624,7 @@ function normalizeAgentRuntimeState( pending: 0, running: 0, waitingForConfirmation: 0, + cancelled: 0, completed: 0, failed: 0, latestRunId: null, @@ -663,6 +665,8 @@ function agentRuntimeWaitingOnFromPhase(phase: string) { case 'completed': case 'idle': return '开发者下一轮输入'; + case 'cancelled': + return '开发者下一轮输入'; case 'failed': return '开发者处理失败'; default: @@ -683,6 +687,8 @@ function agentRuntimeNextStepFromPhase(phase: string) { case 'completed': case 'idle': return '等待下一轮输入'; + case 'cancelled': + return '可重试该后台任务或提交新任务'; case 'failed': return '等待开发者处理失败'; default: @@ -715,6 +721,7 @@ function formatAgentRuntimeTaskQueue( `pending ${queue.pending}`, `running ${queue.running}`, `waiting ${queue.waitingForConfirmation ?? 0}`, + `cancelled ${queue.cancelled ?? 0}`, `completed ${queue.completed}`, `failed ${queue.failed}`, `total ${queue.total}`, @@ -757,12 +764,26 @@ function agentRuntimeActivePlanStep( ); } +function agentRuntimeCanCancel(status: string) { + return ['pending', 'running', 'waiting-for-confirmation'].includes(status); +} + +function agentRuntimeCanRetry(status: string) { + return ['cancelled', 'failed', 'completed', 'idle'].includes(status); +} + function AgentRuntimeStatusPanel({ runtime, error, + controlBusy = false, + onCancelRuntimeTask, + onRetryRuntimeTask, }: { runtime: AgentRuntimeState | null; error?: string | null; + controlBusy?: boolean; + onCancelRuntimeTask?: (runId: string) => void; + onRetryRuntimeTask?: (runId: string) => void; }) { if (!runtime && error) { return ( @@ -789,12 +810,38 @@ function AgentRuntimeStatusPanel({ const nextStep = runtime.nextStep ?? agentRuntimeNextStepFromPhase(runtime.phase); const currentGoal = runtime.currentGoal ?? runtime.currentTask; const waitingOn = runtime.waitingOn ?? agentRuntimeWaitingOnFromPhase(runtime.phase); + const canCancel = + Boolean(runtime.runId) && + agentRuntimeCanCancel(runtime.status) && + Boolean(onCancelRuntimeTask); + const canRetry = + Boolean(runtime.runId) && + agentRuntimeCanRetry(runtime.status) && + Boolean(onRetryRuntimeTask); return (
{`${runtime.status} / ${runtime.phase}`} {runtime.sessionId}
+ {onCancelRuntimeTask || onRetryRuntimeTask ? ( +
+ + +
+ ) : null} {`task: ${runtime.taskId} · ${runtime.source}`} {runtime.runId ? {`run: ${runtime.runId}`} : null} {currentGoal ?

{`当前目标:${currentGoal}`}

: null} @@ -3767,6 +3814,107 @@ export function WorkspaceLauncher({ } } + async function handleAgentChatCancelRuntimeTask(runId: string) { + const projectPathForChat = validateAgentChatProjectPath(); + const agent = selectedLauncherAgentChatAgent(); + if (!projectPathForChat || !agent || !runId || agentChatBackgroundBusy) { + 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( + 'cancel_game_creator_agent_runtime_task', + { + projectPath: projectPathForChat, + agentId: agent.id, + runId, + }, + ); + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + setAgentChatRuntime(agentRuntimeStateFromResult(runtime)); + setAgentChatRuntimeError(''); + setAgentChatStatus(`已取消后台任务:${runId}`); + } catch (error) { + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + setAgentChatStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (agentChatLoadVersionRef.current === saveVersion) { + setAgentChatBackgroundBusy(false); + } + } + } + + async function handleAgentChatRetryRuntimeTask(runId: string) { + const projectPathForChat = validateAgentChatProjectPath(); + const agent = selectedLauncherAgentChatAgent(); + if (!projectPathForChat || !agent || !runId || 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( + 'retry_game_creator_agent_runtime_task', + { + projectPath: projectPathForChat, + agentId: agent.id, + runId, + nextRunId: createAgentChatRunId('launcher-agent-retry'), + }, + ); + 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; @@ -4434,6 +4582,13 @@ export function WorkspaceLauncher({ + void handleAgentChatCancelRuntimeTask(runId) + } + onRetryRuntimeTask={(runId) => + void handleAgentChatRetryRuntimeTask(runId) + } />
@@ -10476,6 +10631,7 @@ function sameAgentRuntimeTaskQueue( left.running === right.running && (left.waitingForConfirmation ?? 0) === (right.waitingForConfirmation ?? 0) && + (left.cancelled ?? 0) === (right.cancelled ?? 0) && left.completed === right.completed && left.failed === right.failed && left.latestRunId === right.latestRunId && @@ -13578,6 +13734,133 @@ export function App() { } } + async function cancelSelectedAgentRuntimeTask( + agent: AgentStatusCard, + runId: string, + ) { + if (!agent || !runId || agentConversationBackgroundBusyRef.current) { + return; + } + const invoke = resolveTauriInvoke(); + const nextProjectPath = resolveChatProjectPath(localProject); + if (!invoke) { + setAgentConversationStatus('需要在 Tauri App 内运行'); + return; + } + if (!nextProjectPath) { + setAgentConversationStatus('请先初始化本地项目'); + return; + } + const saveVersion = agentConversationLoadVersionRef.current; + agentConversationBackgroundBusyRef.current = true; + setAgentConversationBackgroundBusy(true); + setAgentConversationStatus('正在取消 Agent 后台任务'); + try { + const runtime = await invoke( + 'cancel_game_creator_agent_runtime_task', + { + projectPath: nextProjectPath, + agentId: agent.id, + runId, + }, + ); + if (agentConversationLoadVersionRef.current !== saveVersion) { + return; + } + const nextRuntime = agentRuntimeStateFromResult(runtime); + setAgentConversationRuntime(nextRuntime); + rememberAgentRuntimeState(nextRuntime); + setAgentConversationRuntimeError(''); + setAgentConversationStatus(`已取消后台任务:${runId}`); + setCommandLog((current) => [ + ...current, + 'agent.runtime.background_task.cancel', + ]); + } catch (error) { + if (agentConversationLoadVersionRef.current !== saveVersion) { + return; + } + setAgentConversationStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + agentConversationBackgroundBusyRef.current = false; + setAgentConversationBackgroundBusy(false); + } + } + + async function retrySelectedAgentRuntimeTask( + agent: AgentStatusCard, + runId: string, + ) { + if (!agent || !runId || 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( + 'retry_game_creator_agent_runtime_task', + { + projectPath: nextProjectPath, + agentId: agent.id, + runId, + nextRunId: createAgentChatRunId('agent-background-retry'), + }, + ); + 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.background_task.retry', + ]); + } 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, @@ -20969,6 +21252,17 @@ export function App() { + selectedAgent + ? void cancelSelectedAgentRuntimeTask(selectedAgent, runId) + : undefined + } + onRetryRuntimeTask={(runId) => + selectedAgent + ? void retrySelectedAgentRuntimeTask(selectedAgent, runId) + : undefined + } />
{ expect(screen.getByText('Loop:1/3 · 工具预算 3')).not.toBeNull(); expect( screen.getByText( - /任务队列:pending 0 · running 1 · waiting 0 · completed 0 · failed 0 · total 1 · latest launcher-agent-task-/, + /任务队列:pending 0 · running 1 · waiting 0 · cancelled 0 · completed 0 · failed 0 · total 1 · latest launcher-agent-task-/, ), ).not.toBeNull(); + const runtimeActions = screen.getByLabelText('Agent Runtime 操作'); + expect( + (within(runtimeActions).getByRole('button', { + name: '取消', + }) as HTMLButtonElement).disabled, + ).toBe(false); + expect( + (within(runtimeActions).getByRole('button', { + name: '重试', + }) as HTMLButtonElement).disabled, + ).toBe(true); expect(screen.getByText('计划进度')).not.toBeNull(); expect( screen.getByText('#1 completed · 读取项目笔记 · file.read:ok · 已读取 game/notes.txt'), @@ -1921,7 +1932,7 @@ describe('AI 游戏创作 App 界面边界', () => { ).not.toBeNull(); expect( screen.getByText( - /任务队列:pending 1 · running 1 · waiting 0 · completed 0 · failed 0 · total 2 · latest launcher-agent-task-/, + /任务队列:pending 1 · running 1 · waiting 0 · cancelled 0 · completed 0 · failed 0 · total 2 · latest launcher-agent-task-/, ), ).not.toBeNull(); }); @@ -13648,7 +13659,7 @@ describe('AI 游戏创作 App 界面边界', () => { '当前计划步骤:#1 active · 读取项目上下文 · 正在整理目标和约束', ); expect(designCard.textContent).toContain( - '任务队列:pending 1 · running 1 · waiting 0 · completed 0 · failed 0 · total 2 · latest runtime-design-director-1', + '任务队列:pending 1 · running 1 · waiting 0 · cancelled 0 · completed 0 · failed 0 · total 2 · latest runtime-design-director-1', ); expect(designCard.textContent).toContain( '最近任务:pending / queued · 排队补齐世界观拆解', @@ -13694,7 +13705,7 @@ describe('AI 游戏创作 App 界面边界', () => { 'Runtime:idle / completed · Loop 2/3 · 等待下一轮输入 · 等待 开发者下一轮输入 · 下一步 等待下一轮输入 · run runtime-design-director-1', ); expect(designCard.textContent).toContain( - '任务队列:pending 1 · running 0 · waiting 0 · completed 1 · failed 0 · total 2 · latest runtime-design-director-1', + '任务队列:pending 1 · running 0 · waiting 0 · cancelled 0 · completed 1 · failed 0 · total 2 · latest runtime-design-director-1', ); expect(designCard.textContent).toContain( '最近任务:completed / completed · 排队补齐世界观拆解', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b9a1bbdc8..deee722b1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4062,6 +4062,7 @@ - 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 state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、reason、summary、detail 和 updatedAt。状态面板展示最近动作时使用该字段,不解析 observation 文本;写入前继续过滤敏感上下文,不保存原始密钥或任意未过滤输入。 - 2026-07-10 调整:Agent Runtime state 新增 `currentGoal` 和 `waitingOn`。`currentGoal` 固定表达本轮任务目标,`waitingOn` 表达当前等待 LLM、工具观察、开发者输入或失败处理;后台任务生命周期、`agent.run_status` observation、下一轮 planning prompt、开发单 Agent 对话页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都必须展示同一份目标 / 等待状态。 - 2026-07-10 调整:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`。后台 Agent loop 每轮规划前刷新当前轮次、最大轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,字段只做运行观测,不改变 loop 上限或权限 gate。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 256deba31..3851dee72 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -36,6 +36,7 @@ Agent Runtime 负责: - 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 Runtime state 新增 `recentToolCalls`,每次后台工具执行后记录最近 20 条结构化工具动作,包含 tool、status、reason、summary、detail 和 updatedAt;开发窗口、项目内 Agent 对话弹窗和主窗口 Agent 状态列表可直接展示“最近动作”,不再只能从 observation 字符串里猜测 action / observation 对应关系。字段只保存过滤后的摘要和观察细节,不保存原始 API Key 或任意未过滤输入。 - 2026-07-10 补充:Agent Runtime state 新增 `currentGoal` 和 `waitingOn`,把本轮目标与当前等待对象从 `currentTask / currentAction / nextStep` 中显式拆出来;后台任务启动、工具 observation、完成和失败都会刷新该状态,开发窗口、项目内 Agent 对话弹窗、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示同一份目标 / 等待信息,避免开发者只能从动作文本里猜 Agent 卡在 LLM、工具、同伴还是人工输入。 - 2026-07-10 补充:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`,结构化记录后台 Agent loop 当前轮次、最大轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,帮助判断 Agent 是刚开始规划、正在 replan,还是接近本轮 loop 上限。该字段只做运行观测,不改变后台 loop 的执行上限或工具权限。 @@ -274,7 +275,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 状态查看进度和结果。后台任务会向 `.agent/runtime/tasks/.jsonl` 追加任务视角记录,任务状态使用 `pending / running / waiting-for-confirmation / completed / failed`,读取时按 `runId` 去重返回最近任务;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行;若某个工具动作命中确认策略,该 Agent 会停在 `waiting-for-confirmation` 并暂停继续消费队列,等待后续确认或策略调整。客户端重开项目时会对当前项目路径自动尝试一次 Runtime 恢复;恢复命令必须通过 `agent.resume` 自动权限,默认确认策略下不会静默启动;同一 Agent 同时存在上一进程遗留 `running` 和 `pending` 时,先重接 `running`,再由 drain 继续 `pending`。后台任务的核心 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.tool_observation` / `agent.runtime.tool_confirmation_required` / `agent.runtime.memory.write` / `agent.runtime.file.write` / `agent.runtime.command.run_limited` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.agent.delegate` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `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`;若项目策略拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent 修正计划;若项目策略要求确认,Runtime 会保留待确认状态而不执行该工具。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作,供状态面板展示最近动作;`.agent/agent.db` 追加写入按整行 JSONL 写入,减少多个 Agent 同时完成时的行交错风险。 +- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动或排队单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果,也可对当前 run 执行取消 / 重试。后台任务会向 `.agent/runtime/tasks/.jsonl` 追加任务视角记录,任务状态使用 `pending / running / waiting-for-confirmation / cancelled / completed / failed`,读取时按 `runId` 去重返回最近任务;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行;若某个工具动作命中确认策略,该 Agent 会停在 `waiting-for-confirmation` 并暂停继续消费队列,等待后续确认或策略调整;若用户取消 pending 任务,drain 不再消费该 run,若取消 running 任务,则在当前 LLM 或工具调用返回后的检查点停止,不继续执行工具或保存最终 assistant 回复。客户端重开项目时会对当前项目路径自动尝试一次 Runtime 恢复;恢复命令必须通过 `agent.resume` 自动权限,默认确认策略下不会静默启动;同一 Agent 同时存在上一进程遗留 `running` 和 `pending` 时,先重接 `running`,再由 drain 继续 `pending`。后台任务的核心 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.tool_observation` / `agent.runtime.tool_confirmation_required` / `agent.runtime.memory.write` / `agent.runtime.file.write` / `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`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;若项目策略拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent 修正计划;若项目策略要求确认,Runtime 会保留待确认状态而不执行该工具。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作,供状态面板展示最近动作;`.agent/agent.db` 追加写入按整行 JSONL 写入,减少多个 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` 项目权限策略保护。