From 981fafec0cf5d1bf92f617d41a9e672a231806f7 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Sun, 20 Sep 2026 17:01:40 -0400 Subject: [PATCH 1/2] feat(swarm): add directive completion semantics --- .../src/simulation-service.swarm.test.ts | 200 ++++++++++++++++++ apps/game-api/src/simulation-service.ts | 88 +++++++- apps/game-api/src/swarm-comparison.ts | 28 +-- apps/game-api/src/swarm-directives.test.ts | 124 +++++++++++ apps/game-api/src/swarm-directives.ts | 98 +++++++++ .../src/components/swarm-view.test.tsx | 18 ++ apps/world-lab/src/components/swarm-view.tsx | 11 + docs/ARCHITECTURE.md | 15 ++ docs/TESTING.md | 7 + .../agent-runtime/src/swarm-planner.test.ts | 27 +++ packages/agent-runtime/src/swarm-planner.ts | 12 +- packages/shared/src/index.ts | 23 +- 12 files changed, 631 insertions(+), 20 deletions(-) create mode 100644 apps/game-api/src/swarm-directives.test.ts create mode 100644 apps/game-api/src/swarm-directives.ts diff --git a/apps/game-api/src/simulation-service.swarm.test.ts b/apps/game-api/src/simulation-service.swarm.test.ts index 9052c0e..c028c5f 100644 --- a/apps/game-api/src/simulation-service.swarm.test.ts +++ b/apps/game-api/src/simulation-service.swarm.test.ts @@ -112,6 +112,118 @@ class InspectingPlanner implements SwarmPlanner { } } +class LifecyclePlanner implements SwarmPlanner { + readonly mode = 'scripted-swarm-test' as const; + readonly configured = true; + readonly observations: ZeroStrategicObservation[] = []; + private relocatingAgentId: string | null = null; + constructor(private readonly mission: 'expand' | 'hold' | 'relocate') {} + async plan( + observation: ZeroStrategicObservation, + _model: string, + options: PlannerOptions = {}, + ) { + this.observations.push(structuredClone(observation)); + const workers = observation.agents.filter( + ({ agentId }) => agentId !== observation.zeroAgentId, + ); + if (this.mission === 'relocate' && observation.tickNumber === 1) { + this.relocatingAgentId = + workers.find((agent) => + observation.strategicTargetCells.some( + (cell) => + gridDistance(agent.position, cell) === 1 && + !observation.agents.some(({ position }) => position === cell), + ), + )?.agentId ?? null; + if (!this.relocatingAgentId) + throw new Error('No worker has an adjacent relocate target.'); + } + const zeroActionCandidateId = observation.legalZeroActions.find( + ({ action }) => action.type === 'wait', + )!.id; + const plan: SwarmPlan = { + strategySummary: 'Lifecycle fixture.', + zeroActionCandidateId, + directives: workers.map((agent, index) => { + const mission = + this.mission === 'relocate' + ? agent.agentId === this.relocatingAgentId && + observation.tickNumber === 1 + ? 'relocate' + : 'hold' + : this.mission; + const targetCell = + mission === 'relocate' + ? observation.strategicTargetCells.find( + (cell) => + gridDistance(agent.position, cell) === 1 && + !observation.agents.some(({ position }) => position === cell), + ) + : agent.position; + if (!targetCell) throw new Error('No adjacent relocate target.'); + return { + id: `lifecycle-${observation.tickNumber}-${index}`, + agentId: agent.agentId, + mission, + targetCell, + priority: 'normal', + riskTolerance: 'low', + issuedAtTick: observation.tickNumber, + expiresAtTick: observation.tickNumber + 4, + }; + }), + }; + const metadata = { + provider: 'scripted-test' as const, + model: 'test/zero', + latencyMs: 0, + }; + options.beginAttempt?.('initial')?.({ + outcome: 'completed', + provider: metadata, + swarmPlan: plan, + }); + return { plan, metadata }; + } +} + +function lifecycleReflex(): ReflexProvider { + return { + mode: 'scripted-reflex-test', + model: 'test-reflex', + configured: true, + async decide(observation, options) { + const choice = observation.candidates.find(({ description }) => + observation.directive.mission === 'relocate' + ? description.includes('This advances toward the assigned target.') + : 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', + reflexDecision: decision, + }); + return decision; + }, + }; +} + function setup( planner: SwarmPlanner, reflex: ReflexProvider, @@ -857,4 +969,92 @@ describe('zero-swarm SimulationService tick', () => { simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted, ).toBe(1); }); + + it('replans after relocate completion and identifies completed directives to Zero', async () => { + const planner = new LifecyclePlanner('relocate'); + const simulation = setup(planner, lifecycleReflex()); + await simulation.executeNextTick(); + const first = simulation.getSnapshot().swarmTicks?.[0]; + const relocating = first?.workers.find( + ({ directive }) => directive.mission === 'relocate', + ); + expect(relocating?.action?.type).toBe('move'); + await simulation.executeNextTick(); + const second = simulation.getSnapshot().swarmTicks?.[1]; + expect(second?.replanReasons).toContain('directive-complete'); + expect(second?.planSource).toBe('zero-llm'); + expect(planner.observations).toHaveLength(2); + expect(planner.observations[1]?.completedDirectives).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agentId: relocating?.agentId, + directiveId: relocating?.directive.id, + }), + ]), + ); + }); + + it('completes expand only after its target becomes worker-controlled', async () => { + const planner = new LifecyclePlanner('expand'); + const infecting: 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'), + )!; + 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', + reflexDecision: decision, + }); + return decision; + }, + }; + const simulation = setup(planner, infecting); + await simulation.executeNextTick(); + expect(planner.observations).toHaveLength(1); + expect( + simulation + .getSnapshot() + .swarmTicks?.[0]?.workers.every( + ({ actionResult }) => actionResult?.accepted, + ), + ).toBe(true); + await simulation.executeNextTick(); + const second = simulation.getSnapshot().swarmTicks?.[1]; + expect(second?.replanReasons).toContain('directive-complete'); + expect(second?.planSource).toBe('deterministic-fallback'); + expect(second?.plannerFailure?.code).toBe('invalid-decision'); + expect( + simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted, + ).toBe(9); + }); + + it('does not complete a hold directive merely because its worker waits at target', async () => { + const planner = new LifecyclePlanner('hold'); + const simulation = setup(planner, lifecycleReflex()); + await simulation.executeNextTick(); + await simulation.executeNextTick(); + expect(simulation.getSnapshot().swarmTicks?.[1]?.planSource).toBe( + 'directive-reuse', + ); + expect(planner.observations).toHaveLength(1); + }); }); diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 25b8010..ac73b8b 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -1,6 +1,7 @@ import { gridDisk, gridDistance } from 'h3-js'; import { AgentProviderError, + SwarmPlannerError, dispatchTickDecisions, type ReflexProvider, type SwarmPlanner, @@ -71,6 +72,7 @@ import { type AllianceProposalId, type SimulatedPlayerEvent, type SwarmPlan, + type CompletedSwarmDirective, type SwarmReplanReason, type SwarmTickRecord, type ZeroStrategicObservation, @@ -120,6 +122,10 @@ import { type CompiledReflexObservation, type ReflexSelection, } from './reflex-execution'; +import { + isSwarmDirectiveComplete, + swarmDirectiveIssue, +} from './swarm-directives'; function attemptAccountingForScenario( executionLimits: AppliedScenario['executionLimits'], @@ -1619,10 +1625,14 @@ export class SimulationService { this.#scenario.worldSeed, tickNumber, ); + const completedDirectives = this.#completedSwarmDirectives( + preTickState, + ).filter(({ agentId }) => candidate.agents.has(agentId)); const replanReasons = this.#swarmReplanReasons( tickNumber, playerAdvance.events, candidate, + completedDirectives, ); if ( agents.length !== preTickState.agents.size && @@ -1664,6 +1674,7 @@ export class SimulationService { virtualTime, playerAdvance.events, replanReasons, + completedDirectives, ); let plan: SwarmPlan; let planSource: @@ -1725,7 +1736,13 @@ export class SimulationService { throw new SimulationTurnCancelledError(); plan = swarmPlanSchema.parse(planned.plan); plannerMetadata = planned.metadata; - this.#assertSwarmPlan(plan, observation, zero.id, tickNumber); + this.#assertSwarmPlan( + plan, + observation, + candidate, + zero.id, + tickNumber, + ); } } catch (error) { if ( @@ -1735,7 +1752,13 @@ export class SimulationService { throw error; plannerFailure = this.#providerFailure(error, resolvedZero.modelId); planSource = 'deterministic-fallback'; - plan = this.#fallbackSwarmPlan(agents, zero.id, tickNumber, candidate); + plan = this.#fallbackSwarmPlan( + agents, + zero.id, + tickNumber, + candidate, + completedDirectives, + ); } const zeroAction = observation.legalZeroActions.find( ({ id }) => id === plan.zeroActionCandidateId, @@ -1775,7 +1798,10 @@ export class SimulationService { const retainedDirective = this.#lastValidSwarmPlan?.directives.some( (previous) => previous.agentId === worker.id && - previous.expiresAtTick >= tickNumber, + previous.expiresAtTick >= tickNumber && + !completedDirectives.some( + ({ directiveId }) => directiveId === previous.id, + ), ); const choice = planSource === 'deterministic-fallback' && !retainedDirective @@ -1848,6 +1874,7 @@ export class SimulationService { plan, planSource, ...(replanReasons.length ? { replanReasons } : {}), + ...(completedDirectives.length ? { completedDirectives } : {}), ...(plannerFailure ? { plannerFailure } : {}), ...(plannerMetadata ? { plannerMetadata } : {}), zeroAction, @@ -2715,6 +2742,7 @@ export class SimulationService { virtualTime: string, playerEvents: readonly SimulatedPlayerEvent[], replanReasons: readonly SwarmReplanReason[] = [], + completedDirectives: readonly CompletedSwarmDirective[] = [], ): ZeroStrategicObservation { const counts = new Map( [...state.agents.keys()].map((id) => [id, 0]), @@ -2807,16 +2835,53 @@ export class SimulationService { : 'The simulated player moved this tick.', ), ...(replanReasons.length ? { replanReasons: [...replanReasons] } : {}), + ...(completedDirectives.length + ? { completedDirectives: [...completedDirectives] } + : {}), ...(workerReplanRequests.length ? { workerReplanRequests } : {}), legalZeroActions, strategicTargetCells, }; } + #completedSwarmDirectives(state: WorldState): CompletedSwarmDirective[] { + if (!this.#lastValidSwarmPlan) return []; + const recentCleanedCells = this.#simulatedPlayerEvents + .filter( + ( + event, + ): event is Extract< + SimulatedPlayerEvent, + { type: 'hex-disinfected' } + > => event.type === 'hex-disinfected', + ) + .slice(-6) + .map(({ cell }) => cell); + const latestWorkers = this.#swarmTicks.at(-1)?.workers ?? []; + return this.#lastValidSwarmPlan.directives.flatMap((directive) => { + if ( + directive.expiresAtTick < this.#completedTickCount || + !state.agents.has(directive.agentId) + ) + return []; + const priorNearbyPressure = latestWorkers.find( + ({ agentId, directive: active }) => + agentId === directive.agentId && active.id === directive.id, + )?.situation?.nearbyPressure; + return isSwarmDirectiveComplete(state, directive, { + priorNearbyPressure, + recentCleanedCells, + }) + ? [{ agentId: directive.agentId, directiveId: directive.id }] + : []; + }); + } + #swarmReplanReasons( tickNumber: number, playerEvents: readonly SimulatedPlayerEvent[], state: WorldState = this.#state, + completedDirectives: readonly CompletedSwarmDirective[] = [], ): SwarmReplanReason[] { if (!this.#lastValidSwarmPlan) return ['initial']; const reasons: SwarmReplanReason[] = []; @@ -2831,6 +2896,7 @@ export class SimulationService { currentWorkers.some((agentId, index) => agentId !== plannedWorkers[index]) ) reasons.push('roster-changed'); + if (completedDirectives.length) reasons.push('directive-complete'); if ((tickNumber - 1) % 5 === 0) reasons.push('periodic-review'); if ( this.#lastValidSwarmPlan.directives.some( @@ -2922,6 +2988,7 @@ export class SimulationService { #assertSwarmPlan( plan: SwarmPlan, observation: ZeroStrategicObservation, + state: WorldState, zeroAgentId: AgentId, tickNumber: number, ): void { @@ -2947,6 +3014,15 @@ export class SimulationService { throw new Error( 'The Zero plan does not contain one current, allowlisted directive per worker.', ); + for (const directive of directives) { + const issue = swarmDirectiveIssue(state, directive); + if (issue) + throw new SwarmPlannerError({ + code: 'invalid-decision', + message: `Agent Zero assigned an invalid directive: ${issue}`, + retryable: false, + }); + } if ( !observation.legalZeroActions.some( ({ id }) => id === plan.zeroActionCandidateId, @@ -2962,6 +3038,7 @@ export class SimulationService { zeroAgentId: AgentId, tickNumber: number, state: WorldState, + completedDirectives: readonly CompletedSwarmDirective[], ): SwarmPlan { const workers = agents.filter(({ id }) => id !== zeroAgentId); const retained = this.#lastValidSwarmPlan?.directives; @@ -2970,7 +3047,10 @@ export class SimulationService { retained?.find( (directive) => directive.agentId === worker.id && - directive.expiresAtTick >= tickNumber, + directive.expiresAtTick >= tickNumber && + !completedDirectives.some( + ({ directiveId }) => directiveId === directive.id, + ), ) ?? { id: `neutral-${tickNumber}-${worker.id}`, agentId: worker.id, diff --git a/apps/game-api/src/swarm-comparison.ts b/apps/game-api/src/swarm-comparison.ts index dda94e8..00d46bf 100644 --- a/apps/game-api/src/swarm-comparison.ts +++ b/apps/game-api/src/swarm-comparison.ts @@ -165,19 +165,21 @@ class OfflinePlanner implements SwarmPlanner { zeroActionCandidateId: zeroAction.id, directives: observation.agents .filter(({ agentId }) => agentId !== observation.zeroAgentId) - .map((agent, index) => ({ - id: `offline-${observation.tickNumber}-${index}`, - agentId: agent.agentId, - mission: 'expand' as const, - targetCell: - nearestTarget(agent.position, openTargets) ?? agent.position, - priority: 'normal' as const, - riskTolerance: 'medium' as const, - issuedAtTick: observation.tickNumber, - // PR D cadence: directives normally cover five ticks, with events - // still able to bring Zero back sooner. - expiresAtTick: observation.tickNumber + 4, - })), + .map((agent, index) => { + const openTarget = nearestTarget(agent.position, openTargets); + return { + id: `offline-${observation.tickNumber}-${index}`, + agentId: agent.agentId, + mission: openTarget ? ('expand' as const) : ('hold' as const), + targetCell: openTarget ?? agent.position, + priority: 'normal' as const, + riskTolerance: 'medium' as const, + issuedAtTick: observation.tickNumber, + // PR D cadence: directives normally cover five ticks, with events + // still able to bring Zero back sooner. + expiresAtTick: observation.tickNumber + 4, + }; + }), }; finalize?.({ outcome: 'completed', diff --git a/apps/game-api/src/swarm-directives.test.ts b/apps/game-api/src/swarm-directives.test.ts new file mode 100644 index 0000000..a19615b --- /dev/null +++ b/apps/game-api/src/swarm-directives.test.ts @@ -0,0 +1,124 @@ +import { gridDisk } from 'h3-js'; +import { describe, expect, it } from 'vitest'; +import { type H3Cell, type SwarmDirective } from '@hexzero/shared'; +import { createDevelopmentWorld, toWorldState } from '@hexzero/world-engine'; +import { + isSwarmDirectiveComplete, + swarmDirectiveIssue, +} from './swarm-directives'; + +function fixture() { + const state = toWorldState(createDevelopmentWorld()); + const worker = [...state.agents.values()][1]!; + const neighbor = gridDisk(worker.currentCell, 1).find( + (cell) => cell !== worker.currentCell && state.hexes.has(cell as H3Cell), + ) as H3Cell; + const farOpen = [...state.hexes.entries()].find( + ([cell, hex]) => + hex.state === 'open' && + cell !== worker.currentCell && + !gridDisk(cell, 1).some((nearby) => nearby === worker.currentCell), + )![0]; + const directive = ( + mission: SwarmDirective['mission'], + targetCell: H3Cell | null = neighbor, + ): SwarmDirective => ({ + id: `directive-${mission}`, + agentId: worker.id, + mission, + targetCell, + priority: 'normal', + riskTolerance: 'medium', + issuedAtTick: 1, + expiresAtTick: 4, + }); + return { state, worker, neighbor, farOpen, directive }; +} + +describe('swarm directive semantics', () => { + it('completes expand only after the target is infected and worker-controlled', () => { + const { state, worker, neighbor, directive } = fixture(); + expect(isSwarmDirectiveComplete(state, directive('expand'))).toBe(false); + const hexes = new Map(state.hexes); + hexes.set(neighbor, { state: 'infected', controllerAgentId: worker.id }); + expect( + isSwarmDirectiveComplete({ ...state, hexes }, directive('expand')), + ).toBe(true); + }); + + it('completes relocate and reinforce once the worker reaches the target', () => { + const { state, worker, neighbor, directive } = fixture(); + const agents = new Map(state.agents); + agents.set(worker.id, { ...worker, currentCell: neighbor }); + const arrived = { ...state, agents }; + expect(isSwarmDirectiveComplete(arrived, directive('relocate'))).toBe(true); + expect(isSwarmDirectiveComplete(arrived, directive('reinforce'))).toBe( + true, + ); + }); + + it('completes evade on arrival or after exiting prior high pressure', () => { + const { state, worker, neighbor, farOpen, directive } = fixture(); + expect(isSwarmDirectiveComplete(state, directive('evade'))).toBe(false); + const agents = new Map(state.agents); + agents.set(worker.id, { ...worker, currentCell: neighbor }); + expect( + isSwarmDirectiveComplete({ ...state, agents }, directive('evade')), + ).toBe(true); + expect( + isSwarmDirectiveComplete(state, directive('evade', farOpen), { + priorNearbyPressure: 'high', + recentCleanedCells: [farOpen], + }), + ).toBe(true); + expect( + isSwarmDirectiveComplete(state, directive('evade', farOpen), { + priorNearbyPressure: 'high', + recentCleanedCells: [worker.currentCell], + }), + ).toBe(false); + expect( + isSwarmDirectiveComplete(state, directive('evade', farOpen), { + priorNearbyPressure: 'high', + }), + ).toBe(false); + }); + + it('does not complete hold or directives without targets automatically', () => { + const { state, directive } = fixture(); + expect(isSwarmDirectiveComplete(state, directive('hold'))).toBe(false); + expect(isSwarmDirectiveComplete(state, directive('relocate', null))).toBe( + false, + ); + }); + + it('rejects missing, already-satisfied, and incoherent targets on issue', () => { + const { state, worker, neighbor, farOpen, directive } = fixture(); + expect(swarmDirectiveIssue(state, directive('expand', null))).toBeTruthy(); + expect( + swarmDirectiveIssue(state, directive('relocate', worker.currentCell)), + ).toBeTruthy(); + const hexes = new Map(state.hexes); + hexes.set(neighbor, { state: 'infected', controllerAgentId: worker.id }); + expect( + swarmDirectiveIssue({ ...state, hexes }, directive('expand')), + ).toBeTruthy(); + expect( + swarmDirectiveIssue({ ...state, hexes }, directive('reinforce')), + ).toBeNull(); + const frontierHexes = new Map(state.hexes); + frontierHexes.set(worker.currentCell, { + state: 'infected', + controllerAgentId: worker.id, + }); + expect( + swarmDirectiveIssue( + { ...state, hexes: frontierHexes }, + directive('reinforce'), + ), + ).toBeNull(); + expect( + swarmDirectiveIssue(state, directive('reinforce', farOpen)), + ).toContain('Reinforce'); + }); +}); diff --git a/apps/game-api/src/swarm-directives.ts b/apps/game-api/src/swarm-directives.ts new file mode 100644 index 0000000..1d3a56b --- /dev/null +++ b/apps/game-api/src/swarm-directives.ts @@ -0,0 +1,98 @@ +import { gridDistance } from 'h3-js'; +import { type H3Cell, type SwarmDirective } from '@hexzero/shared'; +import { type WorldState } from '@hexzero/world-engine'; + +export interface SwarmDirectiveCompletionContext { + priorNearbyPressure?: 'low' | 'rising' | 'high'; + recentCleanedCells?: readonly H3Cell[]; +} + +function gridDistanceOrNull(from: H3Cell, to: H3Cell): number | null { + try { + return gridDistance(from, to); + } catch { + return null; + } +} + +function isOpenAdjacentToInfected(state: WorldState, target: H3Cell): boolean { + if (state.hexes.get(target)?.state !== 'open') return false; + return [...state.hexes.entries()].some( + ([cell, hex]) => + hex.state === 'infected' && gridDistanceOrNull(target, cell) === 1, + ); +} + +/** + * Identifies directives whose objective is already satisfied by authoritative + * world state. This deliberately has no engine or provider side effects. + */ +export function isSwarmDirectiveComplete( + state: WorldState, + directive: SwarmDirective, + context: SwarmDirectiveCompletionContext = {}, +): boolean { + if (!directive.targetCell) return false; + const worker = state.agents.get(directive.agentId); + if (!worker) return false; + + switch (directive.mission) { + case 'expand': { + const target = state.hexes.get(directive.targetCell); + return ( + target?.state === 'infected' && + target.controllerAgentId === directive.agentId + ); + } + case 'relocate': + case 'reinforce': + return worker.currentCell === directive.targetCell; + case 'evade': + return ( + worker.currentCell === directive.targetCell || + (context.priorNearbyPressure === 'high' && + context.recentCleanedCells !== undefined && + !context.recentCleanedCells.slice(-6).some((cell) => { + const distance = gridDistanceOrNull(worker.currentCell, cell); + return distance !== null && distance <= 1; + })) + ); + case 'hold': + return false; + } +} + +/** Returns a deterministic reason when Zero proposes an incoherent directive. */ +export function swarmDirectiveIssue( + state: WorldState, + directive: SwarmDirective, +): string | null { + const worker = state.agents.get(directive.agentId); + if (!worker) return 'Assigned worker does not exist.'; + if (directive.mission !== 'hold' && !directive.targetCell) + return 'This mission requires a target cell.'; + if (!directive.targetCell) return null; + const target = state.hexes.get(directive.targetCell); + if (!target) return 'Target is outside the current world.'; + + if ( + directive.mission === 'expand' && + target.state === 'infected' && + target.controllerAgentId === directive.agentId + ) + return 'Expand target is already controlled by this worker.'; + if ( + (directive.mission === 'relocate' || + directive.mission === 'reinforce' || + directive.mission === 'evade') && + directive.targetCell === worker.currentCell + ) + return 'Target is already the worker’s current cell.'; + if ( + directive.mission === 'reinforce' && + target.state !== 'infected' && + !isOpenAdjacentToInfected(state, directive.targetCell) + ) + return 'Reinforce target must be infected or adjacent to infected territory.'; + return null; +} diff --git a/apps/world-lab/src/components/swarm-view.test.tsx b/apps/world-lab/src/components/swarm-view.test.tsx index 1f87321..cd908a1 100644 --- a/apps/world-lab/src/components/swarm-view.test.tsx +++ b/apps/world-lab/src/components/swarm-view.test.tsx @@ -162,6 +162,24 @@ describe('swarm telemetry panels', () => { expect(screen.getByText(/0.03 credits · includes/)).toBeInTheDocument(); }); + it('shows completed worker directives in strategy and activity telemetry', () => { + const value = snapshot(); + value.swarmTicks![0]!.replanReasons = ['directive-complete']; + value.swarmTicks![0]!.completedDirectives = [ + { agentId: value.world.agents[1]!.id, directiveId: 'directive-1' }, + ]; + render( + <> + + + , + ); + expect(screen.getByText('Completed directives')).toBeInTheDocument(); + // The completion summary and directive list both name this worker. + expect(screen.getAllByText('Worker')).toHaveLength(2); + expect(screen.getByText(/1 directive completed/)).toBeInTheDocument(); + }); + it('shows the telemetry empty state before the first committed tick', () => { render(); expect( diff --git a/apps/world-lab/src/components/swarm-view.tsx b/apps/world-lab/src/components/swarm-view.tsx index 55cafe1..265baeb 100644 --- a/apps/world-lab/src/components/swarm-view.tsx +++ b/apps/world-lab/src/components/swarm-view.tsx @@ -78,6 +78,14 @@ export function SwarmStrategyPanel({
Review triggers
{tick.replanReasons?.join(', ') || 'None this tick'}
+
+
Completed directives
+
+ {tick.completedDirectives + ?.map(({ agentId }) => agentName(snapshot, agentId)) + .join(', ') || 'None this tick'} +
+
Planner failure
@@ -430,6 +438,9 @@ export function SwarmActivityPanel({ {tick.signals?.length ? ` · ${tick.signals.length} worker replan request${tick.signals.length === 1 ? '' : 's'}` : ''} + {tick.completedDirectives?.length + ? ` · ${tick.completedDirectives.length} directive${tick.completedDirectives.length === 1 ? '' : 's'} completed` + : ''} ))} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fe16630..fa67021 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,6 +23,21 @@ the prior tick's physical action. Reaching the directive target has its own `at-target` observation status. Zero's worker status uses the same position comparison, rather than treating any accepted world action as progress. The existing deterministic replanning trigger for repeated waits is unchanged. +An `expand` directive completes only after its target is worker-controlled; +arriving on an open target is not completion. A `relocate` directive completes +when its worker reaches the target. A `reinforce` directive completes on arrival +at its target, which must be infected or adjacent to infection when issued. An +`evade` directive completes on arrival or when a worker that previously faced +high cleaning pressure leaves that pressure. `hold` persists until expiry or +another review trigger. +Completion triggers Zero review on the next tick. The strategic observation +identifies completed workers by safe `agentId` and server-issued `directiveId`. +New directives are validated against the authoritative pre-action world after +simulated-player pressure: non-hold missions need a target, expand cannot target +a cell already controlled by that worker, relocation/reinforcement/evasion +cannot target the worker's current cell, and reinforcement needs an infected or +adjacent frontier target. An invalid plan falls back safely. A completed prior +directive is not reused during that fallback. Zero planning uses the same provider-reported OpenRouter usage normalization as legacy turns, including actual `usage.cost` when returned, and preserves that metadata when a returned plan is rejected or a bounded non-success response diff --git a/docs/TESTING.md b/docs/TESTING.md index 235f9c6..c2a42ad 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -41,6 +41,13 @@ fallbacks for immediate diagnosis of apparently stationary ticks. Focused offline progress tests cover a worker advancing after a move, reaching a directive target, waiting at that target, and the corresponding status seen by Zero on the next planning tick. +Directive-lifecycle tests also cover expand completion only after worker control, +relocate and reinforce completion on arrival, with reinforce target validity +checked when issued; evade completion on arrival or +after leaving high cleaning pressure, non-completing hold-at-target behavior, +completed-directive IDs passed to the next Zero observation, and deterministic +fallback for an invalid newly issued friendly expand target. These use scripted +providers and make no network calls. The pre-live provider audit tests the Jev request's bounded capture-pressure projection, omission of hidden hunter state and empty capture noise, and the diff --git a/packages/agent-runtime/src/swarm-planner.test.ts b/packages/agent-runtime/src/swarm-planner.test.ts index 584075d..2ee3c57 100644 --- a/packages/agent-runtime/src/swarm-planner.test.ts +++ b/packages/agent-runtime/src/swarm-planner.test.ts @@ -116,6 +116,33 @@ describe('swarm planners', () => { }); }); + it('marks completed worker directives in the compact Zero request', async () => { + const completedObservation = zeroStrategicObservationSchema.parse({ + ...observation, + agents: observation.agents.map((agent) => + agent.agentId === worker + ? { ...agent, directive: plan.directives[0] } + : agent, + ), + replanReasons: ['directive-complete'], + completedDirectives: [{ agentId: worker, directiveId: 'directive-1' }], + }); + const fetchImplementation = vi + .fn() + .mockResolvedValue(plannerResponse(compactPlan)); + await new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation, + }).plan(completedObservation, 'test-model'); + const body = JSON.parse( + String(fetchImplementation.mock.calls[0]?.[1]?.body), + ) as { messages: Array<{ content: string }> }; + expect(JSON.parse(body.messages[1]!.content)).toMatchObject({ + replanReasons: ['directive-complete'], + workers: [{ workerId: 'worker_0', directiveComplete: true }], + }); + }); + it('rejects unknown compact choices with a safe specific reason', async () => { await expect( new OpenRouterSwarmPlanner({ diff --git a/packages/agent-runtime/src/swarm-planner.ts b/packages/agent-runtime/src/swarm-planner.ts index 15ce8a4..67245cc 100644 --- a/packages/agent-runtime/src/swarm-planner.ts +++ b/packages/agent-runtime/src/swarm-planner.ts @@ -416,6 +416,12 @@ function buildSwarmPlannerRequest( const targetIdByCell = new Map( targetChoices.map(({ targetId, cell }) => [cell, targetId]), ); + const completedDirectiveByAgent = new Map( + (observation.completedDirectives ?? []).map(({ agentId, directiveId }) => [ + agentId, + directiveId, + ]), + ); const workers = observation.agents .filter(({ agentId }) => agentId !== observation.zeroAgentId) .map((agent, index) => ({ @@ -424,6 +430,10 @@ function buildSwarmPlannerRequest( controlledCellCount: agent.controlledCellCount, territoryDelta: agent.territoryDelta, workerStatus: agent.workerStatus ?? 'unknown', + directiveComplete: + agent.directive !== null && + agent.directive !== undefined && + completedDirectiveByAgent.get(agent.agentId) === agent.directive.id, activeDirective: agent.directive ? { mission: agent.directive.mission, @@ -467,7 +477,7 @@ function buildSwarmPlannerRequest( { role: 'system', content: - 'You are Agent Zero, a strategic planner. Return only a JSON object with strategySummary, zeroActionCandidateId, and directives. Return exactly one directive per offered worker, using each workerId once. Each directive has only workerId, mission (expand|hold|relocate|reinforce|evade), targetId (one offered targetId or null), priority (low|normal|high), and riskTolerance (low|medium|high). Select zeroActionCandidateId from legalZeroActions. Assign intent, never exact worker movement. Code supplies directive IDs, agent IDs, target cells, and tick lifetimes; do not output those fields. Use replanReasons and workerReplanRequests when present.', + "You are Agent Zero, a strategic planner. Return only a JSON object with strategySummary, zeroActionCandidateId, and directives. Return exactly one directive per offered worker, using each workerId once. Each directive has only workerId, mission (expand|hold|relocate|reinforce|evade), targetId (one offered targetId or null), priority (low|normal|high), and riskTolerance (low|medium|high). Select zeroActionCandidateId from legalZeroActions. Assign intent, never exact worker movement. Code supplies directive IDs, agent IDs, target cells, and tick lifetimes; do not output those fields. Use replanReasons, directiveComplete, and workerReplanRequests when present. Non-hold missions need a target. Expand targets must not already be controlled by that worker. Reinforce targets must be infected or adjacent to infection. Do not assign a relocate, reinforce, or evade target equal to that worker's current position.", }, { role: 'user', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0209324..0c5e2a9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -660,6 +660,7 @@ export type SwarmPlan = z.infer; export const swarmReplanReasonSchema = z.enum([ 'initial', 'periodic-review', + 'directive-complete', 'directive-expired', 'worker-request', 'worker-stalled', @@ -670,6 +671,16 @@ export const swarmReplanReasonSchema = z.enum([ ]); export type SwarmReplanReason = z.infer; +export const completedSwarmDirectiveSchema = z + .object({ + agentId: agentIdSchema, + directiveId: z.string().trim().min(1).max(80), + }) + .strict(); +export type CompletedSwarmDirective = z.infer< + typeof completedSwarmDirectiveSchema +>; + export const swarmSignalSchema = z .object({ type: z.literal('worker-replan-requested'), @@ -718,7 +729,11 @@ export const zeroStrategicObservationSchema = z .min(1) .max(WORLD_SCENARIO_LIMITS.maximumAgents), recentPlayerPressure: z.array(z.string().trim().min(1).max(180)).max(12), - replanReasons: z.array(swarmReplanReasonSchema).max(8).optional(), + replanReasons: z.array(swarmReplanReasonSchema).max(10).optional(), + completedDirectives: z + .array(completedSwarmDirectiveSchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents) + .optional(), workerReplanRequests: z .array(swarmSignalSchema.omit({ type: true })) .max(WORLD_SCENARIO_LIMITS.maximumAgents) @@ -2795,7 +2810,11 @@ export const swarmTickRecordSchema = z 'deterministic-fallback', 'directive-reuse', ]), - replanReasons: z.array(swarmReplanReasonSchema).max(8).optional(), + replanReasons: z.array(swarmReplanReasonSchema).max(10).optional(), + completedDirectives: z + .array(completedSwarmDirectiveSchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents) + .optional(), signals: z .array(swarmSignalSchema) .max(WORLD_SCENARIO_LIMITS.maximumAgents) From 100b44d7db9e13dd0284154761bb47a2c8d100fd Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Sun, 20 Sep 2026 20:38:31 -0400 Subject: [PATCH 2/2] fix(swarm): require open expansion targets --- apps/game-api/src/swarm-directives.test.ts | 24 +++++++++++++++++++ apps/game-api/src/swarm-directives.ts | 8 ++----- docs/ARCHITECTURE.md | 8 +++---- docs/TESTING.md | 6 +++-- .../agent-runtime/src/swarm-planner.test.ts | 3 +++ packages/agent-runtime/src/swarm-planner.ts | 2 +- 6 files changed, 38 insertions(+), 13 deletions(-) diff --git a/apps/game-api/src/swarm-directives.test.ts b/apps/game-api/src/swarm-directives.test.ts index a19615b..d716f5b 100644 --- a/apps/game-api/src/swarm-directives.test.ts +++ b/apps/game-api/src/swarm-directives.test.ts @@ -92,6 +92,30 @@ describe('swarm directive semantics', () => { ); }); + it('rejects infected expand targets while accepting open targets', () => { + const { state, worker, neighbor, directive } = fixture(); + const otherWorker = [...state.agents.values()][2]!; + expect(swarmDirectiveIssue(state, directive('expand'))).toBeNull(); + + const otherControlled = new Map(state.hexes); + otherControlled.set(neighbor, { + state: 'infected', + controllerAgentId: otherWorker.id, + }); + expect( + swarmDirectiveIssue( + { ...state, hexes: otherControlled }, + directive('expand'), + ), + ).toContain('Expand'); + + const abandoned = new Map(state.hexes); + abandoned.set(neighbor, { state: 'infected', controllerAgentId: null }); + expect( + swarmDirectiveIssue({ ...state, hexes: abandoned }, directive('expand')), + ).toContain('Expand'); + }); + it('rejects missing, already-satisfied, and incoherent targets on issue', () => { const { state, worker, neighbor, farOpen, directive } = fixture(); expect(swarmDirectiveIssue(state, directive('expand', null))).toBeTruthy(); diff --git a/apps/game-api/src/swarm-directives.ts b/apps/game-api/src/swarm-directives.ts index 1d3a56b..c83fa54 100644 --- a/apps/game-api/src/swarm-directives.ts +++ b/apps/game-api/src/swarm-directives.ts @@ -75,12 +75,8 @@ export function swarmDirectiveIssue( const target = state.hexes.get(directive.targetCell); if (!target) return 'Target is outside the current world.'; - if ( - directive.mission === 'expand' && - target.state === 'infected' && - target.controllerAgentId === directive.agentId - ) - return 'Expand target is already controlled by this worker.'; + if (directive.mission === 'expand' && target.state !== 'open') + return 'Expand target must be an open cell.'; if ( (directive.mission === 'relocate' || directive.mission === 'reinforce' || diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fa67021..7fa5fc9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,10 +33,10 @@ another review trigger. Completion triggers Zero review on the next tick. The strategic observation identifies completed workers by safe `agentId` and server-issued `directiveId`. New directives are validated against the authoritative pre-action world after -simulated-player pressure: non-hold missions need a target, expand cannot target -a cell already controlled by that worker, relocation/reinforcement/evasion -cannot target the worker's current cell, and reinforcement needs an infected or -adjacent frontier target. An invalid plan falls back safely. A completed prior +simulated-player pressure: non-hold missions need a target, expand requires an +open cell, relocation/reinforcement/evasion cannot target the worker's current +cell, and reinforcement needs an infected or adjacent frontier target. An +invalid plan falls back safely. A completed prior directive is not reused during that fallback. Zero planning uses the same provider-reported OpenRouter usage normalization as legacy turns, including actual `usage.cost` when returned, and preserves that diff --git a/docs/TESTING.md b/docs/TESTING.md index c2a42ad..86307e2 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -46,8 +46,10 @@ relocate and reinforce completion on arrival, with reinforce target validity checked when issued; evade completion on arrival or after leaving high cleaning pressure, non-completing hold-at-target behavior, completed-directive IDs passed to the next Zero observation, and deterministic -fallback for an invalid newly issued friendly expand target. These use scripted -providers and make no network calls. +fallback for invalid newly issued expand targets. Focused validation tests +reject targets infected by the same worker, another worker, or no controller, +while accepting open targets. These use scripted providers and make no network +calls. The pre-live provider audit tests the Jev request's bounded capture-pressure projection, omission of hidden hunter state and empty capture noise, and the diff --git a/packages/agent-runtime/src/swarm-planner.test.ts b/packages/agent-runtime/src/swarm-planner.test.ts index 2ee3c57..088d8da 100644 --- a/packages/agent-runtime/src/swarm-planner.test.ts +++ b/packages/agent-runtime/src/swarm-planner.test.ts @@ -110,6 +110,9 @@ describe('swarm planners', () => { }); expect(body.max_tokens).toBe(4_096); expect(body.messages[0]?.content).toContain('Code supplies directive IDs'); + expect(body.messages[0]?.content).toContain( + 'Expand targets must be open cells.', + ); expect(JSON.parse(body.messages[1]!.content)).toMatchObject({ workers: [{ workerId: 'worker_0', position: cell }], targetChoices: [{ targetId: 'target_0', cell }], diff --git a/packages/agent-runtime/src/swarm-planner.ts b/packages/agent-runtime/src/swarm-planner.ts index 67245cc..955a4b0 100644 --- a/packages/agent-runtime/src/swarm-planner.ts +++ b/packages/agent-runtime/src/swarm-planner.ts @@ -477,7 +477,7 @@ function buildSwarmPlannerRequest( { role: 'system', content: - "You are Agent Zero, a strategic planner. Return only a JSON object with strategySummary, zeroActionCandidateId, and directives. Return exactly one directive per offered worker, using each workerId once. Each directive has only workerId, mission (expand|hold|relocate|reinforce|evade), targetId (one offered targetId or null), priority (low|normal|high), and riskTolerance (low|medium|high). Select zeroActionCandidateId from legalZeroActions. Assign intent, never exact worker movement. Code supplies directive IDs, agent IDs, target cells, and tick lifetimes; do not output those fields. Use replanReasons, directiveComplete, and workerReplanRequests when present. Non-hold missions need a target. Expand targets must not already be controlled by that worker. Reinforce targets must be infected or adjacent to infection. Do not assign a relocate, reinforce, or evade target equal to that worker's current position.", + "You are Agent Zero, a strategic planner. Return only a JSON object with strategySummary, zeroActionCandidateId, and directives. Return exactly one directive per offered worker, using each workerId once. Each directive has only workerId, mission (expand|hold|relocate|reinforce|evade), targetId (one offered targetId or null), priority (low|normal|high), and riskTolerance (low|medium|high). Select zeroActionCandidateId from legalZeroActions. Assign intent, never exact worker movement. Code supplies directive IDs, agent IDs, target cells, and tick lifetimes; do not output those fields. Use replanReasons, directiveComplete, and workerReplanRequests when present. Non-hold missions need a target. Expand targets must be open cells. Reinforce targets must be infected or adjacent to infection. Do not assign a relocate, reinforce, or evade target equal to that worker's current position.", }, { role: 'user',