From 5c615ee1cf6d86b23136d0e004b802310f10a8fe Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Sun, 20 Sep 2026 00:52:25 -0400 Subject: [PATCH] feat(swarm): reuse directives with event-driven replanning --- ROADMAP.md | 11 + apps/game-api/src/reflex-execution.test.ts | 1 + .../src/simulation-service.swarm.test.ts | 156 +++++++++++- apps/game-api/src/simulation-service.ts | 241 ++++++++++++++---- .../src/components/swarm-view.test.tsx | 37 +++ apps/world-lab/src/components/swarm-view.tsx | 69 +++-- docs/ARCHITECTURE.md | 11 +- docs/SECURITY.md | 5 + docs/TESTING.md | 4 + docs/adr/0031-persistent-swarm-directives.md | 45 ++++ packages/agent-runtime/src/reflex-provider.ts | 2 + .../agent-runtime/src/swarm-planner.test.ts | 10 + packages/agent-runtime/src/swarm-planner.ts | 3 +- .../src/typesafe-jev-reflex-provider.test.ts | 101 +++++++- .../src/typesafe-jev-reflex-provider.ts | 16 ++ packages/shared/src/index.ts | 57 ++++- packages/shared/src/reflex.test.ts | 23 ++ 17 files changed, 705 insertions(+), 87 deletions(-) create mode 100644 docs/adr/0031-persistent-swarm-directives.md diff --git a/ROADMAP.md b/ROADMAP.md index ceedacf..48368d2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,16 @@ # Roadmap +## Active migration: one intelligence, many bodies + +The active zero-swarm sequence supersedes the earlier social-agent direction +for new experiments while retaining `legacy-multi-agent` as a comparison mode. +PRs A-C established the Jev reflex seam, Agent Zero planner/tick, and World Lab +presentation. PR D makes directives persistent across ticks and wakes Zero on +fixed review and material events. PR E will add a stronger optional deterministic +human-pressure simulator. PR F will compare zero-swarm, legacy, and deterministic +worker baselines before deciding which legacy systems to retire. See ADRs +0028-0031. Historical milestones below remain as implementation history. + ## Experimental Patient Zero coordinator The current focused slice requires one normal physical agent, provides a diff --git a/apps/game-api/src/reflex-execution.test.ts b/apps/game-api/src/reflex-execution.test.ts index ad91382..9f9d1ed 100644 --- a/apps/game-api/src/reflex-execution.test.ts +++ b/apps/game-api/src/reflex-execution.test.ts @@ -172,6 +172,7 @@ describe('zero-swarm reflex execution seam', () => { probabilities, confidence: 1, }, + request_replan: { type: 'noul', noul: 0 }, }, usage: { input_tokens: 24, output_tokens: 0 }, }), diff --git a/apps/game-api/src/simulation-service.swarm.test.ts b/apps/game-api/src/simulation-service.swarm.test.ts index 167452f..0d728ba 100644 --- a/apps/game-api/src/simulation-service.swarm.test.ts +++ b/apps/game-api/src/simulation-service.swarm.test.ts @@ -37,7 +37,10 @@ class InspectingPlanner implements SwarmPlanner { readonly mode = 'scripted-swarm-test' as const; readonly configured = true; readonly observations: ZeroStrategicObservation[] = []; - constructor(private readonly failure: boolean | number = false) {} + constructor( + private readonly failure: boolean | number = false, + private readonly directiveLifetime = 5, + ) {} async plan( observation: ZeroStrategicObservation, _model: string, @@ -75,7 +78,7 @@ class InspectingPlanner implements SwarmPlanner { priority: 'normal', riskTolerance: 'low', issuedAtTick: observation.tickNumber, - expiresAtTick: observation.tickNumber + 1, + expiresAtTick: observation.tickNumber + this.directiveLifetime, })), }, metadata: { provider: 'scripted-test', model: 'test/zero', latencyMs: 0 }, @@ -237,20 +240,157 @@ describe('zero-swarm SimulationService tick', () => { ).toBe(true); }); - it('retains unexpired prior directives when a later Zero plan fails', async () => { + it('reuses unexpired directives for four ticks, then replans on the fifth tick', async () => { const simulation = setup( - new InspectingPlanner(2), + new InspectingPlanner(6), new ScriptedReflexProvider( - Array.from({ length: 14 }, () => ({ chosenCandidateId: 'action_0' })), + Array.from({ length: 42 }, () => ({ chosenCandidateId: 'action_0' })), ), ); await simulation.executeNextTick(); const first = simulation.getSnapshot().swarmTicks?.[0]; + for (let tick = 0; tick < 4; tick += 1) await simulation.executeNextTick(); + const reused = simulation.getSnapshot().swarmTicks?.slice(1); + expect( + reused?.every(({ planSource }) => planSource === 'directive-reuse'), + ).toBe(true); + expect(reused?.map(({ plan }) => plan.directives)).toEqual( + Array.from({ length: 4 }, () => first?.plan.directives), + ); + expect( + simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted, + ).toBe(36); + await simulation.executeNextTick(); + const sixth = simulation.getSnapshot().swarmTicks?.[5]; + expect(sixth?.planSource).toBe('deterministic-fallback'); + expect(sixth?.replanReasons).toContain('periodic-review'); + expect(sixth?.zeroAction).toEqual({ type: 'wait' }); + }); + + it('wakes Zero for a worker replan request and only admits worker attempts on reuse', async () => { + const planner = new InspectingPlanner(); + const simulation = setup( + planner, + new ScriptedReflexProvider( + Array.from({ length: 21 }, () => ({ + chosenCandidateId: 'action_0', + replanProbability: 0.8, + })), + ), + ); + await simulation.executeNextTick(); + expect( + simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted, + ).toBe(8); 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); + expect(second?.replanReasons).toContain('worker-request'); + expect(second?.planSource).toBe('zero-llm'); + expect( + simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted, + ).toBe(16); + expect(planner.observations).toHaveLength(2); + + const reuseSimulation = setup( + new InspectingPlanner(), + new ScriptedReflexProvider( + Array.from({ length: 14 }, () => ({ chosenCandidateId: 'action_0' })), + ), + ); + await reuseSimulation.executeNextTick(); + await reuseSimulation.executeNextTick(); + expect(reuseSimulation.getSnapshot().swarmTicks?.[1]?.planSource).toBe( + 'directive-reuse', + ); + expect( + reuseSimulation.getSnapshot().experiment.attemptAccounting + .attemptsStarted, + ).toBe(15); + }); + + it('wakes Zero when retained directives expire', async () => { + const planner = new InspectingPlanner(false, 1); + const simulation = setup( + planner, + new ScriptedReflexProvider( + Array.from({ length: 21 }, () => ({ chosenCandidateId: 'action_0' })), + ), + ); + await simulation.executeNextTick(); + await simulation.executeNextTick(); + await simulation.executeNextTick(); + const third = simulation.getSnapshot().swarmTicks?.[2]; + expect(third?.replanReasons).toContain('directive-expired'); + expect(third?.planSource).toBe('zero-llm'); + expect(planner.observations).toHaveLength(2); + }); + + it('releases reuse-tick reservations when a worker request is cancelled', async () => { + let calls = 0; + const reflex: ReflexProvider = { + mode: 'scripted-reflex-test', + model: 'test-reflex', + configured: true, + async decide(observation, options) { + calls += 1; + const finalize = options?.beginAttempt?.('initial'); + if (calls > 7) + return await new Promise((_, reject) => { + options?.signal?.addEventListener( + 'abort', + () => { + finalize?.({ + outcome: 'cancelled', + failure: { + code: 'cancelled', + message: 'cancelled', + retryable: false, + }, + }); + reject(new Error('cancelled')); + }, + { once: true }, + ); + }); + const choice = observation.candidates[0]!; + 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', + }); + finalize?.({ outcome: 'completed', reflexDecision: decision }); + return decision; + }, + }; + const simulation = setup(new InspectingPlanner(), reflex); + await simulation.executeNextTick(); + const execution = simulation.executeNextTick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + simulation.cancelCurrentRequest(); + await expect(execution).rejects.toBeInstanceOf( + SimulationTurnCancelledError, + ); + expect(simulation.getSnapshot().tickNumber).toBe(1); + expect( + simulation.getSnapshot().experiment.attemptAccounting.attemptsStarted, + ).toBe(9); + expect( + simulation.getSnapshot().experiment.attemptAccounting.attemptsInFlight, + ).toBe(0); + expect( + simulation.getSnapshot().experiment.attemptAccounting.reservedPermits, + ).toBe(0); }); it('exports simulated-player events under swarm tick selection', async () => { diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index e47058b..64d63c0 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -71,6 +71,7 @@ import { type AllianceProposalId, type SimulatedPlayerEvent, type SwarmPlan, + type SwarmReplanReason, type SwarmTickRecord, type ZeroStrategicObservation, type PatientZeroPressureContext, @@ -1499,14 +1500,6 @@ export class SimulationService { '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; @@ -1531,6 +1524,19 @@ export class SimulationService { { createEventId: this.#createEventId, now: () => virtualTime }, ); const candidate = playerAdvance.state; + const replanReasons = this.#swarmReplanReasons( + tickNumber, + playerAdvance.events, + ); + const replan = replanReasons.length > 0; + // Planning ticks reserve Zero plus workers; directive reuse only reserves workers. + if (!this.#attemptAccounting.reserve(agents.length - (replan ? 0 : 1))) { + 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 controller = new AbortController(); this.#busy = true; this.#activeRequestController = controller; @@ -1546,61 +1552,69 @@ export class SimulationService { tickNumber, virtualTime, playerAdvance.events, + replanReasons, ); let plan: SwarmPlan; - let planSource: 'zero-llm' | 'deterministic-fallback' = 'zero-llm'; + let planSource: + 'zero-llm' | 'deterministic-fallback' | 'directive-reuse' = '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 (!replan) { + planSource = 'directive-reuse'; + plan = this.#reusedSwarmPlan(candidate, zero.id); + } else { + 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); + 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 || @@ -1682,12 +1696,27 @@ export class SimulationService { applied.set(agentId, result.result); } if (controller.signal.aborted) throw new SimulationTurnCancelledError(); + const signals = workers.flatMap((worker) => { + const selection = selected.get(worker.id)!; + const probability = selection.decision?.replanProbability; + return probability !== undefined && probability >= 0.8 + ? [ + { + type: 'worker-replan-requested' as const, + agentId: worker.id, + directiveId: selection.observation.directive.id, + probability, + }, + ] + : []; + }); const tick = swarmTickRecordSchema.parse({ tickNumber, virtualTime, tickIntervalMinutes: interval, plan, planSource, + ...(replanReasons.length ? { replanReasons } : {}), ...(plannerFailure ? { plannerFailure } : {}), ...(plannerMetadata ? { plannerMetadata } : {}), zeroAction, @@ -1709,6 +1738,7 @@ export class SimulationService { ...(selection.failure ? { failure: selection.failure } : {}), }; }), + ...(signals.length ? { signals } : {}), }); const observationEvents = state.events.slice(preTickState.events.length); this.#state = { @@ -2474,6 +2504,7 @@ export class SimulationService { tickNumber: number, virtualTime: string, playerEvents: readonly SimulatedPlayerEvent[], + replanReasons: readonly SwarmReplanReason[] = [], ): ZeroStrategicObservation { const counts = new Map( [...state.agents.keys()].map((id) => [id, 0]), @@ -2508,6 +2539,7 @@ export class SimulationService { : 'Wait on the current cell.', }), ); + const workerReplanRequests = this.#swarmWorkerReplanRequests(); return { zeroAgentId, tickNumber, @@ -2549,11 +2581,107 @@ export class SimulationService { ? 'Cleaning pressure was blocked by an occupied infected cell.' : 'The simulated player moved this tick.', ), + ...(replanReasons.length ? { replanReasons: [...replanReasons] } : {}), + ...(workerReplanRequests.length ? { workerReplanRequests } : {}), legalZeroActions, strategicTargetCells, }; } + #swarmReplanReasons( + tickNumber: number, + playerEvents: readonly SimulatedPlayerEvent[], + ): SwarmReplanReason[] { + if (!this.#lastValidSwarmPlan) return ['initial']; + const reasons: SwarmReplanReason[] = []; + if ((tickNumber - 1) % 5 === 0) reasons.push('periodic-review'); + if ( + this.#lastValidSwarmPlan.directives.some( + ({ expiresAtTick }) => expiresAtTick < tickNumber, + ) + ) + reasons.push('directive-expired'); + if (this.#swarmWorkerReplanRequests().length) + reasons.push('worker-request'); + const recent = this.#swarmTicks.slice(-2); + if ( + recent.length === 2 && + [...this.#state.agents.keys()].some((agentId) => { + if (agentId === this.#scenario.patientZeroAgentId) return false; + const [previousTick, latestTick] = recent; + const previous = previousTick!.workers.find( + ({ agentId: id }) => id === agentId, + ); + const latest = latestTick!.workers.find( + ({ agentId: id }) => id === agentId, + ); + if ( + !previous || + !latest || + previous.directive.id !== latest.directive.id + ) + return false; + const stalled = (worker: NonNullable) => + worker.source === 'deterministic-fallback' || + worker.actionResult?.accepted === false || + (worker.action?.type === 'wait' && + worker.directive.mission !== 'hold'); + return stalled(previous) && stalled(latest); + }) + ) + reasons.push('worker-stalled'); + if ([...this.#lastSwarmTerritoryDeltas.values()].some((delta) => delta < 0)) + reasons.push('territory-loss'); + const disinfections = playerEvents.filter( + ({ type }) => type === 'hex-disinfected', + ).length; + if (disinfections) reasons.push('player-disinfection'); + const [previousTick, latestTick] = this.#swarmTicks.slice(-2); + if ( + latestTick?.workers.some((worker) => { + const previous = previousTick?.workers.find( + ({ agentId }) => agentId === worker.agentId, + ); + return ( + worker.situation?.nearbyPressure === 'high' && + previous?.situation?.nearbyPressure !== 'high' + ); + }) + ) + reasons.push('high-pressure'); + return reasons; + } + + #swarmWorkerReplanRequests(): Array<{ + agentId: AgentId; + directiveId: string; + probability: number; + }> { + return (this.#swarmTicks.at(-1)?.signals ?? []).map( + ({ agentId, directiveId, probability }) => ({ + agentId, + directiveId, + probability, + }), + ); + } + + #reusedSwarmPlan(state: WorldState, zeroAgentId: AgentId): SwarmPlan { + const retained = this.#lastValidSwarmPlan; + if (!retained) + throw new Error('Cannot reuse a swarm plan before a plan is committed.'); + 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: retained.strategySummary, + directives: retained.directives, + zeroActionCandidateId: `zero_action_${waitIndex}`, + }); + } + #assertSwarmPlan( plan: SwarmPlan, observation: ZeroStrategicObservation, @@ -2574,6 +2702,7 @@ export class SimulationService { (directive) => directive.issuedAtTick !== tickNumber || directive.expiresAtTick < tickNumber || + directive.expiresAtTick > tickNumber + 9 || (directive.targetCell !== null && !observation.strategicTargetCells.includes(directive.targetCell)), ) diff --git a/apps/world-lab/src/components/swarm-view.test.tsx b/apps/world-lab/src/components/swarm-view.test.tsx index 0bac5ec..fb76d8d 100644 --- a/apps/world-lab/src/components/swarm-view.test.tsx +++ b/apps/world-lab/src/components/swarm-view.test.tsx @@ -156,4 +156,41 @@ describe('swarm telemetry panels', () => { expect(screen.getByText('deterministic-fallback')).toBeInTheDocument(); expect(screen.getByText(/Reflex timed out/)).toBeInTheDocument(); }); + + it('distinguishes reused directives and shows a structured worker replan request', () => { + const value = snapshot(); + const tick = value.swarmTicks![0]!; + tick.planSource = 'directive-reuse'; + tick.replanReasons = []; + tick.workers[0]!.reflexDecision!.replanProbability = 0.91; + tick.signals = [ + { + type: 'worker-replan-requested', + agentId: value.world.agents[1]!.id, + directiveId: 'directive-1', + probability: 0.91, + }, + ]; + render( + <> + + + + , + ); + expect(screen.getByText('Active directives reused')).toBeInTheDocument(); + expect(screen.getByText(/91% · request sent to Zero/)).toBeInTheDocument(); + expect( + screen.getByText('Retained Zero plans / reused ticks'), + ).toBeInTheDocument(); + expect(screen.getByText('0 / 1')).toBeInTheDocument(); + expect( + screen.getByText('Retained worker replan requests'), + ).toBeInTheDocument(); + }); }); diff --git a/apps/world-lab/src/components/swarm-view.tsx b/apps/world-lab/src/components/swarm-view.tsx index ddb66ad..2644a9f 100644 --- a/apps/world-lab/src/components/swarm-view.tsx +++ b/apps/world-lab/src/components/swarm-view.tsx @@ -34,6 +34,14 @@ function agentName(snapshot: SimulationSnapshot, id: AgentId): string { return snapshot.world.agents.find((agent) => agent.id === id)?.name ?? id; } +function planSourceLabel(source: Tick['planSource']): string { + return source === 'zero-llm' + ? 'Zero provider plan' + : source === 'directive-reuse' + ? 'Active directives reused' + : 'Deterministic fallback'; +} + function EmptyTelemetry() { return

