From 5263efdfc1e36ffc7f042e9d54bdaf0e417aa207 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 17 Jul 2026 11:48:07 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=87=AA=E4=B8=BBSwarm?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E9=AA=8C=E6=94=B6=E4=B8=8E=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增Project Supervisor自主终端协作真实E2E与隔离AppData安全检查 为Swarm Chat输出脱敏单行turn report并补齐终端收束断言 强化Supervisor多专业并行委派与语义验收提示 修复静态委派和隔离all-join混合等待及认领恢复 为agent.message增加语义幂等持久化并避免重复消息伪造进展 补充消息幂等、混合恢复和turn report定向回归测试 增加根级与应用级自主验收命令并更新Runtime技术文档 --- apps/ai-game-creator-shell/package.json | 1 + .../scripts/agent-runtime-real-e2e.mjs | 425 ++++++++++++- .../src-tauri/src/agent.rs | 223 +++++-- .../src-tauri/src/project.rs | 118 +++- .../src-tauri/src/swarm_cli.rs | 446 ++++++++++++-- .../src-tauri/src/tests.rs | 559 ++++++++++++++++++ ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 14 + package.json | 1 + 8 files changed, 1677 insertions(+), 110 deletions(-) diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 578876e64..08b398a11 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -15,6 +15,7 @@ "agent-run": "node scripts/run-cli-with-config.mjs --agent-run", "agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs", "agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs", + "agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-autonomous-chat", "agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry", "agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs", "agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill", diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs index 1a9e3cf72..0237f9fc1 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs @@ -71,6 +71,10 @@ const supervisorSwarmTransientRetryAppDataSentinelFileName = '.agent-runtime-real-e2e-supervisor-swarm-transient-retry-appdata.json'; const supervisorSwarmTransientRetryAppDataSentinelSchema = 'genarrative-agent-runtime-real-e2e-supervisor-swarm-transient-retry-appdata.v1'; +const supervisorSwarmAutonomousChatAppDataSentinelFileName = + '.agent-runtime-real-e2e-supervisor-swarm-autonomous-chat-appdata.json'; +const supervisorSwarmAutonomousChatAppDataSentinelSchema = + 'genarrative-agent-runtime-real-e2e-supervisor-swarm-autonomous-chat-appdata.v1'; const mainAgentId = 'code-prototype'; const projectSupervisorAgentId = 'project-supervisor'; const requestedRunId = `real-e2e-${Date.now()}-${randomUUID().slice(0, 8)}`; @@ -101,6 +105,7 @@ const steerRunnerKillSuite = 'steer-runner-kill'; const parallelReadSuite = 'parallel-read'; const supervisorSwarmSuite = 'supervisor-swarm'; const supervisorSwarmTransientRetrySuite = 'supervisor-swarm-transient-retry'; +const supervisorSwarmAutonomousChatSuite = 'supervisor-swarm-autonomous-chat'; const runtimeContextBundleSchemaVersion = 'game-creator-runtime-context-bundle.v5'; const providerRequestLifecycleSchemaVersion = @@ -230,6 +235,26 @@ const supervisorSwarmConfirmedTools = [ 'project.patchset', 'project.verify', ]; +const supervisorSwarmAutonomousTask = + '请把这个试玩项目推进到可以交给首批玩家体验的状态。以仓库现有要求和实际验证结果为准,完成后简短说明交付内容、验证结论和仍需关注的问题。'; +const supervisorSwarmAutonomousRoutingTerms = [ + supervisorSwarmDesignAgentId, + supervisorSwarmQualityAgentId, + 'agent.delegate', + 'agent.run_status', + '同一个 planning 轮次', + '同轮', + '并行', + '返工', + 'repair', + '一次', + '两个 Agent', + '两个专业', + 'Runner', + 'pidfd', + 'actionId', + 'delegationId', +]; const webSearchBaselineApiUrl = 'https://api.github.com/repos/nodejs/node/releases/latest'; const goalSessionId = `agent-session-${mainAgentId}`; @@ -350,6 +375,7 @@ let shutdownSignal = null; let linuxPidfdPythonPath = null; let cleanupInProgress = false; let userInputCliSession = null; +let supervisorSwarmCliSession = null; class StreamingSecretScanner { constructor(secrets) { @@ -616,6 +642,10 @@ const state = { newRunnerBootId: null, transientFaultProxy: null, transientFaultCheckpoint: null, + autonomousTaskRecipeFree: false, + autonomousRepositoryRecipeFree: false, + interactiveCliUsed: false, + turnReport: null, }, confirmedActionIds: new Set(), cleanupPerformed: false, @@ -765,6 +795,15 @@ try { } userInputCliSession = null; } + if (isSupervisorSwarmAutonomousChatSuite() && supervisorSwarmCliSession) { + try { + await closeInteractiveCli(supervisorSwarmCliSession); + } catch (error) { + state.status = 'FAIL'; + recordError('supervisor-swarm-autonomous-chat-cli-cleanup-failed', error); + } + supervisorSwarmCliSession = null; + } if (isMcpRuntimeSuite() && state.mcp.httpFixture) { try { await stopMcpHttpFixture(); @@ -5723,6 +5762,23 @@ async function collectPartialParallelReadEvidence() { } function supervisorSwarmRepositoryInstructions() { + if (isSupervisorSwarmAutonomousChatSuite()) { + return `# Player-ready repository contract + +- SUPERVISOR_SWARM_REPOSITORY_CONTEXT: a player-ready handoff requires both deliverables below to satisfy their business meaning and repository verification. +- The experience specification is ${supervisorSwarmDesignPath}; its complete content must be exactly: + DESIGN_STATUS=ready + DESIGN_MARKER=${supervisorSwarmDesignMarker} +- The release quality record is ${supervisorSwarmQualityPath}; its required final content must be exactly: + QUALITY_STATUS=ready + QUALITY_MARKER=${supervisorSwarmQualityMarker} +- A status label is not semantic proof. Before the release quality record is changed, its current content must be independently audited against this contract; any correction remains bound to the same acceptance criteria and artifact. +- Any work that changes a deliverable must read package.json, run its declared verification, and return evidence before the player-ready handoff is accepted. +- The user receives one concise handoff only after all required evidence is accepted. Intermediate professional work stays internal. +- Do not use Git, MCP, external generation, or dynamic child instances for this repository contract. +- Never read or expose .env, ${configFileName}, .agent/private-secret.txt, credentials, private runtime payloads, or absolute paths. +`; + } return `# Supervisor swarm real E2E - SUPERVISOR_SWARM_REPOSITORY_CONTEXT: complete both professional deliverables through the Project Supervisor and keep all professional replies internal. @@ -5741,6 +5797,27 @@ function supervisorSwarmRepositoryInstructions() { `; } +function assertSupervisorSwarmAutonomousRepositoryInstructions( + repositoryInstructions, +) { + if (!isSupervisorSwarmAutonomousChatSuite()) return; + for (const forbidden of supervisorSwarmAutonomousRoutingTerms) { + assert( + !repositoryInstructions.includes(forbidden), + 'supervisor-swarm-autonomous-repository-recipe-leak', + ); + } + assert( + repositoryInstructions.includes(supervisorSwarmDesignPath) && + repositoryInstructions.includes(supervisorSwarmQualityPath) && + repositoryInstructions.includes(supervisorSwarmDesignMarker) && + repositoryInstructions.includes(supervisorSwarmQualityMarker) && + repositoryInstructions.includes('independently audited'), + 'supervisor-swarm-autonomous-repository-contract-missing', + ); + state.supervisorSwarm.autonomousRepositoryRecipeFree = true; +} + function supervisorSwarmProjectPolicy(denyQualityMutations) { return { deniedCommands: [], @@ -5771,6 +5848,7 @@ async function writeSupervisorSwarmProjectPolicy(denyQualityMutations) { async function seedSupervisorSwarmDisposableProject() { await seedDisposableProject(); const repositoryInstructions = supervisorSwarmRepositoryInstructions(); + assertSupervisorSwarmAutonomousRepositoryInstructions(repositoryInstructions); await Promise.all([ fs.writeFile( path.join(state.projectRoot, 'AGENTS.md'), @@ -5826,10 +5904,35 @@ async function seedSupervisorSwarmDisposableProject() { } function buildSupervisorSwarmTaskPrompt() { + if (isSupervisorSwarmAutonomousChatSuite()) { + return supervisorSwarmAutonomousTask; + } return `请把 ${supervisorSwarmDesignPath} 与 ${supervisorSwarmQualityPath} 两项专业交付推进到仓库规范规定的 ready 状态。设计方向交给 ${supervisorSwarmDesignAgentId},质量方向交给 ${supervisorSwarmQualityAgentId};第一轮必须在同一个 planning 轮次同时安排两个方向。收到内部证据后由总控逐项验收,质量首轮未达标时只安排一次返工;下一步执行条件已经齐全时直接行动,不要反复只更新计划。全部证据成立后再由总控向用户简短汇报。不要转述仓库指令、内部标记、私有运行信息或绝对路径。`; } function assertSupervisorSwarmTaskPrompt(task) { + if (isSupervisorSwarmAutonomousChatSuite()) { + assert( + task === supervisorSwarmAutonomousTask && !task.includes('\n'), + 'supervisor-swarm-autonomous-task-invalid', + ); + for (const forbidden of [ + ...supervisorSwarmAutonomousRoutingTerms, + supervisorSwarmDesignPath, + supervisorSwarmQualityPath, + supervisorSwarmDesignMarker, + supervisorSwarmQualityMarker, + '.agent/runtime', + state.projectRoot, + ]) { + assert( + !task.includes(forbidden), + 'supervisor-swarm-autonomous-task-recipe-leak', + ); + } + state.supervisorSwarm.autonomousTaskRecipeFree = true; + return; + } assert( !task.includes('\n') && task.includes(supervisorSwarmDesignAgentId) && @@ -6078,7 +6181,7 @@ function validateSupervisorSwarmInitialProviderBatch(batch) { ['ready', 'completed'].includes(batch.status) && Number.isSafeInteger(batch.nextActionIndex) && batch.nextActionIndex >= 0 && - batch.nextActionIndex <= 2 && + batch.nextActionIndex <= batch.actions?.length && Array.isArray(batch.actions) && batch.actions.length === 2 && Array.isArray(batch.plan?.actions) && @@ -6160,12 +6263,27 @@ async function captureSupervisorSwarmInitialProviderBatch() { if (error?.code === 'ENOENT') return null; throw error; }); - if (batch) { + const autonomousDelegateBatch = + isSupervisorSwarmAutonomousChatSuite() && + Array.isArray(batch?.actions) && + batch.actions.length === 2 && + batch.actions.every( + (pending) => pending?.action?.tool === 'agent.delegate', + ); + if ( + batch && + (!isSupervisorSwarmAutonomousChatSuite() || autonomousDelegateBatch) + ) { validateSupervisorSwarmInitialProviderBatch(batch); return; } pollCount += 1; if (pollCount % 20 === 0) { + if (isSupervisorSwarmAutonomousChatSuite()) { + await confirmSupervisorSwarmPendingActions( + new Set([`${projectSupervisorAgentId}\0${state.initialRunId}`]), + ); + } const agentDb = await readOptionalJsonl( path.join(state.projectRoot, '.agent/agent.db'), ); @@ -7011,13 +7129,18 @@ async function driveSupervisorSwarmToRepairKillBoundary() { initial, ); if (state.supervisorSwarm.initialProviderOverlapObserved) { - await confirmSupervisorSwarmPendingActions( - new Set( - initial.map( - (delivery) => - `${delivery.targetAgentId}\0${delivery.targetRunId}`, - ), + const confirmRunKeys = new Set( + initial.map( + (delivery) => `${delivery.targetAgentId}\0${delivery.targetRunId}`, ), + ); + if (isSupervisorSwarmAutonomousChatSuite()) { + confirmRunKeys.add( + `${projectSupervisorAgentId}\0${state.initialRunId}`, + ); + } + await confirmSupervisorSwarmPendingActions( + confirmRunKeys, new Set( parentDeliveries .filter((delivery) => delivery.repairOfDelegationId != null) @@ -7523,20 +7646,41 @@ async function runSupervisorSwarmE2e() { chars: [...task].length, sha256: hashValue(task), }; - state.initialRunId = requestedRunId; - state.initialSessionId = supervisorSwarmSessionId; state.isolatedRunner.launchAttempted = true; - await runCli( - [ - '--agent-enqueue', + if (isSupervisorSwarmAutonomousChatSuite()) { + supervisorSwarmCliSession = startInteractiveCli([ + '--swarm-chat', '--init', state.projectRoot, - projectSupervisorAgentId, - state.initialRunId, - task, - ], - { timeoutMs: 120_000 }, - ); + ]); + await waitForInteractiveCliOutput( + supervisorSwarmCliSession, + (output) => + output.includes('Agent Swarm Chat') && + output.includes(`父 Agent:${projectSupervisorAgentId}`), + 'supervisor-swarm-autonomous-chat-banner-timeout', + 30_000, + ); + state.supervisorSwarm.interactiveCliUsed = true; + writeInteractiveCliLine(supervisorSwarmCliSession, task); + const started = await waitForSupervisorSwarmAutonomousParentRuntime(task); + state.initialRunId = started.runId; + state.initialSessionId = started.sessionId; + } else { + state.initialRunId = requestedRunId; + state.initialSessionId = supervisorSwarmSessionId; + await runCli( + [ + '--agent-enqueue', + '--init', + state.projectRoot, + projectSupervisorAgentId, + state.initialRunId, + task, + ], + { timeoutMs: 120_000 }, + ); + } await claimOwnedRunner(); const runtime = await readRuntime(projectSupervisorAgentId); assert( @@ -7550,10 +7694,81 @@ async function runSupervisorSwarmE2e() { await captureSupervisorSwarmTransientRetryCheckpoint(); await driveSupervisorSwarmToRepairKillBoundary(); await driveSupervisorSwarmRuntimeToCompletion(); + if (isSupervisorSwarmAutonomousChatSuite()) { + await captureSupervisorSwarmAutonomousTurnReport(); + writeInteractiveCliLine(supervisorSwarmCliSession, '/quit'); + await waitForInteractiveCliExit(supervisorSwarmCliSession, 30_000); + supervisorSwarmCliSession = null; + } state.evidence = await validateSupervisorSwarmEvidence(); assert(state.evidence.secretLeakCount === 0, 'loaded-key-leak-detected'); } +async function waitForSupervisorSwarmAutonomousParentRuntime(task) { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const runtime = await readRuntime(projectSupervisorAgentId).catch( + () => null, + ); + if ( + runtime?.agentId === projectSupervisorAgentId && + runtime.sessionId === supervisorSwarmSessionId && + isNonEmptyString(runtime.runId) && + runtime.currentTask === task + ) { + return runtime; + } + if (supervisorSwarmCliSession?.closed) { + throw codedError('supervisor-swarm-autonomous-chat-closed-before-run'); + } + await sleep(50); + } + throw codedError('supervisor-swarm-autonomous-parent-runtime-timeout'); +} + +async function captureSupervisorSwarmAutonomousTurnReport() { + const session = supervisorSwarmCliSession; + assert(session && !session.closed, 'supervisor-swarm-chat-session-missing'); + const output = await waitForInteractiveCliOutput( + session, + (value) => value.includes('[turn.report] '), + 'supervisor-swarm-autonomous-turn-report-timeout', + 60_000, + ); + const reportLines = output + .split(/\r?\n/u) + .filter((line) => line.startsWith('[turn.report] ')); + assert( + reportLines.length === 1, + 'supervisor-swarm-autonomous-turn-report-count-invalid', + ); + const report = JSON.parse(reportLines[0].slice('[turn.report] '.length)); + assert( + isPlainObject(report) && + JSON.stringify(Object.keys(report).sort()) === + JSON.stringify( + [ + 'schemaVersion', + 'outcome', + 'parentAgentId', + 'sessionId', + 'parentRunId', + 'runtimeCount', + 'busyRuntimeCount', + 'pendingTaskCount', + 'runningTaskCount', + 'waitingForConfirmationCount', + 'waitingForUserInputCount', + 'newAssistantMessageCount', + 'finalReplyChars', + 'reconciliationAgentCount', + ].sort(), + ), + 'supervisor-swarm-autonomous-turn-report-shape-invalid', + ); + state.supervisorSwarm.turnReport = report; +} + function validateSupervisorSwarmProviderLifecycle(agentDb, deliveries) { const relevantRuns = supervisorSwarmRelevantRunKeys(deliveries); const lifecycle = agentDb.filter( @@ -8769,12 +8984,74 @@ async function validateSupervisorSwarmEvidence() { const secretLeakCount = (state.transcriptScanner?.count ?? 0) + projectSecretLeakCount; assert(secretLeakCount === 0, 'loaded-key-leak-detected'); + const autonomousModeEnabled = isSupervisorSwarmAutonomousChatSuite(); + const turnReport = state.supervisorSwarm.turnReport; + const turnReportPrivateLeakCount = autonomousModeEnabled + ? countExactSecrets(Buffer.from(JSON.stringify(turnReport ?? {})), [ + ...state.supervisorSwarm.privateValues, + ...state.secrets, + ...absolutePathVariants(state.projectRoot), + ...formalConfigPathVariants(), + ]) + : 0; + if (autonomousModeEnabled) { + assert( + state.supervisorSwarm.autonomousTaskRecipeFree && + state.supervisorSwarm.autonomousRepositoryRecipeFree && + state.supervisorSwarm.interactiveCliUsed && + turnReport?.schemaVersion === 'game-creator-swarm-turn-report.v1' && + turnReport.outcome === 'settled' && + turnReport.parentAgentId === projectSupervisorAgentId && + turnReport.sessionId === supervisorSwarmSessionId && + turnReport.parentRunId === state.initialRunId && + Number.isSafeInteger(turnReport.runtimeCount) && + turnReport.runtimeCount >= 3 && + turnReport.busyRuntimeCount === 0 && + turnReport.pendingTaskCount === 0 && + turnReport.runningTaskCount === 0 && + turnReport.waitingForConfirmationCount === 0 && + turnReport.waitingForUserInputCount === 0 && + turnReport.newAssistantMessageCount === 1 && + turnReport.finalReplyChars === + [...supervisorAssistants[0].content].length && + turnReport.reconciliationAgentCount === 0 && + turnReportPrivateLeakCount === 0, + 'supervisor-swarm-autonomous-turn-report-invalid', + ); + } return buildSupervisorSwarmEvidence( { - scenario: - 'project-supervisor-dual-delegate-single-repair-runner-recovery', + scenario: autonomousModeEnabled + ? 'project-supervisor-autonomous-chat-dual-delegate-single-repair-runner-recovery' + : 'project-supervisor-dual-delegate-single-repair-runner-recovery', targetAgentId: projectSupervisorAgentId, + autonomousModeEnabled, + autonomousTaskRecipeFree: state.supervisorSwarm.autonomousTaskRecipeFree, + autonomousRepositoryRecipeFree: + state.supervisorSwarm.autonomousRepositoryRecipeFree, + interactiveCliUsed: state.supervisorSwarm.interactiveCliUsed, + turnReportCaptured: turnReport != null, + turnReportOutcome: turnReport?.outcome ?? 'not-requested', + turnReportParentIdentityStable: autonomousModeEnabled + ? turnReport?.parentAgentId === projectSupervisorAgentId && + turnReport?.sessionId === supervisorSwarmSessionId && + turnReport?.parentRunId === state.initialRunId + : false, + turnReportRuntimeCount: turnReport?.runtimeCount ?? 0, + turnReportBusyRuntimeCount: turnReport?.busyRuntimeCount ?? 0, + turnReportPendingTaskCount: turnReport?.pendingTaskCount ?? 0, + turnReportRunningTaskCount: turnReport?.runningTaskCount ?? 0, + turnReportWaitingForConfirmationCount: + turnReport?.waitingForConfirmationCount ?? 0, + turnReportWaitingForUserInputCount: + turnReport?.waitingForUserInputCount ?? 0, + turnReportNewAssistantMessageCount: + turnReport?.newAssistantMessageCount ?? 0, + turnReportFinalReplyChars: turnReport?.finalReplyChars ?? 0, + turnReportReconciliationAgentCount: + turnReport?.reconciliationAgentCount ?? 0, + turnReportPrivateLeakCount, providerModel: state.supervisorSwarm.effectiveModel, providerApiKind: state.supervisorSwarm.effectiveApiKind, providerReasoningEffort: state.supervisorSwarm.effectiveReasoningEffort, @@ -9184,6 +9461,7 @@ function parseArguments(args) { suite === parallelReadSuite || suite === supervisorSwarmSuite || suite === supervisorSwarmTransientRetrySuite || + suite === supervisorSwarmAutonomousChatSuite || suite === steerRunnerKillSuite || processSessionSuites.has(suite), 'unsupported-suite', @@ -9313,8 +9591,15 @@ function sameEffectiveAgentLlm(left, right) { ); } +function isolatedSuiteProtectsSourceAppData() { + return ( + isSupervisorSwarmTransientRetrySuite() || + isSupervisorSwarmAutonomousChatSuite() + ); +} + function isolatedSuiteUsesSiblingAppData() { - return isWebSearchSuite() || isSupervisorSwarmTransientRetrySuite(); + return isWebSearchSuite() || isolatedSuiteProtectsSourceAppData(); } function isolatedSuiteAppDataProfile() { @@ -9398,6 +9683,14 @@ function isolatedSuiteAppDataProfile() { codePrefix: 'supervisor-swarm-transient-retry-appdata', }; } + if (isSupervisorSwarmAutonomousChatSuite()) { + return { + prefix: '.agent-runtime-real-e2e-supervisor-swarm-autonomous-chat-', + sentinelName: supervisorSwarmAutonomousChatAppDataSentinelFileName, + sentinelSchema: supervisorSwarmAutonomousChatAppDataSentinelSchema, + codePrefix: 'supervisor-swarm-autonomous-chat-appdata', + }; + } if (isSupervisorSwarmSuite()) { return { prefix: '.agent-runtime-real-e2e-supervisor-swarm-', @@ -9485,7 +9778,7 @@ function closeSourceAppDataDirectoryGuard() { } function startSourceAppDataDirectoryGuard(sourceConfigDir, profile) { - if (!isSupervisorSwarmTransientRetrySuite()) return; + if (!isolatedSuiteProtectsSourceAppData()) return; assert( !state.isolatedRunner.sourceAppDataDirectoryWatcher, 'source-appdata-directory-guard-already-started', @@ -9509,7 +9802,7 @@ function startSourceAppDataDirectoryGuard(sourceConfigDir, profile) { } async function verifySourceAppDataDirectoryUntouched() { - if (!isSupervisorSwarmTransientRetrySuite()) return; + if (!isolatedSuiteProtectsSourceAppData()) return; closeSourceAppDataDirectoryGuard(); const sourceConfigDir = await fs.realpath(state.options.configDir); const profile = isolatedSuiteAppDataProfile(); @@ -9569,7 +9862,7 @@ async function prepareIsolatedSuiteAppData({ state.isolatedRunner.appDataDir = appDataDir; state.isolatedRunner.ownerToken = ownerToken; state.isolatedRunner.createdAt = createdAt; - if (isSupervisorSwarmTransientRetrySuite()) { + if (isolatedSuiteProtectsSourceAppData()) { const realAppDataDir = await fs.realpath(appDataDir); assert( !isPathInside(sourceConfigDir, realAppDataDir) && @@ -9985,8 +10278,9 @@ async function prepareIsolatedSuiteAppData({ effective.requestTimeoutMs > 0 && Number.isSafeInteger(effective.maxRetries) && effective.maxRetries >= + (isSupervisorSwarmAutonomousChatSuite() || (isSupervisorSwarmTransientRetrySuite() && - agentId !== supervisorSwarmDesignAgentId + agentId !== supervisorSwarmDesignAgentId) ? 0 : 1) && effective.maxRetries <= 3 && @@ -15394,6 +15688,13 @@ async function confirmPendingActions( allowedTools = null, shouldConfirm = () => true, ) { + if (isSupervisorSwarmAutonomousChatSuite()) { + await confirmSupervisorSwarmPendingActionsInChat( + allowedTools, + shouldConfirm, + ); + return; + } for (const pending of await findPendingActions()) { if (state.confirmedActionIds.has(pending.actionId)) continue; if (!shouldConfirm(pending)) continue; @@ -15487,6 +15788,54 @@ async function confirmPendingActions( } } +async function confirmSupervisorSwarmPendingActionsInChat( + allowedTools, + shouldConfirm, +) { + const session = supervisorSwarmCliSession; + assert(session && !session.closed, 'supervisor-swarm-chat-session-missing'); + const allowed = new Set( + allowedTools ? [...allowedTools] : supervisorSwarmConfirmedTools, + ); + const candidates = (await findPendingActions()).filter((pending) => { + if (state.confirmedActionIds.has(pending.actionId)) return false; + if (!shouldConfirm(pending)) return false; + assert( + allowed.has(pending.tool), + `pending-tool-not-allowed-in-scenario:${pending.tool}`, + ); + return true; + }); + if (candidates.length === 0) return; + const output = await waitForInteractiveCliOutput( + session, + (value) => + candidates.some((pending) => + value.includes( + `[待确认] agent=${pending.agentId} run=${pending.runId} action=${pending.actionId} tool=${pending.tool}`, + ), + ), + 'supervisor-swarm-chat-confirmation-prompt-timeout', + 120_000, + ); + const pending = candidates.find((candidate) => + output.includes( + `[待确认] agent=${candidate.agentId} run=${candidate.runId} action=${candidate.actionId} tool=${candidate.tool}`, + ), + ); + assert(pending, 'supervisor-swarm-chat-prompted-action-missing'); + const runtime = await readRuntime(pending.agentId); + assert(runtime.runId === pending.runId, 'pending-run-mismatch'); + writeInteractiveCliLine(session, 'approve'); + await waitForInteractiveCliOutput( + session, + (output) => output.includes(`[已批准] ${pending.actionId}`), + 'supervisor-swarm-chat-confirmation-result-timeout', + 120_000, + ); + state.confirmedActionIds.add(pending.actionId); +} + async function monitorGoalWriteActionUntilSettled(pending) { state.goal.monitoredWriteActionIds.add(pending.actionId); const deadline = Date.now() + 120_000; @@ -21065,6 +21414,23 @@ function supervisorSwarmEvidenceFieldTemplate() { return { scenario: 'project-supervisor-dual-delegate-single-repair-runner-recovery', targetAgentId: projectSupervisorAgentId, + autonomousModeEnabled: false, + autonomousTaskRecipeFree: false, + autonomousRepositoryRecipeFree: false, + interactiveCliUsed: false, + turnReportCaptured: false, + turnReportOutcome: 'not-requested', + turnReportParentIdentityStable: false, + turnReportRuntimeCount: 0, + turnReportBusyRuntimeCount: 0, + turnReportPendingTaskCount: 0, + turnReportRunningTaskCount: 0, + turnReportWaitingForConfirmationCount: 0, + turnReportWaitingForUserInputCount: 0, + turnReportNewAssistantMessageCount: 0, + turnReportFinalReplyChars: 0, + turnReportReconciliationAgentCount: 0, + turnReportPrivateLeakCount: 0, providerModel: null, providerApiKind: null, providerReasoningEffort: null, @@ -22203,7 +22569,8 @@ function isParallelReadSuite() { function isSupervisorSwarmSuite() { return ( state.suite === supervisorSwarmSuite || - isSupervisorSwarmTransientRetrySuite() + isSupervisorSwarmTransientRetrySuite() || + isSupervisorSwarmAutonomousChatSuite() ); } @@ -22211,6 +22578,10 @@ function isSupervisorSwarmTransientRetrySuite() { return state.suite === supervisorSwarmTransientRetrySuite; } +function isSupervisorSwarmAutonomousChatSuite() { + return state.suite === supervisorSwarmAutonomousChatSuite; +} + function isSteerRunnerKillSuite() { return state.suite === steerRunnerKillSuite; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 2a75b6d0e..8c0891a34 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -1596,12 +1596,23 @@ fn supervisor_delivery_pending_action_has_durable_side_effect_at( pending: &AgentRuntimePendingToolAction, ) -> Result { match pending.action.tool.as_str() { - "agent.run_status" => static_delegate_claim_exists_at( - root, - &pending.agent_id, - &pending.run_id, - &pending.action_id, - ), + "agent.run_status" => { + if static_delegate_claim_exists_at( + root, + &pending.agent_id, + &pending.run_id, + &pending.action_id, + )? { + Ok(true) + } else { + isolated_join_claim_exists_for_parent_action_at( + root, + &pending.agent_id, + &pending.run_id, + &pending.action_id, + ) + } + } "agent.delegate" => { let target_agent_id = normalize_game_creator_runtime_agent_id(&agent_runtime_tool_input_text( @@ -6240,6 +6251,63 @@ fn persist_waiting_static_delegate_parent_context_at( Ok(()) } +fn persist_waiting_isolated_parent_context_at( + root: &Path, + runtime: &mut AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &mut Vec, + next_loop_index: usize, + context_tracker: &mut AgentRuntimeContextWindowTracker, + blocker: AgentRuntimeToolObservation, +) -> Result<(), String> { + let blocker_summary = blocker.summary(); + runtime.status = "running".to_string(); + runtime.phase = "waiting-for-isolated-join".to_string(); + runtime.current_action = "等待动态隔离 Agent 的 all-join".to_string(); + runtime.waiting_on = "隔离子 Agent 完成并由父 run 认领 all-join".to_string(); + runtime.next_step = "all-join ready 后自动唤醒当前父 run".to_string(); + runtime.observations.push(blocker_summary.clone()); + runtime.updated_at = unix_timestamp(); + context_tracker.record(&blocker); + observations.push(blocker); + persist_game_creator_agent_runtime_context( + root, + runtime, + task, + plan, + observations, + next_loop_index, + context_tracker, + )?; + append_game_creator_agent_runtime_task(root, runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, runtime)?; + write_game_creator_agent_runtime_state(root, runtime)?; + let _ = append_game_creator_agent_runtime_event( + root, + runtime, + "observation", + "running", + "waiting-for-isolated-join", + &blocker_summary, + None, + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.agent.isolated_join.waiting", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "status": "waiting-for-isolated-join", + "nextLoopIndex": next_loop_index, + }), + ); + emit_game_creator_agent_runtime_update(root, &runtime.agent_id); + Ok(()) +} + async fn run_game_creator_agent_background_task_pass_with_context( root: PathBuf, agent_id: String, @@ -6333,6 +6401,35 @@ async fn run_game_creator_agent_background_task_pass_with_context( } }; if !consumed_steer { + if let Some(blocker) = + isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id) + { + let waits_for_join = blocker + .detail + .as_deref() + .is_some_and(isolated_join_barrier_has_waiting_groups); + if waits_for_join { + if let Err(error) = persist_waiting_isolated_parent_context_at( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index, + &mut context_tracker, + blocker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化动态隔离 Agent all-join 等待状态失败:{error}"), + ); + } + return AgentBackgroundTaskOutcome::WaitingForIsolatedJoin; + } + } if let Some(blocker) = static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) { @@ -11236,7 +11333,10 @@ impl AgentRuntimeContextWindowTracker { } pub(crate) fn record(&mut self, observation: &AgentRuntimeToolObservation) { - if observation.tool == "runtime.context" { + if observation.tool == "runtime.context" + || (observation.tool == "agent.message" + && observation.detail.as_deref() == Some("messageAppended=false")) + { return; } self.observation_signatures @@ -19997,7 +20097,9 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ .await } "blackboard.write" => observe_agent_runtime_blackboard_write(root, agent_id, &action.input), - "agent.message" => observe_agent_runtime_agent_message(root, agent_id, &action.input), + "agent.message" => { + observe_agent_runtime_agent_message(root, agent_id, run_id, &action.input) + } "agent.delegate" => observe_agent_runtime_project_snapshot_with_lock( root, agent_id, @@ -26274,9 +26376,10 @@ fn observe_agent_runtime_blackboard_write( } } -fn observe_agent_runtime_agent_message( +pub(crate) fn observe_agent_runtime_agent_message( root: &Path, agent_id: &str, + run_id: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { let target_agent_id = agent_runtime_tool_input_text(input, &["agentId", "targetAgentId"]); @@ -26313,36 +26416,63 @@ fn observe_agent_runtime_agent_message( }; let content = truncate_agent_runtime_text(sanitize_prompt_context(&content).as_str(), 1_200); let message = format!("来自 {agent_id} 的定向消息:{content}"); - let result = append_local_conversation_message_at( - root, - Some(&target_agent_id), - LocalConversationMessage { - role: "tool".to_string(), - content: message, - agent_id: None, - }, - ) - .and_then(|conversation| { - let relative_path = - agent_runtime_relative_project_path(root, Path::new(&conversation.path))?; - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.agent.message", - "agentId": agent_id, - "targetAgentId": target_agent_id.clone(), - "targetSessionId": conversation.session_id, - "path": relative_path, - }), - ) - }); + let result = resolve_agent_conversation_session_id_at(root, &target_agent_id, None, true) + .and_then(|target_session_id| { + let content_sha256 = format!("{:x}", Sha256::digest(content.as_bytes())); + let semantic_identity = format!( + "{agent_id}\n{run_id}\n{target_agent_id}\n{target_session_id}\n{content_sha256}" + ); + let semantic_sha256 = format!("{:x}", Sha256::digest(semantic_identity.as_bytes())); + let message_id = format!("agent-message-{semantic_sha256}"); + let audit_action_id = format!("action-{}", &semantic_sha256[..24]); + append_local_conversation_message_for_session_idempotent_with_status_at( + root, + Some(&target_agent_id), + Some(&target_session_id), + LocalConversationMessage { + role: "tool".to_string(), + content: message, + agent_id: None, + }, + &message_id, + ) + .and_then(|(conversation, appended)| { + let relative_path = + agent_runtime_relative_project_path(root, Path::new(&conversation.path))?; + append_agent_db_agent_message_if_missing( + root, + agent_id, + run_id, + &audit_action_id, + serde_json::json!({ + "recordType": "agent.runtime.agent.message", + "agentId": agent_id, + "runId": run_id, + "actionId": audit_action_id, + "messageId": message_id, + "targetAgentId": target_agent_id, + "targetSessionId": conversation.session_id, + "path": relative_path, + "contentSha256": content_sha256, + "contentChars": content.chars().count(), + }), + )?; + Ok(appended) + }) + }); match result { - Ok(()) => AgentRuntimeToolObservation { + Ok(true) => AgentRuntimeToolObservation { tool: "agent.message".to_string(), status: "ok".to_string(), summary: format!("已给 {target_agent_id} 留消息"), detail: Some(content), }, + Ok(false) => AgentRuntimeToolObservation { + tool: "agent.message".to_string(), + status: "ok".to_string(), + summary: format!("给 {target_agent_id} 的相同定向消息已存在,未重复追加"), + detail: Some("messageAppended=false".to_string()), + }, Err(error) => AgentRuntimeToolObservation { tool: "agent.message".to_string(), status: "failed".to_string(), @@ -29287,6 +29417,28 @@ fn claimed_isolated_join_count_for_parent_at( Ok(count) } +fn isolated_join_claim_exists_for_parent_action_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + action_id: &str, +) -> Result { + for join in reconcile_all_isolated_groups_at(root)? + .into_iter() + .filter(|join| { + join.parent_agent_id == parent_agent_id && join.parent_run_id == parent_run_id + }) + { + if read_isolated_join_delivery_at(root, &join)?.is_some_and(|delivery| { + delivery.status == IsolatedAgentJoinDeliveryStatus::ClaimedByParent + && delivery.claimed_by_action_id.as_deref() == Some(action_id) + }) { + return Ok(true); + } + } + Ok(false) +} + fn ready_isolated_join_status_for_parent_at( root: &Path, parent_agent_id: &str, @@ -33552,7 +33704,7 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( return prompt; } let prompt = format!( - "{prompt}\n\n你当前是项目唯一面向用户的 Project Supervisor,并拥有最终回复权。每一轮都必须把用户原始目标视为最高层业务目标,专业 Agent 回执只能补充证据,不能把回执内容改写成新目标。优先把边界清晰的专业工作通过带 acceptanceCriteria 和 expectedArtifacts 的 agent.delegate 分给静态专业 Agent,把互不重叠的临时并行检查通过 agent.spawn_isolated 分派;已有委派未收束时不要重复委派。需要等待专业 Agent 时返回空 response,让 Runtime 的 delegate/all-join 完成屏障保持同一父 run;取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。只在所有必要回执已认领、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。" + "{prompt}\n\n你当前是项目唯一面向用户的 Project Supervisor,并拥有最终回复权。每一轮都必须把用户原始目标视为最高层业务目标,专业 Agent 回执只能补充证据,不能把回执内容改写成新目标。总控不能替代已有专业角色完成其领域交付:只要仓库目标同时包含两个以上互不依赖的专业方向,就必须自行查看静态角色目录,选择最匹配的不同专业 Agent,并在同一个 native planning 批次用带 acceptanceCriteria 和 expectedArtifacts 的 agent.delegate 发起委派,让这些方向并行;用户不需要点名 Agent、指定数量或提醒并行。只有没有匹配专业角色、纯协调工作或一两步轻量读取时才由总控直接处理。互不重叠的临时并行检查通过 agent.spawn_isolated 分派;已有委派未收束时不要重复委派。需要等待专业 Agent 时返回空 response,让 Runtime 的 delegate/all-join 完成屏障保持同一父 run;取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。只在所有必要回执已认领、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。" ); format!( "{prompt}\n\n普通 agent.run_status 的 claimedDelegateContracts 只提供已认领合同目录。语义复核或返工前必须用原 delegationId 再调用 agent.run_status,读取 claimedDelegateContract 中未截断的 acceptanceCriteria 和 expectedArtifacts,并在 repair agent.delegate 中逐项原样提交。若返工因合同未完整继承而失败,失败 observation 中的 claimedDelegateContract 是同一 durable delivery 的权威快照,必须逐项据此修正;只有该字段缺失或身份不确定时才按同一 delegationId 重读,不得无目标地重复 run_status 或从 action_history 摘要猜测。" @@ -33620,6 +33772,9 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> String { let prompt = format!( "{prompt} git.inspect 会返回 commitSnapshotFingerprint;只有当前非零 revision 已由本 run 验证通过,且已完整审阅变更时,才能用 project.git_commit 的 message、显式 paths、expectedHead 和 expectedSnapshotFingerprint 创建本地提交。project.git_commit 不允许访问 remote、切换分支或执行 merge、rebase、reset、stash、tag、submodule、worktree。" ); + let prompt = format!( + "{prompt} 作为被委派的专业 Agent 时,agent.message 只用于确有必要的中途协调,不能替代自身终态交付;验收、产物和验证已完成后,必须把全部必要计划步骤更新为 completed,并用一次最终 response 收束为父 Agent 可认领的回执,不得反复给同一 Agent 留消息或重复读取同一证据来维持 run。" + ); let isolated_template_ids = GAME_CREATOR_AGENT_GROUP_DEFINITIONS .iter() .flat_map(|group| group.roles.iter().map(|role| role.task_id)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index becc8557f..1e6ac0ac8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -1608,6 +1608,86 @@ pub(crate) fn append_agent_db_record_if_missing_for_action( ) } +pub(crate) fn append_agent_db_agent_message_if_missing( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: &str, + record: serde_json::Value, +) -> Result { + const RECORD_TYPE: &str = "agent.runtime.agent.message"; + let object = record + .as_object() + .ok_or_else(|| "Agent DB 定向消息审计必须是对象".to_string())?; + let expected_fields = [ + "actionId", + "agentId", + "contentChars", + "contentSha256", + "messageId", + "path", + "recordType", + "runId", + "targetAgentId", + "targetSessionId", + ]; + let mut actual_fields = object.keys().map(String::as_str).collect::>(); + actual_fields.sort_unstable(); + if actual_fields != expected_fields + || record.get("recordType").and_then(serde_json::Value::as_str) != Some(RECORD_TYPE) + || record.get("agentId").and_then(serde_json::Value::as_str) != Some(agent_id) + || record.get("runId").and_then(serde_json::Value::as_str) != Some(run_id) + || record.get("actionId").and_then(serde_json::Value::as_str) != Some(action_id) + { + return Err("Agent DB 定向消息审计身份或字段无效".to_string()); + } + if !action_id.strip_prefix("action-").is_some_and(|suffix| { + suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + return Err("Agent DB 定向消息审计 actionId 无效".to_string()); + } + let message_id = record + .get("messageId") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let content_sha256 = record + .get("contentSha256") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let path = record + .get("path") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !is_valid_agent_db_prefixed_hex(message_id, "agent-message-", 64) + || !is_valid_agent_db_sha256(content_sha256) + || record + .get("contentChars") + .and_then(serde_json::Value::as_u64) + .is_none_or(|count| count == 0 || count > 1_200) + || ["targetAgentId", "targetSessionId"].iter().any(|field| { + record + .get(*field) + .and_then(serde_json::Value::as_str) + .is_none_or(|value| value.trim().is_empty() || value.chars().any(char::is_control)) + }) + || Path::new(path).is_absolute() + || !path.starts_with(".agent/conversations/agents/") + || !path.ends_with(".jsonl") + || path.split('/').any(|segment| segment == "..") + { + return Err("Agent DB 定向消息审计元数据无效".to_string()); + } + append_agent_db_record_if_missing_for_action_internal( + root, + RECORD_TYPE, + agent_id, + run_id, + action_id, + record, + || {}, + ) +} + pub(crate) fn append_agent_db_process_reconciliation_if_missing_for_action( root: &Path, agent_id: &str, @@ -4878,7 +4958,7 @@ fn append_local_conversation_message_for_session_internal_at( message: LocalConversationMessage, message_id: Option<&str>, finalization_id: Option<&str>, -) -> Result { +) -> Result<(LocalConversationResult, bool), String> { let LocalConversationMessage { role, content, @@ -4930,7 +5010,8 @@ fn append_local_conversation_message_for_session_internal_at( if message_id.is_some() { return Err("带 messageId 的对话内容不能为空".to_string()); } - return read_local_conversation_for_session_at(root, agent_id, session_id); + return read_local_conversation_for_session_at(root, agent_id, session_id) + .map(|conversation| (conversation, false)); } let message_id = message_id .map(normalize_local_conversation_message_id) @@ -5040,11 +5121,14 @@ fn append_local_conversation_message_for_session_internal_at( appended, )?; } - Ok(local_conversation_result_from_persisted_records( - &path, - normalized_agent_id, - normalized_session_id, - &records, + Ok(( + local_conversation_result_from_persisted_records( + &path, + normalized_agent_id, + normalized_session_id, + &records, + ), + appended, )) } @@ -5057,6 +5141,7 @@ pub(crate) fn append_local_conversation_message_for_session_at( append_local_conversation_message_for_session_internal_at( root, agent_id, session_id, message, None, None, ) + .map(|(conversation, _)| conversation) } pub(crate) fn append_local_conversation_message_for_session_idempotent_at( @@ -5074,6 +5159,24 @@ pub(crate) fn append_local_conversation_message_for_session_idempotent_at( Some(message_id), None, ) + .map(|(conversation, _)| conversation) +} + +pub(crate) fn append_local_conversation_message_for_session_idempotent_with_status_at( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, + message: LocalConversationMessage, + message_id: &str, +) -> Result<(LocalConversationResult, bool), String> { + append_local_conversation_message_for_session_internal_at( + root, + agent_id, + session_id, + message, + Some(message_id), + None, + ) } pub(crate) fn append_local_conversation_message_for_session_idempotent_with_finalization_at( @@ -5092,6 +5195,7 @@ pub(crate) fn append_local_conversation_message_for_session_idempotent_with_fina Some(message_id), Some(finalization_id), ) + .map(|(conversation, _)| conversation) } pub(crate) fn append_local_conversation_message_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs index eac71bc16..014b375fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs @@ -8,6 +8,8 @@ const SWARM_CHAT_POLL_INTERVAL: Duration = Duration::from_millis(250); const SWARM_CHAT_SETTLE_WINDOW: Duration = Duration::from_millis(1_500); const SWARM_CHAT_HISTORY_LIMIT: usize = 50; const SWARM_CHAT_PLAN_STEP_LIMIT: usize = 8; +const SWARM_TURN_REPORT_PREFIX: &str = "[turn.report] "; +const SWARM_TURN_REPORT_SCHEMA_VERSION: &str = "game-creator-swarm-turn-report.v1"; #[derive(Debug, Eq, PartialEq)] enum SwarmChatInput { @@ -88,11 +90,46 @@ struct SwarmRejectedResponseStreamSnapshot { #[derive(Debug, Eq, PartialEq)] enum SwarmTurnOutcome { - Settled, - NeedsReconciliation(Vec), + Settled(SwarmTurnReport), + NeedsReconciliation { + agent_ids: Vec, + report: SwarmTurnReport, + }, Quit, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +enum SwarmTurnReportOutcome { + Settled, + NeedsReconciliation, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct SwarmTurnReport { + schema_version: &'static str, + outcome: SwarmTurnReportOutcome, + parent_agent_id: String, + session_id: String, + parent_run_id: Option, + runtime_count: usize, + busy_runtime_count: usize, + pending_task_count: u64, + running_task_count: u64, + waiting_for_confirmation_count: u64, + waiting_for_user_input_count: u64, + new_assistant_message_count: usize, + final_reply_chars: usize, + reconciliation_agent_count: usize, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct SwarmTurnConversationMetrics { + new_assistant_message_count: usize, + final_reply_chars: usize, +} + enum SwarmInputEvent { Line(String), Eof, @@ -910,7 +947,14 @@ fn wait_for_swarm_turn( let mut reconciliation = swarm_reconciliation_agents(&runtimes); if !reconciliation.is_empty() { observer.close_response_line(output)?; - return Ok(SwarmTurnOutcome::NeedsReconciliation(reconciliation)); + return build_reconciliation_turn_outcome( + root, + parent_agent_id, + session_id, + previous_message_count, + &runtimes, + reconciliation, + ); } match observer.resolve_confirmations(root, parent_agent_id, &runtimes, input, output)? { SwarmConfirmationResolution::Handled => { @@ -942,7 +986,14 @@ fn wait_for_swarm_turn( if runtimes_are_busy(&runtimes) && (!runner.enabled || !runner.running) { reconciliation.push("external-runner".to_string()); observer.close_response_line(output)?; - return Ok(SwarmTurnOutcome::NeedsReconciliation(reconciliation)); + return build_reconciliation_turn_outcome( + root, + parent_agent_id, + session_id, + previous_message_count, + &runtimes, + reconciliation, + ); } } if runtimes_are_busy(&runtimes) { @@ -966,7 +1017,7 @@ fn wait_for_swarm_turn( stable_since = Some(Instant::now()); continue; } - print_new_parent_reply( + let conversation_metrics = print_new_parent_reply( root, parent_agent_id, session_id, @@ -974,7 +1025,15 @@ fn wait_for_swarm_turn( output, observer, )?; - return Ok(SwarmTurnOutcome::Settled); + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + parent_agent_id, + session_id, + &runtimes, + conversation_metrics, + 0, + ); + return Ok(SwarmTurnOutcome::Settled(report)); } } match input.recv_timeout(poll_interval) { @@ -1044,20 +1103,22 @@ fn wait_for_swarm_turn( } fn runtimes_are_busy(runtimes: &[AgentRuntimeResult]) -> bool { - runtimes.iter().any(|runtime| { - matches!( - runtime.state.status.as_str(), - "pending" - | "running" - | "waiting-for-confirmation" - | "waiting-for-user-input" - | "cancelling" - ) || runtime.state.phase == "needs-reconciliation" - || runtime.task_queue.pending > 0 - || runtime.task_queue.running > 0 - || runtime.task_queue.waiting_for_confirmation > 0 - || runtime.task_queue.waiting_for_user_input > 0 - }) + runtimes.iter().any(runtime_is_busy) +} + +fn runtime_is_busy(runtime: &AgentRuntimeResult) -> bool { + matches!( + runtime.state.status.as_str(), + "pending" + | "running" + | "waiting-for-confirmation" + | "waiting-for-user-input" + | "cancelling" + ) || runtime.state.phase == "needs-reconciliation" + || runtime.task_queue.pending > 0 + || runtime.task_queue.running > 0 + || runtime.task_queue.waiting_for_confirmation > 0 + || runtime.task_queue.waiting_for_user_input > 0 } fn swarm_reconciliation_agents(runtimes: &[AgentRuntimeResult]) -> Vec { @@ -1074,6 +1135,114 @@ fn swarm_reconciliation_agents(runtimes: &[AgentRuntimeResult]) -> Vec { .collect() } +fn build_reconciliation_turn_outcome( + root: &Path, + parent_agent_id: &str, + session_id: &str, + previous_message_count: usize, + runtimes: &[AgentRuntimeResult], + agent_ids: Vec, +) -> Result { + let conversation_metrics = + read_turn_conversation_metrics(root, parent_agent_id, session_id, previous_message_count)?; + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::NeedsReconciliation, + parent_agent_id, + session_id, + runtimes, + conversation_metrics, + agent_ids.len(), + ); + Ok(SwarmTurnOutcome::NeedsReconciliation { agent_ids, report }) +} + +fn read_turn_conversation_metrics( + root: &Path, + parent_agent_id: &str, + session_id: &str, + previous_message_count: usize, +) -> Result { + let conversation = + read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; + let (metrics, _) = summarize_new_assistant_messages( + conversation + .messages + .iter() + .skip(previous_message_count) + .map(|message| (message.role.as_str(), message.content.as_str())), + ); + Ok(metrics) +} + +fn summarize_new_assistant_messages<'a>( + messages: impl IntoIterator, +) -> (SwarmTurnConversationMetrics, Option<&'a str>) { + let mut new_assistant_message_count = 0; + let mut final_reply = None; + for (role, content) in messages { + if role == "assistant" { + new_assistant_message_count += 1; + final_reply = Some(content); + } + } + ( + SwarmTurnConversationMetrics { + new_assistant_message_count, + final_reply_chars: final_reply.map_or(0, |reply| reply.chars().count()), + }, + final_reply, + ) +} + +fn build_swarm_turn_report( + outcome: SwarmTurnReportOutcome, + parent_agent_id: &str, + session_id: &str, + runtimes: &[AgentRuntimeResult], + conversation_metrics: SwarmTurnConversationMetrics, + reconciliation_agent_count: usize, +) -> SwarmTurnReport { + let parent_run_id = runtimes + .iter() + .find(|runtime| { + runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id + }) + .map(|runtime| runtime.state.run_id.trim()) + .filter(|run_id| !run_id.is_empty()) + .map(str::to_string); + SwarmTurnReport { + schema_version: SWARM_TURN_REPORT_SCHEMA_VERSION, + outcome, + parent_agent_id: parent_agent_id.to_string(), + session_id: session_id.to_string(), + parent_run_id, + runtime_count: runtimes.len(), + busy_runtime_count: runtimes + .iter() + .filter(|runtime| runtime_is_busy(runtime)) + .count(), + pending_task_count: runtimes + .iter() + .map(|runtime| u64::from(runtime.task_queue.pending)) + .sum(), + running_task_count: runtimes + .iter() + .map(|runtime| u64::from(runtime.task_queue.running)) + .sum(), + waiting_for_confirmation_count: runtimes + .iter() + .map(|runtime| u64::from(runtime.task_queue.waiting_for_confirmation)) + .sum(), + waiting_for_user_input_count: runtimes + .iter() + .map(|runtime| u64::from(runtime.task_queue.waiting_for_user_input)) + .sum(), + new_assistant_message_count: conversation_metrics.new_assistant_message_count, + final_reply_chars: conversation_metrics.final_reply_chars, + reconciliation_agent_count, + } +} + fn print_new_parent_reply( root: &Path, parent_agent_id: &str, @@ -1081,23 +1250,19 @@ fn print_new_parent_reply( previous_message_count: usize, output: &mut W, observer: &mut SwarmRuntimeObserver, -) -> Result<(), String> { +) -> Result { let conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; - let reply = conversation - .messages - .iter() - .skip(previous_message_count) - .filter(|message| message.role == "assistant") - .next_back(); + let (metrics, reply) = summarize_new_assistant_messages( + conversation + .messages + .iter() + .skip(previous_message_count) + .map(|message| (message.role.as_str(), message.content.as_str())), + ); observer.close_response_line(output)?; - print_settled_parent_reply( - parent_agent_id, - session_id, - reply.map(|message| message.content.as_str()), - observer, - output, - ) + print_settled_parent_reply(parent_agent_id, session_id, reply, observer, output)?; + Ok(metrics) } fn print_settled_parent_reply( @@ -1120,15 +1285,29 @@ fn print_settled_parent_reply( } fn print_turn_outcome(outcome: SwarmTurnOutcome, output: &mut W) -> Result<(), String> { - if let SwarmTurnOutcome::NeedsReconciliation(agent_ids) = outcome { - writeln!( - output, - "[已阻断] 以下 Agent 需要人工 reconciliation:{}", - agent_ids.join(", ") - ) - .map_err(|error| format!("写入终端失败:{error}"))?; + match outcome { + SwarmTurnOutcome::Settled(report) => print_swarm_turn_report(&report, output), + SwarmTurnOutcome::NeedsReconciliation { agent_ids, report } => { + writeln!( + output, + "[已阻断] 以下 Agent 需要人工 reconciliation:{}", + agent_ids.join(", ") + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + print_swarm_turn_report(&report, output) + } + SwarmTurnOutcome::Quit => Ok(()), } - Ok(()) +} + +fn print_swarm_turn_report( + report: &SwarmTurnReport, + output: &mut W, +) -> Result<(), String> { + let json = serde_json::to_string(report) + .map_err(|error| format!("序列化 turn report 失败:{error}"))?; + writeln!(output, "{SWARM_TURN_REPORT_PREFIX}{json}") + .map_err(|error| format!("写入终端失败:{error}")) } impl SwarmResponseStreamIdentity { @@ -2175,6 +2354,176 @@ mod tests { ); } + #[test] + fn turn_report_counts_runtime_and_conversation_snapshots() { + let mut parent = runtime("running", "response", 2); + parent.state.agent_id = "project-supervisor".to_string(); + parent.state.session_id = "session-report".to_string(); + parent.state.run_id = "run-parent".to_string(); + parent.task_queue.running = 1; + parent.task_queue.waiting_for_confirmation = 2; + + let mut child = runtime("idle", "completed", 3); + child.state.agent_id = "child-code".to_string(); + child.task_queue.waiting_for_user_input = 1; + + let mut idle = runtime("idle", "completed", 0); + idle.state.agent_id = "design-review".to_string(); + let runtimes = vec![parent, child, idle]; + let (conversation_metrics, final_reply) = summarize_new_assistant_messages([ + ("user", "请继续"), + ("assistant", "阶段回复"), + ("tool", "PRIVATE_OBSERVATION"), + ("assistant", "最终🙂"), + ]); + assert_eq!(final_reply, Some("最终🙂")); + + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::NeedsReconciliation, + "project-supervisor", + "session-report", + &runtimes, + conversation_metrics, + 1, + ); + + assert_eq!(report.schema_version, SWARM_TURN_REPORT_SCHEMA_VERSION); + assert_eq!(report.outcome, SwarmTurnReportOutcome::NeedsReconciliation); + assert_eq!(report.parent_agent_id, "project-supervisor"); + assert_eq!(report.session_id, "session-report"); + assert_eq!(report.parent_run_id.as_deref(), Some("run-parent")); + assert_eq!(report.runtime_count, 3); + assert_eq!(report.busy_runtime_count, 2); + assert_eq!(report.pending_task_count, 5); + assert_eq!(report.running_task_count, 1); + assert_eq!(report.waiting_for_confirmation_count, 2); + assert_eq!(report.waiting_for_user_input_count, 1); + assert_eq!(report.new_assistant_message_count, 2); + assert_eq!(report.final_reply_chars, "最终🙂".chars().count()); + assert_eq!(report.reconciliation_agent_count, 1); + } + + #[test] + fn turn_report_json_is_single_line_and_omits_sensitive_bodies_and_paths() { + let sensitive_reply = concat!( + "PRIVATE_REPLY_BODY\n", + "/private/project/root ", + "prompt=DO_NOT_LEAK observation=DO_NOT_LEAK CREDENTIAL_SENTINEL" + ); + let (conversation_metrics, _) = + summarize_new_assistant_messages([("assistant", sensitive_reply)]); + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + "project-supervisor", + "session-safe", + &[], + conversation_metrics, + 0, + ); + let json = serde_json::to_string(&report).expect("serialize turn report"); + let value = serde_json::from_str::(&json).expect("parse turn report"); + let object = value.as_object().expect("turn report is an object"); + + assert_eq!(json.lines().count(), 1); + assert_eq!(object.len(), 14); + for key in [ + "schemaVersion", + "outcome", + "parentAgentId", + "sessionId", + "parentRunId", + "runtimeCount", + "busyRuntimeCount", + "pendingTaskCount", + "runningTaskCount", + "waitingForConfirmationCount", + "waitingForUserInputCount", + "newAssistantMessageCount", + "finalReplyChars", + "reconciliationAgentCount", + ] { + assert!(object.contains_key(key), "turn report omitted {key}"); + } + assert_eq!( + value["schemaVersion"], + serde_json::json!(SWARM_TURN_REPORT_SCHEMA_VERSION) + ); + assert_eq!(value["outcome"], serde_json::json!("settled")); + assert_eq!(value["parentRunId"], serde_json::Value::Null); + assert_eq!(value["newAssistantMessageCount"], serde_json::json!(1)); + assert_eq!( + value["finalReplyChars"], + serde_json::json!(sensitive_reply.chars().count()) + ); + for forbidden in [ + "PRIVATE_REPLY_BODY", + "/private/project/root", + "DO_NOT_LEAK", + "CREDENTIAL_SENTINEL", + ] { + assert!(!json.contains(forbidden), "report leaked {forbidden}"); + } + } + + #[test] + fn turn_outcome_prints_settled_and_reconciliation_reports_but_not_quit() { + let metrics = SwarmTurnConversationMetrics { + new_assistant_message_count: 1, + final_reply_chars: 4, + }; + let settled_report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + "project-supervisor", + "session-settled", + &[], + metrics, + 0, + ); + let mut settled_output = Vec::new(); + print_turn_outcome( + SwarmTurnOutcome::Settled(settled_report), + &mut settled_output, + ) + .expect("print settled report"); + let settled_output = String::from_utf8(settled_output).expect("settled output is utf-8"); + assert_eq!(settled_output.lines().count(), 1); + assert!(settled_output.starts_with(SWARM_TURN_REPORT_PREFIX)); + assert!(settled_output.contains("\"outcome\":\"settled\"")); + + let reconciliation_report = build_swarm_turn_report( + SwarmTurnReportOutcome::NeedsReconciliation, + "project-supervisor", + "session-reconciliation", + &[], + metrics, + 2, + ); + let mut reconciliation_output = Vec::new(); + print_turn_outcome( + SwarmTurnOutcome::NeedsReconciliation { + agent_ids: vec!["code-prototype".to_string(), "external-runner".to_string()], + report: reconciliation_report, + }, + &mut reconciliation_output, + ) + .expect("print reconciliation report"); + let reconciliation_output = + String::from_utf8(reconciliation_output).expect("reconciliation output is utf-8"); + let lines = reconciliation_output.lines().collect::>(); + assert_eq!(lines.len(), 2); + assert_eq!( + lines[0], + "[已阻断] 以下 Agent 需要人工 reconciliation:code-prototype, external-runner" + ); + assert!(lines[1].starts_with(SWARM_TURN_REPORT_PREFIX)); + assert!(lines[1].contains("\"outcome\":\"needs-reconciliation\"")); + assert!(lines[1].contains("\"reconciliationAgentCount\":2")); + + let mut quit_output = Vec::new(); + print_turn_outcome(SwarmTurnOutcome::Quit, &mut quit_output).expect("ignore quit"); + assert!(quit_output.is_empty()); + } + #[test] fn blank_agent_snapshots_do_not_reset_the_settle_window() { let mut blank = runtime("idle", "idle", 0); @@ -2520,10 +2869,23 @@ mod tests { &mut output, ) .expect("settle streamed reply"); + let (conversation_metrics, _) = + summarize_new_assistant_messages([("assistant", "权威最终回复")]); + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + "code-prototype", + "session-test", + &[], + conversation_metrics, + 0, + ); + print_turn_outcome(SwarmTurnOutcome::Settled(report), &mut output) + .expect("print settled report after stream"); let output = String::from_utf8(output).expect("settle output is utf-8"); assert_eq!(output.matches("权威最终回复").count(), 1); assert!(output.contains("父 Agent 回复已完整流式输出")); + assert!(output.contains(SWARM_TURN_REPORT_PREFIX)); let mut fallback = Vec::new(); print_settled_parent_reply( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 2b747a327..ed9c03aaf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -11955,6 +11955,154 @@ fn agent_runtime_context_window_ignores_dynamic_successful_verification_output() } } +#[test] +fn agent_runtime_context_window_skips_duplicate_agent_message_noops() { + let mut tracker = AgentRuntimeContextWindowTracker::default(); + for next_loop_index in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + tracker.record(&AgentRuntimeToolObservation { + tool: "agent.message".to_string(), + status: "ok".to_string(), + summary: "给 project-supervisor 的相同定向消息已存在,未重复追加".to_string(), + detail: Some("messageAppended=false".to_string()), + }); + let checkpoint = tracker.complete_loop(next_loop_index); + if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + assert_eq!( + checkpoint, + AgentRuntimeContextCheckpoint::Stalled, + "durable duplicate messages must not keep a run alive" + ); + } else { + assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Continue); + } + } +} + +#[test] +fn agent_runtime_context_window_counts_distinct_agent_message_bodies() { + let mut tracker = AgentRuntimeContextWindowTracker::default(); + for next_loop_index in 1..=AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + let detail = if next_loop_index % 2 == 0 { + "第二条真实新消息" + } else { + "第一条真实新消息" + }; + tracker.record(&AgentRuntimeToolObservation { + tool: "agent.message".to_string(), + status: "ok".to_string(), + summary: "已给 project-supervisor 留消息".to_string(), + detail: Some(detail.to_string()), + }); + let checkpoint = tracker.complete_loop(next_loop_index); + if next_loop_index == AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + assert_eq!( + checkpoint, + AgentRuntimeContextCheckpoint::Compacted, + "two distinct durable messages must still count as independent progress" + ); + } else { + assert_eq!(checkpoint, AgentRuntimeContextCheckpoint::Continue); + } + } +} + +#[test] +fn agent_runtime_agent_message_uses_durable_semantic_idempotency() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "定向消息幂等测试").expect("project init"); + let first_input = serde_json::json!({ + "agentId": "art-asset-plan", + "content": "请核对主角剪影与月光食材。" + }); + let first = observe_agent_runtime_agent_message( + &root, + "design-director", + "agent-message-semantic-run", + &first_input, + ); + assert_eq!(first.status, "ok", "{first:?}"); + assert!(first.summary.contains("已给 art-asset-plan 留消息")); + + let duplicate = observe_agent_runtime_agent_message( + &root, + "design-director", + "agent-message-semantic-run", + &first_input, + ); + assert_eq!(duplicate.status, "ok"); + assert!(duplicate.summary.contains("相同定向消息已存在")); + assert_eq!(duplicate.detail.as_deref(), Some("messageAppended=false")); + + let distinct = observe_agent_runtime_agent_message( + &root, + "design-director", + "agent-message-semantic-run", + &serde_json::json!({ + "agentId": "art-asset-plan", + "content": "请补充暗影厨具的轮廓风险。" + }), + ); + assert_eq!(distinct.status, "ok"); + assert!(distinct.summary.contains("已给 art-asset-plan 留消息")); + let other_target = observe_agent_runtime_agent_message( + &root, + "design-director", + "agent-message-semantic-run", + &serde_json::json!({ + "agentId": "quality-review", + "content": "请核对主角剪影与月光食材。" + }), + ); + assert_eq!(other_target.status, "ok"); + assert!(other_target.summary.contains("已给 quality-review 留消息")); + + let art_conversation = + read_local_conversation_at(&root, Some("art-asset-plan")).expect("art conversation"); + assert_eq!( + art_conversation + .messages + .iter() + .filter(|message| message.role == "tool") + .count(), + 2 + ); + let quality_conversation = + read_local_conversation_at(&root, Some("quality-review")).expect("quality conversation"); + assert_eq!( + quality_conversation + .messages + .iter() + .filter(|message| message.role == "tool") + .count(), + 1 + ); + let message_records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) == Some("agent.runtime.agent.message") + }) + .collect::>(); + assert_eq!(message_records.len(), 3); + assert_eq!( + message_records + .iter() + .filter_map(|record| record.get("messageId").and_then(Value::as_str)) + .collect::>() + .len(), + 3 + ); + assert!(message_records.iter().all(|record| { + record + .get("contentSha256") + .and_then(Value::as_str) + .is_some() + && record.get("contentChars").and_then(Value::as_u64).is_some() + && record.get("content").is_none() + })); + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_runtime_context_bundle_preserves_project_verification_gate_evidence() { let root = unique_project_path(); @@ -14220,6 +14368,146 @@ fn runtime_v11_waiting_isolated_join_resume_preserves_loop_and_child() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn project_supervisor_mixed_waiting_recovery_does_not_plan_until_all_join_ready() { + use platform_agent::game_creation::{ + GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentJoinMode, + GameCreationIsolatedAgentSpawnRequest, + }; + + 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![final_tool_plan_response( + "不应在 all-join ready 前请求 Provider", + )], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "mixed-waiting-key", + "baseUrl": {base_url:?}, + "model": "mixed-waiting-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let task = "等待静态专业回执与动态隔离 all-join"; + let parent_run_id = "project-supervisor-mixed-waiting-run"; + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + task, + parent_run_id, + "agent-chat", + "等待混合委派", + vec!["取得两类交付后统一收束".to_string()], + ) + .expect("start mixed waiting parent"); + parent_state.status = "running".to_string(); + parent_state.phase = "waiting-for-delegate-receipts".to_string(); + parent_state.current_action = "等待专业 Agent 委派回执".to_string(); + parent_state.waiting_on = "静态与隔离 Agent 完成".to_string(); + parent_state.next_step = "保持等待,不请求 Provider".to_string(); + parent_state.loop_iteration = 4; + parent_state.max_loop_iterations = 18; + + let static_delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + "project-supervisor-mixed-waiting-static-action", + "project-supervisor-mixed-waiting-static-delivery", + "design-director", + "project-supervisor-mixed-waiting-static-session", + "project-supervisor-mixed-waiting-static-run", + ); + create_or_read_static_delegate_delivery_at(&root, &static_delivery) + .expect("create ready static delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &static_delivery.target_agent_id, + &static_delivery.target_session_id, + &static_delivery.target_run_id, + &static_delivery.delegation_id, + "completed", + "静态专业交付已完成", + ) + .expect("mark static delivery ready"); + let isolated_request = GameCreationIsolatedAgentSpawnRequest { + children: vec![GameCreationIsolatedAgentChildSpec { + template_agent_id: "code-prototype".to_string(), + task: "继续执行尚未完成的隔离检查".to_string(), + acceptance_criteria: vec!["隔离检查完成".to_string()], + expected_artifacts: vec!["game/mixed/output.txt".to_string()], + write_scopes: vec!["game/mixed/**".to_string()], + }], + join_mode: GameCreationIsolatedAgentJoinMode::All, + }; + create_or_read_isolated_group_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &parent_state.session_id, + "project-supervisor-mixed-waiting-isolated-action", + &isolated_request, + ) + .expect("create waiting isolated group"); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append mixed waiting parent task"); + write_game_creator_agent_runtime_state(&root, &parent_state) + .expect("persist mixed waiting parent state"); + let context = build_game_creator_agent_runtime_context_bundle( + &root, + &parent_state, + task, + &AgentRuntimeToolPlan::default(), + &[], + 4, + &AgentRuntimeContextWindowTracker::default(), + ) + .expect("build mixed waiting context"); + write_game_creator_agent_runtime_context_bundle(&root, &context) + .expect("persist mixed waiting context"); + + resume_game_creator_agent_background_tasks_at(&root).expect("resume mixed waiting parent"); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + let runtime = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read mixed waiting parent") + .state; + if runtime.phase == "waiting-for-isolated-join" { + assert_eq!(runtime.run_id, parent_run_id); + assert_eq!(runtime.loop_iteration, 4); + assert_eq!(runtime.max_loop_iterations, 18); + break; + } + assert!( + std::time::Instant::now() < deadline, + "mixed parent did not switch to isolated waiting" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + resume_game_creator_agent_background_tasks_at(&root) + .expect("repeat resume mixed waiting parent"); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + let stable = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read stable mixed waiting parent") + .state; + assert_eq!(stable.run_id, parent_run_id); + assert_eq!(stable.phase, "waiting-for-isolated-join"); + assert_eq!(stable.loop_iteration, 4); + + fs::remove_dir_all(root).ok(); +} + #[test] fn repository_context_v1_pending_fingerprint_blocks_project_mutation() { let root = unique_project_path(); @@ -44632,6 +44920,9 @@ async fn project_supervisor_prompts_are_total_control_and_reject_isolated_templa "唯一面向用户的 Project Supervisor", "用户原始目标", "专业 Agent 回执只能补充证据", + "总控不能替代已有专业角色", + "同一个 native planning 批次", + "用户不需要点名 Agent", "agent.delegate", "readyDelegateReceipts", "acceptanceCriteria", @@ -44639,6 +44930,7 @@ async fn project_supervisor_prompts_are_total_control_and_reject_isolated_templa "evidence-ready", "所有必要回执已认领", "不能反复只改 explanation", + "不能替代自身终态交付", ] { assert!( planning_prompt.contains(expected), @@ -47776,6 +48068,273 @@ async fn project_supervisor_resume_replays_executing_run_status_observation() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn project_supervisor_mixed_run_status_recovery_reuses_partial_isolated_claim_after_revision_drift( +) { + use platform_agent::game_creation::{ + GameCreationIsolatedAgentArtifact, GameCreationIsolatedAgentChildResult, + GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentEvidence, + GameCreationIsolatedAgentJoinMode, GameCreationIsolatedAgentResultStatus, + GameCreationIsolatedAgentSpawnRequest, + }; + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "混合委派部分认领恢复测试") + .expect("project init"); + let base_url = spawn_mock_llm_server_responses(vec![final_tool_plan_response( + "已恢复同一 mixed run_status 并完成收束。", + )]); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "mixed-resume-key", + "baseUrl": {base_url:?}, + "model": "mixed-resume-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let parent_run_id = "project-supervisor-mixed-resume-parent-run"; + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "恢复部分完成的混合交付认领动作", + parent_run_id, + "agent-chat", + "认领 mixed 交付", + vec!["恢复同一个 agent.run_status observation".to_string()], + ) + .expect("start mixed recovery parent"); + parent_state.loop_iteration = 1; + + let static_delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + parent_run_id, + "project-supervisor-mixed-resume-static-action", + "project-supervisor-mixed-resume-static-delivery", + "design-director", + "project-supervisor-mixed-resume-static-session", + "project-supervisor-mixed-resume-static-run", + ); + create_or_read_static_delegate_delivery_at(&root, &static_delivery) + .expect("create mixed static delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &static_delivery.target_agent_id, + &static_delivery.target_session_id, + &static_delivery.target_run_id, + &static_delivery.delegation_id, + "completed", + "静态交付已 ready", + ) + .expect("mark mixed static delivery ready"); + + let isolated_request = GameCreationIsolatedAgentSpawnRequest { + children: vec![GameCreationIsolatedAgentChildSpec { + template_agent_id: "code-prototype".to_string(), + task: "完成独立 mixed 检查".to_string(), + acceptance_criteria: vec!["mixed 检查已完成".to_string()], + expected_artifacts: vec!["game/mixed/result.txt".to_string()], + write_scopes: vec!["game/mixed/**".to_string()], + }], + join_mode: GameCreationIsolatedAgentJoinMode::All, + }; + let group = create_or_read_isolated_group_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &parent_state.session_id, + "project-supervisor-mixed-resume-isolated-action", + &isolated_request, + ) + .expect("create mixed isolated group"); + let instance = resolve_isolated_agent_instance_at(&root, &group.instance_ids[0]) + .expect("resolve mixed isolated instance"); + let join = record_isolated_child_result_at( + &root, + &GameCreationIsolatedAgentChildResult { + delegation_id: instance.delegation_id.clone(), + instance_id: instance.instance_id.clone(), + template_agent_id: instance.template_agent_id.clone(), + run_id: instance.run_id.clone(), + status: GameCreationIsolatedAgentResultStatus::Completed, + summary: "mixed 隔离检查已完成".to_string(), + artifacts: vec![GameCreationIsolatedAgentArtifact { + path: "game/mixed/result.txt".to_string(), + sha256: "b".repeat(64), + }], + evidence: vec![GameCreationIsolatedAgentEvidence { + kind: "project.verify".to_string(), + summary: "mixed 隔离验证通过".to_string(), + path: None, + sha256: None, + }], + verified_revision: Some(1), + error: None, + }, + ) + .expect("record mixed isolated result") + .expect("mixed isolated join ready"); + + let mut pending = pending_tool_action_for_test( + &root, + &parent_state, + AgentRuntimeToolAction { + tool: "agent.run_status".to_string(), + reason: Some("同时认领 static receipt 与 isolated all-join".to_string()), + input: serde_json::json!({ "scope": "self" }), + }, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("persist mixed executing run_status"); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_action.executing", + "agentId": pending.agent_id, + "taskId": pending.task_id, + "runId": pending.run_id, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "tool": pending.action.tool, + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "inputSummary": pending.input_summary, + }), + ) + .expect("append mixed executing audit"); + parent_state.status = "running".to_string(); + parent_state.phase = "action".to_string(); + parent_state.current_action = "恢复执行 mixed agent.run_status".to_string(); + parent_state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append mixed executing parent task"); + write_game_creator_agent_runtime_state(&root, &parent_state) + .expect("persist mixed executing parent state"); + + let partial_claim = write_isolated_join_delivery_at( + &root, + &join, + IsolatedAgentJoinDeliveryStatus::ClaimedByParent, + None, + Some(&pending.action_id), + ) + .expect("persist partial isolated claim before crash"); + assert_eq!( + partial_claim.claimed_by_action_id.as_deref(), + Some(pending.action_id.as_str()) + ); + let before_static = static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read static barrier before mixed recovery"); + assert_eq!(before_static.ready_unclaimed_count, 1); + advance_project_revision_for_test( + &root, + "code-prototype", + "mixed-external-revision-run", + "file.write", + ); + + let resumed = resume_game_creator_agent_pending_action_for_agent_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &pending.action_id, + ) + .expect("resume partially claimed mixed run_status"); + assert_eq!(resumed.state.run_id, parent_run_id); + assert_eq!(resumed.state.phase, "observation"); + let stable_isolated = read_isolated_join_delivery_at(&root, &join) + .expect("read isolated claim after mixed recovery") + .expect("isolated claim remains present"); + assert_eq!(stable_isolated, partial_claim); + let claimed_static = read_static_delegate_delivery_at(&root, &static_delivery.delegation_id) + .expect("read claimed static delivery") + .expect("claimed static delivery exists"); + assert_eq!( + claimed_static.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + assert_eq!( + claimed_static.claimed_by_action_id.as_deref(), + Some(pending.action_id.as_str()) + ); + assert!(static_delegate_completion_barrier_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read observed mixed static barrier") + .is_clear()); + + let completed = wait_for_agent_runtime_idle(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID); + assert_eq!(completed.phase, "completed"); + assert_eq!( + completed.last_response.as_deref(), + Some("已恢复同一 mixed run_status 并完成收束。") + ); + let replayed = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(&pending.action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(replayed.status, "ok"); + let replayed_detail = replayed.detail.unwrap_or_default(); + assert!(replayed_detail.contains("readyIsolatedJoins")); + assert!(replayed_detail.contains("readyDelegateReceipts")); + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.agent.isolated_join.claimed_by_parent") + && record.get("actionId").and_then(Value::as_str) + == Some(pending.action_id.as_str()) + }) + .count(), + 1 + ); + assert_eq!( + records + .iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.agent.delegate_receipts.claimed_by_parent") + && record.get("actionId").and_then(Value::as_str) + == Some(pending.action_id.as_str()) + }) + .count(), + 1 + ); + let conversation = read_local_conversation_for_session_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + Some(&parent_state.session_id), + ) + .expect("read mixed recovery supervisor conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reservation() { let root = unique_project_path(); diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 148a9ba52..19f059dcb 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -662,6 +662,7 @@ V1.15 新增不依赖 Tauri WebView 或正常客户端 GUI 的终端聊天入口 - 终端只提供聊天所需的轻量控制命令:`/help`、`/agents`、`/status`、`/history`、`/quit`。空闲时普通文本创建父 Agent 新 run;父 Agent run 仍处于 pending / running 时普通文本追加为同一 run steer,只有 child 忙而父 Agent 已终态时拒绝吞掉输入并要求稍后重发。确认动作在终端显示 Agent、run、action、tool 和安全摘要,并接受 `approve / reject`,分别调用现有 confirm / reject Runtime 路径,不能要求回到开发窗口。stdin 由独立读取线程投递,因此活跃 run 中 `/quit`、EOF、状态命令和 steer 仍可响应;退出只结束观察客户端。 - 每轮轮询 `read_game_creator_agent_runtimes_at`,按 Agent / run / event 去重输出状态、phase、委派来源、父 Agent、delegationId、动态 child 和 join / receipt 事件。Provider token delta 当前没有经过 Runner RPC 暴露,首版只承诺 Runtime 状态与事件的持续输出以及持久化后的最终父 Agent 回复,禁止用拆字或延时打印伪装 token streaming。 - 一轮只有在所有已发现 Runtime 都不处于 `pending / running / waiting-for-confirmation / cancelling / needs-reconciliation`,全部任务队列为空,并持续经过稳定观察窗口后才能收束。父 run 暂时 idle 但 delegated child 尚未终态、receipt 尚未入队或 all-join 尚未认领时不得提前返回。失败、取消和 reconciliation 要明确显示并保留项目现场,不自动重试副作用。 +- 每轮进入 `settled` 或 `needs-reconciliation` 终态后,终端必须额外输出且只输出一条 `[turn.report] <单行 JSON>`,schema 固定为 `game-creator-swarm-turn-report.v1`。报告只从本轮 authoritative conversation 与 Runtime snapshot 计算,白名单字段至少包含 outcome、父 Agent/Session/run 身份、Runtime 忙闲数量、四类任务队列计数、新增 assistant 数量、最终回复字符数和 reconciliation Agent 数量;不得包含项目路径、对话/任务/回复正文、observation、prompt、event detail、隐藏 thinking、Provider payload、凭据或本地存储路径。该行用于开发终端和真实 E2E 定位一轮边界,不替代 task/event/delivery/claim/receipt/conversation 等持久事实;`/quit` 不伪造 turn report。 - 终端退出只结束观察客户端,不终止 External Runner、已投递 run 或 Runner-owned process session;下次启动先调用现有 resume,再从 conversation 与 Runtime journal 恢复。首版只允许一个前台输入流,不承诺多个终端并发编辑同一 active Session。 确定性验收必须覆盖 CLI parse、项目绝对路径与 `--config-dir` 门禁、`--init`、空输入和 EOF、命令分流、历史恢复、连续两轮写入同一 Session、状态与事件去重、两个静态 Agent 并行委派、多个隔离 child 并行与唯一 all-join、confirm / reject、父 Agent receipt 汇总、稳定窗口不早退、Runner / 终端重启恢复以及失败与 reconciliation 显示。真实 Provider 验收必须保存一份脱敏 transcript,并以 task / event / Agent DB / receipt / conversation 的结构化事实证明并行、最终父回复唯一、副作用无重放和密钥零泄漏;未实际运行时只能标记未验收,不能凭确定性测试宣称 swarm 可用。 @@ -1128,6 +1129,18 @@ Supervisor 认领回执后必须能够再次从 durable delivery 取回权威返 该受控 suite 是 V1.28 协议与恢复的故障注入门禁,不替代后续自主 Swarm 验收。现有 fixture 明确给出两个专业方向、同轮要求和一次 repair 上限;“Supervisor 在不提供 Agent ID、并行配方或 repair 次数时自主选择编排”仍需独立 `supervisor-swarm-autonomous` 真实 suite 证明。真实 `--swarm-chat`、同一 run 的 static delivery + isolated all-join 组合以及 Tauri/WebView 宿主级 Supervisor GUI 也仍是单独完成项。 +## V1.30 Project Supervisor 自主终端协作验收 + +V1.30 新增独立 `supervisor-swarm-autonomous-chat` 真实 Provider suite,同时证明 Project Supervisor 的自主专业编排和正式 `agc:chat / --swarm-chat` 入口。它复用 V1.28 的 static delivery/claim/repair、External Runner、隔离 AppData、确认、恢复、finalization 和唯一回复事实源,不新增 Agent、调度器、Provider 客户端或第二套对话持久化。现有 `supervisor-swarm` 与 `supervisor-swarm-transient-retry` 继续分别承担固定协议链和受控瞬态故障门禁,不能被本 suite 替代。 + +- 唯一用户任务只能表达业务结果,例如把试玩项目推进到可交给首批玩家体验并汇报交付、验证和风险;任务不得出现静态 Agent ID、Agent 数量、同轮/并行要求、planning 轮次、返工/repair 次数、原生工具名、run/action/delegation 身份或 Runner 操作。一次性仓库规则只描述玩家体验规格、发布质量记录、语义验收、修改后验证和安全边界;不得指定由哪个 Agent 承担、必须同批委派、必须返工几次或调用什么工具。 +- fixture 提供一项缺失的体验规格和一项“客观文件/验证存在但语义仍不满足”的质量记录。质量记录在初始阶段必须先独立审阅再允许修改;harness 只通过项目 policy 暂时拒绝质量角色写入,弱回执被父 run 认领后解除 policy,不发送 steer、不改业务文件、不补充新任务。初始弱回执必须是 `completed + evidence-ready` 且无缺失产物,确保后续 repair 来自 Supervisor 对 acceptance criteria 的语义判断,而不是 Runtime 自动把客观失败标成 `needs-repair`。 +- `--swarm-chat --init ` 必须由真实发布二进制启动,省略 parentAgentId 后进入 `project-supervisor`;用户任务通过 stdin 发送,所有确认也经同一终端 `approve` 入口完成。允许 Supervisor 先做必要读取,但首个包含专业委派的 native Provider 批次必须自主选择至少两个不同规范专业 Agent,并在同批形成两个初始合同;两个 child 的真实 Provider lifecycle 必须重叠,不能用同一 Agent 的 retry/format repair 或仅凭 delegate action 时间冒充并行。 +- 弱质量 claim 进入 `Observed` 后,Supervisor 必须在同一父 Session/run 自主创建引用原 delivery 的唯一 repair;目标 Agent、acceptanceCriteria 和 expectedArtifacts 必须完整继承,repair action 必须晚于弱 claim、早于唯一最终回复。repair 待确认边界继续执行 pidfd Runner 强杀与 boot 恢复,任务、delivery、claim、pending action 和 Provider started 身份不得漂移或重放。 +- 终局必须同时满足:严格 host oracle 判定两项产物语义正确,最后修改后的验证凭证有效;`[turn.report]` 为 v1/settled、父 Agent/Session/run 与 journal 一致、新增 assistant 恰好 1、队列和 reconciliation 计数为 0;正式用户会话只有 1 条 user 和 1 条 Supervisor assistant,专业 assistant 仅留在内部 Session;无 steer、重复 delivery/action/message/receipt/Provider lifecycle、残留 sidecar、Provider payload、私有正文、API Key、诱饵、项目/正式配置路径或报告泄漏,隔离 Runner/AppData/项目全部清理。任何一次带更明确提示的重跑都只能算新的失败后尝试,不能与原 run 拼接成 PASS。 + +该 suite 完成后只能证明自主 static 专业编排与真实终端聊天可组合;同一父 run 的 static delivery + isolated all-join 组合恢复,以及 Tauri/WebView 宿主级 Supervisor E2E 仍需各自独立门禁。 + ## 验收命令 - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture` @@ -1157,6 +1170,7 @@ Supervisor 认领回执后必须能够再次从 durable delivery 取回权威返 - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite parallel-read` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite supervisor-swarm` - `npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-transient-retry-real-e2e -- --config-dir ` +- `npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-autonomous-chat-real-e2e -- --config-dir ` - `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir --suite full` - `npm run check:encoding` - `git diff --check` diff --git a/package.json b/package.json index 22e9cd545..366520bbc 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "ai-game-creator-shell:agent-run": "npm --prefix apps/ai-game-creator-shell run agent-run --", "ai-game-creator-shell:agent-run:smoke": "npm --prefix apps/ai-game-creator-shell run agent-run:smoke", "ai-game-creator-shell:agent-runtime:real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:real-e2e --", + "ai-game-creator-shell:agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-swarm-autonomous-chat-real-e2e --", "ai-game-creator-shell:agent-runtime:supervisor-swarm-transient-retry-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-swarm-transient-retry-real-e2e --", "ai-game-creator-shell:agent-runtime:steer-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-real-e2e --", "ai-game-creator-shell:agent-runtime:steer-runner-kill-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-runner-kill-real-e2e --",