From 7a6fda2513b719e0310c1b9c8741ef3bc096905c Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Thu, 9 Jul 2026 23:38:19 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90Agent=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=8E=92=E9=98=9F=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一 Agent 后台任务忙时改为写入 pending 队列。 后台 drain 持有 Agent 锁并串行执行 pending 任务。 开发窗口提示后台任务已加入队列并展示 pending 状态。 补充 Rust 与前端测试,更新 Runtime 文档和共享决策记录。 --- .../src-tauri/src/agent.rs | 289 ++++++++++++++---- .../src-tauri/src/tests.rs | 222 ++++++++++++++ apps/ai-game-creator-shell/src/App.tsx | 13 +- .../tests/appSurface.test.ts | 129 ++++++++ .../shared-memory/decision-log.md | 2 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- 6 files changed, 598 insertions(+), 61 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index bdca5957f..5ea8d1300 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -238,21 +238,9 @@ pub(crate) fn start_game_creator_agent_background_task_at( if task.is_empty() { return Err("Agent 后台任务不能为空".to_string()); } - let _runtime_lock = acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?; - let state = start_game_creator_agent_runtime_task_at( - root, - &agent_id, - task, - run_id, - "agent-background-task", - "后台任务已投递", - vec![ - "记录开发者投递的后台任务".to_string(), - "独立读取项目上下文和本 Agent 记忆".to_string(), - "调用当前 Agent LLM 路由完成推理".to_string(), - "把结果写回 Agent 对话、runtime 状态和事件流".to_string(), - ], - )?; + let run_id = normalize_game_creator_agent_runtime_run_id(&agent_id, run_id); + let pending_task = + append_game_creator_agent_runtime_pending_task(root, &agent_id, task, &run_id)?; append_local_conversation_message_at( root, Some(&agent_id), @@ -262,6 +250,66 @@ pub(crate) fn start_game_creator_agent_background_task_at( agent_id: None, }, )?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.queued", + "agentId": pending_task.agent_id, + "taskId": pending_task.task_id, + "sessionId": pending_task.session_id, + "runId": pending_task.run_id, + "source": pending_task.source, + "status": pending_task.status, + "phase": pending_task.phase, + "task": pending_task.task, + }), + )?; + let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? + else { + return read_game_creator_agent_runtime_at(root, &agent_id); + }; + let state = start_game_creator_agent_runtime_task_at( + root, + &agent_id, + task, + &run_id, + "agent-background-task", + "后台任务已投递", + game_creator_agent_background_task_default_plan(), + )?; + 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.to_string(); + 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; + }); + + Ok(result) +} + +fn game_creator_agent_background_task_default_plan() -> Vec { + vec![ + "记录开发者投递的后台任务".to_string(), + "独立读取项目上下文和本 Agent 记忆".to_string(), + "调用当前 Agent LLM 路由完成推理".to_string(), + "把结果写回 Agent 对话、runtime 状态和事件流".to_string(), + ] +} + +fn append_game_creator_agent_background_task_started_record( + root: &Path, + state: &AgentRuntimeState, +) -> Result<(), String> { append_agent_db_record( root, serde_json::json!({ @@ -275,19 +323,50 @@ pub(crate) fn start_game_creator_agent_background_task_at( "phase": state.phase, "task": state.current_task, }), - )?; + ) +} - 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.to_string(); - tauri::async_runtime::spawn(async move { - let _runtime_lock = _runtime_lock; - run_game_creator_agent_background_task(root, background_agent_id, background_task, state) - .await; - }); - - Ok(result) +async fn drain_game_creator_agent_background_tasks( + root: PathBuf, + agent_id: String, + first_task: String, + first_state: AgentRuntimeState, +) { + run_game_creator_agent_background_task(root.clone(), agent_id.clone(), first_task, first_state) + .await; + loop { + let Some(next_task) = read_next_pending_game_creator_agent_runtime_task(&root, &agent_id) + .ok() + .flatten() + else { + break; + }; + let state = match start_game_creator_agent_runtime_task_at( + &root, + &agent_id, + &next_task.task, + &next_task.run_id, + next_task.source.as_str(), + "后台任务从队列开始执行", + game_creator_agent_background_task_default_plan(), + ) { + Ok(state) => state, + Err(error) => { + let fallback = + default_game_creator_agent_runtime_state(&agent_id, &next_task.run_id); + let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); + break; + } + }; + let _ = append_game_creator_agent_background_task_started_record(&root, &state); + run_game_creator_agent_background_task( + root.clone(), + agent_id.clone(), + next_task.task, + state, + ) + .await; + } } async fn run_game_creator_agent_background_task( @@ -560,6 +639,7 @@ async fn run_game_creator_agent_background_task( const AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT: usize = 3; const AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS: usize = 900; +pub(crate) const AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS: u64 = 300; #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -1263,10 +1343,10 @@ impl Drop for AgentRuntimeTaskLock { } } -fn acquire_game_creator_agent_runtime_task_lock( +fn try_acquire_game_creator_agent_runtime_task_lock( root: &Path, agent_id: &str, -) -> Result { +) -> Result, String> { let path = root .join(".agent") .join("runtime") @@ -1296,14 +1376,18 @@ fn acquire_game_creator_agent_runtime_task_lock( file.write_all(content.as_bytes()).map_err(|error| { format!("写入 Agent Runtime 锁失败:{}: {error}", path.display()) })?; - Ok(AgentRuntimeTaskLock { path }) + Ok(Some(AgentRuntimeTaskLock { path })) } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - if read_game_creator_agent_runtime_at(root, agent_id) + let is_running = read_game_creator_agent_runtime_at(root, agent_id) .map(|result| result.state.status == "running") - .unwrap_or(false) - { - return Err(format!("Agent {agent_id} 已有后台任务运行中")); + .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() @@ -1316,7 +1400,7 @@ fn acquire_game_creator_agent_runtime_task_lock( file.write_all(content.as_bytes()).map_err(|error| { format!("写入 Agent Runtime 锁失败:{}: {error}", path.display()) })?; - Ok(AgentRuntimeTaskLock { path }) + Ok(Some(AgentRuntimeTaskLock { path })) } Err(error) => Err(format!( "创建 Agent Runtime 锁失败:{}: {error}", @@ -1325,6 +1409,42 @@ fn acquire_game_creator_agent_runtime_task_lock( } } +#[derive(Debug)] +pub(crate) struct AgentRuntimeTaskLockStatus { + pub(crate) is_stale: bool, + pub(crate) belongs_to_previous_process: bool, +} + +pub(crate) fn read_game_creator_agent_runtime_lock_status( + path: &Path, +) -> AgentRuntimeTaskLockStatus { + let Ok(content) = fs::read_to_string(path) else { + return AgentRuntimeTaskLockStatus { + is_stale: true, + belongs_to_previous_process: true, + }; + }; + let Ok(value) = serde_json::from_str::(&content) else { + return AgentRuntimeTaskLockStatus { + is_stale: true, + belongs_to_previous_process: true, + }; + }; + let pid = value.get("pid").and_then(serde_json::Value::as_u64); + let created_at = value + .get("createdAt") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let is_stale = created_at == 0 + || unix_timestamp().saturating_sub(created_at) > AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS; + AgentRuntimeTaskLockStatus { + is_stale, + belongs_to_previous_process: pid + .map(|pid| pid != u64::from(std::process::id())) + .unwrap_or(true), + } +} + fn write_game_creator_agent_runtime_state( root: &Path, state: &AgentRuntimeState, @@ -1411,15 +1531,6 @@ fn append_game_creator_agent_runtime_task( root: &Path, state: &AgentRuntimeState, ) -> Result<(), String> { - let path = game_creator_agent_runtime_task_path(root, &state.agent_id); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 Agent Runtime 任务目录失败:{}: {error}", - parent.display() - ) - })?; - } let record = AgentRuntimeTaskRecord { schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: state.agent_id.clone(), @@ -1434,6 +1545,46 @@ fn append_game_creator_agent_runtime_task( error: state.error.clone(), updated_at: state.updated_at, }; + append_game_creator_agent_runtime_task_record(root, &record) +} + +fn append_game_creator_agent_runtime_pending_task( + root: &Path, + agent_id: &str, + task: &str, + run_id: &str, +) -> Result { + let record = AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: agent_id.to_string(), + task_id: agent_id.to_string(), + session_id: format!("agent-session-{agent_id}"), + run_id: run_id.to_string(), + source: "agent-background-task".to_string(), + task: sanitize_agent_runtime_text(task, 180), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待当前后台任务完成".to_string(), + error: None, + updated_at: unix_timestamp(), + }; + append_game_creator_agent_runtime_task_record(root, &record)?; + Ok(record) +} + +fn append_game_creator_agent_runtime_task_record( + root: &Path, + record: &AgentRuntimeTaskRecord, +) -> Result<(), String> { + let path = game_creator_agent_runtime_task_path(root, &record.agent_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent Runtime 任务目录失败:{}: {error}", + parent.display() + ) + })?; + } let mut file = fs::OpenOptions::new() .create(true) .append(true) @@ -1497,6 +1648,29 @@ fn read_recent_game_creator_agent_runtime_events( fn read_recent_game_creator_agent_runtime_tasks( path: &Path, +) -> Result, String> { + let records = read_all_game_creator_agent_runtime_tasks(path)?; + let mut recent = latest_game_creator_agent_runtime_tasks(records); + if recent.len() > AGENT_RUNTIME_RECENT_TASK_LIMIT { + recent = recent[recent.len() - AGENT_RUNTIME_RECENT_TASK_LIMIT..].to_vec(); + } + Ok(recent) +} + +fn read_next_pending_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)?); + Ok(records + .into_iter() + .find(|record| record.status == "pending")) +} + +fn read_all_game_creator_agent_runtime_tasks( + path: &Path, ) -> Result, String> { let mut records = Vec::new(); match File::open(path) { @@ -1526,20 +1700,23 @@ fn read_recent_game_creator_agent_runtime_tasks( )); } } - let mut recent = Vec::new(); - let mut seen_run_ids: Vec = Vec::new(); - for record in records.into_iter().rev() { - if seen_run_ids.iter().any(|run_id| run_id == &record.run_id) { - continue; - } - seen_run_ids.push(record.run_id.clone()); - recent.push(record); - if recent.len() >= AGENT_RUNTIME_RECENT_TASK_LIMIT { - break; + Ok(records) +} + +fn latest_game_creator_agent_runtime_tasks( + records: Vec, +) -> Vec { + let mut latest: Vec = Vec::new(); + for record in records { + if let Some(index) = latest + .iter() + .position(|existing| existing.run_id == record.run_id) + { + latest.remove(index); } + latest.push(record); } - recent.reverse(); - Ok(recent) + latest } fn truncate_agent_runtime_text(value: &str, max_chars: usize) -> String { 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 ac5e311ad..765351a35 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -79,6 +79,55 @@ fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard { } } +#[test] +fn agent_runtime_lock_status_keeps_fresh_same_process_locks_busy() { + let root = unique_project_path(); + let lock_path = root.join(".agent/runtime/locks/design-director.lock"); + fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir"); + fs::write( + &lock_path, + serde_json::json!({ + "agentId": "design-director", + "pid": std::process::id(), + "createdAt": unix_timestamp(), + }) + .to_string(), + ) + .expect("write lock"); + + let status = read_game_creator_agent_runtime_lock_status(&lock_path); + + assert!(!status.is_stale); + assert!(!status.belongs_to_previous_process); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_lock_status_marks_old_previous_process_locks_stale() { + let root = unique_project_path(); + let lock_path = root.join(".agent/runtime/locks/design-director.lock"); + fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir"); + fs::write( + &lock_path, + serde_json::json!({ + "agentId": "design-director", + "pid": u64::from(std::process::id()) + 1000, + "createdAt": unix_timestamp() + .saturating_sub(AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS + 1), + }) + .to_string(), + ) + .expect("write lock"); + + let status = read_game_creator_agent_runtime_lock_status(&lock_path); + + assert!(status.is_stale); + assert!(status.belongs_to_previous_process); + + fs::remove_dir_all(root).ok(); +} + #[test] fn config_file_overrides_defaults_without_env() { let root = unique_project_path(); @@ -589,6 +638,44 @@ fn spawn_mock_llm_server_responses_with_capture( base_url } +fn spawn_releasable_mock_llm_server_responses_with_capture( + response_contents: Vec, + request_sender: mpsc::Sender, + first_release_receiver: mpsc::Receiver<()>, +) -> String { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock llm bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + for (index, response_content) in response_contents.into_iter().enumerate() { + let (mut stream, _) = listener.accept().expect("mock llm accept"); + let mut request_buffer = [0_u8; 8192]; + let read_len = stream.read(&mut request_buffer).unwrap_or(0); + let _ = request_sender + .send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned()); + if index == 0 { + let _ = first_release_receiver.recv_timeout(Duration::from_secs(3)); + } + let body = serde_json::json!({ + "id": "resp_game_creator_mock", + "model": "mock-game-model", + "output_text": response_content, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("mock llm response"); + } + }); + base_url +} + fn spawn_mock_llm_stream_server_with_capture( response_body: String, request_sender: Option>, @@ -1495,6 +1582,141 @@ async fn background_agent_runtime_tool_action_respects_confirm_policy() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_queues_same_agent_tasks_and_drains_them() { + 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" + }} + }} +}}"# + )); + + let first = start_game_creator_agent_background_task_at( + &root, + "design-director", + "第一个后台任务", + "design-queue-first", + ) + .expect("start first background task"); + assert_eq!(first.state.status, "running"); + let first_request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first queued request"); + assert!(first_request.contains("第一个后台任务")); + + let second = start_game_creator_agent_background_task_at( + &root, + "design-director", + "第二个后台任务", + "design-queue-second", + ) + .expect("queue second background task"); + assert_eq!(second.state.status, "running"); + assert_eq!(second.state.run_id, "design-queue-first"); + assert!(second + .recent_tasks + .iter() + .any(|task| task.run_id == "design-queue-second" + && task.status == "pending" + && task.phase == "queued")); + + release_first_sender + .send(()) + .expect("release first request"); + let second_request = request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("second queued request"); + assert!(second_request.contains("第二个后台任务")); + + let mut runtime_result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime"); + for _ in 0..50 { + if runtime_result.state.status == "idle" + && runtime_result + .recent_tasks + .iter() + .any(|task| task.run_id == "design-queue-second" && task.status == "completed") + { + 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.state.status, "idle"); + assert_eq!(runtime_result.state.run_id, "design-queue-second"); + assert_eq!( + runtime_result.state.last_response.as_deref(), + Some("第二个后台任务完成。") + ); + assert!(runtime_result + .recent_tasks + .iter() + .any(|task| task.run_id == "design-queue-first" + && task.status == "completed" + && task.task == "第一个后台任务")); + assert!(runtime_result + .recent_tasks + .iter() + .any(|task| task.run_id == "design-queue-second" + && task.status == "completed" + && task.task == "第二个后台任务")); + let conversation = + read_local_conversation_at(&root, Some("design-director")).expect("conversation"); + assert!(conversation + .messages + .iter() + .any(|message| message.role == "user" && message.content == "第一个后台任务")); + assert!(conversation + .messages + .iter() + .any(|message| message.role == "user" && message.content == "第二个后台任务")); + assert!(conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == "第一个后台任务完成。")); + assert!(conversation + .messages + .iter() + .any(|message| message.role == "assistant" && message.content == "第二个后台任务完成。")); + assert!(!root + .join(".agent/runtime/locks/design-director.lock") + .exists()); + + 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 7fc533c61..34474ebcb 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -501,6 +501,15 @@ function agentRuntimeStateFromResult( }; } +function agentRuntimeStartStatus(result: AgentRuntimeResult) { + const pendingTask = (result.recentTasks ?? []).find( + (task) => task.status === 'pending', + ); + return pendingTask + ? `已加入后台队列:${pendingTask.runId}` + : `已启动后台任务:${result.state.runId}`; +} + function AgentRuntimeStatusPanel({ runtime, error, @@ -3375,7 +3384,7 @@ export function WorkspaceLauncher({ return; } setAgentChatMessages(conversation.messages); - setAgentChatStatus(`已启动后台任务:${runtime.state.runId}`); + setAgentChatStatus(agentRuntimeStartStatus(runtime)); } catch (error) { if (agentChatLoadVersionRef.current !== saveVersion) { return; @@ -12953,7 +12962,7 @@ export function App() { return; } setAgentConversationMessages(conversation.messages); - setAgentConversationStatus(`已启动后台任务:${runtime.state.runId}`); + setAgentConversationStatus(agentRuntimeStartStatus(runtime)); setCommandLog((current) => [ ...current, 'agent.runtime.background_task', diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index ff260639f..601c20d00 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1448,6 +1448,135 @@ describe('AI 游戏创作 App 界面边界', () => { ]); }); + it('shows queued developer agent background tasks when the agent is already running', async () => { + const runningRuntimeState = { + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'design-director', + taskId: 'design-director', + sessionId: 'agent-session-design-director', + runId: 'launcher-agent-task-running', + source: 'agent-background-task', + status: 'running', + phase: 'planning', + currentTask: '正在处理上一条任务', + currentAction: '生成 Agent 工具计划', + plan: ['读取上下文', '回复开发者'], + observations: ['上一条任务正在运行。'], + allowedTools: ['conversation.read', 'conversation.write'], + lastResponse: null, + error: null, + updatedAt: 5000, + }; + const runningTask = { + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'design-director', + taskId: 'design-director', + sessionId: 'agent-session-design-director', + runId: 'launcher-agent-task-running', + source: 'agent-background-task', + task: '正在处理上一条任务', + status: 'running', + phase: 'planning', + currentAction: '生成 Agent 工具计划', + error: null, + updatedAt: 5000, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'check_game_creator_llm_config') { + return { + configured: true, + apiKeyPresent: true, + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-5.5', + apiKind: 'openai_chat', + stream: true, + error: null, + agents: [ + { + agentId: 'design-director', + label: '拆解创作方向', + configured: true, + apiKeyPresent: true, + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-5.5', + apiKind: 'openai_chat', + stream: true, + error: null, + }, + ], + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: [], + }; + } + if (command === 'read_game_creator_agent_runtime') { + return { + state: runningRuntimeState, + 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', + recentEvents: [], + recentTasks: [runningTask], + }; + } + if (command === 'start_game_creator_agent_runtime_task') { + return { + state: runningRuntimeState, + 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', + recentEvents: [], + recentTasks: [ + runningTask, + { + ...runningTask, + runId: String(args?.runId ?? 'launcher-agent-task-pending'), + task: String(args?.task ?? ''), + status: 'pending', + phase: 'queued', + currentAction: '等待当前后台任务完成', + updatedAt: 5001, + }, + ], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + 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(/已读取 0 条/)).not.toBeNull(); + + fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { + target: { value: '排队整理第二个需求' }, + }); + fireEvent.click(screen.getByRole('button', { name: '后台运行' })); + + expect(await screen.findByText('正在处理上一条任务')).not.toBeNull(); + expect( + screen.getByText(/已加入后台队列:launcher-agent-task-/), + ).not.toBeNull(); + expect( + screen.getByText(/pending \/ queued · 排队整理第二个需求/), + ).not.toBeNull(); + }); + it('shows developer agent runtime read failures', async () => { const invoke = vi.fn( async (command: string, args?: Record) => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1cd62bc06..4106a1efe 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`,Runtime 按白名单和项目权限策略执行只读工具并记录 `action / observation` 事件,再把观察结果交给 Agent 生成最终回复;完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。每个 Agent 的任务历史落在 `.agent/runtime/tasks/.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `running / completed / failed`,UI 在 Runtime 面板展示最近任务。不同 Agent 使用独立 `.agent/runtime/locks/.lock`,允许并行运行;同一 Agent 同时只允许一个后台任务。该能力仍不是独立 OS 进程、可排队 pending 队列或离线常驻 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`,Runtime 按白名单和项目权限策略执行只读工具并记录 `action / observation` 事件,再把观察结果交给 Agent 生成最终回复;完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。每个 Agent 的任务历史落在 `.agent/runtime/tasks/.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,UI 在 Runtime 面板展示最近任务。不同 Agent 使用独立 `.agent/runtime/locks/.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。 - 影响范围:`apps/ai-game-creator-shell` 的 Tauri command、Agent Runtime state/event、开发窗口单 Agent 聊天、项目内 Agent 对话弹窗、`appSurface.test.ts` 和 AI 游戏创作 App 实施计划。 - 验证方式:运行 Tauri Rust 后台 Agent 并行测试、壳前端 appSurface 测试、壳 typecheck、编码检查和 `git diff --check`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index cee874511..870c11253 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -32,7 +32,7 @@ Agent Runtime 负责: - 开发窗口能力:debug 构建额外打开 `developer` 窗口,走 `index.html?agent-chat`;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/.jsonl`,通过 `agentLlm.` 调用该 Agent 的独立 LLM 路由做真实对话,用于单独调试某个 Agent 的长期对话上下文。 - 命令能力:内置命令调用、权限 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`,Runtime 按白名单和项目权限策略执行工具动作,写入 `action / observation` 事件,再把观察结果交给 Agent 生成最终回复并追加回对话。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务,开发窗口和项目内 Agent 对话弹窗在 Runtime 面板展示最近任务。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 同时只允许一个后台任务。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程、可排队 pending 队列或离线常驻 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`,Runtime 按白名单和项目权限策略执行工具动作,写入 `action / observation` 事件,再把观察结果交给 Agent 生成最终回复并追加回对话。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务,开发窗口和项目内 Agent 对话弹窗在 Runtime 面板展示最近任务。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 已有运行任务时,新任务会先写成 `pending / queued`,由当前后台 drain 在完成后串行继续执行。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程或跨重启离线常驻 worker。 - 任务图能力:每轮 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`,不把原始对话混进项目黑板或角色私有记忆。 @@ -252,7 +252,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` 追加任务视角记录,任务状态使用 `running / completed / failed`,读取时按 `runId` 去重返回最近任务;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task` / `agent.runtime.tool_observation` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱只开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index` 和 `file.read`;若项目策略要求确认或拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent。`.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 仍可并行。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.tool_observation` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱只开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index` 和 `file.read`;若项目策略要求确认或拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent。`.agent/agent.db` 追加写入按整行 JSONL 写入,减少多个 Agent 同时完成时的行交错风险。 - 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。 - 单窗口首页和项目组页可选择、打开、新建或显示当前输入的项目绝对路径;最近项目行也可显示目录,非法或相对路径不会调用系统文件管理器。 - 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。