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 33e8871f9..25e0a7c52 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -233,6 +233,83 @@ pub(crate) fn read_game_creator_agent_runtimes_at( root: &Path, ) -> Result, String> { validate_project_root(root)?; + let agent_ids = collect_game_creator_agent_runtime_agent_ids(root)?; + agent_ids + .into_iter() + .map(|agent_id| read_game_creator_agent_runtime_at(root, &agent_id)) + .collect() +} + +pub(crate) fn resume_game_creator_agent_background_tasks_at( + root: &Path, +) -> Result, String> { + 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 source = if task.source.trim().is_empty() { + "agent-background-task" + } else { + task.source.as_str() + }; + let state = match start_game_creator_agent_runtime_task_at( + root, + &agent_id, + &task.task, + &task.run_id, + source, + "后台任务从上次运行状态恢复", + game_creator_agent_background_task_default_plan(), + ) { + Ok(state) => state, + Err(error) => { + let fallback = default_game_creator_agent_runtime_state(&agent_id, &task.run_id); + let _ = fail_game_creator_agent_runtime_turn_at(root, fallback, &error); + continue; + } + }; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.recovered", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "recoveredFromStatus": task.status, + "task": state.current_task, + }), + ); + let _ = append_game_creator_agent_background_task_started_record(root, &state); + let result = read_game_creator_agent_runtime_at(root, &agent_id)?; + let root = root.to_path_buf(); + let background_agent_id = agent_id.clone(); + let background_task = task.task.clone(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + drain_game_creator_agent_background_tasks( + root, + background_agent_id, + background_task, + state, + ) + .await; + }); + resumed.push(result); + } + Ok(resumed) +} + +fn collect_game_creator_agent_runtime_agent_ids( + root: &Path, +) -> Result, String> { let manifest = read_manifest_for_project(root)?; let mut agent_ids = std::collections::BTreeSet::new(); for task in manifest.tasks { @@ -250,10 +327,7 @@ pub(crate) fn read_game_creator_agent_runtimes_at( agent_ids.insert(role.task_id.to_string()); } } - agent_ids - .into_iter() - .map(|agent_id| read_game_creator_agent_runtime_at(root, &agent_id)) - .collect() + Ok(agent_ids) } pub(crate) fn start_game_creator_agent_background_task_at( @@ -3359,6 +3433,25 @@ fn read_next_pending_game_creator_agent_runtime_task( .find(|record| record.status == "pending")) } +fn read_recoverable_game_creator_agent_runtime_task( + root: &Path, + agent_id: &str, +) -> Result, String> { + let path = game_creator_agent_runtime_task_path(root, agent_id); + let records = + latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks(&path)?); + if let Some(task) = records + .iter() + .find(|record| record.status == "running") + .cloned() + { + return Ok(Some(task)); + } + Ok(records + .into_iter() + .find(|record| record.status == "pending")) +} + fn read_all_game_creator_agent_runtime_tasks( path: &Path, ) -> Result, String> { 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 abebf86a2..e7fa05225 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -412,6 +412,18 @@ pub(crate) fn read_game_creator_agent_runtimes( read_game_creator_agent_runtimes_at(root) } +#[tauri::command] +pub(crate) fn resume_game_creator_agent_runtime_tasks( + project_path: String, +) -> Result, String> { + 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")?; + resume_game_creator_agent_background_tasks_at(root) +} + #[tauri::command] pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus { check_game_creator_llm_config_from_config() 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 35e631ce8..a754f991a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1173,6 +1173,7 @@ fn main() { start_game_creator_agent_runtime_task, read_game_creator_agent_runtime, read_game_creator_agent_runtimes, + resume_game_creator_agent_runtime_tasks, check_game_creator_llm_config, read_game_creator_app_config, write_game_creator_app_config, 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 47327794e..52683fd3e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -585,6 +585,30 @@ pub(crate) fn enforce_project_permission_policy( Ok(()) } +pub(crate) fn enforce_project_auto_permission_policy( + root: &Path, + command_id: &str, +) -> Result<(), String> { + let view = read_project_permission_policy_at(root)?; + if view + .policy + .denied_commands + .iter() + .any(|command| command == command_id) + { + return Err(format!("项目权限策略拒绝执行:{command_id}")); + } + if view + .policy + .confirm_commands + .iter() + .any(|command| command == command_id) + { + return Err(format!("项目权限策略要求用户确认:{command_id}")); + } + Ok(()) +} + pub(crate) fn list_local_project_files_at( root: &Path, ) -> Result { 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 6cef5003f..34f297533 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -66,6 +66,20 @@ fn wait_for_agent_runtime_idle(root: &Path, agent_id: &str) -> AgentRuntimeState runtime } +fn write_agent_runtime_task_record_for_test(root: &Path, record: &AgentRuntimeTaskRecord) { + let path = root + .join(".agent/runtime/tasks") + .join(format!("{}.jsonl", record.agent_id)); + fs::create_dir_all(path.parent().expect("task parent")).expect("runtime task dir"); + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .expect("open runtime task file"); + serde_json::to_writer(&mut file, record).expect("task record json"); + file.write_all(b"\n").expect("write runtime task record"); +} + fn test_local_config_path() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -1947,6 +1961,307 @@ async fn background_agent_runtime_can_replan_after_observation() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_recovers_stale_running_task() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let plan_json = serde_json::json!({ + "thinkingSummary": "恢复上一进程遗留任务后直接回复", + "plan": ["确认恢复任务", "回复开发者"], + "actions": [], + "response": "已恢复并完成上一进程遗留的后台任务。" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan_json], 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 task_path = root.join(".agent/runtime/tasks/design-director.jsonl"); + fs::create_dir_all(task_path.parent().expect("task parent")).expect("runtime task dir"); + let task_record = 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-recover-run".to_string(), + source: "agent-background-task".to_string(), + task: "恢复上一进程遗留任务".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "上一进程正在规划".to_string(), + error: None, + updated_at: unix_timestamp().saturating_sub(600), + }; + fs::write( + &task_path, + format!( + "{}\n", + serde_json::to_string(&task_record).expect("task record json") + ), + ) + .expect("write stale running task"); + let lock_path = root.join(".agent/runtime/locks/design-director.lock"); + fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("runtime lock dir"); + fs::write( + &lock_path, + serde_json::json!({ + "agentId": "design-director", + "pid": 0, + "createdAt": 1, + }) + .to_string(), + ) + .expect("write stale lock"); + + let resumed = resume_game_creator_agent_background_tasks_at(&root).expect("resume stale task"); + assert_eq!(resumed.len(), 1); + assert_eq!(resumed[0].state.run_id, "design-recover-run"); + assert_eq!(resumed[0].state.status, "running"); + assert_eq!( + resumed[0].state.current_action, + "后台任务从上次运行状态恢复" + ); + + let plan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("recovered plan request"); + assert!(plan_request.contains("恢复上一进程遗留任务")); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!( + runtime.last_response.as_deref(), + Some("已恢复并完成上一进程遗留的后台任务。") + ); + 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-recover-run" + && task.status == "completed" + && task.phase == "completed")); + assert!(resume_game_creator_agent_background_tasks_at(&root) + .expect("resume after completed") + .is_empty()); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"recordType\":\"agent.runtime.background_task.recovered\"")); + assert!(agent_db.contains("\"recoveredFromStatus\":\"running\"")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_resume_command_requires_auto_resume_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let task_record = 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-confirm-run".to_string(), + source: "agent-background-task".to_string(), + task: "默认确认策略下不能静默恢复".to_string(), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待恢复".to_string(), + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &task_record); + + let error = resume_game_creator_agent_runtime_tasks(root.to_string_lossy().into_owned()) + .expect_err("agent.resume confirm should block automatic recovery"); + assert!(error.contains("项目权限策略要求用户确认:agent.resume")); + + let task_snapshot = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read runtime after blocked resume"); + assert!(task_snapshot + .recent_tasks + .iter() + .any(|task| task.run_id == "design-confirm-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(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let plan_json = serde_json::json!({ + "thinkingSummary": "恢复排队任务后直接回复", + "plan": ["确认排队任务", "回复开发者"], + "actions": [], + "response": "已恢复并完成排队后台任务。" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture(vec![plan_json], 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 task_record = 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-pending-recover-run".to_string(), + source: "agent-background-task".to_string(), + task: "恢复排队后台任务".to_string(), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待后台执行".to_string(), + error: None, + updated_at: unix_timestamp().saturating_sub(60), + }; + write_agent_runtime_task_record_for_test(&root, &task_record); + + let resumed = + resume_game_creator_agent_background_tasks_at(&root).expect("resume pending task"); + assert_eq!(resumed.len(), 1); + assert_eq!(resumed[0].state.run_id, "design-pending-recover-run"); + assert_eq!(resumed[0].state.status, "running"); + assert_eq!( + resumed[0].state.current_action, + "后台任务从上次运行状态恢复" + ); + + let plan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("pending task plan request"); + assert!(plan_request.contains("恢复排队后台任务")); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!( + runtime.last_response.as_deref(), + Some("已恢复并完成排队后台任务。") + ); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"recoveredFromStatus\":\"pending\"")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_recovers_stale_running_before_pending_task() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let running_plan_json = serde_json::json!({ + "thinkingSummary": "先恢复遗留运行任务", + "plan": ["完成遗留 running"], + "actions": [], + "response": "遗留运行任务先完成。" + }) + .to_string(); + let pending_plan_json = serde_json::json!({ + "thinkingSummary": "再继续排队任务", + "plan": ["完成 pending"], + "actions": [], + "response": "后续排队任务已完成。" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![running_plan_json, pending_plan_json], + 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 running_task = 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-stale-running-run".to_string(), + source: "agent-background-task".to_string(), + task: "先处理遗留运行任务".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "上一进程正在规划".to_string(), + error: None, + updated_at: unix_timestamp().saturating_sub(600), + }; + let pending_task = 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-pending-after-stale-run".to_string(), + source: "agent-background-task".to_string(), + task: "后续排队任务".to_string(), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待当前 Agent 空闲".to_string(), + error: None, + updated_at: unix_timestamp().saturating_sub(300), + }; + write_agent_runtime_task_record_for_test(&root, &running_task); + write_agent_runtime_task_record_for_test(&root, &pending_task); + + let resumed = + resume_game_creator_agent_background_tasks_at(&root).expect("resume ordered tasks"); + assert_eq!(resumed.len(), 1); + assert_eq!(resumed[0].state.run_id, "design-stale-running-run"); + + let first_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first recovered plan request"); + assert!(first_request.contains("先处理遗留运行任务")); + let second_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("pending plan request after recovered running"); + assert!(second_request.contains("后续排队任务")); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.run_id, "design-pending-after-stale-run"); + assert_eq!(runtime.status, "idle"); + assert_eq!( + runtime.last_response.as_deref(), + Some("后续排队任务已完成。") + ); + 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-stale-running-run" && task.status == "completed")); + assert!(runtime_result + .recent_tasks + .iter() + .any(|task| task.run_id == "design-pending-after-stale-run" && task.status == "completed")); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_plan_request_includes_same_agent_continuity_context() { 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 212e3c617..86f155cf2 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -11913,6 +11913,7 @@ export function App() { const agentConversationSavingRef = useRef(false); const agentConversationBackgroundBusyRef = useRef(false); const agentConversationLoadVersionRef = useRef(0); + const agentRuntimeResumeProjectPathRef = useRef(null); const agentRunHistoryLoadingMoreRef = useRef(false); const initialProjectOpenedRef = useRef(false); const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null); @@ -19520,9 +19521,24 @@ export function App() { const invoke = resolveTauriInvoke(); if (!invoke || !nextProjectPath) { setAgentRuntimeById({}); + agentRuntimeResumeProjectPathRef.current = null; return; } try { + if (agentRuntimeResumeProjectPathRef.current !== nextProjectPath) { + agentRuntimeResumeProjectPathRef.current = nextProjectPath; + try { + const resumedRuntimes = await invoke( + 'resume_game_creator_agent_runtime_tasks', + { projectPath: nextProjectPath }, + ); + for (const runtimeResult of resumedRuntimes) { + rememberAgentRuntimeState(agentRuntimeStateFromResult(runtimeResult)); + } + } catch { + // Runtime recovery is best-effort; reading current status below remains authoritative. + } + } const runtimes = await invoke( 'read_game_creator_agent_runtimes', { projectPath: nextProjectPath }, diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 976b17a05..8f4eba67f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -13310,6 +13310,12 @@ describe('AI 游戏创作 App 界面边界', () => { if (command === 'read_project_permission_policy') { return emptyProjectPolicy(); } + if (command === 'resume_game_creator_agent_runtime_tasks') { + return []; + } + if (command === 'read_game_creator_agent_runtimes') { + return []; + } if (command === 'read_local_conversation') { if (args?.agentId === null) { return { @@ -13445,6 +13451,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'resume_game_creator_agent_runtime_tasks') { + return []; + } if (command === 'read_game_creator_agent_runtimes') { return [ { @@ -13499,6 +13508,13 @@ describe('AI 游戏创作 App 界面边界', () => { '最近任务:pending / queued · 排队补齐世界观拆解', ); }); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'resume_game_creator_agent_runtime_tasks', + ), + ).toHaveLength(1); + }); }); it('ignores stale agent conversation reads after switching agents', async () => { @@ -13523,6 +13539,12 @@ describe('AI 游戏创作 App 界面边界', () => { if (command === 'read_project_permission_policy') { return emptyProjectPolicy(); } + if (command === 'resume_game_creator_agent_runtime_tasks') { + return []; + } + if (command === 'read_game_creator_agent_runtimes') { + return []; + } if (command === 'read_local_conversation') { if (args?.agentId === null) { return { @@ -13639,6 +13661,12 @@ describe('AI 游戏创作 App 界面边界', () => { if (command === 'read_project_permission_policy') { return emptyProjectPolicy(); } + if (command === 'resume_game_creator_agent_runtime_tasks') { + return []; + } + if (command === 'read_game_creator_agent_runtimes') { + return []; + } if (command === 'read_local_conversation') { if (args?.agentId === null) { return { @@ -13724,6 +13752,9 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.change(input, { target: { value: '旧 Agent 保存回包' } }); fireEvent.submit(input.closest('form') as HTMLFormElement); await screen.findByText('正在保存用户消息'); + await waitFor(() => { + expect(releaseOldSave).not.toBeNull(); + }); fireEvent.click( within(agentStatusList).getByRole('button', { name: /确定视觉方向/ }), ); @@ -13760,6 +13791,12 @@ describe('AI 游戏创作 App 界面边界', () => { if (command === 'read_project_permission_policy') { return emptyProjectPolicy(); } + if (command === 'resume_game_creator_agent_runtime_tasks') { + return []; + } + if (command === 'read_game_creator_agent_runtimes') { + return []; + } if (command === 'read_local_conversation') { if (args?.agentId === null) { return { @@ -13933,6 +13970,12 @@ describe('AI 游戏创作 App 界面边界', () => { if (command === 'read_project_permission_policy') { return emptyProjectPolicy(); } + if (command === 'resume_game_creator_agent_runtime_tasks') { + return []; + } + if (command === 'read_game_creator_agent_runtimes') { + return []; + } if (command === 'read_local_project_file') { runReadCount += 1; return { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5586e21d5..34360429b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4069,6 +4069,7 @@ - 2026-07-10 调整:Agent Runtime V1 后台工具箱新增受策略保护的 `task.update`。Agent 只能把 `.agent/manifest.json` 中已有 seed task 的状态更新为 `pending`、`running`、`waiting-for-confirmation`、`completed` 或 `failed`,Runtime 必须复用项目写锁、`task.update` 权限策略和 `.agent/agent.db` 审计记录;策略要求确认或拒绝时不得修改 manifest,不得创建新任务。 - 2026-07-10 调整:Agent Runtime V1 后台工具箱新增只读 `file.list`。Agent 可自行列出项目文件摘要或相对路径范围内的条目,先观察项目结构再决定是否读取具体文件;该工具必须受 `file.list` 项目权限策略保护,observation 只返回项目相对路径、类型和大小,不读取文件内容、不返回本机绝对路径。 - 2026-07-10 调整:Agent Runtime V1 后台工具箱新增受策略保护的 `agent.delegate`。Agent 可把任务投递给另一个 Agent 的独立后台队列,复用目标 Agent 既有锁和 pending drain 语义;策略要求确认或拒绝时不得写目标 Agent 对话、不得启动目标任务,也不得写 `agent.runtime.agent.delegate` 审计记录。 +- 2026-07-10 调整:Agent Runtime V1 新增 `resume_game_creator_agent_runtime_tasks` 恢复入口。客户端读取项目 Runtime 时对每个项目路径最多自动尝试一次恢复;恢复命令必须通过 `agent.resume` 自动权限,默认需要确认或被拒绝时不会静默启动。恢复扫描 `.agent/runtime/tasks/.jsonl` 里的上一进程遗留 `running` 或 `pending` 任务,同一 Agent 同时存在二者时先重接遗留 `running`,再由既有 drain 串行继续 `pending`,并写 `agent.runtime.background_task.recovered` 审计记录。该能力只恢复本地 JSONL 队列到当前 App 进程,不是跨重启常驻 worker,也不承诺恢复已发出的上游 LLM 请求。 - 2026-07-01 调整:AI 游戏创作 App 借鉴 Godcoder 的本地工程护栏,但只收敛到五项本地机制:`ArtifactWriter` 写入前 checkpoint、写入后 diff、用户确认 restore;进入 LLM 前过滤密钥和本机配置痕迹;`.agent/agent.db` 继续作为轻量 JSONL 项目索引,`/index` 额外刷新 `.agent/project.index.json`;同一项目写入通过 `.agent/project.lock` 串行化;`.agent/policy.json` 记录项目级命令拒绝 / 确认策略。v1 不引入通用 IDE 插件、云工作区、SQLite 或任意 shell 代理。 - 2026-07-03 调整:主窗口最近 checkpoint 列表必须直接展示 checkpoint id、文件数、大小和创建时间,并提供直接对比、填入 `/diff`、确认回滚和填入 `/restore` 的轻量操作;回滚仍走 `project.restore` 确认卡,不在列表按钮中直接写项目文件。 - 2026-06-24 调整:普通用户通过聊天输入 `/help` 发现可用内置命令;命令发现必须留在聊天消息里,不得因此暴露开发面板。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 949192e08..9c0b65b04 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -49,6 +49,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `project.diff`。Agent 可在 loop 中基于已存在 checkpoint 查看当前项目新增、修改和删除摘要;Runtime 复用 `project.diff` 项目权限策略,策略要求确认或拒绝时不执行 diff,observation 只包含 checkpoint id、三类计数和项目相对路径,不返回本机绝对路径或文件正文。 - 2026-07-10 补充:后台任务工具箱已加入 `agent.run_status`。Agent 可在 loop 中读取自己、目标 Agent 或一组 Agent 的 Runtime 状态摘要,判断同伴是否正在运行、最近任务和最近工具动作;Runtime 复用 `agent.run_status` 项目权限策略,策略要求确认或拒绝时不读取状态,observation 不返回 `.agent/runtime/*` 文件绝对路径。 - 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。 +- 2026-07-10 补充:Runtime 增加 `resume_game_creator_agent_runtime_tasks` 恢复入口。客户端读取项目 Runtime 时会对每个项目路径最多自动尝试一次恢复;恢复命令必须通过 `agent.resume` 自动权限,默认需要确认或被拒绝时不会静默启动。恢复扫描 `.agent/runtime/tasks/.jsonl` 中上一进程遗留的 `running` 或仍为 `pending` 的任务,同一 Agent 同时存在二者时先重接遗留 `running`,再由既有 drain 串行继续 `pending`;恢复动作写 `agent.runtime.background_task.recovered` 审计记录。该能力只是把本地 JSONL 队列重接到当前 App 进程,不是独立常驻 worker,也不承诺恢复已经发出的上游 LLM 请求。 - 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。 - 记忆能力:短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目级黑板 `memory/blackboard.md` 和角色私有记忆 `memory/agents//.md`;黑板用于共享重要跨 agent 记忆,角色私有记忆只给对应角色 brief 读取和追加。最近 project / agent conversation 会作为短期 prompt 上下文读取,不替代正式 memory 文件。 - 对话能力:结构化对话记录统一落在 `.agent/conversations/` 的 append-only JSONL;普通聊天写 `.agent/conversations/project.jsonl`,进入单个 agent 后只写对应 `.agent/conversations/agents/.jsonl`,不把原始对话混进项目黑板或角色私有记忆。 @@ -270,7 +271,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 / completed / failed`,读取时按 `runId` 去重返回最近任务;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行。后台任务的核心 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.tool_observation` / `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。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作,供状态面板展示最近动作;`.agent/agent.db` 追加写入按整行 JSONL 写入,减少多个 Agent 同时完成时的行交错风险。 +- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动或排队单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果。后台任务会向 `.agent/runtime/tasks/.jsonl` 追加任务视角记录,任务状态使用 `pending / running / completed / failed`,读取时按 `runId` 去重返回最近任务;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行。客户端重开项目时会对当前项目路径自动尝试一次 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.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。`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` 项目权限策略保护。