补齐Agent Runtime连续上下文

后台 Agent 规划上下文加入本 Agent 最近状态、任务和工具动作。

新后台 run 继承同 Agent 最近回复和工具动作,避免多轮任务断档。

补充同 Agent 连续性与跨 Agent 隔离测试,同步 Runtime V1 文档。
This commit is contained in:
AIGameCreator App
2026-07-10 02:05:01 +08:00
parent eee974fb9e
commit 48cdf33c2c
4 changed files with 412 additions and 0 deletions
@@ -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::<Vec<_>>();
if values.len() > limit {
names.push(format!("{}", values.len()));
}
names.join(", ")
}
fn render_agent_runtime_prompt_context(root: &Path, agent_id: &str) -> Result<String, String> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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()),
@@ -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();
@@ -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/<taskId>.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`
@@ -35,6 +35,7 @@ Agent Runtime 负责:
- 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/<agentId>.json``.agent/runtime/events/<agentId>.jsonl``.agent/runtime/tasks/<agentId>.jsonl``.agent/conversations/agents/<agentId>.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/<agentId>.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/` 并登记 manifestRuntime 复用 `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/
<agentId>.json
events/
<agentId>.jsonl
tasks/
<agentId>.jsonl
locks/
<agentId>.lock
activity.jsonl