From 09c4fe17eeb8e1bf0365dda549558d40a9e140c7 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Thu, 13 Aug 2026 22:56:39 -0400 Subject: [PATCH 1/6] feat: add first visible LLM invasion --- .env.example | 8 + README.md | 46 +- ROADMAP.md | 2 + apps/game-api/package.json | 2 + apps/game-api/src/app.test.ts | 129 ++++- apps/game-api/src/app.ts | 113 ++++- apps/game-api/src/server.ts | 13 +- apps/game-api/src/simulation-service.test.ts | 136 ++++++ apps/game-api/src/simulation-service.ts | 274 +++++++++++ apps/world-lab/next.config.ts | 8 + apps/world-lab/src/app/styles.css | 200 +++++++- .../src/components/world-lab.test.tsx | 209 +++++++-- apps/world-lab/src/components/world-lab.tsx | 443 +++++++++++++++--- apps/world-lab/src/components/world-map.tsx | 145 ++++-- docs/ARCHITECTURE.md | 37 +- docs/SECURITY.md | 16 +- docs/TESTING.md | 33 +- docs/adr/0002-first-visible-llm-invasion.md | 22 + package.json | 4 +- packages/agent-runtime/package.json | 3 + packages/agent-runtime/src/index.test.ts | 157 ++++++- packages/agent-runtime/src/index.ts | 352 ++++++++++++-- .../agent-runtime/src/real-provider-smoke.ts | 40 ++ packages/shared/src/index.test.ts | 134 +++++- packages/shared/src/index.ts | 179 ++++++- packages/world-engine/src/index.test.ts | 54 ++- packages/world-engine/src/index.ts | 62 ++- playwright.config.ts | 2 +- pnpm-lock.yaml | 12 + tests/e2e/world-lab.spec.ts | 42 +- 30 files changed, 2546 insertions(+), 331 deletions(-) create mode 100644 apps/game-api/src/simulation-service.test.ts create mode 100644 apps/game-api/src/simulation-service.ts create mode 100644 docs/adr/0002-first-visible-llm-invasion.md create mode 100644 packages/agent-runtime/src/real-provider-smoke.ts diff --git a/.env.example b/.env.example index 41a21d4..5907bc7 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,11 @@ +# Server-only OpenRouter configuration. Never use NEXT_PUBLIC_ for this key. +OPENROUTER_API_KEY=replace-with-your-openrouter-key +AGENTBORNE_MODEL=openai/gpt-5-mini + +# The scripted provider is exclusively for deterministic automation. Normal +# interactive development must leave this unset so every turn uses OpenRouter. +# AGENTBORNE_PROVIDER=scripted + # Development-map center (Toledo, Ohio) and H3 resolution. NEXT_PUBLIC_DEV_MAP_LATITUDE=41.6528 NEXT_PUBLIC_DEV_MAP_LONGITUDE=-83.5379 diff --git a/README.md b/README.md index 9d1ed41..cbd1475 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,50 @@ # Agentborne -Agentborne is the working title for an agent-first GPS containment game. Persistent, named LLM agents will move across a real-world H3 grid, infect cells, communicate, form relationships, make plans, and react to players. The first product surface is the **World Lab**, a durable developer/admin interface for observing and experimenting with those agents. - -This repository is intentionally at the project-foundation milestone. It contains no real model calls or player systems yet. +Agentborne is the working title for an agent-first GPS containment game. The current milestone is the first visible LLM invasion: six persistent, named model-backed agents move and infect a real Toledo H3 world while the World Lab exposes every safe decision record. ## Workspace -| Path | Responsibility | -| ------------------------ | ------------------------------------------------------- | -| `apps/world-lab` | Next.js map and future simulation-observation interface | -| `apps/game-api` | Small Hono HTTP boundary for future clients | -| `packages/world-engine` | Pure world validation and consequence application | -| `packages/agent-runtime` | Model-provider boundary and scripted testing seam | -| `packages/shared` | Runtime-validated schemas and shared domain types | +| Path | Responsibility | +| --- | --- | +| `apps/world-lab` | Next.js developer/admin map, controls, inspector, and event log | +| `apps/game-api` | Hono HTTP boundary and in-memory simulation service | +| `packages/world-engine` | Pure world validation and consequence application | +| `packages/agent-runtime` | OpenRouter provider boundary and explicit scripted testing seams | +| `packages/shared` | Runtime-validated schemas and inferred domain types | -## Requirements +## Local development -- Node.js 24.18.0 (see `.nvmrc` and `.node-version`) -- pnpm 11.21.0, pinned by the root `packageManager` field +Requirements are Node.js 24.18.0 and pnpm 11.21.0. Copy the example environment file, replace only the placeholder key, install dependencies, and start both applications: ```bash +cp .env.example .env.local +# Edit .env.local and set OPENROUTER_API_KEY. corepack enable pnpm install --frozen-lockfile pnpm dev ``` -The World Lab opens at . Run `pnpm dev:api` in another terminal for the API at ; its current endpoints are `GET /health` and `GET /api/development-world`. +Open the World Lab at . The Game API binds to ; Next.js narrowly proxies `/api/game/*` to it. `AGENTBORNE_MODEL` defaults to `openai/gpt-5-mini`. + +Each Single turn or completed playback interval makes one third-party OpenRouter request and may incur cost. Start is deliberately disabled when the server has no key. This development API has no authentication or cost controls and is not suitable for an unauthenticated public deployment. + +State is held only in the Game API process. Restarting the API or pressing Reset reconstructs the same 61-cell Toledo world and six starting agents, with empty history. Model decisions remain nondeterministic. + +## Opt-in real-provider smoke -Copy `.env.example` to `.env.local` only when changing the documented development-map center or H3 resolution. No secret is required. +The smoke command performs exactly one bounded real decision request and validates it. It is never part of default tests or CI: + +```bash +set -a +. ./.env.local +set +a +pnpm smoke:openrouter +``` ## Development map source -The default map centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9. The shell uses MapLibre GL JS with the public OpenStreetMap raster tile endpoint and visible attribution. That tile endpoint is appropriate for light local development only; it has availability and usage-policy limits and is **not** the production basemap plan. A production deployment must select and configure a compliant tile provider or self-hosted tiles before public traffic. +The map centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9 and renders a radius-four disk. MapLibre uses the public OpenStreetMap raster tile endpoint with attribution for light local development only. A public deployment must choose a compliant production tile source. + +Social messaging, relationship memory, player mechanics, persistence, autonomous scheduling, and provider configuration UI remain deferred to later roadmap milestones. See [Testing](docs/TESTING.md), [Architecture](docs/ARCHITECTURE.md), [Security](docs/SECURITY.md), and the [Roadmap](ROADMAP.md). diff --git a/ROADMAP.md b/ROADMAP.md index f6fd979..74022e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,8 @@ Produce the first video-worthy result: By the end of PR 2, a human must be able to watch real agents independently move around and infect the map. +Implementation scope: six fixed agent profiles, a 61-cell Toledo development world, server-owned in-memory round-robin turns, OpenRouter strict structured decisions, live World Lab controls/markers/inspector, and deterministic offline automation. Persistence, autonomous scheduling, messaging, and player systems remain explicitly out of scope. + ## PR 3 — Personality Lab Prompt/personality editing, presets, cloning, respawning, reproducible starting worlds, provider/model configuration, and cost visibility. diff --git a/apps/game-api/package.json b/apps/game-api/package.json index a228a3e..16d331f 100644 --- a/apps/game-api/package.json +++ b/apps/game-api/package.json @@ -12,9 +12,11 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { + "@agentborne/agent-runtime": "workspace:*", "@agentborne/shared": "workspace:*", "@agentborne/world-engine": "workspace:*", "@hono/node-server": "2.1.0", + "h3-js": "4.5.0", "hono": "4.13.2" }, "devDependencies": { diff --git a/apps/game-api/src/app.test.ts b/apps/game-api/src/app.test.ts index a5f0ee4..2f36a8a 100644 --- a/apps/game-api/src/app.test.ts +++ b/apps/game-api/src/app.test.ts @@ -1,25 +1,128 @@ import { describe, expect, it } from 'vitest'; -import { worldSnapshotSchema } from '@agentborne/shared'; +import { + AgentProviderError, + OpenRouterAgentProvider, + ScriptedAgentProvider, + type AgentProvider, + type ProviderDecision, +} from '@agentborne/agent-runtime'; +import { + resetSimulationResponseSchema, + simulationSnapshotSchema, + singleTurnResponseSchema, +} from '@agentborne/shared'; import { createApp } from './app'; -describe('game API', () => { - const app = createApp(); - - it('reports health with a typed response', async () => { - const response = await app.request('/health'); +describe('game API simulation boundary', () => { + it('reports health and serves a schema-valid snapshot', async () => { + const app = createApp({ + provider: new ScriptedAgentProvider([ + { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + ]), + }); + expect((await app.request('/health')).status).toBe(200); + const response = await app.request('/api/simulation'); expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ status: 'ok' }); + const payload = simulationSnapshotSchema.parse(await response.json()); + expect(payload.world.agents).toHaveLength(6); }); - it('serves a schema-valid development world', async () => { - const response = await app.request('/api/development-world'); - expect(response.status).toBe(200); - const payload = worldSnapshotSchema.parse(await response.json()); - expect(payload.hexes.length).toBeGreaterThan(6); - expect(payload.hexes.some((hex) => hex.state === 'infected')).toBe(true); + it('returns accepted and rejected single-turn records with valid response shapes', async () => { + const acceptedApp = createApp({ + provider: new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + ]), + }); + const accepted = singleTurnResponseSchema.parse( + await (await acceptedApp.request('/api/simulation/turn', { method: 'POST' })).json(), + ); + expect(accepted.turn.outcome).toBe('accepted'); + + const rejectedApp = createApp({ + provider: new ScriptedAgentProvider([ + { + requestedAction: { type: 'move', targetCell: '8928308280fffff' }, + summary: 'Move far away.', + }, + ]), + }); + const rejected = singleTurnResponseSchema.parse( + await (await rejectedApp.request('/api/simulation/turn', { method: 'POST' })).json(), + ); + expect(rejected.turn.outcome).toBe('rejected'); + }); + + it('returns provider failures and missing configuration safely', async () => { + const failureProvider: AgentProvider = { + mode: 'scripted-test', + model: 'failure-test', + configured: true, + async decide() { + throw new AgentProviderError({ + code: 'network', + message: 'The model provider could not be reached.', + retryable: true, + }); + }, + }; + const failed = singleTurnResponseSchema.parse( + await ( + await createApp({ provider: failureProvider }).request( + '/api/simulation/turn', + { method: 'POST' }, + ) + ).json(), + ); + expect(failed.turn.outcome).toBe('provider-error'); + + const missing = createApp({ provider: new OpenRouterAgentProvider() }); + const snapshot = simulationSnapshotSchema.parse( + await (await missing.request('/api/simulation')).json(), + ); + expect(snapshot).toMatchObject({ + status: 'configuration-error', + providerConfigured: false, + }); + }); + + it('resets the complete simulation', async () => { + const app = createApp({ + provider: new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + ]), + }); + await app.request('/api/simulation/turn', { method: 'POST' }); + const response = await app.request('/api/simulation/reset', { method: 'POST' }); + const reset = resetSimulationResponseSchema.parse(await response.json()); + expect(reset.snapshot.turnNumber).toBe(0); + expect(reset.snapshot.world.events).toEqual([]); + }); + + it('returns typed conflicts for an overlapping turn and reset', async () => { + let release!: (result: ProviderDecision) => void; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'deferred-test', + configured: true, + decide: () => new Promise((resolve) => { release = resolve; }), + }; + const app = createApp({ provider }); + const pending = app.request('/api/simulation/turn', { method: 'POST' }); + expect((await app.request('/api/simulation/turn', { method: 'POST' })).status).toBe(409); + expect((await app.request('/api/simulation/reset', { method: 'POST' })).status).toBe(409); + release({ + decision: { requestedAction: { type: 'wait' }, summary: 'Done.' }, + metadata: { provider: 'scripted-test', model: 'deferred-test', latencyMs: 0 }, + }); + expect((await pending).status).toBe(200); }); it('uses a predictable error envelope', async () => { + const app = createApp({ + provider: new ScriptedAgentProvider([ + { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + ]), + }); const response = await app.request('/missing'); expect(response.status).toBe(404); await expect(response.json()).resolves.toEqual({ diff --git a/apps/game-api/src/app.ts b/apps/game-api/src/app.ts index b2f6fc9..b758978 100644 --- a/apps/game-api/src/app.ts +++ b/apps/game-api/src/app.ts @@ -1,6 +1,23 @@ import { Hono } from 'hono'; -import { apiErrorSchema, worldSnapshotSchema } from '@agentborne/shared'; +import { cors } from 'hono/cors'; +import { + BrowserTestAgentProvider, + DEFAULT_OPENROUTER_MODEL, + OpenRouterAgentProvider, + type AgentProvider, +} from '@agentborne/agent-runtime'; +import { + apiErrorSchema, + resetSimulationResponseSchema, + simulationSnapshotSchema, + singleTurnResponseSchema, + worldSnapshotSchema, +} from '@agentborne/shared'; import { createDevelopmentWorld } from '@agentborne/world-engine'; +import { + SimulationConflictError, + SimulationService, +} from './simulation-service'; export const healthResponseSchema = worldSnapshotSchema .pick({ generatedAt: true }) @@ -9,19 +26,93 @@ export const healthResponseSchema = worldSnapshotSchema checkedAt: generatedAt, })); -export function createApp() { - const app = new Hono(); +export interface AppOptions { + service?: SimulationService; + provider?: AgentProvider; +} + +export function providerFromEnvironment(): AgentProvider { + if (process.env.AGENTBORNE_PROVIDER === 'scripted') { + return new BrowserTestAgentProvider(); + } + return new OpenRouterAgentProvider({ + apiKey: process.env.OPENROUTER_API_KEY, + model: process.env.AGENTBORNE_MODEL ?? DEFAULT_OPENROUTER_MODEL, + }); +} - app.get('/health', (context) => { - const payload = healthResponseSchema.parse({ - generatedAt: new Date().toISOString(), +export function createApp(options: AppOptions = {}) { + const app = new Hono(); + const service = + options.service ?? + new SimulationService({ + provider: options.provider ?? providerFromEnvironment(), }); - return context.json(payload); + + app.use( + '/api/*', + cors({ + origin: (origin) => + ['http://localhost:3000', 'http://127.0.0.1:3000'].includes(origin) + ? origin + : null, + allowMethods: ['GET', 'POST'], + allowHeaders: ['Content-Type'], + }), + ); + + app.get('/health', (context) => + context.json( + healthResponseSchema.parse({ generatedAt: new Date().toISOString() }), + ), + ); + + app.get('/api/development-world', (context) => + context.json(worldSnapshotSchema.parse(createDevelopmentWorld())), + ); + + app.get('/api/simulation', (context) => + context.json(simulationSnapshotSchema.parse(service.getSnapshot())), + ); + + app.post('/api/simulation/turn', async (context) => { + try { + const turn = await service.executeNextTurn(); + return context.json( + singleTurnResponseSchema.parse({ + snapshot: service.getSnapshot(), + turn, + }), + ); + } catch (error) { + if (error instanceof SimulationConflictError) { + return context.json( + apiErrorSchema.parse({ + error: { code: 'turn_conflict', message: error.message }, + }), + 409, + ); + } + throw error; + } }); - app.get('/api/development-world', (context) => { - const payload = worldSnapshotSchema.parse(createDevelopmentWorld()); - return context.json(payload); + app.post('/api/simulation/reset', (context) => { + try { + return context.json( + resetSimulationResponseSchema.parse({ snapshot: service.reset() }), + ); + } catch (error) { + if (error instanceof SimulationConflictError) { + return context.json( + apiErrorSchema.parse({ + error: { code: 'reset_conflict', message: error.message }, + }), + 409, + ); + } + throw error; + } }); app.notFound((context) => @@ -37,7 +128,7 @@ export function createApp() { ); app.onError((error, context) => { - console.error('Unhandled API error', error); + console.error('Unhandled API error', error instanceof Error ? error.name : 'unknown'); return context.json( apiErrorSchema.parse({ error: { diff --git a/apps/game-api/src/server.ts b/apps/game-api/src/server.ts index e2077f2..98b968c 100644 --- a/apps/game-api/src/server.ts +++ b/apps/game-api/src/server.ts @@ -1,8 +1,19 @@ import { serve } from '@hono/node-server'; import { createApp } from './app'; +try { + process.loadEnvFile('../../.env.local'); +} catch (error) { + const code = error instanceof Error && 'code' in error ? error.code : undefined; + if (code !== 'ENOENT') throw error; +} + const port = Number.parseInt(process.env.PORT ?? '8787', 10); -serve({ fetch: createApp().fetch, port }, ({ port: listeningPort }) => { +serve({ + fetch: createApp().fetch, + hostname: '127.0.0.1', + port, +}, ({ port: listeningPort }) => { console.log(`Game API listening on http://localhost:${listeningPort}`); }); diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts new file mode 100644 index 0000000..9ce5660 --- /dev/null +++ b/apps/game-api/src/simulation-service.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { + AgentProviderError, + ScriptedAgentProvider, + type AgentProvider, + type ProviderDecision, +} from '@agentborne/agent-runtime'; +import type { AgentObservation } from '@agentborne/shared'; +import { SimulationConflictError, SimulationService } from './simulation-service'; + +const now = () => '2026-08-13T12:00:01.000Z'; +const createEventId = () => '67aa21b9-fc78-4b04-9f92-9862bf346f96'; + +function service(provider: AgentProvider) { + return new SimulationService({ provider, now, createEventId }); +} + +describe('SimulationService', () => { + it('resets to the exact deterministic six-agent starting world', async () => { + const simulation = service( + new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + ]), + ); + const initial = simulation.getSnapshot(); + await simulation.executeNextTurn(); + expect(simulation.reset()).toEqual(initial); + expect(initial.world.agents).toHaveLength(6); + expect(initial.world.hexes).toHaveLength(61); + }); + + it('calls the provider exactly once per turn in round-robin order', async () => { + const seen: AgentObservation[] = []; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'recording-test', + configured: true, + async decide(observation): Promise { + seen.push(observation); + return { + decision: { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + metadata: { provider: 'scripted-test', model: 'recording-test', latencyMs: 0 }, + }; + }, + }; + const simulation = service(provider); + const order = simulation.getSnapshot().world.agents.map(({ id }) => id); + for (let index = 0; index < 7; index += 1) await simulation.executeNextTurn(); + expect(seen).toHaveLength(7); + expect(seen.map(({ agentId }) => agentId)).toEqual([...order, order[0]]); + }); + + it('builds each observation from the latest authoritative world state', async () => { + const simulation = service( + new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + { requestedAction: { type: 'wait' }, summary: 'Observe.' }, + ]), + ); + await simulation.executeNextTurn(); + const second = await simulation.executeNextTurn(); + expect(second.observation.recentEvents).toHaveLength(1); + expect(second.observation.recentEvents[0]?.type).toBe('hex-infected'); + }); + + it('records accepted and rejected actions without mutating on rejection', async () => { + const initial = service( + new ScriptedAgentProvider([ + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + ]), + ); + expect((await initial.executeNextTurn()).outcome).toBe('accepted'); + + const rejected = service( + new ScriptedAgentProvider([ + { + requestedAction: { type: 'move', targetCell: '8928308280fffff' }, + summary: 'Attempt a distant move.', + }, + { requestedAction: { type: 'wait' }, summary: 'Continue.' }, + ]), + ); + const before = rejected.getSnapshot().world; + expect((await rejected.executeNextTurn()).outcome).toBe('rejected'); + expect(rejected.getSnapshot().world.hexes).toEqual(before.hexes); + expect(rejected.getSnapshot().world.agents).toEqual(before.agents); + expect((await rejected.executeNextTurn()).outcome).toBe('accepted'); + }); + + it('records a sanitized provider failure, preserves the world, and continues', async () => { + let calls = 0; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'failure-test', + configured: true, + async decide(): Promise { + calls += 1; + if (calls === 1) + throw new AgentProviderError({ + code: 'timeout', + message: 'The model request timed out.', + retryable: true, + }); + return { + decision: { requestedAction: { type: 'wait' }, summary: 'Recovered.' }, + metadata: { provider: 'scripted-test', model: 'failure-test', latencyMs: 0 }, + }; + }, + }; + const simulation = service(provider); + const before = simulation.getSnapshot().world; + expect((await simulation.executeNextTurn()).outcome).toBe('provider-error'); + expect(simulation.getSnapshot().world).toEqual(before); + expect((await simulation.executeNextTurn()).outcome).toBe('accepted'); + }); + + it('prevents overlapping turns and reset during an in-flight request', async () => { + let release!: (result: ProviderDecision) => void; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'deferred-test', + configured: true, + decide: () => new Promise((resolve) => { release = resolve; }), + }; + const simulation = service(provider); + const pending = simulation.executeNextTurn(); + await expect(simulation.executeNextTurn()).rejects.toBeInstanceOf(SimulationConflictError); + expect(() => simulation.reset()).toThrow(SimulationConflictError); + release({ + decision: { requestedAction: { type: 'wait' }, summary: 'Done.' }, + metadata: { provider: 'scripted-test', model: 'deferred-test', latencyMs: 0 }, + }); + await pending; + expect(simulation.getSnapshot().turnNumber).toBe(1); + }); +}); diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts new file mode 100644 index 0000000..d19f8c9 --- /dev/null +++ b/apps/game-api/src/simulation-service.ts @@ -0,0 +1,274 @@ +import { gridDistance, gridDisk } from 'h3-js'; +import { + AgentProviderError, + type AgentProvider, +} from '@agentborne/agent-runtime'; +import { + agentObservationSchema, + agentTurnRecordSchema, + h3CellSchema, + simulationSnapshotSchema, + type AgentId, + type AgentObservation, + type AgentTurnRecord, + type H3Cell, + type ProviderFailure, + type SimulationSnapshot, + type SimulationStatus, + type WorldEvent, +} from '@agentborne/shared'; +import { + applyRequestedAction, + createDevelopmentWorld, + toWorldState, + type WorldState, +} from '@agentborne/world-engine'; + +const RESET_GENERATED_AT = '2026-08-13T12:00:00.000Z'; +const MAX_TURN_HISTORY = 120; + +export class SimulationConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'SimulationConflictError'; + } +} + +export interface SimulationServiceOptions { + provider: AgentProvider; + now?: () => string; + createEventId?: () => string; +} + +export class SimulationService { + readonly #provider: AgentProvider; + readonly #now: () => string; + readonly #createEventId: () => string; + #state: WorldState; + #turns: AgentTurnRecord[] = []; + #cursor = 0; + #busy = false; + #status: SimulationStatus; + #activeAgentId: AgentId | null = null; + + constructor({ + provider, + now = () => new Date().toISOString(), + createEventId = () => crypto.randomUUID(), + }: SimulationServiceOptions) { + this.#provider = provider; + this.#now = now; + this.#createEventId = createEventId; + this.#state = toWorldState( + createDevelopmentWorld({ generatedAt: RESET_GENERATED_AT }), + ); + this.#status = provider.configured ? 'paused' : 'configuration-error'; + } + + getSnapshot(): SimulationSnapshot { + const agents = [...this.#state.agents.values()]; + const next = agents[this.#cursor % agents.length]; + if (!next) throw new Error('The development world has no agents.'); + return simulationSnapshotSchema.parse({ + world: { + generatedAt: RESET_GENERATED_AT, + hexes: [...this.#state.hexes].map(([cell, state]) => ({ cell, state })), + agents, + events: this.#state.events, + }, + turnNumber: this.#turns.length, + nextAgentId: next.id, + activeAgentId: this.#activeAgentId, + status: this.#status, + providerMode: this.#provider.mode, + providerConfigured: this.#provider.configured, + turns: this.#turns, + }); + } + + reset(): SimulationSnapshot { + if (this.#busy) { + throw new SimulationConflictError( + 'Reset is unavailable while a model turn is in progress.', + ); + } + this.#status = 'resetting'; + this.#state = toWorldState( + createDevelopmentWorld({ generatedAt: RESET_GENERATED_AT }), + ); + this.#turns = []; + this.#cursor = 0; + this.#activeAgentId = null; + this.#status = this.#provider.configured + ? 'paused' + : 'configuration-error'; + return this.getSnapshot(); + } + + async executeNextTurn(): Promise { + if (this.#busy) { + throw new SimulationConflictError( + 'A model turn is already in progress.', + ); + } + const agents = [...this.#state.agents.values()]; + const agent = agents[this.#cursor % agents.length]; + if (!agent) throw new Error('The development world has no agents.'); + + this.#busy = true; + this.#activeAgentId = agent.id; + this.#status = 'waiting-for-model'; + const startedAt = this.#now(); + const observation = this.#buildObservation(agent.id); + const turnNumber = this.#turns.length + 1; + let record: AgentTurnRecord; + + try { + const providerResult = await this.#provider.decide( + structuredClone(observation), + ); + const applied = applyRequestedAction( + this.#state, + agent.id, + providerResult.decision.requestedAction, + { now: this.#now, createEventId: this.#createEventId }, + ); + if (applied.result.accepted) { + this.#state = applied.state; + record = agentTurnRecordSchema.parse({ + turnNumber, + agentId: agent.id, + startedAt, + completedAt: this.#now(), + observation, + outcome: 'accepted', + requestedAction: providerResult.decision.requestedAction, + summary: providerResult.decision.summary, + validation: { accepted: true }, + event: applied.result.event, + provider: providerResult.metadata, + }); + this.#status = 'paused'; + } else { + record = agentTurnRecordSchema.parse({ + turnNumber, + agentId: agent.id, + startedAt, + completedAt: this.#now(), + observation, + outcome: 'rejected', + requestedAction: providerResult.decision.requestedAction, + summary: providerResult.decision.summary, + validation: applied.result, + provider: providerResult.metadata, + }); + this.#status = 'paused'; + } + } catch (error) { + const providerError = asProviderError(error); + record = agentTurnRecordSchema.parse({ + turnNumber, + agentId: agent.id, + startedAt, + completedAt: this.#now(), + observation, + outcome: 'provider-error', + failure: providerError.failure, + provider: providerError.metadata, + }); + this.#status = + providerError.failure.code === 'configuration' + ? 'configuration-error' + : 'provider-error'; + } finally { + this.#busy = false; + this.#activeAgentId = null; + } + + this.#turns = [...this.#turns, record].slice(-MAX_TURN_HISTORY); + this.#cursor = (this.#cursor + 1) % agents.length; + return record; + } + + #buildObservation(agentId: AgentId): AgentObservation { + const agent = this.#state.agents.get(agentId); + if (!agent) throw new Error('The active agent does not exist.'); + const stateFor = (cell: H3Cell) => { + const state = this.#state.hexes.get(cell); + if (!state) throw new Error('Observation cell is outside the world.'); + return { cell, state }; + }; + const adjacentCells = gridDisk(agent.currentCell, 1) + .filter((cell) => cell !== agent.currentCell) + .map((cell) => h3CellSchema.parse(cell)) + .filter((cell) => this.#state.hexes.has(cell)) + .map(stateFor); + const nearbyAgents = [...this.#state.agents.values()] + .filter((candidate) => candidate.id !== agent.id) + .map((candidate) => ({ + id: candidate.id, + name: candidate.name, + currentCell: candidate.currentCell, + distance: safeDistance(agent.currentCell, candidate.currentCell), + })) + .filter(({ distance }) => distance <= 4) + .sort((a, b) => a.distance - b.distance || a.id.localeCompare(b.id)) + .slice(0, 5); + const recentEvents = this.#state.events + .filter( + (event): event is Exclude => + event.type !== 'agent-messaged', + ) + .slice(-8) + .map((event) => ({ + type: event.type, + agentId: event.agentId, + occurredAt: event.occurredAt, + summary: summarizeEvent(event, this.#state), + })); + return agentObservationSchema.parse({ + agentId: agent.id, + agentName: agent.name, + personality: agent.personality, + currentCell: stateFor(agent.currentCell), + adjacentCells, + nearbyAgents, + recentEvents, + }); + } +} + +function safeDistance(from: H3Cell, to: H3Cell): number { + try { + return gridDistance(from, to); + } catch { + return 99; + } +} + +function summarizeEvent( + event: Exclude, + state: WorldState, +): string { + const name = state.agents.get(event.agentId)?.name ?? 'An agent'; + if (event.type === 'agent-moved') return `${name} moved to ${event.toCell}.`; + if (event.type === 'hex-infected') + return `${name} infected ${event.cell}.`; + return `${name} waited.`; +} + +function asProviderError(error: unknown): { + failure: ProviderFailure; + metadata?: AgentProviderError['metadata']; +} { + if (error instanceof AgentProviderError) { + return { failure: error.failure, metadata: error.metadata }; + } + return { + failure: { + code: 'network', + message: 'The model provider failed unexpectedly.', + retryable: true, + }, + }; +} diff --git a/apps/world-lab/next.config.ts b/apps/world-lab/next.config.ts index c09960a..25033ec 100644 --- a/apps/world-lab/next.config.ts +++ b/apps/world-lab/next.config.ts @@ -2,6 +2,14 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { transpilePackages: ['@agentborne/shared', '@agentborne/world-engine'], + async rewrites() { + return [ + { + source: '/api/game/:path*', + destination: 'http://127.0.0.1:8787/api/:path*', + }, + ]; + }, }; export default nextConfig; diff --git a/apps/world-lab/src/app/styles.css b/apps/world-lab/src/app/styles.css index 70882ac..64d9bd8 100644 --- a/apps/world-lab/src/app/styles.css +++ b/apps/world-lab/src/app/styles.css @@ -21,7 +21,8 @@ body { background: #111716; } button, -input { +input, +select { font: inherit; } @@ -70,6 +71,35 @@ h2 { background: #d3a64d; box-shadow: 0 0 0 4px #d3a64d1c; } +.status-group { + display: grid; + justify-items: end; + gap: 7px; +} +.status.running span { + background: #69d59b; +} +.status.waiting-for-model span, +.status.resetting span { + background: #63d2ff; + animation: pulse 1s ease-in-out infinite alternate; +} +.status.configuration-error span, +.status.provider-error span { + background: #e44f45; +} +.provider-badge { + padding: 4px 8px; + border: 1px solid #364540; + border-radius: 999px; + color: #9fb1ab; + font-size: 0.68rem; +} +@keyframes pulse { + to { + opacity: 0.35; + } +} .workspace { display: grid; @@ -81,11 +111,47 @@ h2 { min-height: 670px; border-right: 1px solid #2d3835; } -.world-map { +.map-stage { position: absolute; inset: 0 0 42px; +} +.world-map { + position: absolute; + inset: 0; background: #1a2321; } +.map-ready { + position: absolute; + z-index: 2; + left: 12px; + top: 12px; + padding: 6px 9px; + border: 1px solid #41504b; + border-radius: 5px; + background: #111716dd; + color: #bed0ca; + font-size: 0.7rem; + pointer-events: none; +} +.agent-marker { + width: 30px; + height: 30px; + padding: 0; + border: 3px solid #101615; + border-radius: 50%; + background: var(--agent-color); + color: #101615; + font-weight: 850; + line-height: 24px; + cursor: pointer; + box-shadow: 0 2px 8px #000b; +} +.agent-marker:hover, +.agent-marker.selected { + border-color: #fff5d8; + box-shadow: 0 0 0 4px #fff5d855, 0 2px 8px #000b; + transform: scale(1.12); +} .map-caption { position: absolute; inset: auto 0 0; @@ -103,6 +169,8 @@ h2 { .sidebar { background: #151c1a; + max-height: calc(100vh - 88px); + overflow-y: auto; } .panel { padding: 22px; @@ -168,18 +236,11 @@ dd { border-color: #fff2d3; box-shadow: 0 0 0 2px #fff2d344; } -.reserved-panel { - color: #8fa19b; -} -.reserved-panel h2, +.controls-panel h2, .event-panel h2 { color: #e6ece8; margin-bottom: 11px; } -.reserved-panel > p:last-child { - font-size: 0.8rem; - line-height: 1.5; -} .control-row { display: flex; gap: 7px; @@ -189,8 +250,109 @@ dd { padding: 7px 10px; border: 1px solid #394642; border-radius: 5px; + color: #dbe7e3; + background: #202927; + cursor: pointer; +} +.control-row button:hover:not(:disabled) { + border-color: #739087; + background: #2a3633; +} +.control-row button:disabled { color: #71807b; + cursor: not-allowed; +} +.next-agent { + color: #9aaba5; + font-size: 0.8rem; +} +.speed-control { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: #9aaba5; + font-size: 0.78rem; +} +.speed-control select { + padding: 5px 8px; + border: 1px solid #394642; + border-radius: 5px; background: #202927; + color: #dbe7e3; +} +.callout { + margin-top: 12px; + padding: 9px; + border-radius: 5px; + font-size: 0.76rem; + line-height: 1.45; +} +.callout.configuration, +.callout.error { + border: 1px solid #78443d; + background: #3c2421; + color: #ffc2b8; +} +.agent-inspector h2 { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 15px; + font-size: 1.25rem; +} +.agent-inspector h3 { + margin: 17px 0 7px; + color: #9fb1ab; + font-size: 0.74rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.agent-inspector > p, +.turn-detail p, +.turn-detail details, +.compact-history { + color: #bcc9c5; + font-size: 0.77rem; + line-height: 1.5; +} +.agent-swatch { + width: 12px; + height: 12px; + border: 2px solid #e6ece8; + border-radius: 50%; +} +.outcome { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + text-transform: uppercase; + font-size: 0.67rem !important; + font-weight: 750; +} +.outcome.accepted { + background: #214b39; + color: #8ff1ba; +} +.outcome.rejected, +.outcome.provider-error { + background: #512b27; + color: #ffafa3; +} +.provider-meta, +.muted { + color: #81928c !important; +} +.turn-detail details { + margin-top: 10px; +} +.turn-detail details p { + margin-top: 6px; + overflow-wrap: anywhere; +} +.compact-history { + margin: 0; + padding-left: 18px; } .event-panel ol { list-style: none; @@ -202,10 +364,22 @@ dd { grid-template-columns: 55px 1fr; gap: 10px; font-size: 0.8rem; + padding: 4px 0; } .event-panel time { color: #82938e; } +.event-panel li[data-outcome='provider-error'], +.event-panel li[data-outcome='rejected'] { + color: #ffafa3; +} +.loading-state { + display: grid; + min-height: 100vh; + place-content: center; + gap: 10px; + text-align: center; +} @media (max-width: 780px) { .topbar { @@ -228,6 +402,12 @@ dd { height: 56px; } .world-map { + bottom: 0; + } + .map-stage { bottom: 56px; } + .sidebar { + max-height: none; + } } diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index f21e93c..f142d60 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -1,57 +1,200 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { gridDisk } from 'h3-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { simulationSnapshotSchema, type SimulationSnapshot } from '@agentborne/shared'; +import { createDevelopmentWorld } from '@agentborne/world-engine'; import { WorldLab } from './world-lab'; +let mapClick: ((event: { features: Array<{ properties: { cell: string } }> }) => void) | undefined; + vi.mock('maplibre-gl', () => { class Map { + source = { setData: vi.fn() }; addControl() {} addLayer() {} addSource() {} - getCanvas() { - return { style: { cursor: '' } }; - } - getSource() { - return undefined; - } - on(event: string, layerOrCallback: unknown, callback?: () => void) { - if (event === 'load' && typeof layerOrCallback === 'function') - layerOrCallback(); - void callback; + fitBounds() {} + getCanvas() { return { style: { cursor: '' } }; } + getSource() { return this.source; } + on(event: string, layerOrCallback: unknown, callback?: typeof mapClick) { + if (event === 'load' && typeof layerOrCallback === 'function') layerOrCallback(); + if (event === 'click' && typeof callback === 'function') mapClick = callback; } remove() {} } + class Marker { + constructor(private options: { element: HTMLElement }) {} + setLngLat() { return this; } + addTo() { document.body.append(this.options.element); return this; } + remove() { this.options.element.remove(); } + } return { Map, + Marker, + LngLatBounds: class { extend() { return this; } }, NavigationControl: class {}, AttributionControl: class {}, }; }); +const world = createDevelopmentWorld({ generatedAt: '2026-08-13T12:00:00.000Z' }); +const initial = simulationSnapshotSchema.parse({ + world, + turnNumber: 0, + nextAgentId: world.agents[0]!.id, + activeAgentId: null, + status: 'paused', + providerMode: 'scripted-test', + providerConfigured: true, + turns: [], +}); + +function afterInfection(): SimulationSnapshot { + const agent = world.agents[0]!; + const event = { + id: '67aa21b9-fc78-4b04-9f92-9862bf346f96', + agentId: agent.id, + occurredAt: '2026-08-13T12:00:01.000Z', + type: 'hex-infected' as const, + cell: agent.currentCell, + }; + const adjacent = gridDisk(agent.currentCell, 1).find( + (cell) => cell !== agent.currentCell && world.hexes.some((hex) => hex.cell === cell), + )!; + const turn = { + turnNumber: 1, + agentId: agent.id, + startedAt: '2026-08-13T12:00:00.000Z', + completedAt: '2026-08-13T12:00:01.000Z', + observation: { + agentId: agent.id, + agentName: agent.name, + personality: agent.personality, + currentCell: { cell: agent.currentCell, state: 'open' as const }, + adjacentCells: [{ cell: adjacent, state: 'open' as const }], + nearbyAgents: [], + recentEvents: [], + }, + outcome: 'accepted' as const, + requestedAction: { type: 'infect' as const }, + summary: 'Infecting this open cell.', + validation: { accepted: true as const }, + event, + provider: { provider: 'scripted-test' as const, model: 'test', latencyMs: 0 }, + }; + return simulationSnapshotSchema.parse({ + ...initial, + world: { + ...world, + hexes: world.hexes.map((hex) => + hex.cell === agent.currentCell ? { ...hex, state: 'infected' as const } : hex, + ), + events: [event], + }, + turnNumber: 1, + nextAgentId: world.agents[1]!.id, + turns: [turn], + }); +} + +function jsonResponse(value: unknown) { + return Promise.resolve(new Response(JSON.stringify(value), { status: 200 })); +} + +beforeEach(() => { + mapClick = undefined; + vi.stubGlobal('fetch', vi.fn(() => jsonResponse(initial))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe('WorldLab', () => { - it('renders the map shell and reserved developer areas', () => { + it('renders all controls, status, H3 readiness, and six visible markers', async () => { render(); - expect( - screen.getByRole('heading', { name: 'World Lab' }), - ).toBeInTheDocument(); - expect(screen.getByTestId('world-map')).toBeInTheDocument(); - expect( - screen.getByRole('heading', { name: 'Simulation controls' }), - ).toBeInTheDocument(); - expect( - screen.getByRole('heading', { name: 'Agent inspector' }), - ).toBeInTheDocument(); - }); - - it('selects another hex and reports its H3 index and state', async () => { + expect(await screen.findByRole('heading', { name: 'World Lab' })).toBeInTheDocument(); + expect(await screen.findByText(/H3 overlay ready · 61 cells · 6 agents/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Single turn' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled(); + expect(screen.getByLabelText('Playback speed')).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /Select agent/ })).toHaveLength(6); + expect(screen.getByText('Automated-test provider')).toBeInTheDocument(); + }); + + it('selects an agent and populates its inspector', async () => { const user = userEvent.setup(); render(); - const secondHex = screen.getByRole('button', { name: 'Select hex 2' }); - expect(secondHex).toHaveAttribute('aria-pressed', 'false'); - await user.click(secondHex); - expect(secondHex).toHaveAttribute('aria-pressed', 'true'); - expect( - screen.getByText(secondHex.getAttribute('title')!), - ).toBeInTheDocument(); + await user.click(await screen.findByRole('button', { name: 'Select agent Rook' })); + expect(screen.getByRole('heading', { name: /Rook/ })).toBeInTheDocument(); + expect(screen.getByText(world.agents[1]!.personality)).toBeInTheDocument(); + expect(screen.getByText(world.agents[1]!.id)).toBeInTheDocument(); + }); + + it('executes one turn and renders infection and decision details safely', async () => { + const changed = afterInfection(); + vi.stubGlobal( + 'fetch', + vi.fn() + .mockImplementationOnce(() => jsonResponse(initial)) + .mockImplementationOnce(() => jsonResponse({ snapshot: changed, turn: changed.turns[0] })), + ); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Single turn' })); + expect(await screen.findByText('Infection · ' + world.agents[0]!.currentCell)).toBeInTheDocument(); + expect(screen.getByText('Infecting this open cell.')).toBeInTheDocument(); + expect(screen.queryByText(/chain-of-thought/i)).not.toBeInTheDocument(); + }); + + it('starts, pauses, and changes playback speed without overlapping immediately', async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Start' })); + expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument(); + await user.selectOptions(screen.getByLabelText('Playback speed'), '250'); + await user.click(screen.getByRole('button', { name: 'Pause' })); + expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument(); + }); + + it('resets turn history and UI selections', async () => { + const changed = afterInfection(); + vi.stubGlobal( + 'fetch', + vi.fn() + .mockImplementationOnce(() => jsonResponse(changed)) + .mockImplementationOnce(() => jsonResponse({ snapshot: initial })), + ); + const user = userEvent.setup(); + render(); + expect(await screen.findByText('Infection · ' + world.agents[0]!.currentCell)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Reset' })); + await waitFor(() => expect(screen.getByText('Development world loaded with six agents.')).toBeInTheDocument()); + expect(screen.getByText('Turn 0')).toBeInTheDocument(); + }); + + it('supports hex selection independently of agent selection', async () => { + render(); + await screen.findByRole('button', { name: 'Select agent Ember' }); + const target = world.hexes[1]!.cell; + act(() => mapClick?.({ features: [{ properties: { cell: target } }] })); + expect(screen.getByText(target)).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Ember/ })).toBeInTheDocument(); + }); + + it('renders missing configuration without enabling cost-incurring controls', async () => { + const unconfigured = simulationSnapshotSchema.parse({ + ...initial, + status: 'configuration-error', + providerMode: 'openrouter', + providerConfigured: false, + }); + vi.stubGlobal('fetch', vi.fn(() => jsonResponse(unconfigured))); + render(); + expect(await screen.findByText(/Model calls unavailable/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Single turn' })).toBeDisabled(); }); }); diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index bba6f47..92ca5e0 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -1,23 +1,141 @@ 'use client'; -import { useMemo, useState } from 'react'; -import { createDevelopmentWorld } from '@agentborne/world-engine'; -import type { H3Cell } from '@agentborne/shared'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + resetSimulationResponseSchema, + simulationSnapshotSchema, + singleTurnResponseSchema, + type AgentId, + type AgentTurnRecord, + type H3Cell, + type SimulationSnapshot, +} from '@agentborne/shared'; import { WorldMap } from './world-map'; const latitude = Number(process.env.NEXT_PUBLIC_DEV_MAP_LATITUDE ?? 41.6528); const longitude = Number(process.env.NEXT_PUBLIC_DEV_MAP_LONGITUDE ?? -83.5379); const resolution = Number(process.env.NEXT_PUBLIC_DEV_MAP_H3_RESOLUTION ?? 9); +const apiBase = '/api/game/simulation'; export function WorldLab() { - const world = useMemo( - () => createDevelopmentWorld({ latitude, longitude, resolution }), - [], + const [snapshot, setSnapshot] = useState(null); + const [selectedCell, setSelectedCell] = useState(null); + const [selectedAgentId, setSelectedAgentId] = useState(null); + const [running, setRunning] = useState(false); + const [inFlight, setInFlight] = useState(false); + const [resetting, setResetting] = useState(false); + const [speed, setSpeed] = useState(1_000); + const [uiError, setUiError] = useState(null); + const inFlightRef = useRef(false); + + const applySnapshot = useCallback((next: SimulationSnapshot) => { + setSnapshot(next); + setSelectedCell((current) => current ?? next.world.hexes[0]!.cell); + setSelectedAgentId((current) => current ?? next.world.agents[0]!.id); + }, []); + + useEffect(() => { + let alive = true; + void fetch(apiBase) + .then(async (response) => { + if (!response.ok) throw new Error('The Game API is unavailable.'); + return simulationSnapshotSchema.parse(await response.json()); + }) + .then((next) => { + if (alive) applySnapshot(next); + }) + .catch(() => { + if (alive) + setUiError('The Game API is unavailable. Start it with pnpm dev.'); + }); + return () => { + alive = false; + }; + }, [applySnapshot]); + + const executeTurn = useCallback(async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + setInFlight(true); + setUiError(null); + try { + const response = await fetch(`${apiBase}/turn`, { method: 'POST' }); + if (response.status === 409) { + setUiError('Another turn is already in progress.'); + return; + } + if (!response.ok) throw new Error('turn request failed'); + const payload = singleTurnResponseSchema.parse(await response.json()); + applySnapshot(payload.snapshot); + } catch { + setUiError('The turn failed safely. Check the Game API and try again.'); + setRunning(false); + } finally { + inFlightRef.current = false; + setInFlight(false); + } + }, [applySnapshot]); + + useEffect(() => { + if (!running || inFlight || resetting) return; + const timer = window.setTimeout(() => void executeTurn(), speed); + return () => window.clearTimeout(timer); + }, [executeTurn, inFlight, resetting, running, snapshot?.turnNumber, speed]); + + const reset = async () => { + if (inFlightRef.current) return; + setRunning(false); + setResetting(true); + setUiError(null); + try { + const response = await fetch(`${apiBase}/reset`, { method: 'POST' }); + if (response.status === 409) { + setUiError('Reset is unavailable until the current turn completes.'); + return; + } + if (!response.ok) throw new Error('reset request failed'); + const payload = resetSimulationResponseSchema.parse(await response.json()); + setSnapshot(payload.snapshot); + setSelectedCell(payload.snapshot.world.hexes[0]!.cell); + setSelectedAgentId(payload.snapshot.world.agents[0]!.id); + } catch { + setUiError('Reset failed safely. The existing world was left intact.'); + } finally { + setResetting(false); + } + }; + + if (!snapshot) { + return ( +
+

