支持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(
/Agent:art-director 拒绝:无 确认:memory\.write;design-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();