补齐Agent Runtime项目差异工具

后台 Agent 工具箱新增 project.diff,可按 checkpoint 查看本地项目差异。

project.diff 复用项目权限策略,策略确认时不泄露差异细节。

补充 Runtime 差异工具测试,同步 Runtime V1 文档。
This commit is contained in:
AIGameCreator App
2026-07-10 02:25:32 +08:00
parent e91b7f3294
commit d0dfced8ed
4 changed files with 286 additions and 4 deletions
@@ -833,7 +833,7 @@ fn build_game_creator_agent_background_tool_plan_request(
let tool_policy_json = serde_json::to_string_pretty(&tool_policy)
.map_err(|error| format!("序列化 Agent 工具策略失败:{error}"))?;
let prompt = format!(
"当前工具策略:\n{tool_policy_json}\n\n项目上下文如下。你正在执行后台 Agent loop 第 {loop_index} 轮。请基于目标、已有工具观察和当前上下文修正计划,再决定是否调用最多 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个白名单工具。只输出 JSON 对象,不要 markdown。\n\n{context}\n\n后台任务:\n{task}\n\n已有工具观察:\n{observations_json}\n\nJSON schema:{{\"thinkingSummary\":\"一句话理解\",\"plan\":[\"步骤\"],\"actions\":[{{\"tool\":\"memory.read|memory.write|conversation.read|asset.list|project.index|file.list|file.read|file.write|task.list|task.update|command.run_limited|preview.start|canvas.asset_generate|blackboard.write|agent.message\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}};file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\"}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"文件内容\"}};task.list input 可为空,用于读取 manifest 任务图、状态和 readyTaskIds;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;canvas.asset_generate 使用 {{\"prompt\":\"要生成的美术素材描述\"}},通过配置的 External Editor API 生成首版素材并登记到 assets;blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。"
"当前工具策略:\n{tool_policy_json}\n\n项目上下文如下。你正在执行后台 Agent loop 第 {loop_index} 轮。请基于目标、已有工具观察和当前上下文修正计划,再决定是否调用最多 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个白名单工具。只输出 JSON 对象,不要 markdown。\n\n{context}\n\n后台任务:\n{task}\n\n已有工具观察:\n{observations_json}\n\nJSON schema:{{\"thinkingSummary\":\"一句话理解\",\"plan\":[\"步骤\"],\"actions\":[{{\"tool\":\"memory.read|memory.write|conversation.read|asset.list|project.index|project.diff|file.list|file.read|file.write|task.list|task.update|command.run_limited|preview.start|canvas.asset_generate|blackboard.write|agent.message\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}};project.diff 使用 {{\"checkpointId\":\"checkpoint id\"}},用于读取当前项目相对路径 diff 摘要;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\"}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"文件内容\"}};task.list input 可为空,用于读取 manifest 任务图、状态和 readyTaskIds;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;canvas.asset_generate 使用 {{\"prompt\":\"要生成的美术素材描述\"}},通过配置的 External Editor API 生成首版素材并登记到 assets;blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。"
);
let request = LlmRunRequest::new(vec![
LlmMessage::system(game_creator_agent_runtime_tool_plan_system_prompt()),
@@ -930,6 +930,7 @@ async fn execute_game_creator_agent_runtime_tool_action(
"conversation.read" => observe_agent_runtime_conversation(root, agent_id),
"asset.list" => observe_agent_runtime_assets(root),
"project.index" => observe_agent_runtime_project_index(root),
"project.diff" => observe_agent_runtime_project_diff(root, &action.input),
"file.list" => observe_agent_runtime_file_list(root, &action.input),
"file.read" => observe_agent_runtime_file(root, &action.input),
"file.write" => observe_agent_runtime_file_write(root, agent_id, &action.input),
@@ -961,6 +962,7 @@ fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str
"conversation.read" => Some("conversation.read"),
"asset.list" => Some("asset.list"),
"project.index" => Some("project.index"),
"project.diff" => Some("project.diff"),
"file.list" => Some("file.list"),
"file.read" => Some("file.read"),
"file.write" => Some("file.write"),
@@ -982,6 +984,7 @@ fn agent_runtime_executable_tools() -> Vec<&'static str> {
"conversation.read",
"asset.list",
"project.index",
"project.diff",
"file.list",
"file.read",
"file.write",
@@ -1245,6 +1248,42 @@ fn observe_agent_runtime_project_index(root: &Path) -> AgentRuntimeToolObservati
observation_from_text_result("project.index", result, "已读取项目文件索引")
}
fn observe_agent_runtime_project_diff(
root: &Path,
input: &serde_json::Value,
) -> AgentRuntimeToolObservation {
let checkpoint_id =
agent_runtime_tool_input_text(input, &["checkpointId", "checkpoint_id", "id"]);
if checkpoint_id.trim().is_empty() {
return AgentRuntimeToolObservation {
tool: "project.diff".to_string(),
status: "failed".to_string(),
summary: "缺少 checkpointId".to_string(),
detail: None,
};
}
match diff_local_project_checkpoint_at(root, &checkpoint_id) {
Ok(diff) => {
let detail = format_agent_runtime_project_diff(&diff);
AgentRuntimeToolObservation {
tool: "project.diff".to_string(),
status: "ok".to_string(),
summary: format!("已对比 checkpoint {}", diff.checkpoint_id),
detail: Some(truncate_agent_runtime_text(
sanitize_prompt_context(&detail).as_str(),
AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS,
)),
}
}
Err(error) => AgentRuntimeToolObservation {
tool: "project.diff".to_string(),
status: "failed".to_string(),
summary: sanitize_agent_runtime_text(&error, 240),
detail: None,
},
}
}
fn observe_agent_runtime_file_list(
root: &Path,
input: &serde_json::Value,
@@ -1308,6 +1347,61 @@ fn observe_agent_runtime_file_list(
observation_from_text_result("file.list", result, &summary)
}
fn format_agent_runtime_project_diff(diff: &LocalProjectDiffResult) -> String {
let added = agent_runtime_visible_project_diff_entries(&diff.added);
let changed = agent_runtime_visible_project_diff_entries(&diff.changed);
let deleted = agent_runtime_visible_project_diff_entries(&diff.deleted);
let mut lines = vec![
format!("checkpointId: {}", diff.checkpoint_id),
format!("added: {}", added.len()),
format!("changed: {}", changed.len()),
format!("deleted: {}", deleted.len()),
];
append_agent_runtime_diff_entries(&mut lines, "added files", &added);
append_agent_runtime_diff_entries(&mut lines, "changed files", &changed);
append_agent_runtime_diff_entries(&mut lines, "deleted files", &deleted);
lines.join("\n")
}
fn agent_runtime_visible_project_diff_entries(
entries: &[LocalProjectDiffEntry],
) -> Vec<&LocalProjectDiffEntry> {
entries
.iter()
.filter(|entry| agent_runtime_should_show_project_diff_path(&entry.path))
.collect()
}
fn agent_runtime_should_show_project_diff_path(path: &str) -> bool {
!matches!(
path,
".agent/agent.db"
| ".agent/policy.json"
| ".agent/project.index.json"
| ".agent/project.lock"
) && !path.starts_with(".agent/conversations/")
&& !path.starts_with(".agent/checkpoints/")
&& !path.starts_with(".agent/logs/")
&& !path.starts_with(".agent/runtime/")
}
fn append_agent_runtime_diff_entries(
lines: &mut Vec<String>,
title: &str,
entries: &[&LocalProjectDiffEntry],
) {
if entries.is_empty() {
return;
}
lines.push(format!("{title}:"));
for entry in entries.iter().take(20) {
lines.push(format!("- {}", entry.path));
}
if entries.len() > 20 {
lines.push(format!("- ... 还有 {} 个条目", entries.len() - 20));
}
}
fn observe_agent_runtime_task_list(root: &Path) -> AgentRuntimeToolObservation {
let result = read_manifest_for_project(root).map(|manifest| {
let ready_task_ids = ready_task_ids_for_tasks(&manifest.tasks);
@@ -2181,6 +2275,7 @@ fn default_game_creator_agent_runtime_allowed_tools() -> Vec<String> {
"blackboard.write",
"agent.message",
"asset.list",
"project.diff",
"file.list",
"file.read",
"file.write",
@@ -3662,6 +3662,190 @@ async fn background_agent_runtime_file_list_respects_project_policy() {
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_can_diff_checkpoint() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
write_local_project_file_at(&root, "game/notes.txt", "v1").expect("write notes");
write_local_project_file_at(&root, "exports/README.md", "publish")
.expect("write export readme");
let checkpoint = create_local_project_checkpoint_at(&root).expect("create checkpoint");
write_local_project_file_at(&root, "game/notes.txt", "v2").expect("change notes");
write_local_project_file_at(&root, "game/new.txt", "new").expect("add file");
delete_local_project_file_at(&root, "exports/README.md").expect("delete export readme");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
},
)
.expect("allow project diff");
let (sender, receiver) = mpsc::channel();
let plan_json = serde_json::json!({
"thinkingSummary": "需要查看 checkpoint 后的本地差异",
"plan": ["对比 checkpoint", "根据新增修改删除回复开发者"],
"actions": [
{
"tool": "project.diff",
"reason": "确认本轮项目文件变化",
"input": { "checkpointId": checkpoint.checkpoint_id }
}
],
"response": ""
})
.to_string();
let base_url = spawn_mock_llm_server_responses_with_capture(
vec![plan_json, "checkpoint 差异已经确认。".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-project-diff-run",
)
.expect("start background task");
let plan_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("plan llm request");
assert!(plan_request.contains("project.diff"));
assert!(plan_request.contains("checkpointId"));
let final_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("final reply llm request");
assert!(final_request.contains("project.diff"));
assert!(final_request.contains("已对比 checkpoint"));
assert!(final_request.contains("added: 1"));
assert!(final_request.contains("changed: 1"));
assert!(final_request.contains("deleted: 1"));
assert!(final_request.contains("game/new.txt"));
assert!(final_request.contains("game/notes.txt"));
assert!(final_request.contains("exports/README.md"));
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
assert_eq!(runtime.status, "idle");
assert!(runtime
.tool_policy
.auto_tools
.contains(&"project.diff".to_string()));
assert!(runtime
.observations
.iter()
.any(|item| item.contains("project.diff:ok · 已对比 checkpoint")));
assert!(runtime.recent_tool_calls.iter().any(|call| {
call.tool == "project.diff"
&& call.status == "ok"
&& call
.detail
.as_deref()
.is_some_and(|detail| detail.contains("game/new.txt"))
}));
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
assert!(agent_db.contains("\"tool\":\"project.diff\""));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_project_diff_respects_project_policy() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
write_local_project_file_at(&root, "game/blocked-notes.txt", "v1").expect("write notes");
let checkpoint = create_local_project_checkpoint_at(&root).expect("create checkpoint");
write_local_project_file_at(&root, "game/blocked-notes.txt", "v2").expect("change notes");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["project.diff".to_string()],
},
)
.expect("write policy");
let (sender, receiver) = mpsc::channel();
let plan_json = serde_json::json!({
"thinkingSummary": "尝试对比 checkpoint",
"plan": ["对比 checkpoint", "回复开发者"],
"actions": [
{
"tool": "project.diff",
"reason": "需要知道项目变化",
"input": { "checkpointId": checkpoint.checkpoint_id }
}
],
"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-project-diff-policy-run",
)
.expect("start background task");
let plan_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("plan llm request");
assert!(plan_request.contains("confirmTools"));
assert!(plan_request.contains("project.diff"));
let final_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("final reply llm request");
assert!(final_request.contains("项目权限策略要求用户确认:project.diff"));
assert!(!final_request.contains("changed: 1"));
assert!(!final_request.contains("game/blocked-notes.txt"));
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
assert_eq!(runtime.status, "idle");
assert!(runtime
.tool_policy
.confirm_tools
.contains(&"project.diff".to_string()));
assert!(runtime.observations.iter().any(
|item| item.contains("project.diff:blocked · 项目权限策略要求用户确认:project.diff")
));
assert!(!runtime
.observations
.iter()
.any(|item| item.contains("project.diff:ok")));
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();