From 7d51f09e18fc9a85a355f995fa7bb925868efbb5 Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Sun, 23 Aug 2026 10:25:05 -0400 Subject: [PATCH] feat(world-lab): add agent behavior trace --- README.md | 5 + ROADMAP.md | 2 +- apps/world-lab/src/app/styles.css | 127 +++++- .../src/components/behavior-trace.test.ts | 246 ++++++++++++ .../src/components/behavior-trace.ts | 361 ++++++++++++++++++ .../src/components/world-lab.test.tsx | 68 +++- apps/world-lab/src/components/world-lab.tsx | 203 +++++++++- docs/ARCHITECTURE.md | 9 + docs/TESTING.md | 7 + docs/adr/0020-read-only-behavior-trace.md | 20 + 10 files changed, 1039 insertions(+), 9 deletions(-) create mode 100644 apps/world-lab/src/components/behavior-trace.test.ts create mode 100644 apps/world-lab/src/components/behavior-trace.ts create mode 100644 docs/adr/0020-read-only-behavior-trace.md diff --git a/README.md b/README.md index 8d1fd7d..739bfc4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ memories. One keep, remember, revise, or forget request shares the existing inference and is validated independently; memories are untrusted recollections and grant no world authority. +World Lab derives a bounded read-only Behavior Trace from retained turn records. +It places observation changes, communication and board evidence, legal choices, +chosen actions, action-pattern changes, and goal/memory continuity together while +labeling model summaries as self-reported rather than proof of causation. + ## Workspace | Path | Responsibility | diff --git a/ROADMAP.md b/ROADMAP.md index 497931d..487128f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -94,7 +94,7 @@ restart persistence, simulated players, threats, and Player Mode remain deferred ## PR 5 — Goals and memory -Slice A delivers bounded per-agent strategic goals with deterministic revision semantics, safe attribution, and World Lab inspection. Slice B adds an eight-entry compact self-authored memory ledger in the same inference. Semantic/vector memory, embeddings, retrieval/ranking, relationship scores, shared alliance memory, restart persistence, and extra inference calls remain deferred. +Slice A delivers bounded per-agent strategic goals with deterministic revision semantics, safe attribution, and World Lab inspection. Slice B adds an eight-entry compact self-authored memory ledger in the same inference. Slice C adds a browser-derived bounded Behavior Trace that places observation deltas, retained evidence, legal choices, selected actions, action patterns, and continuity operations together without changing simulation behavior. Semantic/vector memory, embeddings, retrieval/ranking, relationship scores, shared alliance memory, restart persistence, causal claims, simulated players, and extra inference calls remain deferred. Pre-PR-5 observability slice: add a local append-only SQLite experiment archive, transactional schema-v9 export import, bounded human/Codex queries, normalized comparisons, and FTS-searchable curated notes. The in-memory engine remains authoritative. Crash recovery, restartable simulation state, MCP, embeddings, vector search, and a database browser remain deferred. diff --git a/apps/world-lab/src/app/styles.css b/apps/world-lab/src/app/styles.css index 02c6baa..853b85a 100644 --- a/apps/world-lab/src/app/styles.css +++ b/apps/world-lab/src/app/styles.css @@ -1299,6 +1299,129 @@ dd { letter-spacing: 0.06em; text-transform: uppercase; } +.agent-inspector-nav { + position: sticky; + top: -1px; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 5px; + margin: 0 -2px 10px; + padding: 7px 2px; + border-bottom: 1px solid #33413d; + background: color-mix(in srgb, #161e1c 94%, transparent); + backdrop-filter: blur(6px); +} +.agent-inspector-nav a { + padding: 3px 6px; + border: 1px solid #3b4a46; + border-radius: 999px; + color: #a9bbb5; + font-size: 0.67rem; + text-decoration: none; +} +.agent-inspector-nav a:hover, +.agent-inspector-nav a:focus-visible { + border-color: #79bda6; + color: #d7e7e1; +} +.behavior-trace-panel, +.agent-inspector [id$='-goals'], +.agent-inspector [id$='-memories'], +.agent-inspector [id$='-history'], +.agent-inspector [id$='-configuration'], +.agent-inspector [id$='-latest'] { + scroll-margin-top: 46px; +} +.behavior-trace-panel { + margin-top: 14px; + padding-top: 2px; +} +.behavior-trace-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} +.behavior-trace-heading h3 { + margin-top: 0; +} +.behavior-trace-heading p { + margin: 2px 0 0; + color: #82938e; + font-size: 0.69rem; + line-height: 1.4; +} +.behavior-trace-heading > span { + flex: 0 0 auto; + color: #7f928b; + font-size: 0.66rem; +} +.behavior-trace { + display: grid; + gap: 6px; + margin: 8px 0 0; + padding: 0; + list-style: none; +} +.behavior-trace > li { + min-width: 0; + border: 1px solid #34433f; + border-radius: 5px; + background: #121917; +} +.behavior-trace summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 8px; + color: #c9d6d1; + cursor: pointer; + font-size: 0.71rem; + font-weight: 650; +} +.behavior-trace-body { + display: grid; + gap: 7px; + padding: 0 8px 8px; + border-top: 1px solid #2b3834; +} +.behavior-trace-block, +.behavior-trace-decision { + min-width: 0; + padding-top: 7px; + color: #aebdb8; + font-size: 0.71rem; + line-height: 1.45; +} +.behavior-trace-block > strong, +.behavior-trace-decision strong { + color: #d5e0dc; +} +.behavior-trace-block ul { + margin: 4px 0 0; + padding-left: 17px; +} +.behavior-trace-block p, +.behavior-trace-decision p { + margin: 4px 0 0; + overflow-wrap: anywhere; +} +.trace-cell-button { + padding: 1px 4px; + border: 1px solid #45645a; + border-radius: 3px; + background: transparent; + color: #9fd5c2; + cursor: pointer; + font-size: 0.64rem; +} +.trace-cell-button:hover, +.trace-cell-button:focus-visible { + border-color: #80c9b0; + color: #d9f2e9; +} .personality-heading { display: flex; align-items: center; @@ -1409,7 +1532,9 @@ dd { color: #8ff1ba; } .outcome.rejected, -.outcome.provider-error { +.outcome.provider-error, +.outcome.lost-tick, +.outcome.operator-skipped { background: #512b27; color: #ffafa3; } diff --git a/apps/world-lab/src/components/behavior-trace.test.ts b/apps/world-lab/src/components/behavior-trace.test.ts new file mode 100644 index 0000000..7f3896e --- /dev/null +++ b/apps/world-lab/src/components/behavior-trace.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest'; +import { + agentIdSchema, + agentTurnRecordSchema, + type AgentTurnRecord, +} from '@hexzero/shared'; +import { BEHAVIOR_TRACE_LIMIT, deriveBehaviorTrace } from './behavior-trace'; + +const agentId = agentIdSchema.parse('128f3f38-6b7d-4db7-9e95-751b4ce2681e'); +const otherAgentId = agentIdSchema.parse( + '2507bb46-7ae4-45ca-8dda-644c4f85ca14', +); +const currentCell = '892b6b5a2c7ffff'; +const adjacentCell = '892b6b5a2d3ffff'; + +function acceptedTurn( + turnNumber: number, + options: { + move?: boolean; + inboundMessage?: boolean; + territoryChange?: boolean; + continuity?: boolean; + } = {}, +): AgentTurnRecord { + const move = options.move ?? false; + const occurredAt = `2026-08-23T12:00:${String(turnNumber).padStart(2, '0')}.000Z`; + return agentTurnRecordSchema.parse({ + turnNumber, + agentId, + startedAt: occurredAt, + completedAt: occurredAt, + observation: { + agentId, + agentName: 'Ember', + personality: 'A deliberate test agent.', + currentCell: { + cell: currentCell, + state: 'open', + controllerAgentId: null, + controllerAllianceId: null, + effectiveColor: null, + }, + captureEligibility: { + eligible: false, + blockedReason: 'capture-open-cell', + }, + actionAvailability: { + moveTargetCellIds: [adjacentCell], + moveOptions: [ + { + targetCell: adjacentCell, + direction: 'NE', + destinationState: 'open', + controllerRelationship: 'open', + recentlyOccupied: false, + nearbyAgentCount: 1, + }, + ], + infect: { available: true }, + capture: { available: false, reason: 'capture-open-cell' }, + wait: { available: true }, + }, + adjacentCells: [ + { + cell: adjacentCell, + state: 'open', + controllerAgentId: null, + controllerAllianceId: null, + effectiveColor: null, + }, + ], + nearbyAgents: [], + recentEvents: [], + recentPublicMessages: [], + recentDirectMessages: options.inboundMessage + ? [ + { + eventId: '67aa21b9-fc78-4b04-9f92-9862bf346f96', + senderId: otherAgentId, + senderName: 'Rook', + recipientId: agentId, + recipientName: 'Ember', + direction: 'inbound', + message: 'Hold the eastern route.', + occurredAt, + distance: 2, + }, + ] + : [], + territoryScoreboard: [ + { + agentId, + name: 'Ember', + color: '#d55e00', + allianceId: null, + effectiveColor: '#d55e00', + controlledCellCount: 0, + }, + ], + actingAllianceId: null, + actingAlliance: null, + activeAlliances: [], + inboundAllianceProposals: [], + outboundAllianceProposals: [], + recentAllianceEvents: [], + recentControlChanges: options.territoryChange + ? [ + { + eventId: '87aa21b9-fc78-4b04-9f92-9862bf346f96', + direction: 'lost', + otherAgentId, + otherAgentName: 'Rook', + cell: adjacentCell, + occurredAt, + }, + ] + : [], + }, + outcome: 'accepted', + worldAction: move + ? { type: 'move', targetCell: adjacentCell } + : { type: 'wait' }, + summary: move ? 'Respond to the eastern-route warning.' : 'Hold position.', + worldActionResult: { + accepted: true, + event: move + ? { + id: `77bb21b9-fc78-4b04-9f92-9862bf346f9${turnNumber}`, + type: 'agent-moved', + agentId, + fromCell: currentCell, + toCell: adjacentCell, + occurredAt, + } + : { + id: `77bb21b9-fc78-4b04-9f92-9862bf346f9${turnNumber}`, + type: 'agent-waited', + agentId, + occurredAt, + }, + }, + communicationResult: { requested: false }, + diplomacyResult: { requested: false }, + ...(options.continuity + ? { + goalRevision: { + operation: 'establish', + longTermGoal: 'Hold the eastern corridor.', + shortTermGoal: 'Wait for a safe route.', + planSummary: 'Observe before moving.', + reason: 'Set a retained objective.', + }, + goalRevisionResult: { + requested: true, + accepted: true, + operation: 'establish', + }, + memoryOperation: { + operation: 'remember', + text: 'Rook contested the eastern route.', + }, + memoryOperationResult: { + requested: true, + accepted: false, + operation: 'remember', + reason: 'memory-full', + }, + } + : {}), + provider: { + provider: 'scripted-test', + model: 'test', + latencyMs: 0, + }, + }); +} + +describe('deriveBehaviorTrace', () => { + it('places new evidence beside legal choices, chosen direction, and action change', () => { + const trace = deriveBehaviorTrace( + [ + acceptedTurn(1), + acceptedTurn(2, { + move: true, + inboundMessage: true, + territoryChange: true, + }), + ], + agentId, + ); + + expect(trace).toHaveLength(2); + expect(trace[0]).toMatchObject({ + hasPreviousObservation: true, + legalActions: ['Move NE', 'Infect', 'Wait'], + chosenAction: `Move NE → ${adjacentCell}`, + chosenCell: adjacentCell, + actionPattern: 'Changed wait → move NE.', + evidence: [ + { + kind: 'direct', + label: 'Inbound from Rook: Hold the eastern route.', + }, + { + kind: 'territory', + label: `Lost ${adjacentCell} to Rook`, + cell: adjacentCell, + }, + ], + }); + expect(trace[0]!.observedChanges).toContain( + '2 new retained evidence items entered the retained observation.', + ); + expect(trace[1]!.observedChanges).toEqual([ + 'First retained observation for this agent.', + ]); + }); + + it('stays bounded and newest-first', () => { + const turns = Array.from({ length: 8 }, (_, index) => + acceptedTurn(index + 1), + ); + const trace = deriveBehaviorTrace(turns, agentId); + expect(trace).toHaveLength(BEHAVIOR_TRACE_LIMIT); + expect(trace.map(({ turn }) => turn.turnNumber)).toEqual([ + 8, 7, 6, 5, 4, 3, + ]); + expect(deriveBehaviorTrace(turns, agentId, 99)).toHaveLength( + BEHAVIOR_TRACE_LIMIT, + ); + }); + + it('reports repeated actions and independent goal and memory continuity', () => { + const trace = deriveBehaviorTrace( + [acceptedTurn(1), acceptedTurn(2, { continuity: true })], + agentId, + ); + expect(trace[0]).toMatchObject({ + actionPattern: 'Repeated wait.', + continuity: [ + 'Goal establish: accepted', + 'Memory remember: rejected (memory-full)', + ], + }); + }); +}); diff --git a/apps/world-lab/src/components/behavior-trace.ts b/apps/world-lab/src/components/behavior-trace.ts new file mode 100644 index 0000000..3c4a781 --- /dev/null +++ b/apps/world-lab/src/components/behavior-trace.ts @@ -0,0 +1,361 @@ +import type { AgentId, AgentTurnRecord, H3Cell } from '@hexzero/shared'; + +export const BEHAVIOR_TRACE_LIMIT = 6; +const BEHAVIOR_TRACE_EVIDENCE_LIMIT = 6; + +type CompletedTurn = Extract< + AgentTurnRecord, + { outcome: 'accepted' | 'rejected' } +>; + +export interface BehaviorTraceEvidence { + kind: + | 'direct' + | 'public' + | 'alliance-message' + | 'zero-message' + | 'world-event' + | 'territory' + | 'alliance-event'; + label: string; + cell?: H3Cell; +} + +export interface BehaviorTraceEntry { + turn: AgentTurnRecord; + hasPreviousObservation: boolean; + observedChanges: string[]; + evidence: BehaviorTraceEvidence[]; + evidenceTruncated: boolean; + legalActions: string[]; + chosenAction: string; + chosenCell?: H3Cell; + actionPattern?: string; + continuity: string[]; +} + +function isCompletedTurn(turn: AgentTurnRecord): turn is CompletedTurn { + return turn.outcome === 'accepted' || turn.outcome === 'rejected'; +} + +function unseenBy( + current: readonly T[], + previous: readonly T[] | undefined, + idFor: (item: T) => string, +): T[] { + const priorIds = new Set(previous?.map(idFor) ?? []); + return current.filter((item) => !priorIds.has(idFor(item))); +} + +function goalSignature( + goal: AgentTurnRecord['observation']['currentGoal'], +): string { + return goal + ? [ + goal.longTermGoal, + goal.shortTermGoal, + goal.planSummary, + goal.establishedAtTick, + goal.revisedAtTick, + ].join('\u0000') + : ''; +} + +function memorySignature( + memory: AgentTurnRecord['observation']['currentMemory'], +): string { + return memory + .map( + ({ id, text, createdAtTick, revisedAtTick }) => + `${id}\u0000${text}\u0000${createdAtTick}\u0000${revisedAtTick}`, + ) + .join('\u0001'); +} + +function ownTerritoryCount( + turn: AgentTurnRecord, + agentId: AgentId, +): number | undefined { + return turn.observation.territoryScoreboard.find( + (entry) => entry.agentId === agentId, + )?.controlledCellCount; +} + +function collectEvidence( + turn: AgentTurnRecord, + previous: AgentTurnRecord | undefined, + agentId: AgentId, +): BehaviorTraceEvidence[] { + const observation = turn.observation; + const prior = previous?.observation; + const inboundDirect = unseenBy( + observation.recentDirectMessages.filter( + ({ direction }) => direction === 'inbound', + ), + prior?.recentDirectMessages.filter( + ({ direction }) => direction === 'inbound', + ), + ({ eventId }) => eventId, + ).map(({ senderName, message }): BehaviorTraceEvidence => ({ + kind: 'direct', + label: `Inbound from ${senderName}: ${message}`, + })); + const publicMessages = unseenBy( + observation.recentPublicMessages.filter( + ({ senderId }) => senderId !== agentId, + ), + prior?.recentPublicMessages.filter(({ senderId }) => senderId !== agentId), + ({ eventId }) => eventId, + ).map(({ senderName, message }): BehaviorTraceEvidence => ({ + kind: 'public', + label: `Public from ${senderName}: ${message}`, + })); + const allianceMessages = unseenBy( + observation.recentAllianceMessages.filter( + ({ senderId }) => senderId !== agentId, + ), + prior?.recentAllianceMessages.filter( + ({ senderId }) => senderId !== agentId, + ), + ({ eventId }) => eventId, + ).map(({ senderName, message }): BehaviorTraceEvidence => ({ + kind: 'alliance-message', + label: `Alliance from ${senderName}: ${message}`, + })); + const zeroMessages = unseenBy( + observation.recentZeroMessages.filter( + ({ senderId }) => senderId !== agentId, + ), + prior?.recentZeroMessages.filter(({ senderId }) => senderId !== agentId), + ({ eventId }) => eventId, + ).map(({ senderName, message }): BehaviorTraceEvidence => ({ + kind: 'zero-message', + label: `Patient Zero from ${senderName}: ${message}`, + })); + const territory = unseenBy( + observation.recentControlChanges, + prior?.recentControlChanges, + ({ eventId }) => eventId, + ).map(({ direction, cell, otherAgentName }): BehaviorTraceEvidence => ({ + kind: 'territory', + label: `${direction === 'gained' ? 'Gained' : 'Lost'} ${cell} ${ + direction === 'gained' ? 'from' : 'to' + } ${otherAgentName}`, + cell, + })); + const allianceEvents = unseenBy( + observation.recentAllianceEvents, + prior?.recentAllianceEvents, + ({ event }) => event.id, + ).map(({ summary }): BehaviorTraceEvidence => ({ + kind: 'alliance-event', + label: summary, + })); + const worldEvents = unseenBy( + observation.recentEvents.filter( + ({ agentId: actorId }) => actorId !== agentId, + ), + prior?.recentEvents.filter(({ agentId: actorId }) => actorId !== agentId), + ({ type, agentId: actorId, occurredAt, summary }) => + `${type}:${actorId}:${occurredAt}:${summary}`, + ).map(({ summary }): BehaviorTraceEvidence => ({ + kind: 'world-event', + label: `World: ${summary}`, + })); + return [ + ...inboundDirect, + ...zeroMessages, + ...allianceMessages, + ...territory, + ...allianceEvents, + ...worldEvents, + ...publicMessages, + ]; +} + +function observedChanges( + turn: AgentTurnRecord, + previous: AgentTurnRecord | undefined, + agentId: AgentId, + evidence: readonly BehaviorTraceEvidence[], +): string[] { + if (!previous) return ['First retained observation for this agent.']; + const changes: string[] = []; + const current = turn.observation; + const prior = previous.observation; + if (current.currentCell.cell !== prior.currentCell.cell) + changes.push( + `Observed cell changed ${prior.currentCell.cell} → ${current.currentCell.cell}.`, + ); + if (current.currentCell.state !== prior.currentCell.state) + changes.push( + `Current-cell state changed ${prior.currentCell.state} → ${current.currentCell.state}.`, + ); + const previousTerritory = ownTerritoryCount(previous, agentId); + const currentTerritory = ownTerritoryCount(turn, agentId); + if ( + previousTerritory !== undefined && + currentTerritory !== undefined && + previousTerritory !== currentTerritory + ) + changes.push( + `Controlled territory changed ${previousTerritory} → ${currentTerritory}.`, + ); + if (current.actingAllianceId !== prior.actingAllianceId) + changes.push( + `Alliance changed ${prior.actingAllianceId ?? 'unaffiliated'} → ${ + current.actingAllianceId ?? 'unaffiliated' + }.`, + ); + if (goalSignature(current.currentGoal) !== goalSignature(prior.currentGoal)) + changes.push('Strategic goal context changed.'); + if ( + memorySignature(current.currentMemory) !== + memorySignature(prior.currentMemory) + ) + changes.push('Compact memory context changed.'); + if (evidence.length) + changes.push( + `${evidence.length} new retained evidence item${ + evidence.length === 1 ? '' : 's' + } entered the retained observation.`, + ); + return changes.length + ? changes + : ['No retained observation change detected.']; +} + +function legalActions(turn: AgentTurnRecord): string[] { + const availability = turn.observation.actionAvailability; + if (!availability) return ['Legacy affordances unavailable']; + const moves = availability.moveOptions.length + ? availability.moveOptions.map(({ direction }) => `Move ${direction}`) + : [ + `${availability.moveTargetCellIds.length} legal move target${ + availability.moveTargetCellIds.length === 1 ? '' : 's' + }`, + ]; + return [ + ...moves, + ...(availability.infect.available ? ['Infect'] : []), + ...(availability.capture.available ? ['Capture'] : []), + 'Wait', + ]; +} + +function actionDescription(turn: CompletedTurn): { + label: string; + pattern: string; + direction?: string; + cell?: H3Cell; +} { + if (turn.worldAction.type === 'move') { + const targetCell = turn.worldAction.targetCell; + const direction = turn.observation.actionAvailability?.moveOptions.find( + (option) => option.targetCell === targetCell, + )?.direction; + return { + label: `Move${direction ? ` ${direction}` : ''} → ${targetCell}`, + pattern: `move${direction ? ` ${direction}` : ''}`, + direction, + cell: targetCell, + }; + } + const label = + turn.worldAction.type[0]!.toUpperCase() + turn.worldAction.type.slice(1); + return { label, pattern: turn.worldAction.type }; +} + +function actionPattern( + turn: AgentTurnRecord, + previousCompleted: CompletedTurn | undefined, +): string | undefined { + if (!isCompletedTurn(turn) || !previousCompleted) return undefined; + const current = actionDescription(turn); + const previous = actionDescription(previousCompleted); + if (current.pattern === previous.pattern) + return `Repeated ${current.pattern}.`; + if (current.direction && previous.direction) + return `Changed direction ${previous.direction} → ${current.direction}.`; + return `Changed ${previous.pattern} → ${current.pattern}.`; +} + +function continuity(turn: AgentTurnRecord): string[] { + if (!isCompletedTurn(turn)) return []; + const entries: string[] = []; + if (turn.goalRevisionResult.requested) + entries.push( + `Goal ${turn.goalRevisionResult.operation}: ${ + turn.goalRevisionResult.accepted + ? 'accepted' + : `rejected (${turn.goalRevisionResult.reason})` + }`, + ); + if (turn.memoryOperationResult.requested) + entries.push( + `Memory ${turn.memoryOperationResult.operation}: ${ + turn.memoryOperationResult.accepted + ? 'accepted' + : `rejected (${turn.memoryOperationResult.reason})` + }`, + ); + if (turn.communicationResult.requested) + entries.push( + `Communication ${ + turn.communicationResult.accepted + ? turn.communicationResult.event.channel + : turn.communicationResult.attempt.channel + }: ${turn.communicationResult.accepted ? 'accepted' : 'rejected'}`, + ); + if (turn.diplomacyResult.requested) + entries.push( + `Diplomacy ${ + turn.diplomacyResult.accepted + ? turn.diplomacyResult.intent.type + : turn.diplomacyResult.attempt.type + }: ${turn.diplomacyResult.accepted ? 'accepted' : 'rejected'}`, + ); + return entries; +} + +export function deriveBehaviorTrace( + turns: readonly AgentTurnRecord[], + agentId: AgentId, + limit = BEHAVIOR_TRACE_LIMIT, +): BehaviorTraceEntry[] { + const agentTurns = turns.filter((turn) => turn.agentId === agentId); + const boundedLimit = Number.isFinite(limit) + ? Math.max(0, Math.min(BEHAVIOR_TRACE_LIMIT, Math.floor(limit))) + : BEHAVIOR_TRACE_LIMIT; + const start = Math.max(0, agentTurns.length - boundedLimit); + return agentTurns + .slice(start) + .map((turn, visibleIndex) => { + const sourceIndex = start + visibleIndex; + const previous = agentTurns[sourceIndex - 1]; + const previousCompleted = agentTurns + .slice(0, sourceIndex) + .findLast(isCompletedTurn); + const allEvidence = collectEvidence(turn, previous, agentId); + const chosen: ReturnType = isCompletedTurn(turn) + ? actionDescription(turn) + : { + label: `No completed action · ${turn.outcome}`, + pattern: turn.outcome, + }; + const pattern = actionPattern(turn, previousCompleted); + return { + turn, + hasPreviousObservation: Boolean(previous), + observedChanges: observedChanges(turn, previous, agentId, allEvidence), + evidence: allEvidence.slice(0, BEHAVIOR_TRACE_EVIDENCE_LIMIT), + evidenceTruncated: allEvidence.length > BEHAVIOR_TRACE_EVIDENCE_LIMIT, + legalActions: legalActions(turn), + chosenAction: chosen.label, + ...(chosen.cell ? { chosenCell: chosen.cell } : {}), + ...(pattern ? { actionPattern: pattern } : {}), + continuity: continuity(turn), + }; + }) + .reverse(); +} diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index a312761..cc6ea53 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -1937,6 +1937,69 @@ describe('WorldLab', () => { ).toBeVisible(); }); + it('shows a bounded read-only behavior trace and highlights observed cells', async () => { + const changed = afterInfection(); + const agent = changed.world.agents[0]!; + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse(changed)), + ); + const user = userEvent.setup(); + render(); + await user.click( + await screen.findByRole('button', { + name: new RegExp(`Select agent ${agent.name}`), + }), + ); + + const inspector = screen.getByLabelText('Agent inspector'); + const trace = within(inspector).getByLabelText('Recent behavior trace'); + const sectionNavigation = within(inspector).getByRole('navigation', { + name: `${agent.name} inspector sections`, + }); + for (const section of [ + 'Trace', + 'Goals', + 'Memories', + 'History', + 'Configuration', + 'Latest', + ]) + expect( + within(sectionNavigation).getByRole('link', { name: section }), + ).toBeVisible(); + expect(within(inspector).getByText('1/6 retained')).toBeVisible(); + expect(trace).toHaveTextContent( + 'First retained observation for this agent.', + ); + expect(trace).toHaveTextContent('1 legal move target · Infect · Wait'); + expect(trace).toHaveTextContent('Chosen: Infect'); + expect(trace).toHaveTextContent( + 'Model summary (self-reported, not proof): Infecting this open cell.', + ); + expect(inspector).toHaveTextContent( + 'Observation evidence and self-reported summaries show correlation, not proven causation.', + ); + + await user.click( + within(trace).getByRole('button', { name: 'Highlight observed cell' }), + ); + await waitFor(() => + expect(mapLibreMock.latestSourceData).toEqual( + expect.objectContaining({ + features: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + cell: agent.currentCell, + selected: true, + }), + }), + ]), + }), + ), + ); + }); + it('clears visible communications after reset', async () => { const changed = afterMessage(); vi.stubGlobal( @@ -1989,7 +2052,10 @@ describe('WorldLab', () => { expect( await screen.findByText('Infection · ' + world.agents[0]!.currentCell), ).toBeInTheDocument(); - expect(screen.getByText('Infecting this open cell.')).toBeInTheDocument(); + const latestTurn = screen + .getByRole('heading', { name: 'Latest turn' }) + .closest('.turn-detail'); + expect(latestTurn).toHaveTextContent('Summary: Infecting this open cell.'); await user.click(screen.getByText('Latest structured observation')); expect( screen.getByText('Latest structured observation').closest('details'), diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index a113f42..c5c9ebc 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -63,6 +63,7 @@ import { import { WorldMap } from './world-map'; import { buildModelOptions } from './model-options'; import { resolveAgentColor } from './ui-color'; +import { BEHAVIOR_TRACE_LIMIT, deriveBehaviorTrace } from './behavior-trace'; const apiBase = process.env.NEXT_PUBLIC_GAME_API_BASE_URL ?? '/api/game/simulation'; @@ -1324,6 +1325,7 @@ export function WorldLab() { mutationDisabled={personalityControlsDisabled} mutationPending={personalityPending} onApplyPersonality={updatePersonality} + onHighlightCell={setSelectedCell} metrics={ snapshot.experiment.metrics.byAgent.find( ({ agentId }) => agentId === selectedAgent.id, @@ -3743,6 +3745,168 @@ function AllianceEventList({ ); } +function AgentBehaviorTrace({ + agent, + id, + turns, + onHighlightCell, +}: { + agent: SimulationSnapshot['world']['agents'][number]; + id: string; + turns: AgentTurnRecord[]; + onHighlightCell?: (cell: H3Cell) => void; +}) { + const entries = deriveBehaviorTrace(turns, agent.id); + return ( +
+
+
+

