支持Agent独立工具策略

扩展项目权限策略,按Agent保存独立拒绝和确认命令。

后台Runtime按项目级与Agent级策略叠加生成工具快照并拦截执行。

主聊天新增agent-policy命令并保留既有策略确认流程。

补充前端和Rust测试覆盖独立Agent策略读写与执行拦截。

同步AI游戏创作App技术方案和项目决策记录。
This commit is contained in:
AIGameCreator App
2026-07-10 05:04:56 +08:00
parent 5058b42ca9
commit d09febc7cd
8 changed files with 616 additions and 22 deletions
@@ -853,22 +853,22 @@ struct AgentRuntimeToolPlan {
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeToolAction {
pub(crate) struct AgentRuntimeToolAction {
#[serde(default)]
tool: String,
pub(crate) tool: String,
#[serde(default)]
reason: Option<String>,
pub(crate) reason: Option<String>,
#[serde(default)]
input: serde_json::Value,
pub(crate) input: serde_json::Value,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeToolObservation {
tool: String,
status: String,
summary: String,
detail: Option<String>,
pub(crate) struct AgentRuntimeToolObservation {
pub(crate) tool: String,
pub(crate) status: String,
pub(crate) summary: String,
pub(crate) detail: Option<String>,
}
impl AgentRuntimeToolObservation {
@@ -1119,7 +1119,7 @@ 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 = agent_runtime_tool_policy_snapshot_at(root, agent_id)?;
let tool_policy_json = serde_json::to_string_pretty(&tool_policy)
.map_err(|error| format!("序列化 Agent 工具策略失败:{error}"))?;
let prompt = format!(
@@ -1188,7 +1188,7 @@ fn parse_game_creator_agent_tool_plan_response(
Ok(plan)
}
async fn execute_game_creator_agent_runtime_tool_action(
pub(crate) async fn execute_game_creator_agent_runtime_tool_action(
root: &Path,
agent_id: &str,
task: &str,
@@ -1197,7 +1197,9 @@ async fn execute_game_creator_agent_runtime_tool_action(
let tool = action.tool.trim();
let command_id = game_creator_agent_runtime_tool_command_id(tool);
if let Some(command_id) = command_id {
if let Some(blocked) = game_creator_agent_runtime_tool_policy_block(root, command_id) {
if let Some(blocked) =
game_creator_agent_runtime_tool_policy_block(root, agent_id, command_id)
{
return AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "blocked".to_string(),
@@ -1296,8 +1298,9 @@ fn agent_runtime_executable_tools() -> Vec<&'static str> {
fn agent_runtime_tool_policy_snapshot_at(
root: &Path,
agent_id: &str,
) -> Result<AgentRuntimeToolPolicySnapshot, String> {
let view = read_project_permission_policy_at(root)?;
let policy = agent_runtime_effective_tool_policy_at(root, agent_id)?;
let mut auto_tools = Vec::new();
let mut confirm_tools = Vec::new();
let mut denied_tools = Vec::new();
@@ -1305,15 +1308,13 @@ fn agent_runtime_tool_policy_snapshot_at(
let Some(command_id) = game_creator_agent_runtime_tool_command_id(tool) else {
continue;
};
if view
.policy
if policy
.denied_commands
.iter()
.any(|command| command == command_id)
{
denied_tools.push(tool.to_string());
} else if view
.policy
} else if policy
.confirm_commands
.iter()
.any(|command| command == command_id)
@@ -1339,13 +1340,51 @@ 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)?;
state.tool_policy = agent_runtime_tool_policy_snapshot_at(root, &state.agent_id)?;
Ok(())
}
fn game_creator_agent_runtime_tool_policy_block(root: &Path, command_id: &str) -> Option<String> {
fn agent_runtime_effective_tool_policy_at(
root: &Path,
agent_id: &str,
) -> Result<ProjectAgentPermissionPolicy, String> {
let view = match read_project_permission_policy_at(root) {
Ok(view) => view,
Err(error) => return Err(error),
};
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
let mut denied_commands = view.policy.denied_commands.clone();
let mut confirm_commands = view.policy.confirm_commands.clone();
if let Some(agent_policy) = view.policy.agent_policies.get(&agent_id) {
for command_id in &agent_policy.denied_commands {
if !denied_commands.iter().any(|command| command == command_id) {
denied_commands.push(command_id.clone());
}
}
for command_id in &agent_policy.confirm_commands {
if !confirm_commands.iter().any(|command| command == command_id) {
confirm_commands.push(command_id.clone());
}
}
}
confirm_commands.retain(|command| !denied_commands.contains(command));
Ok(ProjectAgentPermissionPolicy {
denied_commands,
confirm_commands,
})
}
fn game_creator_agent_runtime_tool_policy_block(
root: &Path,
agent_id: &str,
command_id: &str,
) -> Option<String> {
let view = match read_project_permission_policy_at(root) {
Ok(view) => view,
Err(error) => return Some(error),
};
let agent_id = match normalize_game_creator_runtime_agent_id(agent_id) {
Ok(agent_id) => agent_id,
Err(error) => return Some(error),
};
if view
@@ -1356,6 +1395,20 @@ fn game_creator_agent_runtime_tool_policy_block(root: &Path, command_id: &str) -
{
return Some(format!("项目权限策略拒绝执行:{command_id}"));
}
if view
.policy
.agent_policies
.get(&agent_id)
.map(|policy| {
policy
.denied_commands
.iter()
.any(|command| command == command_id)
})
.unwrap_or(false)
{
return Some(format!("Agent 权限策略拒绝执行:{agent_id} / {command_id}"));
}
if view
.policy
.confirm_commands
@@ -1364,6 +1417,22 @@ fn game_creator_agent_runtime_tool_policy_block(root: &Path, command_id: &str) -
{
return Some(format!("项目权限策略要求用户确认:{command_id}"));
}
if view
.policy
.agent_policies
.get(&agent_id)
.map(|policy| {
policy
.confirm_commands
.iter()
.any(|command| command == command_id)
})
.unwrap_or(false)
{
return Some(format!(
"Agent 权限策略要求用户确认:{agent_id} / {command_id}"
));
}
None
}
@@ -644,6 +644,17 @@ struct LocalConversationResult {
struct ProjectPermissionPolicy {
denied_commands: Vec<String>,
confirm_commands: Vec<String>,
#[serde(default)]
agent_policies: BTreeMap<String, ProjectAgentPermissionPolicy>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProjectAgentPermissionPolicy {
#[serde(default)]
denied_commands: Vec<String>,
#[serde(default)]
confirm_commands: Vec<String>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
@@ -487,6 +487,7 @@ impl Default for ProjectPermissionPolicy {
.filter(|command| command.permission == GameCreationAppPermission::Confirm)
.map(|command| command.id.to_string())
.collect(),
agent_policies: BTreeMap::new(),
}
}
}
@@ -533,6 +534,7 @@ pub(crate) fn write_project_permission_policy_at(
"recordType": "project.policy_write",
"deniedCommands": policy.denied_commands,
"confirmCommands": policy.confirm_commands,
"agentPolicies": policy.agent_policies,
}),
)?;
read_project_permission_policy_at(root)
@@ -546,6 +548,18 @@ pub(crate) fn normalize_project_permission_policy(
policy
.confirm_commands
.retain(|command| !policy.denied_commands.contains(command));
let mut agent_policies = BTreeMap::new();
for (agent_id, mut agent_policy) in policy.agent_policies {
let agent_id = normalize_game_creator_runtime_agent_id(&agent_id)?;
agent_policy.denied_commands = normalize_policy_command_ids(agent_policy.denied_commands)?;
agent_policy.confirm_commands =
normalize_policy_command_ids(agent_policy.confirm_commands)?;
agent_policy
.confirm_commands
.retain(|command| !agent_policy.denied_commands.contains(command));
agent_policies.insert(agent_id, agent_policy);
}
policy.agent_policies = agent_policies;
Ok(policy)
}
@@ -1401,6 +1401,7 @@ fn agent_runtime_tool_policy_snapshot_reflects_project_policy() {
ProjectPermissionPolicy {
denied_commands: vec!["file.write".to_string()],
confirm_commands: vec!["memory.write".to_string(), "task.update".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -1452,6 +1453,116 @@ fn agent_runtime_tool_policy_snapshot_reflects_project_policy() {
fs::remove_dir_all(root).ok();
}
#[test]
fn agent_runtime_tool_policy_snapshot_reflects_agent_policy() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
let mut agent_policies = BTreeMap::new();
agent_policies.insert(
"design-director".to_string(),
ProjectAgentPermissionPolicy {
denied_commands: vec!["file.read".to_string()],
confirm_commands: vec!["memory.write".to_string()],
},
);
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies,
},
)
.expect("write agent policy");
let design_runtime = start_game_creator_agent_runtime_task_at(
&root,
"design-director",
"检查策划 Agent 工具策略",
"design-policy-snapshot-run",
"agent-chat",
"读取工具策略",
vec!["核对工具策略".to_string()],
)
.expect("start design runtime");
let art_runtime = start_game_creator_agent_runtime_task_at(
&root,
"art-director",
"检查美术 Agent 工具策略",
"art-policy-snapshot-run",
"agent-chat",
"读取工具策略",
vec!["核对工具策略".to_string()],
)
.expect("start art runtime");
assert!(design_runtime
.tool_policy
.denied_tools
.contains(&"file.read".to_string()));
assert!(design_runtime
.tool_policy
.confirm_tools
.contains(&"memory.write".to_string()));
assert!(design_runtime
.tool_policy
.confirm_tools
.contains(&"blackboard.write".to_string()));
assert!(art_runtime
.tool_policy
.auto_tools
.contains(&"file.read".to_string()));
assert!(art_runtime
.tool_policy
.auto_tools
.contains(&"memory.write".to_string()));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_tool_action_respects_agent_policy() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
let mut agent_policies = BTreeMap::new();
agent_policies.insert(
"design-director".to_string(),
ProjectAgentPermissionPolicy {
denied_commands: vec!["file.read".to_string()],
confirm_commands: Vec::new(),
},
);
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies,
},
)
.expect("write agent policy");
let observation = execute_game_creator_agent_runtime_tool_action(
&root,
"design-director",
"读取设计笔记",
&AgentRuntimeToolAction {
tool: "file.read".to_string(),
reason: Some("需要读取设计笔记".to_string()),
input: serde_json::json!({ "path": "game/notes.txt" }),
},
)
.await;
assert_eq!(observation.status, "blocked");
assert_eq!(
observation.summary,
"Agent 权限策略拒绝执行:design-director / file.read"
);
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn role_agent_runtime_turn_persists_session_events_and_index() {
let root = unique_project_path();
@@ -2422,6 +2533,7 @@ async fn background_agent_runtime_can_write_blackboard_and_message_other_agent()
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow runtime collaboration writes");
@@ -2543,6 +2655,7 @@ async fn background_agent_runtime_can_delegate_task_to_other_agent() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow runtime delegation");
@@ -2678,6 +2791,7 @@ async fn background_agent_runtime_delegate_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["agent.delegate".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -2772,6 +2886,7 @@ async fn background_agent_runtime_can_write_memory_and_project_files() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow runtime writes");
@@ -2900,6 +3015,7 @@ async fn background_agent_runtime_write_tools_respect_project_policy() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["memory.write".to_string(), "conversation.write".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -3000,6 +3116,7 @@ async fn background_agent_runtime_file_and_memory_writes_respect_project_policy(
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["memory.write".to_string(), "file.write".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -3109,6 +3226,7 @@ async fn background_agent_runtime_can_list_manifest_tasks() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow task list");
@@ -3203,6 +3321,7 @@ async fn background_agent_runtime_task_list_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["task.list".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -3285,6 +3404,7 @@ async fn background_agent_runtime_can_update_manifest_task_status() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow task update");
@@ -3382,6 +3502,7 @@ async fn background_agent_runtime_task_update_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["task.update".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -3474,6 +3595,7 @@ async fn background_agent_runtime_can_run_limited_static_smoke() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow limited command");
@@ -3579,6 +3701,7 @@ async fn background_agent_runtime_limited_command_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["command.run_limited".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -3664,6 +3787,7 @@ async fn background_agent_runtime_can_start_local_preview() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow preview start");
@@ -3768,6 +3892,7 @@ async fn background_agent_runtime_preview_start_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["preview.start".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -3853,6 +3978,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow asset generation");
@@ -3972,6 +4098,7 @@ async fn background_agent_runtime_asset_generation_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["canvas.asset_generate".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -4061,6 +4188,7 @@ async fn background_agent_runtime_tool_action_respects_confirm_policy() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["file.read".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -4159,6 +4287,7 @@ async fn background_agent_runtime_can_list_project_files() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow file list");
@@ -4257,6 +4386,7 @@ async fn background_agent_runtime_file_list_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["file.list".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -4345,6 +4475,7 @@ async fn background_agent_runtime_can_diff_checkpoint() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow project diff");
@@ -4441,6 +4572,7 @@ async fn background_agent_runtime_project_diff_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["project.diff".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -4542,6 +4674,7 @@ async fn background_agent_runtime_can_read_other_agent_status() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("allow agent status");
@@ -4653,6 +4786,7 @@ async fn background_agent_runtime_run_status_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec!["agent.run_status".to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -7089,6 +7223,7 @@ async fn generate_platform_art_asset_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: vec!["canvas.asset_generate".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -7234,6 +7369,7 @@ fn local_memory_reads_respect_project_policy() {
ProjectPermissionPolicy {
denied_commands: vec!["memory.read".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -7259,6 +7395,7 @@ fn local_agent_memory_writes_respect_project_policy() {
ProjectPermissionPolicy {
denied_commands: vec!["memory.write".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -7565,6 +7702,7 @@ fn local_conversation_write_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: vec!["conversation.write".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -7603,6 +7741,7 @@ fn local_conversation_read_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: vec!["conversation.read".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -7983,6 +8122,7 @@ fn project_permission_policy_can_deny_mutating_commands() {
ProjectPermissionPolicy {
denied_commands: vec!["file.write".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -8003,6 +8143,7 @@ fn project_permission_policy_read_respects_project_policy() {
ProjectPermissionPolicy {
denied_commands: vec!["project.policy_read".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -8023,6 +8164,7 @@ fn local_game_manifest_reads_respect_declared_command_policy() {
ProjectPermissionPolicy {
denied_commands: vec!["project.status".to_string(), "asset.list".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -8052,6 +8194,7 @@ fn local_project_file_read_and_list_respect_project_policy() {
ProjectPermissionPolicy {
denied_commands: vec!["file.list".to_string(), "file.read".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -8077,6 +8220,7 @@ fn local_project_file_read_can_enforce_agent_trace_read_policy() {
ProjectPermissionPolicy {
denied_commands: vec!["agent.trace_read".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -8859,6 +9003,7 @@ fn preview_open_respects_project_policy_when_project_is_provided() {
ProjectPermissionPolicy {
denied_commands: vec!["preview.open".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
@@ -8886,6 +9031,7 @@ fn preview_status_respects_project_policy_when_project_is_provided() {
ProjectPermissionPolicy {
denied_commands: vec!["preview.status".to_string()],
confirm_commands: Vec::new(),
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
+247 -2
View File
@@ -877,6 +877,12 @@ function AgentRuntimeStatusPanel({
interface ProjectPermissionPolicy {
deniedCommands: string[];
confirmCommands: string[];
agentPolicies?: Record<string, ProjectAgentPermissionPolicy>;
}
interface ProjectAgentPermissionPolicy {
deniedCommands: string[];
confirmCommands: string[];
}
interface ProjectPermissionPolicyView {
@@ -4749,6 +4755,10 @@ const chatCommandHelp = [
'/policy-allow 命令:移除项目内某个命令拒绝项',
'/policy-confirm 命令:执行前每次确认',
'/policy-auto 命令:恢复自动执行',
'/agent-policy-deny Agent 命令:拒绝某个 Agent 调用工具',
'/agent-policy-allow Agent 命令:移除某个 Agent 的拒绝项',
'/agent-policy-confirm Agent 命令:某个 Agent 调用工具前要求确认',
'/agent-policy-auto Agent 命令:恢复某个 Agent 自动执行',
'/tasks:查看任务拆分',
'/agents:查看每个 Agent 的当前状态',
'/agent-conversations:列出 Agent 对话读取命令',
@@ -4812,6 +4822,14 @@ function missingChatCommandArgumentMessage(prompt: string) {
return '格式:/policy-confirm project.index';
case '/policy-auto':
return '格式:/policy-auto project.index';
case '/agent-policy-deny':
return '格式:/agent-policy-deny design-director file.read';
case '/agent-policy-allow':
return '格式:/agent-policy-allow design-director file.read';
case '/agent-policy-confirm':
return '格式:/agent-policy-confirm design-director memory.write';
case '/agent-policy-auto':
return '格式:/agent-policy-auto design-director memory.write';
case '/read':
return '格式:/read game/index.html';
case '/asset-register':
@@ -9841,10 +9859,25 @@ function summarizeProjectDiff(result: LocalProjectDiffResult) {
}
function summarizeProjectPolicy(view: ProjectPermissionPolicyView) {
const agentPolicies = view.policy.agentPolicies ?? {};
const agentPolicyLines = Object.entries(agentPolicies)
.slice(0, 8)
.map(
([agentId, policy]) =>
`Agent ${agentId}:拒绝 ${formatProjectPolicyCommandList(
policy.deniedCommands,
)} ${formatProjectPolicyCommandList(policy.confirmCommands)}`,
);
if (Object.keys(agentPolicies).length > agentPolicyLines.length) {
agentPolicyLines.push(
`Agent 策略还有 ${Object.keys(agentPolicies).length - agentPolicyLines.length}`,
);
}
return [
`策略:${view.path}`,
`拒绝:${formatProjectPolicyCommandList(view.policy.deniedCommands)}`,
`确认:${formatProjectPolicyCommandList(view.policy.confirmCommands)}`,
...agentPolicyLines,
].join('\n');
}
@@ -11218,6 +11251,22 @@ function formatAgentCardRuntimeStatus(agent: AgentStatusCard) {
.join(' · ');
}
function formatAgentPolicySummary(policy: ProjectPermissionPolicy) {
const entries = Object.entries(policy.agentPolicies ?? {});
if (entries.length === 0) {
return '无 Agent 独立策略';
}
return entries
.slice(0, 4)
.map(
([agentId, agentPolicy]) =>
`${agentId} 拒绝:${formatProjectPolicyCommandList(
agentPolicy.deniedCommands,
)} ${formatProjectPolicyCommandList(agentPolicy.confirmCommands)}`,
)
.join('');
}
function formatAgentRecentRuntimeTask(task: AgentRuntimeTaskRecord) {
return `${task.status} / ${task.phase} · ${
task.task || task.currentAction || task.runId
@@ -11787,7 +11836,9 @@ export function pendingCommandDetail(
if (command.id === 'project.policy_write') {
return `写入 ${projectPath}/.agent/policy.json · 拒绝:${formatProjectPolicyCommandList(
command.policy.deniedCommands,
)} · ${formatProjectPolicyCommandList(command.policy.confirmCommands)}`;
)} · ${formatProjectPolicyCommandList(
command.policy.confirmCommands,
)} · Agent${formatAgentPolicySummary(command.policy)}`;
}
if (command.id === 'preview.start') {
return `启动 ${projectPath}/game/ 并交给外部浏览器`;
@@ -14873,7 +14924,7 @@ export function App() {
...current,
{
role: 'assistant',
text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
},
]);
return;
@@ -14882,6 +14933,69 @@ export function App() {
return;
}
if (
prompt.startsWith('/agent-policy-deny ') ||
prompt.startsWith('/agent-policy-allow ') ||
prompt.startsWith('/agent-policy-confirm ') ||
prompt.startsWith('/agent-policy-auto ')
) {
if (!requireChatProjectForUserAction()) {
return;
}
const mode = prompt.startsWith('/agent-policy-deny ')
? 'deny'
: prompt.startsWith('/agent-policy-allow ')
? 'allow'
: prompt.startsWith('/agent-policy-confirm ')
? 'confirm'
: 'auto';
const commandPrefix =
mode === 'deny'
? '/agent-policy-deny '
: mode === 'allow'
? '/agent-policy-allow '
: mode === 'confirm'
? '/agent-policy-confirm '
: '/agent-policy-auto ';
const args = prompt.slice(commandPrefix.length).trim();
const [agentId = '', commandId = ''] = args.split(/\s+/, 2);
if (!agentId || !commandId) {
setMessages((current) => [
...current,
{
role: 'assistant',
text:
mode === 'deny' || mode === 'allow'
? '格式:/agent-policy-deny design-director file.read'
: '格式:/agent-policy-confirm design-director memory.write',
},
]);
return;
}
if (!isRegisteredGameCreationCommandId(commandId)) {
setMessages((current) => [
...current,
{ role: 'assistant', text: `未知内置命令:${commandId}` },
]);
return;
}
if (
(mode === 'confirm' || mode === 'auto') &&
!isProjectPolicyConfirmableCommandId(commandId)
) {
setMessages((current) => [
...current,
{
role: 'assistant',
text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
},
]);
return;
}
void queueAgentPolicyMutation(agentId, commandId, mode);
return;
}
if (prompt === '/llm-status') {
void executeLlmConfigStatus();
return;
@@ -16908,6 +17022,137 @@ export function App() {
}
}
async function queueAgentPolicyMutation(
agentId: string,
commandId: string,
mode: 'deny' | 'allow' | 'confirm' | 'auto',
) {
const invoke = resolveTauriInvoke();
if (!invoke) {
setMessages((current) => [
...current,
{ role: 'assistant', text: '需要在 Tauri App 内运行。' },
]);
return;
}
const nextProjectPath = requireChatProjectForUserAction();
if (!nextProjectPath) {
return;
}
try {
const result = await invoke<ProjectPermissionPolicyView>(
'read_project_permission_policy',
{ projectPath: nextProjectPath },
);
const agentPolicies = { ...(result.policy.agentPolicies ?? {}) };
const agentPolicy = agentPolicies[agentId] ?? {
deniedCommands: [],
confirmCommands: [],
};
const alreadyDenied = agentPolicy.deniedCommands.includes(commandId);
const alreadyConfirmed = agentPolicy.confirmCommands.includes(commandId);
if (mode === 'deny' && alreadyDenied) {
setMessages((current) => [
...current,
{
role: 'assistant',
text: `Agent ${agentId} 的命令已在拒绝列表中:${commandId}`,
},
]);
return;
}
if (mode === 'allow' && !alreadyDenied) {
setMessages((current) => [
...current,
{
role: 'assistant',
text: `Agent ${agentId} 的命令不在拒绝列表中:${commandId}`,
},
]);
return;
}
if (mode === 'confirm' && alreadyConfirmed) {
setMessages((current) => [
...current,
{
role: 'assistant',
text: `Agent ${agentId} 的命令已在确认列表中:${commandId}`,
},
]);
return;
}
if (mode === 'auto' && !alreadyConfirmed) {
setMessages((current) => [
...current,
{
role: 'assistant',
text: `Agent ${agentId} 的命令不在确认列表中:${commandId}`,
},
]);
return;
}
const deniedCommands =
mode === 'deny'
? [...new Set([...agentPolicy.deniedCommands, commandId])]
: mode === 'allow'
? agentPolicy.deniedCommands.filter(
(value) => value !== commandId,
)
: mode === 'confirm'
? agentPolicy.deniedCommands.filter(
(value) => value !== commandId,
)
: agentPolicy.deniedCommands;
const confirmCommands =
mode === 'confirm'
? [...new Set([...agentPolicy.confirmCommands, commandId])]
: mode === 'deny'
? agentPolicy.confirmCommands.filter(
(value) => value !== commandId,
)
: mode === 'auto'
? agentPolicy.confirmCommands.filter(
(value) => value !== commandId,
)
: agentPolicy.confirmCommands;
agentPolicies[agentId] = {
deniedCommands,
confirmCommands,
};
queuePendingCommand({
id: 'project.policy_write',
policy: {
...result.policy,
agentPolicies,
},
});
const action =
mode === 'deny'
? '拒绝'
: mode === 'allow'
? '允许'
: mode === 'confirm'
? '确认'
: '自动执行';
setMessages((current) => [
...current,
{
role: 'assistant',
text: `准备${action} Agent ${agentId} 命令:${commandId}`,
},
]);
} catch (error) {
setMessages((current) => [
...current,
{
role: 'assistant',
text: error instanceof Error ? error.message : String(error),
},
]);
}
}
async function executeProjectPolicyWrite(
policy: ProjectPermissionPolicy,
announceToChat: boolean,
@@ -17504,6 +17504,12 @@ describe('AI 游戏创作 App 界面边界', () => {
policy: {
deniedCommands,
confirmCommands: ['game.static_smoke'],
agentPolicies: {
'design-director': {
deniedCommands: ['file.read'],
confirmCommands: ['memory.write'],
},
},
},
};
}
@@ -17528,6 +17534,9 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(screen.queryByText(/file\.write\.13/)).toBeNull();
expect(screen.getByText(/拒绝:.*还有 2 项/)).not.toBeNull();
expect(screen.getByText(/确认:game\.static_smoke/)).not.toBeNull();
expect(
screen.getByText(/Agent design-director:拒绝 file\.read;确认 memory\.write/),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('read_project_permission_policy', {
projectPath: '/tmp/authorized-game',
});
@@ -17541,6 +17550,12 @@ describe('AI 游戏创作 App 界面边界', () => {
let policy = {
deniedCommands: ['file.delete'],
confirmCommands: ['game.static_smoke'],
agentPolicies: {
'design-director': {
deniedCommands: ['file.read'],
confirmCommands: ['memory.write'],
},
},
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
@@ -17598,6 +17613,12 @@ describe('AI 游戏创作 App 界面边界', () => {
policy: {
deniedCommands: ['file.delete', 'file.write'],
confirmCommands: ['game.static_smoke'],
agentPolicies: {
'design-director': {
deniedCommands: ['file.read'],
confirmCommands: ['memory.write'],
},
},
},
});
expect(invoke).toHaveBeenCalledWith('append_local_permission_log', {
@@ -17607,6 +17628,92 @@ describe('AI 游戏创作 App 界面边界', () => {
});
});
it('updates per-agent project policy from chat after confirmation', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let policy = {
deniedCommands: ['project.index'],
confirmCommands: [],
agentPolicies: {
'art-director': {
deniedCommands: [],
confirmCommands: ['memory.write'],
},
},
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy,
};
}
if (command === 'write_project_permission_policy') {
policy = args?.policy as typeof policy;
return {
path: '.agent/policy.json',
policy,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/');
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已设置本地项目:/tmp/authorized-game'),
).not.toBeNull();
submitChat('/agent-policy-deny design-director file.read');
expect(
await screen.findByText('准备拒绝 Agent design-director 命令:file.read'),
).not.toBeNull();
expect(
screen.getByText(
/Agentart-director 拒绝:无 确认:memory\.writedesign-director 拒绝:file\.read 确认:无/,
),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText(/Agent design-director:拒绝 file\.read;确认 无/),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', {
projectPath: '/tmp/authorized-game',
policy: {
deniedCommands: ['project.index'],
confirmCommands: [],
agentPolicies: {
'art-director': {
deniedCommands: [],
confirmCommands: ['memory.write'],
},
'design-director': {
deniedCommands: ['file.read'],
confirmCommands: [],
},
},
},
});
});
it('updates project policy confirm commands from chat after confirmation', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
@@ -18237,7 +18344,7 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(
await screen.findByText(
'当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
'当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
),
).not.toBeNull();
expect(screen.queryByText('project.policy_write')).toBeNull();
@@ -4060,6 +4060,7 @@
- 2026-07-06 调整:AI 游戏创作 App 主聊天普通文本改为进入主聊天 Agent,而不是直接排队 `game.generate_draft`;主聊天 Agent 读取短期记忆、长期记忆、项目黑板、最近项目对话和本地资产摘要作为背景,支持 `agentLlm.chat` 单独 provider 配置,但只做自然语言交互、澄清和 slash 命令建议,不写项目、不运行工具、不伪装生成结果。显式 `/generate <创作想法>``/draft <创作想法>` 才进入 `game.generate_draft` 待确认流。
- 2026-07-09 调整:AI 游戏创作 App 新增 Agent Runtime V1 最小可观测状态。单 Agent 对话和生成 loop 中的角色 brief 必须写 `.agent/runtime/agents/<agentId>.json``.agent/runtime/events/<agentId>.jsonl`,记录 `agentId``taskId``sessionId``runId``source``status``phase`、当前任务 / 动作、计划、观测、允许工具、最近回复和错误;单 Agent 流式聊天事件要回传最新 `runtimeState`,开发单 Agent 聊天页和项目内单 Agent 对话弹窗只读展示该状态,读取失败必须可见提示,不得静默伪装为空状态。`source=agent-chat` 表示开发者单 Agent 对话,`source=generate-draft` 表示生成 loop 角色 briefcarry-over brief 只记录继承和完成,不伪装成重新调用 LLM。Runtime state 写入使用临时文件替换,event JSONL 读取跳过坏行,用户 prompt / 回复摘要进入 runtime 与 `agent.db` 前复用敏感上下文过滤;`.agent/runtime/` 是运行观测状态,不进入项目索引、checkpoint diff 或 restore 删除范围。该层仍是本地 JSONL 状态与事件,不引入 SQLite、常驻独立进程、远程 runner 或可中断上游 LLM 的承诺。
- 2026-07-10 调整:Agent Runtime state 新增 `toolPolicy`,从项目权限策略派生工具级 `allowedTools``autoTools``confirmTools``deniedTools`。后台 planning prompt 必须带入该快照,让 Agent 在规划阶段知道工具策略;执行阶段仍由 Runtime 白名单和项目权限 gate 决定。`blackboard.write` 继承 `memory.write` 策略,`agent.message` 继承 `conversation.write` 策略,`agent.delegate` 使用独立 `agent.delegate` 策略。
- 2026-07-10 调整:`.agent/policy.json` 支持 `agentPolicies`,用规范 Agent id 保存单个 Agent 的 `deniedCommands / confirmCommands`。Runtime 计算有效工具策略时把项目级策略和 Agent 级策略叠加,项目级策略继续对所有 Agent 生效,Agent 级策略只能进一步拒绝或要求确认,不能放宽项目级策略;拒绝优先于确认。主聊天新增 `/agent-policy-deny Agent 命令``/agent-policy-allow Agent 命令``/agent-policy-confirm Agent 命令``/agent-policy-auto Agent 命令`,继续通过 `project.policy_write` 确认卡写入策略。
- 2026-07-10 调整:Agent Runtime state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、reason、summary、detail 和 updatedAt。状态面板展示最近动作时使用该字段,不解析 observation 文本;写入前继续过滤敏感上下文,不保存原始密钥或任意未过滤输入。
- 2026-07-10 调整:Agent Runtime state 新增 `currentGoal``waitingOn``currentGoal` 固定表达本轮任务目标,`waitingOn` 表达当前等待 LLM、工具观察、开发者输入或失败处理;后台任务生命周期、`agent.run_status` observation、下一轮 planning prompt、开发单 Agent 对话页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都必须展示同一份目标 / 等待状态。
- 2026-07-10 调整:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`。后台 Agent loop 每轮规划前刷新当前轮次、最大轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,字段只做运行观测,不改变 loop 上限或权限 gate。
@@ -34,6 +34,7 @@ Agent Runtime 负责:
- 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作;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 按白名单和项目权限策略执行工具动作,写入 `thinking_summary / plan / action / observation / response / error` 事件,再把 observation 放入下一轮 prompt 让 Agent 修正计划、继续行动或用空 actions + response 收束;后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复并追加回对话。工具箱包含只读工具 `memory.read``conversation.read``asset.list``project.index``project.diff``file.list``file.read``agent.run_status`,以及受策略保护的写/运行工具 `memory.write``file.write``command.run_limited``blackboard.write``agent.message``agent.delegate``memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径并记录审计,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加 `memory/blackboard.md``agent.message` 给目标 `.agent/conversations/agents/<agentId>.jsonl` 写入 tool 留言,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列,策略要求确认或拒绝时不执行写入、运行或委派,只把策略结果作为 observation 回给 Agent。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务和最近事件,`read_game_creator_agent_runtimes` 批量读取所有规范 taskId 的 runtime;开发窗口和项目内 Agent 对话弹窗的 Runtime 状态面板展示最近事件、最近任务、当前目标、当前任务、当前动作、等待对象、下一步和运行阶段,主窗口 Agent 状态列表展示当前目标、任务、动作、等待对象和运行阶段摘要。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 已有运行任务时,新任务会先写成 `pending / queued`,由当前后台 drain 在完成后串行继续执行。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程或跨重启离线常驻 worker。
- 2026-07-10 补充:Agent Runtime state 新增 `toolPolicy`,按当前项目 `.agent/policy.json` 派生工具级 `allowedTools / autoTools / confirmTools / deniedTools` 快照;后台 planning prompt 会带入该快照,让 Agent 在规划时知道哪些工具会自动执行、需要确认或被拒绝。`blackboard.write` 继承 `memory.write` 策略,`agent.message` 继承 `conversation.write` 策略,`agent.delegate` 使用独立 `agent.delegate` 策略;实际执行仍以 Runtime 的白名单和项目权限 gate 为准。
- 2026-07-10 补充:`.agent/policy.json` 新增 `agentPolicies`,可按规范 Agent id 分别配置 `deniedCommands / confirmCommands`。有效策略为“项目级策略 + Agent 级策略”的保守叠加:项目级拒绝 / 确认仍对所有 Agent 生效,Agent 级策略只能进一步限制该 Agent,不能放宽项目级策略,拒绝优先于确认。主聊天新增 `/agent-policy-deny Agent 命令``/agent-policy-allow Agent 命令``/agent-policy-confirm Agent 命令``/agent-policy-auto Agent 命令`,写入前仍走 `project.policy_write` 确认卡。
- 2026-07-10 补充:Agent Runtime state 新增 `recentToolCalls`,每次后台工具执行后记录最近 20 条结构化工具动作,包含 tool、status、reason、summary、detail 和 updatedAt;开发窗口、项目内 Agent 对话弹窗和主窗口 Agent 状态列表可直接展示“最近动作”,不再只能从 observation 字符串里猜测 action / observation 对应关系。字段只保存过滤后的摘要和观察细节,不保存原始 API Key 或任意未过滤输入。
- 2026-07-10 补充:Agent Runtime state 新增 `currentGoal``waitingOn`,把本轮目标与当前等待对象从 `currentTask / currentAction / nextStep` 中显式拆出来;后台任务启动、工具 observation、完成和失败都会刷新该状态,开发窗口、项目内 Agent 对话弹窗、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示同一份目标 / 等待信息,避免开发者只能从动作文本里猜 Agent 卡在 LLM、工具、同伴还是人工输入。
- 2026-07-10 补充:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`,结构化记录后台 Agent loop 当前轮次、最大轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,帮助判断 Agent 是刚开始规划、正在 replan,还是接近本轮 loop 上限。该字段只做运行观测,不改变后台 loop 的执行上限或工具权限。