修复子 Agent 澄清回执中转
Project CI / Repository checks (pull_request) Successful in 1m3s
Project CI / Frontend tests (pull_request) Successful in 3m17s
Project CI / Backend tests (pull_request) Successful in 3m56s
Project CI / Native shell tests (pull_request) Failing after 11m22s

补齐 completed child 最终澄清回执的真实解析路径

将用户回答与原 delivery 持久绑定并校验 continuation 指纹

扩展 agent.delegate 原生 schema 并由 Runtime 派生稳定 continuation 身份

按原 delivery 逐一提问并支持自主构建重启恢复

补充端到端状态机测试和权威文档
This commit is contained in:
2026-08-12 17:51:02 +08:00
parent 5e16837f3d
commit 4be14df443
19 changed files with 1053 additions and 26 deletions
@@ -16,4 +16,4 @@ git.inspect 会返回 commitSnapshotFingerprint;只有当前非零 revision
用户输入请求协议:user.input_request 使用 {"questions":[{"id":"唯一 snake_case","header":"最多 12 字符","question":"单句问题","options":[{"label":"短选项","description":"一条影响说明"},{"label":"另一选项","description":"一条影响说明"}]}]},一次 1-3 题、每题 2-3 个选项且始终允许自由输入。它必须是本轮唯一函数调用,不得同批调用 update_agent_plan、其他动作函数或 respond_to_user。只有 Project Supervisor 或没有父委派身份的静态 Agent 开发试聊可直接调用;委派专业 Agent 和动态隔离 child 必须把澄清需要回传父 Agent。
静态委派协议:新 agent.delegate 必须提交 1-8 条 acceptanceCriteria、0-16 个精确项目内非私有 expectedArtifacts,以及 nullable repairOfDelegationId/runId。专业 Agent 收到的 task 会携带完整合同。Supervisor 认领回执后必须区分 evidence-ready 与 needs-repair;前者仍需语义验收,后者不能作为成功
静态委派协议:新 agent.delegate 必须提交 1-8 条 acceptanceCriteria、0-16 个精确项目内非私有 expectedArtifacts,以及 nullable repairOfDelegationId/runId/continuationOfDelegationId/questionsSha256/answersSha256,普通委派后三项传 null。专业 Agent 收到的 task 会携带完整合同。Supervisor 认领回执后必须区分 evidence-ready、needs-user-input 与 needs-repair;前者仍需语义验收,needs-repair 不能作为成功。专业 Agent 若缺少会实质改变结果的用户事实,不能调用 user.input_request,必须以最终回复首行 `AGC_NEEDS_USER_INPUT_V1`,下一行短 JSON `{"questions":[...]}` 返回 1-3 个结构化问题;Runtime 会把它作为内部回执交给 Supervisor。Supervisor 对每个原 delivery 逐一用现有 user.input_request 提问,收齐对应答案后最多创建一次 continuation 委派,并同时提交 continuationOfDelegationId、questionsSha256、answersSha256Runtime 会自动派生稳定 continuation identity,不得把多个 delivery 的问题或答案混入同一 continuation
@@ -1,5 +1,5 @@
互不重叠的临时并行检查通过 agent.spawn_isolated 分派;当同一目标同时需要边界清晰的专业委派和互不重叠的临时检查时,必须把两类协作放进同一个 native planning 批次一次性提交,不能拆成先后轮次。提交首个协作批次前,先分别完整枚举当前目标中已经生效的长期专业交付和临时隔离检查;两类都非空时,遗漏任一类的批次都不得提交。仓库合同明确把临时检查分为先行和后续独立阶段时,首批只提交当前已经生效的检查;先行组 ready 后优先创建刚生效的后续组,所有必要组创建前不得调用 agent.run_status 认领先行组,全部 ready 后用一次 agent.run_status 收齐。已有委派未收束时不要重复委派。
需要等待专业 Agent 时不得调用 respond_to_userRuntime 会通过 delegate/all-join 完成屏障保持同一父 run,取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。
需要等待专业 Agent 时不得调用 respond_to_userRuntime 会通过 delegate/all-join 完成屏障保持同一父 run,取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。contractStatus=needs-user-input 时,Runtime 会按原 delivery 逐一发起 user.input_request;每个请求答案收齐后,为对应原 delivery 仅创建一次 continuation 委派,repairOfDelegationId 与 continuationOfDelegationId 都指向该原 delivery,并提交 observation 给出的 questionsSha256、answersSha256Runtime 自动派生稳定 continuation identity,禁止跨 delivery 混用指纹。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。
只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。
@@ -301,6 +301,7 @@ pub(crate) use provider_recovery::{
#[cfg(test)]
pub(crate) use provider_recovery::{
drive_waiting_autonomous_manifest_parent_wake_budget_for_test,
ensure_static_delegate_user_input_wait_at,
ensure_waiting_provider_retry_records_for_test,
mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test,
prepare_waiting_autonomous_manifest_parent_for_test,
@@ -1879,8 +1879,46 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
.detail
.as_deref()
.is_some_and(static_delegate_barrier_requires_repair);
let user_input_required = blocker.detail.as_deref().is_some_and(|detail| {
detail
.split_whitespace()
.find_map(|part| part.strip_prefix("userInputRequired="))
.and_then(|value| value.parse::<usize>().ok())
.is_some_and(|count| count > 0)
});
runtime.status = "running".to_string();
if repair_required {
if user_input_required {
let deliveries = match claimed_static_delegate_deliveries_at(
&root,
&runtime.agent_id,
&runtime.run_id,
) {
Ok(deliveries) => deliveries,
Err(error) => {
return fail_game_creator_agent_background_context_at(
&root,
&agent_id,
&session_id,
runtime,
&format!("读取 needs-user-input 回执失败:{error}"),
);
}
};
if let Err(error) = ensure_static_delegate_user_input_wait_at(
&root,
&mut runtime,
&deliveries,
) {
return fail_game_creator_agent_background_context_at(
&root,
&agent_id,
&session_id,
runtime,
&format!("Supervisor 用户澄清请求无法安全进入等待态:{error}"),
);
}
return AgentBackgroundTaskOutcome::WaitingForUserInput;
} else if repair_required {
runtime.phase = "planning".to_string();
runtime.current_action = "等待 Project Supervisor 发起唯一返工".to_string();
runtime.waiting_on =
@@ -787,7 +787,9 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at(
can_repair_terminal_receipt = true;
}
if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT {
if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& !static_delegate_clarification_pending_matches_delivery_at(root, &pending)?
{
let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending);
mark_game_creator_agent_runtime_needs_reconciliation_at(
root,
@@ -321,6 +321,92 @@ pub(crate) fn schedule_waiting_static_delegate_parent_wake_after_lane_release(
});
}
/// Convert a claimed `needs-user-input` delivery into the Supervisor's own
/// durable user-input action. The child never owns this action: it is tied to
/// the parent run and therefore passes the normal user-input owner gate.
pub(crate) fn ensure_static_delegate_user_input_wait_at(
root: &Path,
runtime: &mut AgentRuntimeState,
deliveries: &[StaticDelegateDeliveryRecord],
) -> Result<bool, String> {
let mut pending_deliveries = deliveries.iter().filter(|delivery| {
delivery.structured_result.as_ref().is_some_and(|result| {
result.contract_status == StaticDelegateContractStatus::NeedsUserInput
}) && delivery.clarification_answers_sha256.is_none()
});
let Some(delivery) = pending_deliveries.next() else {
return Ok(false);
};
// Each durable request belongs to exactly one original delivery. Other
// deliveries remain behind the completion barrier and are asked next.
let result = delivery
.structured_result
.as_ref()
.ok_or_else(|| "needs-user-input delivery 缺少 structured result".to_string())?;
let questions = result.user_input_questions.clone();
let action = AgentRuntimeToolAction {
tool: GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string(),
reason: Some("代 Supervisor 汇总子 Agent 的澄清问题".to_string()),
input: serde_json::json!({"questions": questions}),
};
let question_binding = game_creator_agent_user_input_action_input_summary(&action.input)
.unwrap_or_else(|| "questionsSha256=unavailable".to_string());
let task = format!(
"子 Agent 需要用户澄清后才能继续。delegationId={}{question_binding}。请回答以下问题;回答完成后只创建一次 agent.delegate continuation,并将 repairOfDelegationId 与 continuationOfDelegationId 指向该原 delegation,同时提交 questionsSha256/answersSha256。",
delivery.delegation_id
);
// Re-entry after a wake or restart may only reuse the exact request that
// belongs to this delivery; an unrelated Supervisor question must not mask it.
if let Ok(existing) = read_game_creator_agent_runtime_pending_tool_action(
root,
&runtime.agent_id,
&runtime.run_id,
) {
if existing.action.tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL
&& matches!(
existing.status.as_str(),
AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT
| AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED
)
{
if existing.action.input != action.input || existing.task != task {
return Err(
"当前 Supervisor 用户输入 pending 与 needs-user-input delivery 身份冲突"
.to_string(),
);
}
return Ok(
existing.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT
);
}
}
let plan = AgentRuntimeToolPlan {
thinking_summary: "汇总子 Agent 澄清问题并等待用户回答".to_string(),
plan: vec!["等待用户回答后创建唯一 continuation 委派".to_string()],
actions: Vec::new(),
response: String::new(),
plan_update: None,
};
let repository_context_fingerprint = build_repository_startup_context_at(root)?.fingerprint;
let project_revision = read_game_creator_agent_runtime_project_revision(root)?;
let mut pending = build_game_creator_agent_runtime_pending_tool_action(
root,
runtime,
&task,
&plan,
&[],
&project_revision,
&repository_context_fingerprint,
&action,
0,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT,
None,
)?;
persist_game_creator_agent_user_input_wait_at(root, runtime, &mut pending)?;
Ok(true)
}
pub(in crate::agent) async fn drive_waiting_static_delegate_parent_wake_pass(
root: &Path,
agent_id: &str,
@@ -34,6 +34,8 @@ pub(in crate::agent) use project_ops::*;
pub(in crate::agent) use run_status::*;
pub(in crate::agent) use task_ops::*;
#[cfg(test)]
pub(crate) use delivery::build_static_delegate_result_for_child_at;
#[cfg(test)]
pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at;
@@ -545,6 +545,24 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
detail: None,
};
}
let clarification_continuation_identity =
match validate_static_delegate_clarification_continuation_at(
root,
agent_id,
parent_run_id,
input,
repair_of_delegation_id.as_deref(),
) {
Ok(identity) => identity,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "agent.delegate".to_string(),
status: "failed".to_string(),
summary: sanitize_agent_runtime_text(&error, 240),
detail: None,
}
}
};
if let Err(error) =
validate_publish_delegate_run_profile_at(root, agent_id, parent_run_id, &target_agent_id)
{
@@ -582,8 +600,15 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
detail: None,
};
}
let delegation_id =
agent_runtime_delegation_id(agent_id, parent_run_id, &target_agent_id, &action_identity);
let delegation_action_identity = clarification_continuation_identity
.as_deref()
.unwrap_or(action_identity.as_str());
let delegation_id = agent_runtime_delegation_id(
agent_id,
parent_run_id,
&target_agent_id,
delegation_action_identity,
);
let delegated_task = match render_static_delegate_task_contract(
&task,
agent_id,
@@ -736,7 +761,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
agent_id,
parent_session_id,
parent_run_id,
&action_identity,
delegation_action_identity,
&delegation_id,
&target_agent_id,
&existing.target_session_id,
@@ -826,7 +851,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
agent_id,
parent_session_id,
parent_run_id,
&action_identity,
delegation_action_identity,
&delegation_id,
&target_agent_id,
&existing.session_id,
@@ -992,7 +1017,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate(
agent_id,
parent_session_id,
parent_run_id,
&action_identity,
delegation_action_identity,
&delegation_id,
&target_agent_id,
&target_session_id,
@@ -94,7 +94,7 @@ pub(in crate::agent) fn validate_static_delegate_delivery_for_child_result(
Ok(())
}
pub(in crate::agent) fn build_static_delegate_result_for_child_at(
pub(crate) fn build_static_delegate_result_for_child_at(
root: &Path,
delivery: &StaticDelegateDeliveryRecord,
child_task: &AgentRuntimeTaskRecord,
@@ -110,11 +110,11 @@ pub(in crate::agent) fn build_static_delegate_result_for_child_at(
let verified_revision = (verification_status == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED))
.then_some(gate.verified_revision)
.flatten();
let error = child_task
let result_detail = child_task
.error
.as_deref()
.or((terminal_status != "completed").then_some(result_detail));
let error = error.map(|value| redact_agent_runtime_error(root, value, 500));
.or((!result_detail.trim().is_empty()).then_some(result_detail));
let result_detail = result_detail.map(|value| redact_agent_runtime_error(root, value, 500));
let mut result = build_static_delegate_structured_result_at(
root,
terminal_status,
@@ -123,7 +123,7 @@ pub(in crate::agent) fn build_static_delegate_result_for_child_at(
verification_status,
gate.last_verification_tool.as_deref(),
verified_revision,
error.as_deref(),
result_detail.as_deref(),
)?;
if verification_status == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) {
if let Some(evidence) = result.evidence.first_mut() {
@@ -327,6 +327,16 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at(
{
return Err("静态委派 parent-wake 的父 run 状态身份不一致".to_string());
}
if barrier.user_input_required_count > 0 {
let deliveries = claimed_static_delegate_deliveries_at(
root,
&current_task.agent_id,
&current_task.run_id,
)?;
let mut state = state;
ensure_static_delegate_user_input_wait_at(root, &mut state, &deliveries)?;
return Ok(true);
}
let state = advance_game_creator_agent_runtime_turn_at(
root,
state,
@@ -885,7 +895,7 @@ pub(crate) fn publish_game_creator_agent_delegate_result(
&existing_delivery,
child_task,
terminal_status,
&safe_result_summary,
&result_detail,
) {
Ok(result) => result,
Err(error) => {
@@ -237,6 +237,14 @@ pub(crate) fn observe_agent_runtime_run_status(
.structured_result
.as_ref()
.map(|result| result.contract_status),
"needsUserInput": delivery
.structured_result
.as_ref()
.is_some_and(|result| result.contract_status == StaticDelegateContractStatus::NeedsUserInput),
"userInputQuestionCount": delivery
.structured_result
.as_ref()
.map(|result| result.user_input_questions.len()),
"acceptanceCriteriaCount": delivery.acceptance_criteria.len(),
"expectedArtifactsCount": delivery.expected_artifacts.len(),
})
@@ -482,6 +482,17 @@ fn validate_native_agent_delegate_input(
"repairOfDelegationId",
"runId",
];
const ALLOWED_FIELDS: [&str; 9] = [
"agentId",
"task",
"acceptanceCriteria",
"expectedArtifacts",
"repairOfDelegationId",
"runId",
"continuationOfDelegationId",
"questionsSha256",
"answersSha256",
];
let object = input.as_object().ok_or_else(|| {
protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
@@ -498,7 +509,7 @@ fn validate_native_agent_delegate_input(
}
if object
.keys()
.any(|field| !REQUIRED_FIELDS.contains(&field.as_str()))
.any(|field| !ALLOWED_FIELDS.contains(&field.as_str()))
{
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
@@ -528,6 +539,14 @@ fn validate_native_agent_delegate_input(
true,
)?;
validate_native_delegate_string(object.get("runId"), "runId", 160, true)?;
validate_native_delegate_string(
object.get("continuationOfDelegationId"),
"continuationOfDelegationId",
160,
true,
)?;
validate_native_delegate_string(object.get("questionsSha256"), "questionsSha256", 64, true)?;
validate_native_delegate_string(object.get("answersSha256"), "answersSha256", 64, true)?;
if object
.get("repairOfDelegationId")
.is_some_and(Value::is_string)
@@ -538,6 +557,47 @@ fn validate_native_agent_delegate_input(
"Agent 原生工具协议错误:agent.delegate 返工委派时 runId 必须为 JSON null",
));
}
let continuation_fields = [
"continuationOfDelegationId",
"questionsSha256",
"answersSha256",
]
.iter()
.filter(|field| object.get(**field).is_some_and(Value::is_string))
.count();
let continuation_present = [
"continuationOfDelegationId",
"questionsSha256",
"answersSha256",
]
.iter()
.filter(|field| object.contains_key(**field))
.count();
if (continuation_present != 0 && continuation_present != 3)
|| (continuation_fields != 0 && continuation_fields != 3)
{
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
"Agent 原生工具协议错误:agent.delegate 澄清 continuation 字段必须同时提供",
));
}
for field in ["questionsSha256", "answersSha256"] {
if object.get(field).is_some_and(Value::is_string)
&& object
.get(field)
.and_then(Value::as_str)
.is_none_or(|value| {
value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
})
{
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!(
"Agent 原生工具协议错误:agent.delegate {field} 必须是 64 位十六进制 SHA-256"
),
));
}
}
Ok(())
}
@@ -1216,14 +1276,17 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
"blackboard.write" => two_string_input_schema("title", "content"),
"agent.message" => two_string_input_schema("agentId", "content"),
"agent.delegate" => json!({
"type": "object", "required": ["agentId", "task", "acceptanceCriteria", "expectedArtifacts", "repairOfDelegationId", "runId"], "additionalProperties": false,
"type": "object", "required": ["agentId", "task", "acceptanceCriteria", "expectedArtifacts", "repairOfDelegationId", "runId", "continuationOfDelegationId", "questionsSha256", "answersSha256"], "additionalProperties": false,
"properties": {
"agentId": { "type": "string", "minLength": 1 },
"task": { "type": "string", "minLength": 1, "maxLength": 2400 },
"acceptanceCriteria": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "type": "string", "minLength": 1, "maxLength": 240 } },
"expectedArtifacts": { "type": "array", "maxItems": 16, "items": { "type": "string", "minLength": 1, "maxLength": 240 } },
"repairOfDelegationId": { "type": ["string", "null"] },
"runId": { "type": ["string", "null"] }
"runId": { "type": ["string", "null"] },
"continuationOfDelegationId": { "type": ["string", "null"] },
"questionsSha256": { "type": ["string", "null"] },
"answersSha256": { "type": ["string", "null"] }
}
}),
"agent.spawn_isolated" => json!({
@@ -1505,9 +1568,56 @@ mod tests {
"expectedArtifacts": [],
"repairOfDelegationId": repair_of_delegation_id,
"runId": run_id,
"continuationOfDelegationId": null,
"questionsSha256": null,
"answersSha256": null,
})
}
#[test]
fn native_agent_delegate_accepts_complete_clarification_continuation_binding() {
let mut input = valid_delegate_input(json!("delegation-id"), Value::Null);
let object = input.as_object_mut().expect("delegate input object");
object.insert(
"continuationOfDelegationId".to_string(),
json!("delegation-id"),
);
object.insert("questionsSha256".to_string(), json!("a".repeat(64)));
object.insert("answersSha256".to_string(), json!("b".repeat(64)));
validate_native_agent_delegate_input(&input)
.expect("complete clarification continuation binding");
}
#[test]
fn native_agent_delegate_rejects_partial_or_invalid_clarification_binding() {
let mut partial = valid_delegate_input(json!("delegation-id"), Value::Null);
partial
.as_object_mut()
.expect("delegate input object")
.insert(
"continuationOfDelegationId".to_string(),
json!("delegation-id"),
);
assert!(validate_native_agent_delegate_input(&partial)
.expect_err("partial continuation binding must fail")
.to_string()
.contains("必须同时提供"));
let mut invalid_sha = valid_delegate_input(json!("delegation-id"), Value::Null);
let object = invalid_sha.as_object_mut().expect("delegate input object");
object.insert(
"continuationOfDelegationId".to_string(),
json!("delegation-id"),
);
object.insert("questionsSha256".to_string(), json!("z".repeat(64)));
object.insert("answersSha256".to_string(), json!("b".repeat(64)));
assert!(validate_native_agent_delegate_input(&invalid_sha)
.expect_err("invalid continuation sha must fail")
.to_string()
.contains("SHA-256"));
}
#[test]
fn native_agent_delegate_repair_rejects_string_run_id() {
let repair_id = "delegation-value-must-not-leak";
@@ -13,6 +13,8 @@ const STATIC_DELEGATE_ACCEPTANCE_CRITERION_MAX_CHARS: usize = 240;
const STATIC_DELEGATE_MAX_EXPECTED_ARTIFACTS: usize = 16;
const STATIC_DELEGATE_EXPECTED_ARTIFACT_MAX_CHARS: usize = 240;
const STATIC_DELEGATE_MAX_EVIDENCE: usize = 16;
const STATIC_DELEGATE_USER_INPUT_PREFIX: &str = "AGC_NEEDS_USER_INPUT_V1\n";
const STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS: usize = 500;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
@@ -28,6 +30,7 @@ pub(crate) enum StaticDelegateDeliveryStatus {
pub(crate) enum StaticDelegateContractStatus {
EvidenceReady,
NeedsRepair,
NeedsUserInput,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -64,6 +67,26 @@ pub(crate) struct StaticDelegateStructuredResult {
pub(crate) evidence: Vec<StaticDelegateEvidence>,
#[serde(default)]
pub(crate) error: Option<String>,
#[serde(default)]
pub(crate) user_input_questions: Vec<AgentRuntimeUserInputQuestion>,
#[serde(default)]
pub(crate) user_input_questions_sha256: Option<String>,
}
impl Default for StaticDelegateStructuredResult {
fn default() -> Self {
Self {
contract_status: StaticDelegateContractStatus::NeedsRepair,
artifacts: Vec::new(),
missing_expected_artifacts: Vec::new(),
verification_required: false,
verified_revision: None,
evidence: Vec::new(),
error: None,
user_input_questions: Vec::new(),
user_input_questions_sha256: None,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -84,6 +107,10 @@ pub(crate) struct StaticDelegateDeliveryRecord {
pub(crate) expected_artifacts: Vec<String>,
#[serde(default)]
pub(crate) repair_of_delegation_id: Option<String>,
#[serde(default)]
pub(crate) clarification_request_id: Option<String>,
#[serde(default)]
pub(crate) clarification_answers_sha256: Option<String>,
pub(crate) status: StaticDelegateDeliveryStatus,
pub(crate) terminal_status: Option<String>,
pub(crate) result_summary: Option<String>,
@@ -136,6 +163,7 @@ pub(crate) struct StaticDelegateCompletionBarrier {
pub(crate) ready_unclaimed_count: usize,
pub(crate) unobserved_claim_count: usize,
pub(crate) repair_required_count: usize,
pub(crate) user_input_required_count: usize,
}
impl StaticDelegateCompletionBarrier {
@@ -144,6 +172,7 @@ impl StaticDelegateCompletionBarrier {
&& self.ready_unclaimed_count == 0
&& self.unobserved_claim_count == 0
&& self.repair_required_count == 0
&& self.user_input_required_count == 0
}
pub(crate) fn has_waiting(self) -> bool {
@@ -152,11 +181,12 @@ impl StaticDelegateCompletionBarrier {
pub(crate) fn detail(self) -> String {
format!(
"waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · 必须认领专业 Agent 回执,并对 needs-repair 原委派发起唯一返工后再继续",
"waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · 必须认领专业 Agent 回执,并处理 needs-user-input 或对 needs-repair 原委派发起唯一返工后再继续",
self.waiting_count,
self.ready_unclaimed_count,
self.unobserved_claim_count,
self.repair_required_count
self.repair_required_count,
self.user_input_required_count
)
}
}
@@ -213,6 +243,8 @@ pub(crate) fn new_static_delegate_delivery_with_contract(
acceptance_criteria: acceptance_criteria.to_vec(),
expected_artifacts: expected_artifacts.to_vec(),
repair_of_delegation_id: repair_of_delegation_id.map(str::to_string),
clarification_request_id: None,
clarification_answers_sha256: None,
status: StaticDelegateDeliveryStatus::Dispatched,
terminal_status: None,
result_summary: None,
@@ -286,6 +318,8 @@ pub(crate) fn mark_static_delegate_delivery_ready_at(
verified_revision: None,
evidence: Vec::new(),
error: (terminal_status != "completed").then(|| result_summary.to_string()),
user_input_questions: Vec::new(),
user_input_questions_sha256: None,
};
mark_static_delegate_delivery_ready_with_result_at(
root,
@@ -434,6 +468,8 @@ pub(crate) fn static_delegate_completion_barrier_at(
&& !legacy_empty_contract
&& delivery.structured_result.as_ref().is_some_and(|result| {
result.contract_status == StaticDelegateContractStatus::NeedsRepair
|| (result.contract_status == StaticDelegateContractStatus::NeedsUserInput
&& delivery.clarification_answers_sha256.is_some())
})
&& !deliveries.iter().any(|candidate| {
candidate.repair_of_delegation_id.as_deref()
@@ -447,6 +483,26 @@ pub(crate) fn static_delegate_completion_barrier_at(
})
})
.count();
barrier.user_input_required_count = deliveries
.iter()
.filter(|delivery| {
delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent
&& delivery.structured_result.as_ref().is_some_and(|result| {
result.contract_status == StaticDelegateContractStatus::NeedsUserInput
})
&& delivery.clarification_answers_sha256.is_none()
&& !deliveries.iter().any(|candidate| {
candidate.repair_of_delegation_id.as_deref()
== Some(delivery.delegation_id.as_str())
&& matches!(
candidate.status,
StaticDelegateDeliveryStatus::Dispatched
| StaticDelegateDeliveryStatus::Ready
| StaticDelegateDeliveryStatus::ClaimedByParent
)
})
})
.count();
Ok(barrier)
}
@@ -1076,6 +1132,207 @@ pub(crate) fn validate_static_delegate_repair_request_at(
Ok(())
}
pub(crate) fn validate_static_delegate_clarification_continuation_at(
root: &Path,
parent_agent_id: &str,
parent_run_id: &str,
input: &serde_json::Value,
repair_of_delegation_id: Option<&str>,
) -> Result<Option<String>, String> {
let input_text = |keys: &[&str]| -> String {
keys.iter()
.find_map(|key| input.get(*key).and_then(serde_json::Value::as_str))
.unwrap_or_default()
.trim()
.to_string()
};
let Some(repair_of_delegation_id) = repair_of_delegation_id
.map(str::trim)
.filter(|value| !value.is_empty())
else {
if !input_text(&[
"continuationOfDelegationId",
"continuation_of_delegation_id",
])
.is_empty()
|| !input_text(&["questionsSha256", "questions_sha256"]).is_empty()
|| !input_text(&["answersSha256", "answers_sha256"]).is_empty()
{
return Err(
"澄清 continuation 必须同时提交 repairOfDelegationId 并指向原 delivery".to_string(),
);
}
return Ok(None);
};
let continuation_of = input_text(&[
"continuationOfDelegationId",
"continuation_of_delegation_id",
]);
let input_questions_sha = input_text(&["questionsSha256", "questions_sha256"]);
let input_answers_sha = input_text(&["answersSha256", "answers_sha256"]);
let original =
read_static_delegate_delivery_at(root, repair_of_delegation_id)?.ok_or_else(|| {
format!("澄清 continuation 引用的原 delivery 不存在:{repair_of_delegation_id}")
})?;
let needs_user_input = original.structured_result.as_ref().is_some_and(|result| {
result.contract_status == StaticDelegateContractStatus::NeedsUserInput
});
if !needs_user_input {
if !continuation_of.is_empty()
|| !input_questions_sha.is_empty()
|| !input_answers_sha.is_empty()
{
return Err("普通返工不能携带澄清 continuation 绑定".to_string());
}
return Ok(None);
}
if parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| original.parent_agent_id != parent_agent_id
|| original.parent_run_id != parent_run_id
|| original.status != StaticDelegateDeliveryStatus::ClaimedByParent
{
return Err(
"澄清 continuation 必须由认领原回执的 Project Supervisor 在同一父 run 创建".to_string(),
);
}
let questions_sha256 = original
.structured_result
.as_ref()
.and_then(|result| result.user_input_questions_sha256.as_deref())
.filter(|value| valid_static_delegate_sha256(value))
.ok_or_else(|| "澄清 continuation 的原 delivery 缺少问题指纹".to_string())?;
let answers_sha256 = original
.clarification_answers_sha256
.as_deref()
.filter(|value| valid_static_delegate_sha256(value))
.ok_or_else(|| "澄清 continuation 尚未取得该原 delivery 对应的用户回答".to_string())?;
let continuation_of = input_text(&[
"continuationOfDelegationId",
"continuation_of_delegation_id",
]);
if continuation_of != repair_of_delegation_id {
return Err("澄清 continuation 必须绑定原 delegationId".to_string());
}
if input_questions_sha != questions_sha256 || input_answers_sha != answers_sha256 {
return Err("澄清 continuation 的问题或答案指纹与已回答请求不一致".to_string());
}
let continuation_identity = format!(
"clarification-continuation-{:x}",
Sha256::digest(format!(
"{parent_run_id}\n{repair_of_delegation_id}\n{questions_sha256}\n{answers_sha256}"
))
);
Ok(Some(continuation_identity))
}
pub(crate) fn bind_static_delegate_clarification_answer_at(
root: &Path,
parent_agent_id: &str,
parent_run_id: &str,
delegation_id: &str,
request_id: &str,
questions_sha256: &str,
answers_sha256: &str,
) -> Result<(), String> {
validate_static_delegate_id(delegation_id, "delegationId", 160)?;
validate_static_delegate_id(request_id, "requestId", 160)?;
if !valid_static_delegate_sha256(questions_sha256)
|| !valid_static_delegate_sha256(answers_sha256)
{
return Err("子 Agent 澄清回答缺少有效 SHA-256 绑定".to_string());
}
let _lock = try_acquire_game_creator_agent_delegation_lock_with_wait(
root,
delegation_id,
"static-clarification-answer",
)?
.ok_or_else(|| format!("静态委派澄清绑定正在更新:{delegation_id}"))?;
let mut delivery = read_static_delegate_delivery_at(root, delegation_id)?
.ok_or_else(|| format!("静态委派澄清原 delivery 不存在:{delegation_id}"))?;
let expected_questions_sha = delivery
.structured_result
.as_ref()
.filter(|result| result.contract_status == StaticDelegateContractStatus::NeedsUserInput)
.and_then(|result| result.user_input_questions_sha256.as_deref())
.ok_or_else(|| "静态委派澄清原 delivery 不是 needs-user-input".to_string())?;
if delivery.parent_agent_id != parent_agent_id
|| delivery.parent_run_id != parent_run_id
|| delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent
|| expected_questions_sha != questions_sha256
{
return Err("静态委派澄清回答与原 delivery 身份或问题指纹冲突".to_string());
}
match (
delivery.clarification_request_id.as_deref(),
delivery.clarification_answers_sha256.as_deref(),
) {
(None, None) => {
delivery.clarification_request_id = Some(request_id.to_string());
delivery.clarification_answers_sha256 = Some(answers_sha256.to_string());
delivery.updated_at = unix_timestamp();
write_static_delegate_delivery_at(root, &delivery)?;
Ok(())
}
(Some(existing_request), Some(existing_answers))
if existing_request == request_id && existing_answers == answers_sha256 =>
{
Ok(())
}
_ => Err("静态委派澄清回答已绑定到不同请求或答案".to_string()),
}
}
pub(crate) fn static_delegate_clarification_pending_matches_delivery_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<bool, String> {
if pending.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| pending.action.tool != GAME_CREATOR_USER_INPUT_REQUEST_TOOL
{
return Ok(false);
}
let Some(delegation_id) = pending
.task
.strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=")
.and_then(|value| value.split('').next())
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(false);
};
let Some(delivery) = read_static_delegate_delivery_at(root, delegation_id)? else {
return Ok(false);
};
let Some(result) = delivery.structured_result.as_ref().filter(|result| {
result.contract_status == StaticDelegateContractStatus::NeedsUserInput
&& result.user_input_questions_sha256.is_some()
}) else {
return Ok(false);
};
let questions = parse_game_creator_agent_user_input_questions(&pending.action.input)?;
let questions_sha256 = format!(
"{:x}",
Sha256::digest(
serde_json::to_vec(&questions)
.map_err(|error| format!("序列化 Supervisor 澄清问题失败:{error}"))?
)
);
Ok(delivery.parent_agent_id == pending.agent_id
&& delivery.parent_session_id == pending.session_id
&& delivery.parent_run_id == pending.run_id
&& delivery.status == StaticDelegateDeliveryStatus::ClaimedByParent
&& delivery.clarification_answers_sha256.is_none()
&& result.user_input_questions == questions
&& result.user_input_questions_sha256.as_deref() == Some(questions_sha256.as_str())
&& pending
.task
.contains(&format!("questionsSha256={questions_sha256}")))
}
fn valid_static_delegate_sha256(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_static_delegate_structured_result_at(
root: &Path,
@@ -1133,12 +1390,16 @@ pub(crate) fn build_static_delegate_structured_result_at(
let verification_passed = !verification_required
|| (verification_status == Some("passed") && verified_revision.is_some());
let completed = terminal_status == "completed";
let contract_status =
if completed && missing_expected_artifacts.is_empty() && verification_passed {
StaticDelegateContractStatus::EvidenceReady
} else {
StaticDelegateContractStatus::NeedsRepair
};
let (user_input_questions, user_input_questions_sha256) =
parse_static_delegate_user_input_request(error)?;
let needs_user_input = completed && user_input_questions.is_some();
let contract_status = if needs_user_input {
StaticDelegateContractStatus::NeedsUserInput
} else if completed && missing_expected_artifacts.is_empty() && verification_passed {
StaticDelegateContractStatus::EvidenceReady
} else {
StaticDelegateContractStatus::NeedsRepair
};
let mut evidence = Vec::new();
if verification_status == Some("passed") {
let kind = verification_tool.unwrap_or("project.verify").to_string();
@@ -1181,9 +1442,33 @@ pub(crate) fn build_static_delegate_structured_result_at(
verified_revision: verification_passed.then_some(verified_revision).flatten(),
evidence,
error: derived_error,
user_input_questions: user_input_questions.unwrap_or_default(),
user_input_questions_sha256,
})
}
fn parse_static_delegate_user_input_request(
response: Option<&str>,
) -> Result<(Option<Vec<AgentRuntimeUserInputQuestion>>, Option<String>), String> {
let Some(response) = response.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok((None, None));
};
if !response.starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) {
return Ok((None, None));
}
if response.chars().count() > STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS {
return Err("子 Agent 用户澄清请求超过回执长度上限".to_string());
}
let payload = response[STATIC_DELEGATE_USER_INPUT_PREFIX.len()..].trim();
let value = serde_json::from_str::<serde_json::Value>(payload)
.map_err(|error| format!("子 Agent 用户澄清请求 JSON 无效:{error}"))?;
let questions = parse_game_creator_agent_user_input_questions(&value)?;
let serialized = serde_json::to_vec(&questions)
.map_err(|error| format!("序列化子 Agent 用户澄清问题失败:{error}"))?;
let sha256 = format!("{:x}", Sha256::digest(serialized));
Ok((Some(questions), Some(sha256)))
}
pub(crate) fn suppress_static_delegate_deliveries_for_parent_terminal_at(
root: &Path,
parent_agent_id: &str,
@@ -1496,6 +1781,24 @@ fn validate_static_delegate_delivery_record(
return Err("静态委派 repairOfDelegationId 不能指向自己".to_string());
}
}
if record.clarification_request_id.is_some() != record.clarification_answers_sha256.is_some()
|| record
.clarification_answers_sha256
.as_deref()
.is_some_and(|value| !valid_static_delegate_sha256(value))
{
return Err("静态委派 delivery 澄清回答绑定无效".to_string());
}
if let Some(request_id) = record.clarification_request_id.as_deref() {
validate_static_delegate_id(request_id, "clarificationRequestId", 160)?;
if record.status != StaticDelegateDeliveryStatus::ClaimedByParent
|| record.structured_result.as_ref().is_none_or(|result| {
result.contract_status != StaticDelegateContractStatus::NeedsUserInput
})
{
return Err("只有已认领 needs-user-input delivery 可以绑定澄清回答".to_string());
}
}
if record.status == StaticDelegateDeliveryStatus::Dispatched
&& (record.terminal_status.is_some()
|| record.result_summary.is_some()
@@ -1750,6 +2053,24 @@ fn validate_static_delegate_structured_result(
{
return Err("静态委派 evidence-ready 与客观证据冲突".to_string());
}
if result.contract_status == StaticDelegateContractStatus::NeedsUserInput {
if terminal_status != "completed"
|| result.user_input_questions.is_empty()
|| result.user_input_questions.len() > 3
{
return Err("静态委派 needs-user-input 与终态或问题数量冲突".to_string());
}
let expected_sha = serde_json::to_vec(&result.user_input_questions)
.map(|bytes| format!("{:x}", Sha256::digest(bytes)))
.map_err(|error| format!("序列化静态委派用户问题失败:{error}"))?;
if result.user_input_questions_sha256.as_deref() != Some(expected_sha.as_str()) {
return Err("静态委派 needs-user-input 问题指纹无效".to_string());
}
} else if !result.user_input_questions.is_empty()
|| result.user_input_questions_sha256.is_some()
{
return Err("非 needs-user-input 静态委派不能携带用户问题".to_string());
}
if result
.error
.as_ref()
@@ -1900,6 +2221,56 @@ mod tests {
fs::remove_dir_all(root).ok();
}
#[test]
fn static_delegate_user_input_envelope_is_structured_and_fingerprinted() {
let response = format!(
"{STATIC_DELEGATE_USER_INPUT_PREFIX}{}",
serde_json::json!({
"questions": [{
"id": "target_platform",
"header": "平台",
"question": "主要运行在哪里?",
"options": [
{"label": "Web", "description": "浏览器运行"},
{"label": "移动端", "description": "手机或平板运行"}
]
}]
})
);
let (questions, sha256) = parse_static_delegate_user_input_request(Some(&response))
.expect("valid child clarification envelope");
let questions = questions.expect("questions present");
assert_eq!(questions.len(), 1);
assert_eq!(sha256.as_deref().map(str::len), Some(64));
let result = build_static_delegate_structured_result_at(
&std::env::temp_dir(),
"completed",
&[],
false,
None,
None,
None,
Some(&response),
)
.expect("build needs-user-input result");
assert_eq!(
result.contract_status,
StaticDelegateContractStatus::NeedsUserInput
);
validate_static_delegate_structured_result(&result, "completed", &[])
.expect("needs-user-input result validates");
}
#[test]
fn static_delegate_user_input_envelope_fails_closed_when_malformed() {
let error = parse_static_delegate_user_input_request(Some(
"AGC_NEEDS_USER_INPUT_V1\n{\"questions\":[]}",
))
.expect_err("empty question envelope must fail");
assert!(error.contains("user.input_request"));
}
#[test]
fn stale_prepared_claim_snapshot_cannot_downgrade_observed_claim() {
let root = std::env::temp_dir().join(format!(
@@ -1005,6 +1005,8 @@ fn project_supervisor_static_ready_receipt_over_budget_is_non_mutating() {
})
.collect(),
error: None,
user_input_questions: Vec::new(),
user_input_questions_sha256: None,
};
mark_static_delegate_delivery_ready_with_result_at(
&root,
@@ -1460,6 +1460,8 @@ fn canvas_replacement_requires_the_claimed_original_and_unique_repair_contract()
verified_revision: None,
evidence: Vec::new(),
error: None,
user_input_questions: Vec::new(),
user_input_questions_sha256: None,
});
original.claimed_by_action_id = Some("canvas-replacement-claim-action".to_string());
create_or_read_static_delegate_delivery_at(&root, &original)
@@ -1680,6 +1682,8 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r
verified_revision: None,
evidence: Vec::new(),
error: None,
user_input_questions: Vec::new(),
user_input_questions_sha256: None,
});
original.claimed_by_action_id = Some("canvas-repair-claim-action".to_string());
create_or_read_static_delegate_delivery_at(&root, &original)
@@ -1562,6 +1562,321 @@ fn project_supervisor_delegate_reuses_reserved_identity_after_interrupted_dispat
fs::remove_dir_all(root).ok();
}
#[test]
fn claimed_needs_user_input_delivery_becomes_one_supervisor_wait() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-needs-user-input", "子 Agent 澄清中转测试")
.expect("project init");
let run_id = "project-supervisor-needs-user-input-run";
let mut state = start_game_creator_agent_runtime_task_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"协调专业 Agent 完成玩法方案",
run_id,
"agent-chat",
"协调专业 Agent",
vec!["取得用户选择后继续专业委派".to_string()],
)
.expect("start supervisor run");
let delegation_id = "needs-user-input-delivery";
let delivery = new_static_delegate_delivery_with_contract(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&state.session_id,
run_id,
"needs-user-input-delegate-action",
delegation_id,
"design-director",
"needs-user-input-child-session",
"needs-user-input-child-run",
&["明确首发平台".to_string()],
&[],
None,
);
create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create delivery");
let result = build_static_delegate_structured_result_at(
&root,
"completed",
&[],
false,
None,
None,
None,
Some(concat!(
"AGC_NEEDS_USER_INPUT_V1\n",
r#"{"questions":[{"id":"target_platform","header":"首发平台","question":"首版优先发布到哪个平台?","options":[{"label":"Web","description":"优先浏览器交付。"},{"label":"桌面端","description":"优先桌面客户端交付。"}]}]}"#
)),
)
.expect("build needs-user-input result");
mark_static_delegate_delivery_ready_with_result_at(
&root,
&delivery.target_agent_id,
&delivery.target_session_id,
&delivery.target_run_id,
delegation_id,
"completed",
"需要用户确认首发平台",
result,
)
.expect("mark delivery ready");
claim_ready_static_delegate_receipts_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
"needs-user-input-claim-action",
)
.expect("claim receipt");
let deliveries = claimed_static_delegate_deliveries_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.expect("read claimed deliveries");
assert!(
ensure_static_delegate_user_input_wait_at(&root, &mut state, &deliveries)
.expect("create supervisor wait")
);
let first = read_game_creator_agent_runtime_pending_tool_action(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.expect("read supervisor pending action");
assert_eq!(first.action.tool, GAME_CREATOR_USER_INPUT_REQUEST_TOOL);
assert_eq!(
first.status,
AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT
);
assert!(first.task.contains(delegation_id));
assert!(
static_delegate_clarification_pending_matches_delivery_at(&root, &first)
.expect("validate delegated clarification pending")
);
let mut spoofed = first.clone();
spoofed.task = format!(
"子 Agent 需要用户澄清后才能继续。delegationId={delegation_id}questionsSha256={}",
"a".repeat(64)
);
assert!(
!static_delegate_clarification_pending_matches_delivery_at(&root, &spoofed)
.expect("reject spoofed delegated clarification pending")
);
assert!(
ensure_static_delegate_user_input_wait_at(&root, &mut state, &deliveries)
.expect("replay supervisor wait")
);
let replayed = read_game_creator_agent_runtime_pending_tool_action(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.expect("read replayed supervisor pending action");
assert_eq!(replayed.action_id, first.action_id);
let request = match prepare_game_creator_agent_user_input_request_at(&root, &first)
.expect("prepare delegated clarification request")
{
AgentRuntimeUserInputRecovery::Waiting(request) => request,
other => panic!("unexpected delegated clarification recovery: {other:?}"),
};
let (_, observation) = answer_game_creator_agent_user_input_request_for_pending_at(
&root,
&first,
&request.request_id,
"needs-user-input-response",
BTreeMap::from([("target_platform".to_string(), "Web".to_string())]),
)
.expect("answer delegated clarification request");
let detail =
serde_json::from_str::<Value>(observation.detail.as_deref().expect("answer detail"))
.expect("parse answer detail");
let questions_sha = detail["questionsSha256"].as_str().expect("questions sha");
let answers_sha = detail["answersSha256"].as_str().expect("answers sha");
let pending_path = game_creator_agent_runtime_pending_tool_action_path(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
);
fs::remove_file(pending_path).expect("clear answered pending before next planning");
let continuation_input = serde_json::json!({
"continuationOfDelegationId": delegation_id,
"questionsSha256": questions_sha,
"answersSha256": answers_sha,
});
let identity = validate_static_delegate_clarification_continuation_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
&continuation_input,
Some(delegation_id),
)
.expect("validate continuation after pending cleanup")
.expect("derived continuation identity");
assert_eq!(
validate_static_delegate_clarification_continuation_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
&continuation_input,
Some(delegation_id),
)
.expect("replay continuation validation"),
Some(identity.clone())
);
let barrier = static_delegate_completion_barrier_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.expect("read answered clarification barrier");
assert_eq!(barrier.user_input_required_count, 0);
assert_eq!(barrier.repair_required_count, 1);
let wrong_binding = serde_json::json!({
"continuationOfDelegationId": delegation_id,
"questionsSha256": "a".repeat(64),
"answersSha256": answers_sha,
});
assert!(validate_static_delegate_clarification_continuation_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
&wrong_binding,
Some(delegation_id),
)
.expect_err("wrong clarification binding must fail")
.contains("指纹"));
let target_lock =
try_acquire_game_creator_agent_runtime_task_lock(&root, &delivery.target_agent_id)
.expect("acquire clarification continuation target lane")
.expect("clarification continuation target lane available");
let delegate_input = serde_json::json!({
"agentId": delivery.target_agent_id,
"task": "根据用户确认的 Web 首发平台继续完成原方案",
"acceptanceCriteria": delivery.acceptance_criteria,
"expectedArtifacts": delivery.expected_artifacts,
"repairOfDelegationId": delegation_id,
"runId": null,
"continuationOfDelegationId": delegation_id,
"questionsSha256": questions_sha,
"answersSha256": answers_sha,
});
let first_continuation = observe_agent_runtime_agent_delegate(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
Some("provider-continuation-action-one"),
&delegate_input,
);
assert_eq!(first_continuation.status, "ok", "{first_continuation:?}");
let replayed_continuation = observe_agent_runtime_agent_delegate(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
Some("provider-continuation-action-two"),
&delegate_input,
);
assert_eq!(
replayed_continuation.status, "ok",
"{replayed_continuation:?}"
);
let continuation_delegation_id = agent_runtime_delegation_id(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
&delivery.target_agent_id,
&identity,
);
let continuation = read_static_delegate_delivery_at(&root, &continuation_delegation_id)
.expect("read clarification continuation delivery")
.expect("clarification continuation delivery exists");
assert_eq!(continuation.parent_action_id, identity);
assert_eq!(
continuation.repair_of_delegation_id.as_deref(),
Some(delegation_id)
);
let continuation_barrier = static_delegate_completion_barrier_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.expect("read continuation barrier");
assert_eq!(continuation_barrier.user_input_required_count, 0);
assert_eq!(continuation_barrier.repair_required_count, 0);
assert_eq!(continuation_barrier.waiting_count, 1);
drop(target_lock);
fs::remove_dir_all(root).ok();
}
#[test]
fn completed_child_final_response_becomes_needs_user_input_delivery_result() {
let root = unique_project_path();
init_local_game_project_at(
&root,
"project-child-final-clarification",
"真实 child 澄清终态测试",
)
.expect("project init");
let delivery = new_static_delegate_delivery_with_contract(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"parent-session",
"parent-run",
"parent-action",
"child-final-clarification-delegation",
"design-director",
"child-session",
"child-run",
&["明确首发平台".to_string()],
&[],
None,
);
let response = concat!(
"AGC_NEEDS_USER_INPUT_V1\n",
r#"{"questions":[{"id":"target_platform","header":"首发平台","question":"首版优先发布到哪个平台?","options":[{"label":"Web","description":"优先浏览器交付。"},{"label":"桌面端","description":"优先桌面客户端交付。"}]}]}"#
);
let child_task = AgentRuntimeTaskRecord {
schema_version: "game-creator-agent-runtime-task.v1".to_string(),
task_id: "child-task".to_string(),
agent_id: delivery.target_agent_id.clone(),
session_id: delivery.target_session_id.clone(),
run_id: delivery.target_run_id.clone(),
source: "agent-delegate".to_string(),
parent_agent_id: Some(delivery.parent_agent_id.clone()),
parent_run_id: Some(delivery.parent_run_id.clone()),
delegation_id: Some(delivery.delegation_id.clone()),
run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(),
goal_id: None,
goal_revision: 0,
goal_status: None,
task: "明确首发平台".to_string(),
status: "completed".to_string(),
phase: "completed".to_string(),
current_action: "已完成澄清请求".to_string(),
terminal_detail: Some(response.to_string()),
error: None,
updated_at: unix_timestamp(),
};
let result = build_static_delegate_result_for_child_at(
&root,
&delivery,
&child_task,
"completed",
response,
)
.expect("build real completed child result");
assert_eq!(
result.contract_status,
StaticDelegateContractStatus::NeedsUserInput
);
assert_eq!(result.user_input_questions.len(), 1);
assert!(result.user_input_questions_sha256.is_some());
fs::remove_dir_all(root).ok();
}
#[test]
fn project_supervisor_run_status_replays_receipts_for_same_action() {
let root = unique_project_path();
@@ -611,6 +611,7 @@ fn build_user_input_observation(
"questionCount": record.question_count,
"answerCount": record.answer_count,
"answerChars": record.answer_chars,
"questionsSha256": record.questions_sha256,
"answersSha256": answers_sha256,
}))
.map_err(|error| format!("序列化用户输入 observation 失败:{error}"))?;
@@ -775,9 +776,38 @@ fn finish_prepared_user_input_answer(
record.answered_at = Some(now);
record.updated_at = now;
write_user_input_record(root, pending, &record)?;
bind_user_input_record_to_static_delegate_at(root, pending, &record)?;
Ok(record)
}
fn bind_user_input_record_to_static_delegate_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
record: &AgentRuntimeUserInputRecord,
) -> Result<(), String> {
let Some(delegation_id) = pending
.task
.strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=")
.and_then(|value| value.split('').next())
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(());
};
bind_static_delegate_clarification_answer_at(
root,
&pending.agent_id,
&pending.run_id,
delegation_id,
&record.request_id,
&record.questions_sha256,
record
.answers_sha256
.as_deref()
.ok_or_else(|| "用户输入请求 answered 状态缺少答案指纹".to_string())?,
)
}
pub(crate) fn prepare_game_creator_agent_user_input_request_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
@@ -805,6 +835,7 @@ pub(crate) fn prepare_game_creator_agent_user_input_request_at(
if record.observation.as_ref() != Some(&observation) {
return Err("用户输入请求 observation 重算冲突".to_string());
}
bind_user_input_record_to_static_delegate_at(root, pending, &record)?;
Ok(AgentRuntimeUserInputRecovery::Answered {
request: user_input_record_view(&record),
observation,
@@ -895,6 +926,7 @@ pub(crate) fn answer_game_creator_agent_user_input_request_for_pending_at(
if record.observation.as_ref() != Some(&observation) {
return Err("用户输入请求 answered observation 冲突".to_string());
}
bind_user_input_record_to_static_delegate_at(root, pending, &record)?;
Ok((user_input_record_view(&record), observation))
}
@@ -7125,6 +7125,12 @@
- 传输边界:dev 公网入口不接受 Tauri WebView 的跨域 OPTIONS 预检,因此 release 使用 `tauri-plugin-http` 原生 transport。插件的 npm 依赖只归属 AGC 子包及其 lockfile,根 H5 package 与根 lockfile 不得引入任何 Tauri guest 依赖。插件 capability 与前端 URL 解析双重限制为 `https://dev.genarrative.world/api/*`,不为 WebView CSP 增加远程 `connect-src`,也不开放任意 HTTP(S) 目标。
- 会话边界:插件默认 Cookie Store 持久化 refresh Cookie;访问 Token 继续保存在现有客户端存储并通过 Authorization header 发送,不把 Token、Cookie 或登录正文写入日志、配置文件或仓库。
## 2026-08-12 子 Agent 澄清回执由 Supervisor 中转(Issue #163
- 决策:`project-supervisor` 继续是唯一正式用户对话 Agent。child 不开放 `user.input_request`;缺少关键用户事实时,child 终态返回 `AGC_NEEDS_USER_INPUT_V1` 结构化问题,形成 `needs-user-input` delivery。
- 中转:Supervisor 认领回执后,在自己的父 run 按原 delivery 逐一创建 durable 用户输入 pending action;重启、重复回执和重复唤醒只复用身份与问题完全一致的 action。回答沿用现有 same-run 回灌,并把 requestId 与 answersSha256 原子绑定回原 delivery;随后最多创建一次绑定原 `delegationId` 的 continuation child。
- 约束:原 delivery 持久保存 `questionsSha256 / clarificationRequestId / clarificationAnswersSha256``agent.delegate` continuation 提交原 delegation、问题 SHA 与答案 SHARuntime 自动派生稳定 identity,即使旧 pending 已清理也必须失败关闭校验。多个 child 的问题不能展平为同一请求,问题 envelope、数量、长度、归属和指纹校验失败关闭。不得把 child pending action直接暴露给用户,也不得把澄清回执误判成普通 repair。
## 2026-08-10 AGC monorepo 构建强制复用单一 React runtime
- 根因:AGC 子目录存在独立 `node_modules` 时,AGC 源码会解析子目录 React,而仓库共享组件解析根目录 React;登录页不依赖共享 Hooks,进入首页后才触发 `Cannot read properties of null (reading 'useCallback')` 并白屏。
@@ -4734,6 +4734,13 @@
- 队列与事务:独立恢复面板必须展示后端权威队列的所有 operation,读取失败不能伪装为空。`remote-failed` 不再重放,只能显式标为 `archived` 并保留账本;`reconciliation-required` 不能归档。派生 asset 使用 `prepared -> media-installed -> manifest-written -> revision-written -> committed` journal,只对可证明状态前向恢复;尚未证明目标写入时严格核对 before/after,已证明目标 asset/media 与 target revision 后允许 manifest/revision 被后续合法提交继续推进,并补齐同一 ledger。committed 后只有 staging 与正式媒体摘要一致、manifest 按 ID 或路径唯一精确匹配 journal asset 时才尽力清理;删除 I/O 失败保持 durable committed,身份或媒体漂移保留 staging 并进入对账。version journal 同样冻结 project revision before/after 身份;manifest 已有子版本但 journal 缺失,或旧 journal 面对已推进 revision 无法补证时都失败关闭。
- 验证:覆盖跨进程唯一 refine 草稿发现和多候选失败关闭、文本 Provider 成功到 staging 崩溃后零重复调用、任务视频/版本旧账本恢复、所有目录条目上限、Key 轮换与旧 Key 无法验证时的显式确认、Accepted 后 401/403 再换 Key 只 GET 原 operation、远端明确失败只归档且零新网络/扣费、三条乱序恢复队列、项目切换迟到结果、asset transaction 各崩溃阶段、revision 后项目继续合法修改仍补齐 ledger、committed 后 staging 清理成功/删除 I/O 失败/媒体或 manifest 漂移保留、源摘要漂移拒绝、committed 视频二次派生,以及 manifest 子版本缺 journal、旧 version journal 无法证明 revision 推进与 version journal exactly-once。
## 子 Agent 澄清不能直接穿透用户输入权限(2026-08-12)
- 现象:child 需要产品取舍时若直接调用 `user.input_request` 会被 owner gate 拒绝;若把它误走 `needs-repair`Supervisor 会错误返工而永远不向用户提问。
- 正确路径:child 返回短小的 `AGC_NEEDS_USER_INPUT_V1` envelopeRuntime 生成 `needs-user-input` delivery,父 Supervisor 认领后创建自己的 durable `user.input_request`。回答仍绑定原父 run,续建 child 由稳定 delegation identity 幂等控制。
- 验证:重复 wake / Runner 重启不得创建第二个用户输入 action;问题数量、字段长度、问题 SHA 和答案 SHA 不匹配时必须 fail-closed。child 直接请求用户输入仍应保持拒绝。
- 恢复加固:正常 completed child 的最终回复也必须进入 envelope 解析;回答后 pending 会被下一轮动作替换,因此 continuation 不能读取 current pending 作为证据,必须读取原 delivery 上的 durable request/answer 绑定。多个 child 各用一条用户请求逐一收束,禁止把不同 delivery 的问题和答案指纹拍平混用。
## 无限画布延迟草稿与零位移不能制造新状态(2026-08-11)
- 现象:r5 的 `loadDraft` 比 r6 更晚回包时会把草稿回退;pointerdown 后没有任何移动,pointerup 仍增加一条空 undo 并触发 CAS 保存;Tauri 自行维护 Shift toggle 后,Shift 单击唯一选中图层会意外清空选择。同一 `canvas.failed` 视图还可能把草稿保存或提交故障显示成生成重试。
@@ -1,5 +1,13 @@
# AI 游戏创作智能体 App 实施计划
## 2026-08-12 Issue #163:子 Agent 澄清回执中转
正式用户对话 Agent 仍固定为 `project-supervisor`;委派专业 Agent 和隔离 child 不得直接调用 `user.input_request`。当 child 缺少会实质改变结果的用户事实时,child 以短小的 `AGC_NEEDS_USER_INPUT_V1` + 结构化 JSON 终态回执交付问题,Runtime 将其作为 `needs-user-input` delivery,而不是 `needs-repair`
Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创建 durable `user.input_request` pending action,并将原 `delegationId`、问题数量和 `questionsSha256` 放入父任务上下文;不同 child 的问题不得展平到同一请求。Runner 重启、重复 delivery 或重复 wake 只复用当前 delivery 的同一 pending action,不把 child 的 pending action 暴露给用户。用户回答仍走现有 Supervisor same-run continuation;回答 observation 绑定 `requestId``responseId`、问题 / 答案指纹,并把 `requestId / answersSha256` 原子写回原 delivery。答案收齐后,Supervisor 最多创建一次绑定原 `delegationId` 的 continuation child`agent.delegate` 必须提交 `continuationOfDelegationId / questionsSha256 / answersSha256`Runtime 自动派生稳定 continuation identity,不依赖已清理的 pending action,也不得按单个答案重复创建 child。
问题 envelope、问题数量、字段长度和 SHA 校验均 fail-closed;问题正文只在受保护的 Runtime sidecar / 父 run 上下文中流转,不写入公共审计。若合并后的问题超过用户输入上限,Runtime 停止自动提问并进入 reconciliation,等待人工核对。
## 目标
在 Genarrative 内建设独立桌面 App:普通用户通过项目开发工作台中的陶泥儿对话、资源画布、运行状态和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览;主窗口提供运行时配置入口,用于保存发布版 AppData / Tauri 配置目录里的 LLM 配置及受控开发者 External Editor 配置。普通客户素材画布使用平台登录态调用内部编辑器 API,不展示或要求填写画板 Base URL / API Key。任务明细、原始文件、命令日志和专业 Agent 调试控制仍放到开发构建的独立开发窗口。v1 的生成闭环仍以 Web 小游戏为主,同时允许用户打开已有 Godot 项目:所选含 `project.godot` 的目录直接成为项目根,Agent 使用标准运行档在该目录内继续修改,只新增并保留 `.agent/` 作为运行元数据,不创建 `game/``assets/``memory/``exports/` 平行目录;本期不扩展 Unity、Godot 内嵌预览、云同步或插件市场。