Behavior trace

+

+ Observation evidence and self-reported summaries show correlation, + not proven causation. +

+
+ + {entries.length}/{BEHAVIOR_TRACE_LIMIT} retained + +
+ {entries.length === 0 ? ( +

No retained behavior records for this agent.

+ ) : ( +
    + {entries.map((entry, index) => { + return ( +
  1. +
    + + + Tick {entry.turn.tickNumber ?? 'legacy'} · turn{' '} + {entry.turn.turnNumber} + + + {entry.turn.outcome} + + +
    +
    + What changed +
      + {entry.observedChanges.map((change) => ( +
    • {change}
    • + ))} +
    +
    +
    + + {entry.hasPreviousObservation + ? 'New retained evidence' + : 'Evidence visible at retained baseline'} + + {entry.evidence.length ? ( +
      + {entry.evidence.map((evidence, evidenceIndex) => ( +
    • + {evidence.label}{' '} + {evidence.cell && onHighlightCell && ( + + )} +
    • + ))} + {entry.evidenceTruncated && ( +
    • Additional evidence omitted from this view.
    • + )} +
    + ) : ( +

    + {entry.hasPreviousObservation + ? 'No new communication or board evidence retained.' + : 'No retained communication or board evidence visible.'} +

    + )} +
    +
    +

    + Observed cell:{' '} + {entry.turn.observation.currentCell.cell}{' '} + {onHighlightCell && ( + + )} +

    +

    + Legal choices:{' '} + {entry.legalActions.join(' · ')} +

    +

    + Chosen: {entry.chosenAction}{' '} + {entry.chosenCell && onHighlightCell && ( + + )} +

    + {entry.actionPattern && ( +

    + Pattern: {entry.actionPattern} +

    + )} +

    + + Model summary (self-reported, not proof): + {' '} + {entry.turn.outcome === 'accepted' || + entry.turn.outcome === 'rejected' + ? entry.turn.summary + : `${entry.turn.failure.code}: ${entry.turn.failure.message}`} +

    +
    +
    + Continuity operations + {entry.continuity.length ? ( +
      + {entry.continuity.map((item) => ( +
    • {item}
    • + ))} +
    + ) : ( +

    + No goal, memory, communication, or diplomacy update. +

    + )} +
    +
    +
    +
  2. + ); + })} +
+ )} +
+ ); +} + function AgentInspector({ agent, snapshot, @@ -3754,6 +3918,7 @@ function AgentInspector({ mutationDisabled, mutationPending, onApplyPersonality, + onHighlightCell, metrics, controlledCellCount, controlChanges, @@ -3776,6 +3941,7 @@ function AgentInspector({ agentId: AgentId, personality: string, ) => Promise; + onHighlightCell?: (cell: H3Cell) => void; metrics?: SimulationSnapshot['experiment']['metrics']['aggregate']; controlledCellCount: number; controlChanges: Array< @@ -3826,6 +3992,7 @@ function AgentInspector({ turn.agentId === agent.id && turn.memoryOperationResult.requested, ); + const inspectorSectionPrefix = `agent-${agent.id}`; const apply = async () => { const parsed = personalitySchema.safeParse(draft); @@ -3849,6 +4016,17 @@ function AgentInspector({ Patient Zero )} +
Patient Zero role
@@ -3903,7 +4081,13 @@ function AgentInspector({
-

Goals

+ +

Goals

{currentGoal ? (
@@ -3944,7 +4128,7 @@ function AgentInspector({ Agent reason: {latestGoalTurn.goalRevision.reason}

)} -

Memories

+

Memories

{currentMemory.length ? (
    {currentMemory.map((entry) => ( @@ -4026,7 +4210,9 @@ function AgentInspector({ )}
-

Recent territory gains and losses

+

+ Recent territory gains and losses +

{controlChanges.length === 0 ? (

No territory gains or losses for this agent yet. @@ -4105,7 +4291,10 @@ function AgentInspector({ })} )} -

+

Active personality

{!editing && (