立项策划:收口 M1A-2 工具面与角色提示
收紧 planning 子 Agent 原生工具目录与 MCP/web search 边界 增加静态委派父根身份与 fail-closed 执行校验 补齐 planning Prompt Bundle、final-reply 终态约束与解析层拒绝 保留项目权限 deny/confirm 并同步恢复归一化策略 同步技术方案与项目决策记录
This commit is contained in:
@@ -303,6 +303,7 @@ pub fn compile_manifest(manifest_path: &Path) -> Result<CompiledPromptBundle, St
|
||||
.supervisor
|
||||
.roles
|
||||
.iter()
|
||||
.chain(manifest.agent_catalog.planning.roles.iter())
|
||||
.chain(
|
||||
manifest
|
||||
.agent_catalog
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"supervisorVisualWithEditor": "supervisor/visual-contract-with-editor.md",
|
||||
"supervisorPlaybook": "supervisor/playbook.md",
|
||||
"supervisorClaimGate": "supervisor/claim-gate.md",
|
||||
"supervisorRepair": "supervisor/repair.md"
|
||||
"supervisorRepair": "supervisor/repair.md",
|
||||
"projectPlanningRoleBrief": "roles/project-planning.md"
|
||||
},
|
||||
"compositions": {
|
||||
"runtime": [
|
||||
@@ -74,6 +75,12 @@
|
||||
"sections": [
|
||||
"codePrototypeGameChat"
|
||||
]
|
||||
},
|
||||
{
|
||||
"agentId": "project-planning",
|
||||
"sections": [
|
||||
"projectPlanningRoleBrief"
|
||||
]
|
||||
}
|
||||
],
|
||||
"providerFragments": {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
你是“立项策划 Agent”(`agentId=project-planning`),由 Project Supervisor 通过静态 `agent.delegate` 委派。你的工作是把一句用户需求收敛成可审批的 MVP Fast GDD;你只负责玩法澄清、原型验证建议和最小 GDD,不负责完整游戏构建。
|
||||
|
||||
## 身份与边界
|
||||
|
||||
- 当前 run 固定为 `source=agent-delegate`、`profile=standard`,父 Agent 是 `project-supervisor`。不得伪造、改写或猜测这些 Runtime 身份。
|
||||
- 你不能委派或调度其他 Agent,不能创建 isolated child,不能调用 MCP、命令、进程、预览、画布、素材生成、写入/补丁/删除工具,也不能改变项目版本或审批事实。
|
||||
- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 的单题问题,交由 Supervisor 转发。
|
||||
- 只有 Runtime 广告并允许 `plan.submit_gdd` 时才可提交 GDD;不要假设未广告的工具存在,也不要把 GDD、审批或下游构建写进普通文本。
|
||||
|
||||
## 目标与轮次
|
||||
|
||||
- 除非用户明确说“直接出稿”,最多进行 3 轮关键澄清;每轮是新 run、同一 session。你看得到自己的历史,但用户答案以 Supervisor 委派任务中的转述为准,缺失信息不能臆造。
|
||||
- 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定;达到第 3 轮、剩余问题不影响首个可玩闭环或 Runtime 提示接近活跃预算时,直接整理并提交。
|
||||
- 决策卡的 header 固定为“第N轮·关键决定”,正文以“当前要决定:”开头,必须说明为什么现在问、推荐方案、好处和代价;选项固定为“接受推荐”“暂按推荐”“需要原型验证”,自由输入按用户原话处理。
|
||||
|
||||
## 低幻觉与 GDD 约束
|
||||
|
||||
- 用户明确提供或接受的内容标 `confirmed`;推荐但未确认的内容标 `default_pending`;需要靠手感、节奏、可读性或重玩行为证明的内容标 `prototype_pending`,并给出 30~90 分钟微型原型、观察信号和通过标准。
|
||||
- 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。
|
||||
- GDD 至少覆盖:游戏名称与类型、一句话描述、2~4 条游戏支柱、核心循环、目标用户、美术方向、3~6 个最小 MVP 系统、先做/暂缓/验证/扩展条件、决定状态和审批请求。不要把 Runtime 注入的身份、时间、指纹、审批 receipt 或平台事实当作 Provider 输入字段。
|
||||
- 平台事实由 Runtime 固定注入为自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览;不得修改、删减或向用户询问。
|
||||
|
||||
## 输出纪律
|
||||
|
||||
- 澄清模式只返回 `AGC_NEEDS_USER_INPUT_V1` 终态信封,不再调用其他函数;成稿模式只在 `plan.submit_gdd` 被广告时调用它并等待 Runtime 校验;收到 revise/reject observation 后按同一 GDD 谱系修订,收到 approve 后只做简短收尾。
|
||||
- 必须直接调用当前请求广告的原生函数;不要输出 JSON、代码围栏或内部思考过程,不要假装已经写入文件、完成审批或启动构建。
|
||||
@@ -526,6 +526,9 @@ fn game_creator_art_asset_plan_tool_plan_prompt(
|
||||
pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent(
|
||||
agent_id: &str,
|
||||
) -> String {
|
||||
if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
return game_creator_project_planning_tool_plan_system_prompt();
|
||||
}
|
||||
let prompt = game_creator_agent_runtime_tool_plan_system_prompt();
|
||||
if agent_id == "design-foundation" {
|
||||
return game_creator_design_foundation_tool_plan_prompt(
|
||||
@@ -548,6 +551,19 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent(
|
||||
game_creator_project_supervisor_tool_plan_prompt(&prompt, editor_api_key_is_configured())
|
||||
}
|
||||
|
||||
/// The planning child has an exact native allowlist. Do not reuse the broad
|
||||
/// runtime composition here: its common section contains examples for
|
||||
/// mutation, commands, previews, delegation and user-input actions that are
|
||||
/// not present in the planning request's function catalog. Keeping this
|
||||
/// prompt deliberately small makes the advertised surface and the textual
|
||||
/// contract agree; the role-specific Fast GDD brief is appended by the
|
||||
/// Provider request builder after the identity binding has been checked.
|
||||
fn game_creator_project_planning_tool_plan_system_prompt() -> String {
|
||||
format!(
|
||||
"你正在使用 Genarrative AI 游戏创作多智能体 Runtime。当前请求只广告以下原生函数:file.read、file.list、update_agent_plan、respond_to_user。只能直接调用这些函数;不得调用未广告的函数、动态工具或普通文本伪造工具调用。\n\n读取工具只用于获取项目内已有文本和文件摘要;不要把读取结果当作已经写入、提交、审批或构建完成。需要记录真实计划变化时调用 update_agent_plan,arguments 必须提交完整 steps;已有足够 observation、需要交付终态信封或当前轮次应收束时调用 respond_to_user。Runtime 身份、审批事实、项目版本和平台事实均由系统维护,不得自行生成或修改。"
|
||||
)
|
||||
}
|
||||
|
||||
fn game_creator_project_supervisor_tool_plan_prompt(
|
||||
prompt: &str,
|
||||
editor_api_key_is_configured: bool,
|
||||
@@ -818,6 +834,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_planning_role_brief_is_isolated_to_its_manifest_overlay() {
|
||||
let planning = game_creator_agent_runtime_role_overlay_prompt(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
None,
|
||||
);
|
||||
assert!(planning.contains("立项策划 Agent"));
|
||||
assert!(planning.contains("AGC_NEEDS_USER_INPUT_V1"));
|
||||
assert!(game_creator_agent_runtime_role_overlay_prompt(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
None,
|
||||
)
|
||||
.is_empty());
|
||||
assert!(
|
||||
game_creator_agent_runtime_role_overlay_prompt("design-foundation", None).is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_prompt_tool_catalog_tracks_the_native_capability_registry() {
|
||||
let prompt = game_creator_agent_runtime_tool_plan_system_prompt();
|
||||
|
||||
@@ -72,7 +72,8 @@ pub(crate) use autonomous_policy::{
|
||||
pub(crate) use context_compaction::compact_game_creator_agent_runtime_session_at;
|
||||
pub(crate) use parallel_ledger::{
|
||||
agent_runtime_confirmation_path_component, agent_runtime_parallel_read_batch_len,
|
||||
agent_runtime_tool_is_parallel_safe_read, game_creator_agent_runtime_parallel_read_batch_path,
|
||||
agent_runtime_tool_allowed_for_agent, agent_runtime_tool_is_parallel_safe_read,
|
||||
game_creator_agent_runtime_parallel_read_batch_path,
|
||||
game_creator_agent_runtime_pending_tool_action_path,
|
||||
game_creator_agent_runtime_provider_action_batch_path,
|
||||
};
|
||||
@@ -138,6 +139,7 @@ pub(crate) use tool_plan_protocol::{
|
||||
parse_game_creator_agent_tool_plan_llm_response,
|
||||
parse_game_creator_agent_tool_plan_llm_response_with_catalog,
|
||||
parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified,
|
||||
parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent,
|
||||
parse_game_creator_agent_tool_plan_response,
|
||||
};
|
||||
pub(crate) use tool_policy_snapshot::{
|
||||
|
||||
@@ -37,6 +37,16 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
|
||||
pending_action: Option<&AgentRuntimePendingToolAction>,
|
||||
) -> AgentRuntimeToolObservation {
|
||||
let tool = action.tool.trim();
|
||||
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
|
||||
&& !matches!(tool, "file.read" | "file.list")
|
||||
{
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: tool.to_string(),
|
||||
status: "rejected".to_string(),
|
||||
summary: "当前 Agent 身份不允许执行该工具".to_string(),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
let action_fingerprint = pending_action
|
||||
.map(|pending| {
|
||||
agent_runtime_pending_tool_action_fingerprint(
|
||||
|
||||
@@ -41,6 +41,9 @@ pub(in crate::agent) fn agent_runtime_parallel_read_batch_is_auto_at(
|
||||
actions: &[AgentRuntimeToolAction],
|
||||
) -> bool {
|
||||
actions.iter().all(|action| {
|
||||
if !agent_runtime_tool_allowed_for_agent(agent_id, action.tool.trim()) {
|
||||
return false;
|
||||
}
|
||||
let Some(command_id) = game_creator_agent_runtime_tool_command_id(action.tool.trim())
|
||||
else {
|
||||
return false;
|
||||
@@ -109,6 +112,48 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the original provider/runtime tool identity before translating it to
|
||||
/// a project permission command. Some tools intentionally share a command
|
||||
/// id (for example `project.search` and `file.read`); policy lookup alone must
|
||||
/// not turn that aliasing into an identity escalation for a restricted Agent.
|
||||
pub(crate) fn agent_runtime_tool_allowed_for_agent(agent_id: &str, tool: &str) -> bool {
|
||||
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
return matches!(tool.trim(), "file.read" | "file.list");
|
||||
}
|
||||
if tool.trim() == GAME_CREATOR_USER_INPUT_REQUEST_TOOL {
|
||||
// `user.input_request` is a protocol control handled by the main
|
||||
// loop, not by the command-id policy map. It remains available to
|
||||
// standard Agents and is separately denied for autonomous profiles.
|
||||
return true;
|
||||
}
|
||||
game_creator_agent_runtime_tool_command_id(tool.trim()).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod identity_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn planning_identity_does_not_inherit_project_search_alias() {
|
||||
assert!(agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"file.read"
|
||||
));
|
||||
assert!(agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"file.list"
|
||||
));
|
||||
assert!(!agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"project.search"
|
||||
));
|
||||
assert!(agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"project.search"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agent_runtime_confirmation_path_component(value: &str, fallback: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
|
||||
@@ -140,6 +140,14 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_parallel_read_pendin
|
||||
root, &error,
|
||||
)));
|
||||
}
|
||||
if !agent_runtime_tool_allowed_for_agent(&pending.agent_id, &pending.action.tool) {
|
||||
return Ok(Some(agent_runtime_tool_policy_block_observation(
|
||||
&pending.action.tool,
|
||||
AgentRuntimeToolPolicyBlock::Denied(
|
||||
"当前 Agent 身份不允许执行该原始工具".to_string(),
|
||||
),
|
||||
)));
|
||||
}
|
||||
if let Some(observation) = pending_repository_context_drift_observation(root, pending)? {
|
||||
return Ok(Some(observation));
|
||||
}
|
||||
@@ -313,6 +321,12 @@ pub(in crate::agent) fn prepare_and_execute_game_creator_agent_runtime_parallel_
|
||||
{
|
||||
return Ok(AgentRuntimeParallelReadBatchExecution::NotEligible);
|
||||
}
|
||||
if !actions
|
||||
.iter()
|
||||
.all(|action| agent_runtime_tool_allowed_for_agent(&runtime.agent_id, &action.tool))
|
||||
{
|
||||
return Ok(AgentRuntimeParallelReadBatchExecution::NotEligible);
|
||||
}
|
||||
let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
&root,
|
||||
"runtime.parallel_read_batch",
|
||||
|
||||
+11
-1
@@ -473,6 +473,15 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
|
||||
None,
|
||||
)?;
|
||||
let command_id = game_creator_agent_runtime_tool_command_id(action.tool.trim());
|
||||
let identity_block = (!agent_runtime_tool_allowed_for_agent(
|
||||
&runtime.agent_id,
|
||||
action.tool.trim(),
|
||||
))
|
||||
.then(|| {
|
||||
AgentRuntimeToolPolicyBlock::Denied(
|
||||
"当前 Agent 身份不允许执行该原始工具".to_string(),
|
||||
)
|
||||
});
|
||||
let game_chat_art_scope_block = game_chat_delegated_art_agent_input_mutation_block(
|
||||
root,
|
||||
&runtime.agent_id,
|
||||
@@ -493,7 +502,8 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let local_policy_block = game_chat_art_scope_block
|
||||
let local_policy_block = identity_block
|
||||
.or(game_chat_art_scope_block)
|
||||
.or(isolated_scope_block)
|
||||
.or_else(|| {
|
||||
command_id
|
||||
|
||||
+231
-16
@@ -4,6 +4,8 @@ use platform_llm::LlmFunctionTool;
|
||||
|
||||
const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止调用 respond_to_user;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。";
|
||||
|
||||
const GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT: &str = "你是 Genarrative 的立项策划 Agent final-reply 收束器。你只能依据当前请求中明确提供的后台任务、运行中用户追加指令、收束摘要和已获准工具 observation 作答;不得使用通用角色聊天人格,也不得补充这些材料之外的项目事实。不要声称已经写入文件、提交 GDD、获得审批、生成素材、构建或验证完成;不要声称调用了未出现在 observation 中的工具,也不要把建议当成用户确认。若收束摘要或 observation 中已有 AGC_NEEDS_USER_INPUT_V1 终态信封,必须保留其首行和下一行严格 JSON 问题信封(只去除外围空白),不得改写、翻译、包装成普通中文或追加解释。若当前需要用户决定而尚无完整信封,只能输出 AGC_NEEDS_USER_INPUT_V1 首行,下一行输出 Runtime 可解析的严格 {\"questions\":[...]} JSON;不得输出 markdown、代码围栏或第三行正文。没有用户输入需求时,只简洁总结已观察到的策划结论、confirmed/default_pending/prototype_pending 状态、未完成事项和下一步,明确审批或构建尚未发生。回复保持中文。";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum AgentBackgroundContextMode {
|
||||
ToolPlan,
|
||||
@@ -104,6 +106,10 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
|
||||
loop_index: usize,
|
||||
mcp_catalog: &GameCreatorMcpCatalog,
|
||||
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest, String), String> {
|
||||
let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID;
|
||||
if planning_agent {
|
||||
validate_project_planning_child_binding_at(root, agent_id, run_id)?;
|
||||
}
|
||||
let effective_task = autonomous_effective_root_task_at(root, agent_id, run_id, task)?;
|
||||
let (llm, config_path, context, repository_context_fingerprint, prompt_observations) =
|
||||
build_game_creator_background_agent_context(
|
||||
@@ -188,7 +194,9 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
|
||||
"deniedTools": denied_tools,
|
||||
}))
|
||||
.map_err(|error| format!("序列化 Agent 工具策略失败:{error}"))?;
|
||||
let collaboration_policy_json = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
let collaboration_policy_json = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
"null".to_string()
|
||||
} else if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
render_supervisor_collaboration_policy_for_prompt_at(root, agent_id, run_id)?
|
||||
} else {
|
||||
"null".to_string()
|
||||
@@ -224,7 +232,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
|
||||
};
|
||||
let steers_json =
|
||||
render_game_creator_agent_runtime_steers_for_prompt(root, agent_id, session_id, run_id)?;
|
||||
let mcp_catalog_json = render_game_creator_mcp_catalog_for_prompt(mcp_catalog)?;
|
||||
let mcp_catalog_json = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
"[]".to_string()
|
||||
} else {
|
||||
render_game_creator_mcp_catalog_for_prompt(mcp_catalog)?
|
||||
};
|
||||
let loop_index = loop_index.saturating_add(1);
|
||||
let context_preload_notice = game_creator_agent_context_preload_notice(agent_id);
|
||||
let canvas_asset_kind_catalog = AGENT_RUNTIME_CANVAS_ASSET_KINDS.join("|");
|
||||
@@ -317,9 +329,46 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
|
||||
"{prompt}\n\n持久进程协议:{command_start_contract};args 中的项目路径必须相对 cwd,禁止绝对路径、file URI、路径加行号以及把绝对路径嵌入脚本或说明文字。默认需要精确确认;它只用于已经从仓库清单确认需要持续交互的长进程,有限诊断、文件探测、构建和测试必须使用 command.exec,不得用 command.start 试错。成功后保存 observation 返回的 processId 和 cursor;同一服务后续只能沿该 processId 继续,不得为探测、重试、交互或停止另起 process session。command.poll 使用 {{\"processId\":\"proc-...\",\"cursor\":null,\"maxChars\":8000,\"waitMs\":1000}};首次调用必须显式传 cursor=null,后续把上一页 nextCursor 原样传入 cursor,并按 nextCursor 增量读取,不要无等待忙轮询。command.stdin 使用 {{\"processId\":\"proc-...\",\"data\":\"UTF-8 文本\",\"appendNewline\":true,\"eof\":false}},正文会写入 PTY 且默认需要确认;command.terminate 使用 {{\"processId\":\"proc-...\",\"cursor\":\"最后一次 poll 的 nextCursor\"}} 并默认需要确认,terminate 不消费输出,后续继续用它返回的同一 nextCursor poll 终态。command.start 会推进 revision 但永远不能签发验证凭证;当前 run 的进程会话必须 poll 到可信终态,或先 terminate 再 poll,才能调用 respond_to_user 收束;needs-reconciliation 只能等待人工核对,不能重启、按 PID 重连或假装已退出。"
|
||||
);
|
||||
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
|
||||
let protocol_prompt = format!(
|
||||
"必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,需要行动时调用对应动作工具,已有观察足够时调用 respond_to_user。只有步骤或状态真实变化时才单独调用 update_agent_plan;当前 in_progress 步骤已具备执行条件时必须在同一响应调用对应动作工具,不能只改计划解释。不要调用未广告的旧 submit_agent_tool_plan,也不要把计划或动作放在普通文本中。\n\n{AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL}"
|
||||
);
|
||||
let protocol_prompt = if planning_agent {
|
||||
"必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,已有观察足够或需要交付终态信封时调用 respond_to_user。不要调用未广告的函数,也不要把计划、动作或回复放在普通文本中。"
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,需要行动时调用对应动作工具,已有观察足够时调用 respond_to_user。只有步骤或状态真实变化时才单独调用 update_agent_plan;当前 in_progress 步骤已具备执行条件时必须在同一响应调用对应动作工具,不能只改计划解释。不要调用未广告的旧 submit_agent_tool_plan,也不要把计划或动作放在普通文本中。\n\n{AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL}"
|
||||
)
|
||||
};
|
||||
let prompt = if planning_agent {
|
||||
format!(
|
||||
"当前 planning 子 Agent 只可调用 file.read、file.list、update_agent_plan、respond_to_user;未广告的函数一律不可调用。读取工具只用于获取已有项目文本和文件摘要,不代表已经写入、提交、审批或构建完成。\n\n运行上下文如下。只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n已有工具观察:\n{observations_json}\n\nfile.list 使用 {{\"path\":\"\"}};file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}}。arguments 外层严格为 {{\"reason\":\"为什么需要\",\"input\":{{...}}}}。不要输出普通文本来代替函数调用。"
|
||||
)
|
||||
} else {
|
||||
prompt
|
||||
};
|
||||
if planning_agent {
|
||||
let mut planning_system_prompt =
|
||||
game_creator_agent_runtime_tool_plan_system_prompt_for_agent(agent_id);
|
||||
let role_brief = game_creator_agent_runtime_role_overlay_prompt(agent_id, None);
|
||||
if !role_brief.is_empty() {
|
||||
planning_system_prompt.push_str("\n\n");
|
||||
planning_system_prompt.push_str(&role_brief);
|
||||
}
|
||||
let request = LlmRunRequest::new(vec![
|
||||
LlmMessage::system(planning_system_prompt),
|
||||
LlmMessage::user(prompt),
|
||||
LlmMessage::user(protocol_prompt),
|
||||
])
|
||||
.with_api_kind(api_kind)
|
||||
.with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS)
|
||||
.with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low)
|
||||
.with_function_tools(build_agent_runtime_native_function_tools_for_agent(
|
||||
agent_id,
|
||||
mcp_catalog,
|
||||
)?)
|
||||
.with_tool_choice(platform_llm::LlmToolChoice::Required);
|
||||
let request = apply_game_creator_llm_reasoning_effort(request, &llm)?
|
||||
.with_web_search(false);
|
||||
return Ok((llm, config_path, request, repository_context_fingerprint));
|
||||
}
|
||||
let mut system_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent(agent_id);
|
||||
if autonomous_game_build {
|
||||
system_prompt.push_str("\n\n");
|
||||
@@ -380,7 +429,10 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
|
||||
.with_api_kind(api_kind)
|
||||
.with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS)
|
||||
.with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low)
|
||||
.with_function_tools(build_agent_runtime_native_function_tools(mcp_catalog)?)
|
||||
.with_function_tools(build_agent_runtime_native_function_tools_for_agent(
|
||||
agent_id,
|
||||
mcp_catalog,
|
||||
)?)
|
||||
.with_tool_choice(platform_llm::LlmToolChoice::Required);
|
||||
if runtime_owner_artifact_validation_available {
|
||||
remove_autonomous_owner_manual_verification_tools(&mut request.function_tools)?;
|
||||
@@ -466,11 +518,12 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
|
||||
"当前根 Run 尚未冻结 Goal Contract。本轮唯一可用工具是 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本。",
|
||||
));
|
||||
}
|
||||
request = apply_game_creator_llm_web_search(
|
||||
apply_game_creator_llm_reasoning_effort(request, &llm)?,
|
||||
&llm,
|
||||
true,
|
||||
)?;
|
||||
let mut request = apply_game_creator_llm_reasoning_effort(request, &llm)?;
|
||||
request = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
request.with_web_search(false)
|
||||
} else {
|
||||
apply_game_creator_llm_web_search(request, &llm, true)?
|
||||
};
|
||||
Ok((llm, config_path, request, repository_context_fingerprint))
|
||||
}
|
||||
|
||||
@@ -506,15 +559,27 @@ pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request(
|
||||
.map_err(|error| format!("序列化 Agent 收束摘要失败:{error}"))?;
|
||||
let steers_json =
|
||||
render_game_creator_agent_runtime_steers_for_prompt(root, agent_id, session_id, run_id)?;
|
||||
let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID;
|
||||
if planning_agent {
|
||||
validate_project_planning_child_binding_at(root, agent_id, run_id)?;
|
||||
}
|
||||
let audience = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
"用户"
|
||||
} else {
|
||||
"开发者"
|
||||
};
|
||||
let prompt = format!(
|
||||
"运行上下文如下。请只依据后台任务、运行中用户追加指令、收束摘要和已获准工具返回的 observation,给{audience}一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}"
|
||||
);
|
||||
let system_prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
let prompt = if planning_agent {
|
||||
format!(
|
||||
"当前是 project-planning 子 Agent 的 final-reply 收束请求。只依据下列后台任务、运行中用户追加指令、收束摘要和已获准工具 observation。若收束摘要或 observation 已包含 AGC_NEEDS_USER_INPUT_V1 信封,逐字保留其首行与下一行严格 JSON;不要改写问题,不要输出普通解释。若没有完整信封且仍缺少用户决定,只输出可解析的 AGC_NEEDS_USER_INPUT_V1 信封;否则只总结已观察到的策划结论和未完成事项。\n\n运行上下文:\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"运行上下文如下。请只依据后台任务、运行中用户追加指令、收束摘要和已获准工具返回的 observation,给{audience}一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}"
|
||||
)
|
||||
};
|
||||
let system_prompt = if planning_agent {
|
||||
GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT
|
||||
} else if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||
game_creator_project_supervisor_chat_system_prompt()
|
||||
} else {
|
||||
game_creator_role_agent_chat_system_prompt()
|
||||
@@ -529,6 +594,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request(
|
||||
.with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low),
|
||||
&llm,
|
||||
)?;
|
||||
let request = if planning_agent {
|
||||
request.with_web_search(false)
|
||||
} else {
|
||||
request
|
||||
};
|
||||
Ok((llm, config_path, request))
|
||||
}
|
||||
|
||||
@@ -671,9 +741,12 @@ mod tests {
|
||||
AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation,
|
||||
AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool,
|
||||
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
|
||||
GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT,
|
||||
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
||||
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
|
||||
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
|
||||
};
|
||||
|
||||
@@ -1562,6 +1635,148 @@ mod tests {
|
||||
assert!(function.parameters.pointer("/properties/reason").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_planning_brief_is_injected_only_for_standard_delegate_child() {
|
||||
let directory = crate::tests::canonical_test_tempdir("planning-role-brief-provider-");
|
||||
let root = directory.path().join("project");
|
||||
init_local_game_project_at(&root, "planning-role-brief", "立项策划 brief 注入测试")
|
||||
.expect("project init");
|
||||
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
|
||||
let parent = bind_game_creator_agent_runtime_run_profile_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"planning-role-brief-parent",
|
||||
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
|
||||
None,
|
||||
)
|
||||
.expect("bind planning parent");
|
||||
let child = bind_game_creator_agent_runtime_run_profile_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"planning-role-brief-child",
|
||||
"agent-delegate",
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
|
||||
Some(&AgentRuntimeTaskLink {
|
||||
parent_agent_id: Some(parent.agent_id.clone()),
|
||||
parent_run_id: Some(parent.run_id.clone()),
|
||||
delegation_id: Some("planning-role-brief-delegation".to_string()),
|
||||
}),
|
||||
)
|
||||
.expect("bind planning child");
|
||||
let planning_state = start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"围绕用户需求形成 Fast GDD",
|
||||
&child.run_id,
|
||||
"agent-delegate",
|
||||
"构建 planning request",
|
||||
vec!["读取需求并准备澄清".to_string()],
|
||||
)
|
||||
.expect("start planning child");
|
||||
let catalog = GameCreatorMcpCatalog {
|
||||
fingerprint: String::new(),
|
||||
servers: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
};
|
||||
let (_, _, planning_request, _) = build_game_creator_agent_background_tool_plan_request(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
&planning_state.session_id,
|
||||
&planning_state.run_id,
|
||||
&planning_state.current_task,
|
||||
&[],
|
||||
0,
|
||||
&catalog,
|
||||
)
|
||||
.expect("build planning request");
|
||||
let planning_system_prompt = &planning_request.messages[0].content;
|
||||
let planning_brief_marker = "你是“立项策划 Agent”(`agentId=project-planning`)";
|
||||
assert!(planning_system_prompt.contains(planning_brief_marker));
|
||||
assert!(planning_system_prompt.contains("当前请求只广告以下原生函数"));
|
||||
assert!(!planning_system_prompt.contains("Runtime 当前注册的原生可执行工具:"));
|
||||
let planning_prompt_text = planning_request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| message.content.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
for leaked_contract in [
|
||||
"project.search 使用",
|
||||
"project.patchset 的每个 change",
|
||||
"file.write 使用",
|
||||
"command.exec 使用",
|
||||
"preview.validate 使用",
|
||||
"当前 MCP 动态工具目录",
|
||||
"持久进程协议",
|
||||
] {
|
||||
assert!(
|
||||
!planning_prompt_text.contains(leaked_contract),
|
||||
"planning prompt 不得注入通用工具契约:{leaked_contract}"
|
||||
);
|
||||
}
|
||||
assert!(planning_prompt_text.contains("file.read 使用"));
|
||||
assert!(planning_prompt_text.contains("file.list 使用"));
|
||||
|
||||
let planning_plan = AgentRuntimeToolPlan {
|
||||
thinking_summary: "等待用户确认核心循环".to_string(),
|
||||
plan_update: None,
|
||||
plan: Vec::new(),
|
||||
actions: Vec::new(),
|
||||
response: "AGC_NEEDS_USER_INPUT_V1\n{\"questions\":[{\"id\":\"core_loop\"}]}".to_string(),
|
||||
};
|
||||
let (_, _, planning_final_request) = build_game_creator_agent_background_final_reply_request(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
&planning_state.session_id,
|
||||
&planning_state.run_id,
|
||||
&planning_state.current_task,
|
||||
&planning_plan,
|
||||
&[],
|
||||
)
|
||||
.expect("build planning final reply request");
|
||||
assert_eq!(
|
||||
planning_final_request.messages[0].content,
|
||||
GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT
|
||||
);
|
||||
assert!(!planning_final_request.enable_web_search);
|
||||
let planning_final_prompt = planning_final_request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| message.content.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(planning_final_prompt.contains("AGC_NEEDS_USER_INPUT_V1"));
|
||||
assert!(planning_final_prompt.contains("不要声称已经写入文件"));
|
||||
assert!(!planning_final_prompt.contains("正常中文回复"));
|
||||
assert!(!planning_final_prompt.contains("你拥有最终回复权"));
|
||||
|
||||
let supervisor_state = start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"委派立项策划子 Agent",
|
||||
&parent.run_id,
|
||||
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
|
||||
"构建 supervisor planning request",
|
||||
vec!["准备委派".to_string()],
|
||||
)
|
||||
.expect("start supervisor");
|
||||
let (_, _, supervisor_request, _) = build_game_creator_agent_background_tool_plan_request(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&supervisor_state.session_id,
|
||||
&supervisor_state.run_id,
|
||||
&supervisor_state.current_task,
|
||||
&[],
|
||||
0,
|
||||
&catalog,
|
||||
)
|
||||
.expect("build supervisor request");
|
||||
assert!(!supervisor_request.messages[0]
|
||||
.content
|
||||
.contains(planning_brief_marker));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_blocker_protocol_requires_tool_repair_before_response() {
|
||||
let protocol = AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL;
|
||||
|
||||
+16
-3
@@ -408,7 +408,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
|
||||
Sha256::digest(response_handoff.provider_request_id.as_bytes())
|
||||
);
|
||||
let mut supervisor_collaboration_candidate_actions = None;
|
||||
let parsed = parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified(
|
||||
let parsed = parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent(
|
||||
agent_id,
|
||||
&response,
|
||||
&mcp_catalog,
|
||||
)
|
||||
@@ -877,7 +878,16 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
|
||||
agent_runtime_protocol_error_requires_supervisor_collaboration_repair(
|
||||
&protocol_error,
|
||||
) && !request.function_tools.is_empty();
|
||||
if force_root_goal_contract
|
||||
if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
request.function_tools =
|
||||
build_agent_runtime_native_function_tools_for_agent(
|
||||
agent_id,
|
||||
&mcp_catalog,
|
||||
)?;
|
||||
request.messages.push(LlmMessage::user(format!(
|
||||
"上一条输出不符合 planning 工具计划协议:{protocol_error}\n本轮修复仍只允许调用 file.read、file.list、update_agent_plan、respond_to_user。不得调用或描述其它工具,不得输出普通文本来代替函数调用;需要用户决定时以 AGC_NEEDS_USER_INPUT_V1 终态信封收束。"
|
||||
)));
|
||||
} else if force_root_goal_contract
|
||||
|| force_supervisor_initial_collaboration
|
||||
|| force_autonomous_specialist_mutation_only
|
||||
|| force_autonomous_specialist_verification_only
|
||||
@@ -895,7 +905,10 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
|
||||
|| force_autonomous_pre_mutation
|
||||
{
|
||||
request.function_tools =
|
||||
build_agent_runtime_native_function_tools(&mcp_catalog)?;
|
||||
build_agent_runtime_native_function_tools_for_agent(
|
||||
agent_id,
|
||||
&mcp_catalog,
|
||||
)?;
|
||||
if runtime_owner_artifact_validation_available {
|
||||
remove_autonomous_owner_manual_verification_tools(
|
||||
&mut request.function_tools,
|
||||
|
||||
+62
-15
@@ -44,22 +44,34 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog(
|
||||
pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified(
|
||||
response: &platform_llm::LlmRunResponse,
|
||||
mcp_catalog: &GameCreatorMcpCatalog,
|
||||
) -> Result<ParsedAgentRuntimeToolPlan, AgentRuntimeToolPlanProtocolError> {
|
||||
parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent(
|
||||
"__all_agents__",
|
||||
response,
|
||||
mcp_catalog,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent(
|
||||
agent_id: &str,
|
||||
response: &platform_llm::LlmRunResponse,
|
||||
mcp_catalog: &GameCreatorMcpCatalog,
|
||||
) -> Result<ParsedAgentRuntimeToolPlan, AgentRuntimeToolPlanProtocolError> {
|
||||
if response.tool_calls.is_empty() {
|
||||
return parse_game_creator_agent_tool_plan_response_classified(response.text.as_str()).map(
|
||||
|plan| ParsedAgentRuntimeToolPlan {
|
||||
plan,
|
||||
protocol: "text_json",
|
||||
call_id: None,
|
||||
function_name: None,
|
||||
call_ids: Vec::new(),
|
||||
function_names: Vec::new(),
|
||||
normalization_kinds: Vec::new(),
|
||||
normalization_count: 0,
|
||||
normalized_text_chars: 0,
|
||||
normalized_text_sha256: None,
|
||||
},
|
||||
);
|
||||
let plan = parse_game_creator_agent_tool_plan_response_classified(response.text.as_str())?;
|
||||
validate_agent_runtime_tool_plan_identity(agent_id, &plan)?;
|
||||
return Ok(ParsedAgentRuntimeToolPlan {
|
||||
plan,
|
||||
protocol: "text_json",
|
||||
call_id: None,
|
||||
function_name: None,
|
||||
call_ids: Vec::new(),
|
||||
function_names: Vec::new(),
|
||||
normalization_kinds: Vec::new(),
|
||||
normalization_count: 0,
|
||||
normalized_text_chars: 0,
|
||||
normalized_text_sha256: None,
|
||||
});
|
||||
}
|
||||
let mut text_normalization =
|
||||
normalize_game_creator_agent_tool_plan_function_text(&response.text);
|
||||
@@ -81,6 +93,12 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_class
|
||||
if response.tool_calls.len() == 1
|
||||
&& response.tool_calls[0].name == AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME
|
||||
{
|
||||
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
return Err(AgentRuntimeToolPlanProtocolError::new(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction,
|
||||
"Agent 原生工具协议错误:project-planning 不允许旧 submit_agent_tool_plan 包装器",
|
||||
));
|
||||
}
|
||||
let call = &response.tool_calls[0];
|
||||
let plan = parse_game_creator_agent_tool_plan_payload(call.arguments.as_str(), true)
|
||||
.map_err(|error| {
|
||||
@@ -102,8 +120,13 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_class
|
||||
normalized_text_sha256: text_normalization.source_text_sha256,
|
||||
});
|
||||
}
|
||||
let native = parse_agent_runtime_native_tool_calls(&response.tool_calls, mcp_catalog)?;
|
||||
let native = parse_agent_runtime_native_tool_calls_for_agent(
|
||||
agent_id,
|
||||
&response.tool_calls,
|
||||
mcp_catalog,
|
||||
)?;
|
||||
let plan = normalize_game_creator_agent_tool_plan(native.plan)?;
|
||||
validate_agent_runtime_tool_plan_identity(agent_id, &plan)?;
|
||||
Ok(ParsedAgentRuntimeToolPlan {
|
||||
plan,
|
||||
protocol: "native_runtime_tools",
|
||||
@@ -118,6 +141,30 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_class
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_agent_runtime_tool_plan_identity(
|
||||
agent_id: &str,
|
||||
plan: &AgentRuntimeToolPlan,
|
||||
) -> Result<(), AgentRuntimeToolPlanProtocolError> {
|
||||
if agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(action) = plan
|
||||
.actions
|
||||
.iter()
|
||||
.find(|action| !agent_runtime_native_tool_allowed_for_agent(agent_id, &action.tool))
|
||||
{
|
||||
return Err(AgentRuntimeToolPlanProtocolError::new(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction,
|
||||
format!(
|
||||
"Agent 原生工具协议错误:Agent {} 不允许调用 {}",
|
||||
agent_id.trim(),
|
||||
action.tool.trim()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(in crate::agent) struct AgentRuntimeToolPlanTextNormalization {
|
||||
pub(in crate::agent) visible_text: String,
|
||||
|
||||
+34
@@ -195,6 +195,40 @@ pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at(
|
||||
)?;
|
||||
snapshot.run_profile = run_profile.clone();
|
||||
snapshot.run_profile_binding_fingerprint = binding_fingerprint;
|
||||
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
validate_project_planning_child_binding_at(root, agent_id, run_id)?;
|
||||
// Planning is a delegated child. Never let normalization/recovery
|
||||
// repopulate the broad default policy for this identity.
|
||||
let exact = ["file.read", "file.list"];
|
||||
snapshot.allowed_tools.retain(|tool| exact.contains(&tool.as_str()));
|
||||
snapshot.auto_tools.retain(|tool| exact.contains(&tool.as_str()));
|
||||
snapshot.confirm_tools.retain(|tool| exact.contains(&tool.as_str()));
|
||||
// `snapshot_at` has already applied the project- and Agent-level
|
||||
// permission policy. Keep an exact-tool deny in that result instead
|
||||
// of replacing it with the ceiling's non-exact denies. Deny wins
|
||||
// over auto/confirm so a stale or hand-edited snapshot cannot
|
||||
// advertise a denied planning read as executable.
|
||||
let exact_denied = snapshot
|
||||
.denied_tools
|
||||
.iter()
|
||||
.filter(|tool| exact.contains(&tool.as_str()))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
snapshot
|
||||
.auto_tools
|
||||
.retain(|tool| !exact_denied.iter().any(|denied| denied == tool));
|
||||
snapshot
|
||||
.confirm_tools
|
||||
.retain(|tool| !exact_denied.iter().any(|denied| denied == tool));
|
||||
snapshot.denied_tools = exact_denied;
|
||||
snapshot.denied_tools.extend(
|
||||
agent_runtime_executable_tools()
|
||||
.into_iter()
|
||||
.filter(|tool| !exact.contains(tool))
|
||||
.map(str::to_string),
|
||||
);
|
||||
return Ok(snapshot);
|
||||
}
|
||||
if run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
|
||||
@@ -2662,12 +2662,19 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
|
||||
let mut pending_action = prepared_action
|
||||
.take()
|
||||
.expect("prepared user input action exists");
|
||||
if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
|
||||
if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
|| runtime.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
|
||||
{
|
||||
let reason = if runtime.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
"project-planning 子 Agent 不允许 user.input_request 进入等待态"
|
||||
} else {
|
||||
"自主构建 Run 的 user.input_request 绕过了 Provider action 预检,已拒绝进入等待态"
|
||||
};
|
||||
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
|
||||
&root,
|
||||
&mut runtime,
|
||||
&pending_action,
|
||||
"自主构建 Run 的 user.input_request 绕过了 Provider action 预检,已拒绝进入等待态",
|
||||
reason,
|
||||
);
|
||||
return AgentBackgroundTaskOutcome::NeedsReconciliation;
|
||||
}
|
||||
@@ -2689,6 +2696,18 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
|
||||
}
|
||||
return AgentBackgroundTaskOutcome::WaitingForUserInput;
|
||||
}
|
||||
if !agent_runtime_tool_allowed_for_agent(&agent_id, action.tool.trim()) {
|
||||
let pending_action = prepared_action
|
||||
.as_ref()
|
||||
.expect("prepared action exists for an identity-rejected tool");
|
||||
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
|
||||
&root,
|
||||
&mut runtime,
|
||||
pending_action,
|
||||
"当前 Agent 身份不允许执行该工具,已拒绝进入执行层",
|
||||
);
|
||||
return AgentBackgroundTaskOutcome::NeedsReconciliation;
|
||||
}
|
||||
let command_id = game_creator_agent_runtime_tool_command_id(action.tool.trim());
|
||||
let action_fingerprint = prepared_action
|
||||
.as_ref()
|
||||
|
||||
@@ -787,15 +787,21 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at(
|
||||
can_repair_terminal_receipt = true;
|
||||
}
|
||||
if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT {
|
||||
if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& !static_delegate_clarification_pending_matches_delivery_at(root, &pending)?
|
||||
let planning_agent = runtime.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID;
|
||||
if planning_agent
|
||||
|| (runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& !static_delegate_clarification_pending_matches_delivery_at(root, &pending)?)
|
||||
{
|
||||
let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending);
|
||||
mark_game_creator_agent_runtime_needs_reconciliation_at(
|
||||
root,
|
||||
&mut runtime,
|
||||
&pending,
|
||||
"自主构建 Run 恢复到 legacy waiting-for-user-input,已拒绝继续等待",
|
||||
if planning_agent {
|
||||
"project-planning 子 Agent 恢复到 waiting-for-user-input,已拒绝继续等待"
|
||||
} else {
|
||||
"自主构建 Run 恢复到 legacy waiting-for-user-input,已拒绝继续等待"
|
||||
},
|
||||
)?;
|
||||
return read_game_creator_agent_runtime_at(root, agent_id)
|
||||
.map(AgentRuntimePendingActionResume::Handled);
|
||||
|
||||
@@ -126,6 +126,83 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate the sole root identity that may dispatch the planning child under D11.
|
||||
///
|
||||
/// This deliberately does not infer authority from an in-memory runtime or from a
|
||||
/// source string alone. The durable binding must describe a top-level
|
||||
/// `project-supervisor-plan` standard run whose root fields point back to itself
|
||||
/// and which has no parent link.
|
||||
pub(in crate::agent) fn validate_project_supervisor_plan_root_binding_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> Result<AgentRuntimeRunProfileBinding, String> {
|
||||
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
|
||||
let run_id = run_id.trim();
|
||||
if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.is_empty() {
|
||||
return Err("project-planning 父 Run 必须是 project-supervisor 的非空根 Run".to_string());
|
||||
}
|
||||
let binding = read_game_creator_agent_runtime_run_profile_binding(root, &agent_id, run_id)?
|
||||
.ok_or_else(|| "project-planning 父 Run 缺少 Run Profile 绑定".to_string())?;
|
||||
if binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
|| binding.run_id != run_id
|
||||
|| binding.root_agent_id != binding.agent_id
|
||||
|| binding.root_run_id != binding.run_id
|
||||
|| binding.parent_agent_id.is_some()
|
||||
|| binding.parent_run_id.is_some()
|
||||
|| binding.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE
|
||||
|| binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD
|
||||
{
|
||||
return Err(
|
||||
"project-planning 父 Run 必须是 project-supervisor-plan standard 顶层根 Run".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(binding)
|
||||
}
|
||||
|
||||
/// Validate the exact D11 identity of the statically delegated planning child.
|
||||
///
|
||||
/// The parent binding is checked independently and the child's root IDs and
|
||||
/// parent-binding fingerprint are required to agree with it. Any missing,
|
||||
/// malformed, or cross-lineage binding fails closed.
|
||||
pub(in crate::agent) fn validate_project_planning_child_binding_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> Result<AgentRuntimeRunProfileBinding, String> {
|
||||
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
|
||||
let run_id = run_id.trim();
|
||||
if agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID || run_id.is_empty() {
|
||||
return Err("project-planning child 身份不匹配".to_string());
|
||||
}
|
||||
let binding = read_game_creator_agent_runtime_run_profile_binding(root, &agent_id, run_id)?
|
||||
.ok_or_else(|| "project-planning 缺少 Run Profile 绑定".to_string())?;
|
||||
if binding.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
|
||||
|| binding.run_id != run_id
|
||||
|| binding.source != "agent-delegate"
|
||||
|| binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD
|
||||
|| binding.parent_agent_id.as_deref() != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
|
||||
|| binding.parent_run_id.as_deref().is_none()
|
||||
{
|
||||
return Err("project-planning Run Profile 身份不符合静态委派合同".to_string());
|
||||
}
|
||||
let parent_agent_id = binding.parent_agent_id.as_deref().unwrap_or_default();
|
||||
let parent_run_id = binding.parent_run_id.as_deref().unwrap_or_default();
|
||||
let parent = validate_project_supervisor_plan_root_binding_at(
|
||||
root,
|
||||
parent_agent_id,
|
||||
parent_run_id,
|
||||
)?;
|
||||
if binding.root_agent_id != parent.root_agent_id
|
||||
|| binding.root_run_id != parent.root_run_id
|
||||
|| binding.parent_binding_fingerprint.as_deref()
|
||||
!= Some(parent.binding_fingerprint.as_str())
|
||||
{
|
||||
return Err("project-planning child 与 project-supervisor-plan 根 Run 身份不一致".to_string());
|
||||
}
|
||||
Ok(binding)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn read_game_creator_agent_runtime_run_profile_binding_once(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
|
||||
@@ -1615,7 +1615,7 @@ pub(crate) fn default_game_creator_agent_runtime_state(
|
||||
recent_tool_calls: Vec::new(),
|
||||
pending_tool_action: None,
|
||||
task_queue: AgentRuntimeTaskQueueSummary::default(),
|
||||
allowed_tools: default_game_creator_agent_runtime_allowed_tools(),
|
||||
allowed_tools: default_game_creator_agent_runtime_allowed_tools_for_agent(agent_id),
|
||||
tool_policy: AgentRuntimeToolPolicySnapshot::default(),
|
||||
applied_steer_cursor: 0,
|
||||
applied_steer_refs: Vec::new(),
|
||||
@@ -1662,6 +1662,56 @@ pub(crate) fn default_game_creator_agent_runtime_allowed_tools() -> Vec<String>
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return the durable Runtime tool surface for an Agent identity.
|
||||
///
|
||||
/// Delegated `project-planning` runs are intentionally narrower than the
|
||||
/// normal Runtime catalog. Keeping this decision in the state constructor
|
||||
/// prevents a freshly-created planning state from briefly advertising the
|
||||
/// broad catalog before its policy snapshot is hydrated.
|
||||
pub(crate) fn default_game_creator_agent_runtime_allowed_tools_for_agent(
|
||||
agent_id: &str,
|
||||
) -> Vec<String> {
|
||||
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
return vec!["file.read".to_string(), "file.list".to_string()];
|
||||
}
|
||||
default_game_creator_agent_runtime_allowed_tools()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod planning_state_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn planning_state_normalization_cannot_expand_tool_surface() {
|
||||
let mut state = default_game_creator_agent_runtime_state(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"planning-normalize-run",
|
||||
);
|
||||
state.allowed_tools = default_game_creator_agent_runtime_allowed_tools();
|
||||
state.tool_policy.allowed_tools = default_game_creator_agent_runtime_allowed_tools();
|
||||
state.tool_policy.auto_tools = default_game_creator_agent_runtime_allowed_tools();
|
||||
normalize_game_creator_agent_runtime_state(
|
||||
&mut state,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
);
|
||||
assert_eq!(
|
||||
state.allowed_tools,
|
||||
vec!["file.read".to_string(), "file.list".to_string()]
|
||||
);
|
||||
assert_eq!(state.tool_policy.allowed_tools, state.allowed_tools);
|
||||
assert!(state
|
||||
.tool_policy
|
||||
.denied_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "project.search"));
|
||||
assert!(state
|
||||
.tool_policy
|
||||
.denied_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "file.write"));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_game_creator_agent_runtime_state(
|
||||
state: &mut AgentRuntimeState,
|
||||
agent_id: &str,
|
||||
@@ -1772,7 +1822,16 @@ pub(super) fn normalize_game_creator_agent_runtime_state(
|
||||
state.next_step = "修复计划快照后恢复当前 run".to_string();
|
||||
state.error = Some(sanitize_agent_runtime_text(&error, 500));
|
||||
}
|
||||
if state.allowed_tools.is_empty() {
|
||||
let planning_agent = agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
|
||||
|| state.agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID;
|
||||
if planning_agent {
|
||||
// State hydration is an authority boundary. Never let an old or
|
||||
// caller-supplied full catalog expand a planning child back into a
|
||||
// general-purpose Agent.
|
||||
state.allowed_tools = default_game_creator_agent_runtime_allowed_tools_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
);
|
||||
} else if state.allowed_tools.is_empty() {
|
||||
state.allowed_tools = default_game_creator_agent_runtime_allowed_tools();
|
||||
} else {
|
||||
for tool in default_game_creator_agent_runtime_allowed_tools() {
|
||||
@@ -1784,7 +1843,43 @@ pub(super) fn normalize_game_creator_agent_runtime_state(
|
||||
if state.updated_at == 0 {
|
||||
state.updated_at = unix_timestamp();
|
||||
}
|
||||
if state.tool_policy.allowed_tools.is_empty() {
|
||||
if planning_agent {
|
||||
let exact = default_game_creator_agent_runtime_allowed_tools_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
);
|
||||
state.tool_policy.allowed_tools = exact.clone();
|
||||
// Recovery/normalization may receive a stale snapshot. Preserve
|
||||
// permission-derived denies for the exact planning tools, while the
|
||||
// planning ceiling keeps every other executable tool fail-closed.
|
||||
let exact_denied = state
|
||||
.tool_policy
|
||||
.denied_tools
|
||||
.iter()
|
||||
.filter(|tool| exact.iter().any(|allowed| allowed == *tool))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
state
|
||||
.tool_policy
|
||||
.auto_tools
|
||||
.retain(|tool| {
|
||||
exact.iter().any(|allowed| allowed == tool)
|
||||
&& !exact_denied.iter().any(|denied| denied == tool)
|
||||
});
|
||||
state
|
||||
.tool_policy
|
||||
.confirm_tools
|
||||
.retain(|tool| {
|
||||
exact.iter().any(|allowed| allowed == tool)
|
||||
&& !exact_denied.iter().any(|denied| denied == tool)
|
||||
});
|
||||
state.tool_policy.denied_tools = exact_denied;
|
||||
state.tool_policy.denied_tools.extend(
|
||||
agent_runtime_executable_tools()
|
||||
.into_iter()
|
||||
.filter(|tool| !exact.iter().any(|allowed| allowed == tool))
|
||||
.map(str::to_string),
|
||||
);
|
||||
} else if state.tool_policy.allowed_tools.is_empty() {
|
||||
state.tool_policy.allowed_tools = agent_runtime_executable_tools()
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
|
||||
@@ -458,6 +458,24 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
// D11 reserves the planning child for the exact top-level plan root. Do
|
||||
// this before parsing or persisting the delegation so forged source/profile
|
||||
// combinations cannot create a child that later looks like a valid plan
|
||||
// continuation.
|
||||
if target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
if let Err(error) = validate_project_supervisor_plan_root_binding_at(
|
||||
root,
|
||||
agent_id,
|
||||
parent_run_id,
|
||||
) {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "agent.delegate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: redact_agent_runtime_project_paths(root, &error, 240),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
let action_identity = action_id
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
|
||||
@@ -51,6 +51,34 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run(
|
||||
stored_binding_fingerprint: Option<&str>,
|
||||
command_id: &str,
|
||||
) -> Option<AgentRuntimeToolPolicyBlock> {
|
||||
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
if let Err(error) = agent_runtime_run_profile_identity_at(
|
||||
root,
|
||||
agent_id,
|
||||
run_id,
|
||||
stored_profile,
|
||||
stored_binding_fingerprint,
|
||||
) {
|
||||
return Some(AgentRuntimeToolPolicyBlock::Denied(error));
|
||||
}
|
||||
if let Err(error) = validate_project_planning_child_binding_at(root, agent_id, run_id) {
|
||||
return Some(AgentRuntimeToolPolicyBlock::Denied(error));
|
||||
}
|
||||
// Project/Agent permission policy remains authoritative even for the
|
||||
// narrower planning ceiling. Evaluate it before applying the exact
|
||||
// allowlist so a denied read cannot be turned into an auto action and
|
||||
// a confirmed read remains pending confirmation.
|
||||
let permission_block = game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id);
|
||||
if matches!(permission_block, Some(AgentRuntimeToolPolicyBlock::Denied(_))) {
|
||||
return permission_block;
|
||||
}
|
||||
if !matches!(command_id, "file.read" | "file.list") {
|
||||
return Some(AgentRuntimeToolPolicyBlock::Denied(format!(
|
||||
"project-planning exact 工具面拒绝:{command_id}"
|
||||
)));
|
||||
}
|
||||
return permission_block;
|
||||
}
|
||||
let blocked = game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id);
|
||||
if matches!(blocked, Some(AgentRuntimeToolPolicyBlock::Denied(_))) {
|
||||
return blocked;
|
||||
@@ -301,6 +329,15 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_block_after_lock(
|
||||
command_id: &str,
|
||||
pending_action: Option<&AgentRuntimePendingToolAction>,
|
||||
) -> Option<AgentRuntimeToolPolicyBlock> {
|
||||
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
|
||||
&& pending_action.is_some_and(|pending| {
|
||||
!agent_runtime_tool_allowed_for_agent(agent_id, &pending.action.tool)
|
||||
})
|
||||
{
|
||||
return Some(AgentRuntimeToolPolicyBlock::Denied(
|
||||
"当前 Agent 身份不允许执行该原始工具".to_string(),
|
||||
));
|
||||
}
|
||||
let blocked = match pending_action {
|
||||
Some(pending) => game_creator_agent_runtime_tool_policy_rule_for_run(
|
||||
root,
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::agent::{
|
||||
AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT,
|
||||
AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_LIMIT,
|
||||
};
|
||||
use crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID;
|
||||
use crate::mcp::{
|
||||
validate_game_creator_mcp_tool_arguments, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool,
|
||||
GAME_CREATOR_MCP_CALL_TOOL,
|
||||
@@ -278,6 +279,22 @@ pub(crate) fn native_mcp_function_name(server_id: &str, tool_name: &str) -> Stri
|
||||
|
||||
pub(crate) fn build_agent_runtime_native_function_tools(
|
||||
mcp_catalog: &GameCreatorMcpCatalog,
|
||||
) -> Result<Vec<LlmFunctionTool>, String> {
|
||||
build_agent_runtime_native_function_tools_for_agent(
|
||||
"__all_agents__",
|
||||
mcp_catalog,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the function catalog for a specific Agent identity.
|
||||
///
|
||||
/// `project-planning` is deliberately handled as an exact allowlist. The
|
||||
/// `plan.submit_gdd` capability is not registered yet (it belongs to M1B-2),
|
||||
/// so it must not be advertised here or added to the global capability
|
||||
/// registry prematurely. Protocol controls remain available to every Agent.
|
||||
pub(crate) fn build_agent_runtime_native_function_tools_for_agent(
|
||||
agent_id: &str,
|
||||
mcp_catalog: &GameCreatorMcpCatalog,
|
||||
) -> Result<Vec<LlmFunctionTool>, String> {
|
||||
let mut functions = vec![plan_update_function_tool(), response_function_tool()];
|
||||
let mut names = BTreeSet::from([
|
||||
@@ -285,7 +302,13 @@ pub(crate) fn build_agent_runtime_native_function_tools(
|
||||
AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(),
|
||||
]);
|
||||
|
||||
let planning_agent = agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID;
|
||||
for definition in agent_runtime_native_capability_registry()?.iter() {
|
||||
if planning_agent
|
||||
&& !matches!(definition.dispatch().as_str(), "file.read" | "file.list")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name = definition.function_name().to_string();
|
||||
if !names.insert(name.clone()) {
|
||||
return Err(format!("Runtime 原生函数名重复:{name}"));
|
||||
@@ -300,6 +323,11 @@ pub(crate) fn build_agent_runtime_native_function_tools(
|
||||
);
|
||||
}
|
||||
|
||||
// Planning Agents never receive an MCP catalog, even if a caller passes
|
||||
// one accidentally. This keeps the ad surface fail-closed by identity.
|
||||
if planning_agent {
|
||||
return Ok(functions);
|
||||
}
|
||||
for tool in &mcp_catalog.tools {
|
||||
let name = native_mcp_function_name(&tool.server_id, &tool.name);
|
||||
if !names.insert(name.clone()) {
|
||||
@@ -314,9 +342,64 @@ pub(crate) fn build_agent_runtime_native_function_tools(
|
||||
Ok(functions)
|
||||
}
|
||||
|
||||
pub(crate) fn agent_runtime_native_tool_allowed_for_agent(
|
||||
agent_id: &str,
|
||||
tool: &str,
|
||||
) -> bool {
|
||||
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
// update_agent_plan/respond_to_user are protocol controls and are
|
||||
// validated outside the action capability registry.
|
||||
return matches!(
|
||||
tool.trim(),
|
||||
"file.read" | "file.list" | AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME
|
||||
| AGENT_RUNTIME_RESPOND_FUNCTION_NAME
|
||||
);
|
||||
}
|
||||
if tool.trim() == GAME_CREATOR_MCP_CALL_TOOL {
|
||||
// MCP calls are bound and checked against the current catalog by the
|
||||
// MCP policy path; they are not part of the native capability registry.
|
||||
return true;
|
||||
}
|
||||
agent_runtime_native_capability_registry()
|
||||
.ok()
|
||||
.and_then(|registry| registry.get(tool.trim()))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn validate_native_tool_identity(
|
||||
agent_id: &str,
|
||||
runtime_tool: Option<&str>,
|
||||
) -> Result<(), AgentRuntimeToolPlanProtocolError> {
|
||||
if let Some(tool) = runtime_tool {
|
||||
if !agent_runtime_native_tool_allowed_for_agent(agent_id, tool) {
|
||||
return Err(protocol_error(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction,
|
||||
format!(
|
||||
"Agent 原生工具协议错误:Agent {} 不允许调用 {}",
|
||||
agent_id.trim(),
|
||||
tool
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_agent_runtime_native_tool_calls(
|
||||
calls: &[LlmToolCall],
|
||||
mcp_catalog: &GameCreatorMcpCatalog,
|
||||
) -> Result<NativeAgentRuntimeToolPlan, AgentRuntimeToolPlanProtocolError> {
|
||||
parse_agent_runtime_native_tool_calls_for_agent(
|
||||
"__all_agents__",
|
||||
calls,
|
||||
mcp_catalog,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_agent_runtime_native_tool_calls_for_agent(
|
||||
agent_id: &str,
|
||||
calls: &[LlmToolCall],
|
||||
mcp_catalog: &GameCreatorMcpCatalog,
|
||||
) -> Result<NativeAgentRuntimeToolPlan, AgentRuntimeToolPlanProtocolError> {
|
||||
if calls.is_empty() {
|
||||
return Err(protocol_error(
|
||||
@@ -373,7 +456,14 @@ pub(crate) fn parse_agent_runtime_native_tool_calls(
|
||||
}
|
||||
|
||||
let runtime_tool = runtime_tool_for_native_function(&call.name);
|
||||
validate_native_tool_identity(agent_id, runtime_tool.as_deref())?;
|
||||
let mcp_tool = mcp_tool_for_native_function(&call.name, mcp_catalog)?;
|
||||
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID && mcp_tool.is_some() {
|
||||
return Err(protocol_error(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction,
|
||||
"Agent 原生工具协议错误:project-planning 不允许 MCP 工具",
|
||||
));
|
||||
}
|
||||
if runtime_tool.is_none() && mcp_tool.is_none() {
|
||||
return Err(protocol_error(
|
||||
AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction,
|
||||
@@ -1504,6 +1594,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_planning_catalog_is_exact_and_mcp_free() {
|
||||
let functions = build_agent_runtime_native_function_tools_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
&native_mcp_catalog(empty_input_schema()),
|
||||
)
|
||||
.expect("planning function catalog");
|
||||
let names = functions
|
||||
.iter()
|
||||
.map(|function| function.name.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert!(names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME));
|
||||
assert!(names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME));
|
||||
assert!(names.contains("runtime_tool_file_read"));
|
||||
assert!(names.contains("runtime_tool_file_list"));
|
||||
assert_eq!(names.len(), 4);
|
||||
assert!(!names.iter().any(|name| name.starts_with("mcp_tool_")));
|
||||
assert!(!agent_runtime_native_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"user.input_request"
|
||||
));
|
||||
assert!(!agent_runtime_native_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"file.write"
|
||||
));
|
||||
}
|
||||
|
||||
fn native_mcp_catalog(input_schema: Value) -> GameCreatorMcpCatalog {
|
||||
GameCreatorMcpCatalog {
|
||||
fingerprint: "catalog-fingerprint".to_string(),
|
||||
|
||||
@@ -7861,6 +7861,59 @@ fn agent_native_tool_parser_accepts_plan_with_reply_and_rejects_reply_with_actio
|
||||
assert!(error.contains("最终回复不能与动作工具同时提交"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_agent_parser_rejects_text_and_legacy_tool_plan_bypasses() {
|
||||
let catalog = GameCreatorMcpCatalog {
|
||||
fingerprint: "planning-parser-empty-catalog".to_string(),
|
||||
servers: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
"thinkingSummary": "不应执行搜索",
|
||||
"planUpdate": null,
|
||||
"plan": [],
|
||||
"actions": [{
|
||||
"tool": "project.search",
|
||||
"reason": "绕过 planning allowlist",
|
||||
"input": {"query": "secret", "path": "", "maxResults": 20, "caseSensitive": false}
|
||||
}],
|
||||
"response": ""
|
||||
})
|
||||
.to_string();
|
||||
let text_error =
|
||||
parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
&agent_tool_plan_llm_response(payload.clone(), Vec::new()),
|
||||
&catalog,
|
||||
)
|
||||
.expect_err("planning text JSON must not bypass the exact tool identity gate");
|
||||
assert_eq!(
|
||||
text_error.kind(),
|
||||
AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction
|
||||
);
|
||||
assert!(text_error.to_string().contains("project.search"));
|
||||
|
||||
let legacy_error =
|
||||
parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
&agent_tool_plan_llm_response(
|
||||
"",
|
||||
vec![platform_llm::LlmToolCall {
|
||||
id: "planning-legacy-wrapper".to_string(),
|
||||
name: AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME.to_string(),
|
||||
arguments: payload,
|
||||
}],
|
||||
),
|
||||
&catalog,
|
||||
)
|
||||
.expect_err("planning must reject the unadvertised legacy wrapper");
|
||||
assert_eq!(
|
||||
legacy_error.kind(),
|
||||
AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction
|
||||
);
|
||||
assert!(legacy_error.to_string().contains("submit_agent_tool_plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_native_tool_parser_binds_dynamic_mcp_function_without_model_fingerprints() {
|
||||
let tool = GameCreatorMcpCatalogTool {
|
||||
|
||||
@@ -17,6 +17,60 @@ fn agent_runtime_default_allowed_tools_match_executable_whitelist() {
|
||||
assert!(!expected.contains(&"conversation.write".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_agent_original_tool_identity_is_not_widened_by_command_aliases() {
|
||||
assert!(agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"file.read"
|
||||
));
|
||||
assert!(agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"file.list"
|
||||
));
|
||||
assert!(!agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"project.search"
|
||||
));
|
||||
assert!(agent_runtime_tool_allowed_for_agent(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"project.search"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn planning_runtime_rejects_search_alias_and_user_input_before_execution() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "planning-boundary", "策划 Agent 运行时边界").expect("project init");
|
||||
|
||||
for (tool, input) in [
|
||||
(
|
||||
"project.search",
|
||||
serde_json::json!({ "query": "should-not-run" }),
|
||||
),
|
||||
(
|
||||
GAME_CREATOR_USER_INPUT_REQUEST_TOOL,
|
||||
serde_json::json!({ "questions": [] }),
|
||||
),
|
||||
] {
|
||||
let observation = execute_game_creator_agent_runtime_tool_action(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"planning-boundary-run",
|
||||
"验证策划 Agent 工具边界",
|
||||
&AgentRuntimeToolAction {
|
||||
tool: tool.to_string(),
|
||||
reason: Some("边界测试".to_string()),
|
||||
input,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(observation.status, "rejected", "{tool}: {observation:?}");
|
||||
assert!(observation.summary.contains("不允许"), "{observation:?}");
|
||||
}
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_runtime_failure_redacts_legacy_plan_detail_and_all_error_projections() {
|
||||
let root = unique_project_path();
|
||||
@@ -210,6 +264,100 @@ fn agent_runtime_tool_policy_snapshot_reflects_project_policy() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_tool_policy_snapshot_keeps_exact_permission_decisions() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "planning-policy-snapshot", "策划工具策略快照").expect("project init");
|
||||
write_project_permission_policy_at(
|
||||
&root,
|
||||
ProjectPermissionPolicy {
|
||||
denied_commands: vec!["file.read".to_string()],
|
||||
confirm_commands: vec!["file.list".to_string()],
|
||||
agent_policies: BTreeMap::new(),
|
||||
},
|
||||
)
|
||||
.expect("write planning policy");
|
||||
|
||||
let parent_run_id = "planning-policy-parent-run";
|
||||
bind_game_creator_agent_runtime_run_profile_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
parent_run_id,
|
||||
"project-supervisor-plan",
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
|
||||
None,
|
||||
)
|
||||
.expect("bind plan root");
|
||||
let child_run_id = "planning-policy-child-run";
|
||||
bind_game_creator_agent_runtime_run_profile_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
child_run_id,
|
||||
"agent-delegate",
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
|
||||
Some(&AgentRuntimeTaskLink {
|
||||
parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()),
|
||||
parent_run_id: Some(parent_run_id.to_string()),
|
||||
delegation_id: Some("planning-policy-delegation".to_string()),
|
||||
}),
|
||||
)
|
||||
.expect("bind planning child");
|
||||
|
||||
let snapshot = agent_runtime_tool_policy_snapshot_for_run_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
child_run_id,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("read planning policy snapshot");
|
||||
assert_eq!(snapshot.allowed_tools, vec!["file.list", "file.read"]);
|
||||
assert!(snapshot.denied_tools.iter().any(|tool| tool == "file.read"));
|
||||
assert!(!snapshot.auto_tools.iter().any(|tool| tool == "file.read"));
|
||||
assert!(!snapshot.confirm_tools.iter().any(|tool| tool == "file.read"));
|
||||
assert!(snapshot.confirm_tools.iter().any(|tool| tool == "file.list"));
|
||||
assert!(snapshot.denied_tools.iter().any(|tool| tool == "project.search"));
|
||||
|
||||
assert!(matches!(
|
||||
game_creator_agent_runtime_tool_policy_rule_for_run(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
child_run_id,
|
||||
None,
|
||||
None,
|
||||
"file.read",
|
||||
),
|
||||
Some(AgentRuntimeToolPolicyBlock::Denied(reason))
|
||||
if reason.contains("项目权限策略拒绝执行")
|
||||
));
|
||||
assert!(matches!(
|
||||
game_creator_agent_runtime_tool_policy_rule_for_run(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
child_run_id,
|
||||
None,
|
||||
None,
|
||||
"file.list",
|
||||
),
|
||||
Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(reason))
|
||||
if reason.contains("项目权限策略要求用户确认")
|
||||
));
|
||||
assert!(matches!(
|
||||
game_creator_agent_runtime_tool_policy_rule_for_run(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
child_run_id,
|
||||
Some("autonomous-game-build"),
|
||||
Some("forged-binding-fingerprint"),
|
||||
"file.list",
|
||||
),
|
||||
Some(AgentRuntimeToolPolicyBlock::Denied(reason))
|
||||
if reason.contains("Run Profile")
|
||||
));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_agent_runtime_asset_generation_respects_project_policy() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -38,6 +38,7 @@ pub(super) use crate::{
|
||||
agent_runtime_read_only_delivery_completion_plan_update, agent_runtime_run_profile_identity_at,
|
||||
agent_runtime_tool_action_fingerprint, agent_runtime_tool_action_id,
|
||||
agent_runtime_tool_policy_snapshot_for_run_at,
|
||||
agent_runtime_tool_allowed_for_agent,
|
||||
agent_runtime_tool_requires_pending_revision_gate,
|
||||
agent_runtime_tool_requires_repository_context_fingerprint_gate,
|
||||
agent_runtime_verified_delivery_completion_plan_update, append_agent_db_record,
|
||||
@@ -114,5 +115,6 @@ pub(super) use crate::{
|
||||
AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH,
|
||||
AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME,
|
||||
GAME_CREATOR_CONFIG_FILE_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
GAME_CREATOR_USER_INPUT_REQUEST_TOOL, PROJECT_BLACKBOARD_MEMORY_PATH,
|
||||
};
|
||||
|
||||
@@ -189,6 +189,21 @@ fn valid_manifest() -> Value {
|
||||
}
|
||||
]
|
||||
},
|
||||
"planning": {
|
||||
"id": "planning",
|
||||
"label": "立项策划",
|
||||
"role": "Project Planning",
|
||||
"briefPathName": "project-planning.md",
|
||||
"roles": [
|
||||
{
|
||||
"id": "project-planning",
|
||||
"role": "Project Planning",
|
||||
"taskId": "project-planning",
|
||||
"toolId": "agent.runtime.project-planning",
|
||||
"briefPathName": "project-planning.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
"groups": [
|
||||
{
|
||||
"id": "code",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-13 M1A-2:planning 子 Agent 两层工具面与角色 brief 注入
|
||||
|
||||
- 落地范围:在 `M1A-1` 的 `project-supervisor-plan` source 基础上,收口两层工具面。Supervisor 根 run 继续使用 `standard` 的现役工具面;`project-planning` 只接受 `source=agent-delegate`、`profile=standard`、父 Agent 为 `project-supervisor` 的静态委派身份。
|
||||
- planning 子 Agent 的当前 native action exact allowlist 只有 `file.read`、`file.list`;`update_agent_plan` / `respond_to_user` 是协议控制函数,不计入 action capability。MCP catalog 强制为空,`webSearchEnabled=false`,`collaborationPolicy=null`。`plan.submit_gdd` 刻意未注册、未广告、未执行,留给后续 `M1B-2`,因此本条不代表 GDD 提交、版本存储或审批闭环已完成。
|
||||
- Prompt:Prompt Bundle 新增并登记 `project-planning` role brief,standard planning child 的初始请求与 repair/rebuild 请求均注入同一 brief;Supervisor 和其它 Agent 不注入该 section。brief 只描述 Fast GDD 澄清、终态 `AGC_NEEDS_USER_INPUT_V1`、三轮边界、平台事实与低幻觉约束,不授予任何写入、命令、MCP、预览、生成或审批能力。
|
||||
- 纵深拒绝:广告层不再向 planning child 暴露 `user.input_request`;Provider parser、action batch/pending、并行只读、执行层和状态恢复均按原始 tool identity 再校验。伪造写入/命令/MCP、`project.search` 等映射为 `file.read` 的 alias、`user.input_request` 都 fail-closed;恢复旧快照不得把 planning 工具面扩回全量目录。委派子 Agent 原有 `validate_user_input_action_owner` 执行层拒绝继续保留。
|
||||
- 回归与边界:覆盖 planning 函数目录精确集合、brief 只注入 planning、MCP/web search/collaboration 收窄、原始工具身份拒绝及状态归一化不扩权;Supervisor 根 run 的 standard 工具面保持既有行为。M1A-3 的 source 保留、强判据与 retry 语义不改;`M1B-1`/`M1B-2` 的 planning 存储、strict schema、typed 指纹和 `plan.submit_gdd` 提交仍未实现。
|
||||
- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` 第 4.3、6、19.2、23.6、23.8 节。
|
||||
|
||||
## 2026-08-13 M1A-3:plan 根 run 强判据与 retry 保源
|
||||
|
||||
- 落地:新增 `supervisor_plan_root_identity_holds_at`。必须核 durable run-profile binding(含 project/fingerprint 校验),并与 task 的 `agentId/source/profile/parent/delegation/root*` 以及「存在且 runId 相同」的 runtime、尚存 provider action batch 逐项相等。**不得只比较内存 `runtime.source`。** `agent_runtime_supervisor_source_is_plan` 仍只用于拒绝(steer),本函数只用于授予 retry 保源。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user