立项策划:完成 M1B-2 GDD 提交与恢复

接入 plan.submit_gdd 原生工具及 exact planning Provider 绑定与结构化注入

实现 create-only GDD 提交点、索引 Markdown session 恢复与策划子 run 收口

补齐定向门禁与阶段文档记录,审批 receipt UI 和构建准入留待后续
This commit is contained in:
2026-08-14 14:23:58 +00:00
parent a17f725834
commit 27c3eb847a
56 changed files with 7978 additions and 339 deletions
@@ -1633,6 +1633,7 @@ mod tests {
request_slot: "slot-1".to_string(),
web_search_enabled: false,
allow_idle_context_compaction: false,
planning_session_binding: None,
}
}
@@ -549,7 +549,11 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent(
if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
return prompt;
}
game_creator_project_supervisor_tool_plan_prompt(&prompt, editor_api_key_is_configured(), source)
game_creator_project_supervisor_tool_plan_prompt(
&prompt,
editor_api_key_is_configured(),
source,
)
}
/// The planning child has an exact native allowlist. Do not reuse the broad
@@ -561,7 +565,7 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent(
/// 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 身份、审批事实、项目版本和平台事实均由系统维护,不得自行生成或修改。"
"你正在使用 Genarrative AI 游戏创作多智能体 Runtime。当前请求只广告以下原生函数:file.read、file.list、plan.submit_gdd、update_agent_plan、respond_to_user。只能直接调用这些函数;不得调用未广告的函数、动态工具或普通文本伪造工具调用。\n\n读取工具只用于获取项目内已有文本和文件摘要;不要把读取结果当作已经写入、提交、审批或构建完成。需要记录真实计划变化时调用 update_agent_planarguments 必须提交完整 steps成稿时调用 plan.submit_gddinput 必须严格符合 plan-submit-gdd-input.v1,只提交 game、decisions、prototypeValidationItems,不得附加 platformFacts、身份、版本、时间或 fingerprint。plan.submit_gdd 必须是本轮唯一 action,可与 update_agent_plan 同响应,但不能与其它动作或 respond_to_user 混合;GDD 提交成功后再由 Runtime 负责 durable 写入和投影。已有足够 observation、需要交付终态信封或当前轮次应收束时调用 respond_to_user。Runtime 身份、审批事实、项目版本和平台事实均由系统维护,不得自行生成或修改。"
)
}
@@ -874,6 +878,19 @@ mod tests {
);
}
#[test]
fn project_planning_prompt_advertises_submit_gdd_contract() {
let prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"",
);
assert!(prompt.contains("plan.submit_gdd"));
assert!(prompt.contains("plan-submit-gdd-input.v1"));
assert!(prompt.contains("唯一 action"));
assert!(prompt.contains("可与 update_agent_plan 同响应"));
assert!(!prompt.contains("user.input_request"));
}
#[test]
fn runtime_prompt_tool_catalog_tracks_the_native_capability_registry() {
let prompt = game_creator_agent_runtime_tool_plan_system_prompt();
@@ -1236,16 +1253,24 @@ mod tests {
"plan 根 prompt 不应再包含 with-editor 视觉合同"
);
assert!(
!plan_prompt.contains("当前未配置 External Editor API Keyart-director 只交付视觉方向文档"),
!plan_prompt.contains(
"当前未配置 External Editor API Keyart-director 只交付视觉方向文档"
),
"plan 根 prompt 不应再包含 without-editor 视觉合同"
);
assert!(
plan_prompt.contains("shared runtime contract"),
"plan 根 prompt 必须保留 $base"
);
assert!(plan_prompt.contains(required_runtime_prompt_section("supervisorPlaybook").trim()));
assert!(plan_prompt.contains(required_runtime_prompt_section("supervisorClaimGate").trim()));
assert!(plan_prompt.contains(required_runtime_prompt_section("supervisorRepair").trim()));
assert!(
plan_prompt.contains(required_runtime_prompt_section("supervisorPlaybook").trim())
);
assert!(
plan_prompt.contains(required_runtime_prompt_section("supervisorClaimGate").trim())
);
assert!(
plan_prompt.contains(required_runtime_prompt_section("supervisorRepair").trim())
);
}
}
@@ -1281,7 +1306,10 @@ mod tests {
editor_api_key_is_configured,
source,
);
assert_eq!(actual, expected, "source={source} 不应被 plan 根收窄逻辑改变");
assert_eq!(
actual, expected,
"source={source} 不应被 plan 根收窄逻辑改变"
);
}
}
}
@@ -115,6 +115,7 @@ pub(crate) use project_gates::{
};
pub(crate) use provider_action_batch::{
prepare_game_creator_agent_runtime_provider_action_batch,
prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding,
update_game_creator_agent_runtime_provider_batch_member, AgentRuntimePendingToolAction,
AgentRuntimeProviderActionBatch,
};
@@ -152,5 +153,5 @@ pub(crate) use tool_policy_snapshot::{
agent_runtime_acceptance_evidence_tools,
agent_runtime_autonomous_design_foundation_command_is_allowed, agent_runtime_executable_tools,
agent_runtime_native_executable_tools, agent_runtime_tool_policy_snapshot_for_run_at,
AGENT_RUNTIME_CANVAS_ASSET_KINDS,
AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS,
};
@@ -37,9 +37,22 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
pending_action: Option<&AgentRuntimePendingToolAction>,
) -> AgentRuntimeToolObservation {
let tool = action.tool.trim();
if tool == PLAN_SUBMIT_GDD_TOOL && agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
return AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "rejected".to_string(),
summary: "plan.submit_gdd 仅允许 project-planning Agent".to_string(),
detail: None,
};
}
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
&& !matches!(tool, "file.read" | "file.list")
{
// `plan.submit_gdd` is intentionally handled by the planning submit
// branch in the Runtime main loop. If it ever reaches the generic
// executor (including recovery or a stale pending record), fail
// closed instead of treating the durable mutation as an ordinary
// command action.
return AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "rejected".to_string(),
@@ -60,7 +60,20 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
let app_config = load_game_creator_app_config()?;
let llm = resolve_game_creator_llm_config_for_agent(&app_config, &template_agent_id);
let config_path = format!("agentLlm.{template_agent_id}");
let request = build_game_creator_agent_runtime_context_compaction_request(&source, &llm)?;
let mut request =
build_game_creator_agent_runtime_context_compaction_request(&source, &llm)?;
let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID;
if planning_agent {
if allow_idle_context_compaction {
return Err(
"project-planning 不支持脱离 active run 的 idle context compaction".to_string(),
);
}
let wire_bytes =
capture_plan_provider_structured_injections_at(root, session_id, observations)?;
let message = render_plan_provider_structured_injections_message(&wire_bytes)?;
request.messages.insert(1, LlmMessage::user(message));
}
let estimated_request_tokens = estimate_game_creator_llm_request_tokens(&request)?;
validate_game_creator_llm_request_context_budget(
&llm,
@@ -88,6 +101,22 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
applied_steer_cursor,
)?
};
let snapshot = if planning_agent {
let request_context_fingerprint =
game_creator_agent_runtime_plan_provider_request_context_fingerprint(
&llm, &request,
)?;
let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state;
let binding = capture_plan_provider_session_binding_for_snapshot(
root,
&runtime,
&snapshot,
&request_context_fingerprint,
)?;
snapshot.with_planning_session_binding(Some(binding))
} else {
snapshot
};
(snapshot, source, llm, config_path, request)
};
let handoff_identity =
@@ -154,6 +183,7 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked(
root,
&base_request_id,
snapshot.planning_session_binding.is_some(),
)
.map(|value| value.0)
.unwrap_or(base_request_id);
@@ -177,6 +207,7 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked(
root,
&base_request_id,
snapshot.planning_session_binding.is_some(),
)
.map(|value| value.0)
.unwrap_or(base_request_id);
@@ -210,6 +241,7 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked(
root,
&base_request_id,
snapshot.planning_session_binding.is_some(),
)
.map(|value| value.0)
.unwrap_or(base_request_id);
@@ -107,6 +107,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
"agent.route_manifest" => Some("agent.route_manifest"),
"agent.action_history" => Some("agent.audit"),
"agent.run_status" => Some("agent.run_status"),
PLAN_SUBMIT_GDD_TOOL => Some(PLAN_SUBMIT_GDD_TOOL),
GAME_CREATOR_MCP_CALL_TOOL => Some(GAME_CREATOR_MCP_CALL_TOOL),
_ => None,
}
@@ -118,7 +119,13 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
/// 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");
return matches!(
tool.trim(),
"file.read" | "file.list" | PLAN_SUBMIT_GDD_TOOL
);
}
if tool.trim() == PLAN_SUBMIT_GDD_TOOL {
return false;
}
if tool.trim() == GAME_CREATOR_USER_INPUT_REQUEST_TOOL {
// `user.input_request` is a protocol control handled by the main
@@ -184,6 +191,10 @@ mod identity_tests {
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"file.read"
));
assert!(!agent_runtime_tool_rejected_by_agent_identity(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
PLAN_SUBMIT_GDD_TOOL
));
}
#[test]
@@ -200,6 +211,14 @@ mod identity_tests {
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"project.search"
));
assert!(agent_runtime_tool_allowed_for_agent(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
PLAN_SUBMIT_GDD_TOOL
));
assert!(!agent_runtime_tool_allowed_for_agent(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
PLAN_SUBMIT_GDD_TOOL
));
assert!(agent_runtime_tool_allowed_for_agent(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"project.search"
@@ -143,9 +143,7 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_parallel_read_pendin
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(),
),
AgentRuntimeToolPolicyBlock::Denied("当前 Agent 身份不允许执行该原始工具".to_string()),
)));
}
if let Some(observation) = pending_repository_context_drift_observation(root, pending)? {
@@ -334,6 +334,32 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record(
{
return Err("Agent Runtime 待确认动作 Run Profile 绑定不匹配".to_string());
}
match pending.planning_session_binding.as_ref() {
Some(binding) => {
validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?;
if pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL
|| binding.agent_id != pending.agent_id
|| binding.task_id != pending.task_id
|| binding.session_id != pending.session_id
|| binding.run_id != pending.run_id
|| binding.source != pending.source
|| binding.run_profile != pending.run_profile
|| binding.run_profile_binding_fingerprint
!= pending.run_profile_binding_fingerprint
|| binding.applied_steer_cursor != pending.planned_steer_cursor
{
return Err(
"planning submit standalone pending 与 frozen binding 不一致".to_string(),
);
}
}
None if pending.provider_batch_plan_update.is_none() => {}
None => {
return Err(
"非 planning standalone pending 不能携带 Provider batch planUpdate".to_string(),
);
}
}
validate_agent_runtime_project_revision(root, &pending.project_revision_before)?;
if pending.verification_gate_before.project_id
!= game_creator_agent_runtime_context_project_id(root)?
@@ -14,6 +14,18 @@ pub(crate) struct AgentRuntimePendingToolAction {
pub(crate) run_profile: String,
#[serde(default)]
pub(crate) run_profile_binding_fingerprint: String,
/// Planning submit actions carry the exact source-session snapshot that
/// was captured before the Provider response was accepted. Other tools
/// leave this field absent and retain the v1-v3 batch semantics.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) planning_session_binding: Option<PlanProviderSessionBindingV1>,
/// The v4 planning batch identity covers the complete Provider plan,
/// including an optional structured plan update. Persist that one
/// batch-only field on the standalone submit anchor as recovery material;
/// otherwise a surviving pending action cannot reproduce the original
/// batch ID after the batch sidecar is lost.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) provider_batch_plan_update: Option<AgentRuntimePlanUpdate>,
pub(crate) task: String,
#[serde(default)]
pub(crate) goal_id: Option<String>,
@@ -78,6 +90,8 @@ pub(in crate::agent) struct AgentRuntimeParallelReadBatch {
pub(crate) struct AgentRuntimeProviderActionBatch {
pub(crate) schema_version: String,
pub(crate) batch_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) provider_request_id: Option<String>,
pub(crate) project_id: String,
pub(crate) agent_id: String,
pub(crate) task_id: String,
@@ -88,6 +102,8 @@ pub(crate) struct AgentRuntimeProviderActionBatch {
pub(crate) run_profile: String,
#[serde(default)]
pub(crate) run_profile_binding_fingerprint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) planning_session_binding: Option<PlanProviderSessionBindingV1>,
pub(crate) loop_iteration: u32,
pub(crate) planned_steer_cursor: u64,
pub(crate) status: String,
@@ -107,6 +123,8 @@ pub(crate) struct AgentRuntimeProviderActionBatch {
struct AgentRuntimeProviderActionBatchWire {
schema_version: String,
batch_id: String,
#[serde(default)]
provider_request_id: Option<String>,
project_id: String,
agent_id: String,
task_id: String,
@@ -117,6 +135,8 @@ struct AgentRuntimeProviderActionBatchWire {
run_profile: String,
#[serde(default)]
run_profile_binding_fingerprint: String,
#[serde(default)]
planning_session_binding: Option<PlanProviderSessionBindingV1>,
loop_iteration: u32,
planned_steer_cursor: u64,
status: String,
@@ -140,6 +160,7 @@ impl<'de> Deserialize<'de> for AgentRuntimeProviderActionBatch {
let batch = Self {
schema_version: wire.schema_version,
batch_id: wire.batch_id,
provider_request_id: wire.provider_request_id,
project_id: wire.project_id,
agent_id: wire.agent_id,
task_id: wire.task_id,
@@ -148,6 +169,7 @@ impl<'de> Deserialize<'de> for AgentRuntimeProviderActionBatch {
source: wire.source,
run_profile: wire.run_profile,
run_profile_binding_fingerprint: wire.run_profile_binding_fingerprint,
planning_session_binding: wire.planning_session_binding,
loop_iteration: wire.loop_iteration,
planned_steer_cursor: wire.planned_steer_cursor,
status: wire.status,
@@ -195,7 +217,7 @@ impl AgentRuntimePendingToolAction {
pub(in crate::agent) fn tool_plan(&self) -> AgentRuntimeToolPlan {
AgentRuntimeToolPlan {
thinking_summary: self.thinking_summary.clone(),
plan_update: None,
plan_update: self.provider_batch_plan_update.clone(),
plan: self.plan.clone(),
actions: Vec::new(),
response: self.fallback_response.clone(),
@@ -253,6 +275,8 @@ pub(in crate::agent) fn build_game_creator_agent_runtime_pending_tool_action(
source: runtime.source.clone(),
run_profile: runtime.run_profile.clone(),
run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(),
planning_session_binding: None,
provider_batch_plan_update: None,
task,
goal_id: runtime.goal_id.clone(),
goal_revision: runtime.goal_revision,
@@ -288,6 +312,110 @@ pub(in crate::agent) fn build_game_creator_agent_runtime_pending_tool_action(
})
}
/// `plan.submit_gdd` is a transactional planning action rather than an
/// ordinary provider action. It must be represented by one (and only one)
/// durable batch member so that the main loop can establish the action
/// identity before handing control to the planning submit handler.
///
/// Keep this check at the batch boundary as a second line of defence behind
/// the native-tool parser. In particular, a text/JSON tool-plan or a stale
/// caller must not be able to smuggle a submit action through the historical
/// `< 2 actions => NotNeeded` fast path.
fn validate_plan_submit_gdd_batch_shape_for_identity(
agent_id: &str,
source: &str,
run_profile: &str,
plan: &AgentRuntimeToolPlan,
) -> Result<bool, String> {
let submit_count = plan
.actions
.iter()
.filter(|action| action.tool.trim() == PLAN_SUBMIT_GDD_TOOL)
.count();
if submit_count == 0 {
return Ok(false);
}
if agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
return Err("plan.submit_gdd 只能由 project-planning Agent 调用".to_string());
}
// The planning child is created through the ordinary delegate path. Keep
// the source/profile check here even though the run-identity binder also
// checks it: this prevents a forged/stale RuntimeState from turning the
// sole-action exception into a generic batch.
if source.trim() != "agent-delegate" || run_profile.trim() != AGENT_RUNTIME_RUN_PROFILE_STANDARD
{
return Err(
"plan.submit_gdd 的 Runtime 身份必须是 source=agent-delegate、runProfile=standard"
.to_string(),
);
}
if submit_count > 1 {
return Err("plan.submit_gdd 在同一 Provider 响应中只能出现一次".to_string());
}
if plan.actions.len() != 1 {
return Err("plan.submit_gdd 必须是 Provider 响应中的唯一 action".to_string());
}
if !plan.response.trim().is_empty() {
return Err("plan.submit_gdd 不得与 respond_to_user 混批".to_string());
}
Ok(true)
}
fn validate_plan_submit_gdd_batch_shape(
runtime: &AgentRuntimeState,
plan: &AgentRuntimeToolPlan,
) -> Result<bool, String> {
validate_plan_submit_gdd_batch_shape_for_identity(
&runtime.agent_id,
&runtime.source,
&runtime.run_profile,
plan,
)
}
/// Return whether a persisted v4 provider batch is the exact planning submit
/// shape that is allowed to contain one action. The provider-batch ledger
/// uses this narrow predicate when applying its normal two-action minimum;
/// all non-plan batches retain the historical minimum unchanged.
pub(in crate::agent) fn is_plan_submit_gdd_provider_action_batch(
batch: &AgentRuntimeProviderActionBatch,
) -> bool {
batch.schema_version == AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION
&& batch.agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
&& batch.source.trim() == "agent-delegate"
&& batch.run_profile.trim() == AGENT_RUNTIME_RUN_PROFILE_STANDARD
&& batch.collaboration_contract.is_none()
&& batch.actions.len() == 1
&& batch.plan.actions.len() == 1
&& batch.plan.actions[0].tool.trim() == PLAN_SUBMIT_GDD_TOOL
&& batch.actions[0].action.tool.trim() == PLAN_SUBMIT_GDD_TOOL
&& batch.plan.response.trim().is_empty()
&& batch.actions[0].action == batch.plan.actions[0]
&& batch.planning_session_binding.is_some()
&& batch.provider_request_id.as_deref()
== batch
.planning_session_binding
.as_ref()
.map(|binding| binding.provider_request_id.as_str())
&& batch.actions[0].planning_session_binding == batch.planning_session_binding
}
fn provider_action_batch_is_not_needed(
action_count: usize,
force_collaboration_batch: bool,
is_plan_submit: bool,
) -> bool {
action_count < 2 && !force_collaboration_batch && !is_plan_submit
}
/// Backwards-compatible entry point for the historical provider-batch callers.
///
/// Planning submit batches now need the frozen session binding captured while
/// building the provider request. Callers that do not build a planning
/// request (including the older test/support helpers) retain the old API and
/// therefore pass no binding; the planning path uses the `_with_planning_binding`
/// variant below.
pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
root: &Path,
runtime: &AgentRuntimeState,
@@ -297,6 +425,34 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
project_revision_before: &AgentRuntimeProjectRevision,
planned_repository_context_fingerprint: &str,
) -> Result<AgentRuntimeProviderActionBatchPreparation, String> {
prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding(
root,
runtime,
task,
plan,
observations,
project_revision_before,
planned_repository_context_fingerprint,
None,
)
.await
}
pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding(
root: &Path,
runtime: &AgentRuntimeState,
task: &str,
plan: &AgentRuntimeToolPlan,
observations: &[AgentRuntimeToolObservation],
project_revision_before: &AgentRuntimeProjectRevision,
planned_repository_context_fingerprint: &str,
captured_planning_session_binding: Option<&PlanProviderSessionBindingV1>,
) -> Result<AgentRuntimeProviderActionBatchPreparation, String> {
// Validate against the complete provider plan before truncating the
// historical action budget. Otherwise a mixed submit batch could hide a
// `plan.submit_gdd` action beyond the truncation boundary and reach the
// generic executor without a durable identity.
let is_plan_submit = validate_plan_submit_gdd_batch_shape(runtime, plan)?;
let mut batch_plan = plan.clone();
batch_plan
.actions
@@ -450,7 +606,11 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
}
}
}
if batch_plan.actions.len() < 2 && !collaboration_preflight.force_durable_batch {
if provider_action_batch_is_not_needed(
batch_plan.actions.len(),
collaboration_preflight.force_durable_batch,
is_plan_submit,
) {
return Ok(AgentRuntimeProviderActionBatchPreparation::NotNeeded);
}
@@ -472,16 +632,23 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
None,
)?;
if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL {
pending.planning_session_binding = captured_planning_session_binding.cloned();
pending.provider_batch_plan_update = batch_plan.plan_update.clone();
if pending.planning_session_binding.is_none() {
return Err(
"planning submit action 缺少 Provider 请求前捕获的 session binding".to_string(),
);
}
}
let command_id = game_creator_agent_runtime_tool_command_id(action.tool.trim());
let identity_block = agent_runtime_tool_rejected_by_agent_identity(
&runtime.agent_id,
action.tool.trim(),
)
.then(|| {
AgentRuntimeToolPolicyBlock::Denied(
"当前 Agent 身份不允许执行该原始工具".to_string(),
)
});
let identity_block =
agent_runtime_tool_rejected_by_agent_identity(&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,
@@ -575,24 +742,71 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
} else {
AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_READY
};
let batch_id = agent_runtime_provider_action_batch_id(
&project_id,
&runtime.agent_id,
&runtime.task_id,
&runtime.session_id,
&runtime.run_id,
runtime.loop_iteration,
runtime.applied_steer_cursor,
&batch_plan,
project_revision_before,
planned_repository_context_fingerprint,
&actions,
collaboration_preflight.contract.as_ref(),
)?;
let planning_session_binding = if is_plan_submit {
let binding = captured_planning_session_binding
.or_else(|| {
actions
.first()
.and_then(|pending| pending.planning_session_binding.as_ref())
})
.ok_or_else(|| "planning submit batch 缺少 frozen session binding".to_string())?;
validate_plan_provider_session_binding_current_at(root, binding)?;
if let Some(pending_binding) = actions
.first()
.and_then(|pending| pending.planning_session_binding.as_ref())
{
if pending_binding != binding {
return Err(
"planning submit pending 与 captured session binding 不一致".to_string()
);
}
}
Some(binding.clone())
} else {
None
};
let batch_id = if let Some(binding) = planning_session_binding.as_ref() {
agent_runtime_plan_provider_action_batch_id(
&project_id,
&runtime.agent_id,
&runtime.task_id,
&runtime.session_id,
&runtime.run_id,
runtime.loop_iteration,
runtime.applied_steer_cursor,
&batch_plan,
project_revision_before,
planned_repository_context_fingerprint,
&actions,
binding,
)?
} else {
agent_runtime_provider_action_batch_id(
&project_id,
&runtime.agent_id,
&runtime.task_id,
&runtime.session_id,
&runtime.run_id,
runtime.loop_iteration,
runtime.applied_steer_cursor,
&batch_plan,
project_revision_before,
planned_repository_context_fingerprint,
&actions,
collaboration_preflight.contract.as_ref(),
)?
};
let now = unix_timestamp();
let batch = AgentRuntimeProviderActionBatch {
schema_version: AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string(),
schema_version: if is_plan_submit {
AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string()
} else {
AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION.to_string()
},
batch_id,
provider_request_id: planning_session_binding
.as_ref()
.map(|binding| binding.provider_request_id.clone()),
project_id,
agent_id: runtime.agent_id.clone(),
task_id: runtime.task_id.clone(),
@@ -601,6 +815,7 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch(
source: runtime.source.clone(),
run_profile: runtime.run_profile.clone(),
run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(),
planning_session_binding,
loop_iteration: runtime.loop_iteration,
planned_steer_cursor: runtime.applied_steer_cursor,
status: status.to_string(),
@@ -1077,3 +1292,93 @@ pub(in crate::agent) fn update_game_creator_agent_runtime_provider_batch_paralle
}
Ok(())
}
#[cfg(test)]
mod plan_submit_batch_shape_tests {
use super::*;
fn action(tool: &str) -> AgentRuntimeToolAction {
AgentRuntimeToolAction {
tool: tool.to_string(),
reason: Some("测试动作".to_string()),
input: serde_json::json!({}),
}
}
fn plan(actions: Vec<AgentRuntimeToolAction>, response: &str) -> AgentRuntimeToolPlan {
AgentRuntimeToolPlan {
thinking_summary: "测试 plan.submit_gdd 批次形状".to_string(),
plan_update: None,
plan: Vec::new(),
actions,
response: response.to_string(),
}
}
fn validate(plan: &AgentRuntimeToolPlan) -> Result<bool, String> {
validate_plan_submit_gdd_batch_shape_for_identity(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"agent-delegate",
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
plan,
)
}
#[test]
fn planning_submit_is_the_only_durable_action_and_allows_plan_control() {
let mut submit = plan(vec![action(PLAN_SUBMIT_GDD_TOOL)], "");
assert_eq!(validate(&submit), Ok(true));
submit.plan_update = Some(AgentRuntimePlanUpdate {
explanation: "同步计划进度".to_string(),
steps: Vec::new(),
});
assert_eq!(validate(&submit), Ok(true));
}
#[test]
fn planning_submit_rejects_mixed_or_duplicate_actions() {
let mixed = plan(vec![action(PLAN_SUBMIT_GDD_TOOL), action("file.read")], "");
let mixed_error = validate(&mixed).expect_err("submit + file.read must fail closed");
assert!(mixed_error.contains("唯一 action"), "{mixed_error}");
let duplicate = plan(
vec![action(PLAN_SUBMIT_GDD_TOOL), action(PLAN_SUBMIT_GDD_TOOL)],
"",
);
let duplicate_error =
validate(&duplicate).expect_err("duplicate submit actions must fail closed");
assert!(
duplicate_error.contains("只能出现一次"),
"{duplicate_error}"
);
}
#[test]
fn planning_submit_rejects_final_response_and_wrong_identity() {
let with_response = plan(vec![action(PLAN_SUBMIT_GDD_TOOL)], "不能同时回复");
let response_error =
validate(&with_response).expect_err("submit + respond_to_user must fail closed");
assert!(
response_error.contains("respond_to_user"),
"{response_error}"
);
let wrong_agent = validate_plan_submit_gdd_batch_shape_for_identity(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"project-supervisor-plan",
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
&plan(vec![action(PLAN_SUBMIT_GDD_TOOL)], ""),
)
.expect_err("non-planning identity must not receive submit exception");
assert!(wrong_agent.contains("project-planning"), "{wrong_agent}");
}
#[test]
fn ordinary_single_action_keeps_not_needed_eligibility() {
assert_eq!(validate(&plan(vec![action("file.read")], "")), Ok(false));
assert!(provider_action_batch_is_not_needed(1, false, false));
assert!(!provider_action_batch_is_not_needed(1, false, true));
assert!(!provider_action_batch_is_not_needed(1, true, false));
}
}
@@ -110,6 +110,48 @@ pub(in crate::agent) fn agent_runtime_provider_action_batch_id(
)
}
#[allow(clippy::too_many_arguments)]
pub(in crate::agent) fn agent_runtime_plan_provider_action_batch_id(
project_id: &str,
agent_id: &str,
task_id: &str,
session_id: &str,
run_id: &str,
loop_iteration: u32,
planned_steer_cursor: u64,
plan: &AgentRuntimeToolPlan,
project_revision_before: &AgentRuntimeProjectRevision,
planned_repository_context_fingerprint: &str,
actions: &[AgentRuntimePendingToolAction],
planning_session_binding: &PlanProviderSessionBindingV1,
) -> Result<String, String> {
let action_ids = actions
.iter()
.map(|pending| pending.action_id.as_str())
.collect::<Vec<_>>();
let identity = serde_json::to_vec(&serde_json::json!({
"schemaVersion": AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION,
"projectId": project_id,
"agentId": agent_id,
"taskId": task_id,
"sessionId": session_id,
"runId": run_id,
"loopIteration": loop_iteration,
"plannedSteerCursor": planned_steer_cursor,
"plan": plan,
"projectRevisionBefore": project_revision_before,
"plannedRepositoryContextFingerprint": planned_repository_context_fingerprint,
"actionIds": action_ids,
"planningSessionBinding": planning_session_binding,
}))
.map_err(|error| format!("序列化 Provider action 批次 v4 身份失败:{error}"))?;
let fingerprint = format!("{:x}", Sha256::digest(identity));
Ok(format!(
"provider-action-v4-{}",
fingerprint.chars().take(32).collect::<String>()
))
}
pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batch(
root: &Path,
batch: &AgentRuntimeProviderActionBatch,
@@ -126,7 +168,8 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
) -> Result<(), String> {
if !matches!(
batch.schema_version.as_str(),
AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION
AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION
| AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION
| AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION
| AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION
) {
@@ -163,8 +206,48 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
batch.status
));
}
let minimum_action_count = if batch.schema_version
!= AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION
// `plan.submit_gdd` is intentionally a sole-action durable batch. It is
// the only non-collaboration batch allowed to bypass the historical
// two-action minimum; keep the exception tied to the complete identity
// predicate so a forged one-action batch cannot widen the normal path.
let plan_schema =
batch.schema_version == AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION;
let plan_submit_batch = is_plan_submit_gdd_provider_action_batch(batch);
if plan_schema {
if !plan_submit_batch {
return Err(
"planning v4 Provider action 批次必须是唯一 plan.submit_gdd action 且无 collaboration 合同"
.to_string(),
);
}
let binding = batch
.planning_session_binding
.as_ref()
.ok_or_else(|| "planning v4 Provider action 批次缺少 session binding".to_string())?;
validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?;
if batch.actions.len() != 1
|| binding.request_kind != "tool-plan"
|| batch.actions[0].planning_session_binding.as_ref() != Some(binding)
|| batch.provider_request_id.as_deref() != Some(binding.provider_request_id.as_str())
|| batch.project_id != binding.project_id
|| batch.agent_id != binding.agent_id
|| batch.task_id != binding.task_id
|| batch.session_id != binding.session_id
|| batch.run_id != binding.run_id
|| batch.source != binding.source
|| batch.run_profile != binding.run_profile
|| batch.run_profile_binding_fingerprint != binding.run_profile_binding_fingerprint
|| batch.planned_steer_cursor != binding.applied_steer_cursor
|| batch.actions[0].provider_batch_plan_update != batch.plan.plan_update
{
return Err("planning v4 批次成员与 session binding 不一致".to_string());
}
} else if batch.planning_session_binding.is_some() || batch.provider_request_id.is_some() {
return Err("非 planning v4 批次不能携带 planning session binding".to_string());
}
let minimum_action_count = if plan_submit_batch {
1
} else if batch.schema_version != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION
&& batch.collaboration_contract.is_some()
{
1
@@ -198,6 +281,13 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
.actions
.first()
.ok_or_else(|| "Agent Runtime Provider action 批次缺少首个动作".to_string())?;
if plan_schema {
let mut recovered_plan = first_pending.tool_plan();
recovered_plan.actions = vec![first_pending.action.clone()];
if recovered_plan != batch.plan {
return Err("planning v4 批次无法从 standalone member 精确重建完整 plan".to_string());
}
}
let mut waiting_confirmation_count = 0_usize;
let mut rejected_count = 0_usize;
for (index, pending) in batch.actions.iter().enumerate() {
@@ -214,6 +304,8 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
|| pending.project_revision_before != batch.project_revision_before
|| pending.planned_repository_context_fingerprint
!= batch.planned_repository_context_fingerprint
|| pending.planning_session_binding != batch.planning_session_binding
|| pending.provider_batch_plan_update != batch.plan.plan_update
|| usize::try_from(pending.action_index).unwrap_or(usize::MAX) != index
|| pending.action != batch.plan.actions[index]
{
@@ -242,6 +334,26 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
if !action_ids.insert(pending.action_id.clone()) {
return Err("Agent Runtime Provider action 批次包含重复 actionId".to_string());
}
if plan_schema {
let binding = batch
.planning_session_binding
.as_ref()
.ok_or_else(|| "planning v4 批次缺少 session binding".to_string())?;
if pending.planning_session_binding.as_ref() != Some(binding)
|| pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL
|| pending.action_id.is_empty()
{
return Err(format!(
"planning v4 批次成员 frozen binding/action identity 不一致:index={index}"
));
}
} else if pending.planning_session_binding.is_some()
|| pending.provider_batch_plan_update.is_some()
{
return Err(format!(
"非 planning Provider action 批次成员不能携带 planning recovery materialindex={index}"
));
}
match pending.status.as_str() {
AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING => {
if pending.execution_mode != AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION
@@ -405,6 +517,26 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc
batch.collaboration_contract.as_ref(),
)?
}
AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION => {
let binding = batch
.planning_session_binding
.as_ref()
.ok_or_else(|| "planning v4 批次缺少 session binding".to_string())?;
agent_runtime_plan_provider_action_batch_id(
&batch.project_id,
&batch.agent_id,
&batch.task_id,
&batch.session_id,
&batch.run_id,
batch.loop_iteration,
batch.planned_steer_cursor,
&batch.plan,
&batch.project_revision_before,
&batch.planned_repository_context_fingerprint,
&batch.actions,
binding,
)?
}
AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION => {
agent_runtime_provider_action_batch_id(
&batch.project_id,
@@ -116,7 +116,29 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_
root,
"runtime.provider_request.capture.final_reply",
)?;
capture_game_creator_agent_runtime_provider_request_snapshot_at_locked(
if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
// Freeze the concrete final-reply request and its Provider-facing
// planning injection under the same project lock as the durable
// session binding. This mirrors tool-plan and prevents an older
// message object from being stamped with a newer session primary.
built_request = build_game_creator_agent_background_final_reply_request(
root,
agent_id,
session_id,
run_id,
task,
plan,
observations,
)?;
let wire_bytes =
capture_plan_provider_structured_injections_at(root, session_id, observations)?;
let message = render_plan_provider_structured_injections_message(&wire_bytes)?;
built_request
.2
.messages
.insert(1, LlmMessage::user(message));
}
let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot_at_locked(
root,
agent_id,
session_id,
@@ -124,8 +146,34 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_
"final-reply",
request_slot,
applied_steer_cursor,
)?
)?;
if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
let request_context_fingerprint =
game_creator_agent_runtime_plan_provider_request_context_fingerprint(
&built_request.0,
&built_request.2,
)?;
let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state;
let binding = capture_plan_provider_session_binding_for_snapshot(
root,
&runtime,
&snapshot,
&request_context_fingerprint,
)?;
snapshot.with_planning_session_binding(Some(binding))
} else {
snapshot
}
};
if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?;
validate_game_creator_llm_request_context_budget(
&built_request.0,
&built_request.2,
estimated_input_tokens,
"锁内冻结后的 final-reply 请求",
)?;
}
let (llm, config_path, request) = built_request;
let auto_compact_token_limit = llm.auto_compact_token_limit;
let stream_snapshot = provider_snapshot.clone();
@@ -4,7 +4,7 @@ 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 状态、未完成事项和下一步,明确审批或构建尚未发生。回复保持中文。";
const GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT: &str = "你是 Genarrative 的立项策划 Agent final-reply 收束器。你只能依据当前请求中明确提供的后台任务、运行中用户追加指令、收束摘要和已获准工具 observation 作答;不得使用通用角色聊天人格,也不得补充这些材料之外的项目事实。没有对应成功 observation 时,不要声称已经写入文件、提交 GDD、获得审批、生成素材、构建或验证完成;不要声称调用了未出现在 observation 中的工具,也不要把建议当成用户确认。若 observation 明确返回 plan.submit_gdd 成功,只能如实报告已提交的 GDD 版本、指纹摘要和待审批状态,不得把提交当成批准。若收束摘要或 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 {
@@ -330,7 +330,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
);
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
let protocol_prompt = if planning_agent {
"必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan已有观察足够或需要交付终态信封时调用 respond_to_user。不要调用未广告的函数,也不要把计划、动作或回复放在普通文本中。"
"必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan成稿时调用 plan.submit_gddinput 严格为 plan-submit-gdd-input.v1,只提交 game、decisions、prototypeValidationItems;不得附加 Runtime 身份、版本、时间、平台事实或 fingerprint),已有观察足够或需要交付终态信封时调用 respond_to_user。plan.submit_gdd 必须是本轮唯一 action,可与 update_agent_plan 同响应,但不能与其它动作或 respond_to_user 混合。不要调用未广告的函数,也不要把计划、动作或回复放在普通文本中。"
.to_string()
} else {
format!(
@@ -339,7 +339,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
};
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\":{{...}}}}。不要输出普通文本来代替函数调用。"
"当前 planning 子 Agent 只可调用 file.read、file.list、plan.submit_gdd、update_agent_plan、respond_to_user;未广告的函数一律不可调用。读取工具只用于获取已有项目文本和文件摘要,不代表已经写入、提交、审批或构建完成。成稿时 plan.submit_gdd 的 input 必须严格符合 plan-submit-gdd-input.v1,只提交 game、decisions、prototypeValidationItemsRuntime 会注入平台事实、身份、版本、时间和 fingerprint。plan.submit_gdd 必须是本轮唯一 action,可与 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
@@ -367,8 +367,8 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
mcp_catalog,
)?)
.with_tool_choice(platform_llm::LlmToolChoice::Required);
let request = apply_game_creator_llm_reasoning_effort(request, &llm)?
.with_web_search(false);
let request =
apply_game_creator_llm_reasoning_effort(request, &llm)?.with_web_search(false);
return Ok((llm, config_path, request, repository_context_fingerprint));
}
// M1A-4Supervisor 的 plan 根 run 需要在合成 system prompt 时收窄
@@ -757,13 +757,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_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, 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,
GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
};
fn native_input_required_fields(
@@ -1733,24 +1732,28 @@ mod tests {
}
assert!(planning_prompt_text.contains("file.read 使用"));
assert!(planning_prompt_text.contains("file.list 使用"));
assert!(planning_prompt_text.contains("plan.submit_gdd"));
assert!(planning_prompt_text.contains("plan-submit-gdd-input.v1"));
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(),
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");
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
@@ -173,7 +173,18 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
let initial_request_slot = format!("loop-{loop_index}-repair-0");
let (run_profile, _) =
agent_runtime_run_profile_identity_at(root, agent_id, run_id, None, None)?;
let mcp_catalog = read_game_creator_mcp_catalog_at(root).await?;
let mcp_catalog = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
// Planning has a structurally empty MCP surface. Do not even resolve
// the project MCP catalog here: doing so can start/connect required
// servers and make an unrelated MCP outage block an exact plan turn.
GameCreatorMcpCatalog {
fingerprint: String::new(),
servers: Vec::new(),
tools: Vec::new(),
}
} else {
read_game_creator_mcp_catalog_at(root).await?
};
let mut built_request = {
let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
root,
@@ -260,21 +271,73 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
.as_ref()
.map(|sidecar| context_compaction_result(sidecar, true));
}
let provider_snapshot = {
let (provider_snapshot, initial_planning_session_binding) = {
let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
root,
"runtime.provider_request.capture.tool_plan",
)?;
capture_game_creator_agent_runtime_provider_request_snapshot_at_locked(
root,
agent_id,
session_id,
run_id,
"tool-plan",
&initial_request_slot,
applied_steer_cursor,
)?
// Exact planning requests must freeze the session and the concrete
// request object under one project lock. Rebuild once while holding
// that lock so a session successor cannot be used to re-label an
// object assembled from an older session.
if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
built_request = build_game_creator_agent_background_tool_plan_request(
root,
agent_id,
session_id,
run_id,
task,
observations,
loop_index,
&mcp_catalog,
)?;
}
if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
let wire_bytes =
capture_plan_provider_structured_injections_at(root, session_id, observations)?;
let message = render_plan_provider_structured_injections_message(&wire_bytes)?;
built_request
.2
.messages
.insert(1, LlmMessage::user(message));
}
let provider_snapshot =
capture_game_creator_agent_runtime_provider_request_snapshot_at_locked(
root,
agent_id,
session_id,
run_id,
"tool-plan",
&initial_request_slot,
applied_steer_cursor,
)?;
let planning_session_binding = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
let request_context_fingerprint =
game_creator_agent_runtime_plan_provider_request_context_fingerprint(
&built_request.0,
&built_request.2,
)?;
let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state;
Some(capture_plan_provider_session_binding_for_snapshot(
root,
&runtime,
&provider_snapshot,
&request_context_fingerprint,
)?)
} else {
None
};
(provider_snapshot, planning_session_binding)
};
if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
estimated_input_tokens = estimate_game_creator_llm_request_tokens(&built_request.2)?;
validate_game_creator_llm_request_context_budget(
&built_request.0,
&built_request.2,
estimated_input_tokens,
"锁内冻结后的 tool-plan 请求",
)?;
}
let (llm, config_path, mut request, repository_context_fingerprint) = built_request;
let auto_compact_token_limit = llm.auto_compact_token_limit;
let format_repair_attempts = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
@@ -337,6 +400,36 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
let request_snapshot = provider_snapshot
.with_request_slot(&request_slot)
.with_web_search_enabled(request.enable_web_search);
let planning_session_binding = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
if repair_attempt == 0 {
initial_planning_session_binding.clone()
} else {
let request_context_fingerprint =
game_creator_agent_runtime_plan_provider_request_context_fingerprint(
&llm, &request,
)?;
let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait(
root,
"runtime.provider_request.freeze.plan_binding",
)?;
let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state;
let candidate = capture_plan_provider_session_binding_for_snapshot(
root,
&runtime,
&request_snapshot,
&request_context_fingerprint,
)?;
let initial = initial_planning_session_binding.as_ref().ok_or_else(|| {
"planning Provider repair 缺少 repair-0 frozen session binding".to_string()
})?;
validate_plan_provider_session_binding_repair_lineage(initial, &candidate)?;
Some(candidate)
}
} else {
None
};
let request_snapshot =
request_snapshot.with_planning_session_binding(planning_session_binding.clone());
let response = request_game_creator_agent_runtime_llm_with_persisted_transient_retry(
root,
&request_snapshot,
@@ -393,6 +486,16 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
);
}
};
let effective_planning_session_binding =
if let Some(base_binding) = planning_session_binding.as_ref() {
Some(plan_provider_session_binding_for_attempt(
base_binding,
&response_handoff.request_slot,
&response_handoff.provider_request_id,
)?)
} else {
None
};
if response_handoff.to_llm_response() != response {
return Err(
game_creator_agent_runtime_provider_handoff_reconciliation_error(
@@ -713,6 +816,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
return Ok(RequestedAgentRuntimeToolPlanOutcome::Ready(Some(
RequestedAgentRuntimeToolPlan {
plan,
planning_session_binding: effective_planning_session_binding,
repository_context_fingerprint,
mcp_catalog_fingerprint: mcp_catalog.fingerprint.clone(),
estimated_input_tokens,
@@ -879,13 +983,12 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
&protocol_error,
) && !request.function_tools.is_empty();
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.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 终态信封收束。"
"上一条输出不符合 planning 工具计划协议:{protocol_error}\n本轮修复仍只允许调用 file.read、file.list、plan.submit_gdd、update_agent_plan、respond_to_user。plan.submit_gdd 的 input 必须严格符合 plan-submit-gdd-input.v1,只提交 game、decisions、prototypeValidationItems;它必须是唯一 action,可与 update_agent_plan 同响应,但不能与其它动作或 respond_to_user 混合。不得调用或描述其它工具,不得输出普通文本来代替函数调用;需要用户决定时以 AGC_NEEDS_USER_INPUT_V1 终态信封收束。"
)));
} else if force_root_goal_contract
|| force_supervisor_initial_collaboration
@@ -904,11 +1007,10 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
|| force_autonomous_read_only_delivery
|| force_autonomous_pre_mutation
{
request.function_tools =
build_agent_runtime_native_function_tools_for_agent(
agent_id,
&mcp_catalog,
)?;
request.function_tools = 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,
@@ -68,6 +68,7 @@ fn response_stream_fixture(
),
web_search_enabled: false,
allow_idle_context_compaction: false,
planning_session_binding: None,
};
(project, state, response_revision, snapshot)
}
@@ -155,6 +155,22 @@ fn validate_agent_runtime_tool_plan_identity(
agent_id: &str,
plan: &AgentRuntimeToolPlan,
) -> Result<(), AgentRuntimeToolPlanProtocolError> {
if agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
&& agent_id.trim() != "__all_agents__"
&& plan
.actions
.iter()
.any(|action| action.tool.trim() == PLAN_SUBMIT_GDD_TOOL)
{
return Err(AgentRuntimeToolPlanProtocolError::new(
AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction,
format!(
"Agent 原生工具协议错误:Agent {} 不允许调用 {}",
agent_id.trim(),
PLAN_SUBMIT_GDD_TOOL
),
));
}
if agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
return Ok(());
}
@@ -363,5 +379,29 @@ pub(in crate::agent) fn normalize_game_creator_agent_tool_plan(
error,
)
})?;
validate_plan_submit_gdd_tool_plan(&plan).map_err(|error| {
AgentRuntimeToolPlanProtocolError::new(
AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint,
error,
)
})?;
Ok(plan)
}
fn validate_plan_submit_gdd_tool_plan(plan: &AgentRuntimeToolPlan) -> Result<(), String> {
let submit_count = plan
.actions
.iter()
.filter(|action| action.tool.trim() == PLAN_SUBMIT_GDD_TOOL)
.count();
if submit_count == 0 {
return Ok(());
}
if submit_count != 1 || plan.actions.len() != 1 || !plan.response.trim().is_empty() {
return Err(
"Agent 工具计划协议错误:plan.submit_gdd 必须是本轮唯一 action,且不能与 respond_to_user 同响应(可与 update_agent_plan 同响应)"
.to_string(),
);
}
Ok(())
}
@@ -3,6 +3,13 @@ use super::*;
pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] =
&["game-art", "icon-spec", "ui-prototype", "art-spritesheet"];
/// Exact executable action surface for the delegated planning child. The
/// two read tools are ordinary Runtime capabilities; `plan.submit_gdd` is a
/// planning-only capability and therefore must not be added to the global
/// `agent_runtime_executable_tools()` catalog.
pub(crate) const AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS: &[&str] =
&["file.read", "file.list", PLAN_SUBMIT_GDD_TOOL];
#[cfg(test)]
mod canvas_asset_kind_contract_tests {
use super::*;
@@ -14,6 +21,45 @@ mod canvas_asset_kind_contract_tests {
&["game-art", "icon-spec", "ui-prototype", "art-spritesheet"]
);
}
#[test]
fn planning_submit_confirmation_is_classified_as_deny_without_a_generic_pending_mode() {
let temporary = crate::tests::canonical_test_tempdir("planning-submit-confirm-policy-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "planning-submit-confirm-policy", "submit policy")
.expect("init policy fixture");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec![PLAN_SUBMIT_GDD_TOOL.to_string()],
agent_policies: BTreeMap::new(),
},
)
.expect("write submit confirmation policy");
let snapshot =
agent_runtime_tool_policy_snapshot_at(&root, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID)
.expect("read planning policy snapshot");
assert!(snapshot
.denied_tools
.iter()
.any(|tool| tool == PLAN_SUBMIT_GDD_TOOL));
assert!(!snapshot
.auto_tools
.iter()
.any(|tool| tool == PLAN_SUBMIT_GDD_TOOL));
assert!(!snapshot
.confirm_tools
.iter()
.any(|tool| tool == PLAN_SUBMIT_GDD_TOOL));
// The M1B-2 submit state machine has no generic confirmation
// consumer. A confirmation rule must therefore never advertise a
// confirmation execution path or create a pending sidecar.
assert!(!root.join(".agent/runtime/pending-actions").exists());
assert!(!root.join(".agent/planning/gdd.v1.json").exists());
}
}
pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
@@ -164,6 +210,29 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
auto_tools.push(tool.to_string());
}
}
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
// `plan.submit_gdd` is intentionally not in the global catalog.
// Classify it against the same project/Agent permission policy so an
// explicit deny/confirm cannot be bypassed by the planning ceiling.
let planning_submit_command = PLAN_SUBMIT_GDD_TOOL;
let denied = policy
.denied_commands
.iter()
.any(|command| command == planning_submit_command);
let confirmation_requested = policy
.confirm_commands
.iter()
.any(|command| command == planning_submit_command);
if denied || confirmation_requested {
// M1B-2 has no generic user-confirmation state for the Runtime
// commit action. An explicit confirm rule therefore fails closed
// instead of creating a pending shape the submit state machine can
// never consume.
denied_tools.push(PLAN_SUBMIT_GDD_TOOL.to_string());
} else {
auto_tools.push(PLAN_SUBMIT_GDD_TOOL.to_string());
}
}
Ok(AgentRuntimeToolPolicySnapshot {
run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(),
@@ -199,10 +268,25 @@ pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at(
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()));
let exact = AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS;
if !snapshot
.allowed_tools
.iter()
.any(|tool| tool == PLAN_SUBMIT_GDD_TOOL)
{
snapshot
.allowed_tools
.push(PLAN_SUBMIT_GDD_TOOL.to_string());
}
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
@@ -60,6 +60,11 @@ pub(super) const AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_OBSERVED: &str = "obse
pub(super) const AGENT_RUNTIME_PARALLEL_READ_BATCH_SIDECAR_MAX_BYTES: usize = 4 * 1024 * 1024;
pub(crate) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION: &str =
"game-creator-provider-action-batch.v3";
/// Exact planning batches carry the frozen provider/session binding. Keep
/// ordinary provider batches on v3 so existing recovery readers remain
/// byte-for-byte compatible.
pub(crate) const AGENT_RUNTIME_PLAN_PROVIDER_ACTION_BATCH_SCHEMA_VERSION: &str =
"game-creator-provider-action-batch.v4";
pub(super) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION: &str =
"game-creator-provider-action-batch.v2";
pub(super) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION: &str =
@@ -119,6 +124,97 @@ pub(crate) const AGENT_RUNTIME_PLAN_ROOT_CHILD_TARGET_UNSUPPORTED_KIND: &str =
pub(super) const GAME_CHAT_FIXED_TASK_GRAPH_STALLED_ERROR: &str =
"game-chat 首版固定任务图无法继续推进,拒绝回退到普通 Provider 协作波";
/// Idempotently close the planning child after the immutable GDD submit point.
/// The original submit pending/batch remain live recovery anchors until M1C-1
/// writes their terminal observation, so every other child projection must be
/// independently replayable across process kills.
pub(in crate::agent) fn ensure_project_planning_submit_child_completion_at(
root: &Path,
runtime: &mut AgentRuntimeState,
pending: &AgentRuntimePendingToolAction,
result: &PlanSubmitGddResultV1,
) -> Result<(), String> {
runtime.pending_tool_action = Some(pending.summary());
runtime.status = "idle".to_string();
runtime.phase = "completed".to_string();
runtime.current_action = format!(
"Fast GDD v{} 已完成 create-only 提交",
result.gdd_ref.version
);
runtime.waiting_on = "".to_string();
runtime.next_step = "策划子 Run 已完成".to_string();
runtime.last_response = Some(format!("Fast GDD v{} 已提交。", result.gdd_ref.version));
runtime.error = None;
complete_agent_runtime_remaining_plan_steps(runtime, "Fast GDD 已到达 create-only 提交点。");
runtime.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task_projection_once(root, runtime, &pending.action_id)?;
refresh_game_creator_agent_runtime_task_queue(root, runtime)?;
write_game_creator_agent_runtime_state(root, runtime)?;
append_game_creator_agent_runtime_action_event(
root,
runtime,
"plan.submit_gdd.committed",
"idle",
"completed",
"策划子 Run 已在 GDD 提交点终止;原 submit action 尚未 observed。",
Some(&format!(
"actionId={} · gddId={} · version={} · fingerprint={}",
pending.action_id,
result.gdd_ref.gdd_id,
result.gdd_ref.version,
result.gdd_ref.fingerprint
)),
&pending.action_id,
)?;
publish_game_creator_agent_delegate_result_for_state(
root,
runtime,
runtime.last_response.as_deref(),
);
let delegation_id = runtime
.delegation_id
.as_deref()
.ok_or_else(|| "策划子 Run 缺少 delegationId,无法验证提交回执".to_string())?;
let delivery = read_static_delegate_delivery_at(root, delegation_id)?
.ok_or_else(|| "策划子 Run 的 durable delivery 不存在".to_string())?;
if delivery.delegation_id != delegation_id
|| delivery.target_agent_id != runtime.agent_id
|| delivery.target_session_id != runtime.session_id
|| delivery.target_run_id != runtime.run_id
|| !matches!(
delivery.status,
StaticDelegateDeliveryStatus::Ready | StaticDelegateDeliveryStatus::ClaimedByParent
)
|| delivery.terminal_status.as_deref() != Some("completed")
{
return Err("策划子 Run 的 durable delivery 尚未收口为同 identity completed".to_string());
}
append_agent_db_plan_submit_gdd_committed_if_missing_for_action(
root,
&runtime.agent_id,
&runtime.run_id,
&pending.action_id,
serde_json::json!({
"recordType": "agent.runtime.plan_submit_gdd.committed",
"agentId": runtime.agent_id,
"taskId": runtime.task_id,
"sessionId": runtime.session_id,
"runId": runtime.run_id,
"actionId": pending.action_id,
"actionFingerprint": pending.action_fingerprint,
"gddId": result.gdd_ref.gdd_id,
"version": result.gdd_ref.version,
"gddFingerprint": result.gdd_ref.fingerprint,
"approvalRequestId": result.approval_request_id,
"recoveryPending": false,
}),
)?;
Ok(())
}
pub(crate) fn agent_runtime_supervisor_source_is_trusted(source: &str) -> bool {
matches!(
source.trim(),
@@ -216,8 +312,7 @@ pub(crate) fn supervisor_plan_root_identity_holds_at(
{
return Ok(false);
}
if game_creator_agent_runtime_provider_action_batch_exists(root, &task.agent_id, &task.run_id)
{
if game_creator_agent_runtime_provider_action_batch_exists(root, &task.agent_id, &task.run_id) {
let batch = read_game_creator_agent_runtime_provider_action_batch(
root,
&task.agent_id,
@@ -492,6 +587,8 @@ pub(super) const AGENT_RUNTIME_FINALIZATION_STATUS_ASSISTANT_PERSISTED: &str =
pub(super) const AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED: &str = "runtime-completed";
pub(super) const AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION: &str =
"game-creator-provider-request-lifecycle.v2";
pub(super) const AGENT_RUNTIME_PLAN_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_VERSION: &str =
"game-creator-provider-request-lifecycle.v3";
pub(super) const AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE: &str =
"agent.runtime.provider_request.lifecycle";
pub(super) const AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX: &str =
@@ -79,12 +79,87 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str)
matches!(kind.as_str(), "empty-response" | "deserialize")
}
fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool {
matches!(
error.code(),
"PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT" | "PLAN_VERSION_LIMIT_REACHED"
)
}
/// A strict submit payload rejection is a normal planning observation, not a
/// Provider/lifecycle reconciliation failure. Close the exact sole-action
/// batch, persist the rejected observation, and return a same-run continuation
/// so the planning child can correct its payload in the next tool-plan turn.
fn project_plan_submit_business_rejection_at(
root: &Path,
runtime: &mut AgentRuntimeState,
task: &str,
plan: &AgentRuntimeToolPlan,
observations: &mut Vec<AgentRuntimeToolObservation>,
loop_index: usize,
context_tracker: &mut AgentRuntimeContextWindowTracker,
pending: &AgentRuntimePendingToolAction,
error: &PlanningStorageError,
) -> Result<AgentBackgroundTaskOutcome, String> {
let batch = read_game_creator_agent_runtime_provider_action_batch(
root,
&pending.agent_id,
&pending.run_id,
)?;
if !is_plan_submit_gdd_provider_action_batch(&batch)
|| batch.actions.len() != 1
|| batch.actions[0].action_id != pending.action_id
|| batch.actions[0].action_fingerprint != pending.action_fingerprint
|| batch.actions[0].action != pending.action
{
return Err(
"plan.submit_gdd 业务拒绝时 Provider v4 batch/pending identity 不一致".to_string(),
);
}
let public_error = redact_agent_runtime_error(root, &error.to_string(), 500);
let observation = AgentRuntimeToolObservation {
tool: PLAN_SUBMIT_GDD_TOOL.to_string(),
status: "rejected".to_string(),
summary: "Fast GDD 提交被 Runtime 拒绝,请根据 observation 修正后重新提交。".to_string(),
detail: Some(public_error),
};
let mut rejected = pending.clone();
rejected.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string();
rejected.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string();
rejected.observation = Some(observation.clone());
rejected.updated_at = unix_timestamp();
project_game_creator_agent_runtime_provider_batch_abort(
root,
runtime,
task,
plan,
observations,
loop_index,
context_tracker,
&batch,
&rejected,
&observation,
)?;
let continuation = continuation_for_game_creator_agent_runtime_steer(
runtime,
&AgentRuntimeToolPlan::default(),
observations,
loop_index.saturating_add(1),
context_tracker,
);
Ok(AgentBackgroundTaskOutcome::ContinueSameRun {
state: runtime.clone(),
continuation,
})
}
fn requested_game_chat_fast_path_plan_at(
root: &Path,
plan: AgentRuntimeToolPlan,
) -> Result<RequestedAgentRuntimeToolPlan, String> {
Ok(RequestedAgentRuntimeToolPlan {
plan,
planning_session_binding: None,
repository_context_fingerprint: build_repository_startup_context_at(root)?.fingerprint,
mcp_catalog_fingerprint: String::new(),
estimated_input_tokens: 0,
@@ -1115,6 +1190,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
};
let mut planning_request_revision: AgentRuntimeProjectRevision;
let mut planning_repository_context_fingerprint: String;
let mut planning_session_binding: Option<PlanProviderSessionBindingV1>;
let action_start_index: usize;
if let Some(batch) = resumed_provider_batch.as_ref() {
let Some(first_pending) = batch.actions.first() else {
@@ -1162,6 +1238,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
planning_request_revision = batch.project_revision_before.clone();
planning_repository_context_fingerprint =
batch.planned_repository_context_fingerprint.clone();
planning_session_binding = batch.planning_session_binding.clone();
action_start_index = usize::try_from(batch.next_action_index).unwrap_or(usize::MAX);
if action_start_index >= plan.actions.len() {
return fail_game_creator_agent_background_context_at(
@@ -1225,6 +1302,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
return AgentBackgroundTaskOutcome::NeedsReconciliation;
}
} else {
planning_session_binding = None;
runtime.loop_iteration = (loop_index + 1) as u32;
runtime.max_loop_iterations = u32::try_from(
(loop_index / AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1)
@@ -1506,6 +1584,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
);
}
planning_repository_context_fingerprint = requested_plan.repository_context_fingerprint;
planning_session_binding = requested_plan.planning_session_binding.clone();
let planning_mcp_catalog_fingerprint = requested_plan.mcp_catalog_fingerprint;
plan = requested_plan.plan;
match refresh_agent_runtime_autonomous_convergence_snapshot_after_provider_at(
@@ -2193,7 +2272,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
&runtime.run_id,
)
{
match prepare_game_creator_agent_runtime_provider_action_batch(
match prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding(
&root,
&runtime,
&task,
@@ -2201,6 +2280,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
&observations,
&planning_request_revision,
&planning_repository_context_fingerprint,
planning_session_binding.as_ref(),
)
.await
{
@@ -2722,30 +2802,40 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
let confirmation_approved = prepared_action
.as_ref()
.is_some_and(|pending| !pending.is_auto() && pending.approved());
let local_policy_block = command_id.and_then(|command_id| {
if confirmation_approved {
match game_creator_agent_runtime_tool_policy_rule_for_run(
&root,
&agent_id,
&runtime.run_id,
Some(&runtime.run_profile),
Some(&runtime.run_profile_binding_fingerprint),
command_id,
) {
Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) => None,
blocked => blocked,
let local_policy_block = if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL {
// `plan.submit_gdd` is a Runtime-owned commit action. It is
// intentionally handled below before the generic policy /
// executor path; the planning-only catalog and batch shape
// checks are its authorization boundary.
None
} else {
command_id.and_then(|command_id| {
if confirmation_approved {
match game_creator_agent_runtime_tool_policy_rule_for_run(
&root,
&agent_id,
&runtime.run_id,
Some(&runtime.run_profile),
Some(&runtime.run_profile_binding_fingerprint),
command_id,
) {
Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_)) => None,
blocked => blocked,
}
} else {
game_creator_agent_runtime_tool_policy_block(
&root,
&agent_id,
runtime.run_id.as_str(),
command_id,
&action_fingerprint,
)
}
} else {
game_creator_agent_runtime_tool_policy_block(
&root,
&agent_id,
runtime.run_id.as_str(),
command_id,
&action_fingerprint,
)
}
});
let mcp_policy_block = if matches!(
})
};
let mcp_policy_block = if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL {
None
} else if matches!(
local_policy_block,
Some(AgentRuntimeToolPolicyBlock::Denied(_))
) {
@@ -2759,14 +2849,152 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
)
.await
};
let policy_block = fail_closed_agent_runtime_confirmation_for_run(
&root,
&agent_id,
&runtime.run_id,
Some(&runtime.run_profile),
Some(&runtime.run_profile_binding_fingerprint),
strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block),
);
let policy_block = if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL {
None
} else {
fail_closed_agent_runtime_confirmation_for_run(
&root,
&agent_id,
&runtime.run_id,
Some(&runtime.run_profile),
Some(&runtime.run_profile_binding_fingerprint),
strictest_agent_runtime_tool_policy_block(local_policy_block, mcp_policy_block),
)
};
if action.tool.trim() == PLAN_SUBMIT_GDD_TOOL {
// `plan.submit_gdd` is a dedicated commit state machine. It
// must not be allowed to fall through the generic observation,
// terminal-receipt, batch-cursor or final-reply paths.
let Some(mut pending_action) = prepared_action.take() else {
return fail_game_creator_agent_background_context_at(
&root,
&agent_id,
&session_id,
runtime,
"plan.submit_gdd 缺少 durable pending action identity",
);
};
if pending_action.execution_mode != AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO
|| !matches!(
pending_action.status.as_str(),
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED
| AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING
)
{
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
&root,
&mut runtime,
&pending_action,
"plan.submit_gdd pending action 不是严格 auto/approved(or executing) 形状",
);
return AgentBackgroundTaskOutcome::NeedsReconciliation;
}
pending_action.updated_at = unix_timestamp();
if let Err(error) =
write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action)
{
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
&root,
&mut runtime,
&pending_action,
&format!("plan.submit_gdd 执行前 pending 无法持久化:{error}"),
);
return AgentBackgroundTaskOutcome::NeedsReconciliation;
}
let action_is_current =
match mark_game_creator_agent_runtime_auto_action_executing_if_current(
&root,
&mut pending_action,
) {
Ok(current) => current,
Err(error) => {
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
&root,
&mut runtime,
&pending_action,
&format!(
"plan.submit_gdd 尚未执行,但无法持久化 executing 状态:{error}"
),
);
return AgentBackgroundTaskOutcome::NeedsReconciliation;
}
};
if !action_is_current {
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
&root,
&mut runtime,
&pending_action,
"plan.submit_gdd action 在执行前被 steer cursor 作废",
);
return AgentBackgroundTaskOutcome::NeedsReconciliation;
}
match execute_plan_submit_gdd_for_pending_action(&root, &runtime, &pending_action) {
Ok(result) if !result.recovery_pending => {
if let Err(error) = ensure_project_planning_submit_child_completion_at(
&root,
&mut runtime,
&pending_action,
&result,
) {
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
&root,
&mut runtime,
&pending_action,
&format!("GDD 提交后子 Run 收口失败:{error}"),
);
return AgentBackgroundTaskOutcome::NeedsReconciliation;
}
return AgentBackgroundTaskOutcome::Finished;
}
Ok(result) => {
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
&root,
&mut runtime,
&pending_action,
&format!(
"GDD v{} 已越过提交点但投影尚未收口(recoveryPending=true",
result.gdd_ref.version
),
);
return AgentBackgroundTaskOutcome::NeedsReconciliation;
}
Err(error) => {
if plan_submit_error_is_business_rejection(&error) {
match project_plan_submit_business_rejection_at(
&root,
&mut runtime,
&task,
&plan,
&mut observations,
loop_index,
&mut context_tracker,
&pending_action,
&error,
) {
Ok(outcome) => return outcome,
Err(projection_error) => {
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
&root,
&mut runtime,
&pending_action,
&format!(
"Fast GDD 业务拒绝 observation 投影失败:{projection_error}"
),
);
return AgentBackgroundTaskOutcome::NeedsReconciliation;
}
}
}
let _ = mark_game_creator_agent_runtime_needs_reconciliation_at(
&root,
&mut runtime,
&pending_action,
&format!("Fast GDD 提交未收口:{error}"),
);
return AgentBackgroundTaskOutcome::NeedsReconciliation;
}
}
}
let mut durable_action = None;
let observation = if let Some(blocked) = policy_block {
agent_runtime_tool_policy_block_observation(action.tool.trim(), blocked)
@@ -4766,3 +4766,311 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac
.chunks_exact(2)
.all(|pair| pair == ["started", "failed"]));
}
fn planning_submit_completion_fixture_at(
root: &Path,
) -> (
AgentRuntimeState,
AgentRuntimePendingToolAction,
PlanSubmitGddResultV1,
String,
) {
init_local_game_project_at(root, "planning-submit-completion", "策划提交终态恢复")
.expect("initialize planning submit completion project");
let parent_run_id = "planning-submit-completion-parent-run";
start_game_creator_agent_runtime_task_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"收敛 Fast GDD",
parent_run_id,
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
"准备委派立项策划 Agent",
vec!["委派 project-planning".to_string()],
)
.expect("start planning Supervisor root");
bind_game_creator_agent_runtime_run_profile_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
None,
)
.expect("bind planning Supervisor root");
let planning_lane = try_acquire_game_creator_agent_runtime_task_lock(
root,
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
)
.expect("acquire planning child lane")
.expect("planning child lane is available");
let delegate_action_id = "action-111111111111111111111111";
let observation = observe_agent_runtime_agent_delegate(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
Some(delegate_action_id),
&serde_json::json!({
"agentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"task": "输出可审批的 Fast GDD",
"acceptanceCriteria": ["提交 strict Fast GDD"],
"expectedArtifacts": [],
"repairOfDelegationId": null,
"runId": null
}),
);
assert_eq!(observation.status, "ok", "{observation:?}");
let delegation_id = agent_runtime_delegation_id(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
parent_run_id,
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
delegate_action_id,
);
let child_task = read_latest_game_creator_agent_runtime_task_by_delegation_id(
root,
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
&delegation_id,
)
.expect("read planning child task")
.expect("planning child task exists");
drop(planning_lane);
let mut runtime = agent_runtime_state_from_task_record(&child_task);
runtime.loop_iteration = 1;
let action = AgentRuntimeToolAction {
tool: PLAN_SUBMIT_GDD_TOOL.to_string(),
reason: Some("提交已校验的 Fast GDD".to_string()),
input: serde_json::json!({"schemaVersion": PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION}),
};
let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task);
let occurrence_nonce = 42;
let action_id = agent_runtime_tool_action_id(
&runtime.run_id,
runtime.loop_iteration,
0,
occurrence_nonce,
&action_fingerprint,
);
let now = unix_timestamp();
let pending = AgentRuntimePendingToolAction {
schema_version: AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION.to_string(),
fingerprint_version: AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION.to_string(),
agent_id: runtime.agent_id.clone(),
task_id: runtime.task_id.clone(),
session_id: runtime.session_id.clone(),
run_id: runtime.run_id.clone(),
source: runtime.source.clone(),
run_profile: runtime.run_profile.clone(),
run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(),
planning_session_binding: None,
provider_batch_plan_update: None,
task: runtime.current_task.clone(),
goal_id: runtime.goal_id.clone(),
goal_revision: runtime.goal_revision,
goal_snapshot_fingerprint: agent_goal_snapshot_fingerprint_for_state_at(root, &runtime)
.expect("read planning child goal snapshot"),
loop_iteration: runtime.loop_iteration,
action_index: 0,
occurrence_nonce,
thinking_summary: "Fast GDD 已通过 strict 校验".to_string(),
plan: vec!["提交 Fast GDD".to_string()],
fallback_response: String::new(),
observations: Vec::new(),
project_revision_before: read_game_creator_agent_runtime_project_revision(root)
.expect("read planning submit project revision"),
verification_gate_before: read_game_creator_agent_runtime_verification_gate(
root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read planning submit verification gate"),
planned_repository_context_fingerprint: build_repository_startup_context_at(root)
.expect("read planning submit repository context")
.fingerprint,
planned_steer_cursor: runtime.applied_steer_cursor,
action,
action_id: action_id.clone(),
action_fingerprint,
input_summary: None,
execution_mode: AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(),
status: AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(),
observation: None,
created_at: now,
updated_at: now,
};
let result = PlanSubmitGddResultV1 {
outcome: "submitted".to_string(),
gdd_ref: PlanGddRef {
gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(),
version: 1,
fingerprint: format!("sha256-serde-json-v2:{}", "a".repeat(64)),
},
pending_action_id: action_id,
approval_request_id: "gdd-approval-00000000-0000-4000-8000-000000000002".to_string(),
recovery_pending: false,
};
(runtime, pending, result, delegation_id)
}
fn read_planning_submit_completion_jsonl(path: &Path) -> Vec<serde_json::Value> {
fs::read_to_string(path)
.ok()
.into_iter()
.flat_map(|content| {
content
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
serde_json::from_str::<serde_json::Value>(line)
.expect("parse planning submit completion JSONL")
})
.collect::<Vec<_>>()
})
.collect()
}
fn planning_submit_completion_audit_count(
root: &Path,
record_type: &str,
action_id: &str,
) -> usize {
read_planning_submit_completion_jsonl(&root.join(".agent/agent.db"))
.into_iter()
.filter(|record| {
record.get("recordType").and_then(serde_json::Value::as_str) == Some(record_type)
&& record.get("actionId").and_then(serde_json::Value::as_str) == Some(action_id)
})
.count()
}
#[test]
fn planning_submit_child_completion_is_action_scoped_and_idempotent() {
let temporary = tempfile::tempdir().expect("create planning submit completion root");
let root = temporary.path().join("project");
let (mut runtime, pending, result, delegation_id) =
planning_submit_completion_fixture_at(&root);
ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result)
.expect("complete planning child first time");
ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result)
.expect("replay planning child completion");
let task_projection_count = read_planning_submit_completion_jsonl(
&game_creator_agent_runtime_task_path(&root, &runtime.agent_id),
)
.into_iter()
.filter(|record| {
record.get("runId").and_then(serde_json::Value::as_str) == Some(runtime.run_id.as_str())
&& record.get("actionId").and_then(serde_json::Value::as_str)
== Some(pending.action_id.as_str())
&& record.get("phase").and_then(serde_json::Value::as_str) == Some("completed")
})
.count();
assert_eq!(task_projection_count, 1);
let action_event_count = read_planning_submit_completion_jsonl(
&game_creator_agent_runtime_event_path(&root, &runtime.agent_id),
)
.into_iter()
.filter(|record| {
record.get("eventType").and_then(serde_json::Value::as_str)
== Some("plan.submit_gdd.committed")
&& record.get("actionId").and_then(serde_json::Value::as_str)
== Some(pending.action_id.as_str())
})
.count();
assert_eq!(action_event_count, 1);
let matching_deliveries = list_static_delegate_deliveries_at(&root)
.expect("list planning child deliveries")
.into_iter()
.filter(|delivery| delivery.delegation_id == delegation_id)
.collect::<Vec<_>>();
assert_eq!(matching_deliveries.len(), 1);
assert_eq!(
matching_deliveries[0].status,
StaticDelegateDeliveryStatus::Ready
);
assert_eq!(
matching_deliveries[0].terminal_status.as_deref(),
Some("completed")
);
assert_eq!(
planning_submit_completion_audit_count(
&root,
"agent.runtime.agent.delegate_receipt.ready",
"action-111111111111111111111111",
),
1
);
assert_eq!(
planning_submit_completion_audit_count(
&root,
"agent.runtime.plan_submit_gdd.committed",
&pending.action_id,
),
1
);
}
#[test]
fn planning_submit_child_completion_waits_for_exact_delivery_before_committed_audit() {
let temporary = tempfile::tempdir().expect("create planning submit delivery recovery root");
let root = temporary.path().join("project");
let (mut runtime, pending, result, delegation_id) =
planning_submit_completion_fixture_at(&root);
let delivery = read_static_delegate_delivery_at(&root, &delegation_id)
.expect("read dispatched planning delivery")
.expect("dispatched planning delivery exists");
fs::remove_file(
root.join(".agent/runtime/delegation-deliveries")
.join(format!("{delegation_id}.json")),
)
.expect("remove planning delivery to model post-commit interruption");
let error =
ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result)
.expect_err("completion must wait for exact durable delivery");
assert!(error.contains("durable delivery 不存在"), "{error}");
assert_eq!(
planning_submit_completion_audit_count(
&root,
"agent.runtime.plan_submit_gdd.committed",
&pending.action_id,
),
0,
"delivery 未 durable 前不得写 recoveryPending=false committed audit"
);
create_or_read_static_delegate_delivery_at(&root, &delivery)
.expect("restore exact dispatched planning delivery");
ensure_project_planning_submit_child_completion_at(&root, &mut runtime, &pending, &result)
.expect("resume same planning submit action after delivery repair");
let recovered_delivery = read_static_delegate_delivery_at(&root, &delegation_id)
.expect("read recovered planning delivery")
.expect("recovered planning delivery exists");
assert_eq!(
recovered_delivery.status,
StaticDelegateDeliveryStatus::Ready
);
assert_eq!(
recovered_delivery.terminal_status.as_deref(),
Some("completed")
);
let committed = read_planning_submit_completion_jsonl(&root.join(".agent/agent.db"))
.into_iter()
.filter(|record| {
record.get("recordType").and_then(serde_json::Value::as_str)
== Some("agent.runtime.plan_submit_gdd.committed")
&& record.get("actionId").and_then(serde_json::Value::as_str)
== Some(pending.action_id.as_str())
})
.collect::<Vec<_>>();
assert_eq!(committed.len(), 1);
assert_eq!(
committed[0]
.get("recoveryPending")
.and_then(serde_json::Value::as_bool),
Some(false)
);
}
File diff suppressed because it is too large Load Diff
@@ -51,6 +51,153 @@ pub(in crate::agent) fn mark_waiting_provider_retry_needs_reconciliation_at(
read_game_creator_agent_runtime_at(root, agent_id)
}
struct MissingPlanSubmitAnchorCandidate {
runtime: AgentRuntimeState,
action_id: String,
action_fingerprint: String,
commit_matches: bool,
}
/// Detect a planning submit whose immutable GDD or Runtime action summary
/// survived while both generic recovery anchors vanished. The GDD is needed
/// for the real commit-point/child-finish gap because Runtime does not publish
/// `pending_tool_action` into state until child finish. Once this detector has
/// projected its own reconciliation state, do not append it again.
fn missing_plan_submit_anchor_candidate_at(
root: &Path,
agent_id: &str,
) -> Result<Option<MissingPlanSubmitAnchorCandidate>, String> {
let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state;
if runtime.phase == "needs-reconciliation"
&& runtime.current_action == "Fast GDD 提交恢复锚点需要人工核对"
{
return Ok(None);
}
if runtime.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
|| runtime.source != "agent-delegate"
|| runtime.run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD
|| runtime.run_id.trim().is_empty()
|| runtime.session_id.trim().is_empty()
{
return Ok(None);
}
let pending_exists =
game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, &runtime.run_id);
let batch_exists =
game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, &runtime.run_id);
if pending_exists || batch_exists {
return Ok(None);
}
let chain = read_plan_gdd_chain(root).map_err(|error| error.to_string())?;
let matching_gdds = chain
.iter()
.filter(|gdd| {
gdd.agent_id == runtime.agent_id
&& gdd.source == runtime.source
&& gdd.run_profile == runtime.run_profile
&& gdd.run_profile_binding_fingerprint == runtime.run_profile_binding_fingerprint
&& gdd.session_id == runtime.session_id
&& gdd.created_by_run_id == runtime.run_id
})
.collect::<Vec<_>>();
let (action_id, action_fingerprint, commit_matches) =
if let Some(summary) = runtime.pending_tool_action.as_ref() {
if summary.tool.trim() != PLAN_SUBMIT_GDD_TOOL
|| summary.action_id.trim().is_empty()
|| summary.action_fingerprint.trim().is_empty()
{
if runtime.phase == "completed" || matching_gdds.len() != 1 {
return Ok(None);
}
let gdd = matching_gdds[0];
(
gdd.submission_id.clone(),
gdd.action_fingerprint.clone(),
false,
)
} else {
let commit_matches = matching_gdds.iter().any(|gdd| {
gdd.submission_id == summary.action_id
&& gdd.action_fingerprint == summary.action_fingerprint
});
(
summary.action_id.clone(),
summary.action_fingerprint.clone(),
commit_matches,
)
}
} else if runtime.phase != "completed" && matching_gdds.len() == 1 {
let gdd = matching_gdds[0];
(
gdd.submission_id.clone(),
gdd.action_fingerprint.clone(),
true,
)
} else {
return Ok(None);
};
Ok(Some(MissingPlanSubmitAnchorCandidate {
runtime,
action_id,
action_fingerprint,
commit_matches,
}))
}
fn reconcile_missing_plan_submit_anchors_at(
root: &Path,
agent_id: &str,
) -> Result<Option<AgentRuntimeResult>, String> {
let Some(candidate) = missing_plan_submit_anchor_candidate_at(root, agent_id)? else {
return Ok(None);
};
let MissingPlanSubmitAnchorCandidate {
mut runtime,
action_id,
action_fingerprint,
commit_matches,
} = candidate;
let error = if commit_matches {
"Fast GDD 已提交,但原 plan.submit_gdd 的 pending/batch 恢复锚点同时缺失"
} else {
"策划子 Run 声称 Fast GDD 已提交,但 immutable GDD 与 Runtime action identity 无法对账"
};
runtime.status = "failed".to_string();
runtime.phase = "needs-reconciliation".to_string();
runtime.current_action = "Fast GDD 提交恢复锚点需要人工核对".to_string();
runtime.waiting_on = "开发者核对 immutable GDD 与原 submit action identity".to_string();
runtime.next_step = "核实并恢复原精确 pending/batch 锚点后再继续".to_string();
runtime.error = Some(error.to_string());
runtime.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &runtime)?;
refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)?;
write_game_creator_agent_runtime_state(root, &runtime)?;
append_game_creator_agent_runtime_event(
root,
&runtime,
"plan.submit_gdd.anchor_missing",
"failed",
"needs-reconciliation",
"Runner 检测到已完成策划提交缺少恢复锚点,已停止自动清理与续跑。",
Some(error),
)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.plan_submit_gdd.anchor_missing",
"agentId": runtime.agent_id,
"taskId": runtime.task_id,
"sessionId": runtime.session_id,
"runId": runtime.run_id,
"actionId": action_id,
"actionFingerprint": action_fingerprint,
"commitMatches": commit_matches,
}),
)?;
emit_game_creator_agent_runtime_update(root, agent_id);
read_game_creator_agent_runtime_at(root, agent_id).map(Some)
}
pub(in crate::agent) fn ensure_waiting_provider_retry_projection_at(
root: &Path,
retry: &AgentRuntimeProviderRetryRecord,
@@ -446,6 +593,10 @@ pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at(
}
for agent_id in collect_game_creator_agent_runtime_agent_ids(root)? {
match missing_plan_submit_anchor_candidate_at(root, &agent_id) {
Ok(Some(_)) | Err(_) => return Ok(true),
Ok(None) => {}
}
match read_recoverable_game_creator_agent_runtime_task(root, &agent_id) {
Ok(Some(_)) | Err(_) => return Ok(true),
Ok(None) => {}
@@ -748,6 +899,11 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at
else {
continue;
};
if let Some(result) = reconcile_missing_plan_submit_anchors_at(root, &agent_id)? {
resumed.push(result);
drop(runtime_lock);
continue;
}
let mut retry_projection_blocked = false;
if let Some(retries) = retry_records_by_agent.remove(&agent_id) {
for retry in retries {
@@ -1242,6 +1398,234 @@ pub(crate) fn resume_game_creator_agent_pending_action_for_agent_at(
mod orphaned_external_generation_recovery_tests {
use super::*;
fn valid_plan_submit_input_for_recovery() -> PlanSubmitGddInputV1 {
serde_json::from_value(serde_json::json!({
"schemaVersion": PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION,
"game": {
"title": "萤火守夜者",
"genre": {"primary": "轻策略", "fusion": null},
"artStyle": {
"visualType": "手绘平面",
"keywords": ["暖色", "剪影", "纸感"],
"moodAndColor": "夜色中的暖黄灯火",
"mvpArtBoundary": "仅制作可复用的角色、灯火和地块素材"
},
"oneLiner": "玩家在一局十分钟的守夜旅程中分配有限灯火、判断风险并选择路线,守住营地后寻找下一处安全落脚点",
"pillars": [
{
"name": "取舍",
"playerFeel": "每次选择都有代价",
"mechanism": "有限灯火在路线与营地之间分配",
"decisionState": "confirmed"
},
{
"name": "重玩",
"playerFeel": "想再试一次更优路线",
"mechanism": "不同路线组合产生不同风险",
"decisionState": "confirmed"
}
],
"coreLoop": ["观察地图", "分配灯火", "选择路线", "处理事件"],
"targetUsers": {
"coreUsers": "喜欢短局策略的玩家",
"preferences": "偏好清晰反馈和轻量决策",
"sessionLength": "10至20分钟",
"referenceGames": []
},
"mvpSystems": [
{
"system": "地图",
"minimalFunction": "展示当前营地与可选路线",
"whyRequired": "让玩家理解空间选择",
"verifyMethod": "能完成一局并看懂下一步",
"decisionState": "confirmed"
},
{
"system": "灯火",
"minimalFunction": "消耗灯火换取安全或探索",
"whyRequired": "承载核心取舍",
"verifyMethod": "两种分配策略结果可区分",
"decisionState": "confirmed"
},
{
"system": "事件",
"minimalFunction": "路线途中触发一项选择",
"whyRequired": "提供短局变化",
"verifyMethod": "重玩时可遇到不同事件",
"decisionState": "confirmed"
}
],
"outOfScope": ["多人联机"],
"creatorTips": {
"doFirst": "先做一张可走完的地图",
"deferForNow": "暂缓复杂成长线",
"howToVerify": "观察玩家是否能说出每次选择的后果",
"expandWhen": "核心循环连续三局都可理解后再扩展"
}
},
"decisions": [{
"id": "initial-request",
"topic": "初始需求",
"state": "confirmed",
"answerSource": "user_freeform",
"round": 0,
"answerSummary": "做一个短局守夜策略游戏"
}],
"prototypeValidationItems": []
}))
.expect("valid plan submit recovery input")
}
#[test]
fn committed_plan_submit_without_either_anchor_is_publicly_recoverable_and_fails_closed() {
let temporary = crate::tests::canonical_test_tempdir("plan-submit-double-anchor-");
let root = temporary.path();
init_local_game_project_at(
root,
"plan-submit-double-anchor",
"策划提交双锚缺失恢复测试",
)
.expect("init project");
bind_game_creator_agent_runtime_run_profile_at(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"plan-submit-double-anchor-root",
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
None,
)
.expect("bind planning root");
let link = AgentRuntimeTaskLink {
parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()),
parent_run_id: Some("plan-submit-double-anchor-root".to_string()),
delegation_id: Some("plan-submit-double-anchor-delegation".to_string()),
};
bind_game_creator_agent_runtime_run_profile_at(
root,
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"plan-submit-double-anchor-child",
"agent-delegate",
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
Some(&link),
)
.expect("bind planning child");
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"提交 Fast GDD",
"plan-submit-double-anchor-child",
"agent-delegate",
"提交 Fast GDD",
vec!["提交 Fast GDD".to_string()],
)
.expect("start planning child");
let action_id = "action-0123456789abcdef01234567";
let action_fingerprint = "a".repeat(64);
let context = PlanSubmitGddRuntimeContext {
project_id: "plan-submit-double-anchor".to_string(),
gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(),
action_id: action_id.to_string(),
action_fingerprint: action_fingerprint.clone(),
agent_id: runtime.agent_id.clone(),
source: runtime.source.clone(),
run_profile: runtime.run_profile.clone(),
run_profile_binding_fingerprint: runtime.run_profile_binding_fingerprint.clone(),
root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
root_run_id: "plan-submit-double-anchor-root".to_string(),
parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()),
parent_run_id: Some("plan-submit-double-anchor-root".to_string()),
delegation_id: "plan-submit-double-anchor-delegation".to_string(),
session_id: runtime.session_id.clone(),
source_session_revision: 1,
source_session_fingerprint: format!("sha256-serde-json-v2:{}", "b".repeat(64)),
created_by_run_id: runtime.run_id.clone(),
created_at_utc: "2026-08-14T00:00:00.000Z".to_string(),
approval_request_id: Some(
"gdd-approval-00000000-0000-4000-8000-000000000002".to_string(),
),
};
let gdd = build_plan_gdd_from_submit_input(
&valid_plan_submit_input_for_recovery(),
&context,
1,
context
.approval_request_id
.as_deref()
.expect("approval request id"),
)
.expect("build committed GDD");
let gdd_bytes = canonical_plan_gdd_bytes(&gdd).expect("canonical committed GDD");
durable_create_json_no_replace(root, ".agent/planning/gdd.v1.json", &gdd_bytes, "GDD")
.expect("persist committed GDD");
assert_eq!(
read_plan_gdd_chain(root)
.expect("read committed GDD chain")
.len(),
1
);
// Model the commit-point/post-finish gap: the GDD fact is durable,
// both generic anchors are gone, but the child has not reached its
// terminal projection yet.
runtime.status = "running".to_string();
runtime.phase = "provider-action-batch".to_string();
runtime.pending_tool_action = None;
runtime.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &runtime).expect("append pre-finish task");
refresh_game_creator_agent_runtime_task_queue(root, &mut runtime)
.expect("refresh pre-finish task queue");
write_game_creator_agent_runtime_state(root, &runtime).expect("write pre-finish state");
assert!(
missing_plan_submit_anchor_candidate_at(root, &runtime.agent_id)
.expect("detect missing pre-finish anchors")
.is_some()
);
assert!(has_recoverable_game_creator_agent_background_tasks_at(root)
.expect("public preflight must expose double-anchor loss"));
let resumed = resume_game_creator_agent_background_tasks_at(root)
.expect("public recovery must fail closed in runtime state");
assert!(resumed
.iter()
.any(|result| result.state.agent_id == runtime.agent_id
&& result.state.phase == "needs-reconciliation"));
let reconciled = read_game_creator_agent_runtime_at(root, &runtime.agent_id)
.expect("read reconciled planning child");
assert_eq!(reconciled.state.phase, "needs-reconciliation");
assert!(reconciled
.state
.error
.as_deref()
.is_some_and(|error| error.contains("恢复锚点同时缺失")));
let audit_count = read_agent_db_records_bounded(root, 1024 * 1024)
.expect("read anchor-missing audit")
.0
.iter()
.filter(|record| {
record.get("recordType").and_then(|value| value.as_str())
== Some("agent.runtime.plan_submit_gdd.anchor_missing")
})
.count();
assert_eq!(audit_count, 1);
assert!(
!has_recoverable_game_creator_agent_background_tasks_at(root)
.expect("the detector must not rediscover its own reconciliation")
);
let _ = resume_game_creator_agent_background_tasks_at(root)
.expect("second recovery scan may surface the existing reconciliation state");
let audit_count_after_second_scan = read_agent_db_records_bounded(root, 1024 * 1024)
.expect("reread anchor-missing audit")
.0
.iter()
.filter(|record| {
record.get("recordType").and_then(|value| value.as_str())
== Some("agent.runtime.plan_submit_gdd.anchor_missing")
})
.count();
assert_eq!(audit_count_after_second_scan, audit_count);
}
#[test]
fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() {
let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-");
@@ -9,6 +9,7 @@ mod finalization;
mod json_sidecar;
mod models;
mod planning_storage;
mod planning_submit;
mod provider_control;
mod provider_retry;
mod real_e2e_checkpoint;
@@ -22,7 +23,8 @@ pub(in crate::agent) use context_bundle::*;
pub(in crate::agent) use finalization::*;
pub(in crate::agent) use json_sidecar::*;
pub(in crate::agent) use models::*;
pub(in crate::agent) use planning_storage::*;
pub(crate) use planning_storage::*;
pub(crate) use planning_submit::*;
pub(in crate::agent) use provider_control::*;
pub(in crate::agent) use provider_retry::*;
pub(in crate::agent) use real_e2e_checkpoint::*;
@@ -310,12 +310,9 @@ fn plan_root_retry_rejects_identity_mismatch_instead_of_degrading() {
"",
&session_id,
);
let missing_error = resolve_game_creator_agent_runtime_retry_configuration_at(
&root,
&missing_binding,
false,
)
.expect_err("缺少 binding 必须拒绝而不是降级");
let missing_error =
resolve_game_creator_agent_runtime_retry_configuration_at(&root, &missing_binding, false)
.expect_err("缺少 binding 必须拒绝而不是降级");
assert!(
missing_error.contains(AGENT_RUNTIME_PLAN_ROOT_RETRY_IDENTITY_UNSUPPORTED_KIND),
"{missing_error}"
@@ -460,7 +457,9 @@ fn plan_root_retry_keeps_plan_source_and_goal_contract_authority() {
assert_eq!(retry_binding.source, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE);
assert_eq!(retry_binding.profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD);
assert!(retry_binding.parent_agent_id.is_none());
assert!(agent_runtime_supervisor_source_is_trusted(&retry_binding.source));
assert!(agent_runtime_supervisor_source_is_trusted(
&retry_binding.source
));
let steer_error = steer_game_creator_agent_runtime_task_at(
&root,
@@ -136,6 +136,7 @@ pub(crate) struct ParsedAgentRuntimeToolPlan {
pub(in crate::agent) struct RequestedAgentRuntimeToolPlan {
pub(in crate::agent) plan: AgentRuntimeToolPlan,
pub(in crate::agent) planning_session_binding: Option<PlanProviderSessionBindingV1>,
pub(in crate::agent) repository_context_fingerprint: String,
pub(in crate::agent) mcp_catalog_fingerprint: String,
pub(in crate::agent) estimated_input_tokens: u64,
@@ -245,6 +246,10 @@ pub(crate) struct AgentRuntimeProviderRequestSnapshot {
pub(in crate::agent) request_slot: String,
pub(in crate::agent) web_search_enabled: bool,
pub(in crate::agent) allow_idle_context_compaction: bool,
/// Exact-plan requests carry the source session captured before the
/// Provider call. Ordinary requests keep this `None` and retain the
/// legacy lifecycle/batch identity path.
pub(in crate::agent) planning_session_binding: Option<PlanProviderSessionBindingV1>,
}
impl AgentRuntimeProviderRequestSnapshot {
@@ -265,6 +270,15 @@ impl AgentRuntimeProviderRequestSnapshot {
snapshot.allow_idle_context_compaction = allow;
snapshot
}
pub(in crate::agent) fn with_planning_session_binding(
&self,
binding: Option<PlanProviderSessionBindingV1>,
) -> Self {
let mut snapshot = self.clone();
snapshot.planning_session_binding = binding;
snapshot
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -114,6 +114,8 @@ pub(crate) const PLAN_STORAGE_ROOT: &str = ".agent/planning";
pub(crate) const PLAN_GDD_INDEX_PATH: &str = ".agent/planning/index.json";
pub(crate) const PLAN_SESSION_PATH: &str = ".agent/planning/session.json";
pub(crate) const PLAN_SESSION_PREVIOUS_PATH: &str = ".agent/planning/.session.json.previous";
pub(crate) const PLAN_FAST_GDD_PATH: &str = "game/fast_gdd.md";
pub(crate) const PLAN_FAST_GDD_MAX_BYTES: usize = 128 * 1024;
static PLANNING_TEMP_NONCE: AtomicU64 = AtomicU64::new(1);
@@ -124,7 +126,7 @@ pub(crate) struct PlanningStorageError {
}
impl PlanningStorageError {
fn new(code: &'static str, detail: impl Into<String>) -> Self {
pub(crate) fn new(code: &'static str, detail: impl Into<String>) -> Self {
Self {
code,
detail: detail.into(),
@@ -208,17 +210,17 @@ fn is_lower_hex(value: &str, length: usize) -> bool {
is_hex(value, length) && value.bytes().all(|byte| !byte.is_ascii_uppercase())
}
fn is_typed_fingerprint(value: &str) -> bool {
pub(crate) fn is_typed_fingerprint(value: &str) -> bool {
value
.strip_prefix("sha256-serde-json-v2:")
.is_some_and(|digest| is_lower_hex(digest, 64))
}
fn is_bare_fingerprint(value: &str) -> bool {
pub(crate) fn is_bare_fingerprint(value: &str) -> bool {
is_lower_hex(value, 64)
}
fn validate_text(
pub(crate) fn validate_text(
value: &str,
label: &str,
min: usize,
@@ -259,7 +261,7 @@ pub(crate) fn normalize_plan_text(
Ok(normalized)
}
fn validate_opaque_id(
pub(crate) fn validate_opaque_id(
value: &str,
label: &str,
allow_empty: bool,
@@ -282,7 +284,7 @@ fn validate_opaque_id(
Ok(())
}
fn validate_uuid_prefixed(
pub(crate) fn validate_uuid_prefixed(
value: &str,
prefix: &str,
label: &str,
@@ -305,7 +307,7 @@ fn validate_uuid_prefixed(
Ok(())
}
fn validate_action_id(value: &str, label: &str) -> Result<(), PlanningStorageError> {
pub(crate) fn validate_action_id(value: &str, label: &str) -> Result<(), PlanningStorageError> {
let Some(digest) = value.strip_prefix("action-") else {
return Err(invalid(format!("{label} 必须以 action- 开头")));
};
@@ -315,7 +317,7 @@ fn validate_action_id(value: &str, label: &str) -> Result<(), PlanningStorageErr
Ok(())
}
fn validate_timestamp(value: &str, label: &str) -> Result<(), PlanningStorageError> {
pub(crate) fn validate_timestamp(value: &str, label: &str) -> Result<(), PlanningStorageError> {
validate_text(value, label, 24, 24)?;
if value.len() != 24 || !value.is_ascii() {
return Err(invalid(format!("{label} 必须是 ASCII UTC 毫秒时间")));
@@ -3002,7 +3004,16 @@ pub(crate) fn durable_create_json_no_replace_locked(
return Err(io_error(&format!("发布 {label} 失败"), error));
}
};
sync_planning_parent(parent)?;
sync_planning_parent(parent).map_err(|error| {
// The target may already have been atomically published when the
// directory flush fails. This is an unknown commit-point result,
// not a normal rejection: callers must reconcile the target before
// claiming committed/replayed semantics.
PlanningStorageError::new(
"PLAN_COMMIT_UNKNOWN",
format!("{label} 已发布但父目录同步结果未知:{error}"),
)
})?;
Ok(outcome)
}
@@ -3063,6 +3074,127 @@ pub(crate) fn write_plan_gdd_index_atomic_locked(
result
}
/// Atomically publish the human-readable Fast GDD projection. This is a
/// Runtime-owned projection writer, deliberately separate from the generic
/// `file.write` gate (which rejects this path for planning Agents). The
/// caller must hold the project write lock when using the `_locked` variant.
pub(crate) fn write_plan_fast_gdd_markdown_atomic(
root: &Path,
markdown: &str,
) -> Result<(), PlanningStorageError> {
let _lock = acquire_project_write_lock(root, "planning.fast-gdd")
.map_err(|error| io_error("取得 Fast GDD 投影项目锁失败", error))?;
write_plan_fast_gdd_markdown_atomic_locked(root, markdown)
}
pub(crate) fn write_plan_fast_gdd_markdown_atomic_locked(
root: &Path,
markdown: &str,
) -> Result<(), PlanningStorageError> {
let bytes = markdown.as_bytes();
if bytes.is_empty() {
return Err(invalid("Fast GDD Markdown 不能为空"));
}
if bytes.len() > PLAN_FAST_GDD_MAX_BYTES {
return Err(PlanningStorageError::new(
"PLAN_SIZE_LIMIT",
format!(
"Fast GDD Markdown 超过 {} 字节上限",
PLAN_FAST_GDD_MAX_BYTES
),
));
}
if bytes.contains(&0) || !std::str::from_utf8(bytes).is_ok() {
return Err(invalid("Fast GDD Markdown 必须是无 NUL 的 UTF-8 文本"));
}
let target = resolve_local_project_path(root, PLAN_FAST_GDD_PATH)
.map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?;
let parent = target
.parent()
.ok_or_else(|| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 缺少父目录"))?;
// The projection lives outside `.agent/planning`, so it cannot use the
// planning-only parent helper. Build the relative `game/` directory one
// component at a time and reject links/reparse points at every step.
let root_metadata =
fs::symlink_metadata(root).map_err(|error| io_error("读取项目根目录失败", error))?;
if planning_metadata_is_link_or_reparse(&root_metadata) || !root_metadata.is_dir() {
return Err(PlanningStorageError::new(
"PLAN_UNTRUSTED_PATH",
"项目根目录必须是可信普通目录",
));
}
let mut cursor = root.to_path_buf();
let relative_parent = parent
.strip_prefix(root)
.map_err(|_| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 父目录越出项目根"))?;
for component in relative_parent.components() {
use std::path::Component;
let Component::Normal(component) = component else {
return Err(PlanningStorageError::new(
"PLAN_INVALID_PATH",
"Fast GDD 父目录组件非法",
));
};
cursor.push(component);
match fs::symlink_metadata(&cursor) {
Ok(metadata) => {
if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() {
return Err(PlanningStorageError::new(
"PLAN_UNTRUSTED_PATH",
"Fast GDD 父目录必须是可信普通目录",
));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir(&cursor)
.map_err(|error| io_error("创建 Fast GDD 父目录失败", error))?;
let metadata = fs::symlink_metadata(&cursor)
.map_err(|error| io_error("复核 Fast GDD 父目录失败", error))?;
if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() {
return Err(PlanningStorageError::new(
"PLAN_UNTRUSTED_PATH",
"新建 Fast GDD 父目录不是可信普通目录",
));
}
}
Err(error) => return Err(io_error("读取 Fast GDD 父目录失败", error)),
}
}
match fs::symlink_metadata(&target) {
Ok(_) => {
verify_regular_planning_file(&target, "现有 Fast GDD Markdown")?;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(io_error("读取现有 Fast GDD Markdown 失败", error)),
}
let temporary = temp_planning_path(parent, &target);
let result = (|| {
write_sync_new_file(&temporary, bytes, "Fast GDD Markdown")?;
verify_replace_target_is_safe(&target, "Fast GDD Markdown")?;
replace_planning_file_atomically(&temporary, &target, "Fast GDD Markdown")?;
let published = read_regular_planning_file(&target, "已发布 Fast GDD Markdown")?;
if published != bytes {
return Err(PlanningStorageError::new(
"PLAN_RECONCILIATION_REQUIRED",
"Fast GDD Markdown 发布后内容不一致",
));
}
sync_planning_parent(parent)
})();
if temporary.exists() {
let cleanup = fs::remove_file(&temporary)
.map_err(|error| io_error("清理 Fast GDD 临时文件失败", error));
if result.is_ok() {
cleanup?;
}
}
result
}
fn replace_planning_file_atomically(
temporary: &Path,
target: &Path,

Some files were not shown because too many files have changed in this diff Show More