补齐Agent Runtime写入工具
后台 Agent 工具箱新增 memory.write 和 file.write。 写入工具复用项目权限策略、本地项目锁和路径保护,并写入 agent.db 审计。 补充写入成功与策略拦截测试,同步 Runtime V1 文档。
This commit is contained in:
@@ -693,6 +693,7 @@ async fn run_game_creator_agent_background_task(
|
||||
const AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT: usize = 3;
|
||||
const AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT: usize = 3;
|
||||
const AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS: usize = 900;
|
||||
const AGENT_RUNTIME_TOOL_WRITE_MAX_CHARS: usize = 12_000;
|
||||
pub(crate) const AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS: u64 = 300;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
@@ -795,7 +796,7 @@ fn build_game_creator_agent_background_tool_plan_request(
|
||||
.map_err(|error| format!("序列化 Agent 工具观察失败:{error}"))?
|
||||
};
|
||||
let prompt = format!(
|
||||
"项目上下文如下。你正在执行后台 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|conversation.read|asset.list|project.index|file.read|blackboard.write|agent.message\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};file.read 使用 {{\"path\":\"项目内相对路径\"}};blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。"
|
||||
"项目上下文如下。你正在执行后台 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.read|file.write|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.read 使用 {{\"path\":\"项目内相对路径\"}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"文件内容\"}};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()),
|
||||
@@ -887,10 +888,12 @@ fn execute_game_creator_agent_runtime_tool_action(
|
||||
|
||||
match tool {
|
||||
"memory.read" => observe_agent_runtime_memory(root, agent_id, &action.input),
|
||||
"memory.write" => observe_agent_runtime_memory_write(root, agent_id, &action.input),
|
||||
"conversation.read" => observe_agent_runtime_conversation(root, agent_id),
|
||||
"asset.list" => observe_agent_runtime_assets(root),
|
||||
"project.index" => observe_agent_runtime_project_index(root),
|
||||
"file.read" => observe_agent_runtime_file(root, &action.input),
|
||||
"file.write" => observe_agent_runtime_file_write(root, agent_id, &action.input),
|
||||
"blackboard.write" => observe_agent_runtime_blackboard_write(root, agent_id, &action.input),
|
||||
"agent.message" => observe_agent_runtime_agent_message(root, agent_id, &action.input),
|
||||
_ => AgentRuntimeToolObservation {
|
||||
@@ -905,10 +908,12 @@ fn execute_game_creator_agent_runtime_tool_action(
|
||||
fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str> {
|
||||
match tool {
|
||||
"memory.read" => Some("memory.read"),
|
||||
"memory.write" => Some("memory.write"),
|
||||
"conversation.read" => Some("conversation.read"),
|
||||
"asset.list" => Some("asset.list"),
|
||||
"project.index" => Some("project.index"),
|
||||
"file.read" => Some("file.read"),
|
||||
"file.write" => Some("file.write"),
|
||||
"blackboard.write" => Some("memory.write"),
|
||||
"agent.message" => Some("conversation.write"),
|
||||
_ => None,
|
||||
@@ -959,6 +964,127 @@ fn observe_agent_runtime_memory(
|
||||
observation_from_text_result("memory.read", result, "已读取记忆")
|
||||
}
|
||||
|
||||
fn observe_agent_runtime_memory_write(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
input: &serde_json::Value,
|
||||
) -> AgentRuntimeToolObservation {
|
||||
let scope = input
|
||||
.get("scope")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("agent")
|
||||
.trim();
|
||||
let content = agent_runtime_tool_input_text(input, &["content", "summary", "message"]);
|
||||
if content.trim().is_empty() {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "memory.write".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "缺少 content".to_string(),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
let _lock = match acquire_project_write_lock(root, "memory.write") {
|
||||
Ok(lock) => lock,
|
||||
Err(error) => {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "memory.write".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: sanitize_agent_runtime_text(&error, 240),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
let title = agent_runtime_tool_input_text(input, &["title", "topic"]);
|
||||
let entry = agent_runtime_memory_write_entry(agent_id, &title, &content);
|
||||
let overwrite = agent_runtime_tool_input_text(input, &["mode", "writeMode"])
|
||||
.eq_ignore_ascii_case("overwrite");
|
||||
let result = if scope == "agent" {
|
||||
let target_agent_id = agent_runtime_tool_input_text(input, &["agentId", "targetAgentId"]);
|
||||
let target_agent_id = if target_agent_id.trim().is_empty() {
|
||||
agent_id.to_string()
|
||||
} else {
|
||||
match normalize_game_creator_runtime_agent_id(target_agent_id.as_str()) {
|
||||
Ok(target_agent_id) => target_agent_id,
|
||||
Err(error) => {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "memory.write".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: sanitize_agent_runtime_text(&error, 240),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
read_local_agent_memory_at(root, &target_agent_id)
|
||||
.and_then(|existing| {
|
||||
let next_content =
|
||||
agent_runtime_next_memory_content(&existing.content, &entry, overwrite);
|
||||
write_local_agent_memory_at(root, &target_agent_id, &next_content)
|
||||
})
|
||||
.and_then(|memory| {
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.memory.write",
|
||||
"agentId": agent_id,
|
||||
"targetAgentId": target_agent_id,
|
||||
"scope": "agent",
|
||||
"path": memory.path,
|
||||
"mode": if overwrite { "overwrite" } else { "append" },
|
||||
}),
|
||||
)
|
||||
.map(|()| format!("已写入 Agent 记忆 {}", memory.task_id))
|
||||
})
|
||||
} else {
|
||||
let game_scope = match scope {
|
||||
"session" | "short" => "short",
|
||||
"project" | "long" => "long",
|
||||
"blackboard" => "blackboard",
|
||||
_ => {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "memory.write".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: format!("不支持的记忆 scope:{scope}"),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
read_local_game_memory_at(root, game_scope)
|
||||
.and_then(|existing| {
|
||||
let next_content =
|
||||
agent_runtime_next_memory_content(&existing.content, &entry, overwrite);
|
||||
write_local_game_memory_at(root, game_scope, &next_content)
|
||||
})
|
||||
.and_then(|memory| {
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.memory.write",
|
||||
"agentId": agent_id,
|
||||
"scope": memory.scope,
|
||||
"path": memory.path,
|
||||
"mode": if overwrite { "overwrite" } else { "append" },
|
||||
}),
|
||||
)
|
||||
.map(|()| format!("已写入 {} 记忆", memory.scope))
|
||||
})
|
||||
};
|
||||
match result {
|
||||
Ok(summary) => AgentRuntimeToolObservation {
|
||||
tool: "memory.write".to_string(),
|
||||
status: "ok".to_string(),
|
||||
summary,
|
||||
detail: Some(entry),
|
||||
},
|
||||
Err(error) => AgentRuntimeToolObservation {
|
||||
tool: "memory.write".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: sanitize_agent_runtime_text(&error, 240),
|
||||
detail: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_agent_runtime_conversation(root: &Path, agent_id: &str) -> AgentRuntimeToolObservation {
|
||||
observation_from_text_result(
|
||||
"conversation.read",
|
||||
@@ -1016,6 +1142,75 @@ fn observe_agent_runtime_file(
|
||||
observation_from_text_result("file.read", result, &format!("已读取 {path}"))
|
||||
}
|
||||
|
||||
fn observe_agent_runtime_file_write(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
input: &serde_json::Value,
|
||||
) -> AgentRuntimeToolObservation {
|
||||
let path = input
|
||||
.get("path")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or_default()
|
||||
.trim();
|
||||
if path.is_empty() {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "file.write".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "缺少 path".to_string(),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
let Some(content) = input.get("content").and_then(|value| value.as_str()) else {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "file.write".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "缺少 content".to_string(),
|
||||
detail: None,
|
||||
};
|
||||
};
|
||||
let _lock = match acquire_project_write_lock(root, "file.write") {
|
||||
Ok(lock) => lock,
|
||||
Err(error) => {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "file.write".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: sanitize_agent_runtime_text(&error, 240),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
let content = truncate_agent_runtime_text(
|
||||
sanitize_prompt_context(content).as_str(),
|
||||
AGENT_RUNTIME_TOOL_WRITE_MAX_CHARS,
|
||||
);
|
||||
let result = write_local_project_file_at(root, path, &content).and_then(|written| {
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.file.write",
|
||||
"agentId": agent_id,
|
||||
"path": written.path,
|
||||
"absolutePath": written.absolute_path,
|
||||
}),
|
||||
)
|
||||
.map(|()| written)
|
||||
});
|
||||
match result {
|
||||
Ok(written) => AgentRuntimeToolObservation {
|
||||
tool: "file.write".to_string(),
|
||||
status: "ok".to_string(),
|
||||
summary: format!("已写入 {}", written.path),
|
||||
detail: Some(content),
|
||||
},
|
||||
Err(error) => AgentRuntimeToolObservation {
|
||||
tool: "file.write".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: sanitize_agent_runtime_text(&error, 240),
|
||||
detail: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_agent_runtime_blackboard_write(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
@@ -1167,6 +1362,26 @@ fn agent_runtime_tool_input_text(input: &serde_json::Value, keys: &[&str]) -> St
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn agent_runtime_memory_write_entry(agent_id: &str, title: &str, content: &str) -> String {
|
||||
let title = if title.trim().is_empty() {
|
||||
"运行结论".to_string()
|
||||
} else {
|
||||
sanitize_agent_runtime_text(title, 80)
|
||||
};
|
||||
let content = truncate_agent_runtime_text(
|
||||
sanitize_prompt_context(content).as_str(),
|
||||
AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS,
|
||||
);
|
||||
format!("## Agent {agent_id} - {title}\n\n{content}\n")
|
||||
}
|
||||
|
||||
fn agent_runtime_next_memory_content(existing: &str, entry: &str, overwrite: bool) -> String {
|
||||
if overwrite || existing.trim().is_empty() {
|
||||
return format!("{}\n", entry.trim());
|
||||
}
|
||||
format!("{}\n\n{}\n", existing.trim_end(), entry.trim())
|
||||
}
|
||||
|
||||
fn observation_from_text_result(
|
||||
tool: &str,
|
||||
result: Result<String, String>,
|
||||
@@ -1436,10 +1651,12 @@ fn default_game_creator_agent_runtime_state(agent_id: &str, run_id: &str) -> Age
|
||||
"conversation.read".to_string(),
|
||||
"conversation.write".to_string(),
|
||||
"memory.read".to_string(),
|
||||
"memory.write".to_string(),
|
||||
"blackboard.write".to_string(),
|
||||
"agent.message".to_string(),
|
||||
"asset.list".to_string(),
|
||||
"file.read".to_string(),
|
||||
"file.write".to_string(),
|
||||
"agent.run_status".to_string(),
|
||||
],
|
||||
last_response: None,
|
||||
@@ -1488,10 +1705,12 @@ fn normalize_game_creator_agent_runtime_state(state: &mut AgentRuntimeState, age
|
||||
"conversation.read".to_string(),
|
||||
"conversation.write".to_string(),
|
||||
"memory.read".to_string(),
|
||||
"memory.write".to_string(),
|
||||
"blackboard.write".to_string(),
|
||||
"agent.message".to_string(),
|
||||
"asset.list".to_string(),
|
||||
"file.read".to_string(),
|
||||
"file.write".to_string(),
|
||||
"agent.run_status".to_string(),
|
||||
];
|
||||
}
|
||||
@@ -2089,7 +2308,7 @@ pub(crate) fn game_creator_role_agent_chat_system_prompt() -> &'static str {
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> &'static str {
|
||||
"你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须在白名单工具内规划行动:先给一句 thinkingSummary,再给短计划,再决定是否请求工具。只能请求 memory.read、conversation.read、asset.list、project.index、file.read、blackboard.write、agent.message。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。只输出 JSON 对象,不要 markdown,不要泄露密钥。"
|
||||
"你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须在白名单工具内规划行动:先给一句 thinkingSummary,再给短计划,再决定是否请求工具。只能请求 memory.read、memory.write、conversation.read、asset.list、project.index、file.read、file.write、blackboard.write、agent.message。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。只输出 JSON 对象,不要 markdown,不要泄露密钥。"
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_role_definition(
|
||||
|
||||
@@ -1835,6 +1835,134 @@ async fn background_agent_runtime_can_write_blackboard_and_message_other_agent()
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_can_write_memory_and_project_files() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||||
write_project_permission_policy_at(
|
||||
&root,
|
||||
ProjectPermissionPolicy {
|
||||
denied_commands: Vec::new(),
|
||||
confirm_commands: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("allow runtime writes");
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let plan_json = serde_json::json!({
|
||||
"thinkingSummary": "需要把本轮设计结论落到记忆和项目文件",
|
||||
"plan": ["写 Agent 私有记忆", "写项目长期记忆", "写项目说明文件"],
|
||||
"actions": [
|
||||
{
|
||||
"tool": "memory.write",
|
||||
"reason": "这是 design-director 后续要复用的私有结论",
|
||||
"input": {
|
||||
"scope": "agent",
|
||||
"title": "主角方向",
|
||||
"content": "主角是戴月光围裙的小厨师。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool": "memory.write",
|
||||
"reason": "这是所有 Agent 都需要知道的项目长期结论",
|
||||
"input": {
|
||||
"scope": "project",
|
||||
"title": "核心主题",
|
||||
"content": "项目主题是月光厨房对抗暗影厨具。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool": "file.write",
|
||||
"reason": "把可交接的玩法说明写入项目文件",
|
||||
"input": {
|
||||
"path": "game/agent-notes.md",
|
||||
"content": "# Agent Notes\n\n月光厨房:收集食材,躲避暗影厨具。\n"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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-write-run",
|
||||
)
|
||||
.expect("start background task");
|
||||
|
||||
let plan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("plan llm request");
|
||||
assert!(plan_request.contains("memory.write"));
|
||||
assert!(plan_request.contains("file.write"));
|
||||
let second_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("second llm request");
|
||||
assert!(second_request.contains("memory.write"));
|
||||
assert!(second_request.contains("file.write"));
|
||||
assert!(second_request.contains("已写入 Agent 记忆 design-director"));
|
||||
assert!(second_request.contains("已写入 long 记忆"));
|
||||
assert!(second_request.contains("已写入 game/agent-notes.md"));
|
||||
|
||||
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("memory.write:ok · 已写入 Agent 记忆 design-director")));
|
||||
assert!(runtime
|
||||
.observations
|
||||
.iter()
|
||||
.any(|item| item.contains("memory.write:ok · 已写入 long 记忆")));
|
||||
assert!(runtime
|
||||
.observations
|
||||
.iter()
|
||||
.any(|item| item.contains("file.write:ok · 已写入 game/agent-notes.md")));
|
||||
let agent_memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory");
|
||||
assert!(agent_memory.content.contains("主角是戴月光围裙的小厨师。"));
|
||||
let project_memory = read_local_game_memory_at(&root, "long").expect("project memory");
|
||||
assert!(project_memory
|
||||
.content
|
||||
.contains("项目主题是月光厨房对抗暗影厨具。"));
|
||||
let notes = fs::read_to_string(root.join("game/agent-notes.md")).expect("notes");
|
||||
assert!(notes.contains("月光厨房:收集食材,躲避暗影厨具。"));
|
||||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||||
assert!(agent_db.contains("\"recordType\":\"agent.runtime.memory.write\""));
|
||||
assert!(agent_db.contains("\"recordType\":\"agent.runtime.file.write\""));
|
||||
assert!(agent_db.contains("\"path\":\"game/agent-notes.md\""));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_write_tools_respect_project_policy() {
|
||||
let root = unique_project_path();
|
||||
@@ -1935,6 +2063,107 @@ async fn background_agent_runtime_write_tools_respect_project_policy() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_file_and_memory_writes_respect_project_policy() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||||
write_project_permission_policy_at(
|
||||
&root,
|
||||
ProjectPermissionPolicy {
|
||||
denied_commands: Vec::new(),
|
||||
confirm_commands: vec!["memory.write".to_string(), "file.write".to_string()],
|
||||
},
|
||||
)
|
||||
.expect("write policy");
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let plan_json = serde_json::json!({
|
||||
"thinkingSummary": "尝试写记忆和文件",
|
||||
"plan": ["写私有记忆", "写项目文件"],
|
||||
"actions": [
|
||||
{
|
||||
"tool": "memory.write",
|
||||
"reason": "测试策略拦截记忆写入",
|
||||
"input": {
|
||||
"scope": "agent",
|
||||
"title": "不应写入",
|
||||
"content": "这段 Agent 记忆不应该绕过确认策略。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool": "file.write",
|
||||
"reason": "测试策略拦截文件写入",
|
||||
"input": {
|
||||
"path": "game/blocked.md",
|
||||
"content": "这段项目文件不应该绕过确认策略。"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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-write-policy-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("项目权限策略要求用户确认:memory.write"));
|
||||
assert!(final_request.contains("项目权限策略要求用户确认:file.write"));
|
||||
|
||||
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("memory.write:blocked · 项目权限策略要求用户确认:memory.write")
|
||||
}));
|
||||
assert!(runtime
|
||||
.observations
|
||||
.iter()
|
||||
.any(|item| item.contains("file.write:blocked · 项目权限策略要求用户确认:file.write")));
|
||||
let agent_memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory");
|
||||
assert!(!agent_memory
|
||||
.content
|
||||
.contains("这段 Agent 记忆不应该绕过确认策略"));
|
||||
assert!(!root.join("game/blocked.md").exists());
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_tool_action_respects_confirm_policy() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -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/task history,追加用户任务到 `.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,仍未收束时再按最后计划和全部观察生成最终回复。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`file.read`,以及受 `memory.write` / `conversation.write` 策略保护的协作写动作 `blackboard.write` 和 `agent.message`;策略要求确认或拒绝时不执行写入,只把策略结果作为 observation 回给 Agent。每个 Agent 的任务历史落在 `.agent/runtime/tasks/<agentId>.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,Runtime state 增加 `nextStep`,UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/<agentId>.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。
|
||||
- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.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,仍未收束时再按最后计划和全部观察生成最终回复。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`file.read`,以及受策略保护的写动作 `memory.write`、`file.write`、`blackboard.write` 和 `agent.message`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径,`blackboard.write` 追加共享黑板,`agent.message` 写目标 Agent 对话;策略要求确认或拒绝时不执行写入,只把策略结果作为 observation 回给 Agent。每个 Agent 的任务历史落在 `.agent/runtime/tasks/<agentId>.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,Runtime state 增加 `nextStep`,UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/<agentId>.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。
|
||||
- 补充:规范 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` 汇总里。
|
||||
- 影响范围:`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`。
|
||||
|
||||
@@ -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 的长期对话上下文。这里的 `<agentId>` 以 manifest taskId 为规范值,旧 `group-role` 别名只作为兼容输入映射到 taskId。
|
||||
- 命令能力:内置命令调用、权限 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/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` / `conversation.write` 策略保护的协作写动作 `blackboard.write` 和 `agent.message`;`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。
|
||||
- 后台任务能力:开发窗口单 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`、`blackboard.write` 和 `agent.message`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径并记录审计,`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。
|
||||
- 任务图能力:每轮 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 状态查看进度和结果。后台任务会向 `.agent/runtime/tasks/<agentId>.jsonl` 追加任务视角记录,任务状态使用 `pending / running / completed / failed`,读取时按 `runId` 去重返回最近任务;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/<agentId>.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行。后台任务的核心 loop 最多 3 轮:每轮把已有 observation 带回 LLM 让 Agent 重新规划,只有 actions 为空且 response 非空时提前收束,否则继续执行白名单工具,跑满后再进入最终回复整理。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/<agentId>.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.tool_observation` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`file.read`,以及受策略保护的协作写工具 `blackboard.write` 和 `agent.message`;若项目策略要求确认或拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent。`.agent/agent.db` 追加写入按整行 JSONL 写入,减少多个 Agent 同时完成时的行交错风险。
|
||||
- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动或排队单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果。后台任务会向 `.agent/runtime/tasks/<agentId>.jsonl` 追加任务视角记录,任务状态使用 `pending / running / completed / failed`,读取时按 `runId` 去重返回最近任务;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/<agentId>.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行。后台任务的核心 loop 最多 3 轮:每轮把已有 observation 带回 LLM 让 Agent 重新规划,只有 actions 为空且 response 非空时提前收束,否则继续执行白名单工具,跑满后再进入最终回复整理。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/<agentId>.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.tool_observation` / `agent.runtime.memory.write` / `agent.runtime.file.write` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`file.read`,以及受策略保护的写工具 `memory.write`、`file.write`、`blackboard.write` 和 `agent.message`;若项目策略要求确认或拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent。`.agent/agent.db` 追加写入按整行 JSONL 写入,减少多个 Agent 同时完成时的行交错风险。
|
||||
- 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。
|
||||
- 单窗口首页和项目组页可选择、打开、新建或显示当前输入的项目绝对路径;最近项目行也可显示目录,非法或相对路径不会调用系统文件管理器。
|
||||
- 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。
|
||||
|
||||
Reference in New Issue
Block a user