From 700b8f98db3226750b4f40289edb678a6471333d Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Sat, 3 Oct 2026 08:33:12 -0400 Subject: [PATCH] fix: clarify expand directive fulfillment for reflex workers --- ROADMAP.md | 9 + apps/game-api/src/reflex-execution.test.ts | 249 ++++++++++++++++++ apps/game-api/src/reflex-execution.ts | 5 +- docs/ARCHITECTURE.md | 9 +- docs/SECURITY.md | 6 + docs/TESTING.md | 7 + .../src/typesafe-jev-reflex-provider.test.ts | 38 +++ .../src/typesafe-jev-reflex-provider.ts | 6 + 8 files changed, 326 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 733bb47..7dd64f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -65,6 +65,15 @@ Schema-v14 exports identify shared batch dispatches and preserve aggregate billing without per-worker allocations. No native production provider, Laya, training, planner-cadence changes, or player features are added. See ADR 0035. +The focused expansion-fulfillment clarification makes the existing completion +rule explicit in Jev's context and the assigned-target infection candidate: +the target must be infected and controlled by its worker; arrival and waiting +do not fulfill expansion. Legal choices, Zero's risk assignments, probability +validation, and replanning policy remain unchanged. The next owner-run live +comparison should stratify open-target infection/wait rates by risk tolerance +and also report captures and territory growth. One 25-tick run provides +observational evidence, not proof of causation. + ## Agent Zero planner Agent Zero is the sole generative planner. It makes an OpenRouter planning call diff --git a/apps/game-api/src/reflex-execution.test.ts b/apps/game-api/src/reflex-execution.test.ts index 5afd190..717ac02 100644 --- a/apps/game-api/src/reflex-execution.test.ts +++ b/apps/game-api/src/reflex-execution.test.ts @@ -15,12 +15,15 @@ import { import { applyWorldAction, createDevelopmentWorld, + enumerateLegalWorldActions, toWorldState, } from '@hexzero/world-engine'; +import { isSwarmDirectiveComplete } from './swarm-directives'; import { AttemptAccounting } from './attempt-accounting'; import { SimulationService } from './simulation-service'; import { chooseReflexWorldAction, + chooseReflexWorldActions, compileReflexObservation, } from './reflex-execution'; @@ -101,6 +104,252 @@ describe('zero-swarm reflex execution seam', () => { ); }); + it('preserves engine-legal candidates and directive priority/risk across missions', () => { + const { state, directive } = fixture(); + const missions = [ + 'expand', + 'hold', + 'relocate', + 'reinforce', + 'evade', + ] as const; + const observations = missions.map( + (mission) => + compileReflexObservation(state, { + ...directive, + mission, + targetCell: mission === 'hold' ? null : directive.targetCell, + priority: 'low', + riskTolerance: 'high', + }).observation, + ); + const expand = observations[0]!; + const expectedIds = enumerateLegalWorldActions( + state, + directive.agentId, + ).map((_, index) => `action_${index}`); + expect(expand.candidates.map(({ id }) => id)).toEqual(expectedIds); + expect(expand.directive).toMatchObject({ + mission: 'expand', + priority: 'low', + riskTolerance: 'high', + }); + for (const observation of observations.slice(1)) { + expect(observation.candidates.map(({ id }) => id)).toEqual(expectedIds); + expect(observation.directive).toMatchObject({ + priority: 'low', + riskTolerance: 'high', + }); + } + }); + + it('explains infection only when standing on the assigned expand target', () => { + const { state, agent, directive, targetCell } = fixture(); + const openCurrentState = { + ...state, + hexes: new Map(state.hexes).set(agent.currentCell, { + state: 'open' as const, + controllerAgentId: null, + }), + }; + const atTarget = compileReflexObservation(openCurrentState, { + ...directive, + mission: 'expand', + targetCell: agent.currentCell, + }); + const infectCandidate = atTarget.observation.candidates.find( + ({ id }) => atTarget.actions.get(id)?.type === 'infect', + ); + expect(infectCandidate?.description).toContain( + 'infecting it establishes this worker’s control and fulfills the directive', + ); + expect(infectCandidate?.description).toContain( + 'Arriving here or waiting does not fulfill it.', + ); + + const offTarget = compileReflexObservation(openCurrentState, { + ...directive, + mission: 'expand', + targetCell, + }); + const genericInfect = offTarget.observation.candidates.find( + ({ id }) => offTarget.actions.get(id)?.type === 'infect', + ); + expect(genericInfect?.description).toBe( + 'Infect the current open cell and establish local territory.', + ); + const sameCellOtherMission = compileReflexObservation(openCurrentState, { + ...directive, + mission: 'relocate', + targetCell: agent.currentCell, + }); + const otherMissionInfect = sameCellOtherMission.observation.candidates.find( + ({ id }) => sameCellOtherMission.actions.get(id)?.type === 'infect', + ); + expect(otherMissionInfect?.description).toBe( + 'Infect the current open cell and establish local territory.', + ); + }); + + it('accepts a legal wait for an open at-target expand directive with low risk and pressure', async () => { + const { state, agent, directive } = fixture(); + const openCurrentState = { + ...state, + hexes: new Map(state.hexes).set(agent.currentCell, { + state: 'open' as const, + controllerAgentId: null, + }), + }; + const lowDirective = { + ...directive, + mission: 'expand' as const, + targetCell: agent.currentCell, + priority: 'low' as const, + riskTolerance: 'low' as const, + }; + const compiled = compileReflexObservation(openCurrentState, lowDirective); + const engineChoices = enumerateLegalWorldActions( + openCurrentState, + directive.agentId, + ); + const legalIds = engineChoices.map((_, index) => `action_${index}`); + expect(compiled.observation.currentSituation.nearbyPressure).toBe('low'); + expect(compiled.observation.candidates.map(({ id }) => id)).toEqual( + legalIds, + ); + expect([...compiled.actions.values()]).toEqual(engineChoices); + expect(compiled.observation.currentSituation.directiveProgress).toBe( + 'at-target', + ); + expect( + compiled.observation.candidates.find( + ({ id }) => compiled.actions.get(id)?.type === 'infect', + )?.description, + ).toContain('fulfills the directive'); + const waitId = compiled.observation.candidates.find( + ({ id }) => compiled.actions.get(id)?.type === 'wait', + )!.id; + const probabilities = Object.fromEntries( + legalIds.map((id) => [id, id === waitId ? 1 : 0]), + ); + const fetchImplementation = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + model: 'jev-1.13.0', + answers: { + choose_action: { + type: 'choice', + choice: waitId, + probabilities, + confidence: 1, + }, + request_replan: { type: 'noul', noul: 0 }, + }, + usage: { input_tokens: 12, output_tokens: 0 }, + }), + ), + ); + const selected = await chooseReflexWorldAction( + openCurrentState, + lowDirective, + new TypeSafeJevReflexProvider({ + apiKey: 'test-only', + fetchImplementation, + }), + ); + expect(selected.cognitionSource).toBe('jev-reflex'); + expect(selected.action).toEqual({ type: 'wait' }); + const request = JSON.parse( + String(fetchImplementation.mock.calls[0]?.[1]?.body), + ); + expect(request.state.directive).toMatchObject({ + mission: 'expand', + priority: 'low', + riskTolerance: 'low', + fulfillmentCondition: expect.stringContaining( + 'Arriving at an open target or waiting there does not fulfill it.', + ), + }); + + const [batchedSelection] = await chooseReflexWorldActions( + [{ compiled, intendedTurnNumber: 1 }], + new ScriptedReflexProvider([{ chosenCandidateId: waitId }]), + ); + expect(batchedSelection?.cognitionSource).toBe('jev-reflex'); + expect(batchedSelection?.action).toEqual({ type: 'wait' }); + + const scripted = new ScriptedReflexProvider([ + { chosenCandidateId: waitId }, + ]); + const nativeProvider: ReflexProvider = { + ...scripted, + decide: scripted.decide.bind(scripted), + decideBatch: async (observations) => + Promise.all( + observations.map(async (observation) => ({ + agentId: observation.agentId, + status: 'completed' as const, + decision: await scripted.decide(observation), + })), + ), + }; + const [nativeSelection] = await chooseReflexWorldActions( + [{ compiled, intendedTurnNumber: 1 }], + nativeProvider, + ); + expect(nativeSelection?.decision?.chosenCandidateId).toBe(waitId); + expect(nativeSelection?.action).toEqual({ type: 'wait' }); + }); + + it('completes expand only after infection gives this worker control', () => { + const { state, agent, directive } = fixture(); + const targetCell = [...state.hexes.entries()].find( + ([cell, hex]) => + hex.state === 'open' && + cell !== agent.currentCell && + gridDisk(agent.currentCell, 1).includes(cell), + )![0]; + const expandDirective = { + ...directive, + mission: 'expand' as const, + targetCell, + }; + const moved = applyWorldAction(state, agent.id, { + type: 'move', + targetCell, + }); + expect(moved.result.accepted).toBe(true); + expect(isSwarmDirectiveComplete(moved.state, expandDirective)).toBe(false); + expect( + compileReflexObservation(moved.state, expandDirective).observation + .currentSituation.directiveProgress, + ).toBe('at-target'); + const waited = applyWorldAction(moved.state, agent.id, { type: 'wait' }); + expect(waited.result.accepted).toBe(true); + expect(isSwarmDirectiveComplete(waited.state, expandDirective)).toBe(false); + + const infected = applyWorldAction(waited.state, agent.id, { + type: 'infect', + }); + expect(infected.result.accepted).toBe(true); + expect(isSwarmDirectiveComplete(infected.state, expandDirective)).toBe( + true, + ); + const otherAgentId = [...state.agents.keys()].find( + (candidateId) => candidateId !== agent.id, + )!; + const wrongControllerHexes = new Map(infected.state.hexes).set(targetCell, { + state: 'infected' as const, + controllerAgentId: otherAgentId, + }); + expect( + isSwarmDirectiveComplete( + { ...infected.state, hexes: wrongControllerHexes }, + expandDirective, + ), + ).toBe(false); + }); + it('retains only the four most recent authoritative capture alerts', () => { const { state, directive } = fixture(); const capturedAgentId = [...state.agents.keys()][0]!; diff --git a/apps/game-api/src/reflex-execution.ts b/apps/game-api/src/reflex-execution.ts index 1b4fa40..66bf70b 100644 --- a/apps/game-api/src/reflex-execution.ts +++ b/apps/game-api/src/reflex-execution.ts @@ -79,7 +79,10 @@ function actionDescription( if (action.type === 'wait') return 'Remain on the current cell for this tick.'; if (action.type === 'infect') - return 'Infect the current open cell and establish local territory.'; + return directive.mission === 'expand' && + directive.targetCell === agent.currentCell + ? 'Infect the current open cell and establish local territory. This is the assigned expand target; infecting it establishes this worker’s control and fulfills the directive. Arriving here or waiting does not fulfill it.' + : 'Infect the current open cell and establish local territory.'; if (action.type === 'capture') return 'Capture the abandoned infected current cell from another controller.'; const status = cellStatus(state, action.targetCell, directive.agentId); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 50f9fb9..1fdeaeb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -33,8 +33,13 @@ 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 +An `expand` directive completes only after its target is infected and controlled +by that worker; arriving on an open target or waiting there is not completion. +Jev's expand context states this fulfillment condition and identifies +`at-target` as positional. The infection candidate at the assigned open target +also explains that infection establishes worker control and fulfills expansion. +All engine-legal choices remain available, and a valid wait remains acceptable. +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 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 5d628b2..3cc76c6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -99,6 +99,12 @@ observation data. Raw planner text is not forwarded. Workers do not receive other workers' choices. Cancellation discards every result from the uncommitted tick. +Expand fulfillment wording is fixed server-authored Jev context. The +assigned-target infection cue is compiled from the validated directive and +engine-authoritative worker position. These cues explain the existing objective; +provider choices still pass the same probability validation and engine action +validation before world mutation. + When strategic replanning is required, the OpenRouter planner receives one bounded strategic observation (a coarse `worldSummary` plus per-worker semantic `workerOptions`, with no raw H3 cell IDs or agent IDs) and is instructed to return exactly one plain JSON object naming opaque worker and option choices plus a Zero-action selection. TypeSafe Jev receives a compact semantic observation with opaque legal candidate IDs per worker and returns a probability distribution over candidates; a second question in the same request returns an optional bounded replan probability. The runtime performs bounded extraction and conservative repair for wrappers such as code fences, surrounding prose, and trailing commas, then rejects missing text, unusable JSON, unknown fields, or output truncation before the deterministic world engine validates all resolved components independently. The request uses the selected model, messages, `max_tokens`, `stream: false`, and at most one normalized reasoning object selected from sanitized model metadata. Provider default omits the object. Off is offered only for non-mandatory reasoning and sends `{ enabled: false, exclude: true }`; an advertised effort sends `{ enabled: true, effort, exclude: true }`. It deliberately sends no tools, `tool_choice`, `response_format`, `provider.require_parameters`, standalone `reasoning_effort`, or model-specific parameter. Model IDs are never inspected or special-cased. Transport/provider failures, unavailable-model/profile failures, text/JSON contract failures, and later simulation-rule rejection remain distinct safe outcomes. The adapter never silently substitutes a model or scripted behavior. diff --git a/docs/TESTING.md b/docs/TESTING.md index ccccbd8..68338d1 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -86,6 +86,13 @@ rejection. ### Game API (`apps/game-api`) +Expansion clarification tests check Jev's fulfillment context across risk +tolerances, the assigned-open-target infection description, and unchanged legal +candidate IDs/actions. Engine-backed assertions distinguish arrival and waiting +from successful worker-controlled infection. Valid wait decisions remain +selectable through the individual and batch seams. Other missions and off-target +infection retain their existing wording and completion behavior. + Batch reflex tests use offline controlled providers and deferred promises to assert the individual-call concurrency cap, stable native-batch attribution, reordered/duplicate/extra/missing/malformed result policy, failure isolation, 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 dd58ba9..b7fa14e 100644 --- a/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts +++ b/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts @@ -167,6 +167,44 @@ describe('TypeSafeJevReflexProvider', () => { ); }); + it('adds bounded fulfillment context only for expand directives', () => { + const fulfillmentCondition = + 'An expand directive is fulfilled when its target is infected and controlled by this worker. Arriving at an open target or waiting there does not fulfill it. The at-target progress label describes position, not fulfillment.'; + for (const riskTolerance of ['low', 'medium', 'high'] as const) { + for (const mission of [ + 'hold', + 'relocate', + 'reinforce', + 'evade', + ] as const) { + const request = buildTypeSafeJevRequest({ + ...observation, + directive: { + ...observation.directive, + mission, + riskTolerance, + }, + }); + expect(request.state.directive).toEqual({ + mission, + priority: observation.directive.priority, + riskTolerance, + }); + } + + const expandRequest = buildTypeSafeJevRequest({ + ...observation, + directive: { ...observation.directive, riskTolerance }, + }); + expect(expandRequest.state.directive).toEqual({ + mission: 'expand', + priority: observation.directive.priority, + riskTolerance, + fulfillmentCondition, + }); + } + }); + it('rejects malformed responses and selections outside its candidates', async () => { const malformed = new TypeSafeJevReflexProvider({ apiKey: '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 e1ba7c7..30707b1 100644 --- a/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts +++ b/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts @@ -357,6 +357,12 @@ export function buildTypeSafeJevRequest(observationInput: ReflexObservation) { mission: observation.directive.mission, priority: observation.directive.priority, riskTolerance: observation.directive.riskTolerance, + ...(observation.directive.mission === 'expand' + ? { + fulfillmentCondition: + 'An expand directive is fulfilled when its target is infected and controlled by this worker. Arriving at an open target or waiting there does not fulfill it. The at-target progress label describes position, not fulfillment.', + } + : {}), }, currentSituation: observation.currentSituation, ...(recentCaptures?.length