From 167b11d52e540e0988e2f101e233c17daea2793e Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Thu, 23 Jul 2026 01:06:54 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=87=AA=E4=B8=BB=20Swarm=20?= =?UTF-8?q?=E6=97=A0=E5=B9=B2=E9=A2=84=E5=8F=AF=E7=8E=A9=E4=BA=A4=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固定首批程序实现与质量只读职责,并在计划执行前阻断质量 Agent 项目写入 要求非只读专业 Agent 完成本人修改和对应 revision 验证后再交付 补齐 Provider 瞬态重试、最终回复和 Swarm CLI 终态收敛 升级 Provider action batch v3 并兼容 v2/v1 持久恢复 扩展确定性与真实外部 Provider 塔防验收、前端状态和项目文档 --- ...ent-runtime-deterministic-playable-e2e.mjs | 261 +++++- .../deterministic-lane-defense-provider.mjs | 169 +++- .../runtime_actions/autonomous_policy.rs | 476 ++++++++++- .../runtime_actions/provider_action_batch.rs | 95 +- .../runtime_actions/provider_batch_ledger.rs | 73 +- .../runtime_actions/provider_final_reply.rs | 44 +- .../runtime_actions/provider_tool_plan.rs | 121 ++- .../src-tauri/src/agent/runtime_driver.rs | 4 +- .../src/agent/runtime_driver/main_loop.rs | 15 +- .../agent/runtime_driver/main_loop_tests.rs | 53 +- .../src-tauri/src/agent/runtime_protocol.rs | 1 + .../agent/runtime_protocol/provider_retry.rs | 116 +++ .../src/agent/runtime_protocol/steering.rs | 41 + .../src-tauri/src/commands.rs | 4 +- .../src-tauri/src/swarm_cli/input.rs | 29 +- .../src/swarm_cli/terminal_classification.rs | 9 +- .../src-tauri/src/swarm_cli/tests.rs | 104 +++ .../src-tauri/src/swarm_cli/turn_wait.rs | 16 +- .../collaboration/supervisor_planning.rs | 497 ++++++++++- .../src-tauri/src/tests/mod.rs | 54 ++ .../src-tauri/src/tests/provider.rs | 224 ++++- .../src-tauri/src/tests/response_stream.rs | 193 ++++- .../planning_strategy/autonomous_build.rs | 809 +++++++++++++++--- .../src/tests/runtime_actions/support.rs | 13 +- .../src/features/agent-runtime/model.ts | 6 + .../tests/appSurface/harness.ts | 1 + .../appSurface/project-development.suite.ts | 1 + .../appSurface/supervisor-runtime.suite.ts | 59 +- .../shared-memory/decision-log.md | 7 + docs/project-memory/shared-memory/pitfalls.md | 9 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + 31 files changed, 3236 insertions(+), 272 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs index da1b7484c..89c08ae00 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs @@ -116,10 +116,57 @@ function parseChildReport(result) { } } +function responsibilityContractEvidence(stats) { + const qualityPlanningCount = + stats?.byAgent?.['quality-review']?.planning ?? null; + const evidence = { + delegationContractCount: stats?.delegationContractCount ?? null, + delegationContractViolationCount: + stats?.delegationContractViolationCount ?? null, + codePrototypeDelegationCount: stats?.codePrototypeDelegationCount ?? null, + codePrototypeExpectedArtifactCount: + stats?.codePrototypeExpectedArtifactCount ?? null, + codePrototypeGameIndexArtifactDelegationCount: + stats?.codePrototypeGameIndexArtifactDelegationCount ?? null, + codePrototypeProjectMutationCount: + stats?.byAgent?.['code-prototype']?.projectMutation ?? null, + qualityReviewDelegationCount: stats?.qualityReviewDelegationCount ?? null, + qualityReviewReadOnlyDelegationCount: + stats?.qualityReviewReadOnlyDelegationCount ?? null, + qualityReviewExpectedArtifactCount: + stats?.qualityReviewExpectedArtifactCount ?? null, + qualityReviewProjectMutationCount: + stats?.byAgent?.['quality-review']?.projectMutation ?? null, + qualityPlanningCount, + qualityRevisionReplanCount: stats?.qualityRevisionReplanCount ?? null, + }; + return { + ...evidence, + codeOwnsGameIndex: + evidence.codePrototypeDelegationCount === 2 && + evidence.codePrototypeExpectedArtifactCount === 2 && + evidence.codePrototypeGameIndexArtifactDelegationCount === 2, + qualityIsReadOnly: + evidence.qualityReviewDelegationCount === 1 && + evidence.qualityReviewReadOnlyDelegationCount === 1 && + evidence.qualityReviewExpectedArtifactCount === 0 && + evidence.qualityReviewProjectMutationCount === 0, + qualityIndependentOfCodeTiming: + (qualityPlanningCount === 1 || qualityPlanningCount === 2) && + evidence.qualityRevisionReplanCount === qualityPlanningCount - 1, + contractViolationFree: + evidence.delegationContractCount === 3 && + evidence.delegationContractViolationCount === 0, + }; +} + function expectedProviderStats(stats) { + const responsibility = responsibilityContractEvidence(stats); + const qualityPlanningCount = responsibility.qualityPlanningCount; return ( - stats.requestCount === 17 && - stats.planningRequestCount === 17 && + (qualityPlanningCount === 1 || qualityPlanningCount === 2) && + stats.requestCount === 15 + qualityPlanningCount && + stats.planningRequestCount === 15 + qualityPlanningCount && stats.finalReplyRequestCount === 0 && stats.initialDelegationCount === 2 && stats.followupDelegationCount === 1 && @@ -133,10 +180,73 @@ function expectedProviderStats(stats) { Object.keys(stats.rejectionCodes ?? {}).length === 0 && stats.byAgent?.['project-supervisor']?.planning === 9 && stats.byAgent?.['project-supervisor']?.finalReply === 0 && + stats.byAgent?.['project-supervisor']?.projectMutation === 1 && stats.byAgent?.['code-prototype']?.planning === 6 && stats.byAgent?.['code-prototype']?.finalReply === 0 && - stats.byAgent?.['quality-review']?.planning === 2 && - stats.byAgent?.['quality-review']?.finalReply === 0 + stats.byAgent?.['code-prototype']?.projectMutation === 2 && + stats.byAgent?.['quality-review']?.finalReply === 0 && + responsibility.codeOwnsGameIndex && + responsibility.qualityIsReadOnly && + responsibility.qualityIndependentOfCodeTiming && + responsibility.contractViolationFree + ); +} + +const requiredZeroChildEvidenceFields = Object.freeze([ + 'activeRunnerKillCount', + 'approveInputCount', + 'answerInputCount', + 'steerInputCount', + 'turnReportWaitingForConfirmationCount', + 'turnReportWaitingForUserInputCount', + 'turnReportReconciliationAgentCount', + 'providerLifecycleFailedCount', + 'openProviderLifecycleCount', + 'pendingActionCount', + 'confirmationSidecarCount', + 'userInputSidecarCount', + 'providerActionBatchSidecarCount', + 'providerRetrySidecarCount', + 'providerHandoffSidecarCount', + 'toolPlanHandoffSidecarCount', + 'finalizationJournalCount', + 'reconciliationResidueCount', +]); + +function expectedChildReport(report, options, providerStats) { + const evidence = report?.evidence; + const providerRequestCount = providerStats?.requestCount; + return ( + report?.status === 'PASS' && + report?.suite === suite && + report?.errorCount === 0 && + Array.isArray(report?.blocked) && + report.blocked.length === 0 && + report?.cleanup?.performed === !options.keepProject && + report?.cleanup?.kept === options.keepProject && + evidence?.evidenceCompleteness === 'complete' && + evidence?.dedicatedZeroInterventionPath === true && + evidence?.stdinTaskCount === 1 && + evidence?.stdinEndedAfterTask === true && + evidence?.turnReportOutcome === 'settled' && + evidence?.parentTaskStatus === 'completed' && + evidence?.parentRuntimeStatus === 'idle' && + evidence?.parentRuntimePhase === 'completed' && + evidence?.laneDefensePlaytestPassed === true && + evidence?.laneDefenseAssertionCount === 37 && + evidence?.laneDefensePassedAssertionCount === 37 && + evidence?.browserValidationPassed === true && + evidence?.staticSmokePassed === true && + evidence?.gameIndexChanged === true && + Number.isInteger(evidence?.projectRevisionDelta) && + evidence.projectRevisionDelta > 0 && + evidence?.finalSupervisorAssistantCount === 1 && + evidence?.professionalAssistantCount === 3 && + evidence?.providerRequestIdentityCount === providerRequestCount && + evidence?.providerLifecycleStartedCount === providerRequestCount && + evidence?.providerLifecycleTerminalCount === providerRequestCount && + evidence?.providerLifecycleCompletedCount === providerRequestCount && + requiredZeroChildEvidenceFields.every((field) => evidence?.[field] === 0) ); } @@ -160,6 +270,13 @@ function responseFunctionNames(response) { ); } +function responseFunctionCalls(response) { + return response.choices[0].message.tool_calls.map((call) => ({ + name: call.function.name, + arguments: JSON.parse(call.function.arguments), + })); +} + async function runSelfTest() { const html = deterministicLaneDefenseInitialHtml(); assert([...html].length <= 8_000, 'self-test-html-source-budget-invalid'); @@ -189,17 +306,41 @@ async function runSelfTest() { authorization: `Bearer ${apiKey}`, payload: syntheticPayload(agentId, runId, tools, extraContext), }); + const initialDelegationResponse = route('project-supervisor', 'parent-run'); + const initialDelegationCalls = responseFunctionCalls( + initialDelegationResponse, + ); assert( - responseFunctionNames(route('project-supervisor', 'parent-run')).join( - ',', - ) === 'runtime_tool_agent_delegate,runtime_tool_agent_delegate', + initialDelegationCalls.map((call) => call.name).join(',') === + 'runtime_tool_agent_delegate,runtime_tool_agent_delegate', 'self-test-initial-delegation-invalid', ); + const initialCodeDelegation = initialDelegationCalls[0]?.arguments?.input; + const initialQualityDelegation = initialDelegationCalls[1]?.arguments?.input; + assert( + initialCodeDelegation?.agentId === 'code-prototype' && + JSON.stringify(initialCodeDelegation.expectedArtifacts) === + JSON.stringify(['game/index.html']) && + initialQualityDelegation?.agentId === 'quality-review' && + initialQualityDelegation.task.includes('只读验收') && + initialQualityDelegation.task.includes('不要修改任何文件') && + initialQualityDelegation.task.includes( + '不要读取或依赖并行 code-prototype', + ) && + Array.isArray(initialQualityDelegation.expectedArtifacts) && + initialQualityDelegation.expectedArtifacts.length === 0, + 'self-test-initial-delegation-contract-invalid', + ); const qualityPlan = '计划进度:\n- #1 [in_progress] 核对完整玩法\n- #2 [pending] 回传结论\n工具策略:auto=无'; const builderPlan = '计划进度:\n- #1 [completed] 生成入口\n- #2 [completed] 静态验证\n- #3 [in_progress] 回传结论\n工具策略:auto=无'; - route('quality-review', 'quality-run', allTools, qualityPlan); + assert( + responseFunctionNames( + route('quality-review', 'quality-run', allTools, qualityPlan), + ).join(',') === 'update_agent_plan,respond_to_user', + 'self-test-quality-read-only-invalid', + ); route('code-prototype', 'initial-code-run'); route('code-prototype', 'initial-code-run'); assert( @@ -222,15 +363,21 @@ async function runSelfTest() { 'runtime_tool_file_patch', 'self-test-forbidden-parent-mutation-invalid', ); + const followupDelegationResponse = route( + 'project-supervisor', + 'parent-run', + ['runtime_tool_agent_delegate'], + '当前父 run 已进入只编排模式,本次修复的原生工具目录只保留 agent.delegate', + ); + const followupDelegationCall = responseFunctionCalls( + followupDelegationResponse, + )[0]; assert( - responseFunctionNames( - route( - 'project-supervisor', - 'parent-run', - ['runtime_tool_agent_delegate'], - '当前父 run 已进入只编排模式,本次修复的原生工具目录只保留 agent.delegate', - ), - )[0] === 'runtime_tool_agent_delegate', + followupDelegationCall?.name === 'runtime_tool_agent_delegate' && + followupDelegationCall.arguments?.input?.agentId === 'code-prototype' && + JSON.stringify( + followupDelegationCall.arguments.input.expectedArtifacts, + ) === JSON.stringify(['game/index.html']), 'self-test-followup-delegation-invalid', ); route('code-prototype', 'repair-code-run'); @@ -250,6 +397,78 @@ async function runSelfTest() { route('project-supervisor', 'parent-run'); const stats = router.getStats(); assert(expectedProviderStats(stats), 'self-test-provider-stats-invalid'); + const responsibilityContract = responsibilityContractEvidence(stats); + assert( + responsibilityContract.codeOwnsGameIndex && + responsibilityContract.qualityIsReadOnly && + responsibilityContract.qualityIndependentOfCodeTiming && + responsibilityContract.contractViolationFree, + 'self-test-responsibility-contract-invalid', + ); + const singlePassQualityStats = structuredClone(stats); + singlePassQualityStats.requestCount -= 1; + singlePassQualityStats.planningRequestCount -= 1; + singlePassQualityStats.qualityRevisionReplanCount = 0; + singlePassQualityStats.byAgent['quality-review'].planning = 1; + assert( + expectedProviderStats(singlePassQualityStats), + 'self-test-quality-single-pass-timing-invalid', + ); + + const syntheticChildReport = { + status: 'PASS', + suite, + errorCount: 0, + blocked: [], + cleanup: { performed: true, kept: false }, + evidence: { + ...Object.fromEntries( + requiredZeroChildEvidenceFields.map((field) => [field, 0]), + ), + evidenceCompleteness: 'complete', + dedicatedZeroInterventionPath: true, + stdinTaskCount: 1, + stdinEndedAfterTask: true, + turnReportOutcome: 'settled', + parentTaskStatus: 'completed', + parentRuntimeStatus: 'idle', + parentRuntimePhase: 'completed', + laneDefensePlaytestPassed: true, + laneDefenseAssertionCount: 37, + laneDefensePassedAssertionCount: 37, + browserValidationPassed: true, + staticSmokePassed: true, + gameIndexChanged: true, + projectRevisionDelta: 2, + finalSupervisorAssistantCount: 1, + professionalAssistantCount: 3, + providerRequestIdentityCount: stats.requestCount, + providerLifecycleStartedCount: stats.requestCount, + providerLifecycleTerminalCount: stats.requestCount, + providerLifecycleCompletedCount: stats.requestCount, + }, + }; + const incompletePlaytestReport = structuredClone(syntheticChildReport); + incompletePlaytestReport.evidence.laneDefensePassedAssertionCount = 36; + const manualInputReport = structuredClone(syntheticChildReport); + manualInputReport.evidence.approveInputCount = 1; + const residualSidecarReport = structuredClone(syntheticChildReport); + residualSidecarReport.evidence.providerHandoffSidecarCount = 1; + assert( + expectedChildReport(syntheticChildReport, { keepProject: false }, stats) && + !expectedChildReport( + incompletePlaytestReport, + { keepProject: false }, + stats, + ) && + !expectedChildReport(manualInputReport, { keepProject: false }, stats) && + !expectedChildReport( + residualSidecarReport, + { keepProject: false }, + stats, + ), + 'self-test-child-hard-gates-invalid', + ); const rootPackage = JSON.parse( await fs.readFile(path.join(repoRoot, 'package.json'), 'utf8'), @@ -273,6 +492,9 @@ async function runSelfTest() { providerUsed: false, htmlChars: [...html].length, providerStats: stats, + responsibilityContract, + qualityTimingOrdersValidated: ['before-code', 'after-code'], + childHardGatesValidated: true, packageCommandsRegistered: true, }; } @@ -359,12 +581,10 @@ async function runE2e(options) { childResult?.code === 0 && childResult?.signal === null && !childResult?.error && - childReport?.status === 'PASS' && - childReport?.suite === suite && - childReport?.cleanup?.performed === !options.keepProject && - childReport?.cleanup?.kept === options.keepProject; + expectedChildReport(childReport, options, providerStats); const providerPassed = providerStats?.stopped === true && expectedProviderStats(providerStats); + const responsibilityContract = responsibilityContractEvidence(providerStats); const status = !failureCode && childPassed && providerPassed && configRemoved ? 'PASS' @@ -383,6 +603,7 @@ async function runE2e(options) { delegatedSuite: suite, child: childReport, provider: providerStats, + responsibilityContract, cleanup: { providerStopped: providerStats?.stopped === true, configRemoved, diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index 06f911f18..907a1e964 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -187,17 +187,51 @@ function chatTextResponse(sequence, model, content) { }; } -function delegateCall(agentId, task, acceptanceCriteria) { +function delegateCall(agentId, task, acceptanceCriteria, expectedArtifacts) { return nativeAction('agent.delegate', `把独立工作交给 ${agentId}`, { agentId, task, acceptanceCriteria, - expectedArtifacts: ['game/index.html'], + expectedArtifacts, repairOfDelegationId: null, runId: null, }); } +const projectMutationFunctionNames = new Set( + [ + 'blackboard.write', + 'canvas.asset_generate', + 'command.exec', + 'command.start', + 'file.delete', + 'file.patch', + 'file.write', + 'memory.write', + 'project.git_commit', + 'project.patchset', + 'project.restore', + 'task.create', + 'task.update', + ].map(runtimeFunction), +); + +function isGameIndexArtifactContract(expectedArtifacts) { + return ( + Array.isArray(expectedArtifacts) && + expectedArtifacts.length === 1 && + expectedArtifacts[0] === 'game/index.html' + ); +} + +function isExplicitReadOnlyQualityTask(task) { + return ( + typeof task === 'string' && + task.includes('只读验收') && + task.includes('不要修改任何文件') + ); +} + function staticSmokeCall(reason) { return nativeAction('command.run_limited', reason, { commandId: 'game.static_smoke', @@ -262,6 +296,15 @@ export function createDeterministicLaneDefenseRouter({ staticSmokeCount: 0, previewValidationCount: 0, supervisorDirectMutationAttemptCount: 0, + delegationContractCount: 0, + delegationContractViolationCount: 0, + codePrototypeDelegationCount: 0, + codePrototypeExpectedArtifactCount: 0, + codePrototypeGameIndexArtifactDelegationCount: 0, + qualityReviewDelegationCount: 0, + qualityReviewReadOnlyDelegationCount: 0, + qualityReviewExpectedArtifactCount: 0, + qualityRevisionReplanCount: 0, unexpectedRequestCount: 0, rejectionCodes: {}, rejections: [], @@ -272,10 +315,7 @@ export function createDeterministicLaneDefenseRouter({ let initialBuilderRunId = null; let repairBuilderRunId = null; - function recordAgent(agentId, kind) { - const current = agentCounts.get(agentId) ?? { planning: 0, finalReply: 0 }; - current[kind] += 1; - agentCounts.set(agentId, current); + function publishAgentCounts() { stats.byAgent = Object.fromEntries( [...agentCounts.entries()].sort(([left], [right]) => left.localeCompare(right), @@ -283,8 +323,70 @@ export function createDeterministicLaneDefenseRouter({ ); } - function callsResponse(tools, calls) { + function getAgentCounts(agentId) { + return ( + agentCounts.get(agentId) ?? { + planning: 0, + finalReply: 0, + projectMutation: 0, + } + ); + } + + function recordAgent(agentId, kind) { + const current = getAgentCounts(agentId); + current[kind] += 1; + agentCounts.set(agentId, current); + publishAgentCounts(); + } + + function recordEmittedCalls(agentId, calls) { + const current = getAgentCounts(agentId); + current.projectMutation += calls.filter((call) => + projectMutationFunctionNames.has(call.name), + ).length; + agentCounts.set(agentId, current); + publishAgentCounts(); + + for (const call of calls) { + if (call.name !== runtimeFunction('agent.delegate')) continue; + stats.delegationContractCount += 1; + const input = call.arguments?.input; + const hasExplicitExpectedArtifacts = Array.isArray( + input?.expectedArtifacts, + ); + const expectedArtifacts = hasExplicitExpectedArtifacts + ? input.expectedArtifacts + : []; + if (input?.agentId === 'code-prototype') { + stats.codePrototypeDelegationCount += 1; + stats.codePrototypeExpectedArtifactCount += expectedArtifacts.length; + if (isGameIndexArtifactContract(expectedArtifacts)) { + stats.codePrototypeGameIndexArtifactDelegationCount += 1; + } else { + stats.delegationContractViolationCount += 1; + } + } else if (input?.agentId === 'quality-review') { + stats.qualityReviewDelegationCount += 1; + stats.qualityReviewExpectedArtifactCount += expectedArtifacts.length; + if ( + hasExplicitExpectedArtifacts && + expectedArtifacts.length === 0 && + isExplicitReadOnlyQualityTask(input.task) + ) { + stats.qualityReviewReadOnlyDelegationCount += 1; + } else { + stats.delegationContractViolationCount += 1; + } + } else { + stats.delegationContractViolationCount += 1; + } + } + } + + function callsResponse(agentId, tools, calls) { requireAdvertised(tools, calls); + recordEmittedCalls(agentId, calls); responseSequence += 1; return chatToolResponse(responseSequence, model, calls); } @@ -302,7 +404,7 @@ export function createDeterministicLaneDefenseRouter({ } parentStage = 'verify-repair'; stats.followupDelegationCount += 1; - return callsResponse(tools, [ + return callsResponse('project-supervisor', tools, [ delegateCall( 'code-prototype', '修复最近一次真实浏览器验证证明的 canvas 不可见问题;保持现有 lane-defense-v1 交互、状态面、关卡推进和重开行为不变,并在修改后通过静态自检。', @@ -311,6 +413,7 @@ export function createDeterministicLaneDefenseRouter({ 'lane-defense-v1 全部固定试玩断言继续通过', '修改后的当前 revision 通过 game.static_smoke', ], + ['game/index.html'], ), ]); } @@ -319,7 +422,7 @@ export function createDeterministicLaneDefenseRouter({ case 'initial-delegation': { parentStage = 'claim-initial'; stats.initialDelegationCount += 2; - return callsResponse(tools, [ + return callsResponse('project-supervisor', tools, [ delegateCall( 'code-prototype', '生成一个紧凑但完整的植物塔防网页游戏:支持开始、选择植物、放置防御单位、敌人移动和受伤、胜利、下一关与重开;写入 game/index.html,并在修改后通过静态自检。', @@ -328,51 +431,60 @@ export function createDeterministicLaneDefenseRouter({ '敌人会移动并受伤,关卡可胜利、进入下一关并重开', '修改后的当前 revision 通过 game.static_smoke', ], + ['game/index.html'], ), delegateCall( 'quality-review', - '独立核对塔防交付合同,确认必须覆盖植物选择、战斗、胜利、下一关、重开、桌面和移动视口;只返回验收重点,不修改项目。', - ['验收结论覆盖完整可玩循环和双视口', '不修改项目文件'], + '执行只读验收:独立核对塔防交付合同是否覆盖植物选择、战斗、胜利、下一关、重开、桌面和移动视口;只返回合同验收重点,不要修改任何文件,也不要读取或依赖并行 code-prototype 尚未完成的项目产物。', + [ + '验收结论覆盖完整可玩循环和双视口', + '只读返回合同重点且项目 mutation 为零', + ], + [], ), ]); } case 'claim-initial': parentStage = 'static-initial'; stats.runStatusCount += 1; - return callsResponse(tools, [runStatusCall('认领两份首轮专业回执')]); + return callsResponse('project-supervisor', tools, [ + runStatusCall('认领两份首轮专业回执'), + ]); case 'static-initial': parentStage = 'preview-initial'; stats.staticSmokeCount += 1; - return callsResponse(tools, [staticSmokeCall('验证初版当前 revision')]); + return callsResponse('project-supervisor', tools, [ + staticSmokeCall('验证初版当前 revision'), + ]); case 'preview-initial': parentStage = 'attempt-forbidden-repair'; stats.previewValidationCount += 1; - return callsResponse(tools, [ + return callsResponse('project-supervisor', tools, [ previewCall('真实试玩并检查桌面与移动视口'), ]); case 'attempt-forbidden-repair': parentStage = 'await-delegation-repair'; stats.supervisorDirectMutationAttemptCount += 1; - return callsResponse(tools, [ + return callsResponse('project-supervisor', tools, [ repairPatchCall('根据浏览器失败诊断直接修复不可见 canvas'), ]); case 'verify-repair': parentStage = 'verify-repair-after-claim'; stats.runStatusCount += 1; - return callsResponse(tools, [ + return callsResponse('project-supervisor', tools, [ runStatusCall('先认领返工后的专业交付,再验证最新 revision'), ]); case 'verify-repair-after-claim': parentStage = 'respond'; stats.staticSmokeCount += 1; stats.previewValidationCount += 1; - return callsResponse(tools, [ + return callsResponse('project-supervisor', tools, [ staticSmokeCall('验证返工后的当前 revision'), previewCall('重跑完整真实试玩和双视口检查'), ]); case 'respond': parentStage = 'done'; - return callsResponse(tools, [ + return callsResponse('project-supervisor', tools, [ ...completedPlanCallsForContext( context, '可试玩项目和全部验证已经完成', @@ -405,7 +517,7 @@ export function createDeterministicLaneDefenseRouter({ if (phase === 0) { runPhases.set(runId, 1); stats.sourceWriteCount += 1; - return callsResponse(tools, [ + return callsResponse('code-prototype', tools, [ nativeAction('file.write', '写入完整可玩的塔防初版', { path: 'game/index.html', content: deterministicLaneDefenseInitialHtml(), @@ -415,11 +527,13 @@ export function createDeterministicLaneDefenseRouter({ if (phase === 1) { runPhases.set(runId, 2); stats.staticSmokeCount += 1; - return callsResponse(tools, [staticSmokeCall('验证初版源码与状态面')]); + return callsResponse('code-prototype', tools, [ + staticSmokeCall('验证初版源码与状态面'), + ]); } if (phase === 2) { runPhases.set(runId, 3); - return callsResponse(tools, [ + return callsResponse('code-prototype', tools, [ ...completedPlanCallsForContext( context, '初版实现和静态验证已经完成', @@ -432,18 +546,20 @@ export function createDeterministicLaneDefenseRouter({ if (phase === 0) { runPhases.set(runId, 1); stats.sourcePatchCount += 1; - return callsResponse(tools, [ + return callsResponse('code-prototype', tools, [ repairPatchCall('修复真实浏览器发现的 canvas 可见性'), ]); } if (phase === 1) { runPhases.set(runId, 2); stats.staticSmokeCount += 1; - return callsResponse(tools, [staticSmokeCall('验证返工后的源码')]); + return callsResponse('code-prototype', tools, [ + staticSmokeCall('验证返工后的源码'), + ]); } if (phase === 2) { runPhases.set(runId, 3); - return callsResponse(tools, [ + return callsResponse('code-prototype', tools, [ ...completedPlanCallsForContext( context, '画布可见性返工和静态验证已经完成', @@ -462,7 +578,7 @@ export function createDeterministicLaneDefenseRouter({ if (phase === 0) { runKinds.set(runId, 'quality-review'); runPhases.set(runId, 1); - return callsResponse(tools, [ + return callsResponse('quality-review', tools, [ ...completedPlanCallsForContext(context, '独立验收重点已经核对'), nativeResponse( '验收必须以真实植物选择、放置、战斗推进、胜利、下一关、重开和双视口可见性为准。', @@ -471,7 +587,8 @@ export function createDeterministicLaneDefenseRouter({ } if (phase === 1) { runPhases.set(runId, 2); - return callsResponse(tools, [ + stats.qualityRevisionReplanCount += 1; + return callsResponse('quality-review', tools, [ ...completedPlanCallsForContext( context, '项目 revision 更新后已重新核对独立验收重点', 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 0b902c6dc..508827928 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 @@ -165,6 +165,182 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENE &str = "自主构建 Project Supervisor 必须先收束已有专业 Agent 委派"; pub(super) const AGENT_RUNTIME_AUTONOMOUS_PREVIEW_AFTER_STATIC_LIVENESS_ERROR_PREFIX: &str = "自主构建 Project Supervisor 必须试玩当前静态验证 revision"; +pub(super) const AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_MUTATION_ONLY_REPAIR_ERROR_PREFIX: &str = + "自主构建非只读专业 Agent 必须先完成本人 run 的项目修改"; +pub(super) const AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX: &str = + "自主构建非只读专业 Agent 必须先验证本人 run 的项目修改"; +pub(super) const AGENT_RUNTIME_AUTONOMOUS_READ_ONLY_MUTATION_ERROR_PREFIX: &str = + "自主构建只读专业 Agent 禁止执行写入或副作用动作"; + +fn autonomous_initial_collaboration_contract_error(detail: impl AsRef) -> String { + format!( + "{AGENT_RUNTIME_SUPERVISOR_INITIAL_COLLABORATION_LIVENESS_ERROR_PREFIX};autonomous-game-build 首批协作合同无效:{}", + detail.as_ref() + ) +} + +fn autonomous_initial_delegate_input<'a>( + action: &'a AgentRuntimeToolAction, +) -> Result>, String> { + if action.tool.trim() != "agent.delegate" { + return Ok(None); + } + action.input.as_object().map(Some).ok_or_else(|| { + autonomous_initial_collaboration_contract_error("agent.delegate input 必须是 object") + }) +} + +fn autonomous_initial_delegate_is_repair( + input: &serde_json::Map, +) -> bool { + input + .get("repairOfDelegationId") + .or_else(|| input.get("repair_of_delegation_id")) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) +} + +fn autonomous_initial_delegate_task( + input: &serde_json::Map, + target_agent_id: &str, +) -> Result { + input + .get("task") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + autonomous_initial_collaboration_contract_error(format!( + "首批 {target_agent_id} 委派缺少非空 task" + )) + }) +} + +fn autonomous_initial_delegate_expected_artifacts( + input: &serde_json::Map, + target_agent_id: &str, +) -> Result, String> { + let value = input + .get("expectedArtifacts") + .or_else(|| input.get("expected_artifacts")) + .or_else(|| input.get("artifacts")) + .ok_or_else(|| { + autonomous_initial_collaboration_contract_error(format!( + "首批 {target_agent_id} 委派必须显式提供 expectedArtifacts" + )) + })?; + let values = value.as_array().ok_or_else(|| { + autonomous_initial_collaboration_contract_error(format!( + "首批 {target_agent_id} 委派的 expectedArtifacts 必须是数组" + )) + })?; + values + .iter() + .map(|value| { + value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + autonomous_initial_collaboration_contract_error(format!( + "首批 {target_agent_id} 委派的 expectedArtifacts 只能包含非空路径" + )) + }) + }) + .collect() +} + +pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_contract( + plan: &AgentRuntimeToolPlan, +) -> Result<(), String> { + let mut code_prototype = None; + let mut quality_review = None; + for action in &plan.actions { + let Some(input) = autonomous_initial_delegate_input(action)? else { + continue; + }; + if autonomous_initial_delegate_is_repair(input) { + continue; + } + let Some(target_agent_id) = input + .get("agentId") + .or_else(|| input.get("agent_id")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + else { + return Err(autonomous_initial_collaboration_contract_error( + "agent.delegate 缺少 agentId", + )); + }; + let slot = match target_agent_id { + "code-prototype" => &mut code_prototype, + AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID => &mut quality_review, + _ => continue, + }; + if slot.replace(input).is_some() { + return Err(autonomous_initial_collaboration_contract_error(format!( + "首批 {target_agent_id} 委派只能出现一次" + ))); + } + } + + let code_prototype = code_prototype.ok_or_else(|| { + autonomous_initial_collaboration_contract_error("首批缺少 code-prototype 委派") + })?; + let code_task = autonomous_initial_delegate_task(code_prototype, "code-prototype")?; + let code_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(code_prototype.clone()), + &["acceptanceCriteria", "acceptance_criteria", "criteria"], + ); + if std::iter::once(code_task.as_str()) + .chain(code_criteria.iter().map(String::as_str)) + .any(agent_runtime_task_explicitly_requires_read_only_delivery) + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 code-prototype 必须是非只读实现任务", + )); + } + let code_artifacts = + autonomous_initial_delegate_expected_artifacts(code_prototype, "code-prototype")?; + if !code_artifacts + .iter() + .any(|path| path == AGENT_RUNTIME_GAME_INDEX_PATH) + { + return Err(autonomous_initial_collaboration_contract_error(format!( + "首批 code-prototype 的 expectedArtifacts 必须包含 {AGENT_RUNTIME_GAME_INDEX_PATH}" + ))); + } + + let quality_review = quality_review.ok_or_else(|| { + autonomous_initial_collaboration_contract_error("首批缺少 quality-review 委派") + })?; + let quality_task = + autonomous_initial_delegate_task(quality_review, AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID)?; + let quality_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(quality_review.clone()), + &["acceptanceCriteria", "acceptance_criteria", "criteria"], + ); + if !std::iter::once(quality_task.as_str()) + .chain(quality_criteria.iter().map(String::as_str)) + .any(agent_runtime_task_explicitly_requires_read_only_delivery) + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 quality-review task 或 acceptanceCriteria 必须显式声明只读且不得修改项目", + )); + } + let quality_artifacts = autonomous_initial_delegate_expected_artifacts( + quality_review, + AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID, + )?; + if !quality_artifacts.is_empty() { + return Err(autonomous_initial_collaboration_contract_error( + "首批 quality-review 的 expectedArtifacts 必须为 []", + )); + } + Ok(()) +} fn is_agent_runtime_autonomous_supervisor_delivery_convergence_plan( plan: &AgentRuntimeToolPlan, @@ -182,6 +358,20 @@ fn is_agent_runtime_autonomous_preview_validation_plan(plan: &AgentRuntimeToolPl matches!(plan.actions.as_slice(), [action] if action.tool.trim() == "preview.validate") } +fn agent_runtime_autonomous_supervisor_plan_prepares_repair(plan: &AgentRuntimeToolPlan) -> bool { + plan.actions.iter().any(|action| { + if action.tool.trim() != "agent.delegate" { + return false; + } + action + .input + .get("repairOfDelegationId") + .or_else(|| action.input.get("repair_of_delegation_id")) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + }) +} + pub(crate) fn refresh_agent_runtime_autonomous_convergence_snapshot_after_provider_at( root: &Path, agent_id: &str, @@ -221,6 +411,24 @@ pub(super) fn validate_agent_runtime_autonomous_plan_liveness_at( plan: &AgentRuntimeToolPlan, supervisor_requires_delegated_repair: bool, ) -> Result<(), String> { + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && agent_runtime_autonomous_supervisor_plan_prepares_repair(plan) + { + let barrier = static_delegate_completion_barrier_at(root, agent_id, run_id)?; + if barrier.ready_unclaimed_count > 0 || barrier.unobserved_claim_count > 0 { + let active_delegations = + active_static_delegate_delivery_count_at(root, agent_id, run_id)?; + if is_agent_runtime_autonomous_supervisor_delivery_convergence_plan(plan) { + return Ok(()); + } + return Err(format!( + "{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 在准备专业 repair 前仍有 activeDelegations={active_delegations}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}、repairRequired={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)原子认领并观察已有回执,再基于权威合同准备 repair", + barrier.ready_unclaimed_count, + barrier.unobserved_claim_count, + barrier.repair_required_count, + )); + } + } if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && supervisor_requires_delegated_repair && verification_gate @@ -229,14 +437,18 @@ pub(super) fn validate_agent_runtime_autonomous_plan_liveness_at( { let barrier = static_delegate_completion_barrier_at(root, agent_id, run_id)?; let active_delegations = active_static_delegate_delivery_count_at(root, agent_id, run_id)?; - let must_claim_or_wait = barrier.ready_unclaimed_count > 0 || active_delegations >= 3; + let must_claim_or_wait = barrier.ready_unclaimed_count > 0 + || barrier.unobserved_claim_count > 0 + || active_delegations >= 3; if must_claim_or_wait { if is_agent_runtime_autonomous_supervisor_delivery_convergence_plan(plan) { return Ok(()); } return Err(format!( - "{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 的 activeDelegations={active_delegations}、waitingDelegations={}、readyUnclaimedReceipts={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)认领 ready delivery 或等待已有委派推进;不得创建第四次 agent.delegate。认领后若项目 revision 已推进,再验证当前 revision", - barrier.waiting_count, barrier.ready_unclaimed_count, + "{AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX};当前父 run 的 activeDelegations={active_delegations}、waitingDelegations={}、readyUnclaimedReceipts={}、unobservedReceiptClaims={}。必须先只调用 agent.run_status(agentId=null、scope=all、delegationId=null)认领并观察 ready delivery,或等待已有委派推进;不得创建第四次 agent.delegate。收束后若项目 revision 已推进,再验证当前 revision", + barrier.waiting_count, + barrier.ready_unclaimed_count, + barrier.unobserved_claim_count, )); } } @@ -440,26 +652,133 @@ pub(in crate::agent) fn agent_runtime_autonomous_verified_delivery_allows_plan_c == Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED) } +pub(in crate::agent) fn validate_agent_runtime_autonomous_read_only_delivery_plan( + read_only_delivery: bool, + plan: &AgentRuntimeToolPlan, +) -> Result<(), String> { + if !read_only_delivery { + return Ok(()); + } + let forbidden = plan.actions.iter().find(|action| { + !matches!( + action.tool.trim(), + "memory.read" + | "conversation.read" + | "asset.list" + | "project.index" + | "project.search" + | "project.diff" + | "git.inspect" + | "file.list" + | "file.read" + | "task.list" + | "agent.action_history" + | "agent.run_status" + | "command.output_read" + | "command.poll" + | "image.inspect" + ) + }); + let Some(forbidden) = forbidden else { + return Ok(()); + }; + Err(format!( + "{AGENT_RUNTIME_AUTONOMOUS_READ_ONLY_MUTATION_ERROR_PREFIX};当前合同只允许读取项目事实并返回审查结论,拒绝动作 {}。不得写文件、推进 revision、启动命令、修改任务/记忆/黑板或委派其它 Agent", + forbidden.tool.trim() + )) +} + +pub(in crate::agent) fn validate_agent_runtime_autonomous_specialist_response_delivery( + agent_id: &str, + run_id: &str, + read_only_delivery: bool, + verification_gate: &AgentRuntimeVerificationGate, + plan: &AgentRuntimeToolPlan, +) -> Result<(), String> { + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || read_only_delivery + || plan.response.trim().is_empty() + { + return Ok(()); + } + if verification_gate.agent_id != agent_id || verification_gate.run_id != run_id { + return Err("自主构建专业 Agent verification gate 与当前 run 身份不匹配".to_string()); + } + if verification_gate.mutation_revision.is_none() { + return Err(format!( + "{AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_MUTATION_ONLY_REPAIR_ERROR_PREFIX};当前 respond_to_user 没有本人 run 的 mutationRevision。必须先执行实际项目修改,不能以其它 Agent 的 revision、只读检查、空验证或任务文案代替" + )); + } + if !agent_runtime_autonomous_verified_delivery_allows_plan_completion( + agent_id, + verification_gate, + ) { + return Err(format!( + "{AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX};当前 mutationRevision={:?}、verifiedRevision={:?}、verificationStatus={}。必须先只验证本人 run 的最新 mutation revision,通过后才能 respond_to_user", + verification_gate.mutation_revision, + verification_gate.verified_revision, + verification_gate + .last_verification_status + .as_deref() + .unwrap_or("none"), + )); + } + Ok(()) +} + +fn agent_runtime_task_explicitly_requires_read_only_delivery(task: &str) -> bool { + let mut normalized = task.to_ascii_lowercase(); + for negated in [ + "非只读", + "不要只读", + "不得只读", + "不能只读", + "不是只读", + "并非只读", + "non-read-only", + "non read-only", + "not a read-only", + "not read-only", + "not a read only", + "not read only", + ] { + normalized = normalized.replace(negated, ""); + } + [ + "不要修改文件", + "不要修改任何文件", + "不得修改文件", + "不修改项目", + "不要修改项目", + "不得修改项目", + "只读审查", + "只读检查", + "只读验收", + "只读评审", + "只读核对", + "read-only review", + "read-only quality review", + "read-only inspection", + "read-only validation", + "read only review", + "read only inspection", + "read only validation", + "do not modify files", + "do not alter project files", + "without modifying files", + "without modifying project files", + "without modifying the project", + ] + .iter() + .any(|marker| normalized.contains(marker)) +} + pub(in crate::agent) fn agent_runtime_task_requires_read_only_delivery( agent_id: &str, task: &str, ) -> bool { let normalized = task.to_ascii_lowercase(); - let explicitly_read_only = [ - "不要修改文件", - "不要修改任何文件", - "不得修改文件", - "只读审查", - "只读检查", - "只读验收", - "read-only", - "do not modify files", - "do not alter project files", - "without modifying files", - ] - .iter() - .any(|marker| normalized.contains(marker)); - if explicitly_read_only { + if agent_runtime_task_explicitly_requires_read_only_delivery(task) { return true; } if agent_id != AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID { @@ -488,6 +807,57 @@ pub(in crate::agent) fn agent_runtime_task_requires_read_only_delivery( is_review_contract && !explicitly_requests_mutation } +pub(in crate::agent) fn agent_runtime_task_requires_read_only_delivery_at( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, + task: &str, +) -> Result { + let fallback = || agent_runtime_task_requires_read_only_delivery(agent_id, task); + let runtime = + read_game_creator_agent_runtime_for_session_at(root, agent_id, Some(session_id))?.state; + if runtime.agent_id != agent_id || runtime.session_id != session_id || runtime.run_id != run_id + { + return Err("自主构建只读合同的 child Runtime Agent/Session/run 身份不匹配".to_string()); + } + let child_link = match ( + runtime.parent_agent_id.as_deref(), + runtime.parent_run_id.as_deref(), + runtime.delegation_id.as_deref(), + ) { + (None, None, None) => return Ok(fallback()), + (Some(parent_agent_id), Some(parent_run_id), Some(delegation_id)) + if !parent_agent_id.trim().is_empty() + && !parent_run_id.trim().is_empty() + && !delegation_id.trim().is_empty() => + { + (parent_agent_id, parent_run_id, delegation_id) + } + _ => { + return Err( + "自主构建只读合同的 child Runtime parent/delegation 身份不完整".to_string(), + ); + } + }; + let Some(delivery) = read_static_delegate_delivery_at(root, child_link.2)? else { + return Ok(fallback()); + }; + if delivery.delegation_id != child_link.2 + || delivery.parent_agent_id != child_link.0 + || delivery.parent_run_id != child_link.1 + || delivery.target_agent_id != agent_id + || delivery.target_session_id != session_id + || delivery.target_run_id != run_id + { + return Err("自主构建只读合同的 child Runtime 与 durable delivery 身份不匹配".to_string()); + } + Ok( + delivery.target_agent_id == AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID + && delivery.expected_artifacts.is_empty(), + ) +} + pub(crate) fn agent_runtime_read_only_delivery_completion_plan_update( runtime: &AgentRuntimeState, ) -> Option { @@ -643,6 +1013,42 @@ pub(in crate::agent) fn restrict_agent_runtime_autonomous_liveness_repair_tools( Ok(()) } +pub(in crate::agent) fn restrict_agent_runtime_autonomous_specialist_mutation_repair_tools( + request: &mut LlmRunRequest, +) -> Result<(), String> { + let allowed_function_names = [ + "file.write", + "file.patch", + "file.delete", + "project.patchset", + "project.restore", + "canvas.asset_generate", + ] + .into_iter() + .map(|tool| { + native_runtime_function_name(tool) + .ok_or_else(|| format!("无法生成 autonomous 专业 mutation 修复工具名:{tool}")) + }) + .collect::, String>>()?; + request + .function_tools + .retain(|tool| allowed_function_names.contains(&tool.name)); + apply_agent_runtime_autonomous_source_schema_limits_with_max( + &mut request.function_tools, + AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_SOURCE_MAX_CHARS, + )?; + if request.function_tools.is_empty() { + return Err("autonomous 专业 mutation 修复工具目录缺少项目修改工具".to_string()); + } + request.max_output_tokens = Some( + request + .max_output_tokens + .unwrap_or(AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_MAX_OUTPUT_TOKENS) + .min(AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_MAX_OUTPUT_TOKENS), + ); + Ok(()) +} + pub(in crate::agent) fn restrict_agent_runtime_autonomous_truncated_scaffold_repair_tools( request: &mut LlmRunRequest, ) -> Result<(), String> { @@ -918,3 +1324,37 @@ pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collabo .into_iter() .any(|prefix| error.starts_with(prefix)) } + +#[cfg(test)] +mod tests { + use super::agent_runtime_task_explicitly_requires_read_only_delivery; + + #[test] + fn autonomous_read_only_detection_rejects_negated_labels() { + for task in [ + "完成非只读实现任务并写入 game/index.html", + "不要只读检查,必须立即修改项目", + "This is a non-read-only implementation task.", + "This is not a read-only task; modify game/index.html.", + ] { + assert!( + !agent_runtime_task_explicitly_requires_read_only_delivery(task), + "negated read-only label must remain mutable: {task}" + ); + } + } + + #[test] + fn autonomous_read_only_detection_accepts_explicit_review_contracts() { + for task in [ + "只读验收 game/index.html,不要修改任何项目文件", + "只读评审可玩性并返回结论", + "Perform a read-only quality review without modifying project files.", + ] { + assert!( + agent_runtime_task_explicitly_requires_read_only_delivery(task), + "explicit read-only contract must remain read-only: {task}" + ); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs index 3fd0d552e..d09fcccbe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs @@ -73,7 +73,7 @@ pub(in crate::agent) struct AgentRuntimeParallelReadBatch { pub(in crate::agent) updated_at: u64, } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct AgentRuntimeProviderActionBatch { pub(crate) schema_version: String, @@ -102,6 +102,79 @@ pub(crate) struct AgentRuntimeProviderActionBatch { pub(crate) updated_at: u64, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeProviderActionBatchWire { + schema_version: String, + batch_id: String, + project_id: String, + agent_id: String, + task_id: String, + session_id: String, + run_id: String, + source: String, + #[serde(default = "default_agent_runtime_run_profile")] + run_profile: String, + #[serde(default)] + run_profile_binding_fingerprint: String, + loop_iteration: u32, + planned_steer_cursor: u64, + status: String, + next_action_index: u32, + plan: AgentRuntimeToolPlan, + actions: Vec, + #[serde(default)] + collaboration_contract: Option, + project_revision_before: AgentRuntimeProjectRevision, + planned_repository_context_fingerprint: String, + created_at: u64, + updated_at: u64, +} + +impl<'de> Deserialize<'de> for AgentRuntimeProviderActionBatch { + fn deserialize(deserializer: Deserializer) -> Result + where + Deserializer: serde::Deserializer<'de>, + { + let wire = AgentRuntimeProviderActionBatchWire::deserialize(deserializer)?; + let batch = Self { + schema_version: wire.schema_version, + batch_id: wire.batch_id, + project_id: wire.project_id, + agent_id: wire.agent_id, + task_id: wire.task_id, + session_id: wire.session_id, + run_id: wire.run_id, + source: wire.source, + run_profile: wire.run_profile, + run_profile_binding_fingerprint: wire.run_profile_binding_fingerprint, + loop_iteration: wire.loop_iteration, + planned_steer_cursor: wire.planned_steer_cursor, + status: wire.status, + next_action_index: wire.next_action_index, + plan: wire.plan, + actions: wire.actions, + collaboration_contract: wire.collaboration_contract, + project_revision_before: wire.project_revision_before, + planned_repository_context_fingerprint: wire.planned_repository_context_fingerprint, + created_at: wire.created_at, + updated_at: wire.updated_at, + }; + if batch.schema_version == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION + && batch.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && batch.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && batch + .collaboration_contract + .as_ref() + .is_some_and(|contract| contract.initial_wave) + { + validate_agent_runtime_autonomous_initial_collaboration_contract(&batch.plan) + .map_err(serde::de::Error::custom)?; + } + Ok(batch) + } +} + impl AgentRuntimePendingToolAction { pub(crate) fn summary(&self) -> AgentRuntimePendingToolActionSummary { AgentRuntimePendingToolActionSummary { @@ -290,6 +363,26 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( } else { (None, None, SupervisorCollaborationPreflight::default()) }; + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && collaboration_preflight + .contract + .as_ref() + .is_some_and(|contract| contract.initial_wave) + { + if let Err(error) = + validate_agent_runtime_autonomous_initial_collaboration_contract(&batch_plan) + { + return Ok(AgentRuntimeProviderActionBatchPreparation::Blocked( + AgentRuntimeToolObservation { + tool: "runtime.collaboration_policy".to_string(), + status: "blocked".to_string(), + summary: "Project Supervisor 首批协作不满足 autonomous-game-build 合同" + .to_string(), + detail: Some(error), + }, + )); + } + } if let Some(violation) = collaboration_preflight.violation { return Ok(AgentRuntimeProviderActionBatchPreparation::Blocked( AgentRuntimeToolObservation { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs index ba0590c9a..f2c6710e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_batch_ledger.rs @@ -39,7 +39,7 @@ pub(in crate::agent) fn agent_runtime_provider_action_batch_id_v1( } #[allow(clippy::too_many_arguments)] -pub(in crate::agent) fn agent_runtime_provider_action_batch_id( +pub(in crate::agent) fn agent_runtime_provider_action_batch_id_v2( project_id: &str, agent_id: &str, task_id: &str, @@ -79,6 +79,37 @@ pub(in crate::agent) fn agent_runtime_provider_action_batch_id( )) } +#[allow(clippy::too_many_arguments)] +pub(in crate::agent) fn agent_runtime_provider_action_batch_id( + project_id: &str, + agent_id: &str, + task_id: &str, + session_id: &str, + run_id: &str, + loop_iteration: u32, + planned_steer_cursor: u64, + plan: &AgentRuntimeToolPlan, + project_revision_before: &AgentRuntimeProjectRevision, + planned_repository_context_fingerprint: &str, + actions: &[AgentRuntimePendingToolAction], + collaboration_contract: Option<&SupervisorCollaborationContract>, +) -> Result { + agent_runtime_provider_action_batch_id_v2( + project_id, + agent_id, + task_id, + session_id, + run_id, + loop_iteration, + planned_steer_cursor, + plan, + project_revision_before, + planned_repository_context_fingerprint, + actions, + collaboration_contract, + ) +} + pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batch( root: &Path, batch: &AgentRuntimeProviderActionBatch, @@ -97,6 +128,7 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc batch.schema_version.as_str(), AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION + | AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION ) { return Err(format!( "不支持的 Agent Runtime Provider action 批次版本:{}", @@ -132,7 +164,7 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc )); } let minimum_action_count = if batch.schema_version - == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION + != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION && batch.collaboration_contract.is_some() { 1 @@ -298,8 +330,8 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc }; let state = read_supervisor_collaboration_state_at(root, &batch.agent_id, &batch.run_id)?; if let Some(contract) = batch.collaboration_contract.as_ref() { - if batch.schema_version != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION { - return Err("旧版 Provider action 批次不能携带协作合同".to_string()); + if batch.schema_version == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION { + return Err("v1 Provider action 批次不能携带协作合同".to_string()); } let pristine = next_action_index == 0 && batch.actions.iter().all(|pending| { @@ -315,6 +347,12 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc contract, pristine.then_some(!state.has_collaboration()), )?; + if batch.schema_version == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION + && batch.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && contract.initial_wave + { + validate_agent_runtime_autonomous_initial_collaboration_contract(&batch.plan)?; + } } else { let preflight = preflight_supervisor_collaboration_plan( &batch.agent_id, @@ -335,8 +373,8 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc } else if batch.collaboration_contract.is_some() { return Err("非 Project Supervisor Provider 批次不能携带协作合同".to_string()); } - let expected_batch_id = - if batch.schema_version == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION { + let expected_batch_id = match batch.schema_version.as_str() { + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION => { agent_runtime_provider_action_batch_id_v1( &batch.project_id, &batch.agent_id, @@ -350,7 +388,24 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc &batch.planned_repository_context_fingerprint, &batch.actions, )? - } else { + } + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION => { + agent_runtime_provider_action_batch_id_v2( + &batch.project_id, + &batch.agent_id, + &batch.task_id, + &batch.session_id, + &batch.run_id, + batch.loop_iteration, + batch.planned_steer_cursor, + &batch.plan, + &batch.project_revision_before, + &batch.planned_repository_context_fingerprint, + &batch.actions, + batch.collaboration_contract.as_ref(), + )? + } + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION => { agent_runtime_provider_action_batch_id( &batch.project_id, &batch.agent_id, @@ -365,7 +420,9 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_provider_action_batc &batch.actions, batch.collaboration_contract.as_ref(), )? - }; + } + _ => unreachable!("Provider action batch schema was validated above"), + }; if batch.batch_id != expected_batch_id { return Err("Agent Runtime Provider action 批次身份指纹已变化".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs index 4e1f0e536..6cd4c006b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs @@ -1,5 +1,19 @@ use super::*; +fn normalize_game_creator_agent_background_final_reply_response( + mut response: platform_llm::LlmRunResponse, + observations: &[AgentRuntimeToolObservation], +) -> Result { + response.text = redact_agent_runtime_private_process_output_from_response( + &strip_llm_thinking_blocks(&response.text), + observations, + ); + if response.text.trim().is_empty() { + return Err(platform_llm::LlmError::EmptyResponse); + } + Ok(response) +} + pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_at( root: &Path, agent_id: &str, @@ -120,6 +134,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ let fallback_response = fallback_response.filter(|response| !response.trim().is_empty()); let stream_response = llm.stream; let request_stream_snapshot = stream_snapshot.clone(); + let response_observations = observations; let response_result = request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using( root, @@ -146,8 +161,19 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ .await { Ok(response) => { - publisher.handoff(); - Ok(response) + match normalize_game_creator_agent_background_final_reply_response( + response, + response_observations, + ) { + Ok(response) => { + publisher.handoff(); + Ok(response) + } + Err(error) => { + publisher.failed(); + Err(error) + } + } } Err(error) => { publisher.failed(); @@ -155,18 +181,14 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ } } } else { - client.run(attempt_request).await + normalize_game_creator_agent_background_final_reply_response( + client.run(attempt_request).await?, + response_observations, + ) } } }, - |response| { - let mut response = response.clone(); - response.text = redact_agent_runtime_private_process_output_from_response( - &strip_llm_thinking_blocks(&response.text), - observations, - ); - response - }, + |response| response.clone(), ) .await; if response_result.is_err() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 992603d11..aae363218 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -160,8 +160,13 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at } else { AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS }; - let read_only_delivery = run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + let task_text_requires_read_only_delivery = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_runtime_task_requires_read_only_delivery(agent_id, task); + let read_only_delivery = run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_runtime_task_requires_read_only_delivery_at( + root, agent_id, session_id, run_id, task, + )?; let verified_delivery = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { let verification_gate = read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; @@ -273,6 +278,28 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at ) .and_then(|parsed| { let source_payload = if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + let verification_gate = read_game_creator_agent_runtime_verification_gate( + root, agent_id, run_id, + ) + .map_err(|error| { + AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics, + error, + ) + })?; + validate_agent_runtime_autonomous_specialist_response_delivery( + agent_id, + run_id, + read_only_delivery, + &verification_gate, + &parsed.plan, + ) + .map_err(|error| { + AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics, + error, + ) + })?; let source_payload = validate_agent_runtime_autonomous_source_payload(&parsed.plan) .map_err(|error| { AgentRuntimeToolPlanProtocolError::new( @@ -280,6 +307,16 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at error, ) })?; + validate_agent_runtime_autonomous_read_only_delivery_plan( + read_only_delivery, + &parsed.plan, + ) + .map_err(|error| { + AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics, + error, + ) + })?; if autonomous_scaffold_repair_active && source_payload.max_field_chars > AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_SOURCE_MAX_CHARS @@ -368,7 +405,23 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at &collaboration_policy, &collaboration_state, )?; - if let Some(violation) = preflight.violation { + let autonomous_initial_collaboration = if run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && preflight + .contract + .as_ref() + .is_some_and(|contract| contract.initial_wave) + { + validate_agent_runtime_autonomous_initial_collaboration_contract(&parsed.plan) + } else { + Ok(()) + }; + if let Err(error) = autonomous_initial_collaboration { + Err(AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics, + error, + )) + } else if let Some(violation) = preflight.violation { Err(AgentRuntimeToolPlanProtocolError::new( AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics, format!("{}:{}", violation.summary, violation.detail), @@ -415,6 +468,20 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at mut normalized_text_chars, mut normalized_text_sha256, } = parsed; + if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && read_only_delivery + && !task_text_requires_read_only_delivery + && !plan.response.trim().is_empty() + { + let runtime = read_game_creator_agent_runtime_for_session_at( + root, + agent_id, + Some(session_id), + )? + .state; + plan.plan_update = + agent_runtime_read_only_delivery_completion_plan_update(&runtime); + } if let Some((count, source_chars, source_sha256)) = response_handoff.thinking_normalization_metadata() { @@ -548,8 +615,25 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error.starts_with(AGENT_RUNTIME_AUTONOMOUS_LIVENESS_ERROR_PREFIX) && !request.function_tools.is_empty(); - let force_autonomous_read_only_delivery = - force_autonomous_pre_mutation && read_only_delivery; + let force_autonomous_read_only_delivery = read_only_delivery + && (force_autonomous_pre_mutation + || protocol_error + .starts_with(AGENT_RUNTIME_AUTONOMOUS_READ_ONLY_MUTATION_ERROR_PREFIX)); + let force_autonomous_specialist_mutation_only = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && !read_only_delivery + && agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && (force_autonomous_pre_mutation + || protocol_error.starts_with( + AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_MUTATION_ONLY_REPAIR_ERROR_PREFIX, + )) + && !request.function_tools.is_empty(); + let force_autonomous_specialist_verification_only = run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && protocol_error.starts_with( + AGENT_RUNTIME_AUTONOMOUS_SPECIALIST_VERIFICATION_ONLY_REPAIR_ERROR_PREFIX, + ) + && !request.function_tools.is_empty(); let force_autonomous_pending_verification = run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && protocol_error.starts_with( @@ -607,6 +691,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at &protocol_error, ) && !request.function_tools.is_empty(); if force_supervisor_initial_collaboration + || force_autonomous_specialist_mutation_only + || force_autonomous_specialist_verification_only || force_autonomous_response_plan_completion || force_autonomous_delegated_playtest_repair || force_autonomous_failed_playtest @@ -616,6 +702,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at || force_autonomous_preview_after_static || force_autonomous_verified_delivery || force_autonomous_truncated_scaffold + || force_autonomous_read_only_delivery || force_autonomous_pre_mutation { request.function_tools = @@ -623,8 +710,30 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at } if force_supervisor_initial_collaboration { restrict_agent_runtime_supervisor_collaboration_repair_tools(&mut request)?; + let instruction = if run_profile + == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { + format!( + "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.html;quality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 []。两者都使用 agent.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" + ) + } else { + format!( + "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须根据当前 Project Supervisor 协作策略,在同一响应中一次性调用完整的 agent.delegate / agent.spawn_isolated 批次,使首批协作合同全部成立。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" + ) + }; + request.messages.push(LlmMessage::user(instruction)); + } else if force_autonomous_specialist_mutation_only { + autonomous_scaffold_repair_active = true; + restrict_agent_runtime_autonomous_specialist_mutation_repair_tools( + &mut request, + )?; request.messages.push(LlmMessage::user(format!( - "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须根据当前 Project Supervisor 协作策略,在同一响应中一次性调用完整的 agent.delegate / agent.spawn_isolated 批次,使首批协作合同全部成立。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" + "上一条输出不符合工具计划协议:{protocol_error}\n当前是 autonomous-game-build 的非只读专业任务,本次修复的原生工具目录只保留项目 mutation 工具。必须立即完成本人 run 的实际项目修改;不得 respond_to_user、验证、更新计划、读取、搜索、查询状态或委派。首次源码字段不得超过 {AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_SOURCE_MAX_CHARS} 字符,完整写入 game/index.html 时必须保持 HTML 与 script 闭合。不要解释,不要 markdown,不要代码围栏。" + ))); + } else if force_autonomous_specialist_verification_only { + restrict_agent_runtime_autonomous_reverification_repair_tools(&mut request)?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合工具计划协议:{protocol_error}\n当前是 autonomous-game-build 的非只读专业任务,且本人 run 已有 mutation。本次修复的原生工具目录只保留 project.verify 与 command.run_limited;必须立即验证本人 run 的最新 mutation revision,通过后才能 respond_to_user。不得继续修改项目、更新计划、读取、搜索、查询状态、委派或解释。不要 markdown,不要代码围栏。" ))); } else if force_autonomous_response_plan_completion { restrict_agent_runtime_autonomous_response_plan_repair_tools(&mut request)?; @@ -636,7 +745,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at &mut request, )?; request.messages.push(LlmMessage::user(format!( - "上一条输出不符合工具计划协议:{protocol_error}\n当前父 run 已有 ready 未认领回执或 3 个 active delivery,本次修复的原生工具目录只保留 agent.run_status。必须立即以 agentId=null、scope=all、delegationId=null 查询状态并原子认领 readyDelegateReceipts;不得创建第四次 agent.delegate、更新计划、读取、搜索、修改项目、重复验证或 respond_to_user。认领完成后再依据最新 project revision 重新规划验证。不要解释,不要 markdown,不要代码围栏。" + "上一条输出不符合工具计划协议:{protocol_error}\n当前父 run 已有 ready 未认领回执、尚未 observed 的持久 claim,或 3 个 active delivery;本次修复的原生工具目录只保留 agent.run_status。必须立即以 agentId=null、scope=all、delegationId=null 查询状态并原子认领、观察 readyDelegateReceipts;不得创建第四次 agent.delegate、更新计划、读取、搜索、修改项目、重复验证或 respond_to_user。收敛完成后再依据最新 project revision 重新规划验证或 repair。不要解释,不要 markdown,不要代码围栏。" ))); } else if force_autonomous_preview_after_static { restrict_agent_runtime_autonomous_preview_after_static_repair_tools( 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 c91702a39..8e5046cbe 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 @@ -50,8 +50,10 @@ pub(super) const AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_EXECUTING: &str = "exe pub(super) const AGENT_RUNTIME_PARALLEL_READ_BATCH_STATUS_OBSERVED: &str = "observed"; pub(super) const AGENT_RUNTIME_PARALLEL_READ_BATCH_SIDECAR_MAX_BYTES: usize = 4 * 1024 * 1024; pub(crate) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION: &str = - "game-creator-provider-action-batch.v2"; + "game-creator-provider-action-batch.v3"; pub(super) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_PREVIOUS_SCHEMA_VERSION: &str = + "game-creator-provider-action-batch.v2"; +pub(super) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_LEGACY_SCHEMA_VERSION: &str = "game-creator-provider-action-batch.v1"; pub(super) const AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_WAITING_CONFIRMATION: &str = "waiting-confirmation"; 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 b0ffe4b6d..66cf95f2a 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 @@ -10,13 +10,18 @@ pub(super) fn game_creator_agent_background_final_reply_fallback( let response = strip_llm_thinking_blocks(plan_response); return (!response.trim().is_empty()).then_some(response); } - (run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - .then(|| { + if run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + return None; + } + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + Some( format!( "项目已完成生成,并通过当前 revision {response_revision} 的静态检查和桌面、移动端交互试玩验证。" - ) - }) + ), + ) + } else { + Some("当前专业任务已完成,执行结果与验证证据已记录。".to_string()) + } } const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_TOOL_PLAN: &str = "tool-plan-failed"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index 8ba28aea4..95bd464de 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -180,14 +180,17 @@ fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() { } #[test] -fn ordinary_agent_empty_plan_has_no_deterministic_final_reply_fallback() { - assert!(game_creator_agent_background_final_reply_fallback( - "", - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, - "code-prototype", - 7, - ) - .is_none()); +fn autonomous_specialist_empty_plan_uses_internal_completion_fallback() { + assert_eq!( + game_creator_agent_background_final_reply_fallback( + "", + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + "code-prototype", + 7, + ) + .as_deref(), + Some("当前专业任务已完成,执行结果与验证证据已记录。") + ); } #[test] @@ -201,6 +204,17 @@ fn standard_supervisor_empty_plan_has_no_deterministic_final_reply_fallback() { .is_none()); } +#[test] +fn standard_specialist_empty_plan_has_no_deterministic_final_reply_fallback() { + assert!(game_creator_agent_background_final_reply_fallback( + "", + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + "code-prototype", + 7, + ) + .is_none()); +} + #[test] fn plan_response_precedes_autonomous_supervisor_deterministic_fallback() { assert_eq!( @@ -314,13 +328,26 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac .collect::>(), vec![fallback.as_str()] ); - let stream = read_game_creator_agent_runtime_response_stream_at( + let mut stream = read_game_creator_agent_runtime_response_stream_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, RUN_ID, ) .expect("read autonomous fallback response stream") .expect("autonomous fallback response stream exists"); + for _ in 0..250 { + if stream.status == "committed" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + stream = read_game_creator_agent_runtime_response_stream_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + RUN_ID, + ) + .expect("poll autonomous fallback response stream") + .expect("autonomous fallback response stream remains present"); + } assert_eq!(stream.status, "committed"); assert_eq!(stream.accumulated_text, fallback); assert!(read_game_creator_agent_runtime_finalization_journal( @@ -377,5 +404,11 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac }) .filter_map(|record| record["status"].as_str().map(str::to_string)) .collect::>(); - assert_eq!(final_reply_lifecycle_statuses, vec!["started", "failed"]); + assert!( + final_reply_lifecycle_statuses.len() >= 2 && final_reply_lifecycle_statuses.len() % 2 == 0, + "final reply retry chain must contain complete started/failed pairs" + ); + assert!(final_reply_lifecycle_statuses + .chunks_exact(2) + .all(|pair| pair == ["started", "failed"])); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 29fe5e02c..a9274bc23 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -80,6 +80,7 @@ pub(crate) use steering::{ consume_game_creator_agent_runtime_steers, game_creator_agent_runtime_steer_ledger_path, interrupt_game_creator_agent_runtime_provider_request_at, render_game_creator_agent_runtime_steers_for_prompt, steer_game_creator_agent_runtime_task_at, + steer_game_creator_agent_runtime_task_for_profile_at, validate_game_creator_agent_runtime_steer_notification_at, }; pub(crate) use verification::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index 2f3c4142e..6e316ca00 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -34,6 +34,20 @@ pub(in crate::agent) fn game_creator_agent_runtime_transient_provider_error_kind platform_llm::LlmError::Upstream { status_code: 400, .. } if retry_autonomous_upstream_400 => Some("upstream-400"), + platform_llm::LlmError::Upstream { + status_code: 408, .. + } => Some("upstream-408"), + platform_llm::LlmError::Upstream { + status_code: 429, .. + } => Some("upstream-429"), + platform_llm::LlmError::Upstream { status_code, .. } + if (500..=599).contains(status_code) => + { + Some("upstream-5xx") + } + platform_llm::LlmError::EmptyResponse => Some("empty-response"), + platform_llm::LlmError::Deserialize(_) => Some("deserialize"), + platform_llm::LlmError::StreamUnavailable => Some("stream-unavailable"), _ => None, } } @@ -1317,3 +1331,105 @@ pub(crate) fn append_game_creator_agent_runtime_provider_lifecycle_for_test( )?; Ok(request_id) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_retry_error_classification_accepts_durable_failures_and_rejects_client_faults() { + let retryable = vec![ + (platform_llm::LlmError::Timeout { attempts: 1 }, "timeout"), + ( + platform_llm::LlmError::Connectivity { + attempts: 1, + message: "temporary connection failure".to_string(), + }, + "connectivity", + ), + ( + platform_llm::LlmError::Transport("temporary transport failure".to_string()), + "transport", + ), + ( + platform_llm::LlmError::Upstream { + status_code: 408, + message: "request timeout".to_string(), + }, + "upstream-408", + ), + ( + platform_llm::LlmError::Upstream { + status_code: 429, + message: "rate limited".to_string(), + }, + "upstream-429", + ), + ( + platform_llm::LlmError::Upstream { + status_code: 500, + message: "server failure".to_string(), + }, + "upstream-5xx", + ), + ( + platform_llm::LlmError::Upstream { + status_code: 599, + message: "server failure".to_string(), + }, + "upstream-5xx", + ), + (platform_llm::LlmError::EmptyResponse, "empty-response"), + ( + platform_llm::LlmError::Deserialize("temporary invalid response".to_string()), + "deserialize", + ), + ( + platform_llm::LlmError::StreamUnavailable, + "stream-unavailable", + ), + ]; + for (error, expected_kind) in retryable { + assert_eq!( + game_creator_agent_runtime_transient_provider_error_kind(&error, false), + Some(expected_kind), + "unexpected retry classification for {error:?}" + ); + } + + let upstream_400 = platform_llm::LlmError::Upstream { + status_code: 400, + message: "autonomous gateway failure".to_string(), + }; + assert_eq!( + game_creator_agent_runtime_transient_provider_error_kind(&upstream_400, true), + Some("upstream-400") + ); + assert_eq!( + game_creator_agent_runtime_transient_provider_error_kind(&upstream_400, false), + None + ); + + for status_code in [400, 401, 403, 404, 422, 499, 600] { + let error = platform_llm::LlmError::Upstream { + status_code, + message: "non-retryable upstream response".to_string(), + }; + assert_eq!( + game_creator_agent_runtime_transient_provider_error_kind(&error, false), + None, + "upstream {status_code} must not enter durable retry" + ); + } + for error in [ + platform_llm::LlmError::InvalidConfig("invalid config".to_string()), + platform_llm::LlmError::InvalidRequest("invalid request".to_string()), + ] { + assert_eq!( + game_creator_agent_runtime_transient_provider_error_kind(&error, false), + None, + "client fault must not enter durable retry: {error:?}" + ); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs index 04faa8ac9..7eda6d8db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs @@ -473,6 +473,28 @@ pub(crate) fn steer_game_creator_agent_runtime_task_at( steer_id: &str, instruction: &str, accepted_via: &str, +) -> Result { + steer_game_creator_agent_runtime_task_for_profile_at( + root, + agent_id, + session_id, + run_id, + steer_id, + instruction, + None, + accepted_via, + ) +} + +pub(crate) fn steer_game_creator_agent_runtime_task_for_profile_at( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, + steer_id: &str, + instruction: &str, + expected_run_profile: Option<&str>, + accepted_via: &str, ) -> Result { validate_project_root(root)?; let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; @@ -495,6 +517,25 @@ pub(crate) fn steer_game_creator_agent_runtime_task_at( if state.run_id != run_id || state.session_id != session_id { return Err("追加指令与当前 Agent 的 session/run 身份不匹配".to_string()); } + if let Some(expected_run_profile) = expected_run_profile { + let expected_run_profile = normalize_agent_runtime_run_profile(Some(expected_run_profile))?; + let (persisted_run_profile, persisted_binding_fingerprint) = + agent_runtime_run_profile_identity_at( + root, + &agent_id, + run_id, + Some(&state.run_profile), + Some(&state.run_profile_binding_fingerprint), + )?; + if persisted_run_profile != expected_run_profile + || state.run_profile != persisted_run_profile + || state.run_profile_binding_fingerprint != persisted_binding_fingerprint + { + return Err(format!( + "追加指令请求的 Run Profile 与目标 run 不匹配:expected={expected_run_profile} actual={persisted_run_profile}" + )); + } + } validate_agent_runtime_steer_target_state(&state)?; if game_creator_agent_runtime_finalization_path(root, &agent_id, run_id).exists() { return Err("当前 Agent run 已进入最终持久化,不再接受追加指令".to_string()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index c58d685b5..e97fd3918 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -637,18 +637,20 @@ pub(crate) fn steer_game_creator_agent_runtime_task( run_id: String, steer_id: String, instruction: String, + run_profile: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.write")?; enforce_project_permission_policy(root, "agent.run_status")?; - let mut result = steer_game_creator_agent_runtime_task_at( + let mut result = steer_game_creator_agent_runtime_task_for_profile_at( root, agent_id.trim(), session_id.trim(), run_id.trim(), steer_id.trim(), instruction.trim(), + run_profile.as_deref(), "tauri", )?; if !result.provider_interrupted diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs index 6556192d5..6a645a453 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs @@ -98,17 +98,31 @@ pub(super) fn run_game_creator_swarm_chat_with_input( .map_err(|error| format!("写入终端失败:{error}"))?; } - if runtimes_are_busy(&existing_runtimes) { + let active_conversation = + read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; + let active_session_id = active_conversation + .session_id + .as_deref() + .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; + let matching_parent_is_busy = swarm_parent_runtime( + parent_agent_id, + active_session_id, + run_profile, + &existing_runtimes, + ) + .is_some_and(runtime_is_busy); + if matching_parent_is_busy { writeln!(output, "[恢复] 检测到未收束 Runtime,继续观察现有任务。") .map_err(|error| format!("写入终端失败:{error}"))?; - let before = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; + let before = active_conversation; let session_id = before .session_id .as_deref() .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; - let parent_run_id = swarm_parent_runtime(parent_agent_id, session_id, &existing_runtimes) - .map(|runtime| runtime.state.run_id.as_str()) - .unwrap_or_default(); + let parent_run_id = + swarm_parent_runtime(parent_agent_id, session_id, run_profile, &existing_runtimes) + .map(|runtime| runtime.state.run_id.as_str()) + .unwrap_or_default(); let mut conversation_baseline = new_swarm_turn_conversation_baseline(before.messages.len(), parent_run_id); capture_recovered_swarm_assistant_at( @@ -122,6 +136,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( root, parent_agent_id, session_id, + run_profile, conversation_baseline, input, output, @@ -172,6 +187,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( root, parent_agent_id, &observation.session_id, + run_profile, conversation_baseline, input, output, @@ -214,6 +230,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( goal.run_id.clone(), steer_id.clone(), message, + Some(run_profile.to_string()), )?; writeln!( output, @@ -229,6 +246,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( root, parent_agent_id, &session_id, + run_profile, conversation_baseline, input, output, @@ -307,6 +325,7 @@ pub(super) fn run_game_creator_swarm_chat_with_input( root, parent_agent_id, &session_id, + run_profile, conversation_baseline, input, output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs index dc5a6b632..39dc51df3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs @@ -24,10 +24,13 @@ pub(super) struct SwarmTerminalFailureScan { pub(super) fn swarm_parent_runtime<'a>( parent_agent_id: &str, session_id: &str, + run_profile: &str, runtimes: &'a [AgentRuntimeResult], ) -> Option<&'a AgentRuntimeResult> { runtimes.iter().find(|runtime| { - runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id + runtime.state.agent_id == parent_agent_id + && runtime.state.session_id == session_id + && runtime.state.run_profile == run_profile }) } @@ -131,10 +134,12 @@ pub(super) fn scan_swarm_terminal_failures_at( root: &Path, parent_agent_id: &str, session_id: &str, + run_profile: &str, runtimes: &[AgentRuntimeResult], ) -> SwarmTerminalFailureScan { let mut scan = SwarmTerminalFailureScan::default(); - let Some(parent) = swarm_parent_runtime(parent_agent_id, session_id, runtimes) else { + let Some(parent) = swarm_parent_runtime(parent_agent_id, session_id, run_profile, runtimes) + else { return scan; }; let claimed_deliveries = match claimed_static_delegate_deliveries_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs index 270eebfc1..e0988d9c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs @@ -146,6 +146,107 @@ fn same_run_steer_preserves_bound_autonomous_profile() { fs::remove_dir_all(root).ok(); } +#[test] +fn cross_profile_steer_is_rejected_before_persistent_side_effects() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-profile-mismatch-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-swarm-mismatch", "Swarm Profile Mismatch") + .expect("initialize profile mismatch project"); + let run_id = "swarm-profile-standard-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "等待开发者确认的标准任务", + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "准备标准模式运行", + vec!["等待确认".to_string()], + ) + .expect("start standard supervisor runtime"); + assert_eq!( + state.run_profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD, + "fixture must stay standard" + ); + let conversation_before = read_local_conversation_for_session_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + Some(&state.session_id), + ) + .expect("read conversation before rejected steer"); + + let error = steer_game_creator_agent_runtime_task_for_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "swarm-profile-mismatch-steer", + "切换为自主构建并继续", + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + "swarm-cli", + ) + .expect_err("cross-profile steer must fail closed"); + assert!(error.contains("Run Profile"), "unexpected error: {error}"); + assert!(!game_creator_agent_runtime_steer_ledger_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .exists()); + let conversation_after = read_local_conversation_for_session_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + Some(&state.session_id), + ) + .expect("read conversation after rejected steer"); + assert_eq!(conversation_after.messages, conversation_before.messages); + let persisted = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read runtime after rejected steer"); + assert_eq!(persisted.state.run_id, run_id); + assert_eq!( + persisted.state.run_profile, + AGENT_RUNTIME_RUN_PROFILE_STANDARD + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn parent_runtime_matching_is_scoped_to_requested_profile() { + let mut standard = runtime("running", "planning", 0); + standard.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + standard.state.session_id = "session-profile-match".to_string(); + standard.state.run_id = "run-standard".to_string(); + standard.state.run_profile = AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(); + let mut autonomous = standard.clone(); + autonomous.state.run_id = "run-autonomous".to_string(); + autonomous.state.run_profile = AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD.to_string(); + let runtimes = vec![standard, autonomous]; + + assert_eq!( + swarm_parent_runtime( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "session-profile-match", + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &runtimes, + ) + .map(|runtime| runtime.state.run_id.as_str()), + Some("run-standard") + ); + assert_eq!( + swarm_parent_runtime( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "session-profile-match", + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + &runtimes, + ) + .map(|runtime| runtime.state.run_id.as_str()), + Some("run-autonomous") + ); +} + #[test] fn parses_chat_commands_without_stealing_normal_messages() { assert_eq!(parse_swarm_chat_input(" "), None); @@ -427,6 +528,7 @@ fn active_turn_eof_keeps_observing_until_parent_completes() { &root, parent_agent_id, &session_id, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, conversation_baseline, &rx, &mut output, @@ -801,6 +903,7 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { &root, &parent.state.agent_id, &parent.state.session_id, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, &[parent.clone(), child.clone()], ); assert!(original_scan.failed_agents.is_empty()); @@ -827,6 +930,7 @@ fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { &root, &parent.state.agent_id, &parent.state.session_id, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, &[parent.clone(), child], ); assert_eq!(repair_scan.failed_agents, vec!["code-prototype:failed"]); diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs index 7d1fa7b17..7da85b9d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs @@ -13,6 +13,7 @@ pub(super) fn wait_for_swarm_turn( root: &Path, parent_agent_id: &str, session_id: &str, + run_profile: &str, conversation_baseline: SwarmTurnConversationBaseline, input: &Receiver, output: &mut W, @@ -91,8 +92,13 @@ pub(super) fn wait_for_swarm_turn( pending_interactions, ); } - let failure_scan = - scan_swarm_terminal_failures_at(root, parent_agent_id, session_id, &runtimes); + let failure_scan = scan_swarm_terminal_failures_at( + root, + parent_agent_id, + session_id, + run_profile, + &runtimes, + ); if !failure_scan.reconciliation_agents.is_empty() { observer.close_response_line(output)?; return build_reconciliation_turn_outcome( @@ -169,7 +175,8 @@ pub(super) fn wait_for_swarm_turn( session_id, &conversation_baseline, )?; - let parent_runtime = swarm_parent_runtime(parent_agent_id, session_id, &runtimes); + let parent_runtime = + swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes); let completion_blockers = parent_runtime .map(|parent| swarm_parent_completion_contract_blockers_at(root, parent)) .unwrap_or_else(|| vec!["parent-runtime-missing".to_string()]); @@ -275,6 +282,8 @@ pub(super) fn wait_for_swarm_turn( SwarmChatInput::Message(message) => { if let Some(parent) = runtimes.iter().find(|runtime| { runtime.state.agent_id == parent_agent_id + && runtime.state.session_id == session_id + && runtime.state.run_profile == run_profile && matches!(runtime.state.status.as_str(), "pending" | "running") }) { let steer_id = format!("swarm-steer-{}", unix_millis()); @@ -285,6 +294,7 @@ pub(super) fn wait_for_swarm_turn( parent.state.run_id.clone(), steer_id.clone(), message, + Some(run_profile.to_string()), )?; writeln!( output, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs index 2bb6f2462..9ae1e5fac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs @@ -200,6 +200,241 @@ async fn supervisor_collaboration_blocks_unannotated_mcp_in_initial_delegate_bat fs::remove_dir_all(config_dir).ok(); } +fn rewrite_autonomous_responsibility_batch_actions_for_test( + root: &Path, + batch: &mut AgentRuntimeProviderActionBatch, + actions: Vec, +) { + assert_eq!(batch.actions.len(), actions.len()); + batch.plan.actions = actions.clone(); + for (pending, action) in batch.actions.iter_mut().zip(actions) { + pending.action = action; + assert_eq!(pending.planned_steer_cursor, 0); + pending.action_fingerprint = + agent_runtime_tool_action_fingerprint(&pending.action, &pending.task); + pending.action_id = agent_runtime_tool_action_id( + &pending.run_id, + pending.loop_iteration, + pending.action_index, + pending.occurrence_nonce, + &pending.action_fingerprint, + ); + pending.input_summary = agent_runtime_tool_action_input_summary(root, &pending.action); + } + let action_ids = batch + .actions + .iter() + .map(|pending| pending.action_id.as_str()) + .collect::>(); + let identity = serde_json::to_vec(&serde_json::json!({ + "projectId": &batch.project_id, + "agentId": &batch.agent_id, + "taskId": &batch.task_id, + "sessionId": &batch.session_id, + "runId": &batch.run_id, + "loopIteration": batch.loop_iteration, + "plannedSteerCursor": batch.planned_steer_cursor, + "plan": &batch.plan, + "projectRevisionBefore": &batch.project_revision_before, + "plannedRepositoryContextFingerprint": &batch.planned_repository_context_fingerprint, + "actionIds": action_ids, + "collaborationContract": &batch.collaboration_contract, + })) + .expect("serialize internally consistent Provider batch identity"); + let fingerprint = format!("{:x}", Sha256::digest(identity)); + batch.batch_id = format!( + "provider-action-{}", + fingerprint.chars().take(32).collect::() + ); +} + +#[tokio::test] +async fn supervisor_autonomous_durable_batch_rejects_invalid_responsibilities_without_side_effects() +{ + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-supervisor-autonomous-durable-responsibility", + "自主构建持久批次职责门禁测试", + ) + .expect("project init"); + let run_id = "supervisor-autonomous-durable-responsibility-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous Supervisor profile"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "建立程序实现与独立质量验收职责。", + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "准备持久首批协作", + Vec::new(), + ) + .expect("start autonomous Supervisor runtime"); + let valid_plan = supervisor_collaboration_plan_for_test( + valid_autonomous_initial_responsibility_actions_for_test(), + ); + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read durable responsibility revision"); + let preparation = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + &runtime.current_task, + &valid_plan, + &[], + &revision, + &"d".repeat(64), + ) + .await + .expect("prepare valid autonomous responsibility batch"); + let AgentRuntimeProviderActionBatchPreparation::Ready(valid_batch) = preparation else { + panic!("valid autonomous responsibilities must form a ready durable batch"); + }; + assert_eq!(valid_batch.actions.len(), 2); + assert!(valid_batch.collaboration_contract.is_some()); + let valid_batch_id = valid_batch.batch_id.clone(); + let mut quality_not_read_only = autonomous_initial_responsibility_actions_for_test( + "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", + &["game/index.html"], + "直接修改 game/index.html,修复试玩阻塞并重新验证。", + &[], + ); + quality_not_read_only[1].input["acceptanceCriteria"] = serde_json::json!([ + "直接写入 game/index.html 修复试玩问题", + "修改后执行静态验证并交付新 revision" + ]); + + let invalid_cases = vec![ + ( + "code-missing-game-index", + "game/index.html", + autonomous_initial_responsibility_actions_for_test( + "创建可直接试玩的游戏实现并执行静态验证。", + &["game/main.js"], + "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", + &[], + ), + ), + ( + "code-read-only", + "code-prototype", + autonomous_initial_responsibility_actions_for_test( + "只读检查 game/index.html,不要修改任何项目文件。", + &["game/index.html"], + "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", + &[], + ), + ), + ( + "quality-not-read-only", + "quality-review", + quality_not_read_only, + ), + ( + "quality-with-artifacts", + "expectedArtifacts", + autonomous_initial_responsibility_actions_for_test( + "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", + &["game/index.html"], + "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", + &["game/index.html"], + ), + ), + ]; + for (case_name, expected_error, actions) in invalid_cases { + let mut invalid_batch = valid_batch.clone(); + rewrite_autonomous_responsibility_batch_actions_for_test( + &root, + &mut invalid_batch, + actions, + ); + let action_ids = invalid_batch + .actions + .iter() + .map(|pending| pending.action_id.clone()) + .collect::>(); + write_supervisor_provider_action_batch_fixture_for_test(&root, &invalid_batch); + + let error = read_game_creator_agent_runtime_provider_action_batch( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect_err("invalid durable responsibility batch must fail closed"); + assert!(error.contains(expected_error), "{case_name}: {error}"); + assert_supervisor_collaboration_has_zero_runtime_side_effects_for_test( + &root, + run_id, + &action_ids, + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read unchanged durable responsibility revision"), + revision, + "{case_name} must not advance project revision" + ); + } + + let mut legacy_v2_batch = valid_batch.clone(); + rewrite_autonomous_responsibility_batch_actions_for_test( + &root, + &mut legacy_v2_batch, + autonomous_initial_responsibility_actions_for_test( + "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", + &["game/index.html"], + "直接修改 game/index.html,修复试玩阻塞并重新验证。", + &[], + ), + ); + let legacy_v2_schema = "game-creator-provider-action-batch.v2"; + legacy_v2_batch.schema_version = legacy_v2_schema.to_string(); + write_supervisor_provider_action_batch_fixture_for_test(&root, &legacy_v2_batch); + let recovered_v2 = read_game_creator_agent_runtime_provider_action_batch( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("v2 autonomous initial batch must retain its original recovery contract"); + assert_eq!(recovered_v2.schema_version, legacy_v2_schema); + assert_eq!(recovered_v2.batch_id, legacy_v2_batch.batch_id); + assert_supervisor_collaboration_has_zero_runtime_side_effects_for_test( + &root, + run_id, + &legacy_v2_batch + .actions + .iter() + .map(|pending| pending.action_id.clone()) + .collect::>(), + ); + + write_supervisor_provider_action_batch_fixture_for_test(&root, &valid_batch); + let reread = read_game_creator_agent_runtime_provider_action_batch( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("valid durable responsibility batch must remain readable"); + assert_eq!(reread.batch_id, valid_batch_id); + assert_supervisor_collaboration_has_zero_runtime_side_effects_for_test( + &root, + run_id, + &valid_batch + .actions + .iter() + .map(|pending| pending.action_id.clone()) + .collect::>(), + ); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn autonomous_game_build_repairs_supervisor_failed_playtest_stall_into_mutation() { let root = unique_project_path(); @@ -361,6 +596,264 @@ async fn autonomous_game_build_repairs_supervisor_failed_playtest_stall_into_mut fs::remove_dir_all(root).ok(); } +fn autonomous_initial_responsibility_actions_for_test( + code_task: &str, + code_artifacts: &[&str], + quality_task: &str, + quality_artifacts: &[&str], +) -> Vec { + vec![ + AgentRuntimeToolAction { + tool: "agent.delegate".to_string(), + reason: Some("委派程序 Agent 形成可玩入口".to_string()), + input: serde_json::json!({ + "agentId": "code-prototype", + "task": code_task, + "acceptanceCriteria": [ + "game/index.html 必须形成可直接试玩的完整入口", + "程序交付必须完成当前 revision 的静态验证" + ], + "expectedArtifacts": code_artifacts, + "repairOfDelegationId": null, + "runId": null + }), + }, + AgentRuntimeToolAction { + tool: "agent.delegate".to_string(), + reason: Some("委派质量 Agent 独立只读验收".to_string()), + input: serde_json::json!({ + "agentId": "quality-review", + "task": quality_task, + "acceptanceCriteria": [ + "只读核对可玩性、交互闭环和阻塞问题", + "返回可追溯的验收结论,不修改项目文件" + ], + "expectedArtifacts": quality_artifacts, + "repairOfDelegationId": null, + "runId": null + }), + }, + ] +} + +fn valid_autonomous_initial_responsibility_actions_for_test() -> Vec { + autonomous_initial_responsibility_actions_for_test( + "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", + &["game/index.html"], + "只读验收 game/index.html 的可玩性与交互闭环;不要修改任何项目文件。", + &[], + ) +} + +fn native_supervisor_responsibility_plan_response_for_test( + call_prefix: &str, + actions: &[AgentRuntimeToolAction], +) -> serde_json::Value { + let delegate_function = + native_runtime_function_name("agent.delegate").expect("delegate function"); + let tool_calls = actions + .iter() + .enumerate() + .map(|(index, action)| { + serde_json::json!({ + "id": format!("{call_prefix}-{index}"), + "type": "function", + "function": { + "name": delegate_function, + "arguments": serde_json::json!({ + "reason": action.reason, + "input": action.input + }).to_string() + } + }) + }) + .collect::>(); + serde_json::json!({ + "id": format!("chatcmpl-{call_prefix}"), + "model": "mock-game-model", + "choices": [{ + "message": { + "content": null, + "tool_calls": tool_calls + }, + "finish_reason": "tool_calls" + }], + "usage": { + "prompt_tokens": 11, + "completion_tokens": 22, + "total_tokens": 33 + } + }) +} + +fn captured_supervisor_function_names_for_test(request: &str) -> BTreeSet { + mock_http_request_json(request)["tools"] + .as_array() + .expect("captured Supervisor function tools") + .iter() + .filter_map(|tool| { + tool.get("name") + .and_then(serde_json::Value::as_str) + .or_else(|| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + }) + }) + .map(str::to_string) + .collect() +} + +#[tokio::test] +async fn supervisor_autonomous_initial_responsibilities_reject_four_invalid_plans_before_accepting_contract( +) { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-supervisor-autonomous-responsibility-repair", + "自主构建首批职责修复测试", + ) + .expect("project init"); + let run_id = "supervisor-autonomous-responsibility-repair-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous Supervisor profile"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "并行建立程序实现与独立质量验收职责。", + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "建立首批专业职责", + Vec::new(), + ) + .expect("start autonomous Supervisor runtime"); + + let code_missing_game_index = autonomous_initial_responsibility_actions_for_test( + "创建可直接试玩的游戏实现并执行静态验证。", + &["game/main.js"], + "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", + &[], + ); + let code_read_only = autonomous_initial_responsibility_actions_for_test( + "只读检查 game/index.html,不要修改任何项目文件。", + &["game/index.html"], + "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", + &[], + ); + let mut quality_not_read_only = autonomous_initial_responsibility_actions_for_test( + "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", + &["game/index.html"], + "直接修改 game/index.html,修复试玩阻塞并重新验证。", + &[], + ); + quality_not_read_only[1].input["acceptanceCriteria"] = serde_json::json!([ + "直接写入 game/index.html 修复试玩问题", + "修改后执行静态验证并交付新 revision" + ]); + let quality_with_artifacts = autonomous_initial_responsibility_actions_for_test( + "创建或修改 game/index.html,完成可直接试玩的游戏实现并执行静态验证。", + &["game/index.html"], + "只读验收 game/index.html 的可玩性;不要修改任何项目文件。", + &["game/index.html"], + ); + let valid = valid_autonomous_initial_responsibility_actions_for_test(); + let responses = [ + ("code-missing-game-index", &code_missing_game_index), + ("code-read-only", &code_read_only), + ("quality-not-read-only", &quality_not_read_only), + ("quality-with-artifacts", &quality_with_artifacts), + ("valid-responsibilities", &valid), + ] + .into_iter() + .map(|(call_prefix, actions)| { + native_supervisor_responsibility_plan_response_for_test(call_prefix, actions) + }) + .collect::>(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_raw_responses_with_capture(responses, Some(sender)); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "supervisor-autonomous-responsibility-key", + "baseUrl": {base_url:?}, + "model": "supervisor-autonomous-responsibility-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &runtime.session_id, + run_id, + &runtime.current_task, + &[], + 1, + 0, + ) + .await + .expect("repair invalid initial responsibilities") + .expect("valid initial responsibility plan"); + assert_eq!(plan.actions, valid); + assert!(plan.response.is_empty()); + + let requests = (0..5) + .map(|_| { + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial responsibility Provider request") + }) + .collect::>(); + let delegate_function = + native_runtime_function_name("agent.delegate").expect("delegate function"); + let isolated_function = + native_runtime_function_name("agent.spawn_isolated").expect("isolated function"); + let collaboration_functions = + BTreeSet::from([delegate_function.clone(), isolated_function.clone()]); + for repair_request in &requests[1..] { + let function_names = captured_supervisor_function_names_for_test(repair_request); + assert!(function_names.contains(&delegate_function)); + assert!(function_names + .iter() + .all(|name| collaboration_functions.contains(name))); + assert!(!function_names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert!(!function_names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + } + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" + && record["runId"] == run_id + }) + .count(), + 4 + ); + assert!(!read_supervisor_collaboration_state_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read untouched collaboration state") + .has_collaboration()); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn autonomous_supervisor_delegates_failed_playtest_repair_after_collaboration() { let root = unique_project_path(); @@ -866,8 +1359,8 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat "reason": "委派质量评审 Agent", "input": { "agentId": "quality-review", - "task": "评审可玩性与闯关闭环", - "acceptanceCriteria": ["指出阻塞试玩的具体问题并给出验收结论"], + "task": "只读评审可玩性与闯关闭环,不要修改任何项目文件", + "acceptanceCriteria": ["只读指出阻塞试玩的具体问题并给出验收结论"], "expectedArtifacts": [], "repairOfDelegationId": null, "runId": null diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 1ee1c6337..5aac62950 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1495,6 +1495,60 @@ fn spawn_mock_llm_upstream_400_then_raw_response( base_url } +fn spawn_mock_llm_http_failure_then_response( + failed_status_line: &'static str, + failed_body: String, + recovered_content: String, + request_notice_sender: Option>, +) -> String { + let listener = bind_test_tcp_listener("mock durable Provider classification bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + let (mut failed_stream, _) = listener + .accept() + .expect("mock durable Provider failure accept"); + drop(read_mock_http_request(&mut failed_stream)); + if let Some(sender) = request_notice_sender.as_ref() { + let _ = sender.send(()); + } + let failed_response = format!( + "HTTP/1.1 {failed_status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + failed_body.len(), + failed_body + ); + failed_stream + .write_all(failed_response.as_bytes()) + .expect("mock durable Provider failure response"); + + let (mut recovered_stream, _) = listener + .accept() + .expect("mock durable Provider recovery accept"); + drop(read_mock_http_request(&mut recovered_stream)); + if let Some(sender) = request_notice_sender.as_ref() { + let _ = sender.send(()); + } + let recovered_body = serde_json::json!({ + "id": "chatcmpl_durable_provider_recovered", + "model": "mock-game-model", + "choices": [{ + "message": { "content": recovered_content }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } + }) + .to_string(); + let recovered_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + recovered_body.len(), + recovered_body + ); + recovered_stream + .write_all(recovered_response.as_bytes()) + .expect("mock durable Provider recovery response"); + }); + base_url +} + fn spawn_mock_llm_tool_plan_then_transient_final_reply( planning_response: String, final_response: String, 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 0734d1d9e..3ed6137d5 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 @@ -3425,14 +3425,14 @@ async fn background_agent_runtime_resumes_approved_auto_action_once_without_llm_ } #[tokio::test] -async fn background_agent_runtime_does_not_replay_empty_provider_response() { +async fn provider_retry_empty_response_closes_then_stable_retry_succeeds() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); let (sender, receiver) = mpsc::channel(); let base_url = spawn_mock_llm_server_responses_with_capture( vec![ String::new(), - final_tool_plan_response("不应发起第二次请求"), + final_tool_plan_response("空响应持久重试后已恢复"), ], Some(sender), ); @@ -3443,57 +3443,217 @@ async fn background_agent_runtime_does_not_replay_empty_provider_response() { "apiKey": "design-key", "baseUrl": {base_url:?}, "model": "design-runtime-model", - "apiKind": "openai_chat" + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 1, + "retryBackoffMs": 30000 }} }} }}"# )); - start_game_creator_agent_background_task_at( + let run_id = "design-empty-response-retry-run"; + let runtime = start_game_creator_agent_runtime_task_at( &root, "design-director", - "验证后台 Agent 空响应不会自动重放", - "design-empty-response-no-replay-run", + "验证后台 Agent 空响应进入持久重试", + run_id, + "agent-background-task", + "等待 Provider 规划", + vec!["请求并验证工具计划".to_string()], ) - .expect("start background task"); + .expect("start empty-response retry runtime"); - let request = receiver + let waiting = request_game_creator_agent_background_tool_plan_waiting_retry_for_test( + &root, + "design-director", + &runtime.session_id, + run_id, + &runtime.current_task, + &[], + 1, + 0, + ) + .await + .expect("empty response enters durable retry wait"); + let first_request = receiver .recv_timeout(Duration::from_secs(2)) - .expect("capture only empty-response Provider request"); - assert!(request.contains("POST /chat/completions HTTP/1.1")); - assert!(receiver.recv_timeout(Duration::from_millis(250)).is_err()); - let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") - .expect("read empty-response runtime") - .state; - for _ in 0..250 { - if runtime.phase == "failed" { - break; - } - std::thread::sleep(Duration::from_millis(20)); - runtime = read_game_creator_agent_runtime_at(&root, "design-director") - .expect("poll empty-response runtime") - .state; - } - assert_eq!(runtime.phase, "failed"); - assert!(runtime - .error - .as_deref() - .is_some_and(|error| error.contains("kind=empty-response"))); + .expect("capture empty-response Provider request"); + assert!(first_request.contains("POST /chat/completions HTTP/1.1")); + assert_eq!(waiting.error_kind, "empty-response"); + assert_eq!(waiting.next_attempt, 1); + assert_eq!(waiting.max_retries, 1); + assert!(provider_retry::remaining_ms(&waiting) > 0); + assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err()); + provider_retry::force_provider_retry_due_for_test_at(&root, &waiting.identity) + .expect("force empty-response retry due"); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "design-director", + &runtime.session_id, + run_id, + &runtime.current_task, + &[], + 1, + 0, + ) + .await + .expect("empty-response retry succeeds") + .expect("recovered tool plan"); + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("capture recovered Provider request"); + assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err()); + assert_eq!(plan.response, "空响应持久重试后已恢复"); + assert!( + provider_retry::read_for_run_at(&root, "design-director", run_id) + .expect("read cleared empty-response retry") + .is_none() + ); let lifecycle = read_agent_db_records_for_test(&root) .into_iter() .filter(|record| { record["recordType"] == "agent.runtime.provider_request.lifecycle" - && record["runId"] == "design-empty-response-no-replay-run" + && record["runId"] == run_id }) .collect::>(); - assert_eq!(lifecycle.len(), 2); + assert_eq!(lifecycle.len(), 4); assert_eq!(lifecycle[0]["status"], "started"); assert_eq!(lifecycle[1]["status"], "failed"); assert_eq!(lifecycle[0]["requestId"], lifecycle[1]["requestId"]); + assert_eq!(lifecycle[2]["status"], "started"); + assert_eq!(lifecycle[3]["status"], "completed"); + assert_eq!(lifecycle[2]["requestId"], lifecycle[3]["requestId"]); + assert_ne!(lifecycle[0]["requestId"], lifecycle[2]["requestId"]); fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn provider_retry_http_and_deserialize_failures_recover_through_durable_sidecar() { + let cases = vec![ + ( + "rate-limit", + "429 Too Many Requests", + serde_json::json!({"error": {"message": "rate limited"}}).to_string(), + "upstream-429", + ), + ( + "server-error", + "503 Service Unavailable", + serde_json::json!({"error": {"message": "temporarily unavailable"}}).to_string(), + "upstream-5xx", + ), + ("deserialize", "200 OK", "{".to_string(), "deserialize"), + ]; + + for (case_id, status_line, failed_body, expected_kind) in cases { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-provider-classification", + "Provider 持久分类恢复测试", + ) + .expect("provider classification project init"); + let recovered_text = format!("{case_id} 持久重试后已恢复"); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_http_failure_then_response( + status_line, + failed_body, + final_tool_plan_response(&recovered_text), + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "provider-classification-key", + "baseUrl": {base_url:?}, + "model": "provider-classification-model", + "apiKind": "openai_chat", + "stream": false, + "maxRetries": 1, + "retryBackoffMs": 30000 + }} + }} +}}"# + )); + let run_id = format!("provider-classification-{case_id}-run"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "验证 Provider 错误进入同一 durable retry", + &run_id, + "agent-background-task", + "等待 Provider 规划", + vec!["请求并验证工具计划".to_string()], + ) + .expect("start provider classification runtime"); + + let waiting = request_game_creator_agent_background_tool_plan_waiting_retry_for_test( + &root, + "design-director", + &runtime.session_id, + &run_id, + &runtime.current_task, + &[], + 1, + 0, + ) + .await + .expect("Provider failure enters durable retry wait"); + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("capture failed Provider request"); + assert_eq!(waiting.error_kind, expected_kind, "case={case_id}"); + assert_eq!(waiting.next_attempt, 1, "case={case_id}"); + assert_eq!(waiting.max_retries, 1, "case={case_id}"); + assert!(provider_retry::remaining_ms(&waiting) > 0, "case={case_id}"); + assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err()); + provider_retry::force_provider_retry_due_for_test_at(&root, &waiting.identity) + .expect("force classified Provider retry due"); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "design-director", + &runtime.session_id, + &run_id, + &runtime.current_task, + &[], + 1, + 0, + ) + .await + .expect("classified Provider retry succeeds") + .expect("classified Provider retry returns plan"); + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("capture recovered Provider request"); + assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err()); + assert_eq!(plan.response, recovered_text, "case={case_id}"); + assert!( + provider_retry::read_for_run_at(&root, "design-director", &run_id) + .expect("read cleared classified Provider retry") + .is_none() + ); + let lifecycle = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(lifecycle.len(), 4, "case={case_id}"); + assert_eq!(lifecycle[0]["status"], "started", "case={case_id}"); + assert_eq!(lifecycle[1]["status"], "failed", "case={case_id}"); + assert_eq!(lifecycle[2]["status"], "started", "case={case_id}"); + assert_eq!(lifecycle[3]["status"], "completed", "case={case_id}"); + + fs::remove_dir_all(root).ok(); + } +} + #[tokio::test] async fn provider_transient_retry_transport_failure_closes_then_stable_retry_succeeds() { let root = unique_project_path(); @@ -4564,8 +4724,8 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b "reason": "委派独立质量评审", "input": { "agentId": "quality-review", - "task": "评审可玩性与闯关闭环", - "acceptanceCriteria": ["给出阻塞试玩的问题和验收结论"], + "task": "只读评审可玩性与闯关闭环,不要修改任何项目文件", + "acceptanceCriteria": ["只读给出阻塞试玩的问题和验收结论"], "expectedArtifacts": [], "repairOfDelegationId": null, "runId": null diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs index 37ed16fc4..baaabf2a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs @@ -608,7 +608,7 @@ async fn response_stream_private_process_output_is_never_published_or_committed_ } #[tokio::test] -async fn response_stream_final_failure_is_single_attempt_and_commits_planning_fallback() { +async fn response_stream_final_failure_with_retry_disabled_commits_planning_fallback() { let root = unique_project_path(); init_local_game_project_at( &root, @@ -632,7 +632,7 @@ async fn response_stream_final_failure_is_single_attempt_and_commits_planning_fa "model": "response-stream-retry-model", "apiKind": "openai_responses", "stream": true, - "maxRetries": 7, + "maxRetries": 0, "retryBackoffMs": 1 }} }} @@ -642,7 +642,7 @@ async fn response_stream_final_failure_is_single_attempt_and_commits_planning_fa let started = start_game_creator_agent_background_task_at( &root, "design-director", - "验证 final reply 失败不在 lifecycle 内重试", + "验证关闭 retry 时 final reply 失败只发起一次请求", run_id, ) .expect("start final stream single-attempt task"); @@ -704,7 +704,11 @@ async fn response_stream_final_failure_is_single_attempt_and_commits_planning_fa ); let requests = mock.stop_and_collect(); - assert_eq!(requests.len(), 2, "final stream must not retry physically"); + assert_eq!( + requests.len(), + 2, + "disabled retry must not issue a second final stream" + ); assert_eq!( mock_http_request_json(&requests[0])["stream"], Value::Bool(false) @@ -757,6 +761,187 @@ async fn response_stream_final_failure_is_single_attempt_and_commits_planning_fa fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn provider_retry_final_reply_thinking_only_response_retries_before_handoff() { + const PRIVATE_THINKING: &str = "FINAL_REPLY_PRIVATE_THINKING_MUST_NOT_PERSIST"; + const FINAL_RESPONSE: &str = "去除 thinking 后为空的最终回复已通过持久重试恢复。"; + + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-final-reply-empty-normalization", + "最终回复规范化为空持久重试项目", + ) + .expect("thinking-only final reply project init"); + let (request_sender, request_receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![ + final_tool_plan_response(""), + format!("{PRIVATE_THINKING}"), + FINAL_RESPONSE.to_string(), + ], + Some(request_sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "final-reply-empty-normalization-key", + "baseUrl": {base_url:?}, + "model": "final-reply-empty-normalization-model", + "apiKind": "openai_responses", + "stream": false, + "maxRetries": 1, + "retryBackoffMs": 30000 + }} + }} +}}"# + )); + let run_id = "provider-retry-final-reply-empty-normalization-run"; + let started = start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证 final reply 在 durable handoff 前完成去 thinking 和非空校验", + run_id, + ) + .expect("start thinking-only final reply task"); + + request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("tool-plan Provider request"); + request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("thinking-only final-reply Provider request"); + let mut retry = None; + for _ in 0..250 { + retry = crate::provider_retry::read_for_run_at(&root, "design-director", run_id) + .expect("read thinking-only final reply retry"); + if retry.is_some() + && game_creator_agent_runtime_task_lock_is_available(&root, "design-director") + .expect("probe thinking-only final reply lane") + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + let retry = retry.expect("thinking-only final reply must persist retry sidecar"); + assert_eq!(retry.identity.request_kind, "final-reply"); + assert_eq!(retry.error_kind, "empty-response"); + assert_eq!(retry.next_attempt, 1); + assert!(retry.next_request_slot.ends_with("-transient-1")); + assert!(crate::provider_retry::remaining_ms(&retry) > 0); + assert!( + provider_handoff::read_for_run_at(&root, "design-director", run_id) + .expect("read absent thinking-only final reply handoff") + .is_none(), + "normalized empty final reply must fail before durable handoff" + ); + + let before_retry_records = read_agent_db_records_for_test(&root); + let before_retry_lifecycle = before_retry_records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + && record["requestKind"] == "final-reply" + }) + .collect::>(); + assert_eq!(before_retry_lifecycle.len(), 2); + assert_eq!(before_retry_lifecycle[0]["status"], "started"); + assert_eq!(before_retry_lifecycle[1]["status"], "failed"); + assert!(before_retry_lifecycle + .iter() + .all(|record| record["status"] != "completed")); + let before_retry_conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&started.state.session_id), + ) + .expect("read conversation before final reply retry"); + assert!(before_retry_conversation + .messages + .iter() + .all(|message| message.role != "assistant")); + + crate::provider_retry::force_provider_retry_due_for_test_at(&root, &retry.identity) + .expect("force thinking-only final reply retry due"); + resume_game_creator_agent_background_tasks_at(&root) + .expect("resume thinking-only final reply retry"); + request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("recovered final-reply Provider request"); + assert!(request_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + + let completed = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.last_response.as_deref(), Some(FINAL_RESPONSE)); + assert!( + crate::provider_retry::read_for_run_at(&root, "design-director", run_id) + .expect("read cleared thinking-only final reply retry") + .is_none() + ); + assert!( + provider_handoff::read_for_run_at(&root, "design-director", run_id) + .expect("read cleared thinking-only final reply handoff") + .is_none() + ); + let committed = + wait_for_response_stream_status(&root, "design-director", run_id, "committed", 1); + assert_eq!(committed.accumulated_text, FINAL_RESPONSE); + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&started.state.session_id), + ) + .expect("read recovered final reply conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(), + vec![FINAL_RESPONSE] + ); + + let records = read_agent_db_records_for_test(&root); + let final_reply_lifecycle = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + && record["requestKind"] == "final-reply" + }) + .collect::>(); + assert_eq!(final_reply_lifecycle.len(), 4); + assert_eq!(final_reply_lifecycle[0]["status"], "started"); + assert_eq!(final_reply_lifecycle[1]["status"], "failed"); + assert_eq!(final_reply_lifecycle[2]["status"], "started"); + assert_eq!(final_reply_lifecycle[3]["status"], "completed"); + let retry_audits = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.retry" + && record["runId"] == run_id + && record["requestKind"] == "final-reply" + }) + .collect::>(); + assert_eq!(retry_audits.len(), 1); + assert_eq!(retry_audits[0]["errorKind"], "empty-response"); + let persisted = format!( + "{}\n{}\n{}", + serde_json::to_string(&completed).expect("serialize completed Runtime"), + fs::read_to_string(root.join(".agent/runtime/events/design-director.jsonl")) + .expect("read final reply events"), + fs::read_to_string(root.join(".agent/agent.db")).expect("read final reply Agent DB"), + ); + assert!(!persisted.contains(PRIVATE_THINKING)); + + fs::remove_dir_all(root).ok(); +} + #[test] fn visual_specialist_finalization_requires_existing_registered_canvas_image() { for (agent_id, local_path, kind) in [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs index bc74b5015..aaa22475b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs @@ -1,10 +1,14 @@ use super::super::support::*; use crate::{ active_static_delegate_delivery_count_at, bind_supervisor_collaboration_policy_snapshot_at, - create_or_read_static_delegate_delivery_at, mark_static_delegate_delivery_ready_at, - new_static_delegate_delivery, observe_agent_runtime_run_status, + build_static_delegate_structured_result_at, claim_ready_static_delegate_receipts_at, + create_or_read_static_delegate_delivery_at, mark_static_delegate_claim_observed_at, + mark_static_delegate_delivery_ready_at, mark_static_delegate_delivery_ready_with_result_at, + new_static_delegate_delivery, new_static_delegate_delivery_with_contract, + observe_agent_runtime_run_status, refresh_agent_runtime_autonomous_convergence_snapshot_after_provider_at, static_delegate_completion_barrier_at, validate_agent_runtime_autonomous_plan_liveness, + StaticDelegateContractStatus, }; #[test] @@ -163,6 +167,553 @@ fn autonomous_game_build_profile_auto_grants_only_scoped_build_actions() { fs::remove_dir_all(root).ok(); } +fn captured_native_function_names_for_test(request: &str) -> BTreeSet { + mock_http_request_json(request)["tools"] + .as_array() + .expect("captured native function tools") + .iter() + .filter_map(|tool| { + tool.get("name") + .and_then(serde_json::Value::as_str) + .or_else(|| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + }) + }) + .map(str::to_string) + .collect() +} + +fn bind_autonomous_specialist_runtime_for_test( + root: &Path, + parent_run_id: &str, + agent_id: &str, + child_run_id: &str, + task: &str, +) -> AgentRuntimeState { + bind_game_creator_agent_runtime_run_profile_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous parent profile"); + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(format!("{child_run_id}-delivery")), + }; + bind_game_creator_agent_runtime_run_profile_at( + root, + agent_id, + child_run_id, + "agent-delegate", + None, + Some(&child_link), + ) + .expect("bind autonomous specialist profile"); + start_game_creator_agent_runtime_task_at( + root, + agent_id, + task, + child_run_id, + "agent-delegate", + "执行专业交付", + Vec::new(), + ) + .expect("start autonomous specialist runtime") +} + +#[tokio::test] +async fn autonomous_game_build_non_read_only_code_first_round_repairs_response_into_mutation_only() +{ + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-code-first-round-mutation", + "自主构建程序首轮修改合同测试", + ) + .expect("project init"); + let child_run_id = "autonomous-code-first-round-mutation-child"; + let task = "实现并修改 game/index.html,交付可直接试玩的完整游戏入口。"; + + let response_arguments = serde_json::json!({ + "response": "入口已经完成,可以直接交付。" + }) + .to_string(); + let write_arguments = serde_json::json!({ + "reason": "按非只读程序合同立即落地游戏入口", + "input": { + "path": "game/index.html", + "content": "" + } + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-autonomous-code-first-round-response", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + response_arguments, + ), + native_agent_tool_plan_chat_response( + "call-autonomous-code-first-round-write", + &native_runtime_function_name("file.write").expect("write function"), + write_arguments, + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "autonomous-code-first-round-key", + "baseUrl": {base_url:?}, + "model": "autonomous-code-first-round-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + let runtime = bind_autonomous_specialist_runtime_for_test( + &root, + "autonomous-code-first-round-mutation-parent", + "code-prototype", + child_run_id, + task, + ); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "code-prototype", + &runtime.session_id, + child_run_id, + task, + &[], + 1, + 0, + ) + .await + .expect("repair first-round response") + .expect("first-round mutation plan"); + assert!(plan.response.is_empty()); + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].tool, "file.write"); + assert_eq!(plan.actions[0].input["path"], "game/index.html"); + + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial first-round response request"); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first-round mutation-only repair request"); + let repair_function_names = captured_native_function_names_for_test(&repair_request); + let mutation_function_names = [ + "file.write", + "file.patch", + "file.delete", + "project.patchset", + "project.restore", + "canvas.asset_generate", + ] + .into_iter() + .map(|tool| native_runtime_function_name(tool).expect("mutation function")) + .collect::>(); + assert!(repair_function_names + .contains(&native_runtime_function_name("file.write").expect("write function"))); + assert!(repair_function_names + .iter() + .all(|name| mutation_function_names.contains(name))); + assert!(!repair_function_names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert!(!repair_function_names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + assert!(read_game_creator_agent_runtime_verification_gate( + &root, + "code-prototype", + child_run_id, + ) + .expect("read untouched first-round verification gate") + .mutation_revision + .is_none()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn autonomous_game_build_unverified_mutation_immediately_repairs_into_verification_only() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-code-unverified-mutation", + "自主构建程序修改后立即验证测试", + ) + .expect("project init"); + let child_run_id = "autonomous-code-unverified-mutation-child"; + let task = "实现 game/index.html,并在修改后完成真实静态验证。"; + + let response_arguments = serde_json::json!({ + "response": "项目修改已经完成,可以直接交付。" + }) + .to_string(); + let verify_arguments = serde_json::json!({ + "reason": "立即验证刚完成修改的当前 revision", + "input": {"commandId": "game.static_smoke"} + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-autonomous-code-unverified-response", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + response_arguments, + ), + native_agent_tool_plan_chat_response( + "call-autonomous-code-unverified-static-smoke", + &native_runtime_function_name("command.run_limited") + .expect("limited command function"), + verify_arguments, + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "code-prototype": {{ + "apiKey": "autonomous-code-unverified-key", + "baseUrl": {base_url:?}, + "model": "autonomous-code-unverified-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + let runtime = bind_autonomous_specialist_runtime_for_test( + &root, + "autonomous-code-unverified-mutation-parent", + "code-prototype", + child_run_id, + task, + ); + let mutation_revision = prepare_agent_runtime_project_mutation_locked( + &root, + "code-prototype", + child_run_id, + "file.write", + ) + .expect("record unverified project mutation"); + let observations = vec![AgentRuntimeToolObservation { + tool: "file.write".to_string(), + status: "ok".to_string(), + summary: "已写入 game/index.html".to_string(), + detail: None, + }]; + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "code-prototype", + &runtime.session_id, + child_run_id, + task, + &observations, + 2, + 0, + ) + .await + .expect("repair immediate unverified completion") + .expect("verification-only plan"); + assert!(plan.response.is_empty()); + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].tool, "command.run_limited"); + + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial unverified response request"); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("immediate verification-only repair request"); + let repair_function_names = captured_native_function_names_for_test(&repair_request); + assert_eq!( + repair_function_names, + ["project.verify", "command.run_limited"] + .into_iter() + .map(|tool| native_runtime_function_name(tool).expect("verification function")) + .collect::>() + ); + assert!(!repair_function_names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert!(!repair_function_names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + let gate = + read_game_creator_agent_runtime_verification_gate(&root, "code-prototype", child_run_id) + .expect("read unverified mutation gate"); + assert_eq!(gate.mutation_revision, Some(mutation_revision)); + assert_ne!(gate.verified_revision, Some(mutation_revision)); + + fs::remove_dir_all(root).ok(); +} + +async fn assert_autonomous_repair_waits_for_receipt_observation_for_test(unobserved_claim: bool) { + let root = unique_project_path(); + let case_name = if unobserved_claim { + "unobserved" + } else { + "ready-unclaimed" + }; + init_local_game_project_at( + &root, + &format!("project-autonomous-repair-{case_name}"), + "自主构建返工回执顺序测试", + ) + .expect("project init"); + write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) + .expect("write default collaboration policy"); + let run_id = format!("autonomous-repair-{case_name}-run"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous Supervisor profile"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "先认领或重放专业回执,再发起唯一返工。", + &run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "收束专业 Agent 回执", + Vec::new(), + ) + .expect("start autonomous Supervisor runtime"); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration policy snapshot"); + + let acceptance_criteria = vec!["必须交付缺失的程序修复证据".to_string()]; + let expected_artifacts = vec!["game/missing-repair-evidence.txt".to_string()]; + let original_delegation_id = format!("autonomous-repair-{case_name}-original"); + let original = new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &runtime.session_id, + &run_id, + &format!("autonomous-repair-{case_name}-original-action"), + &original_delegation_id, + "code-prototype", + &format!("autonomous-repair-{case_name}-code-session"), + &format!("autonomous-repair-{case_name}-code-run"), + &acceptance_criteria, + &expected_artifacts, + None, + ); + create_or_read_static_delegate_delivery_at(&root, &original) + .expect("create needs-repair delivery"); + let needs_repair_result = build_static_delegate_structured_result_at( + &root, + "completed", + &expected_artifacts, + false, + None, + None, + None, + None, + ) + .expect("build needs-repair result"); + assert_eq!( + needs_repair_result.contract_status, + StaticDelegateContractStatus::NeedsRepair + ); + mark_static_delegate_delivery_ready_with_result_at( + &root, + "code-prototype", + &original.target_session_id, + &original.target_run_id, + &original_delegation_id, + "completed", + "缺少合同证据,需要唯一返工", + needs_repair_result, + ) + .expect("mark needs-repair delivery ready"); + let claim_action_id = format!("autonomous-repair-{case_name}-claim"); + let claimed = claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &run_id, + &claim_action_id, + ) + .expect("claim needs-repair receipt"); + assert_eq!(claimed.len(), 1); + if !unobserved_claim { + assert!(mark_static_delegate_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &run_id, + &claim_action_id, + ) + .expect("observe needs-repair claim")); + let ready = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &runtime.session_id, + &run_id, + "autonomous-repair-ready-action", + "autonomous-repair-ready-delivery", + "quality-review", + "autonomous-repair-ready-session", + "autonomous-repair-ready-run", + ); + create_or_read_static_delegate_delivery_at(&root, &ready) + .expect("create additional ready delivery"); + mark_static_delegate_delivery_ready_at( + &root, + "quality-review", + "autonomous-repair-ready-session", + "autonomous-repair-ready-run", + "autonomous-repair-ready-delivery", + "completed", + "质量审查已经完成,等待认领", + ) + .expect("mark additional delivery ready"); + } + let barrier = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &run_id, + ) + .expect("read mixed repair barrier"); + assert_eq!(barrier.repair_required_count, 1); + if unobserved_claim { + assert_eq!(barrier.ready_unclaimed_count, 0); + assert_eq!(barrier.unobserved_claim_count, 1); + } else { + assert_eq!(barrier.ready_unclaimed_count, 1); + assert_eq!(barrier.unobserved_claim_count, 0); + } + + let repair_arguments = serde_json::json!({ + "reason": "误在回执尚未完整观察时先发起返工", + "input": { + "agentId": "code-prototype", + "task": "补齐缺失的程序修复证据。", + "acceptanceCriteria": acceptance_criteria, + "expectedArtifacts": expected_artifacts, + "repairOfDelegationId": original_delegation_id, + "runId": null + } + }) + .to_string(); + let run_status_arguments = serde_json::json!({ + "reason": "先认领或重放当前父 run 尚未完整观察的回执", + "input": { + "agentId": null, + "scope": "all", + "delegationId": null + } + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + &format!("call-autonomous-repair-{case_name}-delegate"), + &native_runtime_function_name("agent.delegate").expect("delegate function"), + repair_arguments, + ), + native_agent_tool_plan_chat_response( + &format!("call-autonomous-repair-{case_name}-run-status"), + &native_runtime_function_name("agent.run_status").expect("run status function"), + run_status_arguments, + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "autonomous-repair-{case_name}-key", + "baseUrl": {base_url:?}, + "model": "autonomous-repair-{case_name}-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &runtime.session_id, + &run_id, + &runtime.current_task, + &[], + 2, + 0, + ) + .await + .expect("repair premature repair delegation") + .expect("run-status convergence plan"); + assert!(plan.response.is_empty()); + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].tool, "agent.run_status"); + assert_eq!(plan.actions[0].input["scope"], "all"); + + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial premature repair request"); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("receipt convergence repair request"); + assert!(repair_request.contains("repairRequired=1")); + if unobserved_claim { + assert!(repair_request.contains("unobservedReceiptClaims=1")); + } else { + assert!(repair_request.contains("readyUnclaimedReceipts=1")); + } + assert_eq!( + captured_native_function_names_for_test(&repair_request), + BTreeSet::from([ + native_runtime_function_name("agent.run_status").expect("run status function") + ]) + ); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + assert_eq!( + static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &run_id, + ) + .expect("read unchanged mixed repair barrier"), + barrier + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn autonomous_game_build_repair_waits_for_ready_unclaimed_receipt() { + assert_autonomous_repair_waits_for_receipt_observation_for_test(false).await; +} + +#[tokio::test] +async fn autonomous_game_build_repair_replays_unobserved_claim_before_delegating() { + assert_autonomous_repair_waits_for_receipt_observation_for_test(true).await; +} + #[test] fn standard_profile_keeps_confirmation_policy_unchanged() { let root = unique_project_path(); @@ -528,7 +1079,152 @@ async fn autonomous_game_build_repairs_explicit_read_only_loop_into_completed_de } #[tokio::test] -async fn autonomous_game_build_repairs_final_response_with_incomplete_plan() { +async fn autonomous_game_build_read_only_delivery_rejects_mutation_before_execution() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-autonomous-read-only-mutation-block", + "自主构建只读写入阻断测试", + ) + .expect("project init"); + let parent_run_id = "autonomous-read-only-mutation-parent"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous parent profile"); + let child_run_id = "autonomous-read-only-mutation-child"; + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some("autonomous-read-only-mutation-delivery".to_string()), + }; + bind_game_creator_agent_runtime_run_profile_at( + &root, + "quality-review", + child_run_id, + "agent-delegate", + None, + Some(&child_link), + ) + .expect("bind autonomous child profile"); + let index_path = root.join("game/index.html"); + let index_before = fs::read(&index_path).ok(); + let mutation_arguments = serde_json::json!({ + "reason": "错误地尝试由质量 Agent 写入游戏入口", + "input": { + "path": "game/index.html", + "content": "quality mutation" + } + }) + .to_string(); + let response = serde_json::json!({ + "response": "只读验收已完成;实现工作必须由程序 Agent 负责。" + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-autonomous-read-only-forbidden-write", + &native_runtime_function_name("file.write").expect("write function"), + mutation_arguments, + ), + native_agent_tool_plan_chat_response( + "call-autonomous-read-only-safe-response", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + response, + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "quality-review": {{ + "apiKey": "autonomous-read-only-mutation-key", + "baseUrl": {base_url:?}, + "model": "autonomous-read-only-mutation-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "quality-review", + "只读验收玩法合同,不要修改任何项目文件;完成后返回阻塞问题和证据。", + child_run_id, + "agent-delegate", + "开始只读验收", + vec!["核对合同并回传结论".to_string()], + ) + .expect("start autonomous read-only runtime"); + + let plan = request_game_creator_agent_background_tool_plan_for_test( + &root, + "quality-review", + &runtime.session_id, + child_run_id, + &runtime.current_task, + &[], + 1, + 0, + ) + .await + .expect("repair forbidden read-only mutation") + .expect("read-only response plan"); + assert!(plan.actions.is_empty()); + assert_eq!( + plan.response, + "只读验收已完成;实现工作必须由程序 Agent 负责。" + ); + assert_eq!(fs::read(&index_path).ok(), index_before); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read unchanged project revision") + .revision, + 0 + ); + + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial read-only mutation request"); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("read-only mutation repair request"); + assert!(repair_request.contains("只读专业 Agent 禁止执行写入或副作用动作")); + let repair_request_json = mock_http_request_json(&repair_request); + let repair_function_names = repair_request_json["tools"] + .as_array() + .expect("read-only mutation repair tools") + .iter() + .filter_map(|tool| { + tool.get("name") + .and_then(serde_json::Value::as_str) + .or_else(|| { + tool.get("function") + .and_then(|function| function.get("name")) + .and_then(serde_json::Value::as_str) + }) + }) + .collect::>(); + assert_eq!( + repair_function_names, + BTreeSet::from([AGENT_RUNTIME_RESPOND_FUNCTION_NAME]) + ); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn autonomous_game_build_explicit_read_only_response_defers_plan_completion_to_main_loop() { let root = unique_project_path(); init_local_game_project_at( &root, @@ -561,49 +1257,17 @@ async fn autonomous_game_build_repairs_final_response_with_incomplete_plan() { Some(&child_link), ) .expect("bind autonomous child profile"); - let read_arguments = serde_json::json!({"reason": "继续重复读取项目", "input": {}}).to_string(); let incomplete_response = serde_json::json!({ "response": "只读审查已经完成,当前入口缺少可玩状态合同。" }) .to_string(); - let completed_plan = serde_json::json!({ - "explanation": "只读审查和结论整理均已完成", - "steps": [ - {"step": "审查入口", "status": "completed"}, - {"step": "回传结论", "status": "completed"} - ] - }) - .to_string(); - let repaired_response = serde_json::json!({ - "response": "只读审查已经完成,当前入口缺少可玩状态合同。" - }) - .to_string(); let (sender, receiver) = mpsc::channel(); let base_url = spawn_mock_llm_raw_responses_with_capture( - vec![ - native_agent_tool_plan_chat_response( - "call-autonomous-response-plan-read", - &native_runtime_function_name("project.index").expect("index function"), - read_arguments, - ), - native_agent_tool_plan_chat_response( - "call-autonomous-response-only", - AGENT_RUNTIME_RESPOND_FUNCTION_NAME, - incomplete_response, - ), - native_agent_tool_plan_chat_response_with_calls(vec![ - ( - "call-autonomous-plan-completed", - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, - completed_plan, - ), - ( - "call-autonomous-response-repaired", - AGENT_RUNTIME_RESPOND_FUNCTION_NAME, - repaired_response, - ), - ]), - ], + vec![native_agent_tool_plan_chat_response( + "call-autonomous-response-only", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + incomplete_response, + )], Some(sender), ); let _config_guard = write_test_local_config(format!( @@ -622,7 +1286,7 @@ async fn autonomous_game_build_repairs_final_response_with_incomplete_plan() { let mut runtime = start_game_creator_agent_runtime_task_at( &root, "quality-review", - "Perform an independent quality review and apply narrowly scoped fixes when needed; then report blockers.", + "Perform an independent read-only quality review without modifying project files; then report blockers.", child_run_id, "agent-delegate", "开始只读审查", @@ -656,75 +1320,22 @@ async fn autonomous_game_build_repairs_final_response_with_incomplete_plan() { child_run_id, &runtime.current_task, &[], - AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 1, 0, ) .await - .expect("repair autonomous response plan") - .expect("repaired response plan"); + .expect("accept autonomous read-only response") + .expect("read-only response plan"); assert_eq!( plan.response, "只读审查已经完成,当前入口缺少可玩状态合同。" ); assert!(plan.actions.is_empty()); - assert!(plan.plan_update.as_ref().is_some_and(|update| { - update - .steps - .iter() - .all(|step| step.status == AGENT_RUNTIME_PLAN_STATUS_COMPLETED) - })); + assert!(plan.plan_update.is_none()); receiver .recv_timeout(Duration::from_secs(2)) .expect("initial autonomous response request"); - let action_repair_request = receiver - .recv_timeout(Duration::from_secs(2)) - .expect("autonomous forced-action repair request"); - assert!(action_repair_request.contains("首次项目修改前最多允许")); - let action_repair_request_json = mock_http_request_json(&action_repair_request); - let action_repair_function_names = action_repair_request_json["tools"] - .as_array() - .expect("forced-action repair tools") - .iter() - .filter_map(|tool| { - tool.get("name") - .and_then(serde_json::Value::as_str) - .or_else(|| { - tool.get("function") - .and_then(|function| function.get("name")) - .and_then(serde_json::Value::as_str) - }) - }) - .collect::>(); - assert!(!action_repair_function_names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); - assert!(action_repair_function_names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); - let repair_request = receiver - .recv_timeout(Duration::from_secs(2)) - .expect("autonomous response-plan repair request after forced-action repair"); - assert!(repair_request.contains("已给出结论但结构化计划仍未完成")); - assert!(repair_request.contains("必须在同一响应先调用 update_agent_plan")); - let repair_request_json = mock_http_request_json(&repair_request); - let repair_function_names = repair_request_json["tools"] - .as_array() - .expect("response-plan repair tools") - .iter() - .filter_map(|tool| { - tool.get("name") - .and_then(serde_json::Value::as_str) - .or_else(|| { - tool.get("function") - .and_then(|function| function.get("name")) - .and_then(serde_json::Value::as_str) - }) - }) - .collect::>(); - assert_eq!( - repair_function_names, - BTreeSet::from([ - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, - AGENT_RUNTIME_RESPOND_FUNCTION_NAME, - ]) - ); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); fs::remove_dir_all(root).ok(); @@ -1822,7 +2433,7 @@ async fn autonomous_game_build_repairs_pre_mutation_read_loop_into_action() { }) }) .collect::>(); - assert!(repair_function_names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert!(!repair_function_names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); assert!(repair_function_names.contains( native_runtime_function_name("file.write") .expect("write function") diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 5d856dff5..e5224c70a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -14,13 +14,12 @@ pub(super) use super::super::{ append_auto_tool_action_audit_pair_for_test, assert_auto_tool_action_audit_pair, assert_pending_runtime_decision_revalidates_after_lock, assert_task_status, fake_llm_game_draft, final_tool_plan_response, mock_http_request_json, - native_agent_tool_plan_chat_response, native_agent_tool_plan_chat_response_with_calls, - pending_tool_action_for_test, persist_needs_reconciliation_runtime_for_test, - persist_project_verification_for_test, read_agent_db_records_for_test, - register_canvas_visual_asset_fixture, spawn_barrier_mock_llm_server, - spawn_interruptible_mock_llm_server_with_capture, spawn_mock_llm_raw_responses_with_capture, - spawn_mock_llm_server, spawn_mock_llm_server_responses, - spawn_mock_llm_server_responses_with_capture, + native_agent_tool_plan_chat_response, pending_tool_action_for_test, + persist_needs_reconciliation_runtime_for_test, persist_project_verification_for_test, + read_agent_db_records_for_test, register_canvas_visual_asset_fixture, + spawn_barrier_mock_llm_server, spawn_interruptible_mock_llm_server_with_capture, + spawn_mock_llm_raw_responses_with_capture, spawn_mock_llm_server, + spawn_mock_llm_server_responses, spawn_mock_llm_server_responses_with_capture, spawn_releasable_mock_llm_server_responses_with_capture, spawn_releasable_mock_llm_server_responses_with_capture_at, start_agent_runtime_steer_fixture, ui_prototype_assessment_fixture, unique_project_path, use_test_runtime_config_dir, diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index d81a2e03e..4ed29e95d 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -667,6 +667,9 @@ export function matchingAgentRuntimeForSteer( runtimes: Array, agentId: string, sessionId: string | null, + requestedRunProfile: NonNullable< + AgentRuntimeState['runProfile'] + > = 'standard', ) { if (!sessionId) { return null; @@ -676,6 +679,7 @@ export function matchingAgentRuntimeForSteer( (runtime) => runtime?.agentId === agentId && runtime.sessionId === sessionId && + (runtime.runProfile ?? 'standard') === requestedRunProfile && isAgentRuntimeSteerableState(runtime), ) ?? null ); @@ -772,6 +776,7 @@ export async function submitProjectSupervisorRuntimeTask({ [runtime], PROJECT_SUPERVISOR_AGENT_ID, sessionId, + runProfile, ); if (steerRuntime) { const steer = await invoke( @@ -783,6 +788,7 @@ export async function submitProjectSupervisorRuntimeTask({ runId: steerRuntime.runId, steerId: createAgentChatRunId('project-supervisor-steer'), instruction: prompt, + runProfile, }, ); return { mode: 'steer' as const, runtimeResult: steer.runtime }; diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 5535d1d9f..ae1026442 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -486,6 +486,7 @@ function createProjectSupervisorRuntimeHarness({ ); currentRuntime = runtimeState({ runId, + runProfile: args?.runProfile, status: 'running', phase: 'planning', currentTask: String(args?.task ?? ''), 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 0ee8d0236..3f129ef63 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 @@ -1152,6 +1152,7 @@ export function registerProjectSupervisorSurfaceTests() { runId, steerId: expect.stringMatching(/^project-supervisor-steer-/), instruction: '补充:优先复用现有素材', + runProfile: 'autonomous-game-build', }, ); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/supervisor-runtime.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/supervisor-runtime.suite.ts index 80f67e8c5..459e7bbc1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/supervisor-runtime.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/supervisor-runtime.suite.ts @@ -406,8 +406,15 @@ export function registerSupervisorRuntimeTests() { ); }); - it('steers the running Project Supervisor run without changing run or Session', async () => { - const harness = createProjectSupervisorRuntimeHarness(); + it('starts a new autonomous run instead of steering an active standard Project Supervisor run', async () => { + const activeRunId = 'supervisor-standard-active-run'; + const harness = createProjectSupervisorRuntimeHarness({ + initialRuntime: { + runId: activeRunId, + status: 'running', + phase: 'planning', + }, + }); window.__TAURI__ = { core: { invoke: harness.invoke }, event: { listen: harness.listen }, @@ -421,7 +428,7 @@ export function registerSupervisorRuntimeTests() { ), ); - submitChat('先完成玩法拆解'); + submitChat('切换为自主构建并完成可玩原型'); await waitFor(() => { expect( harness.invoke.mock.calls.filter( @@ -433,13 +440,42 @@ export function registerSupervisorRuntimeTests() { const startCall = harness.invoke.mock.calls.find( ([command]) => command === 'start_game_creator_supervisor_runtime_task', ); - const startedRunId = String(startCall?.[1]?.runId ?? ''); - await waitFor(() => { - expect( - (screen.getByRole('button', { name: '发送' }) as HTMLButtonElement) - .disabled, - ).toBe(false); + expect(startCall?.[1]).toMatchObject({ + projectPath: harness.projectPath, + sessionId: harness.sessionId, + task: '切换为自主构建并完成可玩原型', + runProfile: 'autonomous-game-build', }); + expect(String(startCall?.[1]?.runId ?? '')).not.toBe(activeRunId); + expect( + harness.invoke.mock.calls.filter( + ([command]) => command === 'steer_game_creator_agent_runtime_task', + ), + ).toHaveLength(0); + }); + + it('steers an active autonomous Project Supervisor run for another autonomous request', async () => { + const activeRunId = 'supervisor-autonomous-active-run'; + const harness = createProjectSupervisorRuntimeHarness({ + initialRuntime: { + runId: activeRunId, + runProfile: 'autonomous-game-build', + status: 'running', + phase: 'planning', + }, + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + renderAppAt('/'); + await openMainProject(harness.projectPath); + await waitFor(() => + expect(harness.invoke).toHaveBeenCalledWith( + 'read_game_creator_agent_runtime', + expect.objectContaining({ sessionId: harness.sessionId }), + ), + ); submitChat('补充:先检查已有素材'); @@ -450,9 +486,10 @@ export function registerSupervisorRuntimeTests() { projectPath: harness.projectPath, agentId: 'project-supervisor', sessionId: harness.sessionId, - runId: startedRunId, + runId: activeRunId, steerId: expect.stringMatching(/^project-supervisor-steer-/), instruction: '补充:先检查已有素材', + runProfile: 'autonomous-game-build', }, ); }); @@ -460,7 +497,7 @@ export function registerSupervisorRuntimeTests() { harness.invoke.mock.calls.filter( ([command]) => command === 'start_game_creator_supervisor_runtime_task', ), - ).toHaveLength(1); + ).toHaveLength(0); }); it('reopens merged legacy and Project Supervisor history in stable unique order', async () => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3b0596e0c..c52fbca90 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5275,3 +5275,10 @@ - 决策:`autonomous-game-build` 只允许固定 auto-safe 白名单消除默认确认;其余任何本地或 MCP 动态确认结果统一失败关闭为 `Denied`。批次含拒绝成员时先完整预检并持久化 `aborted`,整批工具保持零执行,再把拒绝 observation 交回同一 Session/run 重新规划。标准 profile 和显式 deny 保持原合同。 - 恢复:旧自主 `pending-confirmation` 必须在校验持久 Run Profile、Runtime、Session/run、action identity 和 batch member 后迁移。先原子写 `aborted` batch,再写 `observed-rejected` pending 镜像;两次写入之间退出时由 batch 重建 pending。恢复后的状态和审计使用 `runtime-policy-rejected`,不能误报自动动作已执行或开发者主动拒绝。 - 验收:自主构建测试 `20/20`、确认测试 `18/18`、旧等待批次完整恢复、批次零副作用、`cargo check --tests`、Rust 串行全量 `1149 passed / 5 ignored / 0 failed` 均通过。确定性 Runner + Chrome E2E 为 `17/17` Provider lifecycle、revision `0 -> 2`、试玩 `37/37`;独立外部 Provider E2E 为 `62/62`、revision `0 -> 5`、`game/index.html=7816 bytes`、试玩 `37/37`,两轮人工输入、等待态、残留、重复与泄漏均为 `0`。 + +## 2026-07-22 自主构建首批职责和专业交付按 durable 合同收束 + +- 背景:此前首批只固定 `code-prototype / quality-review` 两个 Agent ID,没有固定两者职责。质量 Agent 可能先写验证脚本推进全局 revision,程序 Agent 的旧写动作随即过期;非只读专业 Agent 也可能在零 mutation 或未验证时仅返回文字完成。父 run 同时看到 repairRequired 与 ready/unobserved receipt 时还可能先发 repair,跳过权威交付收束。 +- 决策:`autonomous-game-build` initial wave 中,程序任务必须非只读且 `expectedArtifacts` 包含 `game/index.html`;质量任务必须显式只读、不得修改项目且 `expectedArtifacts=[]`。Provider 计划解析、batch prepare 与 durable batch 恢复均重验同一合同;Provider action batch 升级为 v3,仅 v3 应用新职责,升级前 v2 collaboration batch 与 v1 contractless batch 继续按原 fingerprint 和合同恢复。只读 specialist 的计划只允许纯读取与状态观察,任何文件、revision、命令、任务、记忆、黑板、资产或委派副作用都在执行前拒绝,格式修复只保留 `respond_to_user`。非只读 specialist 只有本人 run 已产生 mutation 且对应 revision 验证通过后才能回复;ready 未认领或 claim 未 observed 时只允许先执行 `agent.run_status`。 +- 语义边界:只读识别接受明确只读审查/验收和不得修改指令,不再把任意“只读”子串视为只读合同。`非只读 / 不要只读 / not read-only` 必须保持可修改;否则模型照抄修复提示中的“非只读实现任务”会永久触发同一格式修复错误。 +- 验收:阻塞终审修复前 Rust 串行全量为 `1163 passed / 5 ignored / 0 failed`;补入只读写入与 v3/v2/v1 恢复回归后共运行 1169 项并以退出码 `0` 完成,其中 5 项真实浏览器环境用例 ignored。Provider `148/148`、collaboration `142/142`、swarm CLI `37/37`、autonomous `24/24` 和 App Surface `294/294` 通过。当前树确定性 Runner + Chrome 为 `17/17` lifecycle、revision `0 -> 2`、试玩 `37/37`。最新独立真实外部轮次单次输入后立即 EOF,一个原始专业任务失败后由唯一 repair 恢复,父 Supervisor completed,revision `0 -> 6`、`game/index.html=8080 bytes`、真实 Chrome `37/37`、唯一 Supervisor assistant;`88` 个 lifecycle 全部 terminal,`75 completed / 13 failed` 和 `12` 条 durable retry audit 保留真实失败证据并自行恢复,终局人工输入、open lifecycle、sidecar、reconciliation、重复与泄漏均为 `0`,Runner、项目和隔离 AppData 自动清理。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0a460828e..6ac0fd53f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3551,3 +3551,12 @@ - 处理:最终合并后的本地/MCP policy block 必须再次经过持久 Run Profile gate;自主 profile 的所有剩余确认统一转为 deny,混合批次整体零执行并同 run 重规划。恢复迁移以 aborted batch 为提交点、pending 为镜像,并把自动策略拒绝显式审计为 `runtime-policy-rejected`。 - 验证:同时覆盖 auto-safe 白名单、显式 deny、动态确认失败关闭、标准 profile 不变、完整恢复扫描、前缀副作用未发生、Session/run/profile identity 不变和 replacement planning 已发出;最后必须重新运行确定性与外部 Provider 的单输入可玩 E2E。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs`。 + +## 只读职责不能用无边界关键词子串判定 + +- 现象:真实 Provider 已连续返回首批 `code-prototype / quality-review` 委派,但四次格式修复都报“code-prototype 必须是非只读实现任务”;Provider lifecycle 全部正常完成,首批 batch 却始终无法建立,父 run 在 revision 0 失败。 +- 原因:只读分类器用 `contains("只读")` 判断任务。模型按修复提示把程序任务写成“非只读实现任务”或“不要只读检查”,否定式文本仍命中“只读”子串,因此同一正确修复会被永久拒绝。 +- 处理:只接受明确的“只读审查 / 只读检查 / 只读验收 / 不得修改项目”等正向合同;判定前剥离 `非只读 / 不要只读 / 不是只读 / non-read-only / not read-only` 等否定式标签。首批角色事实继续以 durable delivery 的 target 与 expectedArtifacts 为准,不能只依赖易漂移的任务文案。文本合同还必须落实到 Provider 计划边界:只读 delivery 只允许纯读取和状态观察,写文件、启动命令、推进 revision、修改任务/记忆/黑板/资产或继续委派必须在任何执行前拒绝,并只允许修复成 `respond_to_user`。 +- 恢复:给既有持久 batch 增加新职责时必须升级 schema;新 v3 按新合同失败关闭,旧 v2 collaboration batch 与 v1 contractless batch 继续按原 fingerprint 和创建时语义恢复,不能在反序列化时用新规则误杀升级中的无人干预轮次。 +- 验证:正向只读与否定式可修改分别做定向回归,并额外让只读 quality Agent 尝试 `file.write`,证明目标文件和 revision 均不变且下一请求只广告 `respond_to_user`;同内容 v3 batch 必须失败关闭,v2 必须可恢复。真实外部 E2E 必须证明首批两份委派建立、程序 Agent 实际推进 `game/index.html` revision、质量 Agent mutation 为零、父 run 最终 settled,并保持人工输入和终局残留为零。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 09b3f604e..f4f9dd639 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -82,6 +82,10 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod 终局 pending / confirmation / user-input / provider batch / retry / handoff / tool-plan handoff / finalization 残留 / reconciliation / duplicate 全为 `0`;Provider payload / private body / API Key / project path / config path / log / browser report leak 全为 `0`;人工 approve / answer / steer 全为 `0`。Runner 与 AppData 已清理,项目因 `--keep-project` 暂留后由主线程清理。此前 `114` identity 与 `105` identity 两个 **FAIL** 继续保留为独立历史失败,证据未与本轮拼接;最终 PASS 是这个单个新轮次的完整证据,当前外部 Provider 全链路状态现已 **PASS**。 +2026-07-22 V1.47 收紧自主构建首批职责与专业交付:`project-supervisor` 的 initial wave 必须同时且各一次委派 `code-prototype` 与 `quality-review`。程序委派必须是非只读实现任务,`expectedArtifacts` 包含 `game/index.html`;质量委派必须显式只读、不得修改项目且 `expectedArtifacts=[]`。合同在 Provider 计划解析、batch prepare 和 durable batch 恢复三处重验;新批次使用 `game-creator-provider-action-batch.v3`,只有 v3 按新职责失败关闭,升级前已持久化的 v2 collaboration batch 与 v1 contractless batch 继续按原 fingerprint 和合同恢复。只读专业 Agent 的 Provider 计划只允许纯读取与状态观察动作,任何文件、revision、命令、任务、记忆、黑板、资产或委派副作用都在执行前拒绝,并把格式修复目录收窄为仅 `respond_to_user`。非只读专业 Agent 只有在本人 run 产生 project mutation 且当前 mutation revision 已验证通过后才能回复;ready 未认领或 claim 尚未 observed 时,父 Supervisor 必须先只调用 `agent.run_status` 收束回执,再决定 repair。只读判定只接受明确的只读审查/验收或不得修改指令,`非只读 / 不要只读 / not read-only` 等否定式标签不得因子串命中而误判。 + +V1.47 在只读工具边界和 batch v3/v2/v1 恢复终审修复后的最新独立真实外部轮次已完整 **PASS**:用户只输入一次任务后 stdin 立即 EOF,人工 approve / answer / steer 均为 `0`;一个原始专业任务失败后由唯一 repair 自行恢复,父 Supervisor 为 `idle / completed`,`turn.report=settled` 且只有 `1` 条 `44` 字符 assistant。项目 revision `0 -> 6`,`game/index.html` 为 `8080` bytes 且已变化,两次静态检查通过,desktop / mobile 的 `lane-defense-v1` 真实 Chrome 试玩为 `37/37`。`88` 个 Provider identity 全部 terminal,其中 `75 completed / 13 failed`,`12` 条 durable retry audit 与专业 repair 均自行恢复;open lifecycle、pending、confirmation、user-input、provider batch/retry/handoff/tool-plan handoff、finalization、reconciliation、duplicate 与各类泄漏终局均为 `0`,Runner、disposable 项目和隔离 AppData 已自动清理。该轮证明失败 attempt 可保留真实证据而循环仍能零人工干预收束,不能把它改写成 Provider 零失败。 + 2026-07-15 起,Runtime V1.1 文档的“V1.17 单 Agent 持久计划”作为后台工具规划进度的新事实源。`submit_agent_tool_plan` 新增 nullable `planUpdate={explanation,steps[{step,status}]}`;步骤只接受 `pending / in_progress / completed`,最多 8 步且至多一个 `in_progress`。结构化计划一旦建立,legacy `plan` 只作旧协议 fallback;终态步骤必须保留,`planRevision` 只在真实变化时单调递增,工具 action 下标不得自动完成结构化步骤,存在未完成步骤时不得写最终回复或 completed。 V1.17 计划快照随 `game-creator-runtime-context-bundle.v3` 持久化,v2 在通过原身份、revision 和 verification gate 校验后从当前 Runtime state 补齐计划字段继续恢复;计划元数据本身不推进项目 revision、不改变 verification gate,也不触发项目权限确认。开发 UI 和 CLI 有界展示 revision、说明与完整 8 步;正式用户的 Supervisor 只展示完成数、当前步骤、等待对象、下一步和协作数量的紧凑摘要。恢复、same-run steer 和真实 Provider 的完整验收矩阵以 Runtime V1.17 章节为准;2026-07-16 已在当前 v5 context 上完成正式 `openai_chat / gpt-5.5` 的同 run steer + Runner 强杀恢复专项,门禁状态为 PASS。