From 2110fae00e1f7f1f3c1f350b8647e8e27fb0b5c3 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Sat, 19 Sep 2026 23:28:57 -0400 Subject: [PATCH] feat: orchestrate zero-swarm two-stage ticks --- apps/game-api/src/app.ts | 16 +- apps/game-api/src/experiment-export.ts | 39 +- apps/game-api/src/reflex-execution.test.ts | 2 +- apps/game-api/src/reflex-execution.ts | 17 +- .../src/simulation-service.swarm.test.ts | 361 ++++++++++++ apps/game-api/src/simulation-service.ts | 538 +++++++++++++++++- docs/ARCHITECTURE.md | 22 +- docs/EXPERIMENT_ARCHIVE.md | 6 + docs/SECURITY.md | 9 + docs/TESTING.md | 6 +- docs/adr/0029-zero-swarm-two-stage-tick.md | 42 ++ packages/agent-runtime/src/index.ts | 1 + .../agent-runtime/src/swarm-planner.test.ts | 153 +++++ packages/agent-runtime/src/swarm-planner.ts | 477 ++++++++++++++++ .../experiment-archive/src/archive.test.ts | 48 ++ packages/experiment-archive/src/importer.ts | 25 + packages/experiment-archive/src/migrations.ts | 16 + packages/shared/src/index.ts | 254 ++++++++- 18 files changed, 2002 insertions(+), 30 deletions(-) create mode 100644 apps/game-api/src/simulation-service.swarm.test.ts create mode 100644 docs/adr/0029-zero-swarm-two-stage-tick.md create mode 100644 packages/agent-runtime/src/swarm-planner.test.ts create mode 100644 packages/agent-runtime/src/swarm-planner.ts diff --git a/apps/game-api/src/app.ts b/apps/game-api/src/app.ts index a568c06..69e2116 100644 --- a/apps/game-api/src/app.ts +++ b/apps/game-api/src/app.ts @@ -6,7 +6,11 @@ import { AgentProviderError, OpenRouterModelCatalog, OpenRouterAgentProvider, + OpenRouterSwarmPlanner, + TypeSafeJevReflexProvider, type AgentProvider, + type ReflexProvider, + type SwarmPlanner, } from '@hexzero/agent-runtime'; import { archiveExperimentExportRequestSchema, @@ -74,6 +78,8 @@ export { healthResponseSchema }; export interface AppOptions { service?: SimulationService; provider?: AgentProvider; + swarmPlanner?: SwarmPlanner; + reflexProvider?: ReflexProvider; catalog?: Pick; geocoder?: Geocoder; archiveExperimentExport?: ( @@ -134,6 +140,8 @@ export function createApp(options: AppOptions = {}) { options.service ?? new SimulationService({ provider: options.provider ?? providerFromEnvironment(), + swarmPlanner: options.swarmPlanner ?? new OpenRouterSwarmPlanner(), + reflexProvider: options.reflexProvider ?? new TypeSafeJevReflexProvider(), }); const catalog = options.catalog ?? @@ -545,10 +553,14 @@ export function createApp(options: AppOptions = {}) { try { const response = await mutationPromise(context, 'tick', async () => { const records = await service.executeNextTick(); + const snapshot = service.getSnapshot(); return singleTickResponseSchema.parse({ - snapshot: service.getSnapshot(), - tickNumber: records[0]!.tickNumber, + snapshot, + tickNumber: snapshot.tickNumber, records, + ...(snapshot.scenario.cognitionMode === 'zero-swarm-v1' + ? { swarmTick: snapshot.swarmTicks?.at(-1) } + : {}), }); }); return context.json(response); diff --git a/apps/game-api/src/experiment-export.ts b/apps/game-api/src/experiment-export.ts index fe1876c..bb1aeb5 100644 --- a/apps/game-api/src/experiment-export.ts +++ b/apps/game-api/src/experiment-export.ts @@ -38,6 +38,7 @@ import { type ProviderAttemptRecord, type ProviderAttemptRetention, type ExperimentAttemptAccounting, + type SwarmTickRecord, } from '@hexzero/shared'; export interface ExperimentSource { @@ -48,6 +49,7 @@ export interface ExperimentSource { retentionLimit: number; totalCompletedTurns: number; turns: readonly AgentTurnRecord[]; + swarmTicks?: readonly SwarmTickRecord[]; initialAgents: readonly Agent[]; currentAgents: readonly Agent[]; configurationEvents: readonly ExperimentConfigurationEvent[]; @@ -745,6 +747,18 @@ export function createExperimentExport( requestedRangeExtendsBeyondRetention, }; const include = inclusionsFor(request); + const exportedSwarmTicks: SwarmTickRecord[] | undefined = + source.scenario.cognitionMode === 'zero-swarm-v1' && + request.agents.mode === 'all' && + request.turns.mode === 'entire-retained' + ? [...structuredClone(source.swarmTicks ?? [])] + : undefined; + const selectedTickNumbers = new Set([ + ...filtered + .map(({ tickNumber }) => tickNumber) + .filter((tick): tick is number => tick !== undefined), + ...(exportedSwarmTicks ?? []).map(({ tickNumber }) => tickNumber), + ]); const selectedAgents = source.currentAgents .filter(({ id }) => selectedSet.has(id)) .map((agent) => @@ -779,11 +793,16 @@ export function createExperimentExport( matchingTurnCount: filtered.length, ...(source.schemaVersion === 10 || source.schemaVersion === 11 ? { - matchingTickCount: new Set( - filtered.map(({ tickNumber }) => tickNumber).filter(Boolean), - ).size, + matchingTickCount: + exportedSwarmTicks?.length ?? + new Set( + filtered.map(({ tickNumber }) => tickNumber).filter(Boolean), + ).size, } : {}), + ...(exportedSwarmTicks + ? { matchingSwarmTickCount: exportedSwarmTicks.length } + : {}), matchingCommunicationCount: communications.length, matchingControlChangeCount: controlChanges.length, matchingDiplomacyEventCount: allianceEvents.length, @@ -791,10 +810,7 @@ export function createExperimentExport( ? { matchingProviderAttemptCount: providerAttempts.length } : {}), matchingSimulatedPlayerEventCount: source.simulatedPlayerEvents.filter( - (event) => - new Set( - filtered.map(({ tickNumber }) => tickNumber).filter(Boolean), - ).has(event.originatingTick), + (event) => selectedTickNumbers.has(event.originatingTick), ).length, firstMatchingTurn: filtered[0]?.turnNumber, lastMatchingTurn: filtered.at(-1)?.turnNumber, @@ -857,10 +873,7 @@ export function createExperimentExport( return [structuredClone(turn.worldActionResult.event)]; }), ...source.simulatedPlayerEvents.filter((event) => { - const selectedTicks = new Set( - filtered.map(({ tickNumber }) => tickNumber).filter(Boolean), - ); - return selectedTicks.has(event.originatingTick); + return selectedTickNumbers.has(event.originatingTick); }), ], } @@ -873,6 +886,7 @@ export function createExperimentExport( : {}), allianceEvents: structuredClone(allianceEvents), turns: filtered.map((turn) => exportTurn(turn, request)), + ...(exportedSwarmTicks ? { swarmTicks: exportedSwarmTicks } : {}), ...(source.schemaVersion === 10 || source.schemaVersion === 11 ? { tickSummaries: summarizeTicks(filtered) } : {}), @@ -995,6 +1009,9 @@ export function createExperimentPreview( ...(document.selection.matchingTickCount === undefined ? {} : { matchingTickCount: document.selection.matchingTickCount }), + ...(document.selection.matchingSwarmTickCount === undefined + ? {} + : { matchingSwarmTickCount: document.selection.matchingSwarmTickCount }), matchingCommunicationCount: document.selection.matchingCommunicationCount, matchingControlChangeCount: document.selection.matchingControlChangeCount, matchingDiplomacyEventCount: document.selection.matchingDiplomacyEventCount, diff --git a/apps/game-api/src/reflex-execution.test.ts b/apps/game-api/src/reflex-execution.test.ts index 6a87c4f..ad91382 100644 --- a/apps/game-api/src/reflex-execution.test.ts +++ b/apps/game-api/src/reflex-execution.test.ts @@ -52,7 +52,7 @@ describe('zero-swarm reflex execution seam', () => { }); expect(snapshot.scenario.cognitionMode).toBe('zero-swarm-v1'); await expect(service.executeNextTick()).rejects.toThrow( - 'Zero-swarm ticks require the Agent Zero planner', + 'Zero-swarm execution requires a planner and reflex provider', ); expect(service.getSnapshot().tickNumber).toBe(0); }); diff --git a/apps/game-api/src/reflex-execution.ts b/apps/game-api/src/reflex-execution.ts index 3cc09d0..64a3579 100644 --- a/apps/game-api/src/reflex-execution.ts +++ b/apps/game-api/src/reflex-execution.ts @@ -21,6 +21,7 @@ import { type WorldState, } from '@hexzero/world-engine'; import { AttemptAccounting } from './attempt-accounting'; +import { geographicDirectionBetweenCells } from './geographic-direction'; export interface ReflexLocalHistory { previousCell?: H3Cell; @@ -88,7 +89,11 @@ function actionDescription( : nextDistance > currentDistance ? 'This moves away from the assigned target.' : 'This maintains the current target distance.'; - return `Move into adjacent ${terrain}. ${progress}`; + const direction = geographicDirectionBetweenCells( + agent.currentCell, + action.targetCell, + ); + return `Move ${direction} into adjacent ${terrain}. ${progress}`; } /** Compile only engine-legal actions from one frozen world state. */ @@ -185,6 +190,8 @@ export interface ReflexSelectionOptions { signal?: AbortSignal; deadlineAtMs?: number; accounting?: AttemptAccounting; + /** First HTTP dispatch consumes a permit reserved for the whole tick. */ + initialPermitReserved?: boolean; intendedTickNumber?: number; intendedTurnNumber?: number; now?: () => string; @@ -234,7 +241,7 @@ export async function chooseReflexWorldAction( deadlineAtMs: options.deadlineAtMs, beginAttempt: options.accounting ? (kind) => { - const permit = options.accounting!.startAdditional({ + const details = { agentId: compiled.observation.agentId, intendedTurnNumber: options.intendedTurnNumber ?? 1, ...(options.intendedTickNumber @@ -244,7 +251,11 @@ export async function chooseReflexWorldAction( startedAt: now(), modelId: model, reasoningProfile: 'provider-default', - }); + } as const; + const permit = + kind === 'initial' && options.initialPermitReserved + ? options.accounting!.startReserved(details) + : options.accounting!.startAdditional(details); if (permit === null) return null; startedAttempts += 1; return (completion) => diff --git a/apps/game-api/src/simulation-service.swarm.test.ts b/apps/game-api/src/simulation-service.swarm.test.ts new file mode 100644 index 0000000..5e63d46 --- /dev/null +++ b/apps/game-api/src/simulation-service.swarm.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from 'vitest'; +import { + BrowserTestAgentProvider, + ReflexProviderError, + ScriptedReflexProvider, + type PlannerOptions, + type ReflexProvider, + type SwarmPlanner, +} from '@hexzero/agent-runtime'; +import { + type CompatibleModel, + type SwarmPlan, + type ZeroStrategicObservation, + reflexDecisionSchema, + singleTickResponseSchema, +} from '@hexzero/shared'; +import { createApp } from './app'; +import { + SimulationService, + SimulationTurnCancelledError, +} from './simulation-service'; + +const model = { + id: 'test/zero', + name: 'Zero', + author: 'test', + contextLength: 4_096, + inputPricePerToken: '0', + outputPricePerToken: '0', + supportedParameters: [], + isFree: true, + reasoning: { mandatory: false, supportedEfforts: ['low'] }, +} as CompatibleModel; + +class InspectingPlanner implements SwarmPlanner { + readonly mode = 'scripted-swarm-test' as const; + readonly configured = true; + readonly observations: ZeroStrategicObservation[] = []; + constructor(private readonly failure: boolean | number = false) {} + async plan( + observation: ZeroStrategicObservation, + _model: string, + options: PlannerOptions = {}, + ): Promise<{ + plan: SwarmPlan; + metadata: { provider: 'scripted-test'; model: string; latencyMs: number }; + }> { + this.observations.push(structuredClone(observation)); + const finalize = options.beginAttempt?.('initial'); + if (this.failure === true || this.failure === observation.tickNumber) { + finalize?.({ + outcome: 'provider-error', + failure: { + code: 'provider-http', + message: 'planner unavailable', + retryable: true, + }, + }); + throw new Error('planner unavailable'); + } + const result = { + plan: { + strategySummary: 'Hold the local perimeter.', + zeroActionCandidateId: observation.legalZeroActions.find( + ({ action }) => action.type === 'wait', + )!.id, + directives: observation.agents + .filter(({ agentId }) => agentId !== observation.zeroAgentId) + .map((agent, index) => ({ + id: `directive-${observation.tickNumber}-${index}`, + agentId: agent.agentId, + mission: 'hold', + targetCell: agent.position, + priority: 'normal', + riskTolerance: 'low', + issuedAtTick: observation.tickNumber, + expiresAtTick: observation.tickNumber + 1, + })), + }, + metadata: { provider: 'scripted-test', model: 'test/zero', latencyMs: 0 }, + } satisfies Awaited>; + finalize?.({ + outcome: 'completed', + provider: result.metadata, + swarmPlan: result.plan, + }); + return result; + } +} + +function setup( + planner: SwarmPlanner, + reflex: ReflexProvider, + pressure = false, +) { + const simulation = new SimulationService({ + provider: new BrowserTestAgentProvider(), + swarmPlanner: planner, + reflexProvider: reflex, + now: () => '2026-08-13T12:00:00.000Z', + }); + simulation.setCompatibleModels([model]); + const request = simulation.getDefaultWorldSetup(); + simulation.applyWorldSetup({ + ...request, + cognitionMode: 'zero-swarm-v1', + ...(pressure + ? { + objectiveVersion: 'durable-influence-v3' as const, + capabilities: { + ...request.capabilities, + simulatedPlayerPressure: true, + }, + simulatedPlayer: { + enabled: true, + profile: 'casual-cleaner' as const, + seed: 'swarm-export-pressure', + }, + } + : {}), + modelConfiguration: { + globalModelId: model.id, + globalReasoningProfile: 'low', + overrides: [], + locked: false, + }, + }); + return simulation; +} + +describe('zero-swarm SimulationService tick', () => { + it('returns a schema-valid swarm tick through the API without legacy turn records', async () => { + const simulation = setup( + new InspectingPlanner(), + new ScriptedReflexProvider( + Array.from({ length: 7 }, () => ({ chosenCandidateId: 'action_0' })), + ), + ); + const response = await createApp({ service: simulation }).request( + '/api/simulation/tick', + { method: 'POST' }, + ); + expect(response.status).toBe(200); + const tick = singleTickResponseSchema.parse(await response.json()); + expect(tick.records).toEqual([]); + expect(tick.swarmTick?.tickNumber).toBe(1); + }); + + it('freezes player-advanced facts for Zero, uses only reflex choices, and resolves physical actions in engine order', async () => { + const planner = new InspectingPlanner(); + const simulation = setup( + planner, + new ScriptedReflexProvider( + Array.from({ length: 7 }, () => ({ chosenCandidateId: 'action_0' })), + ), + ); + const startingCells = new Map( + simulation + .getSnapshot() + .world.agents.map(({ id, currentCell }) => [id, currentCell]), + ); + await expect(simulation.executeNextTick()).resolves.toEqual([]); + const snapshot = simulation.getSnapshot(); + expect(snapshot.tickNumber).toBe(1); + expect(snapshot.turnNumber).toBe(0); + expect(snapshot.turns).toEqual([]); + expect(snapshot.swarmTicks).toHaveLength(1); + expect(snapshot.swarmTicks?.[0]?.workers).toHaveLength(7); + expect( + snapshot.swarmTicks?.[0]?.workers.every( + ({ source }) => source === 'jev-reflex', + ), + ).toBe(true); + expect( + snapshot.swarmTicks?.[0]?.workers.some( + ({ agentId, action, actionResult }) => + action?.type === 'move' && + actionResult?.accepted === true && + snapshot.world.agents.find(({ id }) => id === agentId) + ?.currentCell !== startingCells.get(agentId), + ), + ).toBe(true); + expect(planner.observations[0]?.tickNumber).toBe(1); + expect(planner.observations[0]?.cells).toEqual(expect.any(Array)); + expect(snapshot.resolutionOrder).toHaveLength(8); + expect(snapshot.experiment.attemptAccounting.attemptsStarted).toBe(8); + }); + + it('uses deterministic neutral directives and Zero wait when planning fails', async () => { + const simulation = setup( + new InspectingPlanner(true), + new ScriptedReflexProvider( + Array.from({ length: 7 }, () => ({ chosenCandidateId: 'action_0' })), + ), + ); + await simulation.executeNextTick(); + const tick = simulation.getSnapshot().swarmTicks?.[0]; + expect(tick?.planSource).toBe('deterministic-fallback'); + expect(tick?.zeroAction).toEqual({ type: 'wait' }); + expect( + tick?.workers.every(({ directive }) => directive.mission === 'hold'), + ).toBe(true); + }); + + it('retains unexpired prior directives when a later Zero plan fails', async () => { + const simulation = setup( + new InspectingPlanner(2), + new ScriptedReflexProvider( + Array.from({ length: 14 }, () => ({ chosenCandidateId: 'action_0' })), + ), + ); + await simulation.executeNextTick(); + const first = simulation.getSnapshot().swarmTicks?.[0]; + await simulation.executeNextTick(); + const second = simulation.getSnapshot().swarmTicks?.[1]; + expect(second?.planSource).toBe('deterministic-fallback'); + expect(second?.zeroAction).toEqual({ type: 'wait' }); + expect(second?.plan.directives).toEqual(first?.plan.directives); + }); + + it('exports simulated-player events under swarm tick selection', async () => { + const reflex: ReflexProvider = { + mode: 'scripted-reflex-test', + model: 'test-reflex', + configured: true, + async decide(observation, options) { + const choice = + observation.candidates.find(({ description }) => + description.startsWith('Infect the current open cell'), + ) ?? + observation.candidates.find(({ description }) => + description.startsWith('Remain on the current cell'), + )!; + const decision = reflexDecisionSchema.parse({ + chosenCandidateId: choice.id, + confidence: 1, + probabilities: Object.fromEntries( + observation.candidates.map(({ id }) => [ + id, + id === choice.id ? 1 : 0, + ]), + ), + model: 'test-reflex', + latencyMs: 0, + inputTokens: 0, + outputTokens: 0, + directiveId: observation.directive.id, + cognitionSource: 'jev-reflex', + }); + options?.beginAttempt?.('initial')?.({ + outcome: 'completed', + provider: { + provider: 'scripted-test', + model: 'test-reflex', + latencyMs: 0, + costCredits: 0, + }, + reflexDecision: decision, + }); + return decision; + }, + }; + const simulation = setup(new InspectingPlanner(), reflex, true); + for (let tick = 0; tick < 12; tick += 1) await simulation.executeNextTick(); + const exported = simulation.generateExperimentExport({ + agents: { mode: 'all' }, + turns: { mode: 'entire-retained' }, + outcomes: ['accepted', 'rejected', 'provider-error', 'operator-skipped'], + actions: ['move', 'infect', 'capture', 'wait'], + communications: { channel: 'all', status: 'all' }, + level: 'full-safe', + serialization: 'compact', + }); + expect(exported.swarmTicks).toHaveLength(12); + expect(exported.selection.matchingTickCount).toBe(12); + expect( + exported.selection.matchingSimulatedPlayerEventCount, + ).toBeGreaterThan(0); + expect( + exported.worldEvents?.filter( + (event) => + event.type === 'simulated-player-moved' || + event.type === 'hex-disinfected' || + event.type === 'simulated-player-clean-blocked', + ), + ).toHaveLength(exported.selection.matchingSimulatedPlayerEventCount); + }); + + it('falls back to wait for one failed Jev request without losing the tick', async () => { + const failing: ReflexProvider = { + mode: 'scripted-reflex-test', + model: 'test-reflex', + configured: true, + async decide(_observation, options) { + const failure = { + code: 'provider-http' as const, + message: 'down', + retryable: true, + }; + options?.beginAttempt?.('initial')?.({ + outcome: 'provider-error', + failure, + }); + throw new ReflexProviderError(failure); + }, + }; + const simulation = setup(new InspectingPlanner(), failing); + await simulation.executeNextTick(); + const workers = simulation.getSnapshot().swarmTicks?.[0]?.workers ?? []; + expect( + workers.every( + ({ source, action }) => + source === 'deterministic-fallback' && action?.type === 'wait', + ), + ).toBe(true); + expect( + simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted, + ).toBe(8); + }); + + it('cancels without committing the candidate world while retaining the started planner attempt', async () => { + let abort: (() => void) | undefined; + const planner: SwarmPlanner = { + mode: 'scripted-swarm-test', + configured: true, + async plan(_observation, _model, options) { + const finalize = options?.beginAttempt?.('initial'); + return await new Promise((_, reject) => { + abort = () => { + finalize?.({ + outcome: 'cancelled', + failure: { + code: 'cancelled', + message: 'cancelled', + retryable: false, + }, + }); + reject(new Error('aborted')); + }; + options?.signal?.addEventListener('abort', abort, { once: true }); + }); + }, + }; + const simulation = setup( + planner, + new ScriptedReflexProvider([{ chosenCandidateId: 'action_0' }]), + ); + const execution = simulation.executeNextTick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + simulation.cancelCurrentRequest(); + abort?.(); + await expect(execution).rejects.toBeInstanceOf( + SimulationTurnCancelledError, + ); + expect(simulation.getSnapshot().tickNumber).toBe(0); + expect(simulation.getSnapshot().swarmTicks).toEqual([]); + expect( + simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted, + ).toBe(1); + }); +}); diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 7e464bd..a5b223f 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -2,6 +2,8 @@ import { gridDisk, gridDistance } from 'h3-js'; import { AgentProviderError, dispatchTickDecisions, + type ReflexProvider, + type SwarmPlanner, type AgentProvider, type ProviderDecision, } from '@hexzero/agent-runtime'; @@ -35,6 +37,9 @@ import { MEMORY_ENTRY_LIMIT, personalitySchema, providerMetadataSchema, + swarmDirectiveSchema, + swarmPlanSchema, + swarmTickRecordSchema, simulationSnapshotSchema, type Agent, type AgentId, @@ -65,6 +70,9 @@ import { type AllianceEvent, type AllianceProposalId, type SimulatedPlayerEvent, + type SwarmPlan, + type SwarmTickRecord, + type ZeroStrategicObservation, type PatientZeroPressureContext, worldSetupRequestSchema, type AppliedScenario, @@ -90,6 +98,7 @@ import { seededTickIntervalMinutes, seededTickOrder, advanceCasualCleaner, + enumerateLegalWorldActions, toWorldState, type WorldState, } from '@hexzero/world-engine'; @@ -102,6 +111,10 @@ import { import { geographicDirectionBetweenCells } from './geographic-direction'; import { ObservationHistory } from './observation-history'; import { AttemptAccounting } from './attempt-accounting'; +import { + chooseReflexWorldAction, + ReflexSelectionCancelledError, +} from './reflex-execution'; function attemptAccountingForScenario( executionLimits: AppliedScenario['executionLimits'], @@ -248,6 +261,9 @@ export class SimulationValidationError extends Error { export interface SimulationServiceOptions { provider: AgentProvider; + /** Separate strategic and reflex cognition used only by zero-swarm-v1. */ + swarmPlanner?: SwarmPlanner; + reflexProvider?: ReflexProvider; now?: () => string; createEventId?: () => string; createExperimentId?: () => string; @@ -258,6 +274,8 @@ export interface SimulationServiceOptions { export class SimulationService { readonly #provider: AgentProvider; + readonly #swarmPlanner: SwarmPlanner | undefined; + readonly #reflexProvider: ReflexProvider | undefined; readonly #now: () => string; readonly #createEventId: () => string; readonly #createExperimentId: () => string; @@ -296,9 +314,17 @@ export class SimulationService { #simulatedPlayerEvents: SimulatedPlayerEvent[] = []; #observationHistory: ObservationHistory; #attemptAccounting: AttemptAccounting; + #swarmTicks: SwarmTickRecord[] = []; + #experimentSwarmTicks: SwarmTickRecord[] = []; + #lastValidSwarmPlan: SwarmPlan | null = null; + #lastSwarmTerritoryCounts = new Map(); + #lastSwarmTerritoryDeltas = new Map(); + #lastSwarmPositions = new Map(); constructor({ provider, + swarmPlanner, + reflexProvider, now = () => new Date().toISOString(), createEventId = () => crypto.randomUUID(), createExperimentId = () => crypto.randomUUID(), @@ -312,6 +338,8 @@ export class SimulationService { ) throw new Error('Experiment retention limit must be a positive integer.'); this.#provider = provider; + this.#swarmPlanner = swarmPlanner; + this.#reflexProvider = reflexProvider; this.#now = now; this.#createEventId = createEventId; this.#createExperimentId = createExperimentId; @@ -405,6 +433,9 @@ export class SimulationService { agentId: id, entries: structuredClone(this.#agentMemories.get(id) ?? []), })), + ...(this.#scenario.cognitionMode === 'zero-swarm-v1' + ? { swarmTicks: structuredClone(this.#swarmTicks) } + : {}), turns: this.#turns, experiment: { id: this.#experimentId, @@ -451,6 +482,12 @@ export class SimulationService { this.#pendingFailedTurn = null; this.#agentGoals = new Map(); this.#agentMemories = new Map(); + this.#swarmTicks = []; + this.#experimentSwarmTicks = []; + this.#lastValidSwarmPlan = null; + this.#lastSwarmTerritoryCounts = new Map(); + this.#lastSwarmTerritoryDeltas = new Map(); + this.#lastSwarmPositions = new Map(); this.#experimentId = experimentIdSchema.parse(this.#createExperimentId()); this.#experimentStartedAt = this.#now(); this.#experimentTurns = []; @@ -596,6 +633,12 @@ export class SimulationService { this.#pendingFailedTurn = null; this.#agentGoals = new Map(); this.#agentMemories = new Map(); + this.#swarmTicks = []; + this.#experimentSwarmTicks = []; + this.#lastValidSwarmPlan = null; + this.#lastSwarmTerritoryCounts = new Map(); + this.#lastSwarmTerritoryDeltas = new Map(); + this.#lastSwarmPositions = new Map(); this.#experimentId = experimentIdSchema.parse(this.#createExperimentId()); this.#experimentStartedAt = this.#now(); this.#experimentTurns = []; @@ -1035,7 +1078,7 @@ export class SimulationService { async executeNextTurn(): Promise { if (this.#scenario.cognitionMode === 'zero-swarm-v1') throw new SimulationConflictError( - 'Zero-swarm ticks require the Agent Zero planner, which is not enabled in this PR.', + 'Zero-swarm execution supports whole simultaneous ticks only.', ); if (this.#completedTickCount > 0) throw new SimulationConflictError( @@ -1051,9 +1094,7 @@ export class SimulationService { /** Execute one atomic simultaneous tick for every active agent. */ async executeNextTick(): Promise { if (this.#scenario.cognitionMode === 'zero-swarm-v1') - throw new SimulationConflictError( - 'Zero-swarm ticks require the Agent Zero planner, which is not enabled in this PR.', - ); + return this.#executeZeroSwarmTick(); if (this.#busy || this.#verificationBusy) throw new SimulationConflictError( 'A simulation tick is already in progress.', @@ -1402,6 +1443,322 @@ export class SimulationService { } } + /** + * The swarm path intentionally has no AgentTurnRecord: its safe telemetry is + * a SwarmTickRecord and it never invokes legacy social cognition. + */ + async #executeZeroSwarmTick(): Promise { + if (this.#busy || this.#verificationBusy) + throw new SimulationConflictError( + 'A simulation tick is already in progress.', + ); + if (!this.#swarmPlanner || !this.#reflexProvider) + throw new SimulationConflictError( + 'Zero-swarm execution requires a planner and reflex provider.', + ); + const zeroAgentId = this.#scenario.patientZeroAgentId; + const agents = [...this.#state.agents.values()]; + const zero = zeroAgentId ? this.#state.agents.get(zeroAgentId) : undefined; + if (!zero) + throw new SimulationValidationError( + 'invalid_model_configuration', + 'Zero-swarm requires the designated Patient Zero.', + ); + const resolvedZero = this.#resolvedModel(zero.id); + if (!resolvedZero.available || !resolvedZero.modelId) + throw new SimulationValidationError( + 'models_unavailable', + 'Agent Zero requires an available compatible model.', + ); + // One reserved admission for Zero and one for every worker’s initial Jev call. + if (!this.#attemptAccounting.reserve(agents.length)) { + this.#status = 'budget-exhausted'; + throw new SimulationValidationError( + 'experiment_budget_exhausted', + 'The experiment does not have enough provider-attempt or credit-admission capacity for a complete tick.', + ); + } + const tickNumber = this.#completedTickCount + 1; + const tickTurnBase = (tickNumber - 1) * agents.length; + const preTickState = this.#state; + const order = seededTickOrder( + agents.map(({ id }) => id), + this.#scenario.worldSeed, + tickNumber, + ); + const interval = seededTickIntervalMinutes( + this.#scenario.worldSeed, + tickNumber, + this.#scenario.minimumTickIntervalMinutes, + this.#scenario.maximumTickIntervalMinutes, + ); + const virtualTime = new Date( + new Date(this.#virtualTime).getTime() + interval * 60_000, + ).toISOString(); + const playerAdvance = advanceCasualCleaner( + preTickState, + this.#scenario.simulatedPlayer.seed, + tickNumber, + { createEventId: this.#createEventId, now: () => virtualTime }, + ); + const candidate = playerAdvance.state; + const controller = new AbortController(); + this.#busy = true; + this.#activeRequestController = controller; + this.#activeAgentId = zero.id; + this.#cancellationRequested = false; + this.#status = 'waiting-for-model'; + const deadlineAtMs = Date.now() + OPENROUTER_PROVIDER_TIMEOUT_MS; + let plannerFailure: ProviderFailure | undefined; + try { + const observation = this.#buildZeroStrategicObservation( + candidate, + zero.id, + tickNumber, + virtualTime, + playerAdvance.events, + ); + let plan: SwarmPlan; + let planSource: 'zero-llm' | 'deterministic-fallback' = 'zero-llm'; + let plannerMetadata: ProviderMetadata | undefined; + let plannerAttempts = 0; + try { + const planned = await this.#swarmPlanner.plan( + observation, + resolvedZero.modelId, + { + signal: controller.signal, + deadlineAtMs, + beginAttempt: (kind) => { + const startedAt = this.#now(); + const permit = + kind === 'initial' + ? this.#attemptAccounting.startReserved({ + agentId: zero.id, + intendedTurnNumber: + tickTurnBase + order.indexOf(zero.id) + 1, + intendedTickNumber: tickNumber, + kind, + startedAt, + modelId: resolvedZero.modelId!, + reasoningProfile: resolvedZero.reasoningProfile, + }) + : this.#attemptAccounting.startAdditional({ + agentId: zero.id, + intendedTurnNumber: + tickTurnBase + order.indexOf(zero.id) + 1, + intendedTickNumber: tickNumber, + kind, + startedAt, + modelId: resolvedZero.modelId!, + reasoningProfile: resolvedZero.reasoningProfile, + }); + if (permit !== null) plannerAttempts += 1; + return permit === null + ? null + : (completion) => + this.#attemptAccounting.finalize(permit, { + ...completion, + completedAt: this.#now(), + }); + }, + }, + ); + if (plannerAttempts === 0) + throw new Error( + 'The planner returned without provider-attempt accounting.', + ); + if (controller.signal.aborted) throw new SimulationTurnCancelledError(); + plan = swarmPlanSchema.parse(planned.plan); + plannerMetadata = planned.metadata; + this.#assertSwarmPlan(plan, observation, zero.id, tickNumber); + } catch (error) { + if ( + controller.signal.aborted || + error instanceof SimulationTurnCancelledError + ) + throw error; + plannerFailure = this.#providerFailure(error, resolvedZero.modelId); + planSource = 'deterministic-fallback'; + plan = this.#fallbackSwarmPlan(agents, zero.id, tickNumber, candidate); + } + const zeroAction = observation.legalZeroActions.find( + ({ id }) => id === plan.zeroActionCandidateId, + )?.action ?? { type: 'wait' as const }; + const selected = new Map< + AgentId, + Awaited> + >(); + const workers = agents.filter(({ id }) => id !== zero.id); + for (const worker of workers) { + const directive = plan.directives.find( + ({ agentId }) => agentId === worker.id, + )!; + this.#activeAgentId = worker.id; + const choice = await chooseReflexWorldAction( + candidate, + directive, + this.#reflexProvider, + { + history: { + previousCell: this.#lastSwarmPositions.get(worker.id), + recentCleanedCells: this.#simulatedPlayerEvents + .filter( + ( + event, + ): event is Extract< + SimulatedPlayerEvent, + { type: 'hex-disinfected' } + > => event.type === 'hex-disinfected', + ) + .slice(-6) + .map(({ cell }) => cell), + territoryDelta: + this.#lastSwarmTerritoryDeltas.get(worker.id) ?? 0, + recentActionOutcome: this.#swarmTicks + .at(-1) + ?.workers.find(({ agentId }) => agentId === worker.id) + ?.actionResult?.accepted + ? 'success' + : 'unknown', + }, + signal: controller.signal, + deadlineAtMs, + accounting: this.#attemptAccounting, + initialPermitReserved: true, + intendedTickNumber: tickNumber, + intendedTurnNumber: tickTurnBase + order.indexOf(worker.id) + 1, + now: this.#now, + }, + ); + selected.set(worker.id, choice); + } + if (controller.signal.aborted) throw new SimulationTurnCancelledError(); + let state = candidate; + const context = { + now: () => virtualTime, + createEventId: this.#createEventId, + patientZeroAgentId: zero.id, + tickNumber, + }; + const applied = new Map< + AgentId, + ReturnType['result'] + >(); + for (const agentId of order) { + const action = + agentId === zero.id ? zeroAction : selected.get(agentId)!.action; + const result = applyWorldAction(state, agentId, action, context); + state = result.state; + applied.set(agentId, result.result); + } + if (controller.signal.aborted) throw new SimulationTurnCancelledError(); + const tick = swarmTickRecordSchema.parse({ + tickNumber, + virtualTime, + tickIntervalMinutes: interval, + plan, + planSource, + ...(plannerFailure ? { plannerFailure } : {}), + ...(plannerMetadata ? { plannerMetadata } : {}), + zeroAction, + zeroActionResult: applied.get(zero.id), + workers: workers.map((worker) => { + const selection = selected.get(worker.id)!; + return { + agentId: worker.id, + directive: plan.directives.find( + ({ agentId }) => agentId === worker.id, + )!, + action: selection.action, + actionResult: applied.get(worker.id), + ...(selection.decision + ? { reflexDecision: selection.decision } + : {}), + source: selection.cognitionSource, + ...(selection.failure ? { failure: selection.failure } : {}), + }; + }), + }); + const observationEvents = state.events.slice(preTickState.events.length); + this.#state = { + ...state, + events: state.events.slice(-MAX_WORLD_EVENT_HISTORY), + }; + this.#observationHistory.ingest(observationEvents); + this.#completedTickCount = tickNumber; + this.#virtualTime = virtualTime; + this.#lastTickIntervalMinutes = interval; + this.#resolutionOrder = order; + this.#simulatedPlayerEvents = [ + ...this.#simulatedPlayerEvents, + ...structuredClone(playerAdvance.events), + ].slice(-this.#experimentRetentionLimit * 2); + this.#swarmTicks = [...this.#swarmTicks, tick].slice(-MAX_TURN_HISTORY); + this.#experimentSwarmTicks = [ + ...this.#experimentSwarmTicks, + structuredClone(tick), + ].slice(-this.#experimentRetentionLimit); + if (planSource === 'zero-llm') this.#lastValidSwarmPlan = plan; + this.#lastSwarmTerritoryCounts = new Map( + [...state.agents.keys()].map((agentId) => [ + agentId, + [...state.hexes.values()].filter( + (hex) => + hex.state === 'infected' && hex.controllerAgentId === agentId, + ).length, + ]), + ); + this.#lastSwarmTerritoryDeltas = new Map( + [...state.agents.keys()].map((agentId) => [ + agentId, + [...state.hexes.values()].filter( + (hex) => + hex.state === 'infected' && hex.controllerAgentId === agentId, + ).length - + [...preTickState.hexes.values()].filter( + (hex) => + hex.state === 'infected' && hex.controllerAgentId === agentId, + ).length, + ]), + ); + this.#lastSwarmPositions = new Map( + [...state.agents.values()].map(({ id, currentCell }) => [ + id, + currentCell, + ]), + ); + this.#behaviorConfiguration = { + ...this.#behaviorConfiguration, + locked: true, + }; + this.#status = this.#attemptAccounting.snapshot().exhausted + ? 'budget-exhausted' + : 'paused'; + return []; + } catch (error) { + if ( + controller.signal.aborted || + error instanceof ReflexSelectionCancelledError || + error instanceof SimulationTurnCancelledError + ) { + this.#status = 'paused'; + throw new SimulationTurnCancelledError(); + } + throw error; + } finally { + this.#attemptAccounting.releaseReservations(); + this.#busy = false; + this.#activeRequestController = null; + this.#activeAgentId = null; + this.#cancellationRequested = false; + if (this.#status === 'waiting-for-model') + this.#status = this.#attemptAccounting.snapshot().exhausted + ? 'budget-exhausted' + : 'paused'; + } + } + #commitCompletedTick( records: AgentTurnRecord[], state: WorldState, @@ -2081,6 +2438,178 @@ export class SimulationService { }; } + #buildZeroStrategicObservation( + state: WorldState, + zeroAgentId: AgentId, + tickNumber: number, + virtualTime: string, + playerEvents: readonly SimulatedPlayerEvent[], + ): ZeroStrategicObservation { + const counts = new Map( + [...state.agents.keys()].map((id) => [id, 0]), + ); + for (const hex of state.hexes.values()) + if (hex.state === 'infected') + counts.set( + hex.controllerAgentId, + (counts.get(hex.controllerAgentId) ?? 0) + 1, + ); + const strategicTargetCells = [ + ...new Set([ + ...(this.#lastValidSwarmPlan?.directives.flatMap(({ targetCell }) => + targetCell ? [targetCell] : [], + ) ?? []), + ...[...state.agents.values()].map(({ currentCell }) => currentCell), + ...[...state.hexes.keys()].sort(), + ]), + ].slice(0, 80); + const zero = state.agents.get(zeroAgentId)!; + const legalZeroActions = enumerateLegalWorldActions(state, zeroAgentId).map( + (action, index) => ({ + id: `zero_action_${index}`, + action, + description: + action.type === 'move' + ? `Move ${geographicDirectionBetweenCells(zero.currentCell, action.targetCell)} into an adjacent engine-legal cell.` + : action.type === 'infect' + ? 'Infect the current open cell.' + : action.type === 'capture' + ? 'Capture the current abandoned infected cell.' + : 'Wait on the current cell.', + }), + ); + return { + zeroAgentId, + tickNumber, + virtualTime, + cells: [...state.hexes.entries()].map(([cell, hex]) => ({ + cell, + state: hex.state, + controllerAgentId: + hex.state === 'infected' ? hex.controllerAgentId : null, + })), + agents: [...state.agents.values()].map((agent) => { + const priorWorker = this.#swarmTicks + .at(-1) + ?.workers.find(({ agentId }) => agentId === agent.id); + const directive = + this.#lastValidSwarmPlan?.directives.find( + ({ agentId }) => agentId === agent.id, + ) ?? null; + const workerStatus = + priorWorker?.source === 'deterministic-fallback' + ? ('blocked' as const) + : priorWorker?.actionResult?.accepted + ? ('advancing' as const) + : priorWorker + ? ('stalled' as const) + : ('unknown' as const); + return { + agentId: agent.id, + position: agent.currentCell, + controlledCellCount: counts.get(agent.id) ?? 0, + territoryDelta: this.#lastSwarmTerritoryDeltas.get(agent.id) ?? 0, + ...(agent.id === zeroAgentId ? {} : { workerStatus, directive }), + }; + }), + recentPlayerPressure: playerEvents.map((event) => + event.type === 'hex-disinfected' + ? 'A nearby infected cell was cleaned this tick.' + : event.type === 'simulated-player-clean-blocked' + ? 'Cleaning pressure was blocked by an occupied infected cell.' + : 'The simulated player moved this tick.', + ), + legalZeroActions, + strategicTargetCells, + }; + } + + #assertSwarmPlan( + plan: SwarmPlan, + observation: ZeroStrategicObservation, + zeroAgentId: AgentId, + tickNumber: number, + ): void { + const workers = observation.agents + .filter(({ agentId }) => agentId !== zeroAgentId) + .map(({ agentId }) => agentId) + .toSorted(); + const directives = plan.directives.map((directive) => + swarmDirectiveSchema.parse(directive), + ); + if ( + directives.length !== workers.length || + directives.some((directive) => !workers.includes(directive.agentId)) || + directives.some( + (directive) => + directive.issuedAtTick !== tickNumber || + directive.expiresAtTick < tickNumber || + (directive.targetCell !== null && + !observation.strategicTargetCells.includes(directive.targetCell)), + ) + ) + throw new Error( + 'The Zero plan does not contain one current, allowlisted directive per worker.', + ); + if ( + !observation.legalZeroActions.some( + ({ id }) => id === plan.zeroActionCandidateId, + ) + ) + throw new Error( + 'The Zero plan selected an unknown world action candidate.', + ); + } + + #fallbackSwarmPlan( + agents: readonly Agent[], + zeroAgentId: AgentId, + tickNumber: number, + state: WorldState, + ): SwarmPlan { + const workers = agents.filter(({ id }) => id !== zeroAgentId); + const retained = this.#lastValidSwarmPlan?.directives; + const directives = workers.map( + (worker) => + retained?.find( + (directive) => + directive.agentId === worker.id && + directive.expiresAtTick >= tickNumber, + ) ?? { + id: `neutral-${tickNumber}-${worker.id}`, + agentId: worker.id, + mission: 'hold' as const, + targetCell: state.agents.get(worker.id)!.currentCell, + priority: 'normal' as const, + riskTolerance: 'low' as const, + issuedAtTick: tickNumber, + expiresAtTick: tickNumber, + }, + ); + const waitIndex = enumerateLegalWorldActions(state, zeroAgentId).findIndex( + (action) => action.type === 'wait', + ); + if (waitIndex < 0) + throw new Error('The engine must provide a legal wait action.'); + return swarmPlanSchema.parse({ + strategySummary: + 'Maintain legal local positions while strategic planning is unavailable.', + directives, + zeroActionCandidateId: `zero_action_${waitIndex}`, + }); + } + + #providerFailure(error: unknown, model: string): ProviderFailure { + if (error && typeof error === 'object' && 'failure' in error) + return (error as { failure: ProviderFailure }).failure; + return { + code: 'provider-http', + message: 'Agent Zero planning failed.', + retryable: false, + model, + }; + } + #experimentSource(): ExperimentSource { return { id: this.#experimentId, @@ -2110,6 +2639,7 @@ export class SimulationService { entries: structuredClone(this.#agentMemories.get(agentId) ?? []), })), simulatedPlayerEvents: structuredClone(this.#simulatedPlayerEvents), + swarmTicks: structuredClone(this.#experimentSwarmTicks), }; } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 46c0eef..87696c7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,14 +1,22 @@ # Architecture -## Zero-swarm PR A seam +## Zero-swarm execution `cognitionMode` distinguishes `legacy-multi-agent` from `zero-swarm-v1` in -scenarios, snapshots, and exports. The legacy tick remains the active -production executor. Until the Agent Zero planner is connected in PR B, a -zero-swarm production tick is rejected explicitly. The separate reflex seam -compiles engine-legal physical actions from a frozen world, describes them as -opaque candidates, asks Jev to select one, and maps the chosen ID back to a -world action for normal engine validation. See ADR 0028. +scenarios, snapshots, and exports. Legacy ticks retain their original path. +For a zero-swarm tick, the service first advances deterministic player pressure +into an uncommitted world candidate, then asks one OpenRouter Agent Zero planner +for a strategy, worker directives, and one opaque Zero action ID. After plan +validation, each worker receives a compact local projection and engine-legal +physical actions as opaque candidates. Jev selects one candidate ID, which the +service maps to a world action for seeded engine resolution. One complete tick +commits atomically. Planner and worker failures use explicit deterministic +fallbacks; provider attempts survive world rollback. See ADRs 0028 and 0029. + +Swarm tick records are separate from legacy agent turn records. Full all-agent +exports and the archive retain safe plans, directives, action choices, and +factual provider usage; selective legacy turn filters do not export partial +swarm plans. World Lab's dedicated swarm presentation follows in PR C. Provider-attempt and credit-exposure admission are owned by `SimulationService`. Its ledger is deliberately separate from deterministic world state and committed-turn diff --git a/docs/EXPERIMENT_ARCHIVE.md b/docs/EXPERIMENT_ARCHIVE.md index 95ac3f3..71afd28 100644 --- a/docs/EXPERIMENT_ARCHIVE.md +++ b/docs/EXPERIMENT_ARCHIVE.md @@ -113,3 +113,9 @@ Use `pnpm experiment:db provider-attempts ` to inspect committed and uncommitted provider work. Monetary values round-trip as canonical TEXT. For v11 summaries this ledger is canonical; legacy model attempts are not added again. The SQLite archive is for analysis and is not active runtime recovery. + +Archive schema v5 adds `swarm_ticks` for safe committed zero-swarm plans, +directives, physical action results, and worker choice telemetry. Full +all-agent exports carry these records separately from legacy `turns`. +Provider attempts remain in the independent v4 ledger, including attempts +from cancelled or rolled-back swarm ticks. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fef430e..035deeb 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -42,6 +42,15 @@ TypeSafe requests and responses, and provider error bodies never enter safe telemetry, World Lab, archives, or exports. TypeSafe token usage is factual; no monetary cost is inferred from it. +In `zero-swarm-v1`, a separate OpenRouter planner receives bounded strategic +facts, an engine-generated target allowlist, and opaque legal Zero action IDs. +The service validates every returned directive and action selection before +worker dispatch. A rejected plan causes an explicit deterministic fallback; +it never grants world mutation authority. Safe swarm tick records contain +structured plans and outcomes, while raw planner messages and responses remain +server-private. Legacy social and prose-memory cognition does not run in this +mode. + `OPENROUTER_API_KEY` is the only required OpenRouter environment value and is read only by the Game API process. It never enters catalog DTOs, assignments, exports, fixtures, browser responses, errors, or logs. The repository-root `.env` is ignored; `.env.example` contains only a placeholder. The development API has no authentication, rate limiting, or provider-account balance enforcement. It does enforce server-owned per-experiment provider-attempt and conservative credit-admission ceilings configured by World Setup. Credit reservations bound admission exposure but cannot guarantee the upstream bill, especially when reported cost exceeds the operator's reservation. It binds to loopback and its CORS allowlist is limited to the documented local World Lab origins. Do not deploy its cost-incurring tick endpoint to unauthenticated public traffic. diff --git a/docs/TESTING.md b/docs/TESTING.md index 62a5a8f..771516b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,10 +1,14 @@ # Testing -Zero-swarm PR A tests are offline by default. They cover deterministic legal +Zero-swarm tests are offline by default. PR A covers deterministic legal candidate compilation, a scripted reflex choice through `applyWorldAction`, malformed and unknown TypeSafe choices, cancellation and bounded transient retry, and provider attempts retained when a candidate world change is not committed. `pnpm validate` and `pnpm test:e2e` are the complete local gates. +PR B adds scripted Agent Zero and Jev providers to test plan validation, +planning-before-reflex ordering, real engine resolution, deterministic failure +fallback, cancellation rollback, and independent provider-attempt retention. +The live provider smoke test remains separately opted into. Attempt-budget tests use deterministic providers and cover whole-roster tick admission, retry permits, cancellation finalization, and the distinction diff --git a/docs/adr/0029-zero-swarm-two-stage-tick.md b/docs/adr/0029-zero-swarm-two-stage-tick.md new file mode 100644 index 0000000..6e8c0fb --- /dev/null +++ b/docs/adr/0029-zero-swarm-two-stage-tick.md @@ -0,0 +1,42 @@ +# ADR 0029: Zero-swarm two-stage tick + +## Status + +Accepted for PR B. + +## Decision + +`zero-swarm-v1` executes one Agent Zero planning call before any worker reflex +call. The service freezes the authoritative pre-tick state, advances optional +deterministic player pressure into an uncommitted candidate, and constructs +Zero's strategic observation from that candidate. Zero receives engine-derived +legal physical actions identified by opaque IDs and a bounded target-cell +allowlist. Its plan must contain one directive for each worker and one legal +Zero action ID. The service validates exact roster coverage, target membership, +and directive timing before use. + +Each worker receives only its directive, compact local facts, and +engine-derived legal candidates. Jev selects an opaque candidate ID; the +service maps it to the physical action and the world engine validates and +resolves it. Zero and worker actions resolve in the existing seeded tick order. +The service commits the complete world, time, player events, plan, and worker +records atomically. The legacy tick remains on its existing path. + +A worker request failure resolves to an engine-legal wait and records +`deterministic-fallback`. A planning failure reuses valid unexpired directives +when possible, otherwise creates neutral directives; Zero waits in either +case. Cancellation rolls back the candidate world and tick record. Started +OpenRouter and TypeSafe attempts remain in the independent attempt ledger even +if the world does not commit. + +Swarm ticks have their own safe telemetry rather than synthetic legacy agent +turn records. Full, all-agent exports include these records; the SQLite archive +stores them separately. No raw provider body, prompt, credential, private +reasoning, personality, chat, diplomacy, goal, or prose-memory operation +participates in the zero-swarm tick. + +## Consequences + +Agent Zero currently plans every tick. Persistent directives and event-driven +replanning are deferred to PR D. World Lab presentation changes are deferred +to PR C. diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 4ff6e8d..5c9321f 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -25,6 +25,7 @@ import { export { applyProviderEnvironmentFile } from './provider-environment'; export * from './model-catalog'; export * from './reflex-provider'; +export * from './swarm-planner'; export * from './tick-dispatcher'; export * from './typesafe-jev-reflex-provider'; diff --git a/packages/agent-runtime/src/swarm-planner.test.ts b/packages/agent-runtime/src/swarm-planner.test.ts new file mode 100644 index 0000000..cd73075 --- /dev/null +++ b/packages/agent-runtime/src/swarm-planner.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + swarmPlanSchema, + zeroStrategicObservationSchema, +} from '@hexzero/shared'; +import { + OpenRouterSwarmPlanner, + ScriptedSwarmPlanner, + SwarmPlannerError, +} from './swarm-planner'; + +const zero = '00000000-0000-4000-8000-000000000001'; +const worker = '00000000-0000-4000-8000-000000000002'; +const cell = '8928308280fffff'; +const observation = zeroStrategicObservationSchema.parse({ + zeroAgentId: zero, + tickNumber: 1, + virtualTime: '2026-08-13T12:00:00.000Z', + cells: [{ cell, state: 'infected' as const, controllerAgentId: zero }], + agents: [ + { + agentId: zero, + position: cell, + controlledCellCount: 1, + territoryDelta: 0, + }, + { + agentId: worker, + position: cell, + controlledCellCount: 0, + territoryDelta: 0, + }, + ], + recentPlayerPressure: [], + legalZeroActions: [ + { + id: 'zero_action_0', + action: { type: 'wait' as const }, + description: 'Wait on the current cell.', + }, + ], + strategicTargetCells: [cell], +}); +const plan = swarmPlanSchema.parse({ + strategySummary: 'Expand carefully.', + zeroActionCandidateId: 'zero_action_0', + directives: [ + { + id: 'directive-1', + agentId: worker, + mission: 'expand' as const, + targetCell: cell, + priority: 'normal' as const, + riskTolerance: 'medium' as const, + issuedAtTick: 1, + expiresAtTick: 2, + }, + ], +}); + +describe('swarm planners', () => { + it('uses a deterministic plan only when it stays within authoritative options', async () => { + const result = await new ScriptedSwarmPlanner([plan]).plan( + observation, + 'test-model', + ); + expect(result.plan).toEqual(plan); + expect(result.metadata.provider).toBe('openrouter'); + }); + + it('rejects an OpenRouter plan with an invented zero candidate', async () => { + const fetchImplementation = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + choices: [ + { + message: { + content: JSON.stringify({ + ...plan, + zeroActionCandidateId: 'zero_action_9', + }), + }, + }, + ], + }), + { status: 200 }, + ), + ); + const provider = new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation, + }); + await expect( + provider.plan(observation, 'test-model'), + ).rejects.toBeInstanceOf(SwarmPlannerError); + }); + + it('rejects a plan that omits a worker directive before it is committed', async () => { + const fetchImplementation = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + choices: [ + { + message: { + content: JSON.stringify({ ...plan, directives: [] }), + }, + }, + ], + }), + { status: 200 }, + ), + ); + const finalized: unknown[] = []; + await expect( + new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation, + }).plan(observation, 'test-model', { + beginAttempt: () => (completion) => finalized.push(completion), + }), + ).rejects.toBeInstanceOf(SwarmPlannerError); + expect(finalized).toMatchObject([{ outcome: 'provider-error' }]); + }); + + it('makes one bounded retry for an overloaded planner response', async () => { + const fetchImplementation = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 529 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'safe-request-id', + model: 'test-model', + choices: [{ message: { content: JSON.stringify(plan) } }], + usage: { prompt_tokens: 4, completion_tokens: 3, total_tokens: 7 }, + }), + { status: 200 }, + ), + ); + const attempts: string[] = []; + const result = await new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation, + }).plan(observation, 'test-model', { + beginAttempt: (kind) => { + attempts.push(kind); + return () => undefined; + }, + }); + expect(result.metadata.totalTokens).toBe(7); + expect(attempts).toEqual(['initial', 'automatic-transport-retry']); + }); +}); diff --git a/packages/agent-runtime/src/swarm-planner.ts b/packages/agent-runtime/src/swarm-planner.ts new file mode 100644 index 0000000..dfac43a --- /dev/null +++ b/packages/agent-runtime/src/swarm-planner.ts @@ -0,0 +1,477 @@ +import { z } from 'zod'; +import { + providerMetadataSchema, + swarmPlanSchema, + zeroStrategicObservationSchema, + type ProviderFailure, + type ProviderMetadata, + type SwarmPlan, + type ZeroStrategicObservation, +} from '@hexzero/shared'; + +const OPENROUTER_ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions'; +const DEFAULT_TIMEOUT_MS = 30_000; +const RETRY_DELAY_MS = 250; +const RESPONSE_BODY_MAX_BYTES = 65_536; + +export interface SwarmPlanner { + readonly mode: 'openrouter-swarm' | 'scripted-swarm-test'; + readonly configured: boolean; + plan( + observation: ZeroStrategicObservation, + model: string, + options?: PlannerOptions, + ): Promise; +} + +export interface PlannerOptions { + signal?: AbortSignal; + deadlineAtMs?: number; + beginAttempt?: SwarmAttemptStarter; +} + +export interface SwarmPlanResult { + plan: SwarmPlan; + metadata: ProviderMetadata; +} + +export interface SwarmAttemptCompletion { + outcome: 'completed' | 'provider-error' | 'cancelled' | 'timeout'; + provider?: ProviderMetadata; + failure?: ProviderFailure; + swarmPlan?: SwarmPlan; +} + +export type SwarmAttemptFinalizer = ( + completion: SwarmAttemptCompletion, +) => void; +export type SwarmAttemptStarter = ( + kind: 'initial' | 'automatic-transport-retry', +) => SwarmAttemptFinalizer | null; + +export class SwarmPlannerError extends Error { + constructor( + readonly failure: ProviderFailure, + readonly metadata?: ProviderMetadata, + ) { + super(failure.message); + this.name = 'SwarmPlannerError'; + } +} + +const openRouterResponseSchema = z.object({ + id: z.string().trim().min(1).max(160).optional(), + model: z.string().trim().min(1).max(200).optional(), + usage: z + .object({ + prompt_tokens: z.number().int().nonnegative().optional(), + completion_tokens: z.number().int().nonnegative().optional(), + total_tokens: z.number().int().nonnegative().optional(), + }) + .optional(), + choices: z + .array( + z.object({ + finish_reason: z.string().trim().min(1).max(80).nullable().optional(), + message: z.object({ + content: z + .union([z.string(), z.array(z.object({ text: z.string() }))]) + .nullable() + .optional(), + }), + }), + ) + .min(1), +}); + +export interface OpenRouterSwarmPlannerOptions { + apiKey?: string; + timeoutMs?: number; + fetchImplementation?: typeof fetch; +} + +/** OpenRouter-backed strategic planner, isolated from AgentProvider. */ +export class OpenRouterSwarmPlanner implements SwarmPlanner { + readonly mode = 'openrouter-swarm' as const; + readonly configured: boolean; + readonly #apiKey?: string; + readonly #timeoutMs: number; + readonly #fetch: typeof fetch; + + constructor({ + apiKey, + timeoutMs = DEFAULT_TIMEOUT_MS, + fetchImplementation = fetch, + }: OpenRouterSwarmPlannerOptions = {}) { + this.#apiKey = + (apiKey ?? process.env.OPENROUTER_API_KEY)?.trim() || undefined; + this.#timeoutMs = timeoutMs; + this.#fetch = fetchImplementation; + this.configured = Boolean(this.#apiKey); + } + + async plan( + observationInput: ZeroStrategicObservation, + model: string, + options: PlannerOptions = {}, + ): Promise { + const startedAt = Date.now(); + if (!this.#apiKey) + throw new SwarmPlannerError( + failure('configuration', false, model, startedAt), + ); + const observation = zeroStrategicObservationSchema.parse(observationInput); + const deadlineAt = Math.min( + options.deadlineAtMs ?? Infinity, + startedAt + this.#timeoutMs, + ); + const requestBody = JSON.stringify( + buildSwarmPlannerRequest(observation, model), + ); + for (let index = 0; index < 2; index += 1) { + if (Date.now() >= deadlineAt) + throw new SwarmPlannerError(failure('timeout', true, model, startedAt)); + const finalize = options.beginAttempt?.( + index === 0 ? 'initial' : 'automatic-transport-retry', + ); + if (finalize === null) + throw new SwarmPlannerError({ + code: 'budget-exhausted', + message: 'The planner attempt budget is exhausted.', + retryable: false, + }); + let done = false; + const complete = (result: SwarmAttemptCompletion) => { + if (!done) { + done = true; + finalize?.(result); + } + }; + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout( + () => { + timedOut = true; + controller.abort(); + }, + Math.max(1, deadlineAt - Date.now()), + ); + const cancel = () => controller.abort(); + options.signal?.addEventListener('abort', cancel, { once: true }); + try { + let response: Response; + try { + response = await this.#fetch(OPENROUTER_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.#apiKey}`, + 'Content-Type': 'application/json', + }, + body: requestBody, + signal: controller.signal, + }); + } catch { + throw new SwarmPlannerError( + failure( + timedOut + ? 'timeout' + : options.signal?.aborted + ? 'cancelled' + : 'network', + !timedOut && !options.signal?.aborted, + model, + startedAt, + ), + ); + } + if (options.signal?.aborted) + throw new SwarmPlannerError( + failure('cancelled', false, model, startedAt), + ); + const metadata = metadataFor( + model, + Date.now() - startedAt, + response.status, + ); + if ( + (response.status === 429 || response.status === 529) && + index === 0 + ) { + const retryFailure = new SwarmPlannerError( + { + ...failure('provider-http', true, model, startedAt), + httpStatus: response.status, + }, + metadata, + ); + complete({ + outcome: 'provider-error', + provider: metadata, + failure: retryFailure.failure, + }); + await delay( + Math.min(RETRY_DELAY_MS, Math.max(0, deadlineAt - Date.now())), + options.signal, + ); + continue; + } + if (!response.ok) + throw new SwarmPlannerError( + { + ...failure( + 'provider-http', + response.status === 429 || response.status === 529, + model, + startedAt, + ), + httpStatus: response.status, + }, + metadata, + ); + let raw: unknown; + try { + const body = await response.text(); + if ( + new TextEncoder().encode(body).byteLength > RESPONSE_BODY_MAX_BYTES + ) + throw new Error('response body limit'); + raw = JSON.parse(body); + } catch { + throw new SwarmPlannerError( + failure('malformed-response', true, model, startedAt), + metadata, + ); + } + if (options.signal?.aborted) + throw new SwarmPlannerError( + failure('cancelled', false, model, startedAt), + ); + const parsed = openRouterResponseSchema.safeParse(raw); + if (!parsed.success) + throw new SwarmPlannerError( + failure('unsupported-response', true, model, startedAt), + metadata, + ); + const content = responseText(parsed.data.choices[0]?.message.content); + if (!content) + throw new SwarmPlannerError( + failure('unsupported-response', true, model, startedAt), + metadata, + ); + let planRaw: unknown; + try { + planRaw = JSON.parse(content); + } catch { + throw new SwarmPlannerError( + failure('malformed-response', true, model, startedAt), + metadata, + ); + } + const plan = swarmPlanSchema.safeParse(planRaw); + if (!plan.success || !validPlan(plan.data, observation)) + throw new SwarmPlannerError( + { + ...failure('invalid-decision', false, model, startedAt), + message: + 'Agent Zero returned a plan outside the authoritative choices.', + }, + metadata, + ); + const safeMetadata = providerMetadataSchema.parse({ + ...metadata, + requestId: parsed.data.id, + resolvedModel: parsed.data.model, + promptTokens: parsed.data.usage?.prompt_tokens, + completionTokens: parsed.data.usage?.completion_tokens, + totalTokens: parsed.data.usage?.total_tokens, + }); + complete({ + outcome: 'completed', + provider: safeMetadata, + swarmPlan: plan.data, + }); + return { plan: plan.data, metadata: safeMetadata }; + } catch (error) { + const plannerError = + error instanceof SwarmPlannerError + ? error + : new SwarmPlannerError( + failure('unsupported-response', false, model, startedAt), + ); + complete({ + outcome: + plannerError.failure.code === 'cancelled' + ? 'cancelled' + : plannerError.failure.code === 'timeout' + ? 'timeout' + : 'provider-error', + provider: plannerError.metadata, + failure: plannerError.failure, + }); + throw plannerError; + } finally { + clearTimeout(timer); + options.signal?.removeEventListener('abort', cancel); + } + } + throw new SwarmPlannerError( + failure('provider-http', true, model, startedAt), + ); + } +} + +export class ScriptedSwarmPlanner implements SwarmPlanner { + readonly mode = 'scripted-swarm-test' as const; + readonly configured = true; + #cursor = 0; + constructor(private readonly plans: SwarmPlan[]) { + if (plans.length === 0) + throw new Error('ScriptedSwarmPlanner requires at least one plan.'); + } + async plan( + observationInput: ZeroStrategicObservation, + model: string, + options: PlannerOptions = {}, + ): Promise { + const observation = zeroStrategicObservationSchema.parse(observationInput); + const finalize = options.beginAttempt?.('initial'); + if (finalize === null) + throw new SwarmPlannerError({ + code: 'budget-exhausted', + message: 'The planner attempt budget is exhausted.', + retryable: false, + }); + const plan = this.plans[this.#cursor++]; + if (!plan || !validPlan(plan, observation)) { + const error = new SwarmPlannerError({ + code: 'invalid-decision', + message: + 'The deterministic planner selected a plan outside authoritative choices.', + retryable: false, + }); + finalize?.({ outcome: 'provider-error', failure: error.failure }); + throw error; + } + const metadata = metadataFor(model, 0); + finalize?.({ outcome: 'completed', provider: metadata, swarmPlan: plan }); + return { plan, metadata }; + } +} + +function buildSwarmPlannerRequest( + observation: ZeroStrategicObservation, + model: string, +) { + return { + model, + temperature: 0, + response_format: { type: 'json_object' }, + messages: [ + { + role: 'system', + content: + 'You are Agent Zero, a strategic planner. Return JSON only with strategySummary, directives, and zeroActionCandidateId. Assign intent, never exact worker movement. Each directive targetCell must be one of strategicTargetCells. zeroActionCandidateId must be one offered opaque candidate. Do not add fields.', + }, + { role: 'user', content: JSON.stringify(observation) }, + ], + }; +} + +function validPlan( + plan: SwarmPlan, + observation: ZeroStrategicObservation, +): boolean { + const targets = new Set(observation.strategicTargetCells); + const workers = new Set( + observation.agents + .filter(({ agentId }) => agentId !== observation.zeroAgentId) + .map(({ agentId }) => agentId), + ); + return ( + observation.legalZeroActions.some( + ({ id }) => id === plan.zeroActionCandidateId, + ) && + plan.directives.length === workers.size && + plan.directives.every( + (directive) => + workers.has(directive.agentId) && + directive.issuedAtTick === observation.tickNumber && + directive.expiresAtTick >= observation.tickNumber && + (!directive.targetCell || targets.has(directive.targetCell)), + ) + ); +} + +function responseText( + content: string | { text: string }[] | null | undefined, +): string | undefined { + if (typeof content === 'string') return content; + if (Array.isArray(content)) return content.map(({ text }) => text).join(''); + return undefined; +} + +function metadataFor( + model: string, + latencyMs: number, + httpStatus?: number, +): ProviderMetadata { + return { + provider: 'openrouter', + model, + selectedModel: model, + latencyMs: Math.round(latencyMs), + ...(httpStatus ? { httpStatus } : {}), + }; +} + +function failure( + code: ProviderFailure['code'], + retryable: boolean, + model: string, + startedAt: number, +): ProviderFailure { + const messages: Partial> = { + configuration: + 'OpenRouter is unavailable. Set OPENROUTER_API_KEY on the Game API server.', + timeout: 'The Agent Zero planning request timed out.', + network: 'The Agent Zero planning provider could not be reached.', + 'model-unavailable': 'The requested Agent Zero model is unavailable.', + 'provider-http': 'The Agent Zero planning provider returned an HTTP error.', + cancelled: 'The Agent Zero planning request was cancelled.', + 'malformed-response': + 'The Agent Zero planning provider returned malformed JSON.', + 'unsupported-response': + 'The Agent Zero planning provider returned an unsupported response.', + 'output-length': 'The Agent Zero planning response was incomplete.', + 'invalid-decision': 'The Agent Zero plan was invalid.', + 'budget-exhausted': 'The planner attempt budget is exhausted.', + }; + return { + code, + message: messages[code] ?? 'The Agent Zero planner failed.', + retryable, + model, + latencyMs: Date.now() - startedAt, + }; +} + +function delay(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject( + new SwarmPlannerError({ + code: 'cancelled', + message: 'The Agent Zero planning request was cancelled.', + retryable: false, + }), + ); + }, + { once: true }, + ); + }); +} diff --git a/packages/experiment-archive/src/archive.test.ts b/packages/experiment-archive/src/archive.test.ts index 5c77b83..e71ef62 100644 --- a/packages/experiment-archive/src/archive.test.ts +++ b/packages/experiment-archive/src/archive.test.ts @@ -495,6 +495,54 @@ describe('experiment archive', () => { archive.close(); }); + it('archives safe committed swarm tick records idempotently', async () => { + const base = await currentExport(); + const document = experimentExportDocumentSchema.parse({ + ...base, + experiment: { + ...base.experiment, + scenario: { + ...base.experiment.scenario, + cognitionMode: 'zero-swarm-v1', + }, + }, + selection: { + ...base.selection, + matchingSwarmTickCount: 1, + }, + swarmTicks: [ + { + tickNumber: 1, + virtualTime: NOW, + tickIntervalMinutes: 60, + plan: { + strategySummary: 'Hold current territory.', + directives: [], + zeroActionCandidateId: 'zero_action_0', + }, + planSource: 'deterministic-fallback', + zeroAction: { type: 'wait' }, + workers: [], + }, + ], + }); + const archive = new ArchiveDatabase({ path: ':memory:' }); + importExperimentExport(archive, document); + importExperimentExport(archive, document); + const row = archive.database + .prepare( + 'SELECT source_json FROM swarm_ticks WHERE experiment_id = ? AND tick_number = 1', + ) + .get(document.experiment.id) as { source_json: string } | undefined; + expect(JSON.parse(row!.source_json)).toEqual(document.swarmTicks![0]); + expect( + archive.database + .prepare('SELECT COUNT(*) AS count FROM swarm_ticks') + .get(), + ).toEqual({ count: 1 }); + archive.close(); + }); + it('imports an existing schema-v9 export with the legacy download filename', async () => { const path = temporaryPath( 'agentborne-experiment-existing-full-entire.json', diff --git a/packages/experiment-archive/src/importer.ts b/packages/experiment-archive/src/importer.ts index f56882e..1c76c3e 100644 --- a/packages/experiment-archive/src/importer.ts +++ b/packages/experiment-archive/src/importer.ts @@ -299,6 +299,7 @@ export function importExperimentExport( importAgents(archive, document, report); importMap(archive, document, report); importTurns(archive, document, report); + importSwarmTicks(archive, document, report); importProviderAttempts(archive, document, report); importCommunications(archive, document, report); importAllianceEvents(archive, document, report); @@ -318,6 +319,30 @@ export function importExperimentExport( } } +function importSwarmTicks( + archive: ArchiveDatabase, + document: ExperimentExportDocument, + report: ImportReport, +): void { + const statement = archive.database.prepare(` + INSERT OR IGNORE INTO swarm_ticks( + experiment_id, tick_number, virtual_time, plan_source, source_json + ) VALUES (?, ?, ?, ?, ?) + `); + for (const tick of document.swarmTicks ?? []) + runInsert( + statement, + [ + document.experiment.id, + tick.tickNumber, + tick.virtualTime, + tick.planSource, + json(tick)!, + ], + report, + ); +} + function importAgents( archive: ArchiveDatabase, document: ExperimentExportDocument, diff --git a/packages/experiment-archive/src/migrations.ts b/packages/experiment-archive/src/migrations.ts index d7b6dd0..bbefe8f 100644 --- a/packages/experiment-archive/src/migrations.ts +++ b/packages/experiment-archive/src/migrations.ts @@ -338,4 +338,20 @@ export const migrations: readonly Migration[] = [ ON provider_attempts(experiment_id, outcome, started_at, id); `, }, + { + version: 5, + description: 'safe zero-swarm tick telemetry', + sql: ` + CREATE TABLE swarm_ticks ( + experiment_id TEXT NOT NULL REFERENCES experiments(id) ON DELETE CASCADE, + tick_number INTEGER NOT NULL, + virtual_time TEXT NOT NULL, + plan_source TEXT NOT NULL, + source_json TEXT NOT NULL, + PRIMARY KEY (experiment_id, tick_number) + ) STRICT; + CREATE INDEX swarm_ticks_experiment_time_idx + ON swarm_ticks(experiment_id, virtual_time, tick_number); + `, + }, ] as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7e4e045..981da59 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -596,6 +596,137 @@ export const reflexObservationSchema = z }); export type ReflexObservation = z.infer; +/** A server-compiled action Zero may select by opaque identifier. */ +export const zeroActionCandidateSchema = z + .object({ + id: z + .string() + .regex(/^zero_action_[0-9]+$/) + .max(40), + action: worldActionSchema, + description: z.string().trim().min(1).max(320), + }) + .strict(); +export type ZeroActionCandidate = z.infer; + +export const swarmPlanSchema = z + .object({ + strategySummary: z.string().trim().min(1).max(500), + directives: z + .array(swarmDirectiveSchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents), + zeroActionCandidateId: z + .string() + .regex(/^zero_action_[0-9]+$/) + .max(40), + }) + .strict() + .superRefine((plan, context) => { + const directiveIds = plan.directives.map(({ id }) => id); + const agentIds = plan.directives.map(({ agentId }) => agentId); + if (new Set(directiveIds).size !== directiveIds.length) + context.addIssue({ + code: 'custom', + path: ['directives'], + message: 'Swarm directive IDs must be unique.', + }); + if (new Set(agentIds).size !== agentIds.length) + context.addIssue({ + code: 'custom', + path: ['directives'], + message: 'A swarm plan may contain at most one directive per worker.', + }); + }); +export type SwarmPlan = z.infer; + +/** + * Strategic input for Agent Zero. This deliberately carries only authoritative + * world facts and bounded choices; it contains no social or prose-memory data. + */ +export const zeroStrategicObservationSchema = z + .object({ + zeroAgentId: agentIdSchema, + tickNumber: z.number().int().nonnegative(), + virtualTime: z.iso.datetime(), + cells: z + .array( + z + .object({ + cell: h3CellSchema, + state: z.enum(['open', 'infected']), + controllerAgentId: agentIdSchema.nullable(), + }) + .strict(), + ) + .min(1), + agents: z + .array( + z + .object({ + agentId: agentIdSchema, + position: h3CellSchema, + controlledCellCount: z.number().int().nonnegative(), + territoryDelta: z.number().int(), + workerStatus: z + .enum(['advancing', 'stalled', 'blocked', 'unknown']) + .optional(), + directive: swarmDirectiveSchema.nullable().optional(), + }) + .strict(), + ) + .min(1) + .max(WORLD_SCENARIO_LIMITS.maximumAgents), + recentPlayerPressure: z.array(z.string().trim().min(1).max(180)).max(12), + legalZeroActions: z.array(zeroActionCandidateSchema).min(1).max(9), + strategicTargetCells: z.array(h3CellSchema).max(80), + }) + .strict() + .superRefine((observation, context) => { + const cells = observation.cells.map(({ cell }) => cell); + const agents = observation.agents.map(({ agentId }) => agentId); + const actionIds = observation.legalZeroActions.map(({ id }) => id); + if (new Set(cells).size !== cells.length) + context.addIssue({ + code: 'custom', + path: ['cells'], + message: 'Strategic cell facts must be unique.', + }); + if (new Set(agents).size !== agents.length) + context.addIssue({ + code: 'custom', + path: ['agents'], + message: 'Strategic agent facts must be unique.', + }); + if (!agents.includes(observation.zeroAgentId)) + context.addIssue({ + code: 'custom', + path: ['zeroAgentId'], + message: 'Agent Zero must appear in strategic agent facts.', + }); + if (new Set(actionIds).size !== actionIds.length) + context.addIssue({ + code: 'custom', + path: ['legalZeroActions'], + message: 'Zero action candidate IDs must be unique.', + }); + const targets = new Set(observation.strategicTargetCells); + if ( + observation.agents.some( + ({ directive }) => + directive?.targetCell && !targets.has(directive.targetCell), + ) + ) + context.addIssue({ + code: 'custom', + path: ['agents'], + message: + 'Active directive targets must be in the strategic target allowlist.', + }); + }); +export type ZeroStrategicObservation = z.infer< + typeof zeroStrategicObservationSchema +>; + export const cognitionSourceSchema = z.enum([ 'zero-llm', 'jev-reflex', @@ -2498,6 +2629,7 @@ export const providerAttemptRecordSchema = z provider: providerMetadataSchema.optional(), failure: providerFailureSchema.optional(), reflexDecision: reflexDecisionSchema.optional(), + swarmPlan: swarmPlanSchema.optional(), reservedCredits: z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/), actualCostCredits: z .string() @@ -2534,6 +2666,12 @@ export const providerAttemptRecordSchema = z message: 'Reflex decision telemetry belongs only to completed attempts.', }); + if (attempt.swarmPlan && attempt.outcome !== 'completed') + context.addIssue({ + code: 'custom', + path: ['swarmPlan'], + message: 'Swarm plan telemetry belongs only to completed attempts.', + }); if ( ['provider-error', 'cancelled', 'timeout'].includes(attempt.outcome) && !attempt.failure @@ -2571,6 +2709,53 @@ export const providerAttemptRecordSchema = z }); export type ProviderAttemptRecord = z.infer; +export const swarmWorkerTickRecordSchema = z + .object({ + agentId: agentIdSchema, + directive: swarmDirectiveSchema, + action: worldActionSchema.optional(), + actionResult: worldActionResultSchema.optional(), + reflexDecision: reflexDecisionSchema.optional(), + source: cognitionSourceSchema, + failure: providerFailureSchema.optional(), + }) + .strict(); +export type SwarmWorkerTickRecord = z.infer; + +/** Safe committed-tick telemetry for zero-swarm experiments. */ +export const swarmTickRecordSchema = z + .object({ + tickNumber: z.number().int().positive(), + virtualTime: z.iso.datetime(), + tickIntervalMinutes: z.number().int().positive(), + plan: swarmPlanSchema, + planSource: z.enum(['zero-llm', 'deterministic-fallback']), + plannerFailure: providerFailureSchema.optional(), + plannerMetadata: providerMetadataSchema.optional(), + zeroAction: worldActionSchema.optional(), + zeroActionResult: worldActionResultSchema.optional(), + workers: z + .array(swarmWorkerTickRecordSchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents), + }) + .strict() + .superRefine((record, context) => { + const agentIds = record.workers.map(({ agentId }) => agentId); + if (new Set(agentIds).size !== agentIds.length) + context.addIssue({ + code: 'custom', + path: ['workers'], + message: 'Each worker may have one swarm tick record.', + }); + if (record.planSource === 'zero-llm' && record.plannerFailure) + context.addIssue({ + code: 'custom', + path: ['plannerFailure'], + message: 'A successful Zero plan cannot include a planner failure.', + }); + }); +export type SwarmTickRecord = z.infer; + export const providerAttemptRetentionSchema = z .object({ limit: z.number().int().positive(), @@ -3205,6 +3390,7 @@ export const simulationSnapshotSchema = z ) .default([]), turns: z.array(agentTurnRecordSchema).max(120), + swarmTicks: z.array(swarmTickRecordSchema).max(120).optional(), experiment: z.object({ id: z.uuid().brand<'ExperimentId'>(), startedAt: z.iso.datetime(), @@ -3471,9 +3657,29 @@ export const singleTickResponseSchema = z .object({ snapshot: simulationSnapshotSchema, tickNumber: z.number().int().positive(), - records: z.array(agentTurnRecordSchema).min(1), + records: z.array(agentTurnRecordSchema).min(0), + swarmTick: swarmTickRecordSchema.optional(), }) .superRefine((response, context) => { + const zeroSwarmResponse = + response.snapshot.scenario.cognitionMode === 'zero-swarm-v1' && + response.records.length === 0 && + response.swarmTick !== undefined; + if (zeroSwarmResponse) { + const swarmTick = response.swarmTick!; + if ( + response.snapshot.tickNumber !== response.tickNumber || + swarmTick.tickNumber !== response.tickNumber || + response.snapshot.virtualTime !== swarmTick.virtualTime || + response.snapshot.lastTickIntervalMinutes !== + swarmTick.tickIntervalMinutes + ) + context.addIssue({ + code: 'custom', + message: 'Swarm tick telemetry must match the committed snapshot.', + }); + return; + } const roster = response.snapshot.world.agents.map(({ id }) => id); const positions = response.records.map(({ tickPosition }) => tickPosition); const agents = response.records.map(({ agentId }) => agentId); @@ -3501,6 +3707,13 @@ export const singleTickResponseSchema = z message: 'Tick response records must exactly match the committed snapshot and roster.', }); + if (response.records.length === 0) + context.addIssue({ + code: 'custom', + path: ['records'], + message: + 'Empty tick records require zero-swarm mode with swarm tick telemetry.', + }); }); export type SingleTickResponse = z.infer; @@ -4063,6 +4276,7 @@ export const experimentExportPreviewSchema = z.object({ experimentId: experimentIdSchema, matchingTurnCount: z.number().int().nonnegative(), matchingTickCount: z.number().int().nonnegative().optional(), + matchingSwarmTickCount: z.number().int().nonnegative().optional(), matchingCommunicationCount: z.number().int().nonnegative(), matchingControlChangeCount: z.number().int().nonnegative(), matchingDiplomacyEventCount: z.number().int().nonnegative(), @@ -4248,6 +4462,7 @@ const experimentExportDocumentObjectSchema = z .max(WORLD_SCENARIO_LIMITS.maximumAgents), matchingTurnCount: z.number().int().nonnegative(), matchingTickCount: z.number().int().nonnegative().optional(), + matchingSwarmTickCount: z.number().int().nonnegative().optional(), matchingCommunicationCount: z.number().int().nonnegative(), matchingControlChangeCount: z.number().int().nonnegative(), matchingDiplomacyEventCount: z.number().int().nonnegative(), @@ -4302,12 +4517,49 @@ const experimentExportDocumentObjectSchema = z controlChanges: z.array(exportedControlChangeSchema).optional(), allianceEvents: z.array(allianceEventSchema).optional(), tickSummaries: z.array(experimentTickSummarySchema).optional(), + swarmTicks: z.array(swarmTickRecordSchema).optional(), providerAttempts: z.array(providerAttemptRecordSchema).optional(), attemptRetention: providerAttemptRetentionSchema.optional(), attemptAccounting: experimentAttemptAccountingSchema.optional(), turns: z.array(experimentExportTurnSchema), }) .superRefine((document, context) => { + if (document.swarmTicks !== undefined) { + if (document.experiment.scenario?.cognitionMode !== 'zero-swarm-v1') + context.addIssue({ + code: 'custom', + path: ['swarmTicks'], + message: + 'Swarm tick telemetry requires zero-swarm-v1 cognition mode.', + }); + const tickNumbers = document.swarmTicks.map( + ({ tickNumber }) => tickNumber, + ); + if (new Set(tickNumbers).size !== tickNumbers.length) + context.addIssue({ + code: 'custom', + path: ['swarmTicks'], + message: 'Exported swarm tick numbers must be unique.', + }); + if ( + document.selection.matchingSwarmTickCount !== document.swarmTicks.length + ) + context.addIssue({ + code: 'custom', + path: ['selection', 'matchingSwarmTickCount'], + message: 'Swarm tick count must match exported swarm telemetry.', + }); + if ( + document.turns.length === 0 && + document.selection.matchingTickCount !== undefined && + document.selection.matchingTickCount !== document.swarmTicks.length + ) + context.addIssue({ + code: 'custom', + path: ['selection', 'matchingTickCount'], + message: 'Zero-swarm tick count must match exported swarm telemetry.', + }); + } if (document.currentGoals) { const selectedIds = new Set(document.selection.selectedAgentIds); const agentIds = new Set(document.agents.map(({ id }) => id));