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 c8f68b8ba..431e08bdf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -295,9 +295,9 @@ async fn run_game_creator_agent_background_task( let mut runtime = match advance_game_creator_agent_runtime_turn_at( &root, state, - "llm", - "后台请求 Agent LLM", - "后台任务已开始执行,正在让 Agent 独立推理。", + "planning", + "生成 Agent 工具计划", + "后台任务已开始执行,正在让 Agent 规划下一步动作。", ) { Ok(runtime) => runtime, Err(error) => { @@ -307,33 +307,200 @@ async fn run_game_creator_agent_background_task( } }; - match chat_with_game_creator_role_agent_at(&root, &agent_id, &task).await { - Ok(reply) => { - if let Ok(completed_runtime) = - finish_game_creator_agent_runtime_turn_at(&root, runtime.clone(), &reply.reply_text) - { - runtime = completed_runtime; - } + let plan = match request_game_creator_agent_background_tool_plan_at(&root, &agent_id, &task) + .await + { + Ok(plan) => plan, + Err(error) => { + let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); let _ = append_local_conversation_message_at( &root, Some(&agent_id), LocalConversationMessage { role: "assistant".to_string(), - content: reply.reply_text.clone(), + content: format!("后台任务失败:{error}"), agent_id: None, }, ); - let _ = append_agent_db_record( + if let Ok(runtime) = failed_runtime { + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.failed", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "error": runtime.error, + }), + ); + } + return; + } + }; + + if !plan.thinking_summary.trim().is_empty() { + runtime.observations.push(format!( + "思考摘要:{}", + sanitize_agent_runtime_text(&plan.thinking_summary, 240) + )); + runtime.updated_at = unix_timestamp(); + let _ = write_game_creator_agent_runtime_state(&root, &runtime); + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + "thinking_summary", + runtime.status.as_str(), + runtime.phase.as_str(), + "Agent 已形成任务理解摘要。", + Some(&plan.thinking_summary), + ); + } + if !plan.plan.is_empty() { + runtime.plan = plan.plan.clone(); + runtime.updated_at = unix_timestamp(); + let _ = write_game_creator_agent_runtime_state(&root, &runtime); + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + "plan", + runtime.status.as_str(), + runtime.phase.as_str(), + "Agent 已生成行动计划。", + Some(&runtime.plan.join(" / ")), + ); + } + + let mut observations = Vec::new(); + for action in plan + .actions + .iter() + .take(AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT) + { + runtime = match advance_game_creator_agent_runtime_turn_at( + &root, + runtime, + "action", + &format!("调用工具 {}", action.tool), + action.reason.as_deref().unwrap_or("Agent 请求工具动作。"), + ) { + Ok(runtime) => runtime, + Err(error) => { + let fallback = default_game_creator_agent_runtime_state(&agent_id, ""); + let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); + return; + } + }; + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + "action", + runtime.status.as_str(), + runtime.phase.as_str(), + runtime.current_action.as_str(), + action.reason.as_deref(), + ); + let observation = execute_game_creator_agent_runtime_tool_action(&root, &agent_id, action); + let observation_summary = observation.summary(); + runtime.observations.push(observation_summary.clone()); + runtime.updated_at = unix_timestamp(); + let _ = write_game_creator_agent_runtime_state(&root, &runtime); + let _ = append_game_creator_agent_runtime_event( + &root, + &runtime, + "observation", + runtime.status.as_str(), + "observation", + observation_summary.as_str(), + observation.detail.as_deref(), + ); + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "runId": runtime.run_id, + "tool": observation.tool, + "status": observation.status, + "summary": observation.summary, + }), + ); + observations.push(observation); + } + + runtime = match advance_game_creator_agent_runtime_turn_at( + &root, + runtime, + "response", + "根据观察生成最终回复", + "Agent 已完成工具观察,正在整理最终回复。", + ) { + Ok(runtime) => runtime, + Err(error) => { + let fallback = default_game_creator_agent_runtime_state(&agent_id, ""); + let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); + return; + } + }; + + let final_reply = if observations.is_empty() && !plan.response.trim().is_empty() { + plan.response.clone() + } else { + match request_game_creator_agent_background_final_reply_at( + &root, + &agent_id, + &task, + &plan, + &observations, + ) + .await + { + Ok(reply) => reply, + Err(_) if !plan.response.trim().is_empty() => plan.response.clone(), + Err(error) => { + let failed_runtime = + fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); + let _ = append_local_conversation_message_at( + &root, + Some(&agent_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: format!("后台任务失败:{error}"), + agent_id: None, + }, + ); + if let Ok(runtime) = failed_runtime { + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.failed", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "error": runtime.error, + }), + ); + } + return; + } + } + }; + + match finish_game_creator_agent_runtime_turn_at(&root, runtime.clone(), &final_reply) { + Ok(completed_runtime) => { + runtime = completed_runtime; + let _ = append_game_creator_agent_runtime_event( &root, - serde_json::json!({ - "recordType": "agent.runtime.background_task.completed", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "source": runtime.source, - "responsePreview": runtime.last_response, - }), + &runtime, + "response", + runtime.status.as_str(), + runtime.phase.as_str(), + "Agent 已生成最终回复。", + runtime.last_response.as_deref(), ); } Err(error) => { @@ -361,8 +528,370 @@ async fn run_game_creator_agent_background_task( }), ); } + return; } } + let _ = append_local_conversation_message_at( + &root, + Some(&agent_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: final_reply.clone(), + agent_id: None, + }, + ); + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.completed", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "responsePreview": runtime.last_response, + }), + ); +} + +const AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT: usize = 3; +const AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS: usize = 900; + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimeToolPlan { + #[serde(default)] + thinking_summary: String, + #[serde(default)] + plan: Vec, + #[serde(default)] + actions: Vec, + #[serde(default)] + response: String, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimeToolAction { + #[serde(default)] + tool: String, + #[serde(default)] + reason: Option, + #[serde(default)] + input: serde_json::Value, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimeToolObservation { + tool: String, + status: String, + summary: String, + detail: Option, +} + +impl AgentRuntimeToolObservation { + fn summary(&self) -> String { + format!("{}:{} · {}", self.tool, self.status, self.summary) + } +} + +async fn request_game_creator_agent_background_tool_plan_at( + root: &Path, + agent_id: &str, + task: &str, +) -> Result { + let (llm, config_path, request) = + build_game_creator_agent_background_tool_plan_request(root, agent_id, task)?; + let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; + let response = request_game_creator_llm_text(&client, &llm, request) + .await + .map_err(|error| format!("{config_path} 后台 Agent 工具计划调用 LLM 失败:{error}"))?; + parse_game_creator_agent_tool_plan_response(response.text.as_str()) +} + +async fn request_game_creator_agent_background_final_reply_at( + root: &Path, + agent_id: &str, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &[AgentRuntimeToolObservation], +) -> Result { + let (llm, config_path, request) = build_game_creator_agent_background_final_reply_request( + root, + agent_id, + task, + plan, + observations, + )?; + let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; + let response = request_game_creator_llm_text(&client, &llm, request) + .await + .map_err(|error| format!("{config_path} 后台 Agent 最终回复调用 LLM 失败:{error}"))?; + let reply = strip_llm_thinking_blocks(response.text.as_str()); + if reply.trim().is_empty() { + return Err(format!("{config_path} 后台 Agent 最终回复为空")); + } + Ok(reply) +} + +fn build_game_creator_agent_background_tool_plan_request( + root: &Path, + agent_id: &str, + task: &str, +) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { + let (llm, config_path, context) = build_game_creator_role_agent_context(root, agent_id)?; + let prompt = format!( + "项目上下文如下。请先制定短计划,再决定是否调用最多 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个白名单工具。只输出 JSON 对象,不要 markdown。\n\n{context}\n\n后台任务:\n{task}\n\nJSON schema:{{\"thinkingSummary\":\"一句话理解\",\"plan\":[\"步骤\"],\"actions\":[{{\"tool\":\"memory.read|conversation.read|asset.list|project.index|file.read\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};file.read 使用 {{\"path\":\"项目内相对路径\"}};其他工具 input 可为空。" + ); + let request = LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_agent_runtime_tool_plan_system_prompt()), + LlmMessage::user(prompt), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS); + Ok((llm, config_path, request)) +} + +fn build_game_creator_agent_background_final_reply_request( + root: &Path, + agent_id: &str, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &[AgentRuntimeToolObservation], +) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { + let (llm, config_path, context) = build_game_creator_role_agent_context(root, agent_id)?; + let observations_json = serde_json::to_string_pretty(observations) + .map_err(|error| format!("序列化 Agent 工具观察失败:{error}"))?; + let plan_json = serde_json::to_string_pretty(plan) + .map_err(|error| format!("序列化 Agent 工具计划失败:{error}"))?; + let prompt = format!( + "项目上下文如下。请结合后台任务、你的计划和工具观察,给开发者一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具。\n\n{context}\n\n后台任务:\n{task}\n\n计划:\n{plan_json}\n\n工具观察:\n{observations_json}" + ); + let request = LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_role_agent_chat_system_prompt()), + LlmMessage::user(prompt), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS); + Ok((llm, config_path, request)) +} + +fn parse_game_creator_agent_tool_plan_response( + content: &str, +) -> Result { + let stripped = strip_llm_thinking_blocks(content); + let Some(payload) = extract_json_payload(stripped.as_str()) else { + return Ok(AgentRuntimeToolPlan { + response: truncate_agent_runtime_text(stripped.as_str(), 1_200), + ..AgentRuntimeToolPlan::default() + }); + }; + let mut plan = serde_json::from_str::(payload) + .map_err(|error| format!("解析 Agent 工具计划失败:{error}"))?; + plan.thinking_summary = truncate_agent_runtime_text(&plan.thinking_summary, 240); + plan.plan = plan + .plan + .into_iter() + .map(|item| truncate_agent_runtime_text(&item, 160)) + .filter(|item| !item.trim().is_empty()) + .take(5) + .collect(); + plan.actions = plan + .actions + .into_iter() + .filter(|action| !action.tool.trim().is_empty()) + .take(AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT) + .collect(); + plan.response = truncate_agent_runtime_text(&plan.response, 1_200); + Ok(plan) +} + +fn execute_game_creator_agent_runtime_tool_action( + root: &Path, + agent_id: &str, + action: &AgentRuntimeToolAction, +) -> AgentRuntimeToolObservation { + let tool = action.tool.trim(); + let command_id = game_creator_agent_runtime_tool_command_id(tool); + if let Some(command_id) = command_id { + if let Some(blocked) = game_creator_agent_runtime_tool_policy_block(root, command_id) { + return AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "blocked".to_string(), + summary: blocked, + detail: None, + }; + } + } else { + return AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "rejected".to_string(), + summary: "工具不在 Agent Runtime 白名单中".to_string(), + detail: None, + }; + } + + match tool { + "memory.read" => observe_agent_runtime_memory(root, agent_id, &action.input), + "conversation.read" => observe_agent_runtime_conversation(root, agent_id), + "asset.list" => observe_agent_runtime_assets(root), + "project.index" => observe_agent_runtime_project_index(root), + "file.read" => observe_agent_runtime_file(root, &action.input), + _ => AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "rejected".to_string(), + summary: "工具不在 Agent Runtime 白名单中".to_string(), + detail: None, + }, + } +} + +fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str> { + match tool { + "memory.read" => Some("memory.read"), + "conversation.read" => Some("conversation.read"), + "asset.list" => Some("asset.list"), + "project.index" => Some("project.index"), + "file.read" => Some("file.read"), + _ => None, + } +} + +fn game_creator_agent_runtime_tool_policy_block(root: &Path, command_id: &str) -> Option { + let view = match read_project_permission_policy_at(root) { + Ok(view) => view, + Err(error) => return Some(error), + }; + if view + .policy + .denied_commands + .iter() + .any(|command| command == command_id) + { + return Some(format!("项目权限策略拒绝执行:{command_id}")); + } + if view + .policy + .confirm_commands + .iter() + .any(|command| command == command_id) + { + return Some(format!("项目权限策略要求用户确认:{command_id}")); + } + None +} + +fn observe_agent_runtime_memory( + root: &Path, + agent_id: &str, + input: &serde_json::Value, +) -> AgentRuntimeToolObservation { + let scope = input + .get("scope") + .and_then(|value| value.as_str()) + .unwrap_or("blackboard") + .trim(); + let result = match scope { + "session" => read_optional_text(&root.join("memory/session.md")), + "project" => read_optional_text(&root.join("memory/project.md")), + "blackboard" => read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH)), + "agent" => read_local_agent_memory_at(root, agent_id).map(|result| result.content), + _ => Err(format!("不支持的记忆 scope:{scope}")), + }; + observation_from_text_result("memory.read", result, "已读取记忆") +} + +fn observe_agent_runtime_conversation(root: &Path, agent_id: &str) -> AgentRuntimeToolObservation { + observation_from_text_result( + "conversation.read", + render_local_conversation_prompt_context(root, Some(agent_id)), + "已读取本 Agent 最近对话", + ) +} + +fn observe_agent_runtime_assets(root: &Path) -> AgentRuntimeToolObservation { + observation_from_text_result( + "asset.list", + render_local_asset_prompt_context(root), + "已读取项目资产清单", + ) +} + +fn observe_agent_runtime_project_index(root: &Path) -> AgentRuntimeToolObservation { + let result = list_local_project_files_at(root).map(|result| { + let mut lines = result + .files + .iter() + .take(40) + .map(|file| format!("- {} · {} · {} bytes", file.path, file.kind, file.size)) + .collect::>(); + if result.files.len() > 40 { + lines.push(format!("- ... 还有 {} 个条目", result.files.len() - 40)); + } + if lines.is_empty() { + "项目暂无可列出的文件".to_string() + } else { + lines.join("\n") + } + }); + observation_from_text_result("project.index", result, "已读取项目文件索引") +} + +fn observe_agent_runtime_file( + root: &Path, + input: &serde_json::Value, +) -> AgentRuntimeToolObservation { + let path = input + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .trim(); + if path.is_empty() { + return AgentRuntimeToolObservation { + tool: "file.read".to_string(), + status: "failed".to_string(), + summary: "缺少 path".to_string(), + detail: None, + }; + } + let result = read_local_project_file_at(root, path).map(|result| result.content); + observation_from_text_result("file.read", result, &format!("已读取 {path}")) +} + +fn observation_from_text_result( + tool: &str, + result: Result, + success_summary: &str, +) -> AgentRuntimeToolObservation { + match result { + Ok(content) => { + let detail = truncate_agent_runtime_text( + sanitize_prompt_context(&content).as_str(), + AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, + ); + AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "ok".to_string(), + summary: if detail.trim().is_empty() { + format!("{success_summary},内容为空") + } else { + success_summary.to_string() + }, + detail: if detail.trim().is_empty() { + None + } else { + Some(detail) + }, + } + } + Err(error) => AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "failed".to_string(), + summary: sanitize_agent_runtime_text(&error, 240), + detail: None, + }, + } } pub(crate) fn start_game_creator_agent_runtime_turn_at( @@ -890,14 +1419,33 @@ pub(crate) fn build_game_creator_role_agent_chat_request( agent_id: &str, prompt: &str, ) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { - let agent_id = agent_id.trim(); let prompt = prompt.trim(); - if agent_id.is_empty() { - return Err("Agent ID 不能为空".to_string()); - } if prompt.is_empty() { return Err("聊天内容不能为空".to_string()); } + let (llm, config_path, context) = build_game_creator_role_agent_context(root, agent_id)?; + let user_prompt = if context.trim().is_empty() { + format!("用户这轮输入:\n{prompt}") + } else { + format!("项目上下文如下。请只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}") + }; + let request = LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_role_agent_chat_system_prompt()), + LlmMessage::user(user_prompt), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS); + Ok((llm, config_path, request)) +} + +fn build_game_creator_role_agent_context( + root: &Path, + agent_id: &str, +) -> Result<(GameCreatorLlmConfig, String, String), String> { + let agent_id = agent_id.trim(); + if agent_id.is_empty() { + return Err("Agent ID 不能为空".to_string()); + } validate_project_root(root)?; let (group_definition, role_definition) = game_creator_agent_role_definition(agent_id) .ok_or_else(|| format!("未知 Agent:{agent_id}"))?; @@ -935,19 +1483,7 @@ pub(crate) fn build_game_creator_role_agent_chat_request( let app_config = load_game_creator_app_config()?; let llm = resolve_game_creator_llm_config_for_agent(&app_config, agent_id); - let config_path = format!("agentLlm.{agent_id}"); - let user_prompt = if context.trim().is_empty() { - format!("用户这轮输入:\n{prompt}") - } else { - format!("项目上下文如下。请只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}") - }; - let request = LlmRunRequest::new(vec![ - LlmMessage::system(game_creator_role_agent_chat_system_prompt()), - LlmMessage::user(user_prompt), - ]) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS); - Ok((llm, config_path, request)) + Ok((llm, format!("agentLlm.{agent_id}"), context)) } pub(crate) fn game_creator_chat_agent_system_prompt() -> &'static str { @@ -958,6 +1494,10 @@ pub(crate) fn game_creator_role_agent_chat_system_prompt() -> &'static str { "你是 Genarrative AI 游戏创作多智能体中的一个专业角色 Agent。你正在开发专用单 Agent 聊天窗口中和开发者对话,需要围绕自己的专业职责直接回应、澄清问题、给出可执行建议,并说明哪些信息会影响后续生成。不要假装已经写入文件、生成游戏、调用画板或执行工具;不要泄露密钥;不要输出 JSON;不要包裹代码块;回复保持简洁、具体、中文优先。" } +pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> &'static str { + "你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须在白名单工具内规划行动:先给一句 thinkingSummary,再给短计划,再决定是否请求工具。只能请求 memory.read、conversation.read、asset.list、project.index、file.read。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。只输出 JSON 对象,不要 markdown,不要泄露密钥。" +} + pub(crate) fn game_creator_agent_role_definition( agent_id: &str, ) -> Option<(&'static AgentGroupDefinition, &'static AgentRoleDefinition)> { 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 3d6fabcce..0941323a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -1256,6 +1256,226 @@ async fn role_agent_runtime_turn_persists_session_events_and_index() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_task_executes_plan_tool_observation_loop() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write( + root.join("game/notes.txt"), + "核心循环:收集月光食材并躲避暗影", + ) + .expect("write notes"); + fs::write( + root.join(PROJECT_BLACKBOARD_MEMORY_PATH), + "黑板:必须先确认核心循环", + ) + .expect("write blackboard"); + let (sender, receiver) = mpsc::channel(); + let plan_json = serde_json::json!({ + "thinkingSummary": "需要先读项目笔记再给策划建议", + "plan": ["读取项目笔记", "结合黑板判断下一步", "回复开发者"], + "actions": [ + { + "tool": "file.read", + "reason": "确认已有核心循环记录", + "input": { "path": "game/notes.txt" } + }, + { + "tool": "memory.read", + "reason": "读取项目黑板约束", + "input": { "scope": "blackboard" } + } + ], + "response": "" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![ + plan_json, + "我读到了项目笔记和黑板:核心循环应围绕月光食材收集,并先确认这条玩法闭环。" + .to_string(), + ], + 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 started = start_game_creator_agent_background_task_at( + &root, + "design-director", + "后台分析当前玩法循环", + "design-loop-run", + ) + .expect("start background task"); + assert_eq!(started.state.status, "running"); + assert_eq!(started.state.current_action, "后台任务已投递"); + + let plan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("plan llm request"); + assert!(plan_request.contains("只输出 JSON 对象")); + assert!(plan_request.contains("后台分析当前玩法循环")); + let final_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("final reply llm request"); + assert!(final_request.contains("工具观察")); + assert!(final_request.contains("核心循环:收集月光食材并躲避暗影")); + assert!(final_request.contains("黑板:必须先确认核心循环")); + + let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read runtime") + .state; + for _ in 0..50 { + if runtime.status == "idle" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read runtime") + .state; + } + + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!( + runtime.plan, + vec![ + "读取项目笔记".to_string(), + "结合黑板判断下一步".to_string(), + "回复开发者".to_string(), + ] + ); + assert!(runtime + .observations + .iter() + .any(|item| item.contains("思考摘要:需要先读项目笔记"))); + assert!(runtime + .observations + .iter() + .any(|item| item.contains("file.read:ok"))); + assert!(runtime + .observations + .iter() + .any(|item| item.contains("memory.read:ok"))); + assert_eq!( + runtime.last_response.as_deref(), + Some("我读到了项目笔记和黑板:核心循环应围绕月光食材收集,并先确认这条玩法闭环。") + ); + + let runtime_result = + read_game_creator_agent_runtime_at(&root, "design-director").expect("runtime result"); + let event_types = runtime_result + .recent_events + .iter() + .map(|event| event.event_type.as_str()) + .collect::>(); + assert!(event_types.contains(&"thinking_summary")); + assert!(event_types.contains(&"plan")); + assert!(event_types.contains(&"observation")); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"recordType\":\"agent.runtime.tool_observation\"")); + assert!(agent_db.contains("\"tool\":\"file.read\"")); + assert!(agent_db.contains("\"tool\":\"memory.read\"")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_tool_action_respects_confirm_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write(root.join("game/notes.txt"), "不应该被读取的文件内容").expect("write notes"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: vec!["file.read".to_string()], + }, + ) + .expect("write policy"); + let (sender, receiver) = mpsc::channel(); + let plan_json = serde_json::json!({ + "thinkingSummary": "需要读项目笔记", + "plan": ["读取项目笔记", "回复开发者"], + "actions": [ + { + "tool": "file.read", + "reason": "确认项目笔记", + "input": { "path": "game/notes.txt" } + } + ], + "response": "" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![plan_json, "读取项目笔记需要先获得用户确认。".to_string()], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "后台分析当前玩法循环", + "design-confirm-run", + ) + .expect("start background task"); + + let _plan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("plan llm request"); + let final_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("final reply llm request"); + assert!(final_request.contains("项目权限策略要求用户确认:file.read")); + assert!(!final_request.contains("不应该被读取的文件内容")); + + let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read runtime") + .state; + for _ in 0..50 { + if runtime.status == "idle" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read runtime") + .state; + } + assert_eq!(runtime.status, "idle"); + assert!(runtime + .observations + .iter() + .any(|item| item.contains("file.read:blocked · 项目权限策略要求用户确认:file.read"))); + assert!(!runtime + .observations + .iter() + .any(|item| item.contains("file.read:ok"))); + + 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/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 5e79f6d00..be10c5645 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1298,11 +1298,11 @@ describe('AI 游戏创作 App 界面边界', () => { runId: 'launcher-agent-task-test', source: 'agent-background-task', status: 'running', - phase: 'planning', + phase: 'action', currentTask: '后台整理角色规范', - currentAction: '后台任务已投递', - plan: ['记录开发者投递的后台任务', '独立读取项目上下文'], - observations: ['已创建本轮 Agent Runtime run。'], + currentAction: '调用工具 file.read', + plan: ['读取项目笔记', '结合观察修正建议', '回复开发者'], + observations: ['思考摘要:需要先看项目笔记', 'file.read:ok · 已读取 game/notes.txt'], allowedTools: ['conversation.read', 'conversation.write'], lastResponse: null, error: null, @@ -1391,8 +1391,14 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.click(screen.getByRole('button', { name: '后台运行' })); expect(await screen.findByText('后台整理角色规范')).not.toBeNull(); - expect(await screen.findByText('running / planning')).not.toBeNull(); + expect(await screen.findByText('running / action')).not.toBeNull(); expect(screen.getByText(/agent-background-task/)).not.toBeNull(); + expect(screen.getByText('调用工具 file.read')).not.toBeNull(); + expect(screen.getByText('读取项目笔记')).not.toBeNull(); + expect(screen.getByText('结合观察修正建议')).not.toBeNull(); + expect(screen.getByText('回复开发者')).not.toBeNull(); + expect(screen.getByText('思考摘要:需要先看项目笔记')).not.toBeNull(); + expect(screen.getByText('file.read:ok · 已读取 game/notes.txt')).not.toBeNull(); expect(screen.getByText(/已启动后台任务:launcher-agent-task-/)).not.toBeNull(); expect(invoke).toHaveBeenCalledWith( 'start_game_creator_agent_runtime_task', @@ -13172,12 +13178,17 @@ describe('AI 游戏创作 App 界面边界', () => { renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); await screen.findByText('想做什么游戏?'); - fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + const agentStatusPane = screen.getByLabelText('Agent 状态'); + fireEvent.click( + within(agentStatusPane).getByRole('button', { name: /拆解创作方向/ }), + ); const input = await screen.findByLabelText('Agent 对话内容'); fireEvent.change(input, { target: { value: '旧 Agent 保存回包' } }); fireEvent.submit(input.closest('form') as HTMLFormElement); await screen.findByText('正在保存用户消息'); - fireEvent.click(screen.getByRole('button', { name: /确定视觉方向/ })); + fireEvent.click( + within(agentStatusPane).getByRole('button', { name: /确定视觉方向/ }), + ); expect(await screen.findByText('新 Agent 留存消息')).not.toBeNull(); await act(async () => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index db7235021..f08cec805 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、追加用户任务到 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 调用该 Agent 的独立 LLM 路由;完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。不同 Agent 使用独立 `.agent/runtime/locks/.lock`,允许并行运行;同一 Agent 同时只允许一个后台任务。该能力仍不是独立 OS 进程、持久队列或离线常驻 worker。 +- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event、追加用户任务到 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 先输出 `thinkingSummary / plan / actions`,Runtime 按白名单和项目权限策略执行只读工具并记录 `action / observation` 事件,再把观察结果交给 Agent 生成最终回复;完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。不同 Agent 使用独立 `.agent/runtime/locks/.lock`,允许并行运行;同一 Agent 同时只允许一个后台任务。该能力仍不是独立 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 3314298dc..a70034ad3 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/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 调用该 Agent 的独立 LLM 路由并把 assistant 回复追加回对话。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 同时只允许一个后台任务。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程、持久队列或离线常驻 worker。 +- 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/.json`、`.agent/runtime/events/.jsonl` 和 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:先让该 Agent 输出 `thinkingSummary / plan / actions`,Runtime 按白名单和项目权限策略执行工具动作,写入 `action / observation` 事件,再把观察结果交给 Agent 生成最终回复并追加回对话。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 同时只允许一个后台任务。该能力仍属于 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 状态查看进度和结果。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。`.agent/agent.db` 追加写入按整行 JSONL 写入,减少多个 Agent 同时完成时的行交错风险。 +- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果。后台任务完成后会把 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 同时完成时的行交错风险。 - 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。 - 单窗口首页和项目组页可选择、打开、新建或显示当前输入的项目绝对路径;最近项目行也可显示目录,非法或相对路径不会调用系统文件管理器。 - 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。