No committed swarm tick telemetry yet.

; } @@ -64,11 +72,11 @@ export function SwarmStrategyPanel({
Plan source
-
- {tick.planSource === 'zero-llm' - ? 'Zero provider plan' - : 'Deterministic fallback'} -
+
{planSourceLabel(tick.planSource)}
+
+
+
Review triggers
+
{tick.replanReasons?.join(', ') || 'None this tick'}
Planner failure
@@ -160,6 +168,9 @@ export function SwarmAgentInspector({ const tick = latestTick(snapshot); const isZero = agent.id === snapshot.scenario.patientZeroAgentId; const worker = tick?.workers.find((entry) => entry.agentId === agent.id); + const replanSignal = tick?.signals?.find( + (signal) => signal.agentId === agent.id, + ); const directive = worker?.directive ?? tick?.plan.directives.find((entry) => entry.agentId === agent.id); @@ -280,6 +291,17 @@ export function SwarmAgentInspector({ {Math.round(worker.reflexDecision.confidence * 100)}%
+ {worker.reflexDecision.replanProbability !== undefined && ( +
+
Replan probability
+
+ {Math.round( + worker.reflexDecision.replanProbability * 100, + )} + %{replanSignal ? ' · request sent to Zero' : ''} +
+
+ )} )}
@@ -338,11 +360,7 @@ export function SwarmActivityPanel({ ) : ( <>

{latest.plan.strategySummary}

-

- {latest.planSource === 'zero-llm' - ? 'Zero provider plan' - : 'Deterministic fallback plan'} -

+

{planSourceLabel(latest.planSource)}

Committed ticks

    {ticks @@ -352,11 +370,11 @@ export function SwarmActivityPanel({
  1. Tick {tick.tickNumber} - {tick.planSource === 'zero-llm' - ? 'Zero plan' - : 'Fallback plan'}{' '} - · {tick.workers.length} worker actions · Zero:{' '} - {resultLabel(tick.zeroActionResult)} + {planSourceLabel(tick.planSource)} · {tick.workers.length}{' '} + worker actions · Zero: {resultLabel(tick.zeroActionResult)} + {tick.signals?.length + ? ` · ${tick.signals.length} worker replan request${tick.signals.length === 1 ? '' : 's'}` + : ''}
  2. ))} @@ -401,6 +419,17 @@ export function SwarmRunPanel({ 0, ); const providers = snapshot.swarmProviderStatus; + const retainedTicks = snapshot.swarmTicks ?? []; + const zeroPlans = retainedTicks.filter( + ({ planSource }) => planSource === 'zero-llm', + ).length; + const reusedTicks = retainedTicks.filter( + ({ planSource }) => planSource === 'directive-reuse', + ).length; + const replanRequests = retainedTicks.reduce( + (count, entry) => count + (entry.signals?.length ?? 0), + 0, + ); return (

    Swarm run

    @@ -418,6 +447,16 @@ export function SwarmRunPanel({
    Committed swarm tick
    {tick?.tickNumber ?? 'None'}
    +
    +
    Retained Zero plans / reused ticks
    +
    + {zeroPlans} / {reusedTicks} +
    +
    +
    +
    Retained worker replan requests
    +
    {replanRequests}
    +
    Planner provider
    diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a4b7b19..eb16798 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,20 +5,21 @@ `cognitionMode` distinguishes `legacy-multi-agent` from `zero-swarm-v1` in 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 +into an uncommitted world candidate. Agent Zero plans on the first tick, every +five ticks, and when an expiry or material event requires review. Other ticks +reuse committed unexpired worker directives and compile a fresh legal Zero wait +action. 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. +fallbacks; provider attempts survive world rollback. See ADRs 0028, 0029, and 0031. 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 selects a dedicated swarm presentation from the scenario mode. It shows Zero strategy, worker directives and Jev decisions, -progress, and provider usage while omitting irrelevant legacy social cognition +progress, replanning signals, and provider usage while omitting irrelevant legacy social cognition controls. See ADR 0030. Provider-attempt and credit-exposure admission are owned by `SimulationService`. Its ledger is diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 1862f0c..02f4113 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -41,6 +41,11 @@ mapping. The key, raw 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. +The second Jev question returns only a bounded yes probability in the same +request as the action choice. Code applies the replan threshold and stores a +structured signal; it grants no action or mutation authority. Reused directives +are checked against expiry, and Zero's physical action comes from current legal +engine affordances rather than an earlier plan's candidate ID. In `zero-swarm-v1`, a separate OpenRouter planner receives bounded strategic facts, an engine-generated target allowlist, and opaque legal Zero action IDs. diff --git a/docs/TESTING.md b/docs/TESTING.md index 6f46240..f1bb18b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -13,6 +13,10 @@ PR C adds mode-specific World Lab component coverage for Zero strategy, worker directives and probabilities, provider usage, Zero-only model setup, and hiding of legacy social/personality views while retaining legacy UI coverage. Browser smoke still exercises the deterministic legacy baseline. +PR D covers the same-request Jev Choice and Noul response, deterministic +replan signals, periodic and event-triggered Zero calls, directive expiry, +current legal Zero actions on reuse ticks, provider-attempt reduction, +cancellation rollback, and mode-specific World Lab telemetry. Attempt-budget tests use deterministic providers and cover whole-roster tick admission, retry permits, cancellation finalization, and the distinction diff --git a/docs/adr/0031-persistent-swarm-directives.md b/docs/adr/0031-persistent-swarm-directives.md new file mode 100644 index 0000000..ec97f43 --- /dev/null +++ b/docs/adr/0031-persistent-swarm-directives.md @@ -0,0 +1,45 @@ +# ADR 0031: Persistent swarm directives and event-driven review + +## Status + +Accepted for PR D. + +## Decision + +In `zero-swarm-v1`, Agent Zero plans on the first tick and at a fixed five-tick +review interval. Between reviews, the service reuses the last committed, +unexpired worker directives. It compiles a fresh engine-legal wait action for +Zero each tick; the opaque action ID from an earlier plan is never reused. +Jev still chooses from current engine-legal worker candidates every tick. +New Zero directives expire within ten ticks of issue, while the normal planner +instruction asks them to cover at least one five-tick review interval. +The limit is applied when admitting new plans so historical exported directives +remain readable under the shared schema. + +The service wakes Zero before the interval when a directive expires, a worker +requests replanning, a non-hold worker repeatedly stalls, territory is lost, or +material simulated-player pressure changes. Trigger calculation uses frozen +authoritative facts and committed safe telemetry. Replan reasons are recorded +with the tick and included in Zero's next observation. A failed Zero call +retains unexpired directives and uses a safe Zero wait; without a valid plan, +neutral directives apply. All world changes still commit as one tick. + +The Jev HTTP request asks an independent Noul question alongside the existing +bounded Choice. It reports the probability that local conditions materially +undermine the directive. The service uses a fixed 0.8 threshold to turn that +probability into a `worker-replan-requested` structured signal on a committed +tick. Zero sees that signal in its next planning observation. This signal is +service telemetry, not a world-engine event or a worker message. + +Planning ticks reserve one provider attempt for Zero and one per worker. +Directive-reuse ticks reserve only worker attempts. Started attempts remain in +the independent ledger after cancellation or world rollback. Safe snapshots +and exports retain plan-source, trigger, signal, and probability telemetry so +experiments can measure generative-call reduction against actual outcomes. + +## Consequences + +Five ticks and 0.8 are version-one experimental policy values, not claims of +optimal gameplay. Comparison experiments must evaluate stalls, replan rate, +territory retention, provider attempts, and latency before changing them. +Legacy multi-agent execution remains unchanged. diff --git a/packages/agent-runtime/src/reflex-provider.ts b/packages/agent-runtime/src/reflex-provider.ts index 2607369..5485bfa 100644 --- a/packages/agent-runtime/src/reflex-provider.ts +++ b/packages/agent-runtime/src/reflex-provider.ts @@ -58,6 +58,7 @@ export interface ScriptedReflexChoice { chosenCandidateId: string; confidence?: number; probabilities?: Record; + replanProbability?: number; } /** Deterministic offline provider for reflex-path tests. */ @@ -115,6 +116,7 @@ export class ScriptedReflexProvider implements ReflexProvider { chosenCandidateId: choice.chosenCandidateId, confidence: choice.confidence ?? 1, probabilities, + replanProbability: choice.replanProbability ?? 0, model: this.model, latencyMs: 0, inputTokens: 0, diff --git a/packages/agent-runtime/src/swarm-planner.test.ts b/packages/agent-runtime/src/swarm-planner.test.ts index cd73075..12ca9cc 100644 --- a/packages/agent-runtime/src/swarm-planner.test.ts +++ b/packages/agent-runtime/src/swarm-planner.test.ts @@ -68,6 +68,16 @@ describe('swarm planners', () => { expect(result.metadata.provider).toBe('openrouter'); }); + it('rejects directives extending beyond the ten-tick lifetime', async () => { + const tooLong = swarmPlanSchema.parse({ + ...plan, + directives: [{ ...plan.directives[0]!, expiresAtTick: 11 }], + }); + await expect( + new ScriptedSwarmPlanner([tooLong]).plan(observation, 'test-model'), + ).rejects.toBeInstanceOf(SwarmPlannerError); + }); + it('rejects an OpenRouter plan with an invented zero candidate', async () => { const fetchImplementation = vi.fn().mockResolvedValue( new Response( diff --git a/packages/agent-runtime/src/swarm-planner.ts b/packages/agent-runtime/src/swarm-planner.ts index dfac43a..146b8da 100644 --- a/packages/agent-runtime/src/swarm-planner.ts +++ b/packages/agent-runtime/src/swarm-planner.ts @@ -370,7 +370,7 @@ function buildSwarmPlannerRequest( { 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.', + '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. Issue each directive at the current tick and set expiresAtTick between the current tick and current tick plus 9; normally cover at least five ticks so workers can operate between reviews. Use replanReasons and workerReplanRequests when present. zeroActionCandidateId must be one offered opaque candidate. Do not add fields.', }, { role: 'user', content: JSON.stringify(observation) }, ], @@ -397,6 +397,7 @@ function validPlan( workers.has(directive.agentId) && directive.issuedAtTick === observation.tickNumber && directive.expiresAtTick >= observation.tickNumber && + directive.expiresAtTick <= observation.tickNumber + 9 && (!directive.targetCell || targets.has(directive.targetCell)), ) ); diff --git a/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts b/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts index eb6c038..a2d1b72 100644 --- a/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts +++ b/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts @@ -47,6 +47,11 @@ function choiceResponse(choice = 'action_0', model = TYPESAFE_JEV_MODEL) { confidence: 0.7, future_answer_metadata: 'ignored', }, + request_replan: { + type: 'noul', + noul: 0.25, + future_answer_metadata: 'ignored', + }, }, usage: { input_tokens: 45, output_tokens: 8, future_usage_metadata: 1 }, }), @@ -72,6 +77,7 @@ describe('TypeSafeJevReflexProvider', () => { outputTokens: 8, directiveId: 'directive-1', cognitionSource: 'jev-reflex', + replanProbability: 0.25, }); expect(fetchImplementation).toHaveBeenCalledWith( TYPESAFE_SYSTEM_ONE_ENDPOINT, @@ -92,6 +98,16 @@ describe('TypeSafeJevReflexProvider', () => { action_0: 'Move into adjacent open territory.', action_1: 'Remain on the current infected cell.', }); + expect(request.questions.request_replan).toEqual({ + type: 'noul', + instructions: + 'Do currently observed local conditions materially undermine or prevent successful execution of the assigned directive?', + criteria: { + false: + 'Observed local conditions leave the assigned directive materially achievable with one of the currently legal actions.', + true: 'Observed local conditions materially undermine or prevent the assigned directive, such as when its target, route, required local state, or expected progress is unavailable or contradicted.', + }, + }); expect(JSON.stringify(request.state)).not.toContain('targetCell'); expect(JSON.stringify(request.state)).not.toContain('issuedAtTick'); expect(JSON.stringify(request.state)).not.toContain('expiresAtTick'); @@ -129,6 +145,59 @@ describe('TypeSafeJevReflexProvider', () => { }); }); + it('rejects a missing or malformed Noul answer without a second request', async () => { + const missingFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + model: TYPESAFE_JEV_MODEL, + answers: { + choose_action: { + type: 'choice', + choice: 'action_0', + probabilities: { action_0: 0.8, action_1: 0.2 }, + confidence: 0.7, + }, + }, + usage: { input_tokens: 45, output_tokens: 8 }, + }), + ), + ); + const missingNoul = new TypeSafeJevReflexProvider({ + apiKey: 'test-key', + fetchImplementation: missingFetch, + }); + await expect(missingNoul.decide(observation)).rejects.toMatchObject({ + failure: { code: 'unsupported-response' }, + }); + expect(missingFetch).toHaveBeenCalledTimes(1); + + const malformedFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + model: TYPESAFE_JEV_MODEL, + answers: { + choose_action: { + type: 'choice', + choice: 'action_0', + probabilities: { action_0: 0.8, action_1: 0.2 }, + confidence: 0.7, + }, + request_replan: { type: 'noul', noul: 2 }, + }, + usage: { input_tokens: 45, output_tokens: 8 }, + }), + ), + ); + const malformedNoul = new TypeSafeJevReflexProvider({ + apiKey: 'test-key', + fetchImplementation: malformedFetch, + }); + await expect(malformedNoul.decide(observation)).rejects.toMatchObject({ + failure: { code: 'unsupported-response' }, + }); + expect(malformedFetch).toHaveBeenCalledTimes(1); + }); + it.each([429, 529])('retries one %s response and no more', async (status) => { const fetchImplementation = vi .fn() @@ -222,12 +291,42 @@ describe('TypeSafeJevReflexProvider', () => { describe('ScriptedReflexProvider', () => { it('returns a deterministic opaque candidate selection', async () => { const provider = new ScriptedReflexProvider([ - { chosenCandidateId: 'action_1', confidence: 1 }, + { + chosenCandidateId: 'action_1', + confidence: 1, + replanProbability: 0.6, + }, ]); await expect(provider.decide(observation)).resolves.toMatchObject({ chosenCandidateId: 'action_1', probabilities: { action_0: 0, action_1: 1 }, cognitionSource: 'jev-reflex', + replanProbability: 0.6, + }); + }); + + it('defaults scripted replan probability to zero', async () => { + const provider = new ScriptedReflexProvider([ + { chosenCandidateId: 'action_1', confidence: 1 }, + ]); + await expect(provider.decide(observation)).resolves.toMatchObject({ + replanProbability: 0, }); }); + + it('does not include its API key in the System One request body', async () => { + const fetchImplementation = vi + .fn() + .mockResolvedValue(choiceResponse()); + const provider = new TypeSafeJevReflexProvider({ + apiKey: 'private-test-key', + fetchImplementation, + }); + + await provider.decide(observation); + + expect(String(fetchImplementation.mock.calls[0]?.[1]?.body)).not.toContain( + 'private-test-key', + ); + }); }); diff --git a/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts b/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts index 80c95e6..0d89b36 100644 --- a/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts +++ b/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts @@ -34,6 +34,10 @@ const typesafeResponseSchema = z.object({ ), confidence: z.number().finite().min(0).max(1), }), + request_replan: z.object({ + type: z.literal('noul'), + noul: z.number().finite().min(0).max(1), + }), }), usage: z.object({ input_tokens: z.number().int().nonnegative(), @@ -213,6 +217,7 @@ export class TypeSafeJevReflexProvider implements ReflexProvider { this.#metadata(Date.now() - startedAtMs, response.status), ); const answer = parsed.data.answers.choose_action; + const replanAnswer = parsed.data.answers.request_replan; if ( !candidateIds.has(answer.choice) || Object.keys(answer.probabilities).some( @@ -237,6 +242,7 @@ export class TypeSafeJevReflexProvider implements ReflexProvider { chosenCandidateId: answer.choice, confidence: answer.confidence, probabilities: answer.probabilities, + replanProbability: replanAnswer.noul, model: parsed.data.model, latencyMs: Date.now() - startedAtMs, inputTokens: parsed.data.usage.input_tokens, @@ -361,6 +367,16 @@ export function buildTypeSafeJevRequest(observationInput: ReflexObservation) { ]), ), }, + request_replan: { + type: 'noul' as const, + instructions: + 'Do currently observed local conditions materially undermine or prevent successful execution of the assigned directive?', + criteria: { + false: + 'Observed local conditions leave the assigned directive materially achievable with one of the currently legal actions.', + true: 'Observed local conditions materially undermine or prevent the assigned directive, such as when its target, route, required local state, or expected progress is unavailable or contradicted.', + }, + }, }, }; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3394864..13cee43 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -639,6 +639,28 @@ export const swarmPlanSchema = z }); export type SwarmPlan = z.infer; +export const swarmReplanReasonSchema = z.enum([ + 'initial', + 'periodic-review', + 'directive-expired', + 'worker-request', + 'worker-stalled', + 'territory-loss', + 'high-pressure', + 'player-disinfection', +]); +export type SwarmReplanReason = z.infer; + +export const swarmSignalSchema = z + .object({ + type: z.literal('worker-replan-requested'), + agentId: agentIdSchema, + directiveId: z.string().trim().min(1).max(80), + probability: z.number().finite().min(0).max(1), + }) + .strict(); +export type SwarmSignal = 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. @@ -677,6 +699,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(), + workerReplanRequests: z + .array(swarmSignalSchema.omit({ type: true })) + .max(WORLD_SCENARIO_LIMITS.maximumAgents) + .optional(), legalZeroActions: z.array(zeroActionCandidateSchema).min(1).max(9), strategicTargetCells: z.array(h3CellSchema).max(80), }) @@ -2257,6 +2284,8 @@ export const reflexDecisionSchema = z message: 'Reflex probability telemetry may include at most 9 candidates.', }), + /** Probability that the worker should request a new directive. */ + replanProbability: z.number().finite().min(0).max(1).optional(), model: modelIdSchema, latencyMs: z.number().finite().nonnegative(), inputTokens: z.number().int().nonnegative(), @@ -2730,7 +2759,16 @@ export const swarmTickRecordSchema = z virtualTime: z.iso.datetime(), tickIntervalMinutes: z.number().int().positive(), plan: swarmPlanSchema, - planSource: z.enum(['zero-llm', 'deterministic-fallback']), + planSource: z.enum([ + 'zero-llm', + 'deterministic-fallback', + 'directive-reuse', + ]), + replanReasons: z.array(swarmReplanReasonSchema).max(8).optional(), + signals: z + .array(swarmSignalSchema) + .max(WORLD_SCENARIO_LIMITS.maximumAgents) + .optional(), plannerFailure: providerFailureSchema.optional(), plannerMetadata: providerMetadataSchema.optional(), zeroAction: worldActionSchema.optional(), @@ -2754,6 +2792,23 @@ export const swarmTickRecordSchema = z path: ['plannerFailure'], message: 'A successful Zero plan cannot include a planner failure.', }); + const signals = record.signals ?? []; + if ( + new Set(signals.map(({ agentId }) => agentId)).size !== signals.length || + signals.some( + (signal) => + !record.workers.some( + (worker) => + worker.agentId === signal.agentId && + worker.directive.id === signal.directiveId, + ), + ) + ) + context.addIssue({ + code: 'custom', + path: ['signals'], + message: 'Each replan signal must name one current worker directive.', + }); }); export type SwarmTickRecord = z.infer; diff --git a/packages/shared/src/reflex.test.ts b/packages/shared/src/reflex.test.ts index e8de3ec..616fef6 100644 --- a/packages/shared/src/reflex.test.ts +++ b/packages/shared/src/reflex.test.ts @@ -84,4 +84,27 @@ describe('zero-swarm reflex contracts', () => { .success, ).toBe(false); }); + + it('accepts an optional bounded directive-replan probability', () => { + const decision = { + chosenCandidateId: 'action_0', + confidence: 0.8, + probabilities: { action_0: 0.8 }, + model: 'jev-1.13.0', + latencyMs: 12, + inputTokens: 6, + outputTokens: 1, + directiveId: directive.id, + cognitionSource: 'jev-reflex' as const, + }; + + expect( + reflexDecisionSchema.safeParse({ ...decision, replanProbability: 0.35 }) + .success, + ).toBe(true); + expect( + reflexDecisionSchema.safeParse({ ...decision, replanProbability: 1.01 }) + .success, + ).toBe(false); + }); });