补齐Agent计划工具观察循环
后台Agent任务增加计划、工具动作、观察和最终回复循环。 只读工具调用接入项目权限策略和runtime事件。 前端测试覆盖工具动作状态展示并修正Agent卡片点击定位。 更新AI游戏创作App实施计划和共享决策记录。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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::<Vec<_>>();
|
||||
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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 调用该 Agent 的独立 LLM 路由;完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。不同 Agent 使用独立 `.agent/runtime/locks/<agentId>.lock`,允许并行运行;同一 Agent 同时只允许一个后台任务。该能力仍不是独立 OS 进程、持久队列或离线常驻 worker。
|
||||
- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event、追加用户任务到 `.agent/conversations/agents/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 先输出 `thinkingSummary / plan / actions`,Runtime 按白名单和项目权限策略执行只读工具并记录 `action / observation` 事件,再把观察结果交给 Agent 生成最终回复;完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。不同 Agent 使用独立 `.agent/runtime/locks/<agentId>.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`。
|
||||
|
||||
@@ -32,7 +32,7 @@ Agent Runtime 负责:
|
||||
- 开发窗口能力:debug 构建额外打开 `developer` 窗口,走 `index.html?agent-chat`;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/<agentId>.jsonl`,通过 `agentLlm.<agentId>` 调用该 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/<agentId>.json`、`.agent/runtime/events/<agentId>.jsonl` 和 `.agent/conversations/agents/<agentId>.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/<agentId>.json`、`.agent/runtime/events/<agentId>.jsonl` 和 `.agent/conversations/agents/<agentId>.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/<group>/<role>.md`;黑板用于共享重要跨 agent 记忆,角色私有记忆只给对应角色 brief 读取和追加。最近 project / agent conversation 会作为短期 prompt 上下文读取,不替代正式 memory 文件。
|
||||
- 对话能力:结构化对话记录统一落在 `.agent/conversations/` 的 append-only JSONL;普通聊天写 `.agent/conversations/project.jsonl`,进入单个 agent 后只写对应 `.agent/conversations/agents/<agentId>.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/<agentId>.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/<agentId>.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` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。
|
||||
|
||||
Reference in New Issue
Block a user