补齐Agent Runtime项目差异工具
后台 Agent 工具箱新增 project.diff,可按 checkpoint 查看本地项目差异。 project.diff 复用项目权限策略,策略确认时不泄露差异细节。 补充 Runtime 差异工具测试,同步 Runtime V1 文档。
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user