diff --git a/apps/game-api/src/reflex-execution.test.ts b/apps/game-api/src/reflex-execution.test.ts index 428496c..1e8d94b 100644 --- a/apps/game-api/src/reflex-execution.test.ts +++ b/apps/game-api/src/reflex-execution.test.ts @@ -81,26 +81,23 @@ describe('zero-swarm reflex execution seam', () => { expect(applied.state.agents.get(agent.id)?.currentCell).toBe(targetCell); }); - it('passes bounded authoritative capture facts to Jev without player route data', () => { + it('retains only the four most recent authoritative capture alerts', () => { const { state, directive } = fixture(); + const capturedAgentId = [...state.agents.keys()][0]!; const compiled = compileReflexObservation(state, directive, { - captureAlerts: [ - { - capturedAgentId: [...state.agents.keys()][0]!, - cell: directive.targetCell!, - originatingTick: 4, - abandonedCellCount: 2, - }, - ], + captureAlerts: Array.from({ length: 5 }, (_, index) => ({ + capturedAgentId, + cell: directive.targetCell!, + originatingTick: index, + abandonedCellCount: index, + })), }); - expect(compiled.observation.captureAlerts).toEqual([ - { - capturedAgentId: [...state.agents.keys()][0]!, - cell: directive.targetCell, - originatingTick: 4, - abandonedCellCount: 2, - }, - ]); + expect(compiled.observation.captureAlerts).toHaveLength(4); + expect( + compiled.observation.captureAlerts?.map( + ({ abandonedCellCount }) => abandonedCellCount, + ), + ).toEqual([1, 2, 3, 4]); expect(compiled.observation).not.toHaveProperty('simulatedPlayer'); }); diff --git a/apps/game-api/src/reflex-execution.ts b/apps/game-api/src/reflex-execution.ts index c98caec..162735e 100644 --- a/apps/game-api/src/reflex-execution.ts +++ b/apps/game-api/src/reflex-execution.ts @@ -166,7 +166,6 @@ export function compileReflexObservation( recentTerritoryTrend: territoryTrend, recentActionOutcome: history.recentActionOutcome ?? 'unknown', }, - relevantRecentFacts: [], ...(history.captureAlerts?.length ? { captureAlerts: [...history.captureAlerts].slice(-4) } : {}), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3b9db03..f2182b7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,6 +14,15 @@ service maps to a world action for seeded engine resolution. One complete tick commits atomically. Planner and worker failures use explicit deterministic fallbacks; provider attempts survive world rollback. See ADRs 0028, 0029, and 0031. +The worker observation includes bounded, current capture alerts. The TypeSafe +request projects them as structured capture pressure without cell IDs or tick +numbers. The unused prose `relevantRecentFacts` field is removed; current +situation and legal candidate descriptions supply the relevant local facts. +Zero planning uses the same provider-reported OpenRouter usage normalization as +legacy turns, including actual `usage.cost` when returned, and preserves that +metadata when a returned plan is rejected or a bounded non-success response +contains usage. Missing provider cost remains unknown. + Swarm tick records are separate from legacy agent turn records. Full all-agent exports and the archive retain safe plans, directives, action choices, and factual provider usage; selective legacy turn filters do not export partial diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 27bcfe3..5a8752c 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -40,9 +40,11 @@ their nested rollups. ## Secrets and deployment `TYPESAFE_API_KEY` is server-only for the zero-swarm reflex provider. Jev sees -compact semantic state and opaque candidate IDs. The outbound state omits H3 -cell IDs and tick numbers; the server retains directive targets and the action -mapping. The key, raw +compact semantic state and opaque candidate IDs. When current player pressure +captures a worker, Jev also receives a bounded structured capture-pressure +count and abandoned-cell counts from the worker's authorized capture alerts. +The outbound state omits H3 cell IDs, tick numbers, and hidden hunter routing; +the server retains directive targets and the action mapping. The key, raw TypeSafe requests and responses, and provider error bodies never enter safe telemetry, World Lab, archives, or exports. TypeSafe token usage is factual; no monetary cost is inferred from it. diff --git a/docs/TESTING.md b/docs/TESTING.md index 1103446..5bea9b9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -29,6 +29,12 @@ aggregate metrics, and has no provider, credential, or archive-write path. Run `pnpm compare:offline` (or bounded `--ticks` and `--seeds` options) to produce its JSON report. See [Zero-swarm offline comparison](ZERO_SWARM_COMPARISON.md). +The pre-live provider audit tests the Jev request's bounded capture-pressure +projection, omission of hidden hunter state and empty capture noise, and the +removal of the inert recent-facts field. Planner tests cover provider-reported +OpenRouter cost and detailed tokens for valid, rejected, and non-success Zero +responses, plus sanitized response IDs/models; missing cost stays unknown. + Attempt-budget tests use deterministic providers and cover whole-roster tick admission, retry permits, cancellation finalization, and the distinction between known zero cost and missing/unknown cost. Provider catalog probes are diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 5c9321f..49662b4 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -21,6 +21,7 @@ import { type ProviderMetadata, type ReasoningProfile, } from '@hexzero/shared'; +import { normalizeOpenRouterUsage } from './openrouter-usage'; export { applyProviderEnvironmentFile } from './provider-environment'; export * from './model-catalog'; @@ -203,24 +204,6 @@ const openRouterResponseSchema = z.object({ usage: z.unknown().optional(), }); -const openRouterUsageSchema = z.object({ - prompt_tokens: z.number().int().nonnegative().optional(), - completion_tokens: z.number().int().nonnegative().optional(), - total_tokens: z.number().int().nonnegative().optional(), - cost: z.number().nonnegative().finite().optional(), - completion_tokens_details: z - .object({ reasoning_tokens: z.number().int().nonnegative().optional() }) - .passthrough() - .optional(), - prompt_tokens_details: z - .object({ - cached_tokens: z.number().int().nonnegative().optional(), - cache_write_tokens: z.number().int().nonnegative().optional(), - }) - .passthrough() - .optional(), -}); - export function normalizeFlatDecision(input: unknown) { const parsed = wireDecisionSchema.safeParse(input); if (!parsed.success) return providerDecisionEnvelopeSchema.safeParse({}); @@ -855,8 +838,6 @@ function providerMetadataFromResponse( sensitiveValues: string[], ): ProviderMetadata { const root = asRecord(raw); - const usage = openRouterUsageSchema.safeParse(root?.usage); - const parsedUsage = usage.success ? usage.data : undefined; const requestId = sanitizeDiagnosticCode(root?.id, sensitiveValues, 160) ?? sanitizeDiagnosticCode( @@ -876,33 +857,7 @@ function providerMetadataFromResponse( latencyMs, httpStatus: response.status, ...(requestId === undefined ? {} : { requestId }), - ...(parsedUsage?.prompt_tokens === undefined - ? {} - : { promptTokens: parsedUsage.prompt_tokens }), - ...(parsedUsage?.completion_tokens === undefined - ? {} - : { completionTokens: parsedUsage.completion_tokens }), - ...(parsedUsage?.total_tokens === undefined - ? {} - : { totalTokens: parsedUsage.total_tokens }), - ...(parsedUsage?.completion_tokens_details?.reasoning_tokens === undefined - ? {} - : { - reasoningTokens: - parsedUsage.completion_tokens_details.reasoning_tokens, - }), - ...(parsedUsage?.prompt_tokens_details?.cached_tokens === undefined - ? {} - : { cachedReadTokens: parsedUsage.prompt_tokens_details.cached_tokens }), - ...(parsedUsage?.prompt_tokens_details?.cache_write_tokens === undefined - ? {} - : { - cacheWriteTokens: - parsedUsage.prompt_tokens_details.cache_write_tokens, - }), - ...(parsedUsage?.cost === undefined - ? {} - : { costCredits: parsedUsage.cost }), + ...normalizeOpenRouterUsage(root?.usage), }; } diff --git a/packages/agent-runtime/src/openrouter-usage.ts b/packages/agent-runtime/src/openrouter-usage.ts new file mode 100644 index 0000000..9677793 --- /dev/null +++ b/packages/agent-runtime/src/openrouter-usage.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; +import type { ProviderMetadata } from '@hexzero/shared'; + +const nonnegativeInteger = z.number().int().nonnegative(); +const nonnegativeFinite = z.number().nonnegative().finite(); + +/** Preserves provider-reported OpenRouter accounting without price estimates. */ +export function normalizeOpenRouterUsage( + usage: unknown, +): Partial< + Pick< + ProviderMetadata, + | 'promptTokens' + | 'completionTokens' + | 'totalTokens' + | 'reasoningTokens' + | 'cachedReadTokens' + | 'cacheWriteTokens' + | 'costCredits' + > +> { + if (!usage || typeof usage !== 'object' || Array.isArray(usage)) return {}; + const record = usage as Record; + const completionDetails = asRecord(record.completion_tokens_details); + const promptDetails = asRecord(record.prompt_tokens_details); + return { + ...numberMetadata('promptTokens', record.prompt_tokens, nonnegativeInteger), + ...numberMetadata( + 'completionTokens', + record.completion_tokens, + nonnegativeInteger, + ), + ...numberMetadata('totalTokens', record.total_tokens, nonnegativeInteger), + ...numberMetadata( + 'reasoningTokens', + completionDetails?.reasoning_tokens, + nonnegativeInteger, + ), + ...numberMetadata( + 'cachedReadTokens', + promptDetails?.cached_tokens, + nonnegativeInteger, + ), + ...numberMetadata( + 'cacheWriteTokens', + promptDetails?.cache_write_tokens, + nonnegativeInteger, + ), + ...numberMetadata('costCredits', record.cost, nonnegativeFinite), + }; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function numberMetadata( + key: Key, + value: unknown, + schema: z.ZodType>, +): Partial> { + const parsed = schema.safeParse(value); + return parsed.success + ? ({ [key]: parsed.data } as Partial>) + : {}; +} diff --git a/packages/agent-runtime/src/swarm-planner.test.ts b/packages/agent-runtime/src/swarm-planner.test.ts index 12ca9cc..4f298f1 100644 --- a/packages/agent-runtime/src/swarm-planner.test.ts +++ b/packages/agent-runtime/src/swarm-planner.test.ts @@ -160,4 +160,210 @@ describe('swarm planners', () => { expect(result.metadata.totalTokens).toBe(7); expect(attempts).toEqual(['initial', 'automatic-transport-retry']); }); + + it('preserves complete provider-reported OpenRouter accounting', async () => { + const result = await new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi.fn().mockResolvedValue( + plannerResponse(plan, { + prompt_tokens: 101, + completion_tokens: 23, + total_tokens: 124, + cost: 0.00000017, + completion_tokens_details: { reasoning_tokens: 7 }, + prompt_tokens_details: { + cached_tokens: 80, + cache_write_tokens: 4, + }, + }), + ), + }).plan(observation, 'test-model'); + expect(result.metadata).toMatchObject({ + promptTokens: 101, + completionTokens: 23, + totalTokens: 124, + reasoningTokens: 7, + cachedReadTokens: 80, + cacheWriteTokens: 4, + costCredits: 0.00000017, + }); + }); + + it('retains billable usage when the returned plan is invalid', async () => { + const finalized: unknown[] = []; + await expect( + new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi.fn().mockResolvedValue( + plannerResponse( + { ...plan, zeroActionCandidateId: 'invented' }, + { + prompt_tokens: 11, + completion_tokens: 5, + total_tokens: 16, + cost: 0.001, + }, + ), + ), + }).plan(observation, 'test-model', { + beginAttempt: () => (completion) => finalized.push(completion), + }), + ).rejects.toMatchObject({ metadata: { costCredits: 0.001 } }); + expect(finalized).toMatchObject([ + { + outcome: 'provider-error', + provider: { promptTokens: 11, totalTokens: 16, costCredits: 0.001 }, + }, + ]); + }); + + it('retains billable usage when a response cannot be parsed as a plan', async () => { + await expect( + new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + choices: [{ message: { content: '{not-json' } }], + usage: { prompt_tokens: 12, cost: 0.002 }, + }), + { status: 200 }, + ), + ), + }).plan(observation, 'test-model'), + ).rejects.toMatchObject({ + failure: { code: 'malformed-response' }, + metadata: { promptTokens: 12, costCredits: 0.002 }, + }); + }); + + it('omits absent or malformed optional provider cost while retaining valid usage', async () => { + const withoutUsage = await new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi + .fn() + .mockResolvedValue(plannerResponse(plan)), + }).plan(observation, 'test-model'); + expect(withoutUsage.metadata).not.toHaveProperty('costCredits'); + const malformedCost = await new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi + .fn() + .mockResolvedValue( + plannerResponse(plan, { prompt_tokens: 9, cost: 'untrusted' }), + ), + }).plan(observation, 'test-model'); + expect(malformedCost.metadata).toMatchObject({ promptTokens: 9 }); + expect(malformedCost.metadata).not.toHaveProperty('costCredits'); + }); + + it('attributes usage from a retryable non-OK attempt before retrying', async () => { + const finalized: unknown[] = []; + const result = await new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ usage: { prompt_tokens: 4, cost: 0.003 } }), + { status: 529 }, + ), + ) + .mockResolvedValueOnce(plannerResponse(plan)), + }).plan(observation, 'test-model', { + beginAttempt: () => (completion) => finalized.push(completion), + }); + expect(result.metadata).not.toHaveProperty('costCredits'); + expect(finalized).toMatchObject([ + { + outcome: 'provider-error', + provider: { promptTokens: 4, costCredits: 0.003, httpStatus: 529 }, + }, + { outcome: 'completed' }, + ]); + }); + + it('attributes usage from a non-retryable non-OK response', async () => { + await expect( + new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ usage: { completion_tokens: 8, cost: 0.004 } }), + { status: 400 }, + ), + ), + }).plan(observation, 'test-model'), + ).rejects.toMatchObject({ + metadata: { completionTokens: 8, costCredits: 0.004, httpStatus: 400 }, + }); + }); + + it('preserves cancellation while reading a non-OK response body', async () => { + const cancellation = new AbortController(); + const body = new ReadableStream({ + pull(controller) { + cancellation.abort(); + controller.error(new Error('cancelled body')); + }, + }); + await expect( + new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi + .fn() + .mockResolvedValue(new Response(body, { status: 400 })), + }).plan(observation, 'test-model', { signal: cancellation.signal }), + ).rejects.toMatchObject({ failure: { code: 'cancelled' } }); + }); + + it('excludes echoed secrets and worker observation data from response metadata', async () => { + const echoed = `Bearer test-key ${cell}`; + const success = await new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: echoed, + model: echoed, + choices: [{ message: { content: JSON.stringify(plan) } }], + }), + { status: 200 }, + ), + ), + }).plan(observation, 'test-model'); + expect(success.metadata).not.toHaveProperty('requestId'); + expect(success.metadata).not.toHaveProperty('resolvedModel'); + await expect( + new OpenRouterSwarmPlanner({ + apiKey: 'test-key', + fetchImplementation: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: echoed, + model: echoed, + usage: { cost: 0.005 }, + }), + { status: 400 }, + ), + ), + }).plan(observation, 'test-model'), + ).rejects.toMatchObject({ + metadata: { costCredits: 0.005 }, + }); + }); }); + +function plannerResponse(planValue: unknown, usage?: unknown): Response { + return new Response( + JSON.stringify({ + id: 'safe-request-id', + model: 'test-model', + choices: [{ message: { content: JSON.stringify(planValue) } }], + ...(usage === undefined ? {} : { usage }), + }), + { status: 200 }, + ); +} diff --git a/packages/agent-runtime/src/swarm-planner.ts b/packages/agent-runtime/src/swarm-planner.ts index 146b8da..cbc1600 100644 --- a/packages/agent-runtime/src/swarm-planner.ts +++ b/packages/agent-runtime/src/swarm-planner.ts @@ -8,6 +8,7 @@ import { type SwarmPlan, type ZeroStrategicObservation, } from '@hexzero/shared'; +import { normalizeOpenRouterUsage } from './openrouter-usage'; const OPENROUTER_ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions'; const DEFAULT_TIMEOUT_MS = 30_000; @@ -62,13 +63,7 @@ export class SwarmPlannerError extends Error { const openRouterResponseSchema = z.object({ id: z.string().trim().min(1).max(160).optional(), model: z.string().trim().min(1).max(200).optional(), - usage: z - .object({ - prompt_tokens: z.number().int().nonnegative().optional(), - completion_tokens: z.number().int().nonnegative().optional(), - total_tokens: z.number().int().nonnegative().optional(), - }) - .optional(), + usage: z.unknown().optional(), choices: z .array( z.object({ @@ -128,6 +123,11 @@ export class OpenRouterSwarmPlanner implements SwarmPlanner { const requestBody = JSON.stringify( buildSwarmPlannerRequest(observation, model), ); + const sensitiveValues = collectSensitiveValues( + this.#apiKey, + observation, + requestBody, + ); for (let index = 0; index < 2; index += 1) { if (Date.now() >= deadlineAt) throw new SwarmPlannerError(failure('timeout', true, model, startedAt)); @@ -193,6 +193,23 @@ export class OpenRouterSwarmPlanner implements SwarmPlanner { Date.now() - startedAt, response.status, ); + const errorResponseMetadata = !response.ok + ? metadataFromResponse( + metadata, + await readBoundedJson(response).catch(() => undefined), + sensitiveValues, + ) + : undefined; + if (options.signal?.aborted || timedOut) + throw new SwarmPlannerError( + failure( + timedOut ? 'timeout' : 'cancelled', + false, + model, + startedAt, + ), + errorResponseMetadata ?? metadata, + ); if ( (response.status === 429 || response.status === 529) && index === 0 @@ -202,11 +219,11 @@ export class OpenRouterSwarmPlanner implements SwarmPlanner { ...failure('provider-http', true, model, startedAt), httpStatus: response.status, }, - metadata, + errorResponseMetadata ?? metadata, ); complete({ outcome: 'provider-error', - provider: metadata, + provider: errorResponseMetadata ?? metadata, failure: retryFailure.failure, }); await delay( @@ -226,16 +243,11 @@ export class OpenRouterSwarmPlanner implements SwarmPlanner { ), httpStatus: response.status, }, - metadata, + errorResponseMetadata ?? metadata, ); let raw: unknown; try { - const body = await response.text(); - if ( - new TextEncoder().encode(body).byteLength > RESPONSE_BODY_MAX_BYTES - ) - throw new Error('response body limit'); - raw = JSON.parse(body); + raw = await readBoundedJson(response); } catch { throw new SwarmPlannerError( failure('malformed-response', true, model, startedAt), @@ -246,17 +258,22 @@ export class OpenRouterSwarmPlanner implements SwarmPlanner { throw new SwarmPlannerError( failure('cancelled', false, model, startedAt), ); + const responseMetadata = metadataFromResponse( + metadata, + raw, + sensitiveValues, + ); const parsed = openRouterResponseSchema.safeParse(raw); if (!parsed.success) throw new SwarmPlannerError( failure('unsupported-response', true, model, startedAt), - metadata, + responseMetadata, ); const content = responseText(parsed.data.choices[0]?.message.content); if (!content) throw new SwarmPlannerError( failure('unsupported-response', true, model, startedAt), - metadata, + responseMetadata, ); let planRaw: unknown; try { @@ -264,7 +281,7 @@ export class OpenRouterSwarmPlanner implements SwarmPlanner { } catch { throw new SwarmPlannerError( failure('malformed-response', true, model, startedAt), - metadata, + responseMetadata, ); } const plan = swarmPlanSchema.safeParse(planRaw); @@ -275,16 +292,9 @@ export class OpenRouterSwarmPlanner implements SwarmPlanner { message: 'Agent Zero returned a plan outside the authoritative choices.', }, - metadata, + responseMetadata, ); - const safeMetadata = providerMetadataSchema.parse({ - ...metadata, - requestId: parsed.data.id, - resolvedModel: parsed.data.model, - promptTokens: parsed.data.usage?.prompt_tokens, - completionTokens: parsed.data.usage?.completion_tokens, - totalTokens: parsed.data.usage?.total_tokens, - }); + const safeMetadata = responseMetadata; complete({ outcome: 'completed', provider: safeMetadata, @@ -425,6 +435,109 @@ function metadataFor( }; } +function metadataFromResponse( + metadata: ProviderMetadata, + raw: unknown, + sensitiveValues: string[], +): ProviderMetadata { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return metadata; + const response = raw as Record; + const requestId = safeResponseMetadataValue( + response.id, + sensitiveValues, + 160, + ); + const resolvedModel = safeResponseMetadataValue( + response.model, + sensitiveValues, + 200, + ); + const candidate = { + ...metadata, + ...(requestId ? { requestId } : {}), + ...(resolvedModel ? { resolvedModel } : {}), + ...normalizeOpenRouterUsage(response.usage), + }; + return providerMetadataSchema.safeParse(candidate).success + ? providerMetadataSchema.parse(candidate) + : providerMetadataSchema.parse({ + ...metadata, + ...normalizeOpenRouterUsage(response.usage), + }); +} + +async function readBoundedJson(response: Response): Promise { + const body = await readBoundedText(response, RESPONSE_BODY_MAX_BYTES); + return JSON.parse(body); +} + +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(); + throw new Error('response body limit'); + } + } + if (remaining === 0) { + await reader.cancel(); + throw new Error('response body limit'); + } + return output + decoder.decode(); + } finally { + reader.releaseLock(); + } +} + +function collectSensitiveValues( + apiKey: string, + observation: ZeroStrategicObservation, + requestBody: string, +): string[] { + const values = new Set([apiKey, requestBody]); + const visit = (value: unknown) => { + if (typeof value === 'string') { + if (value.length >= 4) values.add(value); + } else if (Array.isArray(value)) { + value.forEach(visit); + } else if (value && typeof value === 'object') { + Object.values(value).forEach(visit); + } + }; + visit(observation); + return [...values].toSorted((left, right) => right.length - left.length); +} + +function safeResponseMetadataValue( + value: unknown, + sensitiveValues: string[], + maximumLength: number, +): string | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim(); + if ( + !normalized || + normalized.length > maximumLength || + /Bearer\s+\S+|sk-or-[a-zA-Z0-9_-]+/i.test(normalized) || + sensitiveValues.some((sensitive) => normalized.includes(sensitive)) + ) + return undefined; + return normalized; +} + function failure( code: ProviderFailure['code'], retryable: boolean, diff --git a/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts b/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts index a2d1b72..b9ae4e8 100644 --- a/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts +++ b/packages/agent-runtime/src/typesafe-jev-reflex-provider.test.ts @@ -27,7 +27,6 @@ const observation = reflexObservationSchema.parse({ recentTerritoryTrend: 'growing', recentActionOutcome: 'success', }, - relevantRecentFacts: ['An adjacent open cell is available.'], candidates: [ { id: 'action_0', description: 'Move into adjacent open territory.' }, { id: 'action_1', description: 'Remain on the current infected cell.' }, @@ -113,6 +112,50 @@ describe('TypeSafeJevReflexProvider', () => { expect(JSON.stringify(request.state)).not.toContain('expiresAtTick'); }); + it('projects bounded capture pressure into the outbound Jev request without location or hunter data', async () => { + const observationWithCaptures = reflexObservationSchema.parse({ + ...observation, + captureAlerts: [ + { + capturedAgentId: '2507bb46-7ae4-45ca-8dda-644c4f85ca14', + cell: '8928308280fffff', + originatingTick: 99, + abandonedCellCount: 3, + }, + ], + }); + const fetchImplementation = vi + .fn() + .mockResolvedValue(choiceResponse()); + const provider = new TypeSafeJevReflexProvider({ + apiKey: 'test-key', + fetchImplementation, + }); + + await provider.decide(observationWithCaptures); + + const request = JSON.parse( + String(fetchImplementation.mock.calls[0]?.[1]?.body), + ); + expect(request.state).toMatchObject({ + capturePressure: { + recentCaptureCount: 1, + recentCaptures: [{ abandonedCellCount: 3 }], + }, + }); + expect(JSON.stringify(request.state)).not.toContain('capturedAgentId'); + expect(JSON.stringify(request.state)).not.toContain('originatingTick'); + expect(JSON.stringify(request.state)).not.toContain('8928308280fffff'); + expect(JSON.stringify(request.state)).not.toContain('hunter'); + expect(JSON.stringify(request.state)).not.toContain('player'); + }); + + it('omits capture pressure when no captures were observed', () => { + expect(buildTypeSafeJevRequest(observation).state).not.toHaveProperty( + 'capturePressure', + ); + }); + it('rejects malformed responses and selections outside its candidates', async () => { const malformed = new TypeSafeJevReflexProvider({ apiKey: 'test-key', diff --git a/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts b/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts index 0d89b36..f3b58b5 100644 --- a/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts +++ b/packages/agent-runtime/src/typesafe-jev-reflex-provider.ts @@ -342,6 +342,9 @@ export class TypeSafeJevReflexProvider implements ReflexProvider { export function buildTypeSafeJevRequest(observationInput: ReflexObservation) { const observation = reflexObservationSchema.parse(observationInput); + const recentCaptures = observation.captureAlerts?.map( + ({ abandonedCellCount }) => ({ abandonedCellCount }), + ); return { state: { agentId: observation.agentId, @@ -351,7 +354,14 @@ export function buildTypeSafeJevRequest(observationInput: ReflexObservation) { riskTolerance: observation.directive.riskTolerance, }, currentSituation: observation.currentSituation, - relevantRecentFacts: observation.relevantRecentFacts, + ...(recentCaptures?.length + ? { + capturePressure: { + recentCaptures, + recentCaptureCount: recentCaptures.length, + }, + } + : {}), candidateIds: observation.candidates.map(({ id }) => id), }, model: TYPESAFE_JEV_MODEL, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2c8a104..cd05911 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -588,7 +588,6 @@ export const reflexObservationSchema = z recentActionOutcome: z.enum(['success', 'rejected', 'unknown']), }) .strict(), - relevantRecentFacts: z.array(z.string().trim().min(1).max(160)).max(6), captureAlerts: z.array(captureAlertSchema).max(4).optional(), candidates: z.array(reflexCandidateSchema).min(1).max(9), }) diff --git a/packages/shared/src/reflex.test.ts b/packages/shared/src/reflex.test.ts index 616fef6..d0bd279 100644 --- a/packages/shared/src/reflex.test.ts +++ b/packages/shared/src/reflex.test.ts @@ -33,7 +33,6 @@ describe('zero-swarm reflex contracts', () => { recentTerritoryTrend: 'growing', recentActionOutcome: 'success', }, - relevantRecentFacts: ['The assigned direction remains accessible.'], candidates: [ { id: 'action_0', description: 'Move toward the assigned target.' }, { id: 'action_1', description: 'Infect the current open territory.' }, @@ -45,6 +44,24 @@ describe('zero-swarm reflex contracts', () => { description: 'Move toward the assigned target.', }); expect('action' in observation.candidates[0]!).toBe(false); + expect('relevantRecentFacts' in observation).toBe(false); + expect( + reflexObservationSchema.safeParse({ + ...observation, + relevantRecentFacts: ['Unused narrative context.'], + }).success, + ).toBe(false); + expect( + reflexObservationSchema.safeParse({ + ...observation, + captureAlerts: Array.from({ length: 5 }, () => ({ + capturedAgentId: agentId, + cell: targetCell, + originatingTick: 4, + abandonedCellCount: 0, + })), + }).success, + ).toBe(false); }); it('rejects mismatched directives and malformed choice telemetry', () => { @@ -62,7 +79,6 @@ describe('zero-swarm reflex contracts', () => { recentTerritoryTrend: 'growing', recentActionOutcome: 'success', }, - relevantRecentFacts: [], candidates: [{ id: 'action_0', description: 'Wait.' }], }).success, ).toBe(false);