补齐Agent Runtime预览启动工具
后台 Agent 工具箱新增 preview.start,可由 Agent 自主启动本地预览。 预览启动复用共享 PreviewRegistry、权限策略、项目写锁、manifest、preview log 和 trace。 补充预览启动成功与策略拦截测试,并同步 Runtime V1 文档。
This commit is contained in:
@@ -796,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|memory.write|conversation.read|asset.list|project.index|file.read|file.write|command.run_limited|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\":\"文件内容\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;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|command.run_limited|preview.start|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\":\"文件内容\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;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()),
|
||||
@@ -897,6 +897,7 @@ fn execute_game_creator_agent_runtime_tool_action(
|
||||
"command.run_limited" => {
|
||||
observe_agent_runtime_limited_command(root, agent_id, &action.input)
|
||||
}
|
||||
"preview.start" => observe_agent_runtime_preview_start(root, agent_id),
|
||||
"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 {
|
||||
@@ -918,6 +919,7 @@ fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str
|
||||
"file.read" => Some("file.read"),
|
||||
"file.write" => Some("file.write"),
|
||||
"command.run_limited" => Some("command.run_limited"),
|
||||
"preview.start" => Some("preview.start"),
|
||||
"blackboard.write" => Some("memory.write"),
|
||||
"agent.message" => Some("conversation.write"),
|
||||
_ => None,
|
||||
@@ -1285,6 +1287,37 @@ fn observe_agent_runtime_limited_command(
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_agent_runtime_preview_start(root: &Path, agent_id: &str) -> AgentRuntimeToolObservation {
|
||||
let registry = game_creator_preview_registry();
|
||||
let result = start_local_game_preview_at(root, ®istry).and_then(|preview| {
|
||||
append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.preview.start",
|
||||
"agentId": agent_id,
|
||||
"status": "running",
|
||||
"url": preview.url,
|
||||
"port": preview.port,
|
||||
}),
|
||||
)
|
||||
.map(|()| preview)
|
||||
});
|
||||
match result {
|
||||
Ok(preview) => AgentRuntimeToolObservation {
|
||||
tool: "preview.start".to_string(),
|
||||
status: "ok".to_string(),
|
||||
summary: format!("preview.start 已启动:{}", preview.url),
|
||||
detail: Some(format!("url={}, port={}", preview.url, preview.port)),
|
||||
},
|
||||
Err(error) => AgentRuntimeToolObservation {
|
||||
tool: "preview.start".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: redact_agent_runtime_project_paths(root, &error, 240),
|
||||
detail: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_agent_runtime_blackboard_write(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
@@ -1732,6 +1765,7 @@ fn default_game_creator_agent_runtime_state(agent_id: &str, run_id: &str) -> Age
|
||||
"file.read".to_string(),
|
||||
"file.write".to_string(),
|
||||
"command.run_limited".to_string(),
|
||||
"preview.start".to_string(),
|
||||
"agent.run_status".to_string(),
|
||||
],
|
||||
last_response: None,
|
||||
@@ -1787,6 +1821,7 @@ fn normalize_game_creator_agent_runtime_state(state: &mut AgentRuntimeState, age
|
||||
"file.read".to_string(),
|
||||
"file.write".to_string(),
|
||||
"command.run_limited".to_string(),
|
||||
"preview.start".to_string(),
|
||||
"agent.run_status".to_string(),
|
||||
];
|
||||
}
|
||||
@@ -2399,7 +2434,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、memory.write、conversation.read、asset.list、project.index、file.read、file.write、command.run_limited、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、command.run_limited、preview.start、blackboard.write、agent.message。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。只输出 JSON 对象,不要 markdown,不要泄露密钥。"
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_role_definition(
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{mpsc, Mutex, OnceLock};
|
||||
use std::sync::{mpsc, Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -1038,7 +1038,7 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(PreviewRegistry::default())
|
||||
.manage(game_creator_preview_registry())
|
||||
.setup(|app| {
|
||||
configure_game_creator_runtime_config_dir(app.handle())?;
|
||||
#[cfg(all(debug_assertions, not(test)))]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct PreviewRegistry {
|
||||
current: Mutex<Option<PreviewServer>>,
|
||||
current: Arc<Mutex<Option<PreviewServer>>>,
|
||||
}
|
||||
|
||||
struct PreviewServer {
|
||||
@@ -67,6 +67,14 @@ impl PreviewRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
static GAME_CREATOR_PREVIEW_REGISTRY: OnceLock<PreviewRegistry> = OnceLock::new();
|
||||
|
||||
pub(crate) fn game_creator_preview_registry() -> PreviewRegistry {
|
||||
GAME_CREATOR_PREVIEW_REGISTRY
|
||||
.get_or_init(PreviewRegistry::default)
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn stopped_preview_status() -> LocalPreviewStatus {
|
||||
LocalPreviewStatus {
|
||||
status: "stopped".to_string(),
|
||||
@@ -155,6 +163,13 @@ pub(crate) fn start_local_game_preview(
|
||||
registry: tauri::State<'_, PreviewRegistry>,
|
||||
) -> Result<LocalPreviewResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
start_local_game_preview_at(root, ®istry)
|
||||
}
|
||||
|
||||
pub(crate) fn start_local_game_preview_at(
|
||||
root: &Path,
|
||||
registry: &PreviewRegistry,
|
||||
) -> Result<LocalPreviewResult, String> {
|
||||
enforce_project_permission_policy(root, "preview.start")?;
|
||||
let _lock = acquire_project_write_lock(root, "preview.start")?;
|
||||
let (preview, stop) = start_local_game_preview_for_project(root)?;
|
||||
|
||||
@@ -2354,6 +2354,198 @@ async fn background_agent_runtime_limited_command_respects_project_policy() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_can_start_local_preview() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||||
fs::write(
|
||||
root.join("game/index.html"),
|
||||
fake_llm_game_draft().game_html,
|
||||
)
|
||||
.expect("write playable game html");
|
||||
write_project_permission_policy_at(
|
||||
&root,
|
||||
ProjectPermissionPolicy {
|
||||
denied_commands: Vec::new(),
|
||||
confirm_commands: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("allow preview start");
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let plan_json = serde_json::json!({
|
||||
"thinkingSummary": "当前原型已经可运行,需要启动本地预览给用户验收",
|
||||
"plan": ["启动本地预览", "回报预览地址"],
|
||||
"actions": [
|
||||
{
|
||||
"tool": "preview.start",
|
||||
"reason": "让用户可以在浏览器试玩当前原型",
|
||||
"input": {}
|
||||
}
|
||||
],
|
||||
"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": {{
|
||||
"code-prototype": {{
|
||||
"apiKey": "code-key",
|
||||
"baseUrl": {base_url:?},
|
||||
"model": "code-runtime-model",
|
||||
"apiKind": "openai_responses"
|
||||
}}
|
||||
}}
|
||||
}}"#
|
||||
));
|
||||
|
||||
start_game_creator_agent_background_task_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
"启动当前可玩原型预览",
|
||||
"code-preview-run",
|
||||
)
|
||||
.expect("start background task");
|
||||
|
||||
let plan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("plan llm request");
|
||||
assert!(plan_request.contains("preview.start"));
|
||||
let final_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("final reply llm request");
|
||||
assert!(final_request.contains("preview.start 已启动"));
|
||||
assert!(final_request.contains("http://127.0.0.1:"));
|
||||
let root_display = root.to_string_lossy();
|
||||
assert!(!final_request.contains(root_display.as_ref()));
|
||||
|
||||
let mut runtime = read_game_creator_agent_runtime_at(&root, "code-prototype")
|
||||
.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, "code-prototype")
|
||||
.expect("read runtime")
|
||||
.state;
|
||||
}
|
||||
assert_eq!(runtime.status, "idle");
|
||||
assert!(runtime
|
||||
.observations
|
||||
.iter()
|
||||
.any(|item| { item.contains("preview.start:ok · preview.start 已启动") }));
|
||||
let preview_log =
|
||||
fs::read_to_string(root.join(".agent/logs/preview.log")).expect("preview log");
|
||||
assert!(preview_log.contains("preview.running http://127.0.0.1:"));
|
||||
let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db");
|
||||
assert!(agent_db.contains("\"recordType\":\"agent.runtime.preview.start\""));
|
||||
let manifest: Value =
|
||||
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
|
||||
.expect("manifest json");
|
||||
assert_eq!(manifest["preview"]["status"], "running");
|
||||
assert!(manifest["preview"]["url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("http://127.0.0.1:"));
|
||||
|
||||
let registry = game_creator_preview_registry();
|
||||
stop_local_game_preview_for_root(Some(&root), ®istry).expect("stop preview");
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_preview_start_respects_project_policy() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
|
||||
fs::write(
|
||||
root.join("game/index.html"),
|
||||
fake_llm_game_draft().game_html,
|
||||
)
|
||||
.expect("write playable game html");
|
||||
write_project_permission_policy_at(
|
||||
&root,
|
||||
ProjectPermissionPolicy {
|
||||
denied_commands: Vec::new(),
|
||||
confirm_commands: vec!["preview.start".to_string()],
|
||||
},
|
||||
)
|
||||
.expect("write policy");
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let plan_json = serde_json::json!({
|
||||
"thinkingSummary": "尝试启动本地预览",
|
||||
"plan": ["启动本地预览"],
|
||||
"actions": [
|
||||
{
|
||||
"tool": "preview.start",
|
||||
"reason": "测试策略拦截预览启动",
|
||||
"input": {}
|
||||
}
|
||||
],
|
||||
"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": {{
|
||||
"code-prototype": {{
|
||||
"apiKey": "code-key",
|
||||
"baseUrl": {base_url:?},
|
||||
"model": "code-runtime-model",
|
||||
"apiKind": "openai_responses"
|
||||
}}
|
||||
}}
|
||||
}}"#
|
||||
));
|
||||
|
||||
start_game_creator_agent_background_task_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
"后台尝试启动当前预览",
|
||||
"code-preview-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("项目权限策略要求用户确认:preview.start"));
|
||||
assert!(!final_request.contains("preview.start 已启动"));
|
||||
|
||||
let mut runtime = read_game_creator_agent_runtime_at(&root, "code-prototype")
|
||||
.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, "code-prototype")
|
||||
.expect("read runtime")
|
||||
.state;
|
||||
}
|
||||
assert_eq!(runtime.status, "idle");
|
||||
assert!(runtime
|
||||
.observations
|
||||
.iter()
|
||||
.any(|item| item
|
||||
.contains("preview.start:blocked · 项目权限策略要求用户确认:preview.start")));
|
||||
assert!(!root.join(".agent/logs/preview.log").exists());
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_tool_action_respects_confirm_policy() {
|
||||
let root = unique_project_path();
|
||||
|
||||
Reference in New Issue
Block a user