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 d85b1351d..4b7e48dbf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -1894,6 +1894,9 @@ pub(crate) fn start_game_creator_agent_runtime_task_at( return Err("Agent Runtime 任务不能为空".to_string()); } let runtime_task = sanitize_agent_runtime_text(task, 180); + let previous_state = read_game_creator_agent_runtime_at(root, &agent_id) + .ok() + .map(|result| result.state); let mut state = default_game_creator_agent_runtime_state(&agent_id, &run_id); state.source = source.trim().to_string(); state.status = "running".to_string(); @@ -1903,6 +1906,10 @@ pub(crate) fn start_game_creator_agent_runtime_task_at( state.next_step = "等待 Agent 输出计划或回复".to_string(); state.plan = plan; state.observations = vec!["已创建本轮 Agent Runtime run。".to_string()]; + if let Some(previous_state) = previous_state { + state.recent_tool_calls = previous_state.recent_tool_calls; + state.last_response = previous_state.last_response; + } refresh_game_creator_agent_runtime_tool_policy(root, &mut state)?; state.updated_at = unix_timestamp(); write_game_creator_agent_runtime_state(root, &state)?; @@ -2703,6 +2710,244 @@ fn redact_agent_runtime_project_paths(root: &Path, value: &str, max_chars: usize sanitize_agent_runtime_text(&redacted, max_chars) } +fn render_agent_runtime_tool_names(values: &[String], limit: usize) -> String { + if values.is_empty() { + return "无".to_string(); + } + let mut names = values + .iter() + .filter(|value| !value.trim().is_empty()) + .take(limit) + .cloned() + .collect::>(); + if values.len() > limit { + names.push(format!("等{}项", values.len())); + } + names.join(", ") +} + +fn render_agent_runtime_prompt_context(root: &Path, agent_id: &str) -> Result { + let runtime = match read_game_creator_agent_runtime_at(root, agent_id) { + Ok(runtime) => runtime, + Err(error) => { + return Ok(format!( + "Agent Runtime 连续上下文读取失败:{}", + redact_agent_runtime_project_paths(root, &error, 360) + )); + } + }; + let state = &runtime.state; + let has_meaningful_state = !state.current_task.trim().is_empty() + || state + .last_response + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || state + .error + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || !state.observations.is_empty() + || !state.recent_tool_calls.is_empty() + || !runtime.recent_events.is_empty() + || !runtime.recent_tasks.is_empty(); + if !has_meaningful_state { + return Ok(String::new()); + } + + let mut lines = Vec::new(); + lines.push(format!( + "当前状态:status={}, phase={}, source={}, runId={}", + redact_agent_runtime_project_paths(root, &state.status, 80), + redact_agent_runtime_project_paths(root, &state.phase, 80), + redact_agent_runtime_project_paths(root, &state.source, 120), + redact_agent_runtime_project_paths(root, &state.run_id, 160) + )); + if !state.current_task.trim().is_empty() { + lines.push(format!( + "当前任务:{}", + redact_agent_runtime_project_paths(root, &state.current_task, 220) + )); + } + if !state.current_action.trim().is_empty() { + lines.push(format!( + "当前动作:{}", + redact_agent_runtime_project_paths(root, &state.current_action, 160) + )); + } + if !state.next_step.trim().is_empty() { + lines.push(format!( + "下一步:{}", + redact_agent_runtime_project_paths(root, &state.next_step, 160) + )); + } + if let Some(last_response) = state + .last_response + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + lines.push(format!( + "最近回复:{}", + redact_agent_runtime_project_paths(root, last_response, 360) + )); + } + if let Some(error) = state + .error + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + lines.push(format!( + "最近错误:{}", + redact_agent_runtime_project_paths(root, error, 360) + )); + } + if !state.plan.is_empty() { + lines.push("当前/最近计划:".to_string()); + for item in state + .plan + .iter() + .filter(|item| !item.trim().is_empty()) + .take(5) + { + lines.push(format!( + "- {}", + redact_agent_runtime_project_paths(root, item, 180) + )); + } + } + + let recent_observations = state + .observations + .iter() + .filter(|item| !item.trim().is_empty()) + .rev() + .take(3) + .collect::>(); + if !recent_observations.is_empty() { + lines.push("最近观察:".to_string()); + for item in recent_observations.iter().rev() { + lines.push(format!( + "- {}", + redact_agent_runtime_project_paths(root, item, 260) + )); + } + } + + let recent_tool_calls = state + .recent_tool_calls + .iter() + .rev() + .take(3) + .collect::>(); + if !recent_tool_calls.is_empty() { + lines.push("最近工具动作:".to_string()); + for call in recent_tool_calls.iter().rev() { + let mut line = format!( + "- {} [{}]:{}", + redact_agent_runtime_project_paths(root, &call.tool, 80), + redact_agent_runtime_project_paths(root, &call.status, 80), + redact_agent_runtime_project_paths(root, &call.summary, 260) + ); + if let Some(reason) = call + .reason + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + line.push_str(&format!( + ";原因:{}", + redact_agent_runtime_project_paths(root, reason, 160) + )); + } + if let Some(detail) = call + .detail + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + line.push_str(&format!( + ";观察:{}", + redact_agent_runtime_project_paths(root, detail, 260) + )); + } + lines.push(line); + } + } + + let recent_events = runtime + .recent_events + .iter() + .filter(|event| !event.summary.trim().is_empty()) + .rev() + .take(4) + .collect::>(); + if !recent_events.is_empty() { + lines.push("最近事件:".to_string()); + for event in recent_events.iter().rev() { + let mut line = format!( + "- {} [{} / {}]:{}", + redact_agent_runtime_project_paths(root, &event.event_type, 120), + redact_agent_runtime_project_paths(root, &event.status, 80), + redact_agent_runtime_project_paths(root, &event.phase, 80), + redact_agent_runtime_project_paths(root, &event.summary, 260) + ); + if let Some(detail) = event + .detail + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + line.push_str(&format!( + ";{}", + redact_agent_runtime_project_paths(root, detail, 260) + )); + } + lines.push(line); + } + } + + let recent_tasks = runtime + .recent_tasks + .iter() + .rev() + .take(3) + .collect::>(); + if !recent_tasks.is_empty() { + lines.push("最近任务:".to_string()); + for task in recent_tasks.iter().rev() { + let mut line = format!( + "- {} [{} / {}]:{}", + redact_agent_runtime_project_paths(root, &task.run_id, 160), + redact_agent_runtime_project_paths(root, &task.status, 80), + redact_agent_runtime_project_paths(root, &task.phase, 80), + redact_agent_runtime_project_paths(root, &task.task, 220) + ); + if !task.current_action.trim().is_empty() { + line.push_str(&format!( + ";动作:{}", + redact_agent_runtime_project_paths(root, &task.current_action, 180) + )); + } + if let Some(error) = task + .error + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + line.push_str(&format!( + ";错误:{}", + redact_agent_runtime_project_paths(root, error, 220) + )); + } + lines.push(line); + } + } + + lines.push(format!( + "工具策略:auto={};confirm={};denied={}", + render_agent_runtime_tool_names(&state.tool_policy.auto_tools, 8), + render_agent_runtime_tool_names(&state.tool_policy.confirm_tools, 8), + render_agent_runtime_tool_names(&state.tool_policy.denied_tools, 8) + )); + + Ok(lines.join("\n")) +} + fn unix_timestamp_nanos() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -2747,6 +2992,7 @@ fn build_game_creator_role_agent_context( let long_memory = read_optional_text(&root.join("memory/project.md"))?; let project_blackboard = read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH))?; let agent_memory = read_local_agent_memory_at(root, &agent_id)?.content; + let runtime_context = render_agent_runtime_prompt_context(root, &agent_id)?; let asset_context = render_local_asset_prompt_context(root)?; let conversation_context = render_local_conversation_prompt_context(root, Some(&agent_id))?; let identity = format!( @@ -2756,6 +3002,7 @@ fn build_game_creator_role_agent_context( let context = [ ("Agent 身份", identity.as_str()), ("Agent 私有记忆", agent_memory.as_str()), + ("Agent Runtime 连续上下文", runtime_context.as_str()), ("短期记忆", short_memory.as_str()), ("长期记忆", long_memory.as_str()), ("项目黑板", project_blackboard.as_str()), 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 1d168e9ab..b09eee4eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -50,6 +50,22 @@ fn unique_project_path() -> PathBuf { )) } +fn wait_for_agent_runtime_idle(root: &Path, agent_id: &str) -> AgentRuntimeState { + let mut runtime = read_game_creator_agent_runtime_at(root, agent_id) + .expect("read runtime while waiting") + .state; + for _ in 0..50 { + if runtime.status == "idle" { + return runtime; + } + std::thread::sleep(Duration::from_millis(20)); + runtime = read_game_creator_agent_runtime_at(root, agent_id) + .expect("read runtime while waiting") + .state; + } + runtime +} + fn test_local_config_path() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -1814,6 +1830,151 @@ async fn background_agent_runtime_can_replan_after_observation() { 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(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write( + root.join("game/continuity-notes.txt"), + "连续上下文笔记:第一轮已经确认月光厨房核心循环。", + ) + .expect("write continuity notes"); + + let (sender, receiver) = mpsc::channel(); + let first_plan_json = serde_json::json!({ + "thinkingSummary": "首轮需要读取项目笔记", + "plan": ["读取项目笔记", "把观察留给下一轮"], + "actions": [ + { + "tool": "file.read", + "reason": "为连续上下文留下工具证据", + "input": { "path": "game/continuity-notes.txt" } + } + ], + "response": "" + }) + .to_string(); + let art_plan_json = serde_json::json!({ + "thinkingSummary": "美术任务不应继承策划 Agent 的 runtime", + "plan": ["独立处理美术任务"], + "actions": [], + "response": "美术无关任务完成。" + }) + .to_string(); + let second_design_plan_json = serde_json::json!({ + "thinkingSummary": "第二轮应参考本 Agent 上一轮 runtime 上下文", + "plan": ["复用上一轮工具观察", "继续设计建议"], + "actions": [], + "response": "第二轮设计任务完成。" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![ + first_plan_json, + "首轮完成:已经读取连续上下文笔记。".to_string(), + art_plan_json, + second_design_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" + }}, + "art-director": {{ + "apiKey": "art-key", + "baseUrl": {base_url:?}, + "model": "art-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "首轮检查连续上下文", + "design-continuity-first", + ) + .expect("start first design task"); + let first_plan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first design plan request"); + assert!(first_plan_request.contains("首轮检查连续上下文")); + let first_replan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first design replan request"); + assert!(first_replan_request.contains("连续上下文笔记:第一轮已经确认月光厨房核心循环")); + let first_runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(first_runtime.run_id, "design-continuity-first"); + assert_eq!( + first_runtime.last_response.as_deref(), + Some("首轮完成:已经读取连续上下文笔记。") + ); + assert!(first_runtime + .recent_tool_calls + .iter() + .any(|call| call.tool == "file.read" && call.status == "ok")); + + start_game_creator_agent_background_task_at( + &root, + "art-director", + "美术无关任务", + "art-continuity-check", + ) + .expect("start art task"); + let art_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("art plan request"); + assert!(art_request.contains("美术无关任务")); + assert!(!art_request.contains("design-continuity-first")); + assert!(!art_request.contains("首轮完成:已经读取连续上下文笔记")); + assert!(!art_request.contains("file.read [ok]")); + let art_runtime = wait_for_agent_runtime_idle(&root, "art-director"); + assert_eq!(art_runtime.run_id, "art-continuity-check"); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "第二轮继续策划", + "design-continuity-second", + ) + .expect("start second design task"); + let second_design_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("second design plan request"); + assert!(second_design_request.contains("# Agent Runtime 连续上下文")); + assert!(second_design_request.contains("当前状态:status=running")); + assert!(second_design_request.contains("runId=design-continuity-second")); + assert!(second_design_request.contains("最近回复:首轮完成:已经读取连续上下文笔记。")); + assert!(second_design_request.contains("最近工具动作")); + assert!(second_design_request.contains("file.read [ok]")); + assert!(second_design_request.contains("为连续上下文留下工具证据")); + assert!(second_design_request.contains("连续上下文笔记:第一轮已经确认月光厨房核心循环")); + assert!(second_design_request.contains("最近任务")); + assert!(second_design_request.contains("design-continuity-first [completed / completed]")); + assert!(second_design_request.contains("首轮检查连续上下文")); + assert!(!second_design_request.contains(root.to_string_lossy().as_ref())); + let second_runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(second_runtime.run_id, "design-continuity-second"); + assert_eq!( + second_runtime.last_response.as_deref(), + Some("第二轮设计任务完成。") + ); + assert!(second_runtime + .recent_tool_calls + .iter() + .any(|call| call.tool == "file.read" && call.status == "ok")); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_write_blackboard_and_message_other_agent() { let root = unique_project_path(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8fe659c23..551e1d259 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -23,6 +23,7 @@ - 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `preview.start`,让 Agent 在完成写盘或静态自检后能按策略自行启动当前项目的 `127.0.0.1` 本地 HTTP 预览。该工具复用 `preview.start` 权限策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑;写入 `.agent/agent.db` 的审计类型为 `agent.runtime.preview.start`。发给 LLM 的 observation 只包含 localhost URL 和端口,不包含用户项目绝对路径。 - 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `canvas.asset_generate`,让美术类 Agent 可在 loop 中自行请求生成首版美术素材。该工具读取 AppData / Tauri 配置中的 `editorApi`,复用 `canvas.asset_generate` 权限策略、项目写锁、External Editor API 生成和下载链路、manifest 资产登记以及 `canvas.asset_generate` 本地索引记录;另写 `agent.runtime.canvas.asset_generate` 记录到 `.agent/agent.db`,标明触发的 agent 与本地素材路径。API Key 不进入 prompt observation、manifest、agent.db 或日志;策略要求确认或拒绝时不会调用外部 API。 - 补充:规范 Agent ID 统一使用 manifest taskId,例如 `art-asset-plan` 和 `code-prototype`;历史前端曾使用的 `group-role` 别名只在 Tauri command 层兼容并映射到规范 taskId。主窗口 Agent 状态列表通过 `read_game_creator_agent_runtimes` 批量读取 `.agent/runtime/agents/.json` 和最近任务,把每个 Agent 的 Runtime 状态、当前动作和最近 task 直接显示在状态卡片和 `/agents` 汇总里。 +- 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 统一注入本 Agent 的 Runtime 连续上下文,包括最近状态、runId、当前任务、计划、观察、最近回复、最近工具动作、最近事件、最近任务和工具策略摘要;上下文只按规范 taskId 读取本 Agent runtime,进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时继承同 Agent 上次 `recentToolCalls` 和 `lastResponse`,让下一轮任务能基于前一轮真实行动证据继续推理,同时不串入其他 Agent 的 runtime。 - 影响范围:`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 66c920830..8c2d21c5d 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -35,6 +35,7 @@ Agent Runtime 负责: - 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/.json`、`.agent/runtime/events/.jsonl`、`.agent/runtime/tasks/.jsonl` 和 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:每轮让该 Agent 输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具动作,写入 `action / observation` 事件,再把 observation 放入下一轮 prompt 让 Agent 修正计划、继续行动或用空 actions + response 收束;后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复并追加回对话。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`file.read`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write` 和 `agent.message`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径并记录审计,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加 `memory/blackboard.md`,`agent.message` 给目标 `.agent/conversations/agents/.jsonl` 写入 tool 留言,策略要求确认或拒绝时不执行写入或运行,只把策略结果作为 observation 回给 Agent。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务,`read_game_creator_agent_runtimes` 批量读取所有规范 taskId 的 runtime;开发窗口、项目内 Agent 对话弹窗和主窗口 Agent 状态列表展示最近任务、当前任务、当前动作、下一步和运行阶段。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 已有运行任务时,新任务会先写成 `pending / queued`,由当前后台 drain 在完成后串行继续执行。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程或跨重启离线常驻 worker。 - 2026-07-10 补充:Agent Runtime state 新增 `toolPolicy`,按当前项目 `.agent/policy.json` 派生工具级 `allowedTools / autoTools / confirmTools / deniedTools` 快照;后台 planning prompt 会带入该快照,让 Agent 在规划时知道哪些工具会自动执行、需要确认或被拒绝。`blackboard.write` 继承 `memory.write` 策略,`agent.message` 继承 `conversation.write` 策略;实际执行仍以 Runtime 的白名单和项目权限 gate 为准。 - 2026-07-10 补充:Agent Runtime state 新增 `recentToolCalls`,每次后台工具执行后记录最近 20 条结构化工具动作,包含 tool、status、reason、summary、detail 和 updatedAt;开发窗口、项目内 Agent 对话弹窗和主窗口 Agent 状态列表可直接展示“最近动作”,不再只能从 observation 字符串里猜测 action / observation 对应关系。字段只保存过滤后的摘要和观察细节,不保存原始 API Key 或任意未过滤输入。 +- 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls` 和 `lastResponse`,让多轮任务不丢失结构化行动证据。 - 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。 - 2026-07-10 补充:后台任务工具箱已加入 `canvas.asset_generate`。Agent 可在 loop 中自行给出素材 prompt,通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API 生成首版美术素材、下载到 `assets/canvas-generated/` 并登记 manifest;Runtime 复用 `canvas.asset_generate` 策略和项目写锁,并写入 `agent.runtime.canvas.asset_generate` 审计记录。API Key 不进入 observation、manifest、agent.db 或日志;策略要求确认或拒绝时不会调用外部 API。 - 2026-07-10 补充:后台任务工具箱已加入 `task.list`。Agent 可在 loop 中读取 manifest 任务图、每个 seed task 的状态 / 依赖 / 产物交接,以及按依赖计算的 `readyTaskIds`;Runtime 复用 `task.list` 项目权限策略,策略要求确认或拒绝时只返回策略 observation,不向 LLM 暴露任务图细节。 @@ -78,6 +79,8 @@ game-project/ .json events/ .jsonl + tasks/ + .jsonl locks/ .lock activity.jsonl