补齐Agent Runtime工具策略快照
Runtime state 新增 toolPolicy,派生工具级 auto、confirm 和 deny 策略。 后台规划 prompt 注入当前工具策略,Agent 可在决策前感知权限边界。 状态面板展示工具策略摘要,补充策略映射和 prompt 注入测试。
This commit is contained in:
@@ -214,6 +214,7 @@ pub(crate) fn read_game_creator_agent_runtime_at(
|
||||
}
|
||||
};
|
||||
normalize_game_creator_agent_runtime_state(&mut state, &agent_id);
|
||||
let _ = refresh_game_creator_agent_runtime_tool_policy(root, &mut state);
|
||||
let recent_events = read_recent_game_creator_agent_runtime_events(&event_path)?;
|
||||
let recent_tasks = read_recent_game_creator_agent_runtime_tasks(&task_path)?;
|
||||
Ok(AgentRuntimeResult {
|
||||
@@ -828,8 +829,11 @@ fn build_game_creator_agent_background_tool_plan_request(
|
||||
serde_json::to_string_pretty(observations)
|
||||
.map_err(|error| format!("序列化 Agent 工具观察失败:{error}"))?
|
||||
};
|
||||
let tool_policy = agent_runtime_tool_policy_snapshot_at(root)?;
|
||||
let tool_policy_json = serde_json::to_string_pretty(&tool_policy)
|
||||
.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|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.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|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.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()),
|
||||
@@ -969,6 +973,74 @@ fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_runtime_executable_tools() -> Vec<&'static str> {
|
||||
vec![
|
||||
"memory.read",
|
||||
"memory.write",
|
||||
"conversation.read",
|
||||
"asset.list",
|
||||
"project.index",
|
||||
"file.read",
|
||||
"file.write",
|
||||
"task.list",
|
||||
"task.update",
|
||||
"command.run_limited",
|
||||
"preview.start",
|
||||
"canvas.asset_generate",
|
||||
"blackboard.write",
|
||||
"agent.message",
|
||||
]
|
||||
}
|
||||
|
||||
fn agent_runtime_tool_policy_snapshot_at(
|
||||
root: &Path,
|
||||
) -> Result<AgentRuntimeToolPolicySnapshot, String> {
|
||||
let view = read_project_permission_policy_at(root)?;
|
||||
let mut auto_tools = Vec::new();
|
||||
let mut confirm_tools = Vec::new();
|
||||
let mut denied_tools = Vec::new();
|
||||
for tool in agent_runtime_executable_tools() {
|
||||
let Some(command_id) = game_creator_agent_runtime_tool_command_id(tool) else {
|
||||
continue;
|
||||
};
|
||||
if view
|
||||
.policy
|
||||
.denied_commands
|
||||
.iter()
|
||||
.any(|command| command == command_id)
|
||||
{
|
||||
denied_tools.push(tool.to_string());
|
||||
} else if view
|
||||
.policy
|
||||
.confirm_commands
|
||||
.iter()
|
||||
.any(|command| command == command_id)
|
||||
{
|
||||
confirm_tools.push(tool.to_string());
|
||||
} else {
|
||||
auto_tools.push(tool.to_string());
|
||||
}
|
||||
}
|
||||
Ok(AgentRuntimeToolPolicySnapshot {
|
||||
allowed_tools: agent_runtime_executable_tools()
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
auto_tools,
|
||||
confirm_tools,
|
||||
denied_tools,
|
||||
updated_at: unix_timestamp(),
|
||||
})
|
||||
}
|
||||
|
||||
fn refresh_game_creator_agent_runtime_tool_policy(
|
||||
root: &Path,
|
||||
state: &mut AgentRuntimeState,
|
||||
) -> Result<(), String> {
|
||||
state.tool_policy = agent_runtime_tool_policy_snapshot_at(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn game_creator_agent_runtime_tool_policy_block(root: &Path, command_id: &str) -> Option<String> {
|
||||
let view = match read_project_permission_policy_at(root) {
|
||||
Ok(view) => view,
|
||||
@@ -1831,6 +1903,7 @@ 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()];
|
||||
refresh_game_creator_agent_runtime_tool_policy(root, &mut state)?;
|
||||
state.updated_at = unix_timestamp();
|
||||
write_game_creator_agent_runtime_state(root, &state)?;
|
||||
append_game_creator_agent_runtime_task(root, &state)?;
|
||||
@@ -1873,6 +1946,7 @@ pub(crate) fn advance_game_creator_agent_runtime_turn_at(
|
||||
if !observation.trim().is_empty() {
|
||||
state.observations.push(observation.trim().to_string());
|
||||
}
|
||||
refresh_game_creator_agent_runtime_tool_policy(root, &mut state)?;
|
||||
state.updated_at = unix_timestamp();
|
||||
write_game_creator_agent_runtime_state(root, &state)?;
|
||||
append_game_creator_agent_runtime_task(root, &state)?;
|
||||
@@ -1902,6 +1976,7 @@ pub(crate) fn finish_game_creator_agent_runtime_turn_at(
|
||||
state
|
||||
.observations
|
||||
.push("Agent 已完成回复,assistant 消息等待或已经由前端落盘。".to_string());
|
||||
refresh_game_creator_agent_runtime_tool_policy(root, &mut state)?;
|
||||
state.updated_at = unix_timestamp();
|
||||
write_game_creator_agent_runtime_state(root, &state)?;
|
||||
append_game_creator_agent_runtime_task(root, &state)?;
|
||||
@@ -1939,6 +2014,7 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at(
|
||||
state.current_action = "等待开发者处理失败".to_string();
|
||||
state.next_step = "等待开发者处理失败".to_string();
|
||||
state.error = Some(sanitize_agent_runtime_text(error, 500));
|
||||
let _ = refresh_game_creator_agent_runtime_tool_policy(root, &mut state);
|
||||
state.updated_at = unix_timestamp();
|
||||
write_game_creator_agent_runtime_state(root, &state)?;
|
||||
append_game_creator_agent_runtime_task(root, &state)?;
|
||||
@@ -2016,6 +2092,7 @@ fn default_game_creator_agent_runtime_state(agent_id: &str, run_id: &str) -> Age
|
||||
observations: Vec::new(),
|
||||
recent_tool_calls: Vec::new(),
|
||||
allowed_tools: default_game_creator_agent_runtime_allowed_tools(),
|
||||
tool_policy: AgentRuntimeToolPolicySnapshot::default(),
|
||||
last_response: None,
|
||||
error: None,
|
||||
updated_at: unix_timestamp(),
|
||||
@@ -2092,6 +2169,12 @@ fn normalize_game_creator_agent_runtime_state(state: &mut AgentRuntimeState, age
|
||||
if state.updated_at == 0 {
|
||||
state.updated_at = unix_timestamp();
|
||||
}
|
||||
if state.tool_policy.allowed_tools.is_empty() {
|
||||
state.tool_policy.allowed_tools = agent_runtime_executable_tools()
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
}
|
||||
if state.recent_tool_calls.len() > AGENT_RUNTIME_RECENT_TOOL_CALL_LIMIT {
|
||||
let keep_from = state
|
||||
.recent_tool_calls
|
||||
|
||||
@@ -166,6 +166,8 @@ struct AgentRuntimeState {
|
||||
#[serde(default)]
|
||||
allowed_tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
tool_policy: AgentRuntimeToolPolicySnapshot,
|
||||
#[serde(default)]
|
||||
last_response: Option<String>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
@@ -173,6 +175,33 @@ struct AgentRuntimeState {
|
||||
updated_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentRuntimeToolPolicySnapshot {
|
||||
#[serde(default)]
|
||||
allowed_tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
auto_tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
confirm_tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
denied_tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
updated_at: u64,
|
||||
}
|
||||
|
||||
impl Default for AgentRuntimeToolPolicySnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allowed_tools: Vec::new(),
|
||||
auto_tools: Vec::new(),
|
||||
confirm_tools: Vec::new(),
|
||||
denied_tools: Vec::new(),
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgentRuntimeToolCallRecord {
|
||||
|
||||
@@ -1326,6 +1326,66 @@ async fn chat_with_game_creator_role_agent_stream_emits_deltas() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_runtime_tool_policy_snapshot_reflects_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!["file.write".to_string()],
|
||||
confirm_commands: vec!["memory.write".to_string(), "task.update".to_string()],
|
||||
},
|
||||
)
|
||||
.expect("write policy");
|
||||
|
||||
let runtime = start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
"design-director",
|
||||
"检查工具策略",
|
||||
"policy-snapshot-run",
|
||||
"agent-chat",
|
||||
"读取工具策略",
|
||||
vec!["核对工具策略".to_string()],
|
||||
)
|
||||
.expect("start runtime");
|
||||
|
||||
assert!(runtime
|
||||
.tool_policy
|
||||
.allowed_tools
|
||||
.contains(&"task.list".to_string()));
|
||||
assert!(runtime
|
||||
.tool_policy
|
||||
.auto_tools
|
||||
.contains(&"task.list".to_string()));
|
||||
assert!(runtime
|
||||
.tool_policy
|
||||
.confirm_tools
|
||||
.contains(&"memory.write".to_string()));
|
||||
assert!(runtime
|
||||
.tool_policy
|
||||
.confirm_tools
|
||||
.contains(&"blackboard.write".to_string()));
|
||||
assert!(runtime
|
||||
.tool_policy
|
||||
.confirm_tools
|
||||
.contains(&"task.update".to_string()));
|
||||
assert!(runtime
|
||||
.tool_policy
|
||||
.denied_tools
|
||||
.contains(&"file.write".to_string()));
|
||||
|
||||
let read_back =
|
||||
read_game_creator_agent_runtime_at(&root, "design-director").expect("read runtime");
|
||||
assert!(read_back
|
||||
.state
|
||||
.tool_policy
|
||||
.denied_tools
|
||||
.contains(&"file.write".to_string()));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn role_agent_runtime_turn_persists_session_events_and_index() {
|
||||
let root = unique_project_path();
|
||||
@@ -1363,6 +1423,23 @@ async fn role_agent_runtime_turn_persists_session_events_and_index() {
|
||||
assert!(runtime
|
||||
.allowed_tools
|
||||
.contains(&"conversation.read".to_string()));
|
||||
assert!(runtime
|
||||
.tool_policy
|
||||
.allowed_tools
|
||||
.contains(&"task.list".to_string()));
|
||||
assert!(runtime
|
||||
.tool_policy
|
||||
.auto_tools
|
||||
.contains(&"memory.read".to_string()));
|
||||
assert!(runtime
|
||||
.tool_policy
|
||||
.confirm_tools
|
||||
.contains(&"task.update".to_string()));
|
||||
assert!(runtime
|
||||
.tool_policy
|
||||
.confirm_tools
|
||||
.contains(&"blackboard.write".to_string()));
|
||||
assert!(runtime.tool_policy.denied_tools.is_empty());
|
||||
let result =
|
||||
read_game_creator_agent_runtime_at(&root, "art-director").expect("read runtime state");
|
||||
assert!(result
|
||||
@@ -1500,6 +1577,9 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() {
|
||||
let plan_request = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("plan llm request");
|
||||
assert!(plan_request.contains("当前工具策略"));
|
||||
assert!(plan_request.contains("confirmTools"));
|
||||
assert!(plan_request.contains("task.update"));
|
||||
assert!(plan_request.contains("只输出 JSON 对象"));
|
||||
assert!(plan_request.contains("后台分析当前玩法循环"));
|
||||
let final_request = receiver
|
||||
|
||||
@@ -243,12 +243,21 @@ interface AgentRuntimeState {
|
||||
observations: string[];
|
||||
recentToolCalls?: AgentRuntimeToolCallRecord[];
|
||||
allowedTools: string[];
|
||||
toolPolicy?: AgentRuntimeToolPolicySnapshot;
|
||||
lastResponse: string | null;
|
||||
error: string | null;
|
||||
updatedAt: number;
|
||||
recentTasks?: AgentRuntimeTaskRecord[];
|
||||
}
|
||||
|
||||
interface AgentRuntimeToolPolicySnapshot {
|
||||
allowedTools: string[];
|
||||
autoTools: string[];
|
||||
confirmTools: string[];
|
||||
deniedTools: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface AgentRuntimeToolCallRecord {
|
||||
tool: string;
|
||||
status: string;
|
||||
@@ -511,6 +520,13 @@ function agentRuntimeStateFromResult(
|
||||
nextStep:
|
||||
result.state.nextStep ?? agentRuntimeNextStepFromPhase(result.state.phase),
|
||||
recentToolCalls: result.state.recentToolCalls ?? [],
|
||||
toolPolicy: result.state.toolPolicy ?? {
|
||||
allowedTools: result.state.allowedTools ?? [],
|
||||
autoTools: [],
|
||||
confirmTools: [],
|
||||
deniedTools: [],
|
||||
updatedAt: 0,
|
||||
},
|
||||
recentTasks: result.recentTasks ?? result.state.recentTasks ?? [],
|
||||
};
|
||||
}
|
||||
@@ -566,6 +582,7 @@ function AgentRuntimeStatusPanel({
|
||||
const observations = runtime.observations.slice(-2);
|
||||
const recentToolCalls = (runtime.recentToolCalls ?? []).slice(-3).reverse();
|
||||
const recentTasks = (runtime.recentTasks ?? []).slice(-3).reverse();
|
||||
const toolPolicy = runtime.toolPolicy;
|
||||
const nextStep = runtime.nextStep ?? agentRuntimeNextStepFromPhase(runtime.phase);
|
||||
return (
|
||||
<section className="agent-runtime-status" aria-label="Agent Runtime 状态">
|
||||
@@ -578,6 +595,17 @@ function AgentRuntimeStatusPanel({
|
||||
{runtime.currentTask ? <p>{runtime.currentTask}</p> : null}
|
||||
<small>{runtime.currentAction}</small>
|
||||
{nextStep ? <small>{`下一步:${nextStep}`}</small> : null}
|
||||
{toolPolicy ? (
|
||||
<small>
|
||||
{`工具策略:auto ${toolPolicy.autoTools.length} · confirm ${toolPolicy.confirmTools.length} · deny ${toolPolicy.deniedTools.length}`}
|
||||
{toolPolicy.deniedTools.length > 0
|
||||
? ` · deny: ${toolPolicy.deniedTools.slice(0, 3).join(', ')}`
|
||||
: ''}
|
||||
{toolPolicy.confirmTools.length > 0
|
||||
? ` · confirm: ${toolPolicy.confirmTools.slice(0, 3).join(', ')}`
|
||||
: ''}
|
||||
</small>
|
||||
) : null}
|
||||
{planItems.length > 0 ? (
|
||||
<ol>
|
||||
{planItems.map((item, index) => (
|
||||
|
||||
Reference in New Issue
Block a user