Merge remote-tracking branch 'web/master' into feat/five_min_design

# Conflicts:
#	apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs
#	apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
#	docs/project-memory/shared-memory/decision-log.md
#	docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
2026-08-12 12:38:03 +00:00
142 changed files with 23580 additions and 2669 deletions
@@ -29,7 +29,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
- Authenticate MCP and business API calls with `Authorization: Bearer <tnr_sk_...>`. Never ask the user to paste a key into chat or place one in repository files.
- All eight generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
- Retry an uncertain submission only with the exact same body and the same idempotency key. A polling timeout is not permission to generate again.
- Use stable references such as `objectKey`, project resource ID, or asset ID in generation requests. Use `/assets/read-url` only for temporary preview/download access.
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
@@ -50,7 +50,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Capability | POST path | Required body fields | Common optional body fields |
| --- | --- | --- | --- |
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
@@ -82,13 +82,13 @@ After confirming a local upload, pass its stable `objectKey` into operations tha
| Target capability | Field |
| --- | --- |
| Image generation | `referenceImageSrcs` |
| Image edit/redraw | `sourceImageSrc`; additional references in `referenceImageSrcs` |
| Image edit/redraw | `sourceReferenceId` must be a registered project resource ID or asset ID; additional references remain in `referenceImageSrcs` |
| Icon spritesheet | Register the primary spec as an `assetKind="icon-spec"` project resource or asset, then pass its returned ID as `referenceId`; additional style references remain in `referenceImageSrcs` |
| UI design extraction | `sourceImageSrc`; additional references in `referenceImageSrcs` |
| Character animation | `sourceImageSrc` |
| Video with image references | `referenceImageSrcs` |
Use video/audio reference arrays only with models that support them. Do not pass an expiring signed read URL as a generation reference.
For image edit/redraw, confirming an upload is not sufficient: create a project resource or asset-library record first, then pass that record's ID as `sourceReferenceId`. The main source never accepts objectKey, URL, Data URL, or Blob URL. Use video/audio reference arrays only with models that support them. Do not pass an expiring signed read URL as a generation reference.
The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL.
@@ -169,6 +169,8 @@ client.generate_image(
)
```
Image edit/redraw has a stricter main-source identity rule. After upload confirmation, create either a project resource or an asset-library record and pass its `resourceId` or `assetId` as `sourceReferenceId`. Do not pass the uploaded objectKey as the main source; objectKey remains valid only for auxiliary `referenceImageSrcs` where the OpenAPI permits it.
Icon spritesheet generation has a stricter primary-spec contract. After upload confirmation, create a project resource or asset record with `assetKind: "icon-spec"`, retain its returned `resourceId` or `assetId`, and pass that ID as `referenceId`. The primary spec does not accept the uploaded `objectKey` directly; only additional style references may continue to use stable object keys in `referenceImageSrcs`.
For character animation from a local-only source, use actual dimensions and a stable synthetic layer ID:
@@ -548,13 +548,16 @@ class GenarrativeExternalClient:
idempotency_key=idempotency_key,
)
def edit_image(self, prompt: str, source_image_src: str, **fields: Any) -> Any:
def edit_image(self, prompt: str, source_reference_id: str, **fields: Any) -> Any:
source_reference_id = source_reference_id.strip()
if not source_reference_id:
raise GenarrativeApiError("source_reference_id must be a registered resource or asset ID")
self._apply_canvas_session_fields(fields, prompt, 1024, 1024)
prompt = self._apply_art_spec(fields, prompt)
idempotency_key = fields.pop("idempotencyKey", None)
return self.submit_and_wait_generation(
"/api/external/v1/editor/images/edits",
{"prompt": prompt, "sourceImageSrc": source_image_src, **fields},
{"prompt": prompt, "sourceReferenceId": source_reference_id, **fields},
idempotency_key=idempotency_key,
)
+1
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
+1
View File
@@ -36,6 +36,7 @@
"dependencies": {
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
@@ -1688,8 +1688,8 @@ for (const snippet of [
"'write_game_creator_app_config'",
'aria-label="运行时配置"',
'LLM API Key',
'showDeveloperEditorApi',
'开发者 External Editor API Key',
'External Editor Base URL',
'External Editor API Key',
'runtime_config.save',
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
"'activate_local_game_preview'",
+4 -37
View File
@@ -725,16 +725,6 @@ dependencies = [
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -758,7 +748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
@@ -771,7 +761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-foundation",
"libc",
]
@@ -2091,11 +2081,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -4421,7 +4409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -4977,27 +4965,6 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -5019,7 +4986,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -42,7 +42,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "mul
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = "2.5.9"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
tauri-plugin-opener = "2"
tempfile = "3"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
@@ -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 或调试状态。
@@ -5721,6 +5721,9 @@ mod canvas_generation_tests {
};
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_nonblocking(false)
.expect("set request stream blocking");
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set request read timeout");
@@ -720,6 +720,30 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
.actions
.iter()
.any(|action| action.tool.trim() == "agent.route_manifest");
let latest_playtest_index = observations
.iter()
.rposition(|observation| observation.tool == "preview.validate");
let latest_playtest_is_failed = latest_playtest_index.is_some_and(|index| {
observations[index].status == "failed"
&& observations[index].summary == "浏览器验证未通过,请根据诊断修复后重试"
});
// A Supervisor without a mutation must still hit the same first-mutation
// liveness gate as every other autonomous Agent. A concrete failed
// playtest is more specific and must reach its repair gate below.
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& loop_index > AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT
&& verification_gate.mutation_revision.is_none()
&& verification_gate.failed_playtest_revision.is_none()
&& !has_mutation
&& !has_code_asset_route
&& plan.response.trim().is_empty()
&& !has_specialist_delegation
&& !latest_playtest_is_failed
{
return Err(format!(
"{AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX}Supervisor 尚未提交首次项目 mutation 或有效协作动作,禁止继续只规划、读取、验证或空转"
));
}
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
if let Some(failed_playtest_revision) = verification_gate.failed_playtest_revision {
if project_revision < failed_playtest_revision {
@@ -761,10 +785,7 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
"{AGENT_RUNTIME_AUTONOMOUS_PREVIEW_AFTER_STATIC_LIVENESS_ERROR_PREFIX};当前 revision {project_revision} 已通过 game.static_smoke,下一步必须只调用 preview.validate 取得当前 revision 的桌面与移动真实试玩凭证;不得继续委派、更新计划、读取、搜索、查询状态、修改项目或返回最终回复"
));
}
let Some(latest_playtest_index) = observations
.iter()
.rposition(|observation| observation.tool == "preview.validate")
else {
let Some(latest_playtest_index) = latest_playtest_index else {
return Ok(());
};
let latest_playtest = &observations[latest_playtest_index];
@@ -2081,4 +2102,88 @@ mod tests {
.is_err());
}
}
#[test]
fn autonomous_supervisor_without_playtest_still_hits_pre_mutation_liveness_gate() {
let verification_gate = AgentRuntimeVerificationGate {
schema_version: "test".to_string(),
project_id: "test".to_string(),
agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
run_id: "supervisor-pre-mutation".to_string(),
requires_verification: false,
mutation_revision: None,
verified_revision: None,
last_mutation_tool: None,
last_verification_tool: None,
last_verification_status: None,
static_smoke_verified_revision: None,
failed_playtest_revision: None,
updated_at: 0,
};
let plan = AgentRuntimeToolPlan {
thinking_summary: "继续只读规划".to_string(),
..AgentRuntimeToolPlan::default()
};
let error = validate_agent_runtime_autonomous_plan_liveness(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
0,
&verification_gate,
&[],
&plan,
false,
false,
)
.expect_err("Supervisor without a playtest must not bypass pre-mutation liveness");
assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX));
}
#[test]
fn autonomous_supervisor_latest_successful_playtest_does_not_bypass_pre_mutation_gate() {
let verification_gate = AgentRuntimeVerificationGate {
schema_version: "test".to_string(),
project_id: "test".to_string(),
agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
run_id: "supervisor-latest-playtest".to_string(),
requires_verification: false,
mutation_revision: None,
verified_revision: None,
last_mutation_tool: None,
last_verification_tool: None,
last_verification_status: None,
static_smoke_verified_revision: None,
failed_playtest_revision: None,
updated_at: 0,
};
let observations = [
AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "failed".to_string(),
summary: "浏览器验证未通过,请根据诊断修复后重试".to_string(),
detail: None,
},
AgentRuntimeToolObservation {
tool: "preview.validate".to_string(),
status: "ok".to_string(),
summary: "浏览器验证通过".to_string(),
detail: None,
},
];
let plan = AgentRuntimeToolPlan {
thinking_summary: "继续只读规划".to_string(),
..AgentRuntimeToolPlan::default()
};
let error = validate_agent_runtime_autonomous_plan_liveness(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
0,
&verification_gate,
&observations,
&plan,
false,
false,
)
.expect_err("latest successful playtest must not retain an earlier failure exemption");
assert!(error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX));
}
}
@@ -395,6 +395,43 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
if autonomous_game_build && agent_id == "art-director" {
remove_autonomous_art_director_non_canvas_validation_tools(&mut request.function_tools)?;
}
// A rejected structured-plan update is a request-scoped liveness signal.
// The next Provider turn must perform the concrete mutation (or deliver a
// read-only result) instead of entering another planning loop.
let latest_plan_rejection = observations.iter().rposition(|observation| {
observation.tool == "runtime.plan_update" && observation.status == "rejected"
});
let plan_rejection_needs_repair = latest_plan_rejection.is_some_and(|index| {
!observations[index + 1..]
.iter()
.any(is_agent_runtime_project_mutation_observation)
});
if autonomous_game_build && plan_rejection_needs_repair {
let supervisor_orchestrator_repair = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
let policy = resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id)?.policy;
let state = read_supervisor_collaboration_state_at(root, agent_id, run_id)?;
policy.orchestrator_only_after_delegation && state.has_collaboration()
} else {
false
};
let repair_tools: &[&str] = if supervisor_orchestrator_repair {
&["agent.delegate", "agent.run_status"]
} else {
&["file.write", "file.patch", "file.delete", "project.patchset", "project.restore", "canvas.asset_generate"]
};
let mut allowed_function_names = BTreeSet::from([AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string()]);
for tool in repair_tools {
if let Some(name) = native_runtime_function_name(tool) {
allowed_function_names.insert(name);
}
}
request.function_tools.retain(|tool| allowed_function_names.contains(&tool.name));
request.messages.push(LlmMessage::user(if supervisor_orchestrator_repair {
"上一轮 runtime.plan_update 被拒绝。本轮 Supervisor 已进入协作编排模式,只能调用 agent.run_status 或 agent.delegate 继续收束,或在证据足够时 respond_to_user;禁止再次规划、读取、搜索、验证或直接修改项目。"
} else {
"上一轮 runtime.plan_update 被拒绝。本轮必须立即提交当前 in_progress 步骤对应的实际项目 mutation,或在只读合同已满足时 respond_to_user;禁止再次规划、读取、搜索、验证、委派或普通文本解释。"
}));
}
if autonomous_game_build && !editor_api_key_is_configured() {
let canvas_function = native_runtime_function_name("canvas.asset_generate")
.ok_or_else(|| "无法生成画布素材工具函数名".to_string())?;
@@ -610,11 +647,14 @@ mod tests {
new_game_creation_app_seed_tasks, provider_command_exec_contract,
provider_command_start_contract, render_autonomous_manifest_ready_task_background_prompt,
required_runtime_prompt_section, resolve_agent_conversation_session_id_at,
start_game_creator_agent_runtime_task_at, AgentRuntimeTaskLink, AgentRuntimeToolPlan,
start_game_creator_agent_runtime_task_at, AgentRuntimeTaskLink, AgentRuntimeToolObservation,
AgentRuntimeToolPlan,
GameCreatorMcpCatalog, GameCreatorMcpCatalogTool,
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL,
AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
};
@@ -636,6 +676,71 @@ mod tests {
.collect()
}
#[test]
fn rejected_plan_update_forces_request_scoped_mutation_catalog() {
let directory = crate::tests::canonical_test_tempdir("provider-plan-rejection-repair-");
let root = directory.path().join("project");
init_local_game_project_at(&root, "plan-rejection-repair", "修复现有游戏")
.expect("project init");
let binding = bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"plan-rejection-repair-root",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind root");
let state = start_game_creator_agent_runtime_task_at(
&root,
&binding.agent_id,
"修复现有游戏",
&binding.run_id,
&binding.source,
"执行当前计划中的项目修改",
vec!["立即修改 game/index.html".to_string()],
)
.expect("start task");
let catalog = GameCreatorMcpCatalog {
fingerprint: String::new(),
servers: Vec::new(),
tools: Vec::new(),
};
let rejected = AgentRuntimeToolObservation {
tool: "runtime.plan_update".to_string(),
status: "rejected".to_string(),
summary: "结构化计划更新被 Runtime 拒绝".to_string(),
detail: Some("计划状态回退".to_string()),
};
let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request(
&root,
&state.agent_id,
&state.session_id,
&state.run_id,
&state.current_task,
&[rejected],
1,
&catalog,
)
.expect("build request");
let names = request
.function_tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<std::collections::BTreeSet<_>>();
assert!(names.contains(
crate::agent_native_tools::native_runtime_function_name("file.patch")
.expect("file.patch function")
.as_str()
));
assert!(names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME));
assert!(!names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME));
assert!(request
.messages
.iter()
.any(|message| message.content.contains("runtime.plan_update 被拒绝")));
}
fn build_request_system_prompt_for_root_source(
agent_id: &str,
root_source: &str,
@@ -311,6 +311,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 =
@@ -254,11 +254,17 @@ fn persist_game_chat_main_asset_audit_and_route(
.expect("persist game-chat main asset route");
}
fn game_chat_main_art_child_fixture(
fn game_chat_main_art_child_fixture_with_lane(
root: &Path,
target_agent_id: &str,
missing_slots: &[&str],
) -> (AgentRuntimeState, AgentRuntimeState, String) {
hold_child_lane: bool,
) -> (
AgentRuntimeState,
AgentRuntimeState,
String,
Option<AgentRuntimeTaskLock>,
) {
init_local_game_project_at(root, "game-chat-art-child", "单主美术委派")
.expect("init game-chat art child project");
let root_run_id = "game-chat-main-art-root";
@@ -310,6 +316,11 @@ fn game_chat_main_art_child_fixture(
)
.expect("persist game-chat main route");
let child_lane = hold_child_lane.then(|| {
try_acquire_game_creator_agent_runtime_task_lock(root, target_agent_id)
.expect("acquire delegated art child lane")
.expect("delegated art child lane available")
});
let action_id = format!("game-chat-main-delegate-{target_agent_id}");
let observation = observe_agent_runtime_agent_delegate(
root,
@@ -335,11 +346,27 @@ fn game_chat_main_art_child_fixture(
)
.expect("read delegated art child")
.expect("delegated art child exists");
(
main,
agent_runtime_state_from_task_record(&child),
delegation_id,
)
let mut child = agent_runtime_state_from_task_record(&child);
if child_lane.is_some() {
child.status = "running".to_string();
child.phase = "planning".to_string();
child.current_action = "执行受限美术子任务".to_string();
append_game_creator_agent_runtime_task(root, &child)
.expect("persist running delegated art child");
write_game_creator_agent_runtime_state(root, &child)
.expect("persist running delegated art runtime");
}
(main, child, delegation_id, child_lane)
}
fn game_chat_main_art_child_fixture(
root: &Path,
target_agent_id: &str,
missing_slots: &[&str],
) -> (AgentRuntimeState, AgentRuntimeState, String) {
let (main, child, delegation_id, _child_lane) =
game_chat_main_art_child_fixture_with_lane(root, target_agent_id, missing_slots, false);
(main, child, delegation_id)
}
fn snapshot_agent_durable_files(root: &Path) -> std::collections::BTreeMap<String, Vec<u8>> {
@@ -1207,20 +1234,12 @@ fn game_chat_main_without_asset_audit_fixture(root: &Path) -> String {
async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_children_to_assets() {
let temporary = tempfile::tempdir().expect("create game-chat art child root");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "game-chat-art-child", "单主美术委派")
.expect("pre-initialize game-chat art child project");
let _child_runtime_lane =
try_acquire_game_creator_agent_runtime_task_lock(&root, "art-asset-plan")
.expect("acquire game-chat art child runtime lane")
.expect("game-chat art child runtime lane is free");
let (_main, mut child, _delegation_id) =
game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]);
child.status = "running".to_string();
child.phase = "planning".to_string();
append_game_creator_agent_runtime_task(&root, &child)
.expect("persist running game-chat art child task");
write_game_creator_agent_runtime_state(&root, &child)
.expect("persist running game-chat art child state");
let (_main, mut child, _delegation_id, _child_lane) = game_chat_main_art_child_fixture_with_lane(
&root,
"art-asset-plan",
&["core-spritesheet"],
true,
);
assert_eq!(child.agent_id, "art-asset-plan");
assert_eq!(child.source, "agent-delegate");
assert_eq!(child.parent_agent_id.as_deref(), Some("code-prototype"));
@@ -1879,12 +1898,25 @@ fn game_chat_main_agent_rejects_unneeded_or_wrong_art_delegation() {
assert!(out_of_order.summary.contains("先完成并认领 art-director"));
}
#[test]
fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
let temporary = tempfile::tempdir().expect("create game-chat receipt wake root");
let root = temporary.path().join("project");
let (mut main, mut child, delegation_id) =
game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]);
let (mut main, mut child, delegation_id, child_lane) =
game_chat_main_art_child_fixture_with_lane(
&root,
"art-asset-plan",
&["core-spritesheet"],
true,
);
let child_lane = child_lane.expect("delegated art child lane is held");
main.status = "running".to_string();
main.phase = "waiting-for-delegate-receipts".to_string();
main.current_action = "等待临时美术 Agent 回执".to_string();
main.waiting_on = "art-asset-plan 完成并回执".to_string();
append_game_creator_agent_runtime_task(&root, &main).expect("persist waiting main task");
write_game_creator_agent_runtime_state(&root, &main).expect("persist waiting main runtime");
child.status = "completed".to_string();
child.phase = "completed".to_string();
child.current_action = "已写入临时美术资产".to_string();
@@ -1897,6 +1929,9 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
)
.expect("read completed art child")
.expect("completed art child exists");
let parent_lane = try_acquire_game_creator_agent_runtime_task_lock(&root, "code-prototype")
.expect("acquire waiting main-agent execution lane")
.expect("waiting main-agent execution lane is free");
publish_game_creator_agent_delegate_result(
&root,
&child_task,
@@ -1951,12 +1986,8 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
assert_eq!(duplicate.status, "failed", "{duplicate:?}");
assert!(duplicate.summary.contains("最多委派一次"));
main.status = "running".to_string();
main.phase = "waiting-for-delegate-receipts".to_string();
main.current_action = "等待临时美术 Agent 回执".to_string();
main.waiting_on = "art-asset-plan 完成并回执".to_string();
append_game_creator_agent_runtime_task(&root, &main).expect("persist waiting main task");
write_game_creator_agent_runtime_state(&root, &main).expect("persist waiting main runtime");
drop(child_lane);
drop(parent_lane);
let waiting = read_latest_game_creator_agent_runtime_task_by_run_id(
&root,
"code-prototype",
@@ -1966,6 +1997,21 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
.expect("waiting main task exists");
let wake_started = wake_waiting_static_delegate_parent_run_at(&root, &waiting)
.expect("wake same main run after art receipt");
cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", &main.run_id)
.expect("cancel resumed fixture run");
let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if game_creator_agent_runtime_task_lock_is_available(&root, "code-prototype")
.expect("probe resumed main-agent lane release")
{
break;
}
assert!(
std::time::Instant::now() < release_deadline,
"resumed main-agent lane did not release before fixture teardown"
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
let resumed = read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path(
&root,
"code-prototype",
@@ -1981,8 +2027,17 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
#[test]
fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_need_repair() {
let temporary = crate::tests::canonical_test_tempdir("main-loop-legacy-");
let root = temporary.path().join("project");
let temporary_root = std::env::temp_dir()
.canonicalize()
.expect("canonicalize system temporary directory");
let root = temporary_root.join(format!(
"genarrative-agent-main-loop-legacy-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
init_local_game_project_at(&root, "legacy-derived-visuals", "旧派生视觉返工门禁")
.expect("project init");
assert!(!autonomous_registered_derived_visuals_need_repair_at(&root));
@@ -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,
@@ -1185,7 +1185,7 @@ async fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_pro
let _ = crate::tests::wait_for_agent_runtime_manifest_projection_async(
&root,
"code-prototype",
&scheduled[0].state.run_id,
&autonomous_manifest_ready_task_run_id(&continuation.run_id, "code-prototype"),
"failed",
"budget-exhausted",
GameCreationAppTaskStatus::Failed,
@@ -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,25 @@ fn validate_native_agent_delegate_input(
true,
)?;
validate_native_delegate_string(object.get("runId"), "runId", 160, true)?;
if object.contains_key("continuationOfDelegationId") {
validate_native_delegate_string(
object.get("continuationOfDelegationId"),
"continuationOfDelegationId",
160,
true,
)?;
}
if object.contains_key("questionsSha256") {
validate_native_delegate_string(
object.get("questionsSha256"),
"questionsSha256",
64,
true,
)?;
}
if object.contains_key("answersSha256") {
validate_native_delegate_string(object.get("answersSha256"), "answersSha256", 64, true)?;
}
if object
.get("repairOfDelegationId")
.is_some_and(Value::is_string)
@@ -538,6 +568,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 +1287,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 +1579,68 @@ 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_accepts_legacy_input_without_clarification_fields() {
let mut input = valid_delegate_input(Value::Null, Value::Null);
let object = input.as_object_mut().expect("delegate input object");
object.remove("continuationOfDelegationId");
object.remove("questionsSha256");
object.remove("answersSha256");
validate_native_agent_delegate_input(&input)
.expect("legacy delegate input without clarification fields");
}
#[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";

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