立项策划:收口 M1A-2 工具面与角色提示
Project CI / Repository checks (pull_request) Successful in 1m1s
Project CI / Frontend tests (pull_request) Successful in 3m27s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Native shell tests (pull_request) Failing after 3m10s

收紧 planning 子 Agent 原生工具目录与 MCP/web search 边界

增加静态委派父根身份与 fail-closed 执行校验

补齐 planning Prompt Bundle、final-reply 终态约束与解析层拒绝

保留项目权限 deny/confirm 并同步恢复归一化策略

同步技术方案与项目决策记录
This commit is contained in:
2026-08-14 02:58:18 +00:00
parent 84f6e6ec20
commit 09c7d7af8d
26 changed files with 1113 additions and 55 deletions
@@ -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_planarguments 必须提交完整 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",
@@ -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
@@ -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;
@@ -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,
@@ -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,
@@ -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-2planning 子 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 提交、版本存储或审批闭环已完成。
- PromptPrompt Bundle 新增并登记 `project-planning` role briefstandard planning child 的初始请求与 repair/rebuild 请求均注入同一 briefSupervisor 和其它 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/【技术方案】立项策划AgentFast GDD-2026-08-10.md` 第 4.3、6、19.2、23.6、23.8 节。
## 2026-08-13 M1A-3plan 根 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