立项策划:完成 M1B-2 GDD 提交与恢复
接入 plan.submit_gdd 原生工具及 exact planning Provider 绑定与结构化注入 实现 create-only GDD 提交点、索引 Markdown session 恢复与策划子 run 收口 补齐定向门禁与阶段文档记录,审批 receipt UI 和构建准入留待后续
This commit is contained in:
@@ -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_plan,arguments 必须提交完整 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_plan,arguments 必须提交完整 steps;成稿时调用 plan.submit_gdd,input 必须严格符合 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 Key,art-director 只交付视觉方向文档"),
|
||||
!plan_prompt.contains(
|
||||
"当前未配置 External Editor API Key,art-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(),
|
||||
|
||||
+33
-1
@@ -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)? {
|
||||
|
||||
+26
@@ -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)?
|
||||
|
||||
+331
-26
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+135
-3
@@ -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 material:index={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,
|
||||
|
||||
+50
-2
@@ -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();
|
||||
|
||||
+25
-22
@@ -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_gdd(input 严格为 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、prototypeValidationItems;Runtime 会注入平台事实、身份、版本、时间和 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-4:Supervisor 的 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
|
||||
|
||||
+124
-22
@@ -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,
|
||||
|
||||
+1
@@ -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(())
|
||||
}
|
||||
|
||||
+88
-4
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
+1122
-12
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::*;
|
||||
|
||||
+6
-7
@@ -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)]
|
||||
|
||||
+141
-9
@@ -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
Reference in New Issue
Block a user