World Lab

+

{uiError ?? 'Loading simulation…'}

+
+ ); + } + + const selectedAgent = snapshot.world.agents.find( + ({ id }) => id === selectedAgentId, + ); + const selectedHex = snapshot.world.hexes.find( + ({ cell }) => cell === selectedCell, ); - const [selectedCell, setSelectedCell] = useState( - world.hexes[0]!.cell, + const latestTurn = selectedAgent + ? snapshot.turns.findLast(({ agentId }) => agentId === selectedAgent.id) + : undefined; + const status = resetting + ? 'resetting' + : inFlight + ? 'waiting-for-model' + : snapshot.status === 'configuration-error' || + snapshot.status === 'provider-error' + ? snapshot.status + : running + ? 'running' + : 'paused'; + const nextAgent = snapshot.world.agents.find( + ({ id }) => id === snapshot.nextAgentId, ); - const selectedHex = world.hexes.find(({ cell }) => cell === selectedCell)!; return (
@@ -26,9 +144,16 @@ export function WorldLab() {

Developer simulation interface

World Lab

-

-

+
+

+

+

+ {snapshot.providerMode === 'openrouter' + ? 'Genuine model · OpenRouter' + : 'Automated-test provider'} +

+
@@ -36,88 +161,260 @@ export function WorldLab() {
Development location: Toledo, Ohio - H3 resolution {resolution} · {world.hexes.length} cells + H3 resolution {resolution} · {snapshot.world.hexes.length} cells
); } + +function AgentInspector({ + agent, + cellState, + latestTurn, + turns, +}: { + agent: SimulationSnapshot['world']['agents'][number]; + cellState: 'open' | 'infected'; + latestTurn?: AgentTurnRecord; + turns: AgentTurnRecord[]; +}) { + return ( +
+

Agent inspector

+

+ + {agent.name} +

+
+
+
Stable ID
+
{agent.id}
+
+
+
Color
+
{agent.color}
+
+
+
Cell
+
{agent.currentCell}
+
+
+
Cell state
+
{cellState}
+
+
+

Personality

+

{agent.personality}

+ {latestTurn ? ( +
+

Latest turn

+

+ {latestTurn.outcome} +

+ {latestTurn.outcome !== 'provider-error' ? ( + <> +

+ Requested:{' '} + {formatAction(latestTurn.requestedAction)} +

+

+ Summary: {latestTurn.summary} +

+ {latestTurn.outcome === 'rejected' && ( +

+ Rejection: {latestTurn.validation.reason} ·{' '} + {latestTurn.validation.details} +

+ )} +

+ {latestTurn.provider.provider} · {latestTurn.provider.model} ·{' '} + {latestTurn.provider.latencyMs}ms +

+ + ) : ( + <> +

+ {latestTurn.failure.code}: {latestTurn.failure.message} +

+ {latestTurn.provider && ( +

+ {latestTurn.provider.provider} · {latestTurn.provider.model} ·{' '} + {latestTurn.provider.latencyMs}ms +

+ )} + + )} +
+ Latest structured observation +

+ Current: {latestTurn.observation.currentCell.cell} ({latestTurn.observation.currentCell.state}) +

+

Adjacent: {latestTurn.observation.adjacentCells.map(({ cell, state }) => `${cell} (${state})`).join(', ')}

+

Nearby: {latestTurn.observation.nearbyAgents.map(({ name, distance }) => `${name} (${distance})`).join(', ') || 'none'}

+

Recent public events: {latestTurn.observation.recentEvents.length}

+
+
+ ) : ( +

No completed turn for this agent yet.

+ )} +

Recent records

+
    + {turns.slice(-5).toReversed().map((turn) => ( +
  1. + Turn {turn.turnNumber}: {turn.outcome} +
  2. + ))} +
+
+ ); +} + +function EventLog({ turns }: { turns: AgentTurnRecord[] }) { + return ( +
+

World events

+

Event log

+
    + {turns.length === 0 ? ( +
  1. + + Development world loaded with six agents. +
  2. + ) : ( + turns.slice(-20).toReversed().map((turn) => ( +
  3. + + {formatTurn(turn)} +
  4. + )) + )} +
+
+ ); +} + +function formatAction(action: Extract['requestedAction']) { + return action.type === 'move' ? `move → ${action.targetCell}` : action.type; +} + +function formatTurn(turn: AgentTurnRecord) { + if (turn.outcome === 'provider-error') + return `Provider failure · ${turn.failure.message}`; + if (turn.outcome === 'rejected') + return `Rejected ${formatAction(turn.requestedAction)} · ${turn.validation.reason}`; + if (turn.event.type === 'agent-moved') return `Movement · ${turn.event.toCell}`; + if (turn.event.type === 'hex-infected') return `Infection · ${turn.event.cell}`; + return 'Waited'; +} diff --git a/apps/world-lab/src/components/world-map.tsx b/apps/world-lab/src/components/world-map.tsx index a28b727..e5b7cdd 100644 --- a/apps/world-lab/src/components/world-map.tsx +++ b/apps/world-lab/src/components/world-map.tsx @@ -1,25 +1,36 @@ 'use client'; -import { useEffect, useRef } from 'react'; -import { cellToBoundary } from 'h3-js'; +import { useEffect, useRef, useState } from 'react'; +import { cellToBoundary, cellToLatLng } from 'h3-js'; import { AttributionControl, + LngLatBounds, type GeoJSONSource, Map, + Marker, NavigationControl, } from 'maplibre-gl'; -import type { H3Cell, HexState } from '@agentborne/shared'; +import type { + AgentId, + AgentProfile, + H3Cell, + HexState, +} from '@agentborne/shared'; interface WorldMapProps { latitude: number; longitude: number; hexes: Array<{ cell: H3Cell; state: HexState }>; + agents: AgentProfile[]; selectedCell: H3Cell; - onSelect: (cell: H3Cell) => void; + selectedAgentId: AgentId | null; + onSelectCell: (cell: H3Cell) => void; + onSelectAgent: (agentId: AgentId) => void; } const sourceId = 'development-hexes'; const fillLayerId = 'development-hex-fills'; +const lineLayerId = 'development-hex-lines'; function asGeoJson(hexes: WorldMapProps['hexes'], selectedCell: H3Cell) { return { @@ -41,29 +52,37 @@ function closedBoundary(cell: H3Cell): number[][] { return first ? [...coordinates, first] : coordinates; } -export function WorldMap({ - latitude, - longitude, - hexes, - selectedCell, - onSelect, -}: WorldMapProps) { +export function WorldMap(props: WorldMapProps) { + const { + latitude, + longitude, + hexes, + agents, + selectedCell, + selectedAgentId, + onSelectCell, + onSelectAgent, + } = props; const containerRef = useRef(null); const mapRef = useRef(null); - const onSelectRef = useRef(onSelect); + const markersRef = useRef([]); + const onSelectCellRef = useRef(onSelectCell); + const onSelectAgentRef = useRef(onSelectAgent); + const initialHexes = useRef(hexes); const initialSelectedCell = useRef(selectedCell); + const [overlayReady, setOverlayReady] = useState(false); useEffect(() => { - onSelectRef.current = onSelect; - }, [onSelect]); + onSelectCellRef.current = onSelectCell; + onSelectAgentRef.current = onSelectAgent; + }, [onSelectAgent, onSelectCell]); useEffect(() => { if (!containerRef.current || mapRef.current) return; - const map = new Map({ container: containerRef.current, center: [longitude, latitude], - zoom: 14, + zoom: 13, attributionControl: false, style: { version: 8, @@ -87,7 +106,7 @@ export function WorldMap({ map.on('load', () => { map.addSource(sourceId, { type: 'geojson', - data: asGeoJson(hexes, initialSelectedCell.current), + data: asGeoJson(initialHexes.current, initialSelectedCell.current), }); map.addLayer({ id: fillLayerId, @@ -98,21 +117,31 @@ export function WorldMap({ 'match', ['get', 'state'], 'infected', - '#d45749', - '#4f7b75', + '#e44f45', + '#4a8178', ], - 'fill-opacity': ['case', ['get', 'selected'], 0.76, 0.4], - 'fill-outline-color': [ + 'fill-opacity': ['case', ['get', 'selected'], 0.72, 0.38], + }, + }); + map.addLayer({ + id: lineLayerId, + type: 'line', + source: sourceId, + paint: { + 'line-color': [ 'case', ['get', 'selected'], - '#f6e8c9', - '#d8dfd8', + '#fff2c9', + '#b8d4cc', ], + 'line-opacity': 0.95, + 'line-width': ['case', ['get', 'selected'], 4, 1.25], }, }); map.on('click', fillLayerId, (event) => { const cell = event.features?.[0]?.properties?.cell; - if (typeof cell === 'string') onSelectRef.current(cell as H3Cell); + if (typeof cell === 'string') + onSelectCellRef.current(cell as H3Cell); }); map.on('mouseenter', fillLayerId, () => { map.getCanvas().style.cursor = 'pointer'; @@ -120,21 +149,83 @@ export function WorldMap({ map.on('mouseleave', fillLayerId, () => { map.getCanvas().style.cursor = ''; }); + + const bounds = new LngLatBounds(); + for (const { cell } of initialHexes.current) { + for (const [lat, lng] of cellToBoundary(cell)) bounds.extend([lng, lat]); + } + map.fitBounds(bounds, { padding: 56, maxZoom: 14, duration: 0 }); + setOverlayReady(true); }); return () => { + markersRef.current.forEach((marker) => marker.remove()); + markersRef.current = []; map.remove(); mapRef.current = null; }; - }, [hexes, latitude, longitude]); + }, [latitude, longitude]); useEffect(() => { const source = mapRef.current?.getSource(sourceId) as - GeoJSONSource | undefined; + | GeoJSONSource + | undefined; source?.setData(asGeoJson(hexes, selectedCell)); }, [hexes, selectedCell]); + useEffect(() => { + const map = mapRef.current; + if (!map || !overlayReady) return; + markersRef.current.forEach((marker) => marker.remove()); + markersRef.current = []; + + const grouped = new globalThis.Map(); + for (const agent of agents) { + grouped.set(agent.currentCell, [ + ...(grouped.get(agent.currentCell) ?? []), + agent, + ]); + } + for (const agent of agents) { + const cellmates = grouped + .get(agent.currentCell)! + .toSorted((a, b) => a.id.localeCompare(b.id)); + const position = cellmates.findIndex(({ id }) => id === agent.id); + const angle = (position / Math.max(cellmates.length, 1)) * Math.PI * 2; + const distance = cellmates.length > 1 ? 15 : 0; + const element = document.createElement('button'); + element.type = 'button'; + element.className = `agent-marker${agent.id === selectedAgentId ? ' selected' : ''}`; + element.dataset.agentId = agent.id; + element.setAttribute('aria-label', `Select agent ${agent.name}`); + element.title = `${agent.name} · ${agent.currentCell}`; + element.style.setProperty('--agent-color', agent.color); + element.textContent = agent.name.slice(0, 1); + element.addEventListener('click', (event) => { + event.stopPropagation(); + onSelectAgentRef.current(agent.id); + }); + const [lat, lng] = cellToLatLng(agent.currentCell); + const marker = new Marker({ + element, + offset: [Math.cos(angle) * distance, Math.sin(angle) * distance], + }) + .setLngLat([lng, lat]) + .addTo(map); + markersRef.current.push(marker); + } + }, [agents, overlayReady, selectedAgentId]); + return ( -
+
+
+

+ H3 overlay {overlayReady ? 'ready' : 'initializing'} · {hexes.length}{' '} + cells · {agents.length} agents ·{' '} + + {hexes.filter(({ state }) => state === 'infected').length} infected + +

+
); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b5387d2..971fa21 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,30 +1,37 @@ # Architecture -Agentborne begins as a TypeScript monorepo with two deployable applications and three domain packages. The split keeps the world rules independent of frameworks while leaving clean seams for the model-backed work in PR 2. +The PR 2 vertical slice preserves a one-way trust boundary: ```text -World Lab ─────┐ - ├── shared schemas -Game API ──────┤ - ├── world engine ── H3 -Agent runtime ─┘ ▲ - │ structured action request only +World Lab → Game API / simulation service → agent runtime → requested action + ↓ + snapshot / turn record ← world engine validation ``` ## Applications -`apps/world-lab` is a Next.js App Router application. It renders a MapLibre basemap and an H3 GeoJSON overlay. The current development snapshot is created locally so the shell has no service-start dependency. As API-backed state arrives, the UI should consume the same shared response schemas rather than duplicate contracts. +`apps/world-lab` is a Next.js App Router developer/admin surface. It fetches runtime-validated simulation snapshots through a local rewrite, controls one turn at a time, and updates MapLibre's existing H3 GeoJSON source without recreating the map. Agent markers are fully visible and use deterministic offsets when sharing cells. -`apps/game-api` is a Hono service on Node. It currently proves the process and HTTP boundary with health, development-world, not-found, and error shapes. It has no persistence, authentication, scheduler, or player routes. +`apps/game-api` is a Hono service bound conservatively to loopback. Its single in-memory `SimulationService` owns the development session, turn cursor, bounded turn history, and overlap lock. It exposes: -## Packages and flow +- `GET /api/simulation` — current authoritative snapshot +- `POST /api/simulation/turn` — one agent observation, one provider decision, one engine application +- `POST /api/simulation/reset` — deterministic reset, rejected while a turn is active -`packages/shared` owns branded identifiers, action/event schemas, snapshots, and error envelopes. These are runtime Zod schemas first, with TypeScript types inferred from them. +The legacy `GET /api/development-world` and `GET /health` endpoints remain for low-level diagnostics. -`packages/world-engine` validates requested actions and returns a new world state plus either a world event or a machine-readable rejection. It owns consequences; callers and model providers do not. The package has no network, UI, model, or database dependency. +## Turn flow -`packages/agent-runtime` defines structured observations and decisions. A provider receives an immutable observation and returns a requested action plus a concise summary. The included scripted provider is a deterministic test seam, not a simulation and not a substitute for PR 2's genuine model-backed agents. +The development world is an H3 radius-four disk (61 cells) around Toledo with six fixed profiles and starting cells. One agent acts per turn in stable array order. The service reads the latest state, constructs and clones a bounded observation, requests one structured decision, validates it with Zod, and passes only the requested action to the world engine. -## Current state model +The engine alone accepts or rejects actions and creates world events. Rejections do not mutate state and are not replaced by heuristics. Provider failures produce sanitized records, do not mutate the world, advance round robin, and do not prevent later turns. -The in-memory world contains H3 cells (`open` or `infected`), named agents with a current cell, and append-only world events. Infection persists independently from an agent's position. Messages are validated for recipient and range, but inboxes and relationship memory are intentionally deferred to PR 4. +## Packages + +`packages/shared` owns all public schemas: profiles, observations, PR 2 decisions, provider metadata/failures, turn outcomes, snapshots, and API responses. Types are inferred from Zod. + +`packages/world-engine` remains deterministic and has no model, HTTP, UI, storage, or credential dependency. Its broader domain action union retains range-limited messaging for the established boundary, but PR 2's model decision schema excludes messaging entirely. + +`packages/agent-runtime` contains the OpenRouter adapter. The interactive provider uses strict JSON Schema structured output and requires provider parameter support. Scripted providers are explicit deterministic seams selected only by tests or `AGENTBORNE_PROVIDER=scripted`; there is no automatic fallback. + +The rationale and deferrals are recorded in [ADR 0002](adr/0002-first-visible-llm-invasion.md). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 9ac2f89..9748b15 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,20 +1,22 @@ # Security and trust boundaries -## Secrets +## Secrets and deployment -PR 1 requires no secrets. Keep local values in ignored `.env.local` files and document every supported non-secret setting in `.env.example`. Never commit provider keys or expose server credentials through `NEXT_PUBLIC_` variables. Future credentials belong only in server-side provider adapters and the deployment secret store. +`OPENROUTER_API_KEY` is read only by the Game API process. It must never use a `NEXT_PUBLIC_` name, enter a shared schema, reach an API response, or appear in logs. `.env.local` is ignored; `.env.example` contains only a placeholder. Provider errors are reduced to bounded codes and generic messages before recording. + +The development API has no authentication, rate limiting, or spending guard. It binds to loopback and its CORS allowlist is limited to the documented local World Lab origins. Do not deploy its cost-incurring turn endpoint to unauthenticated public traffic. ## Model-provider isolation -A model provider may receive a structured observation and return a schema-validated requested action with a concise decision summary. It must never receive a mutable world object or direct database authority. Only the world engine validates an action and applies consequences; provider output cannot teleport agents, invent state, or declare success. +OpenRouter receives one immutable structured observation. It never receives a mutable world object, storage handle, browser credential, or authority to claim success. The returned JSON is parsed and runtime-validated before the deterministic world engine validates and applies the action. -## Untrusted agent content +The request uses strict JSON Schema structured output and provider routing with `require_parameters: true`. Timeouts, network failures, HTTP failures, malformed JSON, and schema failures produce sanitized provider-failure records. The adapter never silently substitutes scripted behavior. -Agent messages are untrusted input. Validate length and shape, preserve provenance, encode them as data when shown to another model, and never concatenate them into system/developer instructions. UI rendering must escape message text and must not interpret it as HTML. +## Prompt and reasoning data -## Reasoning data +The model is explicitly instructed to return only one structured action and one concise visible summary, with no hidden reasoning or chain-of-thought. The application stores no raw prompts, raw provider payloads, or private reasoning. Inspector and event-log content is rendered as React text. -Do not request, collect, persist, log, or display raw private chain-of-thought. Store only structured observations, structured action requests, concise model-generated decision summaries, validation outcomes, and resulting world events. Logs and inspectors must follow the same rule. +Agent-authored content remains untrusted. Messaging is not exposed to PR 2 providers or UI and remains deferred. ## Reporting diff --git a/docs/TESTING.md b/docs/TESTING.md index 0e1053f..ea25bfc 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,10 +1,10 @@ # Testing -Default validation is deterministic, offline except for dependency/browser installation and the World Lab basemap requested during manual viewing. No test calls a model provider. +Default validation is deterministic and offline except dependency/browser installation and optional basemap requests during browser rendering. No default test or GitHub Actions job contacts OpenRouter. -Coding agents write and update appropriate tests, configure automatic GitHub CI, and provide exact validation commands. The repository owner runs local formatting, lint, type checking, tests, builds, and Playwright unless they explicitly request an agent to run validation in that session. Agents may inspect automatic CI after a branch is pushed, but must not claim local checks passed unless the owner supplied the results. +The repository owner runs local validation. Coding agents write tests and inspect GitHub CI but do not run local formatting, linting, type checking, tests, builds, Playwright, or real-provider calls unless explicitly asked. -## Local sequence +## Owner validation sequence ```bash node --version @@ -19,23 +19,18 @@ pnpm exec playwright install chromium pnpm test:e2e ``` -Playwright starts the World Lab on port 3000 by default. To reuse an existing -development server on another port, run for example -`PLAYWRIGHT_BASE_URL=http://127.0.0.1:3001 pnpm test:e2e`. +`pnpm validate` aggregates formatting, lint, type checking, unit/integration tests, and builds. Playwright remains separate. Its web server starts both applications with the explicit deterministic provider; `AGENTBORNE_PROVIDER=scripted` is never an implicit OpenRouter fallback. -`pnpm validate` aggregates formatting, lint, type checking, unit/integration tests, and builds. Browser smoke coverage remains separate because it installs and starts Chromium. +## Coverage -The World Lab typecheck runs `next typegen` before TypeScript. Next.js manages `apps/world-lab/next-env.d.ts`; the file remains in `tsconfig.json` but is ignored by Git so development, builds, and type generation can recreate it without dirtying the working tree. +- Shared schema tests cover valid/invalid observations and decisions, all turn outcomes, response snapshots, history bounds, and model-authored string limits. +- World-engine tests cover adjacent and non-adjacent movement, infection persistence, repeated-infection rejection, wait, immutability on rejection, and deterministic 61-cell/six-agent construction. +- Agent-runtime tests inspect strict OpenRouter requests, prompt placement, parsing, runtime validation, malformed/unsupported responses, timeouts, sanitization, secret non-leakage, missing configuration, and explicit scripted output. +- Simulation-service tests cover deterministic reset, round robin, exactly one call per turn, latest-state observations, accepted/rejected/failure records, recovery, and turn/reset concurrency. +- API integration tests validate snapshot, turn, reset, missing configuration, provider failure, rejection, and typed conflict responses. +- React tests cover controls/statuses, playback configuration, single turn, reset, agent/hex selection, inspector output, H3 readiness, and safe configuration errors. +- Playwright verifies 61 visible H3 cells are reported ready, six markers, marker/inspector interaction, infection layer data changes, Start/Pause, reset, and the explicit automated-test provider. -## Layers +## Real-provider smoke -- Shared schema unit tests cover the four allowed requested-action forms and reject invented verbs. -- World-engine unit tests cover H3 adjacency, movement validation, infection validation, immutable state transitions, and event production. -- Agent-runtime unit tests cover deterministic scripted-provider output and configuration failure. -- API integration tests call the Hono application in memory and validate health, snapshot, and error responses. -- React Testing Library checks the World Lab shell and hex-selection behavior in jsdom. -- Playwright starts the World Lab automatically and checks the browser-visible map shell, controls reservation, inspector reservation, and event log. - -## External services - -No default test may contact a model, database, map tile service, or game API process. The browser smoke test does not assert tile delivery; MapLibre may request public OpenStreetMap development tiles after the shell loads. Future real-provider tests must be explicitly opted into and must never join the default `pnpm test` or CI path. +The separately opted-in `pnpm smoke:openrouter` command makes one bounded OpenRouter call and validates the decision. It requires an exported `OPENROUTER_API_KEY`, incurs third-party cost, and is intentionally absent from `pnpm test`, `pnpm validate`, Playwright, and CI. diff --git a/docs/adr/0002-first-visible-llm-invasion.md b/docs/adr/0002-first-visible-llm-invasion.md new file mode 100644 index 0000000..c34fb75 --- /dev/null +++ b/docs/adr/0002-first-visible-llm-invasion.md @@ -0,0 +1,22 @@ +# ADR 0002: First visible LLM invasion + +- Status: Accepted +- Date: 2026-08-13 + +## Context + +Roadmap PR 2 needs a browser-observable, genuine model-backed simulation without prematurely adding persistence or autonomous infrastructure. Model output is untrusted and must not own world consequences. + +## Decision + +The Game API owns one in-memory development simulation. It reconstructs a deterministic 61-cell Toledo H3 world with six fixed profiles and starting cells on reset or process startup. + +Turns advance one agent at a time in deterministic round-robin order. The application service builds a fresh immutable observation and calls the provider once. OpenRouter is behind `packages/agent-runtime` and returns a strict structured requested action plus a concise visible summary. Only `packages/world-engine` validates and mutates authoritative state. + +Normal interactive development selects OpenRouter and reports missing configuration or provider failure explicitly. A scripted provider exists only as an explicitly selected deterministic test seam. It is not a fallback and is visibly labeled in the World Lab. + +Turn records contain bounded observations, actions, summaries, validation results, events, and safe provider metadata. They never contain raw prompts, raw payloads, secrets, or private chain-of-thought. + +## Consequences + +The vertical slice is easy to run and inspect, and individual actions remain video-observable. Process restart loses state. There is no autonomous scheduler, database, queue, authentication, public-deployment protection, long-term memory, or replay durability yet. Those concerns remain later-roadmap work. diff --git a/package.json b/package.json index 48da11c..a2ad1d3 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,15 @@ }, "scripts": { "build": "pnpm -r --if-present build", - "dev": "pnpm --filter @agentborne/world-lab dev", + "dev": "pnpm --parallel --filter @agentborne/game-api --filter @agentborne/world-lab dev", + "dev:test-provider": "AGENTBORNE_PROVIDER=scripted pnpm dev", "dev:api": "pnpm --filter @agentborne/game-api dev", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "pnpm -r --if-present lint", "test": "pnpm -r --if-present test", "test:e2e": "playwright test", + "smoke:openrouter": "pnpm --filter @agentborne/agent-runtime smoke:openrouter", "typecheck": "pnpm -r --if-present typecheck", "validate": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build" }, diff --git a/packages/agent-runtime/package.json b/packages/agent-runtime/package.json index 731c23d..894a57c 100644 --- a/packages/agent-runtime/package.json +++ b/packages/agent-runtime/package.json @@ -9,14 +9,17 @@ "scripts": { "build": "tsc -p tsconfig.json", "lint": "eslint .", + "smoke:openrouter": "tsx src/real-provider-smoke.ts", "test": "vitest run", "typecheck": "tsc -p tsconfig.json" }, "dependencies": { "@agentborne/shared": "workspace:*", + "h3-js": "4.5.0", "zod": "4.4.3" }, "devDependencies": { + "tsx": "4.23.12", "vitest": "4.1.10" } } diff --git a/packages/agent-runtime/src/index.test.ts b/packages/agent-runtime/src/index.test.ts index 708ee51..f9c312b 100644 --- a/packages/agent-runtime/src/index.test.ts +++ b/packages/agent-runtime/src/index.test.ts @@ -1,39 +1,144 @@ -import { describe, expect, it } from 'vitest'; -import { agentIdSchema, h3CellSchema } from '@agentborne/shared'; -import { ScriptedAgentProvider } from '.'; - -const observation = { - agentId: agentIdSchema.parse('ca0e2b4d-d88f-4c9e-a401-a7b740c6e5af'), - currentCell: h3CellSchema.parse('892a1072893ffff'), - adjacentCells: [], - nearbyAgentIds: [], - recentMessages: [], -}; +import { describe, expect, it, vi } from 'vitest'; +import { agentObservationSchema } from '@agentborne/shared'; +import { + AgentProviderError, + OpenRouterAgentProvider, + ScriptedAgentProvider, + buildOpenRouterRequest, +} from '.'; + +const observation = agentObservationSchema.parse({ + agentId: '128f3f38-6b7d-4db7-9e95-751b4ce2681e', + agentName: 'Ember', + personality: 'Aggressively infect open cells.', + currentCell: { cell: '892a1072893ffff', state: 'open' }, + adjacentCells: [{ cell: '892a1072883ffff', state: 'open' }], + nearbyAgents: [], + recentEvents: [], +}); + +function response(content: string, status = 200) { + return new Response( + JSON.stringify({ + id: 'request-safe-id', + model: 'openai/gpt-5-mini', + choices: [{ message: { content } }], + usage: { prompt_tokens: 20, completion_tokens: 12 }, + }), + { status, headers: { 'Content-Type': 'application/json' } }, + ); +} + +describe('OpenRouterAgentProvider', () => { + it('constructs a strict structured-output request with personality and observation data', () => { + const request = buildOpenRouterRequest(observation); + expect(request.response_format).toMatchObject({ + type: 'json_schema', + json_schema: { strict: true }, + }); + expect(request.provider).toEqual({ require_parameters: true }); + expect(request.messages[1].content).toContain(observation.personality); + expect(request.messages[0].content).toContain('Never produce messages'); + expect(request.max_tokens).toBeLessThanOrEqual(200); + }); + + it('parses and runtime-validates a structured decision', async () => { + const fetchImplementation = vi.fn(async () => + response(JSON.stringify({ requestedAction: { type: 'infect' }, summary: 'Claiming this cell.' })), + ); + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + fetchImplementation, + }); + await expect(provider.decide(observation)).resolves.toMatchObject({ + decision: { requestedAction: { type: 'infect' } }, + metadata: { provider: 'openrouter' }, + }); + const init = fetchImplementation.mock.calls[0]?.[1]; + expect(JSON.parse(String(init?.body))).toMatchObject({ + provider: { require_parameters: true }, + }); + }); + + it.each([ + ['not-json', 'malformed-response'], + [JSON.stringify({ requestedAction: { type: 'teleport' }, summary: 'No.' }), 'unsupported-response'], + [JSON.stringify({ requestedAction: { type: 'wait' }, summary: 'x'.repeat(241) }), 'unsupported-response'], + ])('rejects invalid provider content without fallback', async (content, code) => { + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + fetchImplementation: vi.fn(async () => response(content)), + }); + await expect(provider.decide(observation)).rejects.toMatchObject({ + failure: { code }, + }); + }); + + it('sanitizes HTTP and network errors without leaking the key', async () => { + const key = 'highly-sensitive-key'; + const httpProvider = new OpenRouterAgentProvider({ + apiKey: key, + fetchImplementation: vi.fn(async () => response('{}', 500)), + }); + const networkProvider = new OpenRouterAgentProvider({ + apiKey: key, + fetchImplementation: vi.fn(async () => { + throw new Error(`socket failed with ${key}`); + }), + }); + for (const provider of [httpProvider, networkProvider]) { + try { + await provider.decide(observation); + } catch (error) { + expect(error).toBeInstanceOf(AgentProviderError); + expect(String(error)).not.toContain(key); + expect(JSON.stringify(error)).not.toContain(key); + } + } + }); + + it('times out a bounded request', async () => { + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + timeoutMs: 1, + fetchImplementation: vi.fn( + async (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }), + ), + }); + await expect(provider.decide(observation)).rejects.toMatchObject({ + failure: { code: 'timeout' }, + }); + }); + + it('reports missing configuration instead of using a heuristic fallback', async () => { + const provider = new OpenRouterAgentProvider(); + await expect(provider.decide(observation)).rejects.toMatchObject({ + failure: { code: 'configuration' }, + }); + }); +}); describe('ScriptedAgentProvider', () => { - it('returns validated decisions in order without a mutable world dependency', async () => { + it('returns explicitly scripted decisions in order', async () => { const provider = new ScriptedAgentProvider([ - { - requestedAction: { type: 'wait' }, - summary: 'Staying still to observe.', - }, - { - requestedAction: { type: 'infect' }, - summary: 'Marking the current cell.', - }, + { requestedAction: { type: 'wait' }, summary: 'Staying still.' }, + { requestedAction: { type: 'infect' }, summary: 'Infecting.' }, ]); - await expect(provider.decide(observation)).resolves.toMatchObject({ - requestedAction: { type: 'wait' }, + decision: { requestedAction: { type: 'wait' } }, + metadata: { provider: 'scripted-test' }, }); await expect(provider.decide(observation)).resolves.toMatchObject({ - requestedAction: { type: 'infect' }, + decision: { requestedAction: { type: 'infect' } }, }); }); it('rejects an empty script', () => { - expect(() => new ScriptedAgentProvider([])).toThrow( - /at least one decision/, - ); + expect(() => new ScriptedAgentProvider([])).toThrow(/at least one decision/); }); }); diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index b03b32a..74ab5f9 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -1,44 +1,290 @@ import { z } from 'zod'; import { - agentIdSchema, - h3CellSchema, - requestedActionSchema, - type RequestedAction, + agentDecisionSchema, + agentObservationSchema, + type AgentDecision, + type AgentObservation, + type ProviderFailure, + type ProviderMetadata, } from '@agentborne/shared'; -export const agentObservationSchema = z.object({ - agentId: agentIdSchema, - currentCell: h3CellSchema, - adjacentCells: z.array(h3CellSchema), - nearbyAgentIds: z.array(agentIdSchema), - recentMessages: z.array(z.string().max(1_000)), -}); -export type AgentObservation = z.infer; +export const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-5-mini'; +export const OPENROUTER_ENDPOINT = + 'https://openrouter.ai/api/v1/chat/completions'; -export const agentDecisionSchema = z.object({ - requestedAction: requestedActionSchema, - summary: z.string().trim().min(1).max(500), -}); -export type AgentDecision = z.infer; +export interface ProviderDecision { + decision: AgentDecision; + metadata: ProviderMetadata; +} -/** - * Providers receive structured observations and return a requested action. - * They never receive a mutable world handle and cannot apply consequences. - */ +/** Providers receive immutable data and never receive a world handle. */ export interface AgentProvider { - decide(observation: AgentObservation): Promise; + readonly mode: 'openrouter' | 'scripted-test'; + readonly model: string; + readonly configured: boolean; + decide(observation: AgentObservation): Promise; +} + +export class AgentProviderError extends Error { + constructor( + readonly failure: ProviderFailure, + readonly metadata?: ProviderMetadata, + ) { + super(failure.message); + this.name = 'AgentProviderError'; + } +} + +const decisionJsonSchema = { + type: 'object', + additionalProperties: false, + properties: { + requestedAction: { + oneOf: [ + { + type: 'object', + additionalProperties: false, + properties: { + type: { const: 'move' }, + targetCell: { + type: 'string', + pattern: '^[0-9a-fA-F]{15}$', + description: + 'One target H3 index copied exactly from adjacentCells.', + }, + }, + required: ['type', 'targetCell'], + }, + { + type: 'object', + additionalProperties: false, + properties: { type: { const: 'infect' } }, + required: ['type'], + }, + { + type: 'object', + additionalProperties: false, + properties: { type: { const: 'wait' } }, + required: ['type'], + }, + ], + }, + summary: { + type: 'string', + minLength: 1, + maxLength: 240, + description: + 'A concise user-visible decision summary, not hidden reasoning.', + }, + }, + required: ['requestedAction', 'summary'], +} as const; + +export function buildOpenRouterRequest( + observationInput: AgentObservation, + model = DEFAULT_OPENROUTER_MODEL, +) { + const observation = agentObservationSchema.parse(observationInput); + return { + model, + messages: [ + { + role: 'system' as const, + content: + 'You control one map agent. Choose exactly one permitted action from the supplied observation. Follow the fixed personality as behavioral guidance. A move target must be copied from adjacentCells. Never produce messages, private chain-of-thought, hidden reasoning, analysis, or extra fields. Return only the strict structured decision and one concise user-visible summary.', + }, + { + role: 'user' as const, + content: JSON.stringify({ + purpose: 'Choose the next action from this immutable observation.', + observation, + }), + }, + ], + response_format: { + type: 'json_schema' as const, + json_schema: { + name: 'agentborne_agent_decision', + strict: true, + schema: decisionJsonSchema, + }, + }, + provider: { require_parameters: true }, + max_tokens: 180, + stream: false, + }; } -export interface ScriptedDecision { - requestedAction: RequestedAction; - summary: string; +const openRouterResponseSchema = z.object({ + id: z.string().optional(), + model: z.string().optional(), + choices: z.array( + z.object({ + message: z.object({ content: z.string().nullable() }), + }), + ), + usage: z + .object({ + prompt_tokens: z.number().int().nonnegative().optional(), + completion_tokens: z.number().int().nonnegative().optional(), + }) + .optional(), +}); + +export interface OpenRouterProviderOptions { + apiKey?: string; + model?: string; + timeoutMs?: number; + fetchImplementation?: typeof fetch; +} + +export class OpenRouterAgentProvider implements AgentProvider { + readonly mode = 'openrouter' as const; + readonly model: string; + readonly configured: boolean; + readonly #apiKey?: string; + readonly #timeoutMs: number; + readonly #fetch: typeof fetch; + + constructor({ + apiKey, + model = DEFAULT_OPENROUTER_MODEL, + timeoutMs = 15_000, + fetchImplementation = fetch, + }: OpenRouterProviderOptions = {}) { + this.#apiKey = apiKey?.trim() || undefined; + this.model = model.trim() || DEFAULT_OPENROUTER_MODEL; + this.#timeoutMs = timeoutMs; + this.#fetch = fetchImplementation; + this.configured = Boolean(this.#apiKey); + } + + async decide(observation: AgentObservation): Promise { + const started = Date.now(); + if (!this.#apiKey) { + throw new AgentProviderError({ + code: 'configuration', + message: + 'OpenRouter is unavailable. Set OPENROUTER_API_KEY on the Game API server.', + retryable: false, + }); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.#timeoutMs); + let response: Response; + try { + response = await this.#fetch(OPENROUTER_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.#apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildOpenRouterRequest(observation, this.model)), + signal: controller.signal, + }); + } catch { + const timedOut = controller.signal.aborted; + throw new AgentProviderError({ + code: timedOut ? 'timeout' : 'network', + message: timedOut + ? 'The model request timed out.' + : 'The model provider could not be reached.', + retryable: true, + }); + } finally { + clearTimeout(timeout); + } + + const latencyMs = Date.now() - started; + if (!response.ok) { + throw new AgentProviderError( + { + code: 'provider-http', + message: `The model provider returned HTTP ${response.status}.`, + retryable: response.status === 429 || response.status >= 500, + }, + { + provider: 'openrouter', + model: this.model, + requestId: response.headers.get('x-request-id') ?? undefined, + latencyMs, + }, + ); + } + + let raw: unknown; + try { + raw = await response.json(); + } catch { + throw new AgentProviderError({ + code: 'malformed-response', + message: 'The model provider returned malformed JSON.', + retryable: true, + }); + } + + const parsedResponse = openRouterResponseSchema.safeParse(raw); + if (!parsedResponse.success) { + throw new AgentProviderError({ + code: 'unsupported-response', + message: 'The model provider returned an unsupported response shape.', + retryable: true, + }); + } + const content = parsedResponse.data.choices[0]?.message.content; + if (!content) { + throw new AgentProviderError({ + code: 'unsupported-response', + message: 'The model provider returned no structured decision.', + retryable: true, + }); + } + + let decisionInput: unknown; + try { + decisionInput = JSON.parse(content); + } catch { + throw new AgentProviderError({ + code: 'malformed-response', + message: 'The model decision was not valid JSON.', + retryable: true, + }); + } + const decision = agentDecisionSchema.safeParse(decisionInput); + if (!decision.success) { + throw new AgentProviderError({ + code: 'unsupported-response', + message: 'The model decision failed runtime schema validation.', + retryable: true, + }); + } + + return { + decision: decision.data, + metadata: { + provider: 'openrouter', + model: parsedResponse.data.model ?? this.model, + requestId: + parsedResponse.data.id ?? + response.headers.get('x-request-id') ?? + undefined, + latencyMs, + promptTokens: parsedResponse.data.usage?.prompt_tokens, + completionTokens: parsedResponse.data.usage?.completion_tokens, + }, + }; + } } export class ScriptedAgentProvider implements AgentProvider { - readonly #decisions: ScriptedDecision[]; + readonly mode = 'scripted-test' as const; + readonly model = 'deterministic-script'; + readonly configured = true; + readonly #decisions: AgentDecision[]; #cursor = 0; - constructor(decisions: ScriptedDecision[]) { + constructor(decisions: AgentDecision[]) { if (decisions.length === 0) { throw new Error('ScriptedAgentProvider requires at least one decision.'); } @@ -47,12 +293,56 @@ export class ScriptedAgentProvider implements AgentProvider { ); } - async decide(observation: AgentObservation): Promise { + async decide(observation: AgentObservation): Promise { agentObservationSchema.parse(observation); const decision = this.#decisions[this.#cursor]; - if (!decision) - throw new Error('ScriptedAgentProvider has no decisions remaining.'); + if (!decision) { + throw new AgentProviderError({ + code: 'unsupported-response', + message: 'The deterministic test script has no decision remaining.', + retryable: false, + }); + } this.#cursor += 1; - return structuredClone(decision); + return { + decision: structuredClone(decision), + metadata: { + provider: 'scripted-test', + model: this.model, + latencyMs: 0, + }, + }; + } +} + +/** Explicit deterministic browser/CI provider; never selected by fallback. */ +export class BrowserTestAgentProvider implements AgentProvider { + readonly mode = 'scripted-test' as const; + readonly model = 'deterministic-browser-script'; + readonly configured = true; + + async decide(observationInput: AgentObservation): Promise { + const observation = agentObservationSchema.parse(observationInput); + const requestedAction = + observation.currentCell.state === 'open' + ? ({ type: 'infect' } as const) + : ({ + type: 'move', + targetCell: observation.adjacentCells[0]!.cell, + } as const); + return { + decision: { + requestedAction, + summary: + requestedAction.type === 'infect' + ? 'Infecting this open cell.' + : 'Moving to the first adjacent cell in the test script.', + }, + metadata: { + provider: 'scripted-test', + model: this.model, + latencyMs: 0, + }, + }; } } diff --git a/packages/agent-runtime/src/real-provider-smoke.ts b/packages/agent-runtime/src/real-provider-smoke.ts new file mode 100644 index 0000000..2408aef --- /dev/null +++ b/packages/agent-runtime/src/real-provider-smoke.ts @@ -0,0 +1,40 @@ +import { latLngToCell, gridDisk } from 'h3-js'; +import { + agentIdSchema, + agentObservationSchema, + h3CellSchema, +} from '@agentborne/shared'; +import { OpenRouterAgentProvider } from './index'; + +const currentCell = h3CellSchema.parse(latLngToCell(41.6528, -83.5379, 9)); +const adjacentCells = gridDisk(currentCell, 1) + .filter((cell) => cell !== currentCell) + .map((cell) => ({ cell: h3CellSchema.parse(cell), state: 'open' as const })); + +const provider = new OpenRouterAgentProvider({ + apiKey: process.env.OPENROUTER_API_KEY, + model: process.env.AGENTBORNE_MODEL, +}); +const observation = agentObservationSchema.parse({ + agentId: agentIdSchema.parse('128f3f38-6b7d-4db7-9e95-751b4ce2681e'), + agentName: 'Ember', + personality: 'Prefer infecting open cells and moving into uninfected space.', + currentCell: { cell: currentCell, state: 'open' }, + adjacentCells, + nearbyAgents: [], + recentEvents: [], +}); + +const result = await provider.decide(observation); +console.log( + JSON.stringify( + { + valid: true, + decision: result.decision, + provider: result.metadata.provider, + model: result.metadata.model, + }, + null, + 2, + ), +); diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 868904a..385291a 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -1,25 +1,131 @@ import { describe, expect, it } from 'vitest'; -import { requestedActionSchema } from './index'; +import { + MODEL_SUMMARY_MAX_LENGTH, + agentDecisionSchema, + agentObservationSchema, + agentTurnRecordSchema, + simulationSnapshotSchema, +} from '.'; + +const agentId = '128f3f38-6b7d-4db7-9e95-751b4ce2681e'; +const cell = '892a1072893ffff'; +const adjacent = '892a1072883ffff'; +const observation = { + agentId, + agentName: 'Ember', + personality: 'Prefer infection.', + currentCell: { cell, state: 'open' }, + adjacentCells: [{ cell: adjacent, state: 'open' }], + nearbyAgents: [], + recentEvents: [], +}; +const baseTurn = { + turnNumber: 1, + agentId, + startedAt: '2026-08-13T12:00:00.000Z', + completedAt: '2026-08-13T12:00:01.000Z', + observation, +}; +const provider = { + provider: 'openrouter', + model: 'openai/gpt-5-mini', + latencyMs: 100, +}; + +describe('agent observation and decision schemas', () => { + it('accepts a bounded state-bearing observation', () => { + expect(agentObservationSchema.parse(observation).currentCell.state).toBe( + 'open', + ); + }); + + it.each([ + { ...observation, adjacentCells: [] }, + { ...observation, currentCell: { cell, state: 'unknown' } }, + { ...observation, nearbyAgents: Array(6).fill({ id: agentId, name: 'x', currentCell: cell, distance: 1 }) }, + ])('rejects invalid or oversized observations', (value) => { + expect(agentObservationSchema.safeParse(value).success).toBe(false); + }); + + it.each([ + { requestedAction: { type: 'move', targetCell: adjacent }, summary: 'Move.' }, + { requestedAction: { type: 'infect' }, summary: 'Infect.' }, + { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + ])('accepts a PR 2 decision', (decision) => { + expect(agentDecisionSchema.safeParse(decision).success).toBe(true); + }); -describe('requestedActionSchema', () => { it.each([ - { type: 'move', targetCell: '892a1072893ffff' }, - { type: 'infect' }, + { requestedAction: { type: 'teleport', targetCell: adjacent }, summary: 'No.' }, + { requestedAction: { type: 'message', recipientId: agentId, message: 'No.' }, summary: 'No.' }, + { requestedAction: { type: 'wait' }, summary: 'x'.repeat(MODEL_SUMMARY_MAX_LENGTH + 1) }, + ])('rejects forbidden actions and oversized model text', (decision) => { + expect(agentDecisionSchema.safeParse(decision).success).toBe(false); + }); +}); + +describe('turn and snapshot schemas', () => { + it.each([ + { + ...baseTurn, + outcome: 'accepted', + requestedAction: { type: 'infect' }, + summary: 'Infect.', + validation: { accepted: true }, + event: { + id: '67aa21b9-fc78-4b04-9f92-9862bf346f96', + agentId, + occurredAt: '2026-08-13T12:00:01.000Z', + type: 'hex-infected', + cell, + }, + provider, + }, + { + ...baseTurn, + outcome: 'rejected', + requestedAction: { type: 'move', targetCell: adjacent }, + summary: 'Move.', + validation: { accepted: false, reason: 'not-adjacent', details: 'No.' }, + provider, + }, { - type: 'message', - recipientId: 'ca0e2b4d-d88f-4c9e-a401-a7b740c6e5af', - message: 'Meet me at the edge.', + ...baseTurn, + outcome: 'provider-error', + failure: { code: 'timeout', message: 'Timed out.', retryable: true }, }, - { type: 'wait' }, - ])('accepts a structured $type action', (action) => { - expect(requestedActionSchema.safeParse(action).success).toBe(true); + ])('validates $outcome turn records', (turn) => { + expect(agentTurnRecordSchema.safeParse(turn).success).toBe(true); }); - it('rejects invented action verbs', () => { + it('validates a complete API snapshot and rejects unbounded history', () => { + const worldAgent = { + id: agentId, + name: 'Ember', + color: '#ff6b57', + personality: 'Prefer infection.', + currentCell: cell, + }; + const snapshot = { + world: { + generatedAt: '2026-08-13T12:00:00.000Z', + hexes: [{ cell, state: 'open' }], + agents: [worldAgent], + events: [], + }, + turnNumber: 0, + nextAgentId: agentId, + activeAgentId: null, + status: 'paused', + providerMode: 'openrouter', + providerConfigured: true, + turns: [], + }; + expect(simulationSnapshotSchema.safeParse(snapshot).success).toBe(true); expect( - requestedActionSchema.safeParse({ - type: 'teleport', - targetCell: '892a1072893ffff', + simulationSnapshotSchema.safeParse({ + ...snapshot, + turns: Array(121).fill({}), }).success, ).toBe(false); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ebc77f9..d67f435 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; +export const MODEL_SUMMARY_MAX_LENGTH = 240; +export const PERSONALITY_MAX_LENGTH = 600; +export const PROVIDER_ERROR_MAX_LENGTH = 240; + export const agentIdSchema = z.uuid().brand<'AgentId'>(); export type AgentId = z.infer; @@ -15,32 +19,31 @@ export type H3Cell = z.infer; export const hexStateSchema = z.enum(['open', 'infected']); export type HexState = z.infer; -export const agentSchema = z.object({ +export const agentProfileSchema = z.object({ id: agentIdSchema, name: z.string().trim().min(1).max(80), + color: z.string().regex(/^#[0-9a-f]{6}$/i), + personality: z.string().trim().min(1).max(PERSONALITY_MAX_LENGTH), currentCell: h3CellSchema, }); +export type AgentProfile = z.infer; + +export const agentSchema = agentProfileSchema; export type Agent = z.infer; export const moveActionSchema = z.object({ type: z.literal('move'), targetCell: h3CellSchema, }); - -export const infectActionSchema = z.object({ - type: z.literal('infect'), -}); - +export const infectActionSchema = z.object({ type: z.literal('infect') }); export const messageActionSchema = z.object({ type: z.literal('message'), recipientId: agentIdSchema, message: z.string().trim().min(1).max(1_000), }); +export const waitActionSchema = z.object({ type: z.literal('wait') }); -export const waitActionSchema = z.object({ - type: z.literal('wait'), -}); - +/** All engine actions. Messaging remains dormant until Roadmap PR 4. */ export const requestedActionSchema = z.discriminatedUnion('type', [ moveActionSchema, infectActionSchema, @@ -49,6 +52,14 @@ export const requestedActionSchema = z.discriminatedUnion('type', [ ]); export type RequestedAction = z.infer; +/** The deliberately smaller action surface available to PR 2 models. */ +export const agentMapActionSchema = z.discriminatedUnion('type', [ + moveActionSchema, + infectActionSchema, + waitActionSchema, +]); +export type AgentMapAction = z.infer; + const worldEventBaseSchema = z.object({ id: eventIdSchema, agentId: agentIdSchema, @@ -87,14 +98,11 @@ export const invalidActionReasonSchema = z.enum([ export type InvalidActionReason = z.infer; export const actionResultSchema = z.discriminatedUnion('accepted', [ - z.object({ - accepted: z.literal(true), - event: worldEventSchema, - }), + z.object({ accepted: z.literal(true), event: worldEventSchema }), z.object({ accepted: z.literal(false), reason: invalidActionReasonSchema, - details: z.string().min(1), + details: z.string().min(1).max(300), }), ]); export type ActionResult = z.infer; @@ -107,10 +115,147 @@ export const worldSnapshotSchema = z.object({ }); export type WorldSnapshot = z.infer; +export const cellObservationSchema = z.object({ + cell: h3CellSchema, + state: hexStateSchema, +}); +export type CellObservation = z.infer; + +export const nearbyAgentObservationSchema = z.object({ + id: agentIdSchema, + name: z.string().trim().min(1).max(80), + currentCell: h3CellSchema, + distance: z.number().int().min(0).max(4), +}); + +export const publicEventObservationSchema = z.object({ + type: z.enum(['agent-moved', 'hex-infected', 'agent-waited']), + agentId: agentIdSchema, + occurredAt: z.iso.datetime(), + summary: z.string().trim().min(1).max(180), +}); + +export const agentObservationSchema = z.object({ + agentId: agentIdSchema, + agentName: z.string().trim().min(1).max(80), + personality: z.string().trim().min(1).max(PERSONALITY_MAX_LENGTH), + currentCell: cellObservationSchema, + adjacentCells: z.array(cellObservationSchema).min(1).max(6), + nearbyAgents: z.array(nearbyAgentObservationSchema).max(5), + recentEvents: z.array(publicEventObservationSchema).max(8), +}); +export type AgentObservation = z.infer; + +export const agentDecisionSchema = z.object({ + requestedAction: agentMapActionSchema, + summary: z.string().trim().min(1).max(MODEL_SUMMARY_MAX_LENGTH), +}); +export type AgentDecision = z.infer; + +export const providerModeSchema = z.enum(['openrouter', 'scripted-test']); +export type ProviderMode = z.infer; + +export const providerMetadataSchema = z.object({ + provider: providerModeSchema, + model: z.string().trim().min(1).max(120), + requestId: z.string().trim().min(1).max(160).optional(), + latencyMs: z.number().int().nonnegative().max(300_000), + promptTokens: z.number().int().nonnegative().optional(), + completionTokens: z.number().int().nonnegative().optional(), +}); +export type ProviderMetadata = z.infer; + +export const providerFailureSchema = z.object({ + code: z.enum([ + 'configuration', + 'timeout', + 'network', + 'provider-http', + 'malformed-response', + 'unsupported-response', + ]), + message: z.string().trim().min(1).max(PROVIDER_ERROR_MAX_LENGTH), + retryable: z.boolean(), +}); +export type ProviderFailure = z.infer; + +const turnRecordBaseSchema = z.object({ + turnNumber: z.number().int().positive(), + agentId: agentIdSchema, + startedAt: z.iso.datetime(), + completedAt: z.iso.datetime(), + observation: agentObservationSchema, +}); + +const completedTurnFields = { + requestedAction: agentMapActionSchema, + summary: z.string().trim().min(1).max(MODEL_SUMMARY_MAX_LENGTH), + provider: providerMetadataSchema, +}; + +export const agentTurnRecordSchema = z.discriminatedUnion('outcome', [ + turnRecordBaseSchema.extend({ + outcome: z.literal('accepted'), + ...completedTurnFields, + validation: z.object({ accepted: z.literal(true) }), + event: worldEventSchema, + }), + turnRecordBaseSchema.extend({ + outcome: z.literal('rejected'), + ...completedTurnFields, + validation: z.object({ + accepted: z.literal(false), + reason: invalidActionReasonSchema, + details: z.string().min(1).max(300), + }), + }), + turnRecordBaseSchema.extend({ + outcome: z.literal('provider-error'), + failure: providerFailureSchema, + provider: providerMetadataSchema.optional(), + }), +]); +export type AgentTurnRecord = z.infer; + +export const simulationStatusSchema = z.enum([ + 'paused', + 'running', + 'waiting-for-model', + 'resetting', + 'configuration-error', + 'provider-error', +]); +export type SimulationStatus = z.infer; + +export const simulationSnapshotSchema = z.object({ + world: worldSnapshotSchema, + turnNumber: z.number().int().nonnegative(), + nextAgentId: agentIdSchema, + activeAgentId: agentIdSchema.nullable(), + status: simulationStatusSchema, + providerMode: providerModeSchema, + providerConfigured: z.boolean(), + turns: z.array(agentTurnRecordSchema).max(120), +}); +export type SimulationSnapshot = z.infer; + +export const singleTurnResponseSchema = z.object({ + snapshot: simulationSnapshotSchema, + turn: agentTurnRecordSchema, +}); +export type SingleTurnResponse = z.infer; + +export const resetSimulationResponseSchema = z.object({ + snapshot: simulationSnapshotSchema, +}); +export type ResetSimulationResponse = z.infer< + typeof resetSimulationResponseSchema +>; + export const apiErrorSchema = z.object({ error: z.object({ - code: z.string().min(1), - message: z.string().min(1), + code: z.string().min(1).max(80), + message: z.string().min(1).max(300), }), }); export type ApiError = z.infer; diff --git a/packages/world-engine/src/index.test.ts b/packages/world-engine/src/index.test.ts index a677b38..34abdb5 100644 --- a/packages/world-engine/src/index.test.ts +++ b/packages/world-engine/src/index.test.ts @@ -14,7 +14,13 @@ const adjacent = h3CellSchema.parse(gridDisk(center, 1)[1]); const distant = h3CellSchema.parse( gridDisk(center, 2).find((cell) => gridDistance(center, cell) === 2), ); -const agent: Agent = { id: agentId, name: 'Morrow', currentCell: center }; +const agent: Agent = { + id: agentId, + name: 'Morrow', + color: '#ff6b57', + personality: 'Moves deliberately.', + currentCell: center, +}; const context = { createEventId: () => '67aa21b9-fc78-4b04-9f92-9862bf346f96', now: () => '2026-08-13T12:00:00.000Z', @@ -86,15 +92,59 @@ describe('infection', () => { }); it('rejects repeated infection', () => { - const result = applyRequestedAction( + const infected = applyRequestedAction( stateWithAgent(), agentId, { type: 'infect' }, context, ); + const result = applyRequestedAction( + infected.state, + agentId, + { type: 'infect' }, + context, + ); expect(result.result).toMatchObject({ accepted: false, reason: 'already-infected', }); }); + + it('persists infection after the agent moves away', () => { + const before = stateWithAgent(); + const infected = applyRequestedAction( + before, + agentId, + { type: 'infect' }, + context, + ); + const moved = applyRequestedAction( + infected.state, + agentId, + { type: 'move', targetCell: adjacent }, + context, + ); + expect(moved.state.hexes.get(center)).toBe('infected'); + expect(moved.state.agents.get(agentId)?.currentCell).toBe(adjacent); + }); +}); + +describe('wait and deterministic development world', () => { + it('records a wait without changing cells or hex states', () => { + const before = stateWithAgent(); + const result = applyRequestedAction(before, agentId, { type: 'wait' }, context); + expect(result.result).toMatchObject({ accepted: true, event: { type: 'agent-waited' } }); + expect(result.state.hexes).toBe(before.hexes); + expect(result.state.agents).toBe(before.agents); + }); + + it('constructs the same 61 cells and six valid named agents', () => { + const first = createDevelopmentWorld({ generatedAt: context.now() }); + const second = createDevelopmentWorld({ generatedAt: context.now() }); + expect(first).toEqual(second); + expect(first.hexes).toHaveLength(61); + expect(first.agents).toHaveLength(6); + expect(new Set(first.agents.map(({ id }) => id)).size).toBe(6); + expect(first.agents.every(({ currentCell }) => first.hexes.some(({ cell }) => cell === currentCell))).toBe(true); + }); }); diff --git a/packages/world-engine/src/index.ts b/packages/world-engine/src/index.ts index ced043a..c0d7411 100644 --- a/packages/world-engine/src/index.ts +++ b/packages/world-engine/src/index.ts @@ -186,26 +186,80 @@ export interface DevelopmentWorldOptions { latitude?: number; longitude?: number; resolution?: number; + radius?: number; generatedAt?: string; } +export const DEVELOPMENT_AGENT_BLUEPRINTS = [ + { + id: '128f3f38-6b7d-4db7-9e95-751b4ce2681e', + name: 'Ember', + color: '#ff6b57', + personality: + 'You are an aggressive infector. Prefer infecting open cells and move decisively toward uninfected space when your current cell is already infected.', + }, + { + id: '2507bb46-7ae4-45ca-8dda-644c4f85ca14', + name: 'Rook', + color: '#ffd166', + personality: + 'You are a restless wanderer. Prefer movement and variety, rarely waiting unless no move seems worthwhile.', + }, + { + id: '3ba3ef0b-2142-44cc-b175-f6e5d6e98df5', + name: 'Mingle', + color: '#63d2ff', + personality: + 'You are curious about other agents. Move toward visible agents and linger near activity, while infecting opportunistically.', + }, + { + id: '442a1667-39c8-48e9-8c89-23803f9e2101', + name: 'Solace', + color: '#c59cff', + personality: + 'You value solitude. Move away from nearby agents, seek quiet cells, and infect only when it helps claim isolated space.', + }, + { + id: '5f812a08-05f2-4950-bf2d-4df59d05e9c2', + name: 'Verge', + color: '#6ee7a8', + personality: + 'You are an edge-seeking explorer. Push toward cells with fewer onward options and favor expanding activity along the world boundary.', + }, + { + id: '67a43b5c-ced8-45bd-970f-a89ac57853fc', + name: 'Jinx', + color: '#ff91c8', + personality: + 'You are an unpredictable opportunist. Mix movement, infection, and occasional waiting based on whatever detail in the observation catches your attention.', + }, +] as const; + export function createDevelopmentWorld({ latitude = 41.6528, longitude = -83.5379, resolution = 9, + radius = 4, generatedAt = new Date().toISOString(), }: DevelopmentWorldOptions = {}): WorldSnapshot { const center = h3CellSchema.parse( latLngToCell(latitude, longitude, resolution), ); - const cells = gridDisk(center, 2).map((cell) => h3CellSchema.parse(cell)); + const cells = gridDisk(center, radius).map((cell) => + h3CellSchema.parse(cell), + ); + const startingIndexes = [0, 10, 20, 30, 40, 50]; return { generatedAt, - hexes: cells.map((cell, index) => ({ + hexes: cells.map((cell) => ({ cell, - state: index === 0 || index === 4 ? 'infected' : 'open', + state: 'open' as const, + })), + agents: DEVELOPMENT_AGENT_BLUEPRINTS.map((profile, index) => ({ + ...profile, + id: agentIdSchema.parse(profile.id), + currentCell: cells[startingIndexes[index]!]!, })), - agents: [], events: [], }; } diff --git a/playwright.config.ts b/playwright.config.ts index 6939937..9659cd7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ trace: 'on-first-retry', }, webServer: { - command: 'pnpm --filter @agentborne/world-lab dev', + command: 'pnpm dev:test-provider', url: baseURL, reuseExistingServer: !process.env.CI, timeout: 120_000, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f65c589..d714346 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: apps/game-api: dependencies: + '@agentborne/agent-runtime': + specifier: workspace:* + version: link:../../packages/agent-runtime '@agentborne/shared': specifier: workspace:* version: link:../../packages/shared @@ -37,6 +40,9 @@ importers: '@hono/node-server': specifier: 2.1.0 version: 2.1.0(hono@4.13.2) + h3-js: + specifier: 4.5.0 + version: 4.5.0 hono: specifier: 4.13.2 version: 4.13.2 @@ -108,10 +114,16 @@ importers: '@agentborne/shared': specifier: workspace:* version: link:../shared + h3-js: + specifier: 4.5.0 + version: 4.5.0 zod: specifier: 4.4.3 version: 4.4.3 devDependencies: + tsx: + specifier: 4.23.12 + version: 4.23.12 vitest: specifier: 4.1.10 version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(tsx@4.23.12)) diff --git a/tests/e2e/world-lab.spec.ts b/tests/e2e/world-lab.spec.ts index a9840d6..72c6b9b 100644 --- a/tests/e2e/world-lab.spec.ts +++ b/tests/e2e/world-lab.spec.ts @@ -1,17 +1,39 @@ import { expect, test } from '@playwright/test'; -test('loads the World Lab map shell and reserved development panels', async ({ - page, -}) => { +test('runs the complete deterministic World Lab browser flow', async ({ page }) => { + const openRouterRequests: string[] = []; + page.on('request', (request) => { + if (request.url().includes('openrouter.ai')) + openRouterRequests.push(request.url()); + }); + await page.goto('/'); await expect(page.getByRole('heading', { name: 'World Lab' })).toBeVisible(); await expect(page.getByTestId('world-map')).toBeVisible(); - await expect( - page.getByRole('heading', { name: 'Simulation controls' }), - ).toBeVisible(); - await expect( - page.getByRole('heading', { name: 'Agent inspector' }), - ).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Event log' })).toBeVisible(); + await expect(page.getByText(/H3 overlay ready · 61 cells · 6 agents/)).toBeVisible(); + await expect(page.getByTestId('infected-count')).toHaveText('0 infected'); + await expect(page.getByText('Automated-test provider')).toBeVisible(); + + const markers = page.getByRole('button', { name: /Select agent/ }); + await expect(markers).toHaveCount(6); + await page.getByRole('button', { name: 'Select agent Rook' }).click(); + await expect(page.getByRole('heading', { name: /Rook/ })).toBeVisible(); + await expect(page.getByText('2507bb46-7ae4-45ca-8dda-644c4f85ca14')).toBeVisible(); + + await page.getByRole('button', { name: 'Single turn' }).click(); + await expect(page.getByText(/Infection ·/)).toBeVisible(); + await expect(page.getByTestId('infected-count')).toHaveText('1 infected'); + + await page.getByRole('button', { name: 'Start' }).click(); + await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible(); + await expect(page.getByText('Turn 2')).toBeVisible({ timeout: 10_000 }); + await page.getByRole('button', { name: 'Pause' }).click(); + await expect(page.getByRole('button', { name: 'Start' })).toBeVisible(); + + await page.getByRole('button', { name: 'Reset' }).click(); + await expect(page.getByText('Turn 0')).toBeVisible(); + await expect(page.getByTestId('infected-count')).toHaveText('0 infected'); + await expect(page.getByText('Development world loaded with six agents.')).toBeVisible(); + expect(openRouterRequests).toEqual([]); }); From e8b98304195f5693f28921f38e48d764bada4318 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Thu, 13 Aug 2026 23:16:00 -0400 Subject: [PATCH 2/6] fix: address local validation findings --- .env.example | 2 + AGENTS.md | 7 ++ README.md | 12 +- apps/game-api/src/app.test.ts | 37 ++++-- apps/game-api/src/app.ts | 5 +- apps/game-api/src/server.ts | 20 +-- apps/game-api/src/simulation-service.test.ts | 47 +++++-- apps/game-api/src/simulation-service.ts | 11 +- apps/world-lab/next.config.ts | 1 + apps/world-lab/src/app/styles.css | 4 +- .../src/components/world-lab.test.tsx | 118 +++++++++++++----- apps/world-lab/src/components/world-lab.tsx | 69 +++++++--- apps/world-lab/src/components/world-map.tsx | 16 +-- packages/agent-runtime/src/index.test.ts | 58 ++++++--- packages/shared/src/index.test.ts | 34 ++++- packages/world-engine/src/index.test.ts | 18 ++- playwright.config.ts | 22 +++- tests/e2e/world-lab.spec.ts | 40 +++++- 18 files changed, 382 insertions(+), 139 deletions(-) diff --git a/.env.example b/.env.example index 5907bc7..2d5b1a1 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,5 @@ AGENTBORNE_MODEL=openai/gpt-5-mini NEXT_PUBLIC_DEV_MAP_LATITUDE=41.6528 NEXT_PUBLIC_DEV_MAP_LONGITUDE=-83.5379 NEXT_PUBLIC_DEV_MAP_H3_RESOLUTION=9 +# Optional browser API override. Normal development uses the Next.js proxy. +# NEXT_PUBLIC_GAME_API_BASE_URL=http://127.0.0.1:8787/api/simulation diff --git a/AGENTS.md b/AGENTS.md index ab300ab..4c3d880 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,5 +31,12 @@ These instructions apply throughout the repository. - Use Conventional Commit messages. Keep pull requests within one roadmap milestone. - Coding agents write and update appropriate tests, configure automatic GitHub CI, and provide the exact local validation commands for the repository owner to run. - The repository owner runs local formatting checks, lint, type checking, tests, builds, and Playwright. Coding agents must not run local validation unless the owner explicitly requests it in that session. +- After implementation and test authoring, coding agents must stop before pushing a branch or opening a pull request. +- The coding agent must provide the exact local validation commands and wait for the repository owner to run them. +- A branch may be pushed and a draft pull request may be opened only after the owner explicitly confirms in the same session that final local validation passed. +- If the owner reports a validation failure, fix it locally, provide the relevant commands, and pause again for owner retesting. +- Never infer validation approval from the original task prompt, completed implementation, local commits, or expected CI behavior. +- Automatic GitHub CI should begin only after the owner’s local validation gate has passed and the draft pull request is opened. +- Coding agents still must not run local validation unless explicitly authorized by the owner. - Automatic GitHub CI may run after a branch is pushed. Coding agents may inspect CI status and failure logs. - Coding agents must never claim local tests passed unless the repository owner supplied the results. diff --git a/README.md b/README.md index cbd1475..86ef146 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ Agentborne is the working title for an agent-first GPS containment game. The cur ## Workspace -| Path | Responsibility | -| --- | --- | -| `apps/world-lab` | Next.js developer/admin map, controls, inspector, and event log | -| `apps/game-api` | Hono HTTP boundary and in-memory simulation service | -| `packages/world-engine` | Pure world validation and consequence application | +| Path | Responsibility | +| ------------------------ | ---------------------------------------------------------------- | +| `apps/world-lab` | Next.js developer/admin map, controls, inspector, and event log | +| `apps/game-api` | Hono HTTP boundary and in-memory simulation service | +| `packages/world-engine` | Pure world validation and consequence application | | `packages/agent-runtime` | OpenRouter provider boundary and explicit scripted testing seams | -| `packages/shared` | Runtime-validated schemas and inferred domain types | +| `packages/shared` | Runtime-validated schemas and inferred domain types | ## Local development diff --git a/apps/game-api/src/app.test.ts b/apps/game-api/src/app.test.ts index 2f36a8a..6148fa9 100644 --- a/apps/game-api/src/app.test.ts +++ b/apps/game-api/src/app.test.ts @@ -7,6 +7,7 @@ import { type ProviderDecision, } from '@agentborne/agent-runtime'; import { + h3CellSchema, resetSimulationResponseSchema, simulationSnapshotSchema, singleTurnResponseSchema, @@ -34,20 +35,27 @@ describe('game API simulation boundary', () => { ]), }); const accepted = singleTurnResponseSchema.parse( - await (await acceptedApp.request('/api/simulation/turn', { method: 'POST' })).json(), + await ( + await acceptedApp.request('/api/simulation/turn', { method: 'POST' }) + ).json(), ); expect(accepted.turn.outcome).toBe('accepted'); const rejectedApp = createApp({ provider: new ScriptedAgentProvider([ { - requestedAction: { type: 'move', targetCell: '8928308280fffff' }, + requestedAction: { + type: 'move', + targetCell: h3CellSchema.parse('8928308280fffff'), + }, summary: 'Move far away.', }, ]), }); const rejected = singleTurnResponseSchema.parse( - await (await rejectedApp.request('/api/simulation/turn', { method: 'POST' })).json(), + await ( + await rejectedApp.request('/api/simulation/turn', { method: 'POST' }) + ).json(), ); expect(rejected.turn.outcome).toBe('rejected'); }); @@ -92,7 +100,9 @@ describe('game API simulation boundary', () => { ]), }); await app.request('/api/simulation/turn', { method: 'POST' }); - const response = await app.request('/api/simulation/reset', { method: 'POST' }); + const response = await app.request('/api/simulation/reset', { + method: 'POST', + }); const reset = resetSimulationResponseSchema.parse(await response.json()); expect(reset.snapshot.turnNumber).toBe(0); expect(reset.snapshot.world.events).toEqual([]); @@ -104,15 +114,26 @@ describe('game API simulation boundary', () => { mode: 'scripted-test', model: 'deferred-test', configured: true, - decide: () => new Promise((resolve) => { release = resolve; }), + decide: () => + new Promise((resolve) => { + release = resolve; + }), }; const app = createApp({ provider }); const pending = app.request('/api/simulation/turn', { method: 'POST' }); - expect((await app.request('/api/simulation/turn', { method: 'POST' })).status).toBe(409); - expect((await app.request('/api/simulation/reset', { method: 'POST' })).status).toBe(409); + expect( + (await app.request('/api/simulation/turn', { method: 'POST' })).status, + ).toBe(409); + expect( + (await app.request('/api/simulation/reset', { method: 'POST' })).status, + ).toBe(409); release({ decision: { requestedAction: { type: 'wait' }, summary: 'Done.' }, - metadata: { provider: 'scripted-test', model: 'deferred-test', latencyMs: 0 }, + metadata: { + provider: 'scripted-test', + model: 'deferred-test', + latencyMs: 0, + }, }); expect((await pending).status).toBe(200); }); diff --git a/apps/game-api/src/app.ts b/apps/game-api/src/app.ts index b758978..7ff8d0c 100644 --- a/apps/game-api/src/app.ts +++ b/apps/game-api/src/app.ts @@ -128,7 +128,10 @@ export function createApp(options: AppOptions = {}) { ); app.onError((error, context) => { - console.error('Unhandled API error', error instanceof Error ? error.name : 'unknown'); + console.error( + 'Unhandled API error', + error instanceof Error ? error.name : 'unknown', + ); return context.json( apiErrorSchema.parse({ error: { diff --git a/apps/game-api/src/server.ts b/apps/game-api/src/server.ts index 98b968c..7d4accd 100644 --- a/apps/game-api/src/server.ts +++ b/apps/game-api/src/server.ts @@ -4,16 +4,20 @@ import { createApp } from './app'; try { process.loadEnvFile('../../.env.local'); } catch (error) { - const code = error instanceof Error && 'code' in error ? error.code : undefined; + const code = + error instanceof Error && 'code' in error ? error.code : undefined; if (code !== 'ENOENT') throw error; } const port = Number.parseInt(process.env.PORT ?? '8787', 10); -serve({ - fetch: createApp().fetch, - hostname: '127.0.0.1', - port, -}, ({ port: listeningPort }) => { - console.log(`Game API listening on http://localhost:${listeningPort}`); -}); +serve( + { + fetch: createApp().fetch, + hostname: '127.0.0.1', + port, + }, + ({ port: listeningPort }) => { + console.log(`Game API listening on http://localhost:${listeningPort}`); + }, +); diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index 9ce5660..f76d54d 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -5,8 +5,11 @@ import { type AgentProvider, type ProviderDecision, } from '@agentborne/agent-runtime'; -import type { AgentObservation } from '@agentborne/shared'; -import { SimulationConflictError, SimulationService } from './simulation-service'; +import { h3CellSchema, type AgentObservation } from '@agentborne/shared'; +import { + SimulationConflictError, + SimulationService, +} from './simulation-service'; const now = () => '2026-08-13T12:00:01.000Z'; const createEventId = () => '67aa21b9-fc78-4b04-9f92-9862bf346f96'; @@ -39,13 +42,18 @@ describe('SimulationService', () => { seen.push(observation); return { decision: { requestedAction: { type: 'wait' }, summary: 'Wait.' }, - metadata: { provider: 'scripted-test', model: 'recording-test', latencyMs: 0 }, + metadata: { + provider: 'scripted-test', + model: 'recording-test', + latencyMs: 0, + }, }; }, }; const simulation = service(provider); const order = simulation.getSnapshot().world.agents.map(({ id }) => id); - for (let index = 0; index < 7; index += 1) await simulation.executeNextTurn(); + for (let index = 0; index < 7; index += 1) + await simulation.executeNextTurn(); expect(seen).toHaveLength(7); expect(seen.map(({ agentId }) => agentId)).toEqual([...order, order[0]]); }); @@ -74,7 +82,10 @@ describe('SimulationService', () => { const rejected = service( new ScriptedAgentProvider([ { - requestedAction: { type: 'move', targetCell: '8928308280fffff' }, + requestedAction: { + type: 'move', + targetCell: h3CellSchema.parse('8928308280fffff'), + }, summary: 'Attempt a distant move.', }, { requestedAction: { type: 'wait' }, summary: 'Continue.' }, @@ -102,8 +113,15 @@ describe('SimulationService', () => { retryable: true, }); return { - decision: { requestedAction: { type: 'wait' }, summary: 'Recovered.' }, - metadata: { provider: 'scripted-test', model: 'failure-test', latencyMs: 0 }, + decision: { + requestedAction: { type: 'wait' }, + summary: 'Recovered.', + }, + metadata: { + provider: 'scripted-test', + model: 'failure-test', + latencyMs: 0, + }, }; }, }; @@ -120,15 +138,24 @@ describe('SimulationService', () => { mode: 'scripted-test', model: 'deferred-test', configured: true, - decide: () => new Promise((resolve) => { release = resolve; }), + decide: () => + new Promise((resolve) => { + release = resolve; + }), }; const simulation = service(provider); const pending = simulation.executeNextTurn(); - await expect(simulation.executeNextTurn()).rejects.toBeInstanceOf(SimulationConflictError); + await expect(simulation.executeNextTurn()).rejects.toBeInstanceOf( + SimulationConflictError, + ); expect(() => simulation.reset()).toThrow(SimulationConflictError); release({ decision: { requestedAction: { type: 'wait' }, summary: 'Done.' }, - metadata: { provider: 'scripted-test', model: 'deferred-test', latencyMs: 0 }, + metadata: { + provider: 'scripted-test', + model: 'deferred-test', + latencyMs: 0, + }, }); await pending; expect(simulation.getSnapshot().turnNumber).toBe(1); diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index d19f8c9..f492780 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -99,17 +99,13 @@ export class SimulationService { this.#turns = []; this.#cursor = 0; this.#activeAgentId = null; - this.#status = this.#provider.configured - ? 'paused' - : 'configuration-error'; + this.#status = this.#provider.configured ? 'paused' : 'configuration-error'; return this.getSnapshot(); } async executeNextTurn(): Promise { if (this.#busy) { - throw new SimulationConflictError( - 'A model turn is already in progress.', - ); + throw new SimulationConflictError('A model turn is already in progress.'); } const agents = [...this.#state.agents.values()]; const agent = agents[this.#cursor % agents.length]; @@ -252,8 +248,7 @@ function summarizeEvent( ): string { const name = state.agents.get(event.agentId)?.name ?? 'An agent'; if (event.type === 'agent-moved') return `${name} moved to ${event.toCell}.`; - if (event.type === 'hex-infected') - return `${name} infected ${event.cell}.`; + if (event.type === 'hex-infected') return `${name} infected ${event.cell}.`; return `${name} waited.`; } diff --git a/apps/world-lab/next.config.ts b/apps/world-lab/next.config.ts index 25033ec..303a595 100644 --- a/apps/world-lab/next.config.ts +++ b/apps/world-lab/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { + allowedDevOrigins: ['127.0.0.1'], transpilePackages: ['@agentborne/shared', '@agentborne/world-engine'], async rewrites() { return [ diff --git a/apps/world-lab/src/app/styles.css b/apps/world-lab/src/app/styles.css index 64d9bd8..54c1eec 100644 --- a/apps/world-lab/src/app/styles.css +++ b/apps/world-lab/src/app/styles.css @@ -149,7 +149,9 @@ h2 { .agent-marker:hover, .agent-marker.selected { border-color: #fff5d8; - box-shadow: 0 0 0 4px #fff5d855, 0 2px 8px #000b; + box-shadow: + 0 0 0 4px #fff5d855, + 0 2px 8px #000b; transform: scale(1.12); } .map-caption { diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index f142d60..2ef32e4 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -2,11 +2,16 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { gridDisk } from 'h3-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { simulationSnapshotSchema, type SimulationSnapshot } from '@agentborne/shared'; +import { + simulationSnapshotSchema, + type SimulationSnapshot, +} from '@agentborne/shared'; import { createDevelopmentWorld } from '@agentborne/world-engine'; import { WorldLab } from './world-lab'; -let mapClick: ((event: { features: Array<{ properties: { cell: string } }> }) => void) | undefined; +let mapClick: + | ((event: { features: Array<{ properties: { cell: string } }> }) => void) + | undefined; vi.mock('maplibre-gl', () => { class Map { @@ -15,30 +20,49 @@ vi.mock('maplibre-gl', () => { addLayer() {} addSource() {} fitBounds() {} - getCanvas() { return { style: { cursor: '' } }; } - getSource() { return this.source; } + getCanvas() { + return { style: { cursor: '' } }; + } + getSource() { + return this.source; + } on(event: string, layerOrCallback: unknown, callback?: typeof mapClick) { - if (event === 'load' && typeof layerOrCallback === 'function') layerOrCallback(); - if (event === 'click' && typeof callback === 'function') mapClick = callback; + if (event === 'load' && typeof layerOrCallback === 'function') + layerOrCallback(); + if (event === 'click' && typeof callback === 'function') + mapClick = callback; } remove() {} } class Marker { constructor(private options: { element: HTMLElement }) {} - setLngLat() { return this; } - addTo() { document.body.append(this.options.element); return this; } - remove() { this.options.element.remove(); } + setLngLat() { + return this; + } + addTo() { + document.body.append(this.options.element); + return this; + } + remove() { + this.options.element.remove(); + } } return { Map, Marker, - LngLatBounds: class { extend() { return this; } }, + LngLatBounds: class { + extend() { + return this; + } + }, NavigationControl: class {}, AttributionControl: class {}, }; }); -const world = createDevelopmentWorld({ generatedAt: '2026-08-13T12:00:00.000Z' }); +const world = createDevelopmentWorld({ + generatedAt: '2026-08-13T12:00:00.000Z', +}); const initial = simulationSnapshotSchema.parse({ world, turnNumber: 0, @@ -60,7 +84,9 @@ function afterInfection(): SimulationSnapshot { cell: agent.currentCell, }; const adjacent = gridDisk(agent.currentCell, 1).find( - (cell) => cell !== agent.currentCell && world.hexes.some((hex) => hex.cell === cell), + (cell) => + cell !== agent.currentCell && + world.hexes.some((hex) => hex.cell === cell), )!; const turn = { turnNumber: 1, @@ -81,14 +107,20 @@ function afterInfection(): SimulationSnapshot { summary: 'Infecting this open cell.', validation: { accepted: true as const }, event, - provider: { provider: 'scripted-test' as const, model: 'test', latencyMs: 0 }, + provider: { + provider: 'scripted-test' as const, + model: 'test', + latencyMs: 0, + }, }; return simulationSnapshotSchema.parse({ ...initial, world: { ...world, hexes: world.hexes.map((hex) => - hex.cell === agent.currentCell ? { ...hex, state: 'infected' as const } : hex, + hex.cell === agent.currentCell + ? { ...hex, state: 'infected' as const } + : hex, ), events: [event], }, @@ -104,7 +136,10 @@ function jsonResponse(value: unknown) { beforeEach(() => { mapClick = undefined; - vi.stubGlobal('fetch', vi.fn(() => jsonResponse(initial))); + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse(initial)), + ); }); afterEach(() => { @@ -114,20 +149,28 @@ afterEach(() => { describe('WorldLab', () => { it('renders all controls, status, H3 readiness, and six visible markers', async () => { render(); - expect(await screen.findByRole('heading', { name: 'World Lab' })).toBeInTheDocument(); - expect(await screen.findByText(/H3 overlay ready · 61 cells · 6 agents/)).toBeInTheDocument(); + expect( + await screen.findByText(/H3 overlay ready · 61 cells · 6 agents/), + ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'World Lab' }), + ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Single turn' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled(); expect(screen.getByLabelText('Playback speed')).toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: /Select agent/ })).toHaveLength(6); + expect( + screen.getAllByRole('button', { name: /Select agent/ }), + ).toHaveLength(6); expect(screen.getByText('Automated-test provider')).toBeInTheDocument(); }); it('selects an agent and populates its inspector', async () => { const user = userEvent.setup(); render(); - await user.click(await screen.findByRole('button', { name: 'Select agent Rook' })); + await user.click( + await screen.findByRole('button', { name: 'Select agent Rook' }), + ); expect(screen.getByRole('heading', { name: /Rook/ })).toBeInTheDocument(); expect(screen.getByText(world.agents[1]!.personality)).toBeInTheDocument(); expect(screen.getByText(world.agents[1]!.id)).toBeInTheDocument(); @@ -137,14 +180,21 @@ describe('WorldLab', () => { const changed = afterInfection(); vi.stubGlobal( 'fetch', - vi.fn() + vi + .fn() .mockImplementationOnce(() => jsonResponse(initial)) - .mockImplementationOnce(() => jsonResponse({ snapshot: changed, turn: changed.turns[0] })), + .mockImplementationOnce(() => + jsonResponse({ snapshot: changed, turn: changed.turns[0] }), + ), ); const user = userEvent.setup(); render(); - await user.click(await screen.findByRole('button', { name: 'Single turn' })); - expect(await screen.findByText('Infection · ' + world.agents[0]!.currentCell)).toBeInTheDocument(); + await user.click( + await screen.findByRole('button', { name: 'Single turn' }), + ); + expect( + await screen.findByText('Infection · ' + world.agents[0]!.currentCell), + ).toBeInTheDocument(); expect(screen.getByText('Infecting this open cell.')).toBeInTheDocument(); expect(screen.queryByText(/chain-of-thought/i)).not.toBeInTheDocument(); }); @@ -163,15 +213,22 @@ describe('WorldLab', () => { const changed = afterInfection(); vi.stubGlobal( 'fetch', - vi.fn() + vi + .fn() .mockImplementationOnce(() => jsonResponse(changed)) .mockImplementationOnce(() => jsonResponse({ snapshot: initial })), ); const user = userEvent.setup(); render(); - expect(await screen.findByText('Infection · ' + world.agents[0]!.currentCell)).toBeInTheDocument(); + expect( + await screen.findByText('Infection · ' + world.agents[0]!.currentCell), + ).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Reset' })); - await waitFor(() => expect(screen.getByText('Development world loaded with six agents.')).toBeInTheDocument()); + await waitFor(() => + expect( + screen.getByText('Development world loaded with six agents.'), + ).toBeInTheDocument(), + ); expect(screen.getByText('Turn 0')).toBeInTheDocument(); }); @@ -191,9 +248,14 @@ describe('WorldLab', () => { providerMode: 'openrouter', providerConfigured: false, }); - vi.stubGlobal('fetch', vi.fn(() => jsonResponse(unconfigured))); + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse(unconfigured)), + ); render(); - expect(await screen.findByText(/Model calls unavailable/)).toBeInTheDocument(); + expect( + await screen.findByText(/Model calls unavailable/), + ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Single turn' })).toBeDisabled(); }); diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index 92ca5e0..faff557 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -15,7 +15,8 @@ import { WorldMap } from './world-map'; const latitude = Number(process.env.NEXT_PUBLIC_DEV_MAP_LATITUDE ?? 41.6528); const longitude = Number(process.env.NEXT_PUBLIC_DEV_MAP_LONGITUDE ?? -83.5379); const resolution = Number(process.env.NEXT_PUBLIC_DEV_MAP_H3_RESOLUTION ?? 9); -const apiBase = '/api/game/simulation'; +const apiBase = + process.env.NEXT_PUBLIC_GAME_API_BASE_URL ?? '/api/game/simulation'; export function WorldLab() { const [snapshot, setSnapshot] = useState(null); @@ -94,7 +95,9 @@ export function WorldLab() { return; } if (!response.ok) throw new Error('reset request failed'); - const payload = resetSimulationResponseSchema.parse(await response.json()); + const payload = resetSimulationResponseSchema.parse( + await response.json(), + ); setSnapshot(payload.snapshot); setSelectedCell(payload.snapshot.world.hexes[0]!.cell); setSelectedAgentId(payload.snapshot.world.agents[0]!.id); @@ -359,11 +362,24 @@ function AgentInspector({
Latest structured observation

- Current: {latestTurn.observation.currentCell.cell} ({latestTurn.observation.currentCell.state}) + Current: {latestTurn.observation.currentCell.cell} ( + {latestTurn.observation.currentCell.state}) +

+

+ Adjacent:{' '} + {latestTurn.observation.adjacentCells + .map(({ cell, state }) => `${cell} (${state})`) + .join(', ')} +

+

+ Nearby:{' '} + {latestTurn.observation.nearbyAgents + .map(({ name, distance }) => `${name} (${distance})`) + .join(', ') || 'none'} +

+

+ Recent public events: {latestTurn.observation.recentEvents.length}

-

Adjacent: {latestTurn.observation.adjacentCells.map(({ cell, state }) => `${cell} (${state})`).join(', ')}

-

Nearby: {latestTurn.observation.nearbyAgents.map(({ name, distance }) => `${name} (${distance})`).join(', ') || 'none'}

-

Recent public events: {latestTurn.observation.recentEvents.length}

) : ( @@ -371,11 +387,14 @@ function AgentInspector({ )}

Recent records

    - {turns.slice(-5).toReversed().map((turn) => ( -
  1. - Turn {turn.turnNumber}: {turn.outcome} -
  2. - ))} + {turns + .slice(-5) + .toReversed() + .map((turn) => ( +
  3. + Turn {turn.turnNumber}: {turn.outcome} +
  4. + ))}
); @@ -393,19 +412,27 @@ function EventLog({ turns }: { turns: AgentTurnRecord[] }) { Development world loaded with six agents. ) : ( - turns.slice(-20).toReversed().map((turn) => ( -
  • - - {formatTurn(turn)} -
  • - )) + turns + .slice(-20) + .toReversed() + .map((turn) => ( +
  • + + {formatTurn(turn)} +
  • + )) )} ); } -function formatAction(action: Extract['requestedAction']) { +function formatAction( + action: Extract< + AgentTurnRecord, + { outcome: 'accepted' | 'rejected' } + >['requestedAction'], +) { return action.type === 'move' ? `move → ${action.targetCell}` : action.type; } @@ -414,7 +441,9 @@ function formatTurn(turn: AgentTurnRecord) { return `Provider failure · ${turn.failure.message}`; if (turn.outcome === 'rejected') return `Rejected ${formatAction(turn.requestedAction)} · ${turn.validation.reason}`; - if (turn.event.type === 'agent-moved') return `Movement · ${turn.event.toCell}`; - if (turn.event.type === 'hex-infected') return `Infection · ${turn.event.cell}`; + if (turn.event.type === 'agent-moved') + return `Movement · ${turn.event.toCell}`; + if (turn.event.type === 'hex-infected') + return `Infection · ${turn.event.cell}`; return 'Waited'; } diff --git a/apps/world-lab/src/components/world-map.tsx b/apps/world-lab/src/components/world-map.tsx index e5b7cdd..e970fcb 100644 --- a/apps/world-lab/src/components/world-map.tsx +++ b/apps/world-lab/src/components/world-map.tsx @@ -128,20 +128,14 @@ export function WorldMap(props: WorldMapProps) { type: 'line', source: sourceId, paint: { - 'line-color': [ - 'case', - ['get', 'selected'], - '#fff2c9', - '#b8d4cc', - ], + 'line-color': ['case', ['get', 'selected'], '#fff2c9', '#b8d4cc'], 'line-opacity': 0.95, 'line-width': ['case', ['get', 'selected'], 4, 1.25], }, }); map.on('click', fillLayerId, (event) => { const cell = event.features?.[0]?.properties?.cell; - if (typeof cell === 'string') - onSelectCellRef.current(cell as H3Cell); + if (typeof cell === 'string') onSelectCellRef.current(cell as H3Cell); }); map.on('mouseenter', fillLayerId, () => { map.getCanvas().style.cursor = 'pointer'; @@ -152,7 +146,8 @@ export function WorldMap(props: WorldMapProps) { const bounds = new LngLatBounds(); for (const { cell } of initialHexes.current) { - for (const [lat, lng] of cellToBoundary(cell)) bounds.extend([lng, lat]); + for (const [lat, lng] of cellToBoundary(cell)) + bounds.extend([lng, lat]); } map.fitBounds(bounds, { padding: 56, maxZoom: 14, duration: 0 }); setOverlayReady(true); @@ -168,8 +163,7 @@ export function WorldMap(props: WorldMapProps) { useEffect(() => { const source = mapRef.current?.getSource(sourceId) as - | GeoJSONSource - | undefined; + GeoJSONSource | undefined; source?.setData(asGeoJson(hexes, selectedCell)); }, [hexes, selectedCell]); diff --git a/packages/agent-runtime/src/index.test.ts b/packages/agent-runtime/src/index.test.ts index f9c312b..f8f2ebd 100644 --- a/packages/agent-runtime/src/index.test.ts +++ b/packages/agent-runtime/src/index.test.ts @@ -37,14 +37,23 @@ describe('OpenRouterAgentProvider', () => { json_schema: { strict: true }, }); expect(request.provider).toEqual({ require_parameters: true }); - expect(request.messages[1].content).toContain(observation.personality); - expect(request.messages[0].content).toContain('Never produce messages'); + expect(request.messages[1]!.content).toContain(observation.personality); + expect(request.messages[0]!.content).toContain('Never produce messages'); expect(request.max_tokens).toBeLessThanOrEqual(200); }); it('parses and runtime-validates a structured decision', async () => { - const fetchImplementation = vi.fn(async () => - response(JSON.stringify({ requestedAction: { type: 'infect' }, summary: 'Claiming this cell.' })), + let capturedInit: RequestInit | undefined; + const fetchImplementation: typeof fetch = vi.fn( + async (_input: string | URL | Request, init?: RequestInit) => { + capturedInit = init; + return response( + JSON.stringify({ + requestedAction: { type: 'infect' }, + summary: 'Claiming this cell.', + }), + ); + }, ); const provider = new OpenRouterAgentProvider({ apiKey: 'secret-test-key', @@ -54,25 +63,36 @@ describe('OpenRouterAgentProvider', () => { decision: { requestedAction: { type: 'infect' } }, metadata: { provider: 'openrouter' }, }); - const init = fetchImplementation.mock.calls[0]?.[1]; - expect(JSON.parse(String(init?.body))).toMatchObject({ + expect(JSON.parse(String(capturedInit?.body))).toMatchObject({ provider: { require_parameters: true }, }); }); it.each([ ['not-json', 'malformed-response'], - [JSON.stringify({ requestedAction: { type: 'teleport' }, summary: 'No.' }), 'unsupported-response'], - [JSON.stringify({ requestedAction: { type: 'wait' }, summary: 'x'.repeat(241) }), 'unsupported-response'], - ])('rejects invalid provider content without fallback', async (content, code) => { - const provider = new OpenRouterAgentProvider({ - apiKey: 'secret-test-key', - fetchImplementation: vi.fn(async () => response(content)), - }); - await expect(provider.decide(observation)).rejects.toMatchObject({ - failure: { code }, - }); - }); + [ + JSON.stringify({ requestedAction: { type: 'teleport' }, summary: 'No.' }), + 'unsupported-response', + ], + [ + JSON.stringify({ + requestedAction: { type: 'wait' }, + summary: 'x'.repeat(241), + }), + 'unsupported-response', + ], + ])( + 'rejects invalid provider content without fallback', + async (content, code) => { + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + fetchImplementation: vi.fn(async () => response(content)), + }); + await expect(provider.decide(observation)).rejects.toMatchObject({ + failure: { code }, + }); + }, + ); it('sanitizes HTTP and network errors without leaking the key', async () => { const key = 'highly-sensitive-key'; @@ -139,6 +159,8 @@ describe('ScriptedAgentProvider', () => { }); it('rejects an empty script', () => { - expect(() => new ScriptedAgentProvider([])).toThrow(/at least one decision/); + expect(() => new ScriptedAgentProvider([])).toThrow( + /at least one decision/, + ); }); }); diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 385291a..e202430 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -42,13 +42,24 @@ describe('agent observation and decision schemas', () => { it.each([ { ...observation, adjacentCells: [] }, { ...observation, currentCell: { cell, state: 'unknown' } }, - { ...observation, nearbyAgents: Array(6).fill({ id: agentId, name: 'x', currentCell: cell, distance: 1 }) }, + { + ...observation, + nearbyAgents: Array(6).fill({ + id: agentId, + name: 'x', + currentCell: cell, + distance: 1, + }), + }, ])('rejects invalid or oversized observations', (value) => { expect(agentObservationSchema.safeParse(value).success).toBe(false); }); it.each([ - { requestedAction: { type: 'move', targetCell: adjacent }, summary: 'Move.' }, + { + requestedAction: { type: 'move', targetCell: adjacent }, + summary: 'Move.', + }, { requestedAction: { type: 'infect' }, summary: 'Infect.' }, { requestedAction: { type: 'wait' }, summary: 'Wait.' }, ])('accepts a PR 2 decision', (decision) => { @@ -56,9 +67,22 @@ describe('agent observation and decision schemas', () => { }); it.each([ - { requestedAction: { type: 'teleport', targetCell: adjacent }, summary: 'No.' }, - { requestedAction: { type: 'message', recipientId: agentId, message: 'No.' }, summary: 'No.' }, - { requestedAction: { type: 'wait' }, summary: 'x'.repeat(MODEL_SUMMARY_MAX_LENGTH + 1) }, + { + requestedAction: { type: 'teleport', targetCell: adjacent }, + summary: 'No.', + }, + { + requestedAction: { + type: 'message', + recipientId: agentId, + message: 'No.', + }, + summary: 'No.', + }, + { + requestedAction: { type: 'wait' }, + summary: 'x'.repeat(MODEL_SUMMARY_MAX_LENGTH + 1), + }, ])('rejects forbidden actions and oversized model text', (decision) => { expect(agentDecisionSchema.safeParse(decision).success).toBe(false); }); diff --git a/packages/world-engine/src/index.test.ts b/packages/world-engine/src/index.test.ts index 34abdb5..9f7dcc6 100644 --- a/packages/world-engine/src/index.test.ts +++ b/packages/world-engine/src/index.test.ts @@ -132,8 +132,16 @@ describe('infection', () => { describe('wait and deterministic development world', () => { it('records a wait without changing cells or hex states', () => { const before = stateWithAgent(); - const result = applyRequestedAction(before, agentId, { type: 'wait' }, context); - expect(result.result).toMatchObject({ accepted: true, event: { type: 'agent-waited' } }); + const result = applyRequestedAction( + before, + agentId, + { type: 'wait' }, + context, + ); + expect(result.result).toMatchObject({ + accepted: true, + event: { type: 'agent-waited' }, + }); expect(result.state.hexes).toBe(before.hexes); expect(result.state.agents).toBe(before.agents); }); @@ -145,6 +153,10 @@ describe('wait and deterministic development world', () => { expect(first.hexes).toHaveLength(61); expect(first.agents).toHaveLength(6); expect(new Set(first.agents.map(({ id }) => id)).size).toBe(6); - expect(first.agents.every(({ currentCell }) => first.hexes.some(({ cell }) => cell === currentCell))).toBe(true); + expect( + first.agents.every(({ currentCell }) => + first.hexes.some(({ cell }) => cell === currentCell), + ), + ).toBe(true); }); }); diff --git a/playwright.config.ts b/playwright.config.ts index 9659cd7..f26c8fa 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,11 +12,21 @@ export default defineConfig({ baseURL, trace: 'on-first-retry', }, - webServer: { - command: 'pnpm dev:test-provider', - url: baseURL, - reuseExistingServer: !process.env.CI, - timeout: 120_000, - }, + webServer: [ + { + command: + 'AGENTBORNE_PROVIDER=scripted pnpm --filter @agentborne/game-api dev', + url: 'http://127.0.0.1:8787/health', + reuseExistingServer: false, + timeout: 120_000, + }, + { + command: + 'NEXT_PUBLIC_GAME_API_BASE_URL=http://127.0.0.1:8787/api/simulation pnpm --filter @agentborne/world-lab dev', + url: baseURL, + reuseExistingServer: false, + timeout: 120_000, + }, + ], projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], }); diff --git a/tests/e2e/world-lab.spec.ts b/tests/e2e/world-lab.spec.ts index 72c6b9b..a7bead8 100644 --- a/tests/e2e/world-lab.spec.ts +++ b/tests/e2e/world-lab.spec.ts @@ -1,25 +1,49 @@ import { expect, test } from '@playwright/test'; -test('runs the complete deterministic World Lab browser flow', async ({ page }) => { +test('runs the complete deterministic World Lab browser flow', async ({ + page, +}) => { const openRouterRequests: string[] = []; + const browserFailures: string[] = []; page.on('request', (request) => { if (request.url().includes('openrouter.ai')) openRouterRequests.push(request.url()); }); + page.on('requestfailed', (request) => { + browserFailures.push( + `request failed: ${request.method()} ${request.url()} · ${request.failure()?.errorText ?? 'unknown'}`, + ); + }); + page.on('pageerror', (error) => { + browserFailures.push(`page error: ${error.message}`); + }); await page.goto('/'); + try { + await expect(page.getByText('Automated-test provider')).toBeVisible({ + timeout: 30_000, + }); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}\nBrowser failures:\n${browserFailures.join('\n') || 'none captured'}`, + ); + } + await expect(page.getByRole('heading', { name: 'World Lab' })).toBeVisible(); await expect(page.getByTestId('world-map')).toBeVisible(); - await expect(page.getByText(/H3 overlay ready · 61 cells · 6 agents/)).toBeVisible(); + await expect( + page.getByText(/H3 overlay ready · 61 cells · 6 agents/), + ).toBeVisible(); await expect(page.getByTestId('infected-count')).toHaveText('0 infected'); - await expect(page.getByText('Automated-test provider')).toBeVisible(); const markers = page.getByRole('button', { name: /Select agent/ }); await expect(markers).toHaveCount(6); await page.getByRole('button', { name: 'Select agent Rook' }).click(); await expect(page.getByRole('heading', { name: /Rook/ })).toBeVisible(); - await expect(page.getByText('2507bb46-7ae4-45ca-8dda-644c4f85ca14')).toBeVisible(); + await expect( + page.getByText('2507bb46-7ae4-45ca-8dda-644c4f85ca14'), + ).toBeVisible(); await page.getByRole('button', { name: 'Single turn' }).click(); await expect(page.getByText(/Infection ·/)).toBeVisible(); @@ -27,13 +51,17 @@ test('runs the complete deterministic World Lab browser flow', async ({ page }) await page.getByRole('button', { name: 'Start' }).click(); await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible(); - await expect(page.getByText('Turn 2')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText('Turn 2', { exact: true })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: 'Pause' }).click(); await expect(page.getByRole('button', { name: 'Start' })).toBeVisible(); await page.getByRole('button', { name: 'Reset' }).click(); await expect(page.getByText('Turn 0')).toBeVisible(); await expect(page.getByTestId('infected-count')).toHaveText('0 infected'); - await expect(page.getByText('Development world loaded with six agents.')).toBeVisible(); + await expect( + page.getByText('Development world loaded with six agents.'), + ).toBeVisible(); expect(openRouterRequests).toEqual([]); }); From 9911979c9727c086e28f560ec3baa9f617fcb91e Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Thu, 13 Aug 2026 23:44:44 -0400 Subject: [PATCH 3/6] fix: harden simulation turn lifecycle --- apps/game-api/src/app.test.ts | 58 ++++++ apps/game-api/src/simulation-service.test.ts | 193 +++++++++++++++++- apps/game-api/src/simulation-service.ts | 92 ++++++--- docs/ARCHITECTURE.md | 8 +- docs/TESTING.md | 8 +- docs/adr/0002-first-visible-llm-invasion.md | 4 +- packages/agent-runtime/src/index.test.ts | 59 +++++- packages/agent-runtime/src/index.ts | 199 ++++++++++--------- packages/shared/src/index.test.ts | 37 +++- packages/shared/src/index.ts | 2 +- 10 files changed, 509 insertions(+), 151 deletions(-) diff --git a/apps/game-api/src/app.test.ts b/apps/game-api/src/app.test.ts index 6148fa9..953be32 100644 --- a/apps/game-api/src/app.test.ts +++ b/apps/game-api/src/app.test.ts @@ -93,6 +93,64 @@ describe('game API simulation boundary', () => { }); }); + it('returns internal errors for post-provider validation failures without advancing', async () => { + let calls = 0; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'invalid-metadata-test', + configured: true, + async decide(): Promise { + calls += 1; + return { + decision: { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + metadata: { + provider: 'scripted-test', + model: calls === 1 ? '' : 'invalid-metadata-test', + latencyMs: 0, + }, + } as ProviderDecision; + }, + }; + const app = createApp({ provider }); + const initial = simulationSnapshotSchema.parse( + await (await app.request('/api/simulation')).json(), + ); + + const failed = await app.request('/api/simulation/turn', { + method: 'POST', + }); + expect(failed.status).toBe(500); + await expect(failed.json()).resolves.toEqual({ + error: { + code: 'internal_error', + message: 'An unexpected error occurred.', + }, + }); + + const afterFailure = simulationSnapshotSchema.parse( + await (await app.request('/api/simulation')).json(), + ); + expect(afterFailure).toMatchObject({ + turnNumber: 0, + turns: [], + nextAgentId: initial.nextAgentId, + activeAgentId: null, + status: 'paused', + }); + expect(afterFailure.world).toEqual(initial.world); + + const recovered = singleTurnResponseSchema.parse( + await ( + await app.request('/api/simulation/turn', { method: 'POST' }) + ).json(), + ); + expect(recovered.turn).toMatchObject({ + turnNumber: 1, + agentId: initial.nextAgentId, + outcome: 'accepted', + }); + }); + it('resets the complete simulation', async () => { const app = createApp({ provider: new ScriptedAgentProvider([ diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index f76d54d..1c03839 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -5,7 +5,12 @@ import { type AgentProvider, type ProviderDecision, } from '@agentborne/agent-runtime'; -import { h3CellSchema, type AgentObservation } from '@agentborne/shared'; +import { + h3CellSchema, + type AgentObservation, + type AgentTurnRecord, + type WorldEvent, +} from '@agentborne/shared'; import { SimulationConflictError, SimulationService, @@ -58,6 +63,47 @@ describe('SimulationService', () => { expect(seen.map(({ agentId }) => agentId)).toEqual([...order, order[0]]); }); + it('keeps total turn numbering and round robin independent of retained history', async () => { + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'long-running-test', + configured: true, + async decide(): Promise { + return { + decision: { requestedAction: { type: 'wait' }, summary: 'Wait.' }, + metadata: { + provider: 'scripted-test', + model: 'long-running-test', + latencyMs: 0, + }, + }; + }, + }; + const simulation = service(provider); + const agentOrder = simulation + .getSnapshot() + .world.agents.map(({ id }) => id); + + for (let index = 0; index < 125; index += 1) { + await simulation.executeNextTurn(); + } + + const snapshot = simulation.getSnapshot(); + const retainedNumbers = snapshot.turns.map(({ turnNumber }) => turnNumber); + expect(snapshot.turnNumber).toBe(125); + expect(snapshot.turns).toHaveLength(120); + expect(retainedNumbers).toEqual( + Array.from({ length: 120 }, (_, index) => index + 6), + ); + expect(new Set(retainedNumbers).size).toBe(120); + expect(snapshot.turns.map(({ agentId }) => agentId)).toEqual( + snapshot.turns.map( + ({ turnNumber }) => agentOrder[(turnNumber - 1) % agentOrder.length], + ), + ); + expect(snapshot.nextAgentId).toBe(agentOrder[125 % agentOrder.length]); + }); + it('builds each observation from the latest authoritative world state', async () => { const simulation = service( new ScriptedAgentProvider([ @@ -92,10 +138,17 @@ describe('SimulationService', () => { ]), ); const before = rejected.getSnapshot().world; - expect((await rejected.executeNextTurn()).outcome).toBe('rejected'); + expect(await rejected.executeNextTurn()).toMatchObject({ + turnNumber: 1, + outcome: 'rejected', + }); expect(rejected.getSnapshot().world.hexes).toEqual(before.hexes); expect(rejected.getSnapshot().world.agents).toEqual(before.agents); - expect((await rejected.executeNextTurn()).outcome).toBe('accepted'); + expect(await rejected.executeNextTurn()).toMatchObject({ + turnNumber: 2, + outcome: 'accepted', + }); + expect(rejected.getSnapshot().turnNumber).toBe(2); }); it('records a sanitized provider failure, preserves the world, and continues', async () => { @@ -127,9 +180,139 @@ describe('SimulationService', () => { }; const simulation = service(provider); const before = simulation.getSnapshot().world; - expect((await simulation.executeNextTurn()).outcome).toBe('provider-error'); + expect(await simulation.executeNextTurn()).toMatchObject({ + turnNumber: 1, + outcome: 'provider-error', + }); expect(simulation.getSnapshot().world).toEqual(before); - expect((await simulation.executeNextTurn()).outcome).toBe('accepted'); + expect(await simulation.executeNextTurn()).toMatchObject({ + turnNumber: 2, + outcome: 'accepted', + }); + expect(simulation.getSnapshot().turnNumber).toBe(2); + }); + + it('does not commit or advance after post-provider validation fails', async () => { + const seenAgentIds: AgentObservation['agentId'][] = []; + let calls = 0; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'invalid-metadata-test', + configured: true, + async decide(observation): Promise { + calls += 1; + seenAgentIds.push(observation.agentId); + if (calls === 1) { + return { + decision: { + requestedAction: { type: 'wait' }, + summary: 'Invalid metadata follows.', + }, + metadata: { + provider: 'scripted-test', + model: '', + latencyMs: 0, + }, + } as ProviderDecision; + } + return { + decision: { requestedAction: { type: 'wait' }, summary: 'Valid.' }, + metadata: { + provider: 'scripted-test', + model: 'invalid-metadata-test', + latencyMs: 0, + }, + }; + }, + }; + const simulation = service(provider); + const before = simulation.getSnapshot(); + + await expect(simulation.executeNextTurn()).rejects.toBeDefined(); + + const afterFailure = simulation.getSnapshot(); + expect(afterFailure.world).toEqual(before.world); + expect(afterFailure.turnNumber).toBe(0); + expect(afterFailure.turns).toEqual([]); + expect(afterFailure.nextAgentId).toBe(before.nextAgentId); + expect(afterFailure).toMatchObject({ + activeAgentId: null, + status: 'paused', + }); + + const recovered = await simulation.executeNextTurn(); + expect(recovered).toMatchObject({ + turnNumber: 1, + agentId: before.nextAgentId, + outcome: 'accepted', + }); + expect(seenAgentIds).toEqual([before.nextAgentId, before.nextAgentId]); + }); + + it('retains only the newest world events without changing current state', async () => { + let clock = 0; + let eventSequence = 0; + const provider: AgentProvider = { + mode: 'scripted-test', + model: 'moving-history-test', + configured: true, + async decide(observation): Promise { + return { + decision: { + requestedAction: { + type: 'move', + targetCell: observation.adjacentCells[0]!.cell, + }, + summary: 'Move.', + }, + metadata: { + provider: 'scripted-test', + model: 'moving-history-test', + latencyMs: 0, + }, + }; + }, + }; + const simulation = new SimulationService({ + provider, + now: () => + new Date( + Date.parse('2026-08-13T12:00:00.000Z') + clock++, + ).toISOString(), + createEventId: () => + `67aa21b9-fc78-4b04-9f92-${String(++eventSequence).padStart(12, '0')}`, + }); + const producedEvents: WorldEvent[] = []; + let lastRecord: AgentTurnRecord | undefined; + + for (let index = 0; index < 125; index += 1) { + lastRecord = await simulation.executeNextTurn(); + if (lastRecord.outcome !== 'accepted') { + throw new Error( + 'The moving history fixture must produce accepted turns.', + ); + } + producedEvents.push(lastRecord.event); + } + + const snapshot = simulation.getSnapshot(); + expect(snapshot.world.events).toHaveLength(120); + expect(snapshot.world.events).toEqual(producedEvents.slice(-120)); + expect( + lastRecord?.observation.recentEvents.map(({ occurredAt }) => occurredAt), + ).toEqual(producedEvents.slice(-9, -1).map(({ occurredAt }) => occurredAt)); + + for (const agent of snapshot.world.agents) { + const latestMove = producedEvents + .filter( + (event) => event.type === 'agent-moved' && event.agentId === agent.id, + ) + .at(-1); + expect(latestMove?.type).toBe('agent-moved'); + if (latestMove?.type === 'agent-moved') { + expect(agent.currentCell).toBe(latestMove.toCell); + } + } }); it('prevents overlapping turns and reset during an in-flight request', async () => { diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index f492780..4472d78 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -2,6 +2,7 @@ import { gridDistance, gridDisk } from 'h3-js'; import { AgentProviderError, type AgentProvider, + type ProviderDecision, } from '@agentborne/agent-runtime'; import { agentObservationSchema, @@ -26,6 +27,7 @@ import { const RESET_GENERATED_AT = '2026-08-13T12:00:00.000Z'; const MAX_TURN_HISTORY = 120; +const MAX_WORLD_EVENT_HISTORY = 120; export class SimulationConflictError extends Error { constructor(message: string) { @@ -46,6 +48,7 @@ export class SimulationService { readonly #createEventId: () => string; #state: WorldState; #turns: AgentTurnRecord[] = []; + #completedTurnCount = 0; #cursor = 0; #busy = false; #status: SimulationStatus; @@ -76,7 +79,7 @@ export class SimulationService { agents, events: this.#state.events, }, - turnNumber: this.#turns.length, + turnNumber: this.#completedTurnCount, nextAgentId: next.id, activeAgentId: this.#activeAgentId, status: this.#status, @@ -97,6 +100,7 @@ export class SimulationService { createDevelopmentWorld({ generatedAt: RESET_GENERATED_AT }), ); this.#turns = []; + this.#completedTurnCount = 0; this.#cursor = 0; this.#activeAgentId = null; this.#status = this.#provider.configured ? 'paused' : 'configuration-error'; @@ -114,23 +118,46 @@ export class SimulationService { this.#busy = true; this.#activeAgentId = agent.id; this.#status = 'waiting-for-model'; - const startedAt = this.#now(); - const observation = this.#buildObservation(agent.id); - const turnNumber = this.#turns.length + 1; - let record: AgentTurnRecord; try { - const providerResult = await this.#provider.decide( - structuredClone(observation), - ); + const startedAt = this.#now(); + const observation = this.#buildObservation(agent.id); + const turnNumber = this.#completedTurnCount + 1; + const providerObservation = structuredClone(observation); + let providerResult: ProviderDecision; + + try { + providerResult = await this.#provider.decide(providerObservation); + } catch (error) { + const providerError = asProviderError(error); + const record = agentTurnRecordSchema.parse({ + turnNumber, + agentId: agent.id, + startedAt, + completedAt: this.#now(), + observation, + outcome: 'provider-error', + failure: providerError.failure, + provider: providerError.metadata, + }); + this.#commitCompletedTurn(record, this.#state, agents.length); + this.#status = + providerError.failure.code === 'configuration' + ? 'configuration-error' + : 'provider-error'; + return record; + } + const applied = applyRequestedAction( this.#state, agent.id, providerResult.decision.requestedAction, { now: this.#now, createEventId: this.#createEventId }, ); + let record: AgentTurnRecord; + let candidateState = this.#state; + if (applied.result.accepted) { - this.#state = applied.state; record = agentTurnRecordSchema.parse({ turnNumber, agentId: agent.id, @@ -144,7 +171,10 @@ export class SimulationService { event: applied.result.event, provider: providerResult.metadata, }); - this.#status = 'paused'; + candidateState = { + ...applied.state, + events: applied.state.events.slice(-MAX_WORLD_EVENT_HISTORY), + }; } else { record = agentTurnRecordSchema.parse({ turnNumber, @@ -158,32 +188,34 @@ export class SimulationService { validation: applied.result, provider: providerResult.metadata, }); - this.#status = 'paused'; } - } catch (error) { - const providerError = asProviderError(error); - record = agentTurnRecordSchema.parse({ - turnNumber, - agentId: agent.id, - startedAt, - completedAt: this.#now(), - observation, - outcome: 'provider-error', - failure: providerError.failure, - provider: providerError.metadata, - }); - this.#status = - providerError.failure.code === 'configuration' - ? 'configuration-error' - : 'provider-error'; + + this.#commitCompletedTurn(record, candidateState, agents.length); + this.#status = 'paused'; + return record; } finally { this.#busy = false; this.#activeAgentId = null; + if (this.#status === 'waiting-for-model') { + this.#status = this.#provider.configured + ? 'paused' + : 'configuration-error'; + } } + } + + #commitCompletedTurn( + record: AgentTurnRecord, + state: WorldState, + agentCount: number, + ): void { + const turns = [...this.#turns, record].slice(-MAX_TURN_HISTORY); + const cursor = (this.#cursor + 1) % agentCount; - this.#turns = [...this.#turns, record].slice(-MAX_TURN_HISTORY); - this.#cursor = (this.#cursor + 1) % agents.length; - return record; + this.#state = state; + this.#turns = turns; + this.#completedTurnCount = record.turnNumber; + this.#cursor = cursor; } #buildObservation(agentId: AgentId): AgentObservation { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 971fa21..c711f1a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -12,7 +12,7 @@ World Lab → Game API / simulation service → agent runtime → requested acti `apps/world-lab` is a Next.js App Router developer/admin surface. It fetches runtime-validated simulation snapshots through a local rewrite, controls one turn at a time, and updates MapLibre's existing H3 GeoJSON source without recreating the map. Agent markers are fully visible and use deterministic offsets when sharing cells. -`apps/game-api` is a Hono service bound conservatively to loopback. Its single in-memory `SimulationService` owns the development session, turn cursor, bounded turn history, and overlap lock. It exposes: +`apps/game-api` is a Hono service bound conservatively to loopback. Its single in-memory `SimulationService` owns the development session, monotonic completed-turn count, turn cursor, bounded histories, and overlap lock. It exposes: - `GET /api/simulation` — current authoritative snapshot - `POST /api/simulation/turn` — one agent observation, one provider decision, one engine application @@ -24,7 +24,9 @@ The legacy `GET /api/development-world` and `GET /health` endpoints remain for l The development world is an H3 radius-four disk (61 cells) around Toledo with six fixed profiles and starting cells. One agent acts per turn in stable array order. The service reads the latest state, constructs and clones a bounded observation, requests one structured decision, validates it with Zod, and passes only the requested action to the world engine. -The engine alone accepts or rejects actions and creates world events. Rejections do not mutate state and are not replaced by heuristics. Provider failures produce sanitized records, do not mutate the world, advance round robin, and do not prevent later turns. +The engine alone accepts or rejects actions and creates world events. Rejections do not mutate state and are not replaced by heuristics. The service schema-validates a complete accepted or rejected turn record before atomically committing its candidate world state, turn record, completed-turn count, and round-robin cursor. Unexpected engine, schema, or internal failures propagate as internal API errors and commit none of those changes; cleanup always returns the service to a coherent idle state. + +Only failures thrown by the provider decision call become sanitized provider-failure turn records. Those failures remain non-mutating, count as completed recorded turns, advance round robin, and do not prevent later turns. The public turn number is the total completed-turn count and is independent of the retained history: snapshots keep only the newest 120 turn records and newest 120 world events. Observations select the newest eight relevant public events from that retained event history in chronological order. ## Packages @@ -32,6 +34,6 @@ The engine alone accepts or rejects actions and creates world events. Rejections `packages/world-engine` remains deterministic and has no model, HTTP, UI, storage, or credential dependency. Its broader domain action union retains range-limited messaging for the established boundary, but PR 2's model decision schema excludes messaging entirely. -`packages/agent-runtime` contains the OpenRouter adapter. The interactive provider uses strict JSON Schema structured output and requires provider parameter support. Scripted providers are explicit deterministic seams selected only by tests or `AGENTBORNE_PROVIDER=scripted`; there is no automatic fallback. +`packages/agent-runtime` contains the OpenRouter adapter. The interactive provider uses strict JSON Schema structured output and requires provider parameter support. Its abort timeout covers the complete response lifecycle, including body reading, JSON decoding, response extraction, and schema validation, and is cleared after every outcome. Scripted providers are explicit deterministic seams selected only by tests or `AGENTBORNE_PROVIDER=scripted`; there is no automatic fallback. The rationale and deferrals are recorded in [ADR 0002](adr/0002-first-visible-llm-invasion.md). diff --git a/docs/TESTING.md b/docs/TESTING.md index ea25bfc..07b3ee3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -23,11 +23,11 @@ pnpm test:e2e ## Coverage -- Shared schema tests cover valid/invalid observations and decisions, all turn outcomes, response snapshots, history bounds, and model-authored string limits. +- Shared schema tests cover valid/invalid observations and decisions, all turn outcomes, response snapshots, 120-record turn and 120-event history bounds, and model-authored string limits. - World-engine tests cover adjacent and non-adjacent movement, infection persistence, repeated-infection rejection, wait, immutability on rejection, and deterministic 61-cell/six-agent construction. -- Agent-runtime tests inspect strict OpenRouter requests, prompt placement, parsing, runtime validation, malformed/unsupported responses, timeouts, sanitization, secret non-leakage, missing configuration, and explicit scripted output. -- Simulation-service tests cover deterministic reset, round robin, exactly one call per turn, latest-state observations, accepted/rejected/failure records, recovery, and turn/reset concurrency. -- API integration tests validate snapshot, turn, reset, missing configuration, provider failure, rejection, and typed conflict responses. +- Agent-runtime tests inspect strict OpenRouter requests, prompt placement, parsing, runtime validation, malformed/unsupported responses, body-inclusive timeouts and timer cleanup, sanitization, secret non-leakage, missing configuration, and explicit scripted output. +- Simulation-service tests cover deterministic reset, round robin beyond retained history, monotonic total turn numbering, exactly one call per turn, latest-state observations, 120-record turn retention, 120-event retention, accepted/rejected/failure records, atomic rollback on internal validation failure, recovery, and turn/reset concurrency. +- API integration tests validate snapshot, turn, reset, missing configuration, provider failure, internal-failure propagation and recovery, rejection, and typed conflict responses. - React tests cover controls/statuses, playback configuration, single turn, reset, agent/hex selection, inspector output, H3 readiness, and safe configuration errors. - Playwright verifies 61 visible H3 cells are reported ready, six markers, marker/inspector interaction, infection layer data changes, Start/Pause, reset, and the explicit automated-test provider. diff --git a/docs/adr/0002-first-visible-llm-invasion.md b/docs/adr/0002-first-visible-llm-invasion.md index c34fb75..cfa220c 100644 --- a/docs/adr/0002-first-visible-llm-invasion.md +++ b/docs/adr/0002-first-visible-llm-invasion.md @@ -15,7 +15,9 @@ Turns advance one agent at a time in deterministic round-robin order. The applic Normal interactive development selects OpenRouter and reports missing configuration or provider failure explicitly. A scripted provider exists only as an explicitly selected deterministic test seam. It is not a fallback and is visibly labeled in the World Lab. -Turn records contain bounded observations, actions, summaries, validation results, events, and safe provider metadata. They never contain raw prompts, raw payloads, secrets, or private chain-of-thought. +Turn records contain bounded observations, actions, summaries, validation results, events, and safe provider metadata. They never contain raw prompts, raw payloads, secrets, or private chain-of-thought. The total completed-turn count is monotonic and separate from the newest 120 retained turn records. The authoritative world likewise retains only its newest 120 events, while observations receive the newest eight relevant events in chronological order. + +Accepted and rejected turn records are fully schema-validated before the service atomically commits the candidate world, record, count, and cursor. Only errors originating in the provider decision call are converted to sanitized, recorded provider failures. Other engine, schema, or internal errors propagate without changing simulation progress, and turn cleanup restores a coherent idle status. OpenRouter's timeout remains active through body reading, decoding, extraction, and runtime validation, with final cleanup on every outcome. ## Consequences diff --git a/packages/agent-runtime/src/index.test.ts b/packages/agent-runtime/src/index.test.ts index f8f2ebd..43b956b 100644 --- a/packages/agent-runtime/src/index.test.ts +++ b/packages/agent-runtime/src/index.test.ts @@ -39,7 +39,8 @@ describe('OpenRouterAgentProvider', () => { expect(request.provider).toEqual({ require_parameters: true }); expect(request.messages[1]!.content).toContain(observation.personality); expect(request.messages[0]!.content).toContain('Never produce messages'); - expect(request.max_tokens).toBeLessThanOrEqual(200); + expect(request.max_completion_tokens).toBe(180); + expect(request).not.toHaveProperty('max_tokens'); }); it('parses and runtime-validates a structured decision', async () => { @@ -135,6 +136,62 @@ describe('OpenRouterAgentProvider', () => { }); }); + it('keeps the timeout active while reading the response body', async () => { + vi.useFakeTimers(); + try { + let requestSignal: AbortSignal | null | undefined; + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + timeoutMs: 10, + fetchImplementation: vi.fn(async (_url, init) => { + requestSignal = init?.signal; + return { + ok: true, + status: 200, + headers: new Headers(), + json: () => + new Promise((_resolve, reject) => { + requestSignal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ); + }), + } as Response; + }), + }); + const pending = expect( + provider.decide(observation), + ).rejects.toMatchObject({ failure: { code: 'timeout' } }); + + await vi.advanceTimersByTimeAsync(10); + await pending; + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('clears the timeout after a successful response', async () => { + vi.useFakeTimers(); + try { + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + fetchImplementation: vi.fn(async () => + response( + JSON.stringify({ + requestedAction: { type: 'wait' }, + summary: 'Done.', + }), + ), + ), + }); + + await expect(provider.decide(observation)).resolves.toBeDefined(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + it('reports missing configuration instead of using a heuristic fallback', async () => { const provider = new OpenRouterAgentProvider(); await expect(provider.decide(observation)).rejects.toMatchObject({ diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 74ab5f9..846830a 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -110,7 +110,7 @@ export function buildOpenRouterRequest( }, }, provider: { require_parameters: true }, - max_tokens: 180, + max_completion_tokens: 180, stream: false, }; } @@ -172,111 +172,116 @@ export class OpenRouterAgentProvider implements AgentProvider { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.#timeoutMs); - let response: Response; try { - response = await this.#fetch(OPENROUTER_ENDPOINT, { - method: 'POST', - headers: { - Authorization: `Bearer ${this.#apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(buildOpenRouterRequest(observation, this.model)), - signal: controller.signal, - }); - } catch { - const timedOut = controller.signal.aborted; - throw new AgentProviderError({ - code: timedOut ? 'timeout' : 'network', - message: timedOut - ? 'The model request timed out.' - : 'The model provider could not be reached.', - retryable: true, - }); - } finally { - clearTimeout(timeout); - } + let response: Response; + try { + response = await this.#fetch(OPENROUTER_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.#apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildOpenRouterRequest(observation, this.model)), + signal: controller.signal, + }); + } catch { + throw requestFailure(controller.signal.aborted); + } - const latencyMs = Date.now() - started; - if (!response.ok) { - throw new AgentProviderError( - { - code: 'provider-http', - message: `The model provider returned HTTP ${response.status}.`, - retryable: response.status === 429 || response.status >= 500, - }, - { - provider: 'openrouter', - model: this.model, - requestId: response.headers.get('x-request-id') ?? undefined, - latencyMs, - }, - ); - } + if (!response.ok) { + throw new AgentProviderError( + { + code: 'provider-http', + message: `The model provider returned HTTP ${response.status}.`, + retryable: response.status === 429 || response.status >= 500, + }, + { + provider: 'openrouter', + model: this.model, + requestId: response.headers.get('x-request-id') ?? undefined, + latencyMs: Date.now() - started, + }, + ); + } - let raw: unknown; - try { - raw = await response.json(); - } catch { - throw new AgentProviderError({ - code: 'malformed-response', - message: 'The model provider returned malformed JSON.', - retryable: true, - }); - } + let raw: unknown; + try { + raw = await response.json(); + } catch { + if (controller.signal.aborted) throw requestFailure(true); + throw new AgentProviderError({ + code: 'malformed-response', + message: 'The model provider returned malformed JSON.', + retryable: true, + }); + } - const parsedResponse = openRouterResponseSchema.safeParse(raw); - if (!parsedResponse.success) { - throw new AgentProviderError({ - code: 'unsupported-response', - message: 'The model provider returned an unsupported response shape.', - retryable: true, - }); - } - const content = parsedResponse.data.choices[0]?.message.content; - if (!content) { - throw new AgentProviderError({ - code: 'unsupported-response', - message: 'The model provider returned no structured decision.', - retryable: true, - }); - } + const parsedResponse = openRouterResponseSchema.safeParse(raw); + if (!parsedResponse.success) { + throw new AgentProviderError({ + code: 'unsupported-response', + message: 'The model provider returned an unsupported response shape.', + retryable: true, + }); + } + const content = parsedResponse.data.choices[0]?.message.content; + if (!content) { + throw new AgentProviderError({ + code: 'unsupported-response', + message: 'The model provider returned no structured decision.', + retryable: true, + }); + } - let decisionInput: unknown; - try { - decisionInput = JSON.parse(content); - } catch { - throw new AgentProviderError({ - code: 'malformed-response', - message: 'The model decision was not valid JSON.', - retryable: true, - }); - } - const decision = agentDecisionSchema.safeParse(decisionInput); - if (!decision.success) { - throw new AgentProviderError({ - code: 'unsupported-response', - message: 'The model decision failed runtime schema validation.', - retryable: true, - }); - } + let decisionInput: unknown; + try { + decisionInput = JSON.parse(content); + } catch { + throw new AgentProviderError({ + code: 'malformed-response', + message: 'The model decision was not valid JSON.', + retryable: true, + }); + } + const decision = agentDecisionSchema.safeParse(decisionInput); + if (!decision.success) { + throw new AgentProviderError({ + code: 'unsupported-response', + message: 'The model decision failed runtime schema validation.', + retryable: true, + }); + } - return { - decision: decision.data, - metadata: { - provider: 'openrouter', - model: parsedResponse.data.model ?? this.model, - requestId: - parsedResponse.data.id ?? - response.headers.get('x-request-id') ?? - undefined, - latencyMs, - promptTokens: parsedResponse.data.usage?.prompt_tokens, - completionTokens: parsedResponse.data.usage?.completion_tokens, - }, - }; + return { + decision: decision.data, + metadata: { + provider: 'openrouter', + model: parsedResponse.data.model ?? this.model, + requestId: + parsedResponse.data.id ?? + response.headers.get('x-request-id') ?? + undefined, + latencyMs: Date.now() - started, + promptTokens: parsedResponse.data.usage?.prompt_tokens, + completionTokens: parsedResponse.data.usage?.completion_tokens, + }, + }; + } finally { + clearTimeout(timeout); + } } } +function requestFailure(timedOut: boolean): AgentProviderError { + return new AgentProviderError({ + code: timedOut ? 'timeout' : 'network', + message: timedOut + ? 'The model request timed out.' + : 'The model provider could not be reached.', + retryable: true, + }); +} + export class ScriptedAgentProvider implements AgentProvider { readonly mode = 'scripted-test' as const; readonly model = 'deterministic-script'; diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index e202430..8425ed0 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -31,6 +31,13 @@ const provider = { model: 'openai/gpt-5-mini', latencyMs: 100, }; +const event = { + id: '67aa21b9-fc78-4b04-9f92-9862bf346f96', + agentId, + occurredAt: '2026-08-13T12:00:01.000Z', + type: 'hex-infected', + cell, +}; describe('agent observation and decision schemas', () => { it('accepts a bounded state-bearing observation', () => { @@ -96,13 +103,7 @@ describe('turn and snapshot schemas', () => { requestedAction: { type: 'infect' }, summary: 'Infect.', validation: { accepted: true }, - event: { - id: '67aa21b9-fc78-4b04-9f92-9862bf346f96', - agentId, - occurredAt: '2026-08-13T12:00:01.000Z', - type: 'hex-infected', - cell, - }, + event, provider, }, { @@ -122,7 +123,7 @@ describe('turn and snapshot schemas', () => { expect(agentTurnRecordSchema.safeParse(turn).success).toBe(true); }); - it('validates a complete API snapshot and rejects unbounded history', () => { + it('validates a complete API snapshot and rejects unbounded histories', () => { const worldAgent = { id: agentId, name: 'Ember', @@ -145,11 +146,29 @@ describe('turn and snapshot schemas', () => { providerConfigured: true, turns: [], }; + const validTurn = { + ...baseTurn, + outcome: 'accepted', + requestedAction: { type: 'infect' }, + summary: 'Infect.', + validation: { accepted: true }, + event, + provider, + }; expect(simulationSnapshotSchema.safeParse(snapshot).success).toBe(true); expect( simulationSnapshotSchema.safeParse({ ...snapshot, - turns: Array(121).fill({}), + turns: Array(121).fill(validTurn), + }).success, + ).toBe(false); + expect( + simulationSnapshotSchema.safeParse({ + ...snapshot, + world: { + ...snapshot.world, + events: Array(121).fill(event), + }, }).success, ).toBe(false); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d67f435..24b7f5f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -111,7 +111,7 @@ export const worldSnapshotSchema = z.object({ generatedAt: z.iso.datetime(), hexes: z.array(z.object({ cell: h3CellSchema, state: hexStateSchema })), agents: z.array(agentSchema), - events: z.array(worldEventSchema), + events: z.array(worldEventSchema).max(120), }); export type WorldSnapshot = z.infer; From 12c7978deec973b388ba1d22f0718df281094447 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Fri, 14 Aug 2026 00:33:47 -0400 Subject: [PATCH 4/6] fix(world-lab): verify rendered H3 overlay --- .../src/app/maplibre-worker/[file]/route.ts | 27 ++ .../src/components/world-lab.test.tsx | 266 +++++++++++++++- apps/world-lab/src/components/world-map.tsx | 288 ++++++++++++++---- docs/TESTING.md | 2 +- tests/e2e/world-lab.spec.ts | 40 ++- 5 files changed, 547 insertions(+), 76 deletions(-) create mode 100644 apps/world-lab/src/app/maplibre-worker/[file]/route.ts diff --git a/apps/world-lab/src/app/maplibre-worker/[file]/route.ts b/apps/world-lab/src/app/maplibre-worker/[file]/route.ts new file mode 100644 index 0000000..8f121ea --- /dev/null +++ b/apps/world-lab/src/app/maplibre-worker/[file]/route.ts @@ -0,0 +1,27 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { notFound } from 'next/navigation'; + +const mapLibreWorkerFiles = new Set([ + 'maplibre-gl-shared.mjs', + 'maplibre-gl-worker.mjs', +]); + +export async function GET( + _request: Request, + context: { params: Promise<{ file: string }> }, +) { + const { file } = await context.params; + if (!mapLibreWorkerFiles.has(file)) notFound(); + + const contents = await readFile( + join(process.cwd(), 'node_modules', 'maplibre-gl', 'dist', file), + ); + + return new Response(contents, { + headers: { + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Type': 'text/javascript; charset=utf-8', + }, + }); +} diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index 2ef32e4..09f7b87 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -9,28 +9,128 @@ import { import { createDevelopmentWorld } from '@agentborne/world-engine'; import { WorldLab } from './world-lab'; -let mapClick: - | ((event: { features: Array<{ properties: { cell: string } }> }) => void) - | undefined; +const mapLibreMock = vi.hoisted(() => ({ + renderMode: 'complete' as 'complete' | 'incomplete', + rejectSource: false, + rejectLayers: false, + duplicateFeatures: false, + autoRender: true, + pendingRenderCallbacks: [] as Array<() => void>, + mapClick: undefined as + | ((event: { features: Array<{ properties: { cell: string } }> }) => void) + | undefined, + layers: [] as Array<{ + id: string; + paint?: Record; + }>, + queryRenderedFeatures: vi.fn(), + setData: vi.fn(), +})); vi.mock('maplibre-gl', () => { class Map { - source = { setData: vi.fn() }; + source: + | { + data: { + features: Array<{ + properties: { cell: string; state: string; selected: boolean }; + }>; + }; + setData: (data: unknown) => void; + } + | undefined; + sourceLoaded = false; + layers = new Set(); + listeners = new globalThis.Map void>>(); addControl() {} - addLayer() {} - addSource() {} + addLayer(layer: { id: string; paint?: Record }) { + mapLibreMock.layers.push(layer); + if (!mapLibreMock.rejectLayers) this.layers.add(layer.id); + } + addSource( + id: string, + source: { + data: { + features: Array<{ + properties: { cell: string; state: string; selected: boolean }; + }>; + }; + }, + ) { + if (mapLibreMock.rejectSource) return; + const completeSourceUpdate = () => { + this.sourceLoaded = true; + this.emit('sourcedata', { sourceId: id, isSourceLoaded: true }); + }; + this.source = { + data: source.data, + setData: (data) => { + mapLibreMock.setData(data); + this.source!.data = data as typeof source.data; + this.sourceLoaded = false; + queueMicrotask(completeSourceUpdate); + }, + }; + queueMicrotask(completeSourceUpdate); + } + emit(event: string, eventData?: unknown) { + for (const listener of [...(this.listeners.get(event) ?? [])]) { + listener(eventData); + } + } fitBounds() {} getCanvas() { return { style: { cursor: '' } }; } + getLayer(id: string) { + return this.layers.has(id) ? { id } : undefined; + } getSource() { return this.source; } - on(event: string, layerOrCallback: unknown, callback?: typeof mapClick) { - if (event === 'load' && typeof layerOrCallback === 'function') - layerOrCallback(); - if (event === 'click' && typeof callback === 'function') - mapClick = callback; + isSourceLoaded() { + return Boolean(this.source) && this.sourceLoaded; + } + queryRenderedFeatures(options: { layers: string[] }) { + mapLibreMock.queryRenderedFeatures(options); + if (!this.source || !this.layers.has('development-hex-fills')) return []; + const features = + mapLibreMock.renderMode === 'incomplete' + ? this.source.data.features.slice(0, -1) + : [...this.source.data.features]; + return mapLibreMock.duplicateFeatures && features[0] + ? [...features, features[0], features[0]] + : features; + } + on( + event: string, + layerOrCallback: unknown, + callback?: (event: { + features: Array<{ properties: { cell: string } }>; + }) => void, + ) { + if (event === 'load' && typeof layerOrCallback === 'function') { + queueMicrotask(() => layerOrCallback()); + } + if (event === 'click' && typeof callback === 'function') { + mapLibreMock.mapClick = callback; + } else if (event !== 'load' && typeof layerOrCallback === 'function') { + const listeners = + this.listeners.get(event) ?? new Set<(event?: unknown) => void>(); + listeners.add(layerOrCallback as (event?: unknown) => void); + this.listeners.set(event, listeners); + } + } + off(event: string, layerOrCallback: unknown, callback?: () => void) { + if (typeof layerOrCallback === 'function') { + this.listeners.get(event)?.delete(layerOrCallback as () => void); + } + if (event === 'click' && callback) mapLibreMock.mapClick = undefined; + } + triggerRepaint() { + const completeRender = () => this.emit('render'); + if (mapLibreMock.autoRender) queueMicrotask(completeRender); + else mapLibreMock.pendingRenderCallbacks.push(completeRender); } remove() {} } @@ -48,6 +148,7 @@ vi.mock('maplibre-gl', () => { } } return { + setWorkerUrl: vi.fn(), Map, Marker, LngLatBounds: class { @@ -135,7 +236,16 @@ function jsonResponse(value: unknown) { } beforeEach(() => { - mapClick = undefined; + mapLibreMock.renderMode = 'complete'; + mapLibreMock.rejectSource = false; + mapLibreMock.rejectLayers = false; + mapLibreMock.duplicateFeatures = false; + mapLibreMock.autoRender = true; + mapLibreMock.pendingRenderCallbacks = []; + mapLibreMock.mapClick = undefined; + mapLibreMock.layers = []; + mapLibreMock.queryRenderedFeatures.mockReset(); + mapLibreMock.setData.mockReset(); vi.stubGlobal( 'fetch', vi.fn(() => jsonResponse(initial)), @@ -150,8 +260,17 @@ describe('WorldLab', () => { it('renders all controls, status, H3 readiness, and six visible markers', async () => { render(); expect( - await screen.findByText(/H3 overlay ready · 61 cells · 6 agents/), + await screen.findByText( + /H3 overlay ready · 61\/61 rendered cells · 6 agents/, + ), ).toBeInTheDocument(); + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-rendered-h3-cell-count', + '61', + ); + expect(mapLibreMock.queryRenderedFeatures).toHaveBeenCalledWith({ + layers: ['development-hex-fills'], + }); expect( screen.getByRole('heading', { name: 'World Lab' }), ).toBeInTheDocument(); @@ -165,6 +284,91 @@ describe('WorldLab', () => { expect(screen.getByText('Automated-test provider')).toBeInTheDocument(); }); + it('deduplicates rendered H3 features before reporting readiness', async () => { + mapLibreMock.duplicateFeatures = true; + render(); + expect( + await screen.findByText(/H3 overlay ready · 61\/61 rendered cells/), + ).toBeInTheDocument(); + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-rendered-h3-cell-count', + '61', + ); + }); + + it('waits for an H3 source update and render cycle before inspecting readiness', async () => { + mapLibreMock.autoRender = false; + render(); + await screen.findByRole('button', { name: 'Select agent Ember' }); + expect(screen.getByText(/H3 overlay initializing/)).toBeInTheDocument(); + expect(mapLibreMock.queryRenderedFeatures).not.toHaveBeenCalled(); + + act(() => { + const pendingRenders = mapLibreMock.pendingRenderCallbacks.splice(0); + for (const completeRender of pendingRenders) { + completeRender(); + } + }); + + expect( + await screen.findByText(/H3 overlay ready · 61\/61 rendered cells/), + ).toBeInTheDocument(); + }); + + it.each([ + { + scenario: 'incomplete rendering', + configure: () => (mapLibreMock.renderMode = 'incomplete'), + expectedStatus: 'incomplete', + expectedCount: '60', + }, + { + scenario: 'rejected layers', + configure: () => (mapLibreMock.rejectLayers = true), + expectedStatus: 'failed', + expectedCount: '0', + }, + ])( + 'does not report readiness for $scenario', + async ({ configure, expectedCount, expectedStatus }) => { + configure(); + render(); + expect( + await screen.findByText(/H3 overlay (?:incomplete|failed)/), + ).toBeInTheDocument(); + expect(screen.queryByText(/H3 overlay ready/)).not.toBeInTheDocument(); + expect(screen.getByTestId('world-map')).not.toHaveAttribute( + 'data-overlay-status', + 'ready', + ); + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-overlay-status', + expectedStatus, + ); + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-rendered-h3-cell-count', + expectedCount, + ); + }, + ); + + it('uses explicit boolean assertions in every conditional paint expression', async () => { + render(); + await screen.findByText(/H3 overlay ready/); + const conditions = mapLibreMock.layers.flatMap(({ paint = {} }) => + Object.values(paint) + .filter( + (expression): expression is unknown[] => + Array.isArray(expression) && expression[0] === 'case', + ) + .map((expression) => expression[1]), + ); + expect(conditions).toHaveLength(3); + expect(conditions).toEqual( + Array(3).fill(['boolean', ['get', 'selected'], false]), + ); + }); + it('selects an agent and populates its inspector', async () => { const user = userEvent.setup(); render(); @@ -196,6 +400,24 @@ describe('WorldLab', () => { await screen.findByText('Infection · ' + world.agents[0]!.currentCell), ).toBeInTheDocument(); expect(screen.getByText('Infecting this open cell.')).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-rendered-infected-cell-count', + '1', + ), + ); + expect(screen.getByTestId('infected-count')).toHaveTextContent( + '1 rendered infected', + ); + expect(mapLibreMock.setData).toHaveBeenLastCalledWith( + expect.objectContaining({ + features: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ state: 'infected' }), + }), + ]), + }), + ); expect(screen.queryByText(/chain-of-thought/i)).not.toBeInTheDocument(); }); @@ -220,6 +442,12 @@ describe('WorldLab', () => { ); const user = userEvent.setup(); render(); + await waitFor(() => + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-rendered-infected-cell-count', + '1', + ), + ); expect( await screen.findByText('Infection · ' + world.agents[0]!.currentCell), ).toBeInTheDocument(); @@ -230,13 +458,23 @@ describe('WorldLab', () => { ).toBeInTheDocument(), ); expect(screen.getByText('Turn 0')).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByTestId('world-map')).toHaveAttribute( + 'data-rendered-infected-cell-count', + '0', + ), + ); }); it('supports hex selection independently of agent selection', async () => { render(); await screen.findByRole('button', { name: 'Select agent Ember' }); const target = world.hexes[1]!.cell; - act(() => mapClick?.({ features: [{ properties: { cell: target } }] })); + act(() => + mapLibreMock.mapClick?.({ + features: [{ properties: { cell: target } }], + }), + ); expect(screen.getByText(target)).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /Ember/ })).toBeInTheDocument(); }); diff --git a/apps/world-lab/src/components/world-map.tsx b/apps/world-lab/src/components/world-map.tsx index e970fcb..d9b7762 100644 --- a/apps/world-lab/src/components/world-map.tsx +++ b/apps/world-lab/src/components/world-map.tsx @@ -7,8 +7,11 @@ import { LngLatBounds, type GeoJSONSource, Map, + type MapLayerMouseEvent, + type MapSourceDataEvent, Marker, NavigationControl, + setWorkerUrl, } from 'maplibre-gl'; import type { AgentId, @@ -31,6 +34,25 @@ interface WorldMapProps { const sourceId = 'development-hexes'; const fillLayerId = 'development-hex-fills'; const lineLayerId = 'development-hex-lines'; +const expectedWorldCellCount = 61; + +setWorkerUrl('/maplibre-worker/maplibre-gl-worker.mjs'); + +type OverlayStatus = 'initializing' | 'ready' | 'incomplete' | 'failed'; + +interface OverlayDiagnostics { + status: OverlayStatus; + renderedCellCount: number; + renderedInfectedCellCount: number; + detail: string; +} + +const initialOverlayDiagnostics: OverlayDiagnostics = { + status: 'initializing', + renderedCellCount: 0, + renderedInfectedCellCount: 0, + detail: 'Waiting for the H3 source and layers to render.', +}; function asGeoJson(hexes: WorldMapProps['hexes'], selectedCell: H3Cell) { return { @@ -70,7 +92,16 @@ export function WorldMap(props: WorldMapProps) { const onSelectAgentRef = useRef(onSelectAgent); const initialHexes = useRef(hexes); const initialSelectedCell = useRef(selectedCell); - const [overlayReady, setOverlayReady] = useState(false); + const currentHexesRef = useRef(hexes); + const scheduleOverlayInspectionRef = useRef<(() => void) | null>(null); + const [mapReady, setMapReady] = useState(false); + const [overlayDiagnostics, setOverlayDiagnostics] = useState( + initialOverlayDiagnostics, + ); + + useEffect(() => { + currentHexesRef.current = hexes; + }, [hexes]); useEffect(() => { onSelectCellRef.current = onSelectCell; @@ -102,58 +133,185 @@ export function WorldMap(props: WorldMapProps) { mapRef.current = map; map.addControl(new NavigationControl({ showCompass: false }), 'top-right'); map.addControl(new AttributionControl({ compact: true })); + const inspectOverlay = () => { + if ( + !map.getSource(sourceId) || + !map.getLayer(fillLayerId) || + !map.getLayer(lineLayerId) + ) { + setOverlayDiagnostics({ + status: 'failed', + renderedCellCount: 0, + renderedInfectedCellCount: 0, + detail: 'MapLibre rejected the H3 source or one of its layers.', + }); + return; + } - map.on('load', () => { - map.addSource(sourceId, { - type: 'geojson', - data: asGeoJson(initialHexes.current, initialSelectedCell.current), - }); - map.addLayer({ - id: fillLayerId, - type: 'fill', - source: sourceId, - paint: { - 'fill-color': [ - 'match', - ['get', 'state'], - 'infected', - '#e44f45', - '#4a8178', - ], - 'fill-opacity': ['case', ['get', 'selected'], 0.72, 0.38], - }, - }); - map.addLayer({ - id: lineLayerId, - type: 'line', - source: sourceId, - paint: { - 'line-color': ['case', ['get', 'selected'], '#fff2c9', '#b8d4cc'], - 'line-opacity': 0.95, - 'line-width': ['case', ['get', 'selected'], 4, 1.25], - }, - }); - map.on('click', fillLayerId, (event) => { - const cell = event.features?.[0]?.properties?.cell; - if (typeof cell === 'string') onSelectCellRef.current(cell as H3Cell); - }); - map.on('mouseenter', fillLayerId, () => { - map.getCanvas().style.cursor = 'pointer'; - }); - map.on('mouseleave', fillLayerId, () => { - map.getCanvas().style.cursor = ''; - }); + if (!map.isSourceLoaded(sourceId)) { + return; + } - const bounds = new LngLatBounds(); - for (const { cell } of initialHexes.current) { - for (const [lat, lng] of cellToBoundary(cell)) - bounds.extend([lng, lat]); + try { + const expectedCells = new Set( + currentHexesRef.current.map(({ cell }) => cell), + ); + const renderedCells = new globalThis.Map(); + for (const feature of map.queryRenderedFeatures({ + layers: [fillLayerId], + })) { + const cell = feature.properties?.cell; + const state = feature.properties?.state; + if ( + typeof cell === 'string' && + (state === 'open' || state === 'infected') + ) { + renderedCells.set(cell as H3Cell, state); + } + } + const renderedCellCount = renderedCells.size; + const renderedInfectedCellCount = [...renderedCells.values()].filter( + (state) => state === 'infected', + ).length; + const ready = + expectedCells.size === expectedWorldCellCount && + renderedCellCount === expectedWorldCellCount && + [...expectedCells].every((cell) => renderedCells.has(cell)); + setOverlayDiagnostics({ + status: ready ? 'ready' : 'incomplete', + renderedCellCount, + renderedInfectedCellCount, + detail: ready + ? 'All expected H3 cells were returned by MapLibre.' + : `MapLibre rendered ${renderedCellCount} of ${expectedWorldCellCount} expected H3 cells.`, + }); + } catch { + setOverlayDiagnostics({ + status: 'failed', + renderedCellCount: 0, + renderedInfectedCellCount: 0, + detail: 'MapLibre could not inspect the rendered H3 layer.', + }); } - map.fitBounds(bounds, { padding: 56, maxZoom: 14, duration: 0 }); - setOverlayReady(true); - }); + }; + let inspectionPending = false; + const inspectAfterRender = () => { + if (!inspectionPending) return; + if ( + !map.getSource(sourceId) || + !map.getLayer(fillLayerId) || + !map.getLayer(lineLayerId) + ) { + inspectionPending = false; + inspectOverlay(); + return; + } + if (!map.isSourceLoaded(sourceId)) return; + inspectionPending = false; + inspectOverlay(); + }; + const scheduleOverlayInspection = () => { + inspectionPending = true; + map.triggerRepaint(); + }; + const inspectLoadedH3Source = (event: MapSourceDataEvent) => { + if (event.sourceId === sourceId && inspectionPending) { + map.triggerRepaint(); + } + }; + const selectRenderedCell = (event: MapLayerMouseEvent) => { + const cell = event.features?.[0]?.properties?.cell; + if (typeof cell === 'string') onSelectCellRef.current(cell as H3Cell); + }; + const showCellCursor = () => { + map.getCanvas().style.cursor = 'pointer'; + }; + const clearCellCursor = () => { + map.getCanvas().style.cursor = ''; + }; + const initializeOverlay = () => { + try { + map.addSource(sourceId, { + type: 'geojson', + data: asGeoJson(initialHexes.current, initialSelectedCell.current), + }); + map.addLayer({ + id: fillLayerId, + type: 'fill', + source: sourceId, + paint: { + 'fill-color': [ + 'match', + ['get', 'state'], + 'infected', + '#e44f45', + '#4a8178', + ], + 'fill-opacity': [ + 'case', + ['boolean', ['get', 'selected'], false], + 0.72, + 0.38, + ], + }, + }); + map.addLayer({ + id: lineLayerId, + type: 'line', + source: sourceId, + paint: { + 'line-color': [ + 'case', + ['boolean', ['get', 'selected'], false], + '#fff2c9', + '#b8d4cc', + ], + 'line-opacity': 0.95, + 'line-width': [ + 'case', + ['boolean', ['get', 'selected'], false], + 4, + 1.25, + ], + }, + }); + map.on('click', fillLayerId, selectRenderedCell); + map.on('mouseenter', fillLayerId, showCellCursor); + map.on('mouseleave', fillLayerId, clearCellCursor); + + const bounds = new LngLatBounds(); + for (const { cell } of initialHexes.current) { + for (const [lat, lng] of cellToBoundary(cell)) { + bounds.extend([lng, lat]); + } + } + map.fitBounds(bounds, { padding: 56, maxZoom: 14, duration: 0 }); + scheduleOverlayInspectionRef.current = scheduleOverlayInspection; + scheduleOverlayInspection(); + } catch { + setOverlayDiagnostics({ + status: 'failed', + renderedCellCount: 0, + renderedInfectedCellCount: 0, + detail: 'MapLibre rejected the H3 source or layer configuration.', + }); + } finally { + setMapReady(true); + } + }; + + map.on('sourcedata', inspectLoadedH3Source); + map.on('render', inspectAfterRender); + map.on('load', initializeOverlay); return () => { + scheduleOverlayInspectionRef.current = null; + map.off('load', initializeOverlay); + map.off('sourcedata', inspectLoadedH3Source); + map.off('render', inspectAfterRender); + map.off('click', fillLayerId, selectRenderedCell); + map.off('mouseenter', fillLayerId, showCellCursor); + map.off('mouseleave', fillLayerId, clearCellCursor); markersRef.current.forEach((marker) => marker.remove()); markersRef.current = []; map.remove(); @@ -164,12 +322,14 @@ export function WorldMap(props: WorldMapProps) { useEffect(() => { const source = mapRef.current?.getSource(sourceId) as GeoJSONSource | undefined; - source?.setData(asGeoJson(hexes, selectedCell)); + if (!source) return; + source.setData(asGeoJson(hexes, selectedCell)); + scheduleOverlayInspectionRef.current?.(); }, [hexes, selectedCell]); useEffect(() => { const map = mapRef.current; - if (!map || !overlayReady) return; + if (!map || !mapReady) return; markersRef.current.forEach((marker) => marker.remove()); markersRef.current = []; @@ -208,16 +368,32 @@ export function WorldMap(props: WorldMapProps) { .addTo(map); markersRef.current.push(marker); } - }, [agents, overlayReady, selectedAgentId]); + }, [agents, mapReady, selectedAgentId]); + + const overlayReady = overlayDiagnostics.status === 'ready'; + const overlayLabel = overlayReady + ? 'ready' + : overlayDiagnostics.status === 'initializing' + ? 'initializing' + : overlayDiagnostics.status; return (
    -
    -

    - H3 overlay {overlayReady ? 'ready' : 'initializing'} · {hexes.length}{' '} - cells · {agents.length} agents ·{' '} +

    +

    + H3 overlay {overlayLabel} · {overlayDiagnostics.renderedCellCount}/ + {expectedWorldCellCount} rendered cells · {agents.length} agents ·{' '} - {hexes.filter(({ state }) => state === 'infected').length} infected + {overlayDiagnostics.renderedInfectedCellCount} rendered infected

    diff --git a/docs/TESTING.md b/docs/TESTING.md index 07b3ee3..25f2fc5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -29,7 +29,7 @@ pnpm test:e2e - Simulation-service tests cover deterministic reset, round robin beyond retained history, monotonic total turn numbering, exactly one call per turn, latest-state observations, 120-record turn retention, 120-event retention, accepted/rejected/failure records, atomic rollback on internal validation failure, recovery, and turn/reset concurrency. - API integration tests validate snapshot, turn, reset, missing configuration, provider failure, internal-failure propagation and recovery, rejection, and typed conflict responses. - React tests cover controls/statuses, playback configuration, single turn, reset, agent/hex selection, inspector output, H3 readiness, and safe configuration errors. -- Playwright verifies 61 visible H3 cells are reported ready, six markers, marker/inspector interaction, infection layer data changes, Start/Pause, reset, and the explicit automated-test provider. +- Playwright uses real MapLibre feature inspection to verify 61 unique rendered H3 cells, rendered infection counts through a turn and reset, six markers, marker/inspector interaction, Start/Pause, and the explicit automated-test provider. ## Real-provider smoke diff --git a/tests/e2e/world-lab.spec.ts b/tests/e2e/world-lab.spec.ts index a7bead8..cca3761 100644 --- a/tests/e2e/world-lab.spec.ts +++ b/tests/e2e/world-lab.spec.ts @@ -31,14 +31,26 @@ test('runs the complete deterministic World Lab browser flow', async ({ } await expect(page.getByRole('heading', { name: 'World Lab' })).toBeVisible(); - await expect(page.getByTestId('world-map')).toBeVisible(); + const worldMap = page.getByTestId('world-map'); + await expect(worldMap).toBeVisible(); + await expect(worldMap).toHaveAttribute('data-overlay-status', 'ready'); + await expect(worldMap).toHaveAttribute('data-rendered-h3-cell-count', '61'); + await expect(worldMap).toHaveAttribute( + 'data-rendered-infected-cell-count', + '0', + ); await expect( - page.getByText(/H3 overlay ready · 61 cells · 6 agents/), + page.getByText(/H3 overlay ready · 61\/61 rendered cells · 6 agents/), ).toBeVisible(); - await expect(page.getByTestId('infected-count')).toHaveText('0 infected'); + await expect(page.getByTestId('infected-count')).toHaveText( + '0 rendered infected', + ); const markers = page.getByRole('button', { name: /Select agent/ }); await expect(markers).toHaveCount(6); + for (let index = 0; index < 6; index += 1) { + await expect(markers.nth(index)).toBeVisible(); + } await page.getByRole('button', { name: 'Select agent Rook' }).click(); await expect(page.getByRole('heading', { name: /Rook/ })).toBeVisible(); await expect( @@ -47,7 +59,14 @@ test('runs the complete deterministic World Lab browser flow', async ({ await page.getByRole('button', { name: 'Single turn' }).click(); await expect(page.getByText(/Infection ·/)).toBeVisible(); - await expect(page.getByTestId('infected-count')).toHaveText('1 infected'); + await expect(worldMap).toHaveAttribute('data-rendered-h3-cell-count', '61'); + await expect(worldMap).toHaveAttribute( + 'data-rendered-infected-cell-count', + '1', + ); + await expect(page.getByTestId('infected-count')).toHaveText( + '1 rendered infected', + ); await page.getByRole('button', { name: 'Start' }).click(); await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible(); @@ -59,7 +78,18 @@ test('runs the complete deterministic World Lab browser flow', async ({ await page.getByRole('button', { name: 'Reset' }).click(); await expect(page.getByText('Turn 0')).toBeVisible(); - await expect(page.getByTestId('infected-count')).toHaveText('0 infected'); + await expect(worldMap).toHaveAttribute('data-rendered-h3-cell-count', '61'); + await expect(worldMap).toHaveAttribute( + 'data-rendered-infected-cell-count', + '0', + ); + await expect(page.getByTestId('infected-count')).toHaveText( + '0 rendered infected', + ); + await expect(markers).toHaveCount(6); + for (let index = 0; index < 6; index += 1) { + await expect(markers.nth(index)).toBeVisible(); + } await expect( page.getByText('Development world loaded with six agents.'), ).toBeVisible(); From 7d9526f7c0b49666629913236a746a5d663287f8 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Fri, 14 Aug 2026 01:04:03 -0400 Subject: [PATCH 5/6] fix(agent-runtime): support Gemini OpenRouter requests --- .env.example | 2 +- README.md | 13 +- apps/game-api/src/app.test.ts | 19 +- docs/ARCHITECTURE.md | 4 +- docs/SECURITY.md | 8 +- docs/TESTING.md | 7 +- docs/adr/0002-first-visible-llm-invasion.md | 4 +- packages/agent-runtime/src/index.test.ts | 173 +++++++++++- packages/agent-runtime/src/index.ts | 257 ++++++++++++++++-- .../agent-runtime/src/real-provider-smoke.ts | 61 ++++- .../agent-runtime/src/smoke-environment.ts | 24 ++ packages/shared/src/index.test.ts | 2 +- 12 files changed, 507 insertions(+), 67 deletions(-) create mode 100644 packages/agent-runtime/src/smoke-environment.ts diff --git a/.env.example b/.env.example index 2d5b1a1..538fb99 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Server-only OpenRouter configuration. Never use NEXT_PUBLIC_ for this key. OPENROUTER_API_KEY=replace-with-your-openrouter-key -AGENTBORNE_MODEL=openai/gpt-5-mini +AGENTBORNE_MODEL=google/gemini-3.7-flash # The scripted provider is exclusively for deterministic automation. Normal # interactive development must leave this unset so every turn uses OpenRouter. diff --git a/README.md b/README.md index 86ef146..a417976 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,17 @@ Agentborne is the working title for an agent-first GPS containment game. The cur ## Local development -Requirements are Node.js 24.18.0 and pnpm 11.21.0. Copy the example environment file, replace only the placeholder key, install dependencies, and start both applications: +Requirements are Node.js 24.18.0 and pnpm 11.21.0. Copy the example environment file to the repository-root `.env`, replace only the placeholder key, install dependencies, and start both applications: ```bash -cp .env.example .env.local -# Edit .env.local and set OPENROUTER_API_KEY. +cp .env.example .env +# Edit .env and set OPENROUTER_API_KEY. corepack enable pnpm install --frozen-lockfile pnpm dev ``` -Open the World Lab at . The Game API binds to ; Next.js narrowly proxies `/api/game/*` to it. `AGENTBORNE_MODEL` defaults to `openai/gpt-5-mini`. +Open the World Lab at . The Game API binds to ; Next.js narrowly proxies `/api/game/*` to it. `AGENTBORNE_MODEL` defaults to `google/gemini-3.7-flash`. Each Single turn or completed playback interval makes one third-party OpenRouter request and may incur cost. Start is deliberately disabled when the server has no key. This development API has no authentication or cost controls and is not suitable for an unauthenticated public deployment. @@ -35,12 +35,11 @@ State is held only in the Game API process. Restarting the API or pressing Reset The smoke command performs exactly one bounded real decision request and validates it. It is never part of default tests or CI: ```bash -set -a -. ./.env.local -set +a pnpm smoke:openrouter ``` +The command uses Node 24's built-in environment parser to read only `OPENROUTER_API_KEY` and `AGENTBORNE_MODEL` from the repository-root `.env`. Values in that file override stale exported values for the smoke process. + ## Development map source The map centers on Toledo, Ohio (`41.6528, -83.5379`) at H3 resolution 9 and renders a radius-four disk. MapLibre uses the public OpenStreetMap raster tile endpoint with attribution for light local development only. A public deployment must choose a compliant production tile source. diff --git a/apps/game-api/src/app.test.ts b/apps/game-api/src/app.test.ts index 953be32..c79b1b2 100644 --- a/apps/game-api/src/app.test.ts +++ b/apps/game-api/src/app.test.ts @@ -66,11 +66,19 @@ describe('game API simulation boundary', () => { model: 'failure-test', configured: true, async decide() { - throw new AgentProviderError({ - code: 'network', - message: 'The model provider could not be reached.', - retryable: true, - }); + throw new AgentProviderError( + { + code: 'network', + message: 'The model provider could not be reached.', + retryable: true, + }, + undefined, + { + httpStatus: 400, + providerMessage: 'internal-diagnostic-marker', + model: 'google/gemini-3.7-flash', + }, + ); }, }; const failed = singleTurnResponseSchema.parse( @@ -82,6 +90,7 @@ describe('game API simulation boundary', () => { ).json(), ); expect(failed.turn.outcome).toBe('provider-error'); + expect(JSON.stringify(failed)).not.toContain('internal-diagnostic-marker'); const missing = createApp({ provider: new OpenRouterAgentProvider() }); const snapshot = simulationSnapshotSchema.parse( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c711f1a..cb37040 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -34,6 +34,8 @@ Only failures thrown by the provider decision call become sanitized provider-fai `packages/world-engine` remains deterministic and has no model, HTTP, UI, storage, or credential dependency. Its broader domain action union retains range-limited messaging for the established boundary, but PR 2's model decision schema excludes messaging entirely. -`packages/agent-runtime` contains the OpenRouter adapter. The interactive provider uses strict JSON Schema structured output and requires provider parameter support. Its abort timeout covers the complete response lifecycle, including body reading, JSON decoding, response extraction, and schema validation, and is cleared after every outcome. Scripted providers are explicit deterministic seams selected only by tests or `AGENTBORNE_PROVIDER=scripted`; there is no automatic fallback. +`packages/agent-runtime` contains the OpenRouter adapter. The interactive provider defaults to `google/gemini-3.7-flash`, uses one strict root-object JSON Schema request with a nested `anyOf` action union and single-value discriminator enums, and requires provider parameter support. The bounded request uses `max_tokens: 1024` plus low-effort reasoning excluded from the response; it does not request, retain, or expose reasoning content. Zod and the world engine remain authoritative for detailed string, H3, action, adjacency, infection-state, and consequence validation. + +The provider abort timeout covers the complete response lifecycle, including body reading, JSON decoding, response extraction, and schema validation, and is cleared after every outcome. Non-success responses retain only bounded, sanitized in-process diagnostics for the opt-in CLI smoke; those details do not enter simulation records or API responses. Scripted providers are explicit deterministic seams selected only by tests or `AGENTBORNE_PROVIDER=scripted`; there is no automatic fallback. The rationale and deferrals are recorded in [ADR 0002](adr/0002-first-visible-llm-invasion.md). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 9748b15..1a3fe06 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2,7 +2,7 @@ ## Secrets and deployment -`OPENROUTER_API_KEY` is read only by the Game API process. It must never use a `NEXT_PUBLIC_` name, enter a shared schema, reach an API response, or appear in logs. `.env.local` is ignored; `.env.example` contains only a placeholder. Provider errors are reduced to bounded codes and generic messages before recording. +`OPENROUTER_API_KEY` is read only by the Game API process. It must never use a `NEXT_PUBLIC_` name, enter a shared schema, reach an API response, or appear in logs. The repository-root `.env` is ignored; `.env.example` contains only a placeholder. Provider errors are reduced to bounded codes and generic messages before recording. The development API has no authentication, rate limiting, or spending guard. It binds to loopback and its CORS allowlist is limited to the documented local World Lab origins. Do not deploy its cost-incurring turn endpoint to unauthenticated public traffic. @@ -10,11 +10,13 @@ The development API has no authentication, rate limiting, or spending guard. It OpenRouter receives one immutable structured observation. It never receives a mutable world object, storage handle, browser credential, or authority to claim success. The returned JSON is parsed and runtime-validated before the deterministic world engine validates and applies the action. -The request uses strict JSON Schema structured output and provider routing with `require_parameters: true`. Timeouts, network failures, HTTP failures, malformed JSON, and schema failures produce sanitized provider-failure records. The adapter never silently substitutes scripted behavior. +The request uses strict JSON Schema structured output and provider routing with `require_parameters: true`. Its Gemini-compatible action union uses nested `anyOf` object variants with single-value enums. Timeouts, network failures, HTTP failures, malformed JSON, and schema failures produce sanitized provider-failure records. The adapter never silently substitutes scripted behavior. + +Non-success OpenRouter bodies are read only up to a fixed bound. The adapter extracts a sanitized status, provider code/message, request ID, and selected model for local CLI diagnosis, redacts credentials and observation strings, and discards the raw body. These diagnostics remain in-process and are excluded from turn records, snapshots, browser responses, and normal logs. ## Prompt and reasoning data -The model is explicitly instructed to return only one structured action and one concise visible summary, with no hidden reasoning or chain-of-thought. The application stores no raw prompts, raw provider payloads, or private reasoning. Inspector and event-log content is rendered as React text. +The model is explicitly instructed to return only one structured action and one concise visible summary, with no hidden reasoning or chain-of-thought. The request selects low reasoning effort and sets `exclude: true`, so reasoning is not returned. The application stores no raw prompts, raw provider payloads, or private reasoning. Inspector and event-log content is rendered as React text. Agent-authored content remains untrusted. Messaging is not exposed to PR 2 providers or UI and remains deferred. diff --git a/docs/TESTING.md b/docs/TESTING.md index 25f2fc5..e1238d3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -15,8 +15,9 @@ pnpm lint pnpm typecheck pnpm test pnpm build -pnpm exec playwright install chromium +pnpm exec playwright install chromium # only if Chromium is not already installed pnpm test:e2e +pnpm smoke:openrouter ``` `pnpm validate` aggregates formatting, lint, type checking, unit/integration tests, and builds. Playwright remains separate. Its web server starts both applications with the explicit deterministic provider; `AGENTBORNE_PROVIDER=scripted` is never an implicit OpenRouter fallback. @@ -25,7 +26,7 @@ pnpm test:e2e - Shared schema tests cover valid/invalid observations and decisions, all turn outcomes, response snapshots, 120-record turn and 120-event history bounds, and model-authored string limits. - World-engine tests cover adjacent and non-adjacent movement, infection persistence, repeated-infection rejection, wait, immutability on rejection, and deterministic 61-cell/six-agent construction. -- Agent-runtime tests inspect strict OpenRouter requests, prompt placement, parsing, runtime validation, malformed/unsupported responses, body-inclusive timeouts and timer cleanup, sanitization, secret non-leakage, missing configuration, and explicit scripted output. +- Agent-runtime tests recursively inspect the actual strict OpenRouter schema and Gemini-compatible parameters, prompt placement, explicit model overrides, parsing, runtime validation, malformed/unsupported responses, body-inclusive timeouts and timer cleanup, bounded safe HTTP diagnostics, secret non-leakage, missing configuration, and explicit scripted output. - Simulation-service tests cover deterministic reset, round robin beyond retained history, monotonic total turn numbering, exactly one call per turn, latest-state observations, 120-record turn retention, 120-event retention, accepted/rejected/failure records, atomic rollback on internal validation failure, recovery, and turn/reset concurrency. - API integration tests validate snapshot, turn, reset, missing configuration, provider failure, internal-failure propagation and recovery, rejection, and typed conflict responses. - React tests cover controls/statuses, playback configuration, single turn, reset, agent/hex selection, inspector output, H3 readiness, and safe configuration errors. @@ -33,4 +34,4 @@ pnpm test:e2e ## Real-provider smoke -The separately opted-in `pnpm smoke:openrouter` command makes one bounded OpenRouter call and validates the decision. It requires an exported `OPENROUTER_API_KEY`, incurs third-party cost, and is intentionally absent from `pnpm test`, `pnpm validate`, Playwright, and CI. +The separately opted-in `pnpm smoke:openrouter` command makes one bounded OpenRouter call and validates the decision. Node 24's built-in environment parser reads only `OPENROUTER_API_KEY` and `AGENTBORNE_MODEL` from the repository-root `.env` for this command, and file values override stale exported values for the smoke process. A missing file still yields the sanitized missing-configuration failure. The call incurs third-party cost and is intentionally absent from `pnpm test`, `pnpm validate`, Playwright, and CI. On failure, the CLI may print only the bounded safe diagnostic fields retained by the adapter. diff --git a/docs/adr/0002-first-visible-llm-invasion.md b/docs/adr/0002-first-visible-llm-invasion.md index cfa220c..485c2e5 100644 --- a/docs/adr/0002-first-visible-llm-invasion.md +++ b/docs/adr/0002-first-visible-llm-invasion.md @@ -15,9 +15,11 @@ Turns advance one agent at a time in deterministic round-robin order. The applic Normal interactive development selects OpenRouter and reports missing configuration or provider failure explicitly. A scripted provider exists only as an explicitly selected deterministic test seam. It is not a fallback and is visibly labeled in the World Lab. +The OpenRouter path targets `google/gemini-3.7-flash` with required parameter support, a bounded `max_tokens` budget, and low-effort reasoning excluded from responses. Its strict root-object response schema expresses the three action variants through a nested `anyOf` with single-value discriminator enums; runtime schemas and the world engine remain authoritative for all detailed validation and consequences. + Turn records contain bounded observations, actions, summaries, validation results, events, and safe provider metadata. They never contain raw prompts, raw payloads, secrets, or private chain-of-thought. The total completed-turn count is monotonic and separate from the newest 120 retained turn records. The authoritative world likewise retains only its newest 120 events, while observations receive the newest eight relevant events in chronological order. -Accepted and rejected turn records are fully schema-validated before the service atomically commits the candidate world, record, count, and cursor. Only errors originating in the provider decision call are converted to sanitized, recorded provider failures. Other engine, schema, or internal errors propagate without changing simulation progress, and turn cleanup restores a coherent idle status. OpenRouter's timeout remains active through body reading, decoding, extraction, and runtime validation, with final cleanup on every outcome. +Accepted and rejected turn records are fully schema-validated before the service atomically commits the candidate world, record, count, and cursor. Only errors originating in the provider decision call are converted to sanitized, recorded provider failures. Other engine, schema, or internal errors propagate without changing simulation progress, and turn cleanup restores a coherent idle status. OpenRouter's timeout remains active through body reading, decoding, extraction, and runtime validation, with final cleanup on every outcome. Bounded sanitized HTTP diagnostics are available only to the opt-in smoke CLI and never enter simulation records or browser responses. ## Consequences diff --git a/packages/agent-runtime/src/index.test.ts b/packages/agent-runtime/src/index.test.ts index 43b956b..9dbdc5d 100644 --- a/packages/agent-runtime/src/index.test.ts +++ b/packages/agent-runtime/src/index.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it, vi } from 'vitest'; import { agentObservationSchema } from '@agentborne/shared'; import { AgentProviderError, + DEFAULT_OPENROUTER_MODEL, OpenRouterAgentProvider, ScriptedAgentProvider, buildOpenRouterRequest, } from '.'; +import { applySmokeEnvironmentFile } from './smoke-environment'; const observation = agentObservationSchema.parse({ agentId: '128f3f38-6b7d-4db7-9e95-751b4ce2681e', @@ -21,7 +23,7 @@ function response(content: string, status = 200) { return new Response( JSON.stringify({ id: 'request-safe-id', - model: 'openai/gpt-5-mini', + model: 'google/gemini-3.7-flash', choices: [{ message: { content } }], usage: { prompt_tokens: 20, completion_tokens: 12 }, }), @@ -29,18 +31,82 @@ function response(content: string, status = 200) { ); } +function errorResponse({ + status, + code, + message, + requestId = 'safe-request-id', +}: { + status: number; + code?: string | number; + message?: string; + requestId?: string; +}) { + return new Response(JSON.stringify({ error: { code, message } }), { + status, + headers: { + 'Content-Type': 'application/json', + 'x-request-id': requestId, + }, + }); +} + +function visitJsonValues( + value: unknown, + visitor: (value: Record) => void, +) { + if (Array.isArray(value)) { + value.forEach((item) => visitJsonValues(item, visitor)); + return; + } + if (typeof value !== 'object' || value === null) return; + const record = value as Record; + visitor(record); + Object.values(record).forEach((item) => visitJsonValues(item, visitor)); +} + describe('OpenRouterAgentProvider', () => { - it('constructs a strict structured-output request with personality and observation data', () => { + it('constructs a Gemini-compatible strict structured-output request', () => { const request = buildOpenRouterRequest(observation); + expect(request.model).toBe('google/gemini-3.7-flash'); + expect(DEFAULT_OPENROUTER_MODEL).toBe('google/gemini-3.7-flash'); expect(request.response_format).toMatchObject({ type: 'json_schema', json_schema: { strict: true }, }); expect(request.provider).toEqual({ require_parameters: true }); + expect(request.stream).toBe(false); + expect(request.max_tokens).toBe(1024); + expect(request).not.toHaveProperty('max_completion_tokens'); + expect(request).not.toHaveProperty('include_reasoning'); + expect(request).not.toHaveProperty('reasoning_effort'); + expect(request.reasoning).toEqual({ effort: 'low', exclude: true }); expect(request.messages[1]!.content).toContain(observation.personality); expect(request.messages[0]!.content).toContain('Never produce messages'); - expect(request.max_completion_tokens).toBe(180); - expect(request).not.toHaveProperty('max_tokens'); + + const schema = request.response_format.json_schema.schema; + expect(schema.type).toBe('object'); + expect(schema.properties.requestedAction).toHaveProperty('anyOf'); + expect(schema.properties.requestedAction.anyOf).toHaveLength(3); + + visitJsonValues(schema, (schemaNode) => { + expect(schemaNode).not.toHaveProperty('oneOf'); + expect(schemaNode).not.toHaveProperty('const'); + if (schemaNode.type !== 'object') return; + expect(schemaNode.additionalProperties).toBe(false); + const properties = schemaNode.properties as + Record | undefined; + expect(properties).toBeDefined(); + expect([...(schemaNode.required as string[])].sort()).toEqual( + Object.keys(properties!).sort(), + ); + }); + }); + + it('preserves an explicit model override', () => { + expect( + buildOpenRouterRequest(observation, 'custom/provider-model').model, + ).toBe('custom/provider-model'); }); it('parses and runtime-validates a structured decision', async () => { @@ -67,6 +133,7 @@ describe('OpenRouterAgentProvider', () => { expect(JSON.parse(String(capturedInit?.body))).toMatchObject({ provider: { require_parameters: true }, }); + expect(fetchImplementation).toHaveBeenCalledTimes(1); }); it.each([ @@ -95,11 +162,58 @@ describe('OpenRouterAgentProvider', () => { }, ); - it('sanitizes HTTP and network errors without leaking the key', async () => { + it.each([ + [400, 'The model provider rejected the request configuration.', false], + [ + 404, + 'The selected model is unavailable or no endpoint supports all required parameters.', + false, + ], + [429, 'The model provider rate limited the request.', true], + [503, 'The model provider is unavailable.', true], + ])( + 'maps HTTP %i to a clear sanitized public failure', + async (status, message, retryable) => { + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + fetchImplementation: vi.fn(async () => + errorResponse({ + status, + code: 'provider_error', + message: 'Safe provider detail.', + }), + ), + }); + await expect(provider.decide(observation)).rejects.toMatchObject({ + failure: { code: 'provider-http', message, retryable }, + diagnostics: { + httpStatus: status, + providerCode: 'provider_error', + providerMessage: 'Safe provider detail.', + requestId: 'safe-request-id', + model: 'google/gemini-3.7-flash', + }, + }); + }, + ); + + it('sanitizes HTTP diagnostics and network errors without leaking sensitive data', async () => { const key = 'highly-sensitive-key'; + const injectedSensitiveString = 'injected-sensitive-observation'; + const sensitiveObservation = agentObservationSchema.parse({ + ...observation, + personality: injectedSensitiveString, + }); const httpProvider = new OpenRouterAgentProvider({ apiKey: key, - fetchImplementation: vi.fn(async () => response('{}', 500)), + fetchImplementation: vi.fn(async () => + errorResponse({ + status: 400, + code: `invalid-${key}`, + message: `Authorization: Bearer ${key}; observation=${injectedSensitiveString}`, + requestId: `request-${key}`, + }), + ), }); const networkProvider = new OpenRouterAgentProvider({ apiKey: key, @@ -109,15 +223,40 @@ describe('OpenRouterAgentProvider', () => { }); for (const provider of [httpProvider, networkProvider]) { try { - await provider.decide(observation); + await provider.decide( + provider === httpProvider ? sensitiveObservation : observation, + ); } catch (error) { expect(error).toBeInstanceOf(AgentProviderError); expect(String(error)).not.toContain(key); expect(JSON.stringify(error)).not.toContain(key); + expect(JSON.stringify(error)).not.toContain(injectedSensitiveString); } } }); + it('reads only a bounded OpenRouter error body', async () => { + const oversizedMessage = 'safe-detail '.repeat(4_000); + const provider = new OpenRouterAgentProvider({ + apiKey: 'secret-test-key', + fetchImplementation: vi.fn(async () => + errorResponse({ + status: 400, + code: 'invalid_request', + message: oversizedMessage, + }), + ), + }); + try { + await provider.decide(observation); + } catch (error) { + expect(error).toBeInstanceOf(AgentProviderError); + expect((error as AgentProviderError).diagnostics?.providerMessage).toBe( + undefined, + ); + } + }); + it('times out a bounded request', async () => { const provider = new OpenRouterAgentProvider({ apiKey: 'secret-test-key', @@ -200,6 +339,26 @@ describe('OpenRouterAgentProvider', () => { }); }); +describe('OpenRouter smoke environment', () => { + it('loads only server provider values and overrides stale exports', () => { + const environment: Record = { + OPENROUTER_API_KEY: 'exported-key', + }; + applySmokeEnvironmentFile( + [ + 'OPENROUTER_API_KEY=file-key', + 'AGENTBORNE_MODEL=google/gemini-3.7-flash', + 'NEXT_PUBLIC_GAME_API_BASE_URL=https://browser.example', + ].join('\n'), + environment, + ); + expect(environment).toEqual({ + OPENROUTER_API_KEY: 'file-key', + AGENTBORNE_MODEL: 'google/gemini-3.7-flash', + }); + }); +}); + describe('ScriptedAgentProvider', () => { it('returns explicitly scripted decisions in order', async () => { const provider = new ScriptedAgentProvider([ diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 846830a..0185a59 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -8,10 +8,13 @@ import { type ProviderMetadata, } from '@agentborne/shared'; -export const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-5-mini'; +export const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3.7-flash'; export const OPENROUTER_ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions'; +const OPENROUTER_ERROR_BODY_MAX_BYTES = 16_384; +const OPENROUTER_DIAGNOSTIC_MESSAGE_MAX_LENGTH = 240; + export interface ProviderDecision { decision: AgentDecision; metadata: ProviderMetadata; @@ -29,28 +32,34 @@ export class AgentProviderError extends Error { constructor( readonly failure: ProviderFailure, readonly metadata?: ProviderMetadata, + readonly diagnostics?: OpenRouterFailureDiagnostics, ) { super(failure.message); this.name = 'AgentProviderError'; } } +export interface OpenRouterFailureDiagnostics { + httpStatus: number; + providerCode?: string; + providerMessage?: string; + requestId?: string; + model: string; +} + const decisionJsonSchema = { type: 'object', additionalProperties: false, properties: { requestedAction: { - oneOf: [ + anyOf: [ { type: 'object', additionalProperties: false, properties: { - type: { const: 'move' }, + type: { type: 'string', enum: ['move'] }, targetCell: { type: 'string', - pattern: '^[0-9a-fA-F]{15}$', - description: - 'One target H3 index copied exactly from adjacentCells.', }, }, required: ['type', 'targetCell'], @@ -58,23 +67,23 @@ const decisionJsonSchema = { { type: 'object', additionalProperties: false, - properties: { type: { const: 'infect' } }, + properties: { + type: { type: 'string', enum: ['infect'] }, + }, required: ['type'], }, { type: 'object', additionalProperties: false, - properties: { type: { const: 'wait' } }, + properties: { + type: { type: 'string', enum: ['wait'] }, + }, required: ['type'], }, ], }, summary: { type: 'string', - minLength: 1, - maxLength: 240, - description: - 'A concise user-visible decision summary, not hidden reasoning.', }, }, required: ['requestedAction', 'summary'], @@ -110,7 +119,11 @@ export function buildOpenRouterRequest( }, }, provider: { require_parameters: true }, - max_completion_tokens: 180, + max_tokens: 1024, + reasoning: { + effort: 'low' as const, + exclude: true, + }, stream: false, }; } @@ -173,6 +186,8 @@ export class OpenRouterAgentProvider implements AgentProvider { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.#timeoutMs); try { + const providerRequest = buildOpenRouterRequest(observation, this.model); + const requestBody = JSON.stringify(providerRequest); let response: Response; try { response = await this.#fetch(OPENROUTER_ENDPOINT, { @@ -181,7 +196,7 @@ export class OpenRouterAgentProvider implements AgentProvider { Authorization: `Bearer ${this.#apiKey}`, 'Content-Type': 'application/json', }, - body: JSON.stringify(buildOpenRouterRequest(observation, this.model)), + body: requestBody, signal: controller.signal, }); } catch { @@ -189,17 +204,29 @@ export class OpenRouterAgentProvider implements AgentProvider { } if (!response.ok) { + const sensitiveValues = collectSensitiveValues({ + apiKey: this.#apiKey, + observation, + additionalValues: [ + requestBody, + ...providerRequest.messages.map(({ content }) => content), + ], + }); + const diagnostics = await readOpenRouterFailureDiagnostics({ + response, + model: this.model, + sensitiveValues, + }).catch(() => undefined); + if (controller.signal.aborted) throw requestFailure(true); + const failure = httpFailure(response.status); throw new AgentProviderError( - { - code: 'provider-http', - message: `The model provider returned HTTP ${response.status}.`, - retryable: response.status === 429 || response.status >= 500, - }, - { - provider: 'openrouter', - model: this.model, - requestId: response.headers.get('x-request-id') ?? undefined, - latencyMs: Date.now() - started, + failure, + undefined, + diagnostics ?? { + httpStatus: response.status, + model: + sanitizeDiagnosticMessage(this.model, sensitiveValues, 120) ?? + '[redacted]', }, ); } @@ -282,6 +309,188 @@ function requestFailure(timedOut: boolean): AgentProviderError { }); } +function httpFailure(status: number): ProviderFailure { + if (status === 400) { + return { + code: 'provider-http', + message: 'The model provider rejected the request configuration.', + retryable: false, + }; + } + if (status === 404) { + return { + code: 'provider-http', + message: + 'The selected model is unavailable or no endpoint supports all required parameters.', + retryable: false, + }; + } + if (status === 429) { + return { + code: 'provider-http', + message: 'The model provider rate limited the request.', + retryable: true, + }; + } + if (status >= 500) { + return { + code: 'provider-http', + message: 'The model provider is unavailable.', + retryable: true, + }; + } + return { + code: 'provider-http', + message: `The model provider returned HTTP ${status}.`, + retryable: false, + }; +} + +async function readOpenRouterFailureDiagnostics({ + response, + model, + sensitiveValues, +}: { + response: Response; + model: string; + sensitiveValues: string[]; +}): Promise { + const rawText = await readBoundedText( + response, + OPENROUTER_ERROR_BODY_MAX_BYTES, + ); + let payload: unknown; + try { + payload = JSON.parse(rawText); + } catch { + payload = undefined; + } + const root = asRecord(payload); + const error = asRecord(root?.error); + const metadata = asRecord(error?.metadata); + const providerCode = sanitizeDiagnosticCode(error?.code, sensitiveValues); + const providerMessage = sanitizeDiagnosticMessage( + error?.message, + sensitiveValues, + ); + const requestId = sanitizeDiagnosticCode( + response.headers.get('x-request-id') ?? + root?.request_id ?? + metadata?.request_id, + sensitiveValues, + 160, + ); + return { + httpStatus: response.status, + providerCode, + providerMessage, + requestId, + model: + sanitizeDiagnosticMessage(model, sensitiveValues, 120) ?? + DEFAULT_OPENROUTER_MODEL, + }; +} + +async function readBoundedText( + response: Response, + maximumBytes: number, +): Promise { + if (!response.body) return ''; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let remaining = maximumBytes; + let output = ''; + try { + while (remaining > 0) { + const { done, value } = await reader.read(); + if (done) break; + const accepted = value.subarray(0, remaining); + output += decoder.decode(accepted, { stream: true }); + remaining -= accepted.byteLength; + if (accepted.byteLength < value.byteLength) { + await reader.cancel(); + remaining = -1; + break; + } + } + if (remaining === 0) await reader.cancel(); + output += decoder.decode(); + return output; + } finally { + reader.releaseLock(); + } +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function collectSensitiveValues({ + apiKey, + observation, + additionalValues, +}: { + apiKey: string; + observation: AgentObservation; + additionalValues: string[]; +}): string[] { + const values = new Set([apiKey]); + const visit = (value: unknown) => { + if (typeof value === 'string') { + if (value.length >= 4) values.add(value); + return; + } + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + if (typeof value === 'object' && value !== null) { + Object.values(value).forEach(visit); + } + }; + visit(observation); + visit(additionalValues); + return [...values].toSorted((left, right) => right.length - left.length); +} + +function sanitizeDiagnosticCode( + value: unknown, + sensitiveValues: string[], + maximumLength = 80, +): string | undefined { + const sanitized = sanitizeDiagnosticMessage( + typeof value === 'number' ? String(value) : value, + sensitiveValues, + maximumLength, + ); + if (!sanitized) return undefined; + return /^[a-zA-Z0-9_.:/-]+$/.test(sanitized) ? sanitized : undefined; +} + +function sanitizeDiagnosticMessage( + value: unknown, + sensitiveValues: string[], + maximumLength = OPENROUTER_DIAGNOSTIC_MESSAGE_MAX_LENGTH, +): string | undefined { + if (typeof value !== 'string') return undefined; + let sanitized = value + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + sanitized = sanitized.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]'); + sanitized = sanitized.replace(/sk-or-[a-zA-Z0-9_-]+/g, '[redacted]'); + for (const sensitiveValue of sensitiveValues) { + sanitized = sanitized.replaceAll(sensitiveValue, '[redacted]'); + } + if (/"(?:authorization|messages|observation)"\s*:/i.test(sanitized)) { + return 'Provider diagnostic details were redacted.'; + } + const bounded = sanitized.slice(0, maximumLength).trim(); + return bounded || undefined; +} + export class ScriptedAgentProvider implements AgentProvider { readonly mode = 'scripted-test' as const; readonly model = 'deterministic-script'; diff --git a/packages/agent-runtime/src/real-provider-smoke.ts b/packages/agent-runtime/src/real-provider-smoke.ts index 2408aef..cf93331 100644 --- a/packages/agent-runtime/src/real-provider-smoke.ts +++ b/packages/agent-runtime/src/real-provider-smoke.ts @@ -1,10 +1,27 @@ +import { readFileSync } from 'node:fs'; import { latLngToCell, gridDisk } from 'h3-js'; import { agentIdSchema, agentObservationSchema, h3CellSchema, } from '@agentborne/shared'; -import { OpenRouterAgentProvider } from './index'; +import { AgentProviderError, OpenRouterAgentProvider } from './index'; +import { applySmokeEnvironmentFile } from './smoke-environment'; + +try { + applySmokeEnvironmentFile( + readFileSync(new URL('../../../.env', import.meta.url), 'utf8'), + ); +} catch (error) { + if ( + typeof error !== 'object' || + error === null || + !('code' in error) || + error.code !== 'ENOENT' + ) { + throw error; + } +} const currentCell = h3CellSchema.parse(latLngToCell(41.6528, -83.5379, 9)); const adjacentCells = gridDisk(currentCell, 1) @@ -25,16 +42,32 @@ const observation = agentObservationSchema.parse({ recentEvents: [], }); -const result = await provider.decide(observation); -console.log( - JSON.stringify( - { - valid: true, - decision: result.decision, - provider: result.metadata.provider, - model: result.metadata.model, - }, - null, - 2, - ), -); +try { + const result = await provider.decide(observation); + console.log( + JSON.stringify( + { + valid: true, + decision: result.decision, + provider: result.metadata.provider, + model: result.metadata.model, + }, + null, + 2, + ), + ); +} catch (error) { + if (!(error instanceof AgentProviderError)) throw error; + console.error( + JSON.stringify( + { + valid: false, + failure: error.failure, + diagnostics: error.diagnostics, + }, + null, + 2, + ), + ); + process.exitCode = 1; +} diff --git a/packages/agent-runtime/src/smoke-environment.ts b/packages/agent-runtime/src/smoke-environment.ts new file mode 100644 index 0000000..a9d3cc9 --- /dev/null +++ b/packages/agent-runtime/src/smoke-environment.ts @@ -0,0 +1,24 @@ +import { parseEnv } from 'node:util'; + +const smokeEnvironmentNames = [ + 'OPENROUTER_API_KEY', + 'AGENTBORNE_MODEL', +] as const; + +export function applySmokeEnvironmentFile( + contents: string, + environment: Record = process.env, +): void { + let fileEnvironment: Record; + try { + fileEnvironment = parseEnv(contents); + } catch { + throw new Error('The repository .env file could not be parsed.'); + } + + for (const name of smokeEnvironmentNames) { + if (fileEnvironment[name] !== undefined) { + environment[name] = fileEnvironment[name]; + } + } +} diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 8425ed0..447706c 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -28,7 +28,7 @@ const baseTurn = { }; const provider = { provider: 'openrouter', - model: 'openai/gpt-5-mini', + model: 'google/gemini-3.7-flash', latencyMs: 100, }; const event = { From 7e288b46be7156cd2327249a87eb80a08dd70942 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Fri, 14 Aug 2026 01:21:12 -0400 Subject: [PATCH 6/6] fix(game-api): honor repository provider environment --- apps/game-api/src/server.ts | 7 ++++++- packages/agent-runtime/src/index.test.ts | 6 +++--- packages/agent-runtime/src/index.ts | 2 ++ .../{smoke-environment.ts => provider-environment.ts} | 6 +++--- packages/agent-runtime/src/real-provider-smoke.ts | 9 ++++++--- 5 files changed, 20 insertions(+), 10 deletions(-) rename packages/agent-runtime/src/{smoke-environment.ts => provider-environment.ts} (79%) diff --git a/apps/game-api/src/server.ts b/apps/game-api/src/server.ts index 7d4accd..a76e8d7 100644 --- a/apps/game-api/src/server.ts +++ b/apps/game-api/src/server.ts @@ -1,8 +1,13 @@ +import { readFileSync } from 'node:fs'; import { serve } from '@hono/node-server'; +import { applyProviderEnvironmentFile } from '@agentborne/agent-runtime'; import { createApp } from './app'; try { - process.loadEnvFile('../../.env.local'); + process.loadEnvFile('../../.env'); + applyProviderEnvironmentFile( + readFileSync(new URL('../../../.env', import.meta.url), 'utf8'), + ); } catch (error) { const code = error instanceof Error && 'code' in error ? error.code : undefined; diff --git a/packages/agent-runtime/src/index.test.ts b/packages/agent-runtime/src/index.test.ts index 9dbdc5d..de55ee8 100644 --- a/packages/agent-runtime/src/index.test.ts +++ b/packages/agent-runtime/src/index.test.ts @@ -7,7 +7,7 @@ import { ScriptedAgentProvider, buildOpenRouterRequest, } from '.'; -import { applySmokeEnvironmentFile } from './smoke-environment'; +import { applyProviderEnvironmentFile } from './provider-environment'; const observation = agentObservationSchema.parse({ agentId: '128f3f38-6b7d-4db7-9e95-751b4ce2681e', @@ -339,12 +339,12 @@ describe('OpenRouterAgentProvider', () => { }); }); -describe('OpenRouter smoke environment', () => { +describe('OpenRouter provider environment', () => { it('loads only server provider values and overrides stale exports', () => { const environment: Record = { OPENROUTER_API_KEY: 'exported-key', }; - applySmokeEnvironmentFile( + applyProviderEnvironmentFile( [ 'OPENROUTER_API_KEY=file-key', 'AGENTBORNE_MODEL=google/gemini-3.7-flash', diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 0185a59..3ca5c5b 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -8,6 +8,8 @@ import { type ProviderMetadata, } from '@agentborne/shared'; +export { applyProviderEnvironmentFile } from './provider-environment'; + export const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3.7-flash'; export const OPENROUTER_ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions'; diff --git a/packages/agent-runtime/src/smoke-environment.ts b/packages/agent-runtime/src/provider-environment.ts similarity index 79% rename from packages/agent-runtime/src/smoke-environment.ts rename to packages/agent-runtime/src/provider-environment.ts index a9d3cc9..6f65789 100644 --- a/packages/agent-runtime/src/smoke-environment.ts +++ b/packages/agent-runtime/src/provider-environment.ts @@ -1,11 +1,11 @@ import { parseEnv } from 'node:util'; -const smokeEnvironmentNames = [ +const providerEnvironmentNames = [ 'OPENROUTER_API_KEY', 'AGENTBORNE_MODEL', ] as const; -export function applySmokeEnvironmentFile( +export function applyProviderEnvironmentFile( contents: string, environment: Record = process.env, ): void { @@ -16,7 +16,7 @@ export function applySmokeEnvironmentFile( throw new Error('The repository .env file could not be parsed.'); } - for (const name of smokeEnvironmentNames) { + for (const name of providerEnvironmentNames) { if (fileEnvironment[name] !== undefined) { environment[name] = fileEnvironment[name]; } diff --git a/packages/agent-runtime/src/real-provider-smoke.ts b/packages/agent-runtime/src/real-provider-smoke.ts index cf93331..58af00b 100644 --- a/packages/agent-runtime/src/real-provider-smoke.ts +++ b/packages/agent-runtime/src/real-provider-smoke.ts @@ -5,11 +5,14 @@ import { agentObservationSchema, h3CellSchema, } from '@agentborne/shared'; -import { AgentProviderError, OpenRouterAgentProvider } from './index'; -import { applySmokeEnvironmentFile } from './smoke-environment'; +import { + AgentProviderError, + OpenRouterAgentProvider, + applyProviderEnvironmentFile, +} from './index'; try { - applySmokeEnvironmentFile( + applyProviderEnvironmentFile( readFileSync(new URL('../../../.env', import.meta.url), 'utf8'), ); } catch (error) {