diff --git a/.env.example b/.env.example index 2c52104..f8a2de6 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # Server-only OpenRouter configuration. Never use NEXT_PUBLIC_ for this key. OPENROUTER_API_KEY=replace-with-your-openrouter-key +# Server-only Jev key for zero-swarm experiments. Never use NEXT_PUBLIC_. +TYPESAFE_API_KEY=replace-with-your-typesafe-key + # The scripted provider is exclusively for deterministic automation. Normal # interactive development must leave this unset so every turn uses OpenRouter. # HEXZERO_PROVIDER=scripted diff --git a/apps/game-api/src/attempt-accounting.ts b/apps/game-api/src/attempt-accounting.ts index 76349f3..55faeff 100644 --- a/apps/game-api/src/attempt-accounting.ts +++ b/apps/game-api/src/attempt-accounting.ts @@ -6,6 +6,7 @@ import { type ProviderAttemptRecord, type ProviderFailure, type ProviderMetadata, + type ReflexDecision, type ReasoningProfile, } from '@hexzero/shared'; @@ -48,6 +49,7 @@ export interface AttemptCompletion { completedAt: string; provider?: ProviderMetadata; failure?: ProviderFailure; + reflexDecision?: ReflexDecision; } interface Decimal { diff --git a/apps/game-api/src/reflex-execution.test.ts b/apps/game-api/src/reflex-execution.test.ts new file mode 100644 index 0000000..6a87c4f --- /dev/null +++ b/apps/game-api/src/reflex-execution.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from 'vitest'; +import { gridDisk } from 'h3-js'; +import { + BrowserTestAgentProvider, + ScriptedReflexProvider, + TypeSafeJevReflexProvider, + type ReflexProvider, +} from '@hexzero/agent-runtime'; +import { type SwarmDirective } from '@hexzero/shared'; +import { + applyWorldAction, + createDevelopmentWorld, + toWorldState, +} from '@hexzero/world-engine'; +import { AttemptAccounting } from './attempt-accounting'; +import { SimulationService } from './simulation-service'; +import { + chooseReflexWorldAction, + compileReflexObservation, +} from './reflex-execution'; + +function fixture() { + const state = toWorldState(createDevelopmentWorld()); + const agent = [...state.agents.values()][1]!; + const targetCell = gridDisk(agent.currentCell, 1).find( + (cell) => + cell !== agent.currentCell && + state.hexes.has(cell as typeof agent.currentCell), + ) as typeof agent.currentCell; + const directive: SwarmDirective = { + id: 'directive-1', + agentId: agent.id, + mission: 'relocate', + targetCell, + priority: 'normal', + riskTolerance: 'medium', + issuedAtTick: 0, + expiresAtTick: 3, + }; + return { state, agent, directive, targetCell }; +} + +describe('zero-swarm reflex execution seam', () => { + it('identifies zero-swarm scenarios without running the legacy tick executor', async () => { + const service = new SimulationService({ + provider: new BrowserTestAgentProvider(), + }); + const setup = service.getDefaultWorldSetup(); + const snapshot = service.applyWorldSetup({ + ...setup, + cognitionMode: 'zero-swarm-v1', + }); + expect(snapshot.scenario.cognitionMode).toBe('zero-swarm-v1'); + await expect(service.executeNextTick()).rejects.toThrow( + 'Zero-swarm ticks require the Agent Zero planner', + ); + expect(service.getSnapshot().tickNumber).toBe(0); + }); + + it('takes a directive through legal candidate choice and the real engine', async () => { + const { state, agent, directive, targetCell } = fixture(); + const compiled = compileReflexObservation(state, directive); + expect(compiled.observation).not.toHaveProperty('messages'); + expect(compiled.observation).not.toHaveProperty('behavior'); + expect(compiled.observation).not.toHaveProperty('memories'); + expect( + compiled.observation.candidates.every(({ id }) => + /^action_\d+$/.test(id), + ), + ).toBe(true); + const chosenCandidateId = compiled.observation.candidates.find(({ id }) => { + const action = compiled.actions.get(id); + return action?.type === 'move' && action.targetCell === targetCell; + })!.id; + const provider = new ScriptedReflexProvider([{ chosenCandidateId }]); + const selected = await chooseReflexWorldAction(state, directive, provider); + expect(selected.cognitionSource).toBe('jev-reflex'); + expect(selected.action).toEqual({ type: 'move', targetCell }); + const applied = applyWorldAction(state, agent.id, selected.action); + expect(applied.result.accepted).toBe(true); + expect(applied.state.agents.get(agent.id)?.currentCell).toBe(targetCell); + }); + + it('retains a completed provider attempt even when no world state is committed', async () => { + const { state, directive } = fixture(); + const accounting = new AttemptAccounting(10); + const compiled = compileReflexObservation(state, directive); + const chosenCandidateId = compiled.observation.candidates.find( + ({ description }) => description.startsWith('Remain'), + )!.id; + const provider = new ScriptedReflexProvider([{ chosenCandidateId }]); + const selected = await chooseReflexWorldAction(state, directive, provider, { + accounting, + intendedTickNumber: 1, + intendedTurnNumber: 1, + now: () => '2026-08-13T12:00:00.000Z', + }); + expect(selected.action).toEqual({ type: 'wait' }); + expect(accounting.ledger()).toMatchObject([ + { + outcome: 'completed', + provider: { provider: 'scripted-test', promptTokens: 0 }, + reflexDecision: { + directiveId: directive.id, + chosenCandidateId: selected.decision?.chosenCandidateId, + probabilities: selected.decision?.probabilities, + }, + }, + ]); + expect(state.agents.get(directive.agentId)).toBeDefined(); + }); + + it('falls back to wait and records a malformed worker response', async () => { + const { state, directive } = fixture(); + const accounting = new AttemptAccounting(10); + const provider = new ScriptedReflexProvider([ + { chosenCandidateId: 'action_999' }, + ]); + const selected = await chooseReflexWorldAction(state, directive, provider, { + accounting, + now: () => '2026-08-13T12:00:00.000Z', + }); + expect(selected.action).toEqual({ type: 'wait' }); + expect(selected.cognitionSource).toBe('deterministic-fallback'); + expect(accounting.ledger()[0]?.outcome).toBe('provider-error'); + }); + + it('rejects fabricated cognition attribution and partial probability telemetry', async () => { + const { state, directive } = fixture(); + const compiled = compileReflexObservation(state, directive); + const selectedId = compiled.observation.candidates[0]!.id; + const scripted = new ScriptedReflexProvider([ + { chosenCandidateId: selectedId }, + ]); + const valid = await scripted.decide(compiled.observation); + const provider: ReflexProvider = { + mode: 'scripted-reflex-test', + model: 'test', + configured: true, + decide: async () => ({ + ...valid, + cognitionSource: 'zero-llm', + probabilities: { [selectedId]: 1 }, + }), + }; + const selected = await chooseReflexWorldAction(state, directive, provider); + expect(selected.action).toEqual({ type: 'wait' }); + expect(selected.cognitionSource).toBe('deterministic-fallback'); + expect(selected.failure?.code).toBe('unsupported-response'); + }); + + it('records both TypeSafe HTTP attempts when a transient retry succeeds', async () => { + const { state, directive } = fixture(); + const accounting = new AttemptAccounting(10); + const compiled = compileReflexObservation(state, directive); + const ids = compiled.observation.candidates.map(({ id }) => id); + const chosenCandidateId = ids.at(-1)!; + const probabilities = Object.fromEntries( + ids.map((id) => [id, id === chosenCandidateId ? 1 : 0]), + ); + const fetchImplementation = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 429 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + model: 'jev-1.13.0', + answers: { + choose_action: { + type: 'choice', + choice: chosenCandidateId, + probabilities, + confidence: 1, + }, + }, + usage: { input_tokens: 24, output_tokens: 0 }, + }), + ), + ); + const provider = new TypeSafeJevReflexProvider({ + apiKey: 'test-only', + fetchImplementation, + }); + const selected = await chooseReflexWorldAction(state, directive, provider, { + accounting, + now: () => '2026-08-13T12:00:00.000Z', + }); + expect(selected.cognitionSource).toBe('jev-reflex'); + expect( + accounting.ledger().map(({ kind, outcome }) => ({ kind, outcome })), + ).toEqual([ + { kind: 'initial', outcome: 'provider-error' }, + { kind: 'automatic-transport-retry', outcome: 'completed' }, + ]); + expect(accounting.ledger()[1]?.reflexDecision?.probabilities).toEqual( + probabilities, + ); + }); +}); diff --git a/apps/game-api/src/reflex-execution.ts b/apps/game-api/src/reflex-execution.ts new file mode 100644 index 0000000..3cc09d0 --- /dev/null +++ b/apps/game-api/src/reflex-execution.ts @@ -0,0 +1,337 @@ +import { gridDistance } from 'h3-js'; +import { + ReflexProviderError, + type ReflexProvider, +} from '@hexzero/agent-runtime'; +import { + h3CellSchema, + reflexDecisionSchema, + reflexObservationSchema, + swarmDirectiveSchema, + type H3Cell, + type ProviderFailure, + type ProviderMetadata, + type ReflexDecision, + type ReflexObservation, + type SwarmDirective, + type WorldAction, +} from '@hexzero/shared'; +import { + enumerateLegalWorldActions, + type WorldState, +} from '@hexzero/world-engine'; +import { AttemptAccounting } from './attempt-accounting'; + +export interface ReflexLocalHistory { + previousCell?: H3Cell; + recentCleanedCells?: readonly H3Cell[]; + territoryDelta?: number; + recentActionOutcome?: 'success' | 'rejected' | 'unknown'; +} + +export interface CompiledReflexObservation { + observation: ReflexObservation; + /** Authoritative mapping stays inside the server. */ + actions: ReadonlyMap; +} + +function distance(from: H3Cell, to: H3Cell): number | null { + try { + return gridDistance(from, to); + } catch { + return null; + } +} + +function cellStatus( + state: WorldState, + cell: H3Cell, + agentId: SwarmDirective['agentId'], +) { + const hex = state.hexes.get(cell); + if (!hex || hex.state === 'open') return 'open' as const; + return hex.controllerAgentId === agentId + ? ('friendly-infected' as const) + : ('other-infected' as const); +} + +function actionDescription( + state: WorldState, + directive: SwarmDirective, + action: WorldAction, +): string { + const agent = state.agents.get(directive.agentId)!; + 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.'; + if (action.type === 'capture') + return 'Capture the abandoned infected current cell from a non-allied controller.'; + const status = cellStatus(state, action.targetCell, directive.agentId); + const terrain = + status === 'open' + ? 'open territory' + : status === 'friendly-infected' + ? 'friendly infected territory' + : 'infected territory controlled by another agent'; + const currentDistance = directive.targetCell + ? distance(agent.currentCell, directive.targetCell) + : null; + const nextDistance = directive.targetCell + ? distance(action.targetCell, directive.targetCell) + : null; + const progress = + currentDistance === null || nextDistance === null + ? 'No target-distance comparison is available.' + : nextDistance < currentDistance + ? 'This advances toward the assigned target.' + : nextDistance > currentDistance + ? 'This moves away from the assigned target.' + : 'This maintains the current target distance.'; + return `Move into adjacent ${terrain}. ${progress}`; +} + +/** Compile only engine-legal actions from one frozen world state. */ +export function compileReflexObservation( + state: WorldState, + directiveInput: SwarmDirective, + history: ReflexLocalHistory = {}, +): CompiledReflexObservation { + const directive = swarmDirectiveSchema.parse(directiveInput); + const agent = state.agents.get(directive.agentId); + if (!agent) throw new Error('The assigned worker does not exist.'); + if (directive.targetCell && !state.hexes.has(directive.targetCell)) + throw new Error('The directive target is outside the current world.'); + const actions = new Map(); + const candidates = enumerateLegalWorldActions(state, directive.agentId).map( + (action, index) => { + const id = `action_${index}`; + actions.set(id, action); + return { id, description: actionDescription(state, directive, action) }; + }, + ); + const current = agent.currentCell; + const currentDistance = directive.targetCell + ? distance(current, directive.targetCell) + : null; + const previousDistance = + history.previousCell && directive.targetCell + ? distance(h3CellSchema.parse(history.previousCell), directive.targetCell) + : null; + const hasForwardMove = + currentDistance !== null && + [...actions.values()].some( + (action) => + action.type === 'move' && + (distance(action.targetCell, directive.targetCell!) ?? Infinity) < + currentDistance, + ); + const directiveProgress = + previousDistance !== null && + currentDistance !== null && + currentDistance < previousDistance + ? ('advancing' as const) + : directive.targetCell && currentDistance !== 0 && !hasForwardMove + ? ('blocked' as const) + : ('stalled' as const); + const nearbyCleaned = (history.recentCleanedCells ?? []) + .slice(-6) + .map((cell) => distance(current, h3CellSchema.parse(cell))) + .filter((value): value is number => value !== null); + const nearbyPressure = nearbyCleaned.some((value) => value <= 1) + ? ('high' as const) + : nearbyCleaned.some((value) => value <= 2) + ? ('rising' as const) + : ('low' as const); + const territoryTrend = + (history.territoryDelta ?? 0) > 0 + ? ('growing' as const) + : (history.territoryDelta ?? 0) < 0 + ? ('shrinking' as const) + : ('stable' as const); + const observation = reflexObservationSchema.parse({ + agentId: directive.agentId, + directive, + currentSituation: { + cellStatus: cellStatus(state, current, directive.agentId), + directiveProgress, + nearbyPressure, + recentTerritoryTrend: territoryTrend, + recentActionOutcome: history.recentActionOutcome ?? 'unknown', + }, + relevantRecentFacts: [], + candidates, + }); + return { observation, actions }; +} + +export interface ReflexSelection { + action: WorldAction; + observation: ReflexObservation; + decision: ReflexDecision | null; + cognitionSource: 'jev-reflex' | 'deterministic-fallback'; + failure?: ProviderFailure; +} + +export class ReflexSelectionCancelledError extends Error { + constructor() { + super('The reflex selection was cancelled.'); + this.name = 'ReflexSelectionCancelledError'; + } +} + +export interface ReflexSelectionOptions { + history?: ReflexLocalHistory; + signal?: AbortSignal; + deadlineAtMs?: number; + accounting?: AttemptAccounting; + intendedTickNumber?: number; + intendedTurnNumber?: number; + now?: () => string; +} + +function safeFailure( + error: unknown, + model: string, +): { + failure: ProviderFailure; + metadata?: ProviderMetadata; +} { + if (error instanceof ReflexProviderError) + return { failure: error.failure, metadata: error.metadata }; + return { + failure: { + code: 'provider-http', + message: 'The reflex provider failed.', + retryable: false, + model, + }, + }; +} + +/** A failed Jev request selects wait; the independent attempt ledger still records it. */ +export async function chooseReflexWorldAction( + state: WorldState, + directive: SwarmDirective, + provider: ReflexProvider, + options: ReflexSelectionOptions = {}, +): Promise { + const compiled = compileReflexObservation(state, directive, options.history); + const fallback = (failure?: ProviderFailure): ReflexSelection => ({ + action: { type: 'wait' }, + observation: compiled.observation, + decision: null, + cognitionSource: 'deterministic-fallback', + ...(failure ? { failure } : {}), + }); + if (options.signal?.aborted) throw new ReflexSelectionCancelledError(); + const model = provider.model ?? 'jev-1.13.0'; + const now = options.now ?? (() => new Date().toISOString()); + let startedAttempts = 0; + try { + const rawDecision = await provider.decide(compiled.observation, { + signal: options.signal, + deadlineAtMs: options.deadlineAtMs, + beginAttempt: options.accounting + ? (kind) => { + const permit = options.accounting!.startAdditional({ + agentId: compiled.observation.agentId, + intendedTurnNumber: options.intendedTurnNumber ?? 1, + ...(options.intendedTickNumber + ? { intendedTickNumber: options.intendedTickNumber } + : {}), + kind, + startedAt: now(), + modelId: model, + reasoningProfile: 'provider-default', + }); + if (permit === null) return null; + startedAttempts += 1; + return (completion) => + options.accounting!.finalize(permit, { + ...completion, + completedAt: now(), + }); + } + : undefined, + }); + if (options.accounting && startedAttempts === 0) + throw new ReflexProviderError({ + code: 'unsupported-response', + message: 'The reflex provider skipped attempt accounting.', + retryable: false, + model, + }); + const parsed = reflexDecisionSchema.safeParse(rawDecision); + if (!parsed.success) + throw new ReflexProviderError({ + code: 'malformed-response', + message: 'The reflex decision failed schema validation.', + retryable: false, + model, + }); + const decision = parsed.data; + if (options.signal?.aborted) throw new ReflexSelectionCancelledError(); + if (decision.directiveId !== compiled.observation.directive.id) + throw new ReflexProviderError({ + code: 'unsupported-response', + message: 'The reflex decision references another directive.', + retryable: false, + model, + }); + if (decision.cognitionSource !== 'jev-reflex') + throw new ReflexProviderError({ + code: 'unsupported-response', + message: 'The reflex decision has incorrect cognition attribution.', + retryable: false, + model, + }); + const action = compiled.actions.get(decision.chosenCandidateId); + if (!action) + throw new ReflexProviderError({ + code: 'unsupported-response', + message: 'The reflex decision selected an unknown candidate.', + retryable: false, + model, + }); + const probabilityIds = Object.keys(decision.probabilities); + const probabilityTotal = Object.values(decision.probabilities).reduce( + (total, value) => total + value, + 0, + ); + if ( + probabilityIds.length !== compiled.actions.size || + probabilityIds.some((id) => !compiled.actions.has(id)) || + Math.abs(probabilityTotal - 1) > 0.001 || + Object.values(decision.probabilities).some( + (value) => value > decision.probabilities[decision.chosenCandidateId]!, + ) + ) + throw new ReflexProviderError({ + code: 'unsupported-response', + message: 'The reflex decision has inconsistent probability telemetry.', + retryable: false, + model, + }); + return { + action, + observation: compiled.observation, + decision, + cognitionSource: 'jev-reflex', + }; + } catch (error) { + const cancelled = + error instanceof ReflexSelectionCancelledError || options.signal?.aborted; + const safe = safeFailure(error, model); + const failure: ProviderFailure = cancelled + ? { + code: 'cancelled', + message: 'The reflex request was cancelled.', + retryable: false, + model, + } + : safe.failure; + if (cancelled) throw new ReflexSelectionCancelledError(); + return fallback(failure); + } +} diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 75237d3..7e464bd 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -1033,6 +1033,10 @@ export class SimulationService { } async executeNextTurn(): Promise { + if (this.#scenario.cognitionMode === 'zero-swarm-v1') + throw new SimulationConflictError( + 'Zero-swarm ticks require the Agent Zero planner, which is not enabled in this PR.', + ); if (this.#completedTickCount > 0) throw new SimulationConflictError( 'Legacy sequential turns cannot run after a simultaneous tick.', @@ -1046,6 +1050,10 @@ export class SimulationService { /** Execute one atomic simultaneous tick for every active agent. */ async executeNextTick(): Promise { + if (this.#scenario.cognitionMode === 'zero-swarm-v1') + throw new SimulationConflictError( + 'Zero-swarm ticks require the Agent Zero planner, which is not enabled in this PR.', + ); if (this.#busy || this.#verificationBusy) throw new SimulationConflictError( 'A simulation tick is already in progress.', diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index 2f48e80..0af6311 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -1899,6 +1899,7 @@ function WorldSetupPanel({ const scenario = snapshot.scenario; return structuredClone({ scenarioVersion: scenario.scenarioVersion, + cognitionMode: scenario.cognitionMode, locationLabel: scenario.locationLabel, center: scenario.center, resolution: scenario.resolution, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 389840b..46c0eef 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,15 @@ # Architecture +## Zero-swarm PR A seam + +`cognitionMode` distinguishes `legacy-multi-agent` from `zero-swarm-v1` in +scenarios, snapshots, and exports. The legacy tick remains the active +production executor. Until the Agent Zero planner is connected in PR B, a +zero-swarm production tick is rejected explicitly. The separate reflex seam +compiles engine-legal physical actions from a frozen world, describes them as +opaque candidates, asks Jev to select one, and maps the chosen ID back to a +world action for normal engine validation. See ADR 0028. + Provider-attempt and credit-exposure admission are owned by `SimulationService`. Its ledger is deliberately separate from deterministic world state and committed-turn metrics: tick rollback cannot erase provider work that may already be billed. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0d80927..fef430e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -34,6 +34,14 @@ their nested rollups. ## Secrets and deployment +`TYPESAFE_API_KEY` is server-only for the zero-swarm reflex provider. Jev sees +compact semantic state and opaque candidate IDs. The outbound state omits H3 +cell IDs and tick numbers; the server retains directive targets and the action +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. + `OPENROUTER_API_KEY` is the only required OpenRouter environment value and is read only by the Game API process. It never enters catalog DTOs, assignments, exports, fixtures, browser responses, errors, or logs. The repository-root `.env` is ignored; `.env.example` contains only a placeholder. The development API has no authentication, rate limiting, or provider-account balance enforcement. It does enforce server-owned per-experiment provider-attempt and conservative credit-admission ceilings configured by World Setup. Credit reservations bound admission exposure but cannot guarantee the upstream bill, especially when reported cost exceeds the operator's reservation. It binds to loopback and its CORS allowlist is limited to the documented local World Lab origins. Do not deploy its cost-incurring tick endpoint to unauthenticated public traffic. diff --git a/docs/TESTING.md b/docs/TESTING.md index ecce9ae..62a5a8f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,5 +1,11 @@ # Testing +Zero-swarm PR A tests are offline by default. They cover deterministic legal +candidate compilation, a scripted reflex choice through `applyWorldAction`, +malformed and unknown TypeSafe choices, cancellation and bounded transient +retry, and provider attempts retained when a candidate world change is not +committed. `pnpm validate` and `pnpm test:e2e` are the complete local gates. + Attempt-budget tests use deterministic providers and cover whole-roster tick admission, retry permits, cancellation finalization, and the distinction between known zero cost and missing/unknown cost. Provider catalog probes are diff --git a/docs/adr/0028-zero-swarm-reflex-seam.md b/docs/adr/0028-zero-swarm-reflex-seam.md new file mode 100644 index 0000000..d293e5e --- /dev/null +++ b/docs/adr/0028-zero-swarm-reflex-seam.md @@ -0,0 +1,42 @@ +# ADR 0028: Zero-swarm reflex seam + +## Status + +Accepted for PR A. Two-stage production tick orchestration remains PR B. + +## Decision + +Scenarios carry `cognitionMode`, with `legacy-multi-agent` as the compatibility +default and `zero-swarm-v1` as the explicit experimental mode. Historical +scenario, snapshot, and export data without the field parse as legacy. The +current production tick keeps its legacy behavior; selecting zero-swarm for a +production tick fails explicitly until the Agent Zero planner exists. + +The world engine exposes a pure enumeration of currently legal physical +actions. The Game API turns these into bounded, semantic descriptions with +opaque IDs, retains the ID-to-action map on the server, and projects only a +compact local observation. A `ReflexProvider` chooses one ID. The engine still +validates and resolves the mapped physical action. Directives convey intent +only and give neither Zero nor Jev mutation authority. The reflex path carries +no chat, diplomacy, personality, goal, or memory fields. + +The TypeSafe adapter uses direct HTTP and pins `jev-1.13.0`. Its outbound +projection omits H3 IDs and tick numbers. It validates the response and rejects +IDs outside the offered candidate set. Calls obey an +absolute deadline and at most one retry for 429 or 529 within that deadline. +Worker request failure produces a deterministic wait decision. Operator +cancellation remains cancellation rather than an ordinary worker failure. + +Each HTTP attempt, including one retry after 429 or 529, enters the server-owned +accounting ledger before dispatch and finalizes independently of world commit. +Safe bounded choice probabilities, +confidence, token counts, model, directive attribution, and cognition source +may be retained. TypeSafe usage does not provide an authoritative monetary +cost, so its attempt records leave actual cost unset. No request body, raw +response, credential, or private reasoning enters snapshots or exports. + +## Consequences + +The isolated reflex path can be tested through the real engine without +changing legacy tick behavior. PR B will connect the planner and all workers +to one atomic two-stage tick, including its cancellation and rollback rules. diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 5805f78..4ff6e8d 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -24,7 +24,9 @@ import { export { applyProviderEnvironmentFile } from './provider-environment'; export * from './model-catalog'; +export * from './reflex-provider'; export * from './tick-dispatcher'; +export * from './typesafe-jev-reflex-provider'; export const OPENROUTER_ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions'; diff --git a/packages/agent-runtime/src/reflex-provider.ts b/packages/agent-runtime/src/reflex-provider.ts new file mode 100644 index 0000000..2607369 --- /dev/null +++ b/packages/agent-runtime/src/reflex-provider.ts @@ -0,0 +1,159 @@ +import { + reflexDecisionSchema, + reflexObservationSchema, + type ProviderFailure, + type ProviderMetadata, + type ReflexDecision, + type ReflexObservation, +} from '@hexzero/shared'; + +export type { ReflexDecision } from '@hexzero/shared'; + +/** + * A bounded local-decision provider. Unlike AgentProvider, this provider can + * select only a service-compiled opaque candidate identifier. + */ +export interface ReflexProvider { + readonly mode: 'typesafe-jev' | 'scripted-reflex-test'; + readonly model?: string; + readonly configured: boolean; + decide( + observation: ReflexObservation, + options?: ReflexDecisionOptions, + ): Promise; +} + +export interface ReflexDecisionOptions { + signal?: AbortSignal; + deadlineAtMs?: number; + beginAttempt?: ReflexAttemptStarter; +} + +export interface ReflexAttemptCompletion { + outcome: 'completed' | 'provider-error' | 'cancelled' | 'timeout'; + provider?: ProviderMetadata; + failure?: ProviderFailure; + reflexDecision?: ReflexDecision; +} + +export type ReflexAttemptFinalizer = ( + completion: ReflexAttemptCompletion, +) => void; + +export type ReflexAttemptStarter = ( + kind: 'initial' | 'automatic-transport-retry', +) => ReflexAttemptFinalizer | null; + +export class ReflexProviderError extends Error { + constructor( + readonly failure: ProviderFailure, + readonly metadata?: ProviderMetadata, + ) { + super(failure.message); + this.name = 'ReflexProviderError'; + } +} + +export interface ScriptedReflexChoice { + chosenCandidateId: string; + confidence?: number; + probabilities?: Record; +} + +/** Deterministic offline provider for reflex-path tests. */ +export class ScriptedReflexProvider implements ReflexProvider { + readonly mode = 'scripted-reflex-test' as const; + readonly model = 'deterministic-reflex-script'; + readonly configured = true; + readonly #choices: ScriptedReflexChoice[]; + #cursor = 0; + + constructor(choices: ScriptedReflexChoice[]) { + if (choices.length === 0) + throw new Error('ScriptedReflexProvider requires at least one choice.'); + this.#choices = choices.map((choice) => ({ ...choice })); + } + + async decide( + observationInput: ReflexObservation, + options: ReflexDecisionOptions = {}, + ): Promise { + const observation = reflexObservationSchema.parse(observationInput); + const finalize = options.beginAttempt?.('initial'); + if (finalize === null) + throw new ReflexProviderError({ + code: 'budget-exhausted', + message: 'The reflex provider attempt budget is exhausted.', + retryable: false, + }); + try { + const choice = this.#choices[this.#cursor]; + if (!choice) + throw new ReflexProviderError({ + code: 'unsupported-response', + message: 'The deterministic reflex script has no choice remaining.', + retryable: false, + }); + this.#cursor += 1; + const candidateIds = observation.candidates.map(({ id }) => id); + if (!candidateIds.includes(choice.chosenCandidateId)) + throw new ReflexProviderError({ + code: 'invalid-decision', + message: + 'The deterministic reflex script selected an unknown candidate.', + retryable: false, + }); + const probabilities = + choice.probabilities ?? + Object.fromEntries( + candidateIds.map((id) => [ + id, + id === choice.chosenCandidateId ? 1 : 0, + ]), + ); + const decision = reflexDecisionSchema.parse({ + chosenCandidateId: choice.chosenCandidateId, + confidence: choice.confidence ?? 1, + probabilities, + model: this.model, + latencyMs: 0, + inputTokens: 0, + outputTokens: 0, + directiveId: observation.directive.id, + cognitionSource: 'jev-reflex', + }); + finalize?.({ + outcome: 'completed', + provider: scriptedMetadata(this.model), + reflexDecision: decision, + }); + return decision; + } catch (error) { + const providerError = + error instanceof ReflexProviderError + ? error + : new ReflexProviderError({ + code: 'unsupported-response', + message: 'The deterministic reflex script returned invalid data.', + retryable: false, + }); + finalize?.({ + outcome: 'provider-error', + provider: providerError.metadata ?? scriptedMetadata(this.model), + failure: providerError.failure, + }); + throw providerError; + } + } +} + +function scriptedMetadata(model: string): ProviderMetadata { + return { + provider: 'scripted-test', + model, + latencyMs: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + }; +} diff --git a/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts b/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts new file mode 100644 index 0000000..eb6c038 --- /dev/null +++ b/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, vi } from 'vitest'; +import { reflexObservationSchema } from '@hexzero/shared'; +import { + ScriptedReflexProvider, + TypeSafeJevReflexProvider, + TYPESAFE_JEV_MODEL, + TYPESAFE_SYSTEM_ONE_ENDPOINT, + buildTypeSafeJevRequest, +} from '.'; + +const observation = reflexObservationSchema.parse({ + agentId: '128f3f38-6b7d-4db7-9e95-751b4ce2681e', + directive: { + id: 'directive-1', + agentId: '128f3f38-6b7d-4db7-9e95-751b4ce2681e', + mission: 'expand', + targetCell: null, + priority: 'normal', + riskTolerance: 'medium', + issuedAtTick: 1, + expiresAtTick: 2, + }, + currentSituation: { + cellStatus: 'friendly-infected', + directiveProgress: 'advancing', + nearbyPressure: 'low', + recentTerritoryTrend: 'growing', + recentActionOutcome: 'success', + }, + relevantRecentFacts: ['An adjacent open cell is available.'], + candidates: [ + { id: 'action_0', description: 'Move into adjacent open territory.' }, + { id: 'action_1', description: 'Remain on the current infected cell.' }, + ], +}); + +function choiceResponse(choice = 'action_0', model = TYPESAFE_JEV_MODEL) { + return new Response( + JSON.stringify({ + model, + future_top_level_metadata: 'ignored', + answers: { + choose_action: { + type: 'choice', + choice, + probabilities: { action_0: 0.8, action_1: 0.2 }, + confidence: 0.7, + future_answer_metadata: 'ignored', + }, + }, + usage: { input_tokens: 45, output_tokens: 8, future_usage_metadata: 1 }, + }), + ); +} + +describe('TypeSafeJevReflexProvider', () => { + it('sends the pinned model and opaque candidate choice criteria', async () => { + const fetchImplementation = vi + .fn() + .mockResolvedValue(choiceResponse()); + const provider = new TypeSafeJevReflexProvider({ + apiKey: 'test-key', + fetchImplementation, + }); + + const decision = await provider.decide(observation); + + expect(decision).toMatchObject({ + chosenCandidateId: 'action_0', + model: TYPESAFE_JEV_MODEL, + inputTokens: 45, + outputTokens: 8, + directiveId: 'directive-1', + cognitionSource: 'jev-reflex', + }); + expect(fetchImplementation).toHaveBeenCalledWith( + TYPESAFE_SYSTEM_ONE_ENDPOINT, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer test-key', + 'Content-Type': 'application/json', + }), + }), + ); + const request = JSON.parse( + String(fetchImplementation.mock.calls[0]?.[1]?.body), + ); + expect(request).toEqual(buildTypeSafeJevRequest(observation)); + expect(request.model).toBe(TYPESAFE_JEV_MODEL); + expect(request.questions.choose_action.criteria).toEqual({ + action_0: 'Move into adjacent open territory.', + action_1: 'Remain on the current infected cell.', + }); + expect(JSON.stringify(request.state)).not.toContain('targetCell'); + expect(JSON.stringify(request.state)).not.toContain('issuedAtTick'); + expect(JSON.stringify(request.state)).not.toContain('expiresAtTick'); + }); + + it('rejects malformed responses and selections outside its candidates', async () => { + const malformed = new TypeSafeJevReflexProvider({ + apiKey: 'test-key', + fetchImplementation: vi + .fn() + .mockResolvedValue(new Response('{')), + }); + await expect(malformed.decide(observation)).rejects.toMatchObject({ + failure: { code: 'malformed-response' }, + }); + + const unknown = new TypeSafeJevReflexProvider({ + apiKey: 'test-key', + fetchImplementation: vi + .fn() + .mockResolvedValue(choiceResponse('action_99')), + }); + await expect(unknown.decide(observation)).rejects.toMatchObject({ + failure: { code: 'invalid-decision' }, + }); + + const wrongModel = new TypeSafeJevReflexProvider({ + apiKey: 'test-key', + fetchImplementation: vi + .fn() + .mockResolvedValue(choiceResponse('action_0', 'jev-latest')), + }); + await expect(wrongModel.decide(observation)).rejects.toMatchObject({ + failure: { code: 'unsupported-response' }, + }); + }); + + it.each([429, 529])('retries one %s response and no more', async (status) => { + const fetchImplementation = vi + .fn() + .mockResolvedValueOnce(new Response('', { status })) + .mockResolvedValueOnce(choiceResponse()); + const provider = new TypeSafeJevReflexProvider({ + apiKey: 'test-key', + fetchImplementation, + }); + await expect(provider.decide(observation)).resolves.toMatchObject({ + chosenCandidateId: 'action_0', + }); + expect(fetchImplementation).toHaveBeenCalledTimes(2); + }); + + it('accounts for every dispatched HTTP attempt independently', async () => { + const fetchImplementation = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 429 })) + .mockResolvedValueOnce(choiceResponse()); + const completions: Array<{ kind: string; outcome?: string }> = []; + const provider = new TypeSafeJevReflexProvider({ + apiKey: 'test-key', + fetchImplementation, + }); + await provider.decide(observation, { + beginAttempt: (kind) => (completion) => { + completions.push({ kind, outcome: completion.outcome }); + }, + }); + expect(completions).toEqual([ + { kind: 'initial', outcome: 'provider-error' }, + { kind: 'automatic-transport-retry', outcome: 'completed' }, + ]); + }); + + it('does not call TypeSafe after its operation deadline has elapsed', async () => { + const fetchImplementation = vi.fn(); + const provider = new TypeSafeJevReflexProvider({ + apiKey: 'test-key', + fetchImplementation, + }); + await expect( + provider.decide(observation, { deadlineAtMs: Date.now() - 1 }), + ).rejects.toMatchObject({ failure: { code: 'timeout' } }); + expect(fetchImplementation).not.toHaveBeenCalled(); + }); + + it('stops before dispatch when attempt accounting denies the budget', async () => { + const fetchImplementation = vi.fn(); + const provider = new TypeSafeJevReflexProvider({ + apiKey: 'test-key', + fetchImplementation, + }); + await expect( + provider.decide(observation, { beginAttempt: () => null }), + ).rejects.toMatchObject({ failure: { code: 'budget-exhausted' } }); + expect(fetchImplementation).not.toHaveBeenCalled(); + }); + + it('rejects invalid timeout configuration', () => { + expect(() => new TypeSafeJevReflexProvider({ timeoutMs: 0 })).toThrow( + RangeError, + ); + }); + + it('propagates cancellation to the transport', async () => { + const controller = new AbortController(); + const fetchImplementation = vi.fn().mockImplementation( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(new DOMException('aborted', 'AbortError')), + { once: true }, + ); + }), + ); + const provider = new TypeSafeJevReflexProvider({ + apiKey: 'test-key', + fetchImplementation, + }); + const pending = provider.decide(observation, { signal: controller.signal }); + controller.abort(); + await expect(pending).rejects.toMatchObject({ + failure: { code: 'cancelled' }, + }); + }); +}); + +describe('ScriptedReflexProvider', () => { + it('returns a deterministic opaque candidate selection', async () => { + const provider = new ScriptedReflexProvider([ + { chosenCandidateId: 'action_1', confidence: 1 }, + ]); + await expect(provider.decide(observation)).resolves.toMatchObject({ + chosenCandidateId: 'action_1', + probabilities: { action_0: 0, action_1: 1 }, + cognitionSource: 'jev-reflex', + }); + }); +}); diff --git a/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts b/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts new file mode 100644 index 0000000..80c95e6 --- /dev/null +++ b/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts @@ -0,0 +1,411 @@ +import { z } from 'zod'; +import { + reflexDecisionSchema, + reflexObservationSchema, + type ProviderFailure, + type ProviderMetadata, + type ReflexDecision, + type ReflexObservation, +} from '@hexzero/shared'; +import { + ReflexProviderError, + type ReflexAttemptCompletion, + type ReflexDecisionOptions, + type ReflexProvider, +} from './reflex-provider'; + +export const TYPESAFE_SYSTEM_ONE_ENDPOINT = + 'https://api.typesafe.ai/v1/systemone'; +export const TYPESAFE_JEV_MODEL = 'jev-1.13.0'; + +const DEFAULT_TIMEOUT_MS = 15_000; +const RETRY_DELAY_MS = 250; +const MAX_TIMEOUT_MS = 300_000; + +const typesafeResponseSchema = z.object({ + model: z.literal(TYPESAFE_JEV_MODEL), + answers: z.object({ + choose_action: z.object({ + type: z.literal('choice'), + choice: z.string().trim().min(1).max(32), + probabilities: z.record( + z.string().min(1).max(32), + z.number().finite().min(0).max(1), + ), + confidence: z.number().finite().min(0).max(1), + }), + }), + usage: z.object({ + input_tokens: z.number().int().nonnegative(), + output_tokens: z.number().int().nonnegative(), + }), +}); + +export interface TypeSafeJevReflexProviderOptions { + apiKey?: string; + timeoutMs?: number; + fetchImplementation?: typeof fetch; +} + +export class TypeSafeJevReflexProvider implements ReflexProvider { + readonly mode = 'typesafe-jev' as const; + readonly model = TYPESAFE_JEV_MODEL; + readonly configured: boolean; + readonly #apiKey?: string; + readonly #timeoutMs: number; + readonly #fetch: typeof fetch; + + constructor({ + apiKey, + timeoutMs = DEFAULT_TIMEOUT_MS, + fetchImplementation = fetch, + }: TypeSafeJevReflexProviderOptions = {}) { + if ( + !Number.isFinite(timeoutMs) || + timeoutMs <= 0 || + timeoutMs > MAX_TIMEOUT_MS + ) + throw new RangeError( + `TypeSafe Jev timeoutMs must be between 1 and ${MAX_TIMEOUT_MS}.`, + ); + this.#apiKey = + (apiKey ?? process.env.TYPESAFE_API_KEY)?.trim() || undefined; + this.#timeoutMs = timeoutMs; + this.#fetch = fetchImplementation; + this.configured = Boolean(this.#apiKey); + } + + async decide( + observationInput: ReflexObservation, + options: ReflexDecisionOptions = {}, + ): Promise { + const startedAtMs = Date.now(); + if (options.signal?.aborted) + throw this.#failure('cancelled', false, startedAtMs); + if (!this.#apiKey) + throw new ReflexProviderError({ + code: 'configuration', + message: + 'TypeSafe Jev is unavailable. Set TYPESAFE_API_KEY on the Game API server.', + retryable: false, + }); + const observation = reflexObservationSchema.parse(observationInput); + const candidateIds = new Set(observation.candidates.map(({ id }) => id)); + const request = buildTypeSafeJevRequest(observation); + const requestBody = JSON.stringify(request); + const deadlineAtMs = Math.min( + options.deadlineAtMs ?? Number.POSITIVE_INFINITY, + startedAtMs + this.#timeoutMs, + ); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const remainingMs = deadlineAtMs - Date.now(); + if (remainingMs <= 0) throw this.#failure('timeout', true, startedAtMs); + const finalize = options.beginAttempt?.( + attempt === 0 ? 'initial' : 'automatic-transport-retry', + ); + if (finalize === null) + throw new ReflexProviderError({ + code: 'budget-exhausted', + message: 'The reflex provider attempt budget is exhausted.', + retryable: false, + }); + let finalized = false; + const complete = (completion: ReflexAttemptCompletion) => { + if (finalized) return; + finalized = true; + finalize?.(completion); + }; + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, remainingMs); + const cancel = () => controller.abort(); + options.signal?.addEventListener('abort', cancel, { once: true }); + try { + let response: Response; + try { + response = await this.#fetch(TYPESAFE_SYSTEM_ONE_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.#apiKey}`, + 'Content-Type': 'application/json', + }, + body: requestBody, + signal: controller.signal, + }); + } catch { + if (timedOut || options.signal?.aborted) + throw this.#failure( + timedOut ? 'timeout' : 'cancelled', + false, + startedAtMs, + ); + throw this.#failure('network', true, startedAtMs); + } + if (options.signal?.aborted) + throw this.#failure('cancelled', false, startedAtMs); + if ( + (response.status === 429 || response.status === 529) && + attempt === 0 + ) { + const failure: ProviderFailure = { + code: 'provider-http', + message: `TypeSafe Jev returned HTTP ${response.status}.`, + retryable: true, + httpStatus: response.status, + model: this.model, + latencyMs: Date.now() - startedAtMs, + }; + complete({ + outcome: 'provider-error', + provider: this.#metadata(Date.now() - startedAtMs, response.status), + failure, + }); + const delayMs = Math.min( + retryDelay(response.headers.get('retry-after')), + Math.max(0, deadlineAtMs - Date.now()), + ); + if (delayMs > 0) await abortableDelay(delayMs, options.signal); + continue; + } + if (!response.ok) + throw new ReflexProviderError( + { + code: 'provider-http', + message: `TypeSafe Jev returned HTTP ${response.status}.`, + retryable: response.status === 429 || response.status === 529, + httpStatus: response.status, + model: this.model, + latencyMs: Date.now() - startedAtMs, + }, + this.#metadata(Date.now() - startedAtMs, response.status), + ); + let raw: unknown; + try { + raw = await response.json(); + } catch { + throw new ReflexProviderError( + { + code: 'malformed-response', + message: 'TypeSafe Jev returned malformed JSON.', + retryable: true, + httpStatus: response.status, + model: this.model, + latencyMs: Date.now() - startedAtMs, + }, + this.#metadata(Date.now() - startedAtMs, response.status), + ); + } + const parsed = typesafeResponseSchema.safeParse(raw); + if (!parsed.success) + throw new ReflexProviderError( + { + code: 'unsupported-response', + message: 'TypeSafe Jev returned an unsupported response shape.', + retryable: true, + httpStatus: response.status, + model: this.model, + latencyMs: Date.now() - startedAtMs, + }, + this.#metadata(Date.now() - startedAtMs, response.status), + ); + const answer = parsed.data.answers.choose_action; + if ( + !candidateIds.has(answer.choice) || + Object.keys(answer.probabilities).some( + (id) => !candidateIds.has(id), + ) || + candidateIds.size !== Object.keys(answer.probabilities).length || + !approximatelyOne(Object.values(answer.probabilities)) + ) + throw new ReflexProviderError( + { + code: 'invalid-decision', + message: + 'TypeSafe Jev selected a candidate outside the supplied choices.', + retryable: false, + httpStatus: response.status, + model: this.model, + latencyMs: Date.now() - startedAtMs, + }, + this.#metadata(Date.now() - startedAtMs, response.status), + ); + const decision = reflexDecisionSchema.parse({ + chosenCandidateId: answer.choice, + confidence: answer.confidence, + probabilities: answer.probabilities, + model: parsed.data.model, + latencyMs: Date.now() - startedAtMs, + inputTokens: parsed.data.usage.input_tokens, + outputTokens: parsed.data.usage.output_tokens, + directiveId: observation.directive.id, + cognitionSource: 'jev-reflex', + }); + complete({ + outcome: 'completed', + provider: this.#metadata( + Date.now() - startedAtMs, + response.status, + parsed.data.usage.input_tokens, + parsed.data.usage.output_tokens, + ), + reflexDecision: decision, + }); + return decision; + } catch (error) { + const providerError = + error instanceof ReflexProviderError + ? error + : new ReflexProviderError( + { + code: 'unsupported-response', + message: 'TypeSafe Jev returned invalid decision telemetry.', + retryable: true, + model: this.model, + latencyMs: Date.now() - startedAtMs, + }, + this.#metadata(Date.now() - startedAtMs), + ); + const outcome = + providerError.failure.code === 'cancelled' + ? 'cancelled' + : providerError.failure.code === 'timeout' + ? 'timeout' + : 'provider-error'; + complete({ + outcome, + provider: providerError.metadata, + failure: providerError.failure, + }); + throw providerError; + } finally { + clearTimeout(timeout); + options.signal?.removeEventListener('abort', cancel); + } + } + throw this.#failure('provider-http', true, startedAtMs); + } + + #metadata( + latencyMs: number, + httpStatus?: number, + promptTokens?: number, + completionTokens?: number, + ): ProviderMetadata { + return { + provider: 'typesafe', + model: this.model, + selectedModel: this.model, + resolvedModel: this.model, + latencyMs, + ...(httpStatus === undefined ? {} : { httpStatus }), + ...(promptTokens === undefined ? {} : { promptTokens }), + ...(completionTokens === undefined ? {} : { completionTokens }), + ...(promptTokens === undefined || completionTokens === undefined + ? {} + : { totalTokens: promptTokens + completionTokens }), + }; + } + + #failure( + code: ProviderFailure['code'], + retryable: boolean, + startedAtMs: number, + ): ReflexProviderError { + const message = + code === 'cancelled' + ? 'The TypeSafe Jev request was cancelled.' + : code === 'timeout' + ? 'The TypeSafe Jev request timed out.' + : 'The TypeSafe Jev request could not be completed.'; + return new ReflexProviderError( + { + code, + message, + retryable, + model: this.model, + latencyMs: Date.now() - startedAtMs, + }, + this.#metadata(Date.now() - startedAtMs), + ); + } +} + +export function buildTypeSafeJevRequest(observationInput: ReflexObservation) { + const observation = reflexObservationSchema.parse(observationInput); + return { + state: { + agentId: observation.agentId, + directive: { + mission: observation.directive.mission, + priority: observation.directive.priority, + riskTolerance: observation.directive.riskTolerance, + }, + currentSituation: observation.currentSituation, + relevantRecentFacts: observation.relevantRecentFacts, + candidateIds: observation.candidates.map(({ id }) => id), + }, + model: TYPESAFE_JEV_MODEL, + questions: { + choose_action: { + type: 'choice' as const, + instructions: + "Which currently legal action best advances this worker's assigned directive given its local observed conditions?", + criteria: Object.fromEntries( + observation.candidates.map(({ id, description }) => [ + id, + description, + ]), + ), + }, + }, + }; +} + +function approximatelyOne(probabilities: number[]): boolean { + return ( + Math.abs(probabilities.reduce((total, value) => total + value, 0) - 1) < + 0.001 + ); +} + +function retryDelay(retryAfter: string | null): number { + const seconds = retryAfter ? Number(retryAfter) : Number.NaN; + return Number.isFinite(seconds) && seconds >= 0 + ? Math.min(seconds * 1_000, 5_000) + : RETRY_DELAY_MS; +} + +function abortableDelay(delayMs: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject( + new ReflexProviderError({ + code: 'cancelled', + message: 'The TypeSafe Jev request was cancelled.', + retryable: false, + }), + ); + return; + } + const timeout = setTimeout(done, delayMs); + function done() { + signal?.removeEventListener('abort', aborted); + resolve(); + } + function aborted() { + clearTimeout(timeout); + reject( + new ReflexProviderError({ + code: 'cancelled', + message: 'The TypeSafe Jev request was cancelled.', + retryable: false, + }), + ); + } + signal?.addEventListener('abort', aborted, { once: true }); + }); +} diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 8424060..3209e60 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -1606,6 +1606,32 @@ describe('personality mutation contracts', () => { }, }).success, ).toBe(true); + const reflexDecision = { + chosenCandidateId: 'action_0', + confidence: 0.9, + probabilities: { action_0: 0.9, action_1: 0.1 }, + model: 'jev-1.13.0', + latencyMs: 12, + inputTokens: 24, + outputTokens: 1, + directiveId: 'directive-1', + cognitionSource: 'jev-reflex' as const, + }; + expect( + providerAttemptRecordSchema.safeParse({ + ...base, + outcome: 'completed', + completedAt: '2026-08-13T12:00:01.000Z', + reflexDecision, + }).success, + ).toBe(true); + expect( + providerAttemptRecordSchema.safeParse({ + ...base, + outcome: 'in-flight', + reflexDecision, + }).success, + ).toBe(false); expect( providerAttemptRecordSchema.safeParse({ ...base, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fb21f9e..7e4e045 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -73,6 +73,13 @@ export const ALLIANCE_COLOR_PALETTE = [ export const agentIdSchema = z.uuid().brand<'AgentId'>(); export type AgentId = z.infer; +/** Selects the decision architecture used to execute an experiment. */ +export const cognitionModeSchema = z.enum([ + 'legacy-multi-agent', + 'zero-swarm-v1', +]); +export type CognitionMode = z.infer; + export const eventIdSchema = z.uuid().brand<'EventId'>(); export type EventId = z.infer; @@ -522,6 +529,80 @@ export type WorldAction = z.infer; export const agentTurnActionSchema = worldActionSchema; export type AgentTurnAction = WorldAction; +export const swarmDirectiveSchema = z + .object({ + id: z.string().trim().min(1).max(80), + agentId: agentIdSchema, + mission: z.enum(['expand', 'hold', 'relocate', 'reinforce', 'evade']), + targetCell: h3CellSchema.nullable(), + priority: z.enum(['low', 'normal', 'high']), + riskTolerance: z.enum(['low', 'medium', 'high']), + issuedAtTick: z.number().int().nonnegative(), + expiresAtTick: z.number().int().nonnegative(), + note: z.string().trim().min(1).max(160).optional(), + }) + .strict() + .superRefine((directive, context) => { + if (directive.expiresAtTick < directive.issuedAtTick) + context.addIssue({ + code: 'custom', + path: ['expiresAtTick'], + message: 'Directive expiry cannot precede its issue tick.', + }); + }); +export type SwarmDirective = z.infer; + +const reflexCandidateSchema = z + .object({ + id: z + .string() + .regex(/^action_[0-9]+$/) + .max(32), + description: z.string().trim().min(1).max(280), + }) + .strict(); + +export const reflexObservationSchema = z + .object({ + agentId: agentIdSchema, + directive: swarmDirectiveSchema, + currentSituation: z + .object({ + cellStatus: z.enum(['open', 'friendly-infected', 'other-infected']), + directiveProgress: z.enum(['advancing', 'stalled', 'blocked']), + nearbyPressure: z.enum(['low', 'rising', 'high']), + recentTerritoryTrend: z.enum(['growing', 'stable', 'shrinking']), + recentActionOutcome: z.enum(['success', 'rejected', 'unknown']), + }) + .strict(), + relevantRecentFacts: z.array(z.string().trim().min(1).max(160)).max(6), + candidates: z.array(reflexCandidateSchema).min(1).max(9), + }) + .strict() + .superRefine((observation, context) => { + if (observation.directive.agentId !== observation.agentId) + context.addIssue({ + code: 'custom', + path: ['directive', 'agentId'], + message: 'A reflex directive must belong to the observed agent.', + }); + const ids = observation.candidates.map(({ id }) => id); + if (new Set(ids).size !== ids.length) + context.addIssue({ + code: 'custom', + path: ['candidates'], + message: 'Reflex candidate IDs must be unique.', + }); + }); +export type ReflexObservation = z.infer; + +export const cognitionSourceSchema = z.enum([ + 'zero-llm', + 'jev-reflex', + 'deterministic-fallback', +]); +export type CognitionSource = z.infer; + const directMessageFields = { recipientId: agentIdSchema, message: messageContentSchema, @@ -2016,12 +2097,45 @@ export type ProviderDecisionEnvelope = z.infer< typeof providerDecisionEnvelopeSchema >; -export const providerModeSchema = z.enum(['openrouter', 'scripted-test']); +export const providerModeSchema = z.enum([ + 'openrouter', + 'typesafe', + 'scripted-test', +]); export type ProviderMode = z.infer; export const modelIdSchema = z.string().trim().min(1).max(200); export type ModelId = z.infer; +export const reflexDecisionSchema = z + .object({ + chosenCandidateId: z + .string() + .regex(/^action_[0-9]+$/) + .max(32), + confidence: z.number().finite().min(0).max(1), + probabilities: z + .record( + z + .string() + .regex(/^action_[0-9]+$/) + .max(32), + z.number().finite().min(0).max(1), + ) + .refine((probabilities) => Object.keys(probabilities).length <= 9, { + message: + 'Reflex probability telemetry may include at most 9 candidates.', + }), + model: modelIdSchema, + latencyMs: z.number().finite().nonnegative(), + inputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + directiveId: z.string().trim().min(1).max(80), + cognitionSource: cognitionSourceSchema, + }) + .strict(); +export type ReflexDecision = z.infer; + const priceStringSchema = z .string() .trim() @@ -2383,6 +2497,7 @@ export const providerAttemptRecordSchema = z reasoningProfile: reasoningProfileSchema.default('provider-default'), provider: providerMetadataSchema.optional(), failure: providerFailureSchema.optional(), + reflexDecision: reflexDecisionSchema.optional(), reservedCredits: z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/), actualCostCredits: z .string() @@ -2412,6 +2527,13 @@ export const providerAttemptRecordSchema = z path: ['failure'], message: 'Completed provider attempts cannot contain a failure.', }); + if (attempt.reflexDecision && attempt.outcome !== 'completed') + context.addIssue({ + code: 'custom', + path: ['reflexDecision'], + message: + 'Reflex decision telemetry belongs only to completed attempts.', + }); if ( ['provider-error', 'cancelled', 'timeout'].includes(attempt.outcome) && !attempt.failure @@ -2664,6 +2786,7 @@ export type ExperimentExecutionLimits = z.infer< const worldSetupRequestObjectSchema = z .object({ scenarioVersion: scenarioContractVersionSchema.default('world-scenario-v1'), + cognitionMode: cognitionModeSchema.default('legacy-multi-agent'), locationLabel: z.string().trim().min(1).max(120).optional(), center: z.object({ latitude: z.number().finite().min(-90).max(90), diff --git a/packages/shared/src/reflex.test.ts b/packages/shared/src/reflex.test.ts new file mode 100644 index 0000000..e8de3ec --- /dev/null +++ b/packages/shared/src/reflex.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { + agentIdSchema, + h3CellSchema, + reflexDecisionSchema, + reflexObservationSchema, + swarmDirectiveSchema, +} from '.'; + +const agentId = agentIdSchema.parse('ca0e2b4d-d88f-4c9e-a401-a7b740c6e5af'); +const targetCell = h3CellSchema.parse('8928308280fffff'); + +const directive = { + id: 'directive-1', + agentId, + mission: 'expand' as const, + targetCell, + priority: 'high' as const, + riskTolerance: 'medium' as const, + issuedAtTick: 4, + expiresAtTick: 7, +}; + +describe('zero-swarm reflex contracts', () => { + it('accepts a compact, opaque worker observation', () => { + const observation = reflexObservationSchema.parse({ + agentId, + directive, + currentSituation: { + cellStatus: 'open', + directiveProgress: 'advancing', + nearbyPressure: 'low', + recentTerritoryTrend: 'growing', + recentActionOutcome: 'success', + }, + relevantRecentFacts: ['The assigned direction remains accessible.'], + candidates: [ + { id: 'action_0', description: 'Move toward the assigned target.' }, + { id: 'action_1', description: 'Infect the current open territory.' }, + ], + }); + + expect(observation.candidates[0]).toEqual({ + id: 'action_0', + description: 'Move toward the assigned target.', + }); + expect('action' in observation.candidates[0]!).toBe(false); + }); + + it('rejects mismatched directives and malformed choice telemetry', () => { + expect( + reflexObservationSchema.safeParse({ + agentId, + directive: { + ...directive, + agentId: agentIdSchema.parse('2507bb46-7ae4-45ca-8dda-644c4f85ca14'), + }, + currentSituation: { + cellStatus: 'open', + directiveProgress: 'advancing', + nearbyPressure: 'low', + recentTerritoryTrend: 'growing', + recentActionOutcome: 'success', + }, + relevantRecentFacts: [], + candidates: [{ id: 'action_0', description: 'Wait.' }], + }).success, + ).toBe(false); + expect( + reflexDecisionSchema.safeParse({ + chosenCandidateId: 'invented-cell', + 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', + }).success, + ).toBe(false); + expect( + swarmDirectiveSchema.safeParse({ ...directive, expiresAtTick: 3 }) + .success, + ).toBe(false); + }); +}); diff --git a/packages/shared/src/scenario.test.ts b/packages/shared/src/scenario.test.ts index a908b3a..dced161 100644 --- a/packages/shared/src/scenario.test.ts +++ b/packages/shared/src/scenario.test.ts @@ -55,6 +55,7 @@ const request = { describe('scenario contracts', () => { it('defaults safe virtual tick bounds and rejects inverted bounds', () => { const parsed = worldSetupRequestSchema.parse(request); + expect(parsed.cognitionMode).toBe('legacy-multi-agent'); expect(parsed.minimumTickIntervalMinutes).toBe(5); expect(parsed.maximumTickIntervalMinutes).toBe(10); expect( diff --git a/packages/world-engine/src/index.test.ts b/packages/world-engine/src/index.test.ts index 864dbf5..d6bb500 100644 --- a/packages/world-engine/src/index.test.ts +++ b/packages/world-engine/src/index.test.ts @@ -15,6 +15,7 @@ import { areAdjacent, createDevelopmentWorld, deterministicAllianceColor, + enumerateLegalWorldActions, getCaptureEligibility, getProposalTargetEligibility, expireAllianceProposals, @@ -199,6 +200,32 @@ describe('H3 movement', () => { reason: 'not-adjacent', }); }); + + it('enumerates only engine-legal actions in deterministic order', () => { + const before = stateWithAgent(); + const first = enumerateLegalWorldActions(before, agentId); + const second = enumerateLegalWorldActions(before, agentId); + + expect(first).toEqual(second); + expect(first).toContainEqual({ type: 'infect' }); + expect(first).toContainEqual({ type: 'wait' }); + expect(first).not.toContainEqual({ type: 'capture' }); + expect( + first + .filter((action) => action.type === 'move') + .map((action) => action.targetCell), + ).toEqual( + first + .filter((action) => action.type === 'move') + .map((action) => action.targetCell) + .slice() + .sort(), + ); + for (const action of first) + expect( + applyWorldAction(before, agentId, action, context).result, + ).toMatchObject({ accepted: true }); + }); }); describe('infection', () => { diff --git a/packages/world-engine/src/index.ts b/packages/world-engine/src/index.ts index b7f1be2..118af4d 100644 --- a/packages/world-engine/src/index.ts +++ b/packages/world-engine/src/index.ts @@ -41,6 +41,7 @@ import { type H3Cell, type NonCommunicationWorldEvent, type WorldEvent, + type WorldAction, type WorldActionResult, type WorldSnapshot, type AppliedScenario, @@ -325,6 +326,36 @@ export function getCaptureEligibility( return { eligible: true }; } +/** + * Lists every physical action the engine currently accepts for an agent in a + * frozen world state. This is intentionally derived by the same authority + * that resolves actions, so callers may safely map opaque model choices back + * to these actions without teaching a provider movement or legality rules. + */ +export function enumerateLegalWorldActions( + state: WorldState, + agentId: AgentId, +): readonly WorldAction[] { + const agent = state.agents.get(agentId); + if (!agent) return []; + + const proposed: WorldAction[] = [ + ...gridDisk(agent.currentCell, 1) + .filter((cell) => cell !== agent.currentCell) + .map((cell) => h3CellSchema.safeParse(cell)) + .filter((result) => result.success) + .map((result) => ({ type: 'move' as const, targetCell: result.data })) + .sort((left, right) => left.targetCell.localeCompare(right.targetCell)), + { type: 'infect' }, + { type: 'capture' }, + { type: 'wait' }, + ]; + + return proposed.filter( + (action) => applyWorldAction(state, agentId, action).result.accepted, + ); +} + export function applyWorldAction( state: WorldState, agentIdInput: string, @@ -1366,6 +1397,7 @@ export function defaultWorldSetupRequest(): WorldSetupRequest { })) as ScenarioRosterEntry[]; return { scenarioVersion: 'world-scenario-v1', + cognitionMode: 'legacy-multi-agent', locationLabel: 'Toledo, Ohio', center: { latitude: DEVELOPMENT_WORLD_CONFIG.latitude,