From effdaefc63ef5d34b7d03d113fc7678b35985e5d Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 12 Aug 2026 17:00:50 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=E8=AE=A1=E5=88=92=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E9=87=8D=E5=A4=8D=E8=A7=84=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 runtime.plan_update 被拒绝后按角色收窄下一轮工具目录 恢复 Supervisor 首次项目修改的活性门禁 补充请求目录与活性门禁回归测试 同步更新游戏创作智能体实施规则 --- .../runtime_actions/autonomous_policy.rs | 50 ++++++++ .../provider_request_builders.rs | 107 +++++++++++++++++- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index cc6d337e5..7727f2188 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -702,6 +702,21 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness( .actions .iter() .any(|action| action.tool.trim() == "agent.route_manifest"); + // A Supervisor without a mutation must still hit the same first-mutation + // liveness gate as every other autonomous Agent. Previously the presence + // of the Supervisor role alone could bypass this check. + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && loop_index > AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + && verification_gate.mutation_revision.is_none() + && !has_mutation + && !has_code_asset_route + && plan.response.trim().is_empty() + && !has_specialist_delegation + { + 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 { @@ -1911,4 +1926,39 @@ 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)); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 3f2a0951d..f3a72dfd6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -316,6 +316,43 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( tool.name != goal_contract_function && tool.name != acceptance_update_function }); } + // 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())?; @@ -530,11 +567,14 @@ mod tests { game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at, provider_command_exec_contract, provider_command_start_contract, 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, }; @@ -556,6 +596,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::>(); + 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, diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e5f75e4b4..1c3113d76 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1051,6 +1051,8 @@ game-project/ ## 2026-08-11 通用 Goal Contract 与动态 Acceptance Graph +- 2026-08-12 计划拒绝恢复:结构化 `runtime.plan_update` 被 Runtime 拒绝后,下一轮 Provider 请求按请求级目录收窄到实际项目 mutation 与 `respond_to_user`(已进入协作编排的 Supervisor 保留 `agent.delegate / agent.run_status`),并明确禁止再次规划、读取、搜索或验证;后续已有真实 mutation observation 后解除临时目录,不改变持久 executable policy。 + - Goal Contract 绑定 project、可信根 Run Profile、source task SHA-256 和不可变 fingerprint;同一根 Run 只允许幂等重放完全相同的合同,语义变化必须进入新根 Run。已有合同的根 Supervisor 收到 steer 时,Runtime 必须按旧 rootRunId 串行化转换并在持锁后重验 Session 当前权威 Run,再取消并确认旧 rootRunId 的静态、ready、isolated 整棵树已进入终态或 `needs-reconciliation`;旧树未停稳时拒绝启动 replacement,停稳后才在同一 Session、source 和 Run Profile 创建唯一的新根 Run,不能把新增要求塞进旧合同继续完成。合同摘要作为 `decision` 投影到共享黑板,JSON sidecar 才是权威源;黑板冲突条目和专家事实仍追加保留。 - 所有静态 delegate、ready child 和 isolated child 都只读继承根合同与当前 Acceptance Graph;isolated child 还必须实际收到自己的 `acceptanceCriteria / expectedArtifacts / writeScopes`。继承上下文不扩大工具、目录、写入权限或 expectedArtifacts。专业 Agent 只能报告局部结果、证据和剩余风险,不能修改根合同、根验收图或宣布用户总目标完成。 - Acceptance Graph 节点由 Supervisor 针对当前任务动态生成,不来自玩法模板。节点记录 required/optional、依赖、状态、证据引用与摘要;只有同一可信根 Supervisor 能调用 `agent.acceptance_update`,且该动作必须独占一轮。failed 与 not-observed 节点形成下一轮定向返工集合,未提交的 passed 节点保持不变。 From 5e16837f3ddf67705f00b651ea91a6ec03ed35d5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 12 Aug 2026 18:43:24 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=20External=20Editor=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 普通 Launcher、开发工作台和独立 game-chat 统一显示 External Editor 配置。 补齐配置可见性、密码输入和持久化回归测试。 同步配置静态门禁、PRD、技术合同与共享决策。 --- .../scripts/check-config.mjs | 4 +- apps/ai-game-creator-shell/src/App.tsx | 1 - .../runtime-config/RuntimeConfigDialog.tsx | 56 ++++++++----------- .../appSurface/project-development.suite.ts | 42 ++++++++++++++ .../appSurface/runtime-settings.suite.ts | 35 +++++++----- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 4 +- .../shared-memory/decision-log.md | 1 + ...客户端素材创作无限画布阶段一合同-2026-08-05.md | 4 +- 8 files changed, 93 insertions(+), 54 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 0d913a01b..9b1a0a318 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -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'", diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 875d09ec6..2dc8e129b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -11187,7 +11187,6 @@ export function App({ {runtimeConfigOpen ? ( setRuntimeConfigOpen(false)} onLog={(entry) => setCommandLog((current) => [...current, entry])} /> diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index ce52e5f90..c8c9fdd23 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -522,12 +522,10 @@ function normalizeRuntimeConfigDraft( export function RuntimeConfigDialog({ projectPath, - showDeveloperEditorApi = false, onClose, onLog, }: { projectPath?: string; - showDeveloperEditorApi?: boolean; onClose: () => void; onLog?: (entry: string) => void; }) { @@ -1381,38 +1379,28 @@ export function RuntimeConfigDialog({ })} ) : null} - {showDeveloperEditorApi ? ( - <> - - - - ) : null} + +
diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index eed4da3a4..a4a3dbea6 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -4278,6 +4278,48 @@ export function registerProjectSupervisorSurfaceTests() { ); }); + it('exposes External Editor credentials from the game-chat settings', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { + path: '/home/test/AppData/game-creator.config.json', + config: { + editorApi: { + baseUrl: 'http://127.0.0.1:8082', + apiKey: 'game-chat-editor-secret', + }, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderGameChatStatus({ + runtime: gameChatRuntimeState(), + viewOverrides: { + onOpenRuntimeConfig: vi.fn(), + runtimeConfigOpen: true, + }, + }); + + expect( + await screen.findByRole('dialog', { name: '运行时配置' }), + ).not.toBeNull(); + expect(screen.getByLabelText('External Editor Base URL')).toHaveProperty( + 'value', + 'http://127.0.0.1:8082', + ); + expect(screen.getByLabelText('External Editor API Key')).toHaveProperty( + 'type', + 'password', + ); + expect( + screen + .getByLabelText('External Editor API Key') + .getAttribute('autocomplete'), + ).toBe('off'); + }); + it('opens the dedicated game-chat release without platform authentication', async () => { const fetchMock = vi .spyOn(globalThis, 'fetch') diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index 6b2941d40..1851a5e4c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -434,6 +434,19 @@ export function registerRuntimeSettingsTests() { 'value', '12000', ); + expect(screen.getByLabelText('External Editor Base URL')).toHaveProperty( + 'value', + 'http://127.0.0.1:8082', + ); + expect(screen.getByLabelText('External Editor API Key')).toHaveProperty( + 'type', + 'password', + ); + expect( + screen + .getByLabelText('External Editor API Key') + .getAttribute('autocomplete'), + ).toBe('off'); fireEvent.change(screen.getByLabelText('LLM 模型'), { target: { value: 'gpt-launcher-updated' }, }); @@ -856,15 +869,13 @@ export function registerPublishedRuntimeSettingsTests() { expect( screen.getByLabelText('LLM API Key').getAttribute('autocomplete'), ).toBe('off'); - expect( - screen.getByLabelText('开发者 External Editor API Key'), - ).toHaveProperty( + expect(screen.getByLabelText('External Editor API Key')).toHaveProperty( 'type', 'password', ); expect( screen - .getByLabelText('开发者 External Editor API Key') + .getByLabelText('External Editor API Key') .getAttribute('autocomplete'), ).toBe('off'); expect(screen.getByLabelText('Planner LLM API Key')).toHaveProperty( @@ -1007,13 +1018,10 @@ export function registerPublishedRuntimeSettingsTests() { target: { value: 'ark' }, }, ); - fireEvent.change( - screen.getByLabelText('开发者 External Editor Base URL'), - { + fireEvent.change(screen.getByLabelText('External Editor Base URL'), { target: { value: 'http://127.0.0.1:8099' }, - }, - ); - fireEvent.change(screen.getByLabelText('开发者 External Editor API Key'), { + }); + fireEvent.change(screen.getByLabelText('External Editor API Key'), { target: { value: 'editor-new-secret' }, }); fireEvent.click( @@ -1166,9 +1174,10 @@ export function registerPublishedRuntimeSettingsTests() { 'value', '12000', ); - expect( - screen.getByLabelText('开发者 External Editor Base URL'), - ).toHaveProperty('value', 'http://127.0.0.1:8082'); + expect(screen.getByLabelText('External Editor Base URL')).toHaveProperty( + 'value', + 'http://127.0.0.1:8082', + ); fireEvent.click( within(runtimeConfigDialog).getByRole('button', { name: '保存' }), ); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index c7d75812e..2f47de891 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -109,7 +109,7 @@ - 主站账号、钱包、服务端项目、云端素材库和生成面板仍留在网站宿主;客户端本地项目、manifest、Runtime、审批、草稿、生成与正式提交仍留在 Tauri 宿主。共享视觉组件不得读取这些业务事实。 - 客户端 UI 对齐采用“同视觉、同组件、保留四区布局”,不复制主站整页布局。资源总览、运行视图和 Supervisor 对话继续是 Game Agent 工作台独有语义。 - 客户端正式产品仍只按 `1280×800` 横屏合同交付;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。 -- 普通用户界面不默认展示内部错误码、开发者 API 配置或本机绝对路径;确需诊断的信息进入受控详情或开发模式,不与主要动作并列。 +- 普通用户界面不默认展示内部错误码或本机绝对路径;External Editor 配置只进入独立“运行时配置”对话框,不与素材画布主要动作并列,确需诊断的信息进入受控详情或开发模式。 - 素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择;精修资源继承源用途且不可改。导出格式继续限定 PNG/JPEG/WebP。工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。 ### 3.8 现有 Godot 项目 @@ -530,7 +530,7 @@ type ProjectAgentMudPointAttribution = { ### 7.6 素材创作无限画布阶段一至五最终验收 -实现状态(2026-08-11):当前产品切片禁用“新增资源”,只从现有资源进入非破坏性编辑。图片进入中央 refine 画布,其他现役类型进入统一派生编辑壳;取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。Tauri 远端媒体编辑固定使用 AppData 私有 `editorApi.baseUrl/apiKey` 访问 `/api/external/v1/*`,不把 Key 传入 WebView、项目事实、账本、日志或普通错误;画布 UI 不提供 Base URL/API Key 输入。重启恢复只继续原 operation,不以新请求、新幂等键或新 operationId 替代结果未知的旧任务。 +实现状态(2026-08-12):当前产品切片禁用“新增资源”,只从现有资源进入非破坏性编辑。图片进入中央 refine 画布,其他现役类型进入统一派生编辑壳;取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。Tauri 远端媒体编辑固定使用 AppData 私有 `editorApi.baseUrl/apiKey` 访问 `/api/external/v1/*`,普通 Launcher、开发工作台和独立 game-chat 的“运行时配置”都可编辑这两个字段;不把 Key 传入 WebView、项目事实、账本、日志或普通错误,素材画布工作区自身不提供凭据输入。重启恢复只继续原 operation,不以新请求、新幂等键或新 operationId 替代结果未知的旧任务。 1. 网站与 Tauri 实际 import 同一份 `@genarrative/image-canvas-core` 和 `@genarrative/image-canvas-react`,客户端没有复制的主站画布目录;viewport、selection、变换、renderer 与 history 算法位于共享层,宿主只保留事件接线与 adapter 副作用。 2. “新增资源”在当前产品切片中保持禁用;“编辑资源”只接受现有资源。图片 refine 保留原资产与原文件、创建新资产,并用规范 `referenceResourceIds` 登记直接血缘;其他类型同样只追加派生文件/asset 或子版本,不覆盖、删除或重排源记录。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7df22658b..72ad69f78 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -58,6 +58,7 @@ - 产品边界:主站网页画布继续使用登录态 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`;AI 游戏创作 Tauri 客户端没有网页画布宿主,图片、图片引用、视频、音效和背景音乐等远端媒体编辑固定使用 `/api/external/v1/*`。两条入口继续复用相同请求 DTO、owner 归属、统一生成队列和正式资产结果,不新建平行生成服务。 - 凭据边界:客户端沿用发布 AppData 私有运行时配置中的 `editorApi.baseUrl/apiKey`,不实现登录后自动签发 Developer API Key,不把站内 Access Token 传给 Tauri 生成命令,也不把 API Key 打包进仓库、传入 WebView、写入项目、账本、日志或普通错误。Key 缺失投影安全配置错误,`401/403` 投影 External 凭据无效或权限不足,不再描述为站内登录失效。 +- 配置入口:普通 Launcher、开发工作台和独立 game-chat 复用同一“运行时配置”对话框,均可编辑 AppData 私有 `editorApi.baseUrl/apiKey`;字段可见性不依赖开发模式或 Agent 模式。素材画布工作区自身不承接凭据输入,密钥输入保持 password 类型并禁用浏览器自动填充。 - 路由边界:图片编辑、视频、音效和 BGM 分别使用 `/api/external/v1/editor/images/edits`、`/api/external/v1/editor/videos/generations`、`/api/external/v1/editor/audios/sound-effects/generations` 与 `/api/external/v1/editor/audios/background-music/generations`;上传、确认、轮询和换签固定使用 External v1 对应端点。SVG、UTF-8 文档、代码、Agent 回执与项目版本仍是本地派生,不制造无意义的远端请求。 - 可靠性边界:External POST 固定携带原稳定 `Idempotency-Key` 并只接受 `202 + operationId`;`prepared` 只重放原正文和原键,`accepted/running` 只查询 `/api/external/v1/generations/{operationId}`。账本使用 `service-origin-v1` 绑定规范化 External base URL 的服务指纹,不绑定或保存明文 Key;升级前 Key-bound 素材画布生成账本无法用当前 Key 验证时,必须由用户显式确认面板中展示的当前 origin 才能恢复冻结请求;全类型资源编辑的旧指纹无法验证时保留原 operation 对账。升级前已保存的站内 endpoint 只保留为待对账状态,禁止拿 External Key 自动重放。本地参考媒体继续严格执行 ticket → OSS form → confirm,结果只消费稳定 `objectKey/resource/asset`,旧资源保留且派生资源追加。 - 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts`、`apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。 diff --git a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md index 1deb38396..b3c50e6ce 100644 --- a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md +++ b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md @@ -58,7 +58,7 @@ apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter - `image-canvas-core` 只含纯 TypeScript 的画布模型、几何、选择、图层命令、历史、序列化、防御校验和状态机;不得依赖 React、DOM、Tauri、HTTP、账号、钱包或浏览器存储。 - `image-canvas-react` 只含 React 视图、hooks、交互控制器和通用 UI,依赖 core 和注入的 Host Port;不得直接 import Tauri API、站点请求客户端、账户 store 或钱包 store。 - 网站 adapter 可以依赖账户、钱包、现有服务端 editor project、云端素材库、OSS/asset object 和生成 API。 -- Tauri adapter 可以依赖 `invoke/listen`、本地项目上下文、受控媒体命令、manifest、项目 revision、草稿 sidecar 和当前平台登录态;受控开发者模式才可依赖 External Editor API 配置。 +- Tauri adapter 可以依赖 `invoke/listen`、本地项目上下文、受控媒体命令、manifest、项目 revision、草稿 sidecar 和 AppData 私有 External Editor API 配置;该配置由独立“运行时配置”对话框维护,不进入共享画布或项目事实。 - 依赖方向只能是“宿主 adapter -> React/UI -> core”。core/react 不得反向 import 任一宿主。 ### 3.2 禁止复制的验收门 @@ -200,7 +200,7 @@ interface ImageCanvasHostPort { - `hostRevision` 是宿主权威提交版本的字符串表示:Tauri 使用十进制项目 mutation revision,网站使用现有服务端 editor project revision。共享 UI 只透传/展示,不比较不同宿主的 revision。 - `commitId/idempotencyKey` 由共享流程在第一次正式保存前生成;响应未知时两宿主都复用原完整请求。`expectedHostRevision` 由 adapter 从已加载的权威宿主快照提供,Tauri 必须无损解析为本文的安全整数 `expectedRevision`。 - Web adapter 把草稿、导入、生成、导出和提交映射到现有服务端 editor project、云端素材库及账户/钱包链路。 -- Tauri adapter 把草稿、导入、导出和提交映射到本文第 7 至 11 节的本地合同;普通客户生成通过平台登录态调用现有内部编辑器/资产/任务状态 API,后端按 owner 进入统一生成队列与泥点预扣/退款。客户界面不显示或要求填写 Base URL / API Key。External Editor API 只作为第三方 Agent、CLI 和受控内部验收通道;任一模式的凭据都不能进入项目 sidecar、manifest、事件或错误正文。 +- Tauri adapter 把草稿、导入、导出和提交映射到本文第 7 至 11 节的本地合同;远端媒体编辑通过 AppData 私有 `editorApi.baseUrl/apiKey` 使用 External v1,后端按 owner 进入统一生成队列与泥点预扣/退款。素材画布工作区不显示或要求填写 Base URL / API Key,普通 Launcher、开发工作台和独立 game-chat 的独立“运行时配置”对话框均可维护这两个字段;凭据不能进入共享画布、项目 sidecar、manifest、事件或错误正文。 ### 3.4 主站 UI 对齐与共享画布 chrome From 82957fe1d48a543ba35b8d8adcb95771e99d977e Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 12 Aug 2026 20:07:59 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AD=90=20Agent=20?= =?UTF-8?q?=E6=BE=84=E6=B8=85=E5=9B=9E=E6=89=A7=E4=B8=AD=E8=BD=AC=20(#165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #163 实现 child 澄清回执到 Supervisor 的 needs-user-input 中转。 - child 返回结构化问题回执,Runtime fail-closed 校验问题和 SHA-256。 - Supervisor 创建自己的 durable user.input_request,重复唤醒复用同一 action。 - 用户回答沿用 same-run 回灌,后续 continuation 绑定原 delegation。 - 补充 Rust 定向测试、技术方案和共享排障记忆。 验证: - cargo check --tests - cargo test ... claimed_needs_user_input_delivery_becomes_one_supervisor_wait - cargo test ... static_delegate_user_input - git diff --check 注:npm run check:encoding 在当前受限执行环境中因 spawnSync git EPERM 未能运行。 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/165 Co-authored-by: kdletters Co-committed-by: kdletters --- .../src-tauri/prompts/runtime/common.md | 2 +- .../prompts/runtime/supervisor/playbook.md | 2 +- .../runtime_actions/autonomous_policy.rs | 67 ++- .../src-tauri/src/agent/runtime_driver.rs | 1 + .../src/agent/runtime_driver/main_loop.rs | 40 +- .../agent/runtime_driver/pending_recovery.rs | 4 +- .../agent/runtime_driver/provider_recovery.rs | 86 ++++ .../src-tauri/src/agent/runtime_tools.rs | 2 + .../src/agent/runtime_tools/delegation.rs | 35 +- .../src/agent/runtime_tools/delivery.rs | 22 +- .../src/agent/runtime_tools/run_status.rs | 8 + .../src-tauri/src/agent_native_tools.rs | 139 ++++++- .../src-tauri/src/delegation.rs | 387 +++++++++++++++++- .../src/tests/collaboration/claims.rs | 2 + .../src/tests/collaboration/delegation.rs | 4 + .../tests/collaboration/static_deliveries.rs | 315 ++++++++++++++ .../src-tauri/src/tests/provider.rs | 5 +- .../src-tauri/src/user_input.rs | 32 ++ .../shared-memory/decision-log.md | 6 + docs/project-memory/shared-memory/pitfalls.md | 9 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 + 21 files changed, 1143 insertions(+), 33 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md index f2a7af165..3403f655f 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md @@ -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、answersSha256;Runtime 会自动派生稳定 continuation identity,不得把多个 delivery 的问题或答案混入同一 continuation。 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md index 204a3d97b..534b488d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md @@ -1,5 +1,5 @@ 互不重叠的临时并行检查通过 agent.spawn_isolated 分派;当同一目标同时需要边界清晰的专业委派和互不重叠的临时检查时,必须把两类协作放进同一个 native planning 批次一次性提交,不能拆成先后轮次。提交首个协作批次前,先分别完整枚举当前目标中已经生效的长期专业交付和临时隔离检查;两类都非空时,遗漏任一类的批次都不得提交。仓库合同明确把临时检查分为先行和后续独立阶段时,首批只提交当前已经生效的检查;先行组 ready 后优先创建刚生效的后续组,所有必要组创建前不得调用 agent.run_status 认领先行组,全部 ready 后用一次 agent.run_status 收齐。已有委派未收束时不要重复委派。 -需要等待专业 Agent 时不得调用 respond_to_user;Runtime 会通过 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_user;Runtime 会通过 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、answersSha256;Runtime 自动派生稳定 continuation identity,禁止跨 delivery 混用指纹。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。 只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 7727f2188..4e6d8528d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -702,16 +702,25 @@ 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. Previously the presence - // of the Supervisor role alone could bypass this check. + // 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 或有效协作动作,禁止继续只规划、读取、验证或空转" @@ -758,10 +767,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]; @@ -1961,4 +1967,53 @@ mod tests { .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)); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 9604b7f27..97a320ab4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 4d02b7aba..3f31ca775 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -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::().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 = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 805214caa..7e130e4a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 1c2f55a5e..3b6af0f9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -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 { + 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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index ed846969b..c8327c490 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -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; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 9b4f7256f..165830cec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 8f6a80e06..a4bbf91db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -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, + ¤t_task.agent_id, + ¤t_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) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs index d31c814da..b58d27a2b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/run_status.rs @@ -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(), }) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 0c9adc269..6a9e44e4e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -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"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 365b5ae72..d726b825b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -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, #[serde(default)] pub(crate) error: Option, + #[serde(default)] + pub(crate) user_input_questions: Vec, + #[serde(default)] + pub(crate) user_input_questions_sha256: Option, +} + +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, #[serde(default)] pub(crate) repair_of_delegation_id: Option, + #[serde(default)] + pub(crate) clarification_request_id: Option, + #[serde(default)] + pub(crate) clarification_answers_sha256: Option, pub(crate) status: StaticDelegateDeliveryStatus, pub(crate) terminal_status: Option, pub(crate) result_summary: Option, @@ -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, 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 { + 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>, Option), 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::(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!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/claims.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/claims.rs index 5995902b0..fa3e6a0bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/claims.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/claims.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index 7c670c11e..13ce2dca3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -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) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 86e58bedc..40679654c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -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::(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(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index d328906ab..e36d3c1ff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -7723,7 +7723,10 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { "acceptanceCriteria", "expectedArtifacts", "repairOfDelegationId", - "runId" + "runId", + "continuationOfDelegationId", + "questionsSha256", + "answersSha256" ]) ); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs index da9959786..9e94618a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs @@ -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)) } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 72ad69f78..4f1f34f8b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 与答案 SHA,Runtime 自动派生稳定 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')` 并白屏。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 43dceb324..94be7941e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4734,6 +4734,15 @@ - 队列与事务:独立恢复面板必须展示后端权威队列的所有 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` envelope;Runtime 生成 `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 的问题和答案指纹拍平混用。 +- 协议演进:`agent.delegate` 的澄清 continuation 字段虽然在 strict schema 中是 required nullable,但 Runtime 解析器仍必须接受完全未携带这三个字段的既有调用;只允许三者全缺失、全 `null` 或全为合法字符串,部分出现、部分字符串和非法 SHA 均失败关闭。新增 schema 字段时要同步原生函数目录断言与旧调用回归,避免协议修复轮次打乱 Supervisor 协作计划。 +- 门禁优先级:已经存在真实 `preview.validate` 失败 observation 或 durable `failed_playtest_revision` 时,具体试玩修复与新 revision 重新验证门禁必须先于通用“首次 mutation”门禁;否则 Runtime 会把明确的试玩修复错误收窄成普通 pre-mutation repair,导致 Supervisor 无法选择正确的协作动作。 + ## 无限画布延迟草稿与零位移不能制造新状态(2026-08-11) - 现象:r5 的 `loadDraft` 比 r6 更晚回包时会把草稿回退;pointerdown 后没有任何移动,pointerup 仍增加一条空 undo 并触发 CAS 保存;Tauri 自行维护 Shift toggle 后,Shift 单击唯一选中图层会意外清空选择。同一 `canvas.failed` 视图还可能把草稿保存或提交故障显示成生成重试。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 1c3113d76..baa72cddf 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -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 内嵌预览、云同步或插件市场。 From e04a8310a0816ef9f71a798e41491f26044a5098 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:58:16 +0800 Subject: [PATCH 04/12] =?UTF-8?q?=E9=87=8D=E5=81=9AAGC=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E8=AE=BE=E7=BD=AE=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将常用设置、Agent 模型、连接工具和高级参数分层展示 补充保存中、保存成功和失败状态反馈 更新设置页测试与项目决策记录 --- .../runtime-config/RuntimeConfigDialog.tsx | 2101 +++++++++-------- apps/ai-game-creator-shell/src/styles.css | 495 +++- .../appSurface/project-development.suite.ts | 4 +- .../appSurface/runtime-settings.suite.ts | 90 +- .../shared-memory/decision-log.md | 6 + 5 files changed, 1719 insertions(+), 977 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index c8c9fdd23..df6e2469e 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -1,5 +1,19 @@ -import { Plus, Trash2, Zap } from 'lucide-react'; -import { type FormEvent, Fragment, useEffect, useRef, useState } from 'react'; +import { + Bot, + Cable, + CheckCircle2, + CircleAlert, + LoaderCircle, + Plus, + RotateCcw, + Save, + Settings2, + SlidersHorizontal, + Trash2, + X, + Zap, +} from 'lucide-react'; +import { type FormEvent, useEffect, useRef, useState } from 'react'; import { createGameCreationAppSeedTasks } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { @@ -82,6 +96,40 @@ const runtimeAgentModes = new Set([ 'provider', ]); +type RuntimeSettingsSection = 'general' | 'agents' | 'connections' | 'advanced'; + +const runtimeSettingsSections = [ + { + id: 'general', + label: '常用设置', + description: '运行方式与默认模型', + icon: Settings2, + }, + { + id: 'agents', + label: 'Agent 模型', + description: '按角色覆盖默认模型', + icon: Bot, + }, + { + id: 'connections', + label: '连接与工具', + description: 'MCP 与外部服务', + icon: Cable, + }, + { + id: 'advanced', + label: '高级参数', + description: '上下文、超时与重试', + icon: SlidersHorizontal, + }, +] as const satisfies ReadonlyArray<{ + id: RuntimeSettingsSection; + label: string; + description: string; + icon: typeof Settings2; +}>; + const defaultRuntimeMcpServerConfig: GameCreatorMcpServerConfig = { enabled: true, required: false, @@ -534,6 +582,9 @@ export function RuntimeConfigDialog({ const [runtimeConfigDraft, setRuntimeConfigDraft] = useState(defaultRuntimeConfigDraft); const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false); + const [activeSection, setActiveSection] = + useState('general'); + const [expandedAgentIds, setExpandedAgentIds] = useState([]); const [newMcpServerId, setNewMcpServerId] = useState(''); const [mcpStructuredDrafts, setMcpStructuredDrafts] = useState< Record @@ -858,6 +909,24 @@ export function RuntimeConfigDialog({ } } + const selectedSection = + runtimeSettingsSections.find((section) => section.id === activeSection) ?? + runtimeSettingsSections[0]; + const configuredAgentCount = Object.values( + runtimeConfigDraft.agentLlm ?? {}, + ).filter((config) => + Object.values(config).some((value) => value !== undefined && value !== ''), + ).length; + const runtimeConfigStatusTone = runtimeConfigBusy + ? 'busy' + : /^(已保存|已读取|已恢复默认|已添加|已移除|MCP 已连接)/.test( + runtimeConfigStatus, + ) + ? 'success' + : runtimeConfigStatus === '未读取' + ? 'neutral' + : 'warning'; + return (
closeDialogOnBackdropMouseDown(event, onClose)} >
closeDialogOnEscape(event, onClose)} > -
-

运行时配置

-
+
+
+ AI GAME CREATOR +

Agent 设置

+
+ +
+
+ +
+
+
+

{selectedSection.label}

+

{selectedSection.description}

+
+ {activeSection === 'agents' ? ( + {configuredAgentCount} 个角色已覆盖 + ) : activeSection === 'connections' ? ( + + {Object.keys(runtimeConfigDraft.mcpServers).length} 个 MCP + + ) : null} +
+
+ {activeSection === 'general' ? ( + <> + + {runtimeConfigDraft.agentMode !== 'codex_cli' ? ( + <> + + + + + + + + + + ) : null} + + ) : null} + {activeSection === 'advanced' && + runtimeConfigDraft.agentMode !== 'codex_cli' ? ( + <> + + + + + + + + ) : null} + {activeSection === 'agents' && + runtimeConfigDraft.agentMode !== 'codex_cli' ? ( +
+ {runtimeAgentLlmRows.map((agent) => { + const agentLlm = + runtimeConfigDraft.agentLlm?.[agent.id] ?? {}; + const defaultReasoningEffort = + runtimeAgentReasoningEffortDefaults[ + agent.id as keyof typeof runtimeAgentReasoningEffortDefaults + ]; + return ( +
+ + {expandedAgentIds.includes(agent.id) ? ( +
+ + + + + + + + + + + +
+ ) : null} +
+ ); + })} +
+ ) : null} + {activeSection === 'connections' ? ( + <> + + + + ) : null} + {activeSection === 'connections' ? ( +
+
+
+

MCP servers

+ {`${Object.keys(runtimeConfigDraft.mcpServers).length} 个配置`} +
+ +
+
+ + +
+ {Object.entries(runtimeConfigDraft.mcpServers).length === + 0 ? ( +

尚未配置 MCP server

+ ) : ( +
+ {Object.entries(runtimeConfigDraft.mcpServers).map( + ([serverId, server]) => { + const structuredDraft = + mcpStructuredDrafts[serverId] ?? + runtimeMcpStructuredDraft(server); + const serverStatus = mcpCatalog?.servers.find( + (candidate) => candidate.serverId === serverId, + ); + return ( +
+
+
+ {serverId} + + {!server.enabled + ? '已停用' + : serverStatus + ? serverStatus.connected + ? `已连接 · ${serverStatus.toolCount} 个工具` + : '连接失败' + : server.transport === 'stdio' + ? 'STDIO · 未测试' + : 'HTTP · 未测试'} + +
+ +
+
+ 配置 +
+ + + + + {server.transport === 'stdio' ? ( + <> + + +