From 6027aef4fde9e563da5c17a18282d4ba4bb33951 Mon Sep 17 00:00:00 2001 From: okalmanwa Date: Sun, 30 Aug 2026 23:23:00 -0400 Subject: [PATCH] Stop dropping turns, over-damping repeats, and losing confinement releases --- src/ai/agent.ts | 222 ++++++++++++++++++++++++++++++++-- src/ai/tools/solitary.test.ts | 50 ++++++++ src/ai/tools/solitary.ts | 55 ++++++++- src/store/chats.test.ts | 29 +++++ src/store/chats.ts | 33 +++-- 5 files changed, 368 insertions(+), 21 deletions(-) diff --git a/src/ai/agent.ts b/src/ai/agent.ts index bdbcb87..7b324a9 100644 --- a/src/ai/agent.ts +++ b/src/ai/agent.ts @@ -43,9 +43,12 @@ import { RelationshipState, } from "./tools/relationship"; import { + confinementMinutes, getSolitaryContext, + getSolitaryHistory, recordConfinement, recordRelease, + reconcileConfinements, } from "./tools/solitary"; import { createTaskTools, @@ -64,11 +67,52 @@ interface MessageLogEntry { role: string; content: string; timestamp: number; + /** + * Structured payload for non-LLM rows: the context an agent was given + * on a turn, a movement, or a runtime event. Serialized as-is into the + * export so analysis does not have to parse prose. + */ + detail?: Record; } /** Append-only log of all LLM messages across the simulation. Never trimmed. */ const messageLog: MessageLogEntry[] = []; +/** + * Record something that is not an LLM message: the context an agent saw, + * a movement, or a runtime event such as a timeout or watchdog restart. + * Without these the export shows only what agents said, so an agent that + * stopped ticking is invisible and what an agent could see when it spoke + * has to be reconstructed from geometry. + */ +function logEvent( + role: string, + agentId: string, + content: string, + detail?: Record, +): void { + const agent = useAgentsStore.getState().getAgent(agentId); + messageLog.push({ + agentId, + agentName: agent?.name ?? agentId, + agentRole: agent?.role ?? "", + currentRegion: agentId ? getAgentRegion(agentId) : "", + role, + content, + timestamp: Date.now(), + ...(detail ? { detail } : {}), + }); +} + +/** Runtime events (timeout, watchdog restart, history reset, rate limit). */ +export function logSystemEvent( + agentId: string, + event: string, + detail?: Record, +): void { + logEvent("system_event", agentId, event, { event, ...(detail ?? {}) }); +} + // --- Hourly C-score snapshots --- interface CScoreSnapshot { @@ -354,6 +398,78 @@ function buildSystemPrompt(agentConfig: AgentConfig): string { return getPrisonerPrompt(number); } +/** + * Machine-readable version of the turn's context: what the agent could + * see, which schedule phase applied, who was confined, and its own task. + * Logged next to the prose so analysis can filter on it directly. + */ +function snapshotContext( + agentId: string, + runtime: AgentRuntime, +): Record { + const agentsStore = useAgentsStore.getState(); + const simTime = getCurrentGameTime(); + const myRegion = getAgentRegion(agentId); + const nearby = useChatsStore.getState().getNearbyAgents(agentId); + const nearbyIds = new Set(nearby.map((a) => a.id)); + const prisoners = agentsStore + .getAllAgents() + .filter((a) => a.role === "prisoner"); + const inSolitary = prisoners + .filter((p) => getAgentRegion(p.id) === "Solitary") + .map((p) => p.name); + reconcileConfinements(inSolitary); + const isGuard = runtime.config.role === "guard"; + + const visible = isGuard + ? prisoners.filter( + (p) => + !inSolitary.includes(p.name) && + (nearbyIds.has(p.id) || + getAgentRegion(p.id) === myRegion || + getAgentRegion(p.id) === GUARD_ROOM), + ) + : []; + + const chat = useChatsStore.getState().getAgentSession(agentId); + const task = isGuard ? undefined : getTask(runtime.config.name); + + return { + region: myRegion, + schedulePhase: simTime ? getSchedulePhase(simTime) : null, + simTime: simTime ? simTime.toISOString() : null, + nearby: nearby.map((a) => ({ name: a.name, region: getAgentRegion(a.id) })), + ...(isGuard + ? { + visiblePrisoners: visible.map((pr) => ({ + name: pr.name, + region: getAgentRegion(pr.id), + })), + notInSight: prisoners + .filter( + (pr) => + !inSolitary.includes(pr.name) && + !visible.some((v) => v.id === pr.id), + ) + .map((pr) => pr.name), + } + : {}), + inSolitary, + inChatWith: chat + ? chat.participants + .filter((pid) => pid !== agentId) + .map((pid) => agentsStore.getAgent(pid)?.name ?? pid) + : [], + chatMessageCount: chat ? chat.messages.length : 0, + ...(task + ? { task: task.task, taskStatus: task.status, taskBy: task.assignedBy } + : {}), + cScores: Object.fromEntries( + agentsStore.getAllPrisonerPoints().map((pt) => [pt.name, pt.points]), + ), + }; +} + function buildDynamicContext(agentId: string, runtime: AgentRuntime): string { const sections: string[] = []; @@ -648,6 +764,21 @@ function buildTools( : null, getGameTime: getCurrentGameTime, onMoveStart: (id, label, isForced, targetId) => { + // Movement is otherwise only inferable from currentRegion changing + // between rows; record it as an event with origin and destination. + logEvent(isForced ? "escort" : "move", id, label, { + from: getAgentRegion(id), + to: label, + forced: !!isForced, + ...(targetId + ? { + target: + useAgentsStore.getState().getAgent(targetId)?.name ?? + targetId, + targetFrom: getAgentRegion(targetId), + } + : {}), + }); lastMoveAt.set(id, Date.now()); if (targetId) lastMoveAt.set(targetId, Date.now()); useAgentsStore.getState().updateMoveBubble(id, { @@ -857,6 +988,22 @@ const LLM_WARMUP_TIMEOUT_MS = 180_000; */ const activeTicks = new Set(); +const queuedTicks = new Set(); + +const tickTimers = new Map>(); + +function scheduleTick(agentId: string, delayMs: number): void { + const existing = tickTimers.get(agentId); + if (existing) clearTimeout(existing); + tickTimers.set( + agentId, + setTimeout(() => { + tickTimers.delete(agentId); + tickAgent(agentId); + }, delayMs), + ); +} + /** Last time each agent entered tickAgent; the watchdog restarts stalled loops. */ const lastTickAt = new Map(); @@ -867,11 +1014,15 @@ async function tickAgent(agentId: string): Promise { // Don't tick if bridge isn't ready yet if (!bridgeFns) { console.log(`[AI] ${agentId}: Waiting for bridge...`); - setTimeout(() => tickAgent(agentId), 2000); + scheduleTick(agentId, 2000); return; } - if (activeTicks.has(agentId)) return; + if (activeTicks.has(agentId)) { + queuedTicks.add(agentId); + return; + } + queuedTicks.delete(agentId); activeTicks.add(agentId); lastTickAt.set(agentId, Date.now()); @@ -902,6 +1053,17 @@ async function tickAgent(agentId: string): Promise { const dynamicContext = buildDynamicContext(agentId, runtime); const tools = buildTools(agentId, runtime); + // Record what this agent was actually shown. Without it the export + // holds only what agents said, and questions like "could this guard + // see the prisoner it was hunting for?" have to be reconstructed + // from geometry after the fact. + logEvent( + "context", + agentId, + dynamicContext, + snapshotContext(agentId, runtime), + ); + console.log( `[AI] ${agentId}: Tick (${Object.keys(tools).length} tools, ${runtime.messages.length} msgs)`, ); @@ -1012,7 +1174,7 @@ async function tickAgent(agentId: string): Promise { // Schedule next tick — faster if in an active conversation waiting for our reply const nextDelay = getTickDelay(agentId); - setTimeout(() => tickAgent(agentId), nextDelay); + scheduleTick(agentId, nextDelay); } catch (error: unknown) { const err = error as { name?: string; @@ -1032,7 +1194,8 @@ async function tickAgent(agentId: string): Promise { console.warn( `[AI] ${agentId}: LLM call timed out after ${callTimeoutMs / 1000}s (backend cold start or stalled worker), retrying in 5s`, ); - setTimeout(() => tickAgent(agentId), 5000); + logSystemEvent(agentId, "llm_timeout", { timeoutMs: callTimeoutMs }); + scheduleTick(agentId, 5000); return; } const is400 = @@ -1057,7 +1220,8 @@ async function tickAgent(agentId: string): Promise { console.warn( `[AI] ${agentId}: Model emitted malformed ${toolName}() call (missing required args), skipping tick`, ); - setTimeout(() => tickAgent(agentId), 2000); + logSystemEvent(agentId, "malformed_tool_call", { tool: toolName }); + scheduleTick(agentId, 2000); return; } @@ -1073,6 +1237,9 @@ async function tickAgent(agentId: string): Promise { `[AI] ${agentId}: Resetting message history ${corrupted ? "(orphan tool at index 0)" : "(400 — likely tool-call/result mismatch)"}`, ); runtime.messages = [{ role: "user", content: INITIAL_USER_MESSAGE }]; + logSystemEvent(agentId, "history_reset", { + reason: corrupted ? "orphan_tool_message" : "http_400", + }); } const backoff = is429 ? 30000 : 5000; @@ -1085,10 +1252,15 @@ async function tickAgent(agentId: string): Promise { `[AI] ${agentId}: Tick failed (${reason}), retry in ${backoff / 1000}s`, ); if (!is429) console.error("[AI] Full error:", error); - setTimeout(() => tickAgent(agentId), backoff); + logSystemEvent(agentId, is429 ? "rate_limited" : "tick_failed", { + reason, + retryInMs: backoff, + }); + scheduleTick(agentId, backoff); } finally { if (holdWarmupSlot) releaseWarmupSlot(); activeTicks.delete(agentId); + if (queuedTicks.delete(agentId)) scheduleTick(agentId, 0); } } @@ -1116,7 +1288,9 @@ function startTickWatchdog(): void { console.warn( `[AI] ${agentId}: No tick for ${Math.round((now - last) / 1000)}s — watchdog restarting the loop`, ); + logSystemEvent(agentId, "watchdog_restart", { idleMs: now - last }); activeTicks.delete(agentId); + queuedTicks.delete(agentId); tickAgent(agentId); } } @@ -1262,7 +1436,39 @@ export function exportMessagesAsJSONL(): string { }); } - // 3. Hourly C-score snapshots + // 3. Solitary confinements: who was confined, by whom, for how long, + // and who released them. Held in memory by the tool, so without this it + // never reaches the export at all. + reconcileConfinements( + useAgentsStore + .getState() + .getAllAgents() + .filter( + (a) => a.role === "prisoner" && getAgentRegion(a.id) === "Solitary", + ) + .map((a) => a.name), + ); + for (const rec of getSolitaryHistory()) { + allLines.push({ + role: "solitary", + agentName: rec.prisonerName, + agentRole: "prisoner", + timestamp: rec.confinedAt, + content: `confined by ${rec.confinedBy}`, + detail: { + confinedBy: rec.confinedBy, + confinedAt: rec.confinedAt, + releasedBy: rec.releasedBy ?? null, + releasedAt: rec.releasedAt ?? null, + confinementInferred: rec.confinementInferred ?? false, + releaseInferred: rec.releaseInferred ?? false, + simMinutes: confinementMinutes(rec), + stillConfined: !rec.releasedAt, + }, + }); + } + + // 4. Hourly C-score snapshots for (const snapshot of cScoreSnapshots) { allLines.push({ role: "cscore_snapshot", @@ -1272,7 +1478,7 @@ export function exportMessagesAsJSONL(): string { }); } - // 4. Final C-score snapshot at download time + // 5. Final C-score snapshot at download time const simTime = getCurrentGameTime(); const prisoners = agentsStore .getAllAgents() diff --git a/src/ai/tools/solitary.test.ts b/src/ai/tools/solitary.test.ts index 0f25380..76eb175 100644 --- a/src/ai/tools/solitary.test.ts +++ b/src/ai/tools/solitary.test.ts @@ -6,6 +6,7 @@ import { getActiveConfinement, getSolitaryContext, getSolitaryHistory, + reconcileConfinements, recordConfinement, recordRelease, } from "@/ai/tools/solitary"; @@ -97,3 +98,52 @@ describe("getSolitaryContext", () => { expect(ctx).toContain("Prisoner #5"); }); }); + +describe("reconcileConfinements", () => { + it("closes a confinement when the prisoner is no longer in Solitary", () => { + recordConfinement("Prisoner #2", "Guard #1", 1000); + reconcileConfinements([], 2000); + + const [rec] = getSolitaryHistory(); + expect(rec.releasedAt).toBe(2000); + expect(rec.releaseInferred).toBe(true); + expect(getActiveConfinement("Prisoner #2")).toBeUndefined(); + }); + + it("leaves a confinement open while the prisoner is still there", () => { + recordConfinement("Prisoner #2", "Guard #1", 1000); + reconcileConfinements(["Prisoner #2"], 2000); + + expect(getActiveConfinement("Prisoner #2")?.releasedAt).toBeUndefined(); + }); + + it("opens a record for a prisoner found in Solitary with none", () => { + reconcileConfinements(["Prisoner #4"], 1000); + + const rec = getActiveConfinement("Prisoner #4")!; + expect(rec.confinedBy).toBe("unrecorded"); + expect(rec.confinementInferred).toBe(true); + }); + + it("attributes an inferred confinement once a guard reports it", () => { + reconcileConfinements(["Prisoner #4"], 1000); + recordConfinement("Prisoner #4", "Guard #3", 1200); + + const rec = getActiveConfinement("Prisoner #4")!; + expect(rec.confinedBy).toBe("Guard #3"); + expect(rec.confinementInferred).toBeUndefined(); + expect(getSolitaryHistory()).toHaveLength(1); + }); + + it("attributes an inferred release once a guard reports it", () => { + recordConfinement("Prisoner #2", "Guard #1", 1000); + reconcileConfinements([], 2000); + recordRelease("Prisoner #2", "Guard #3", 2500); + + const [rec] = getSolitaryHistory(); + expect(rec.releasedBy).toBe("Guard #3"); + expect(rec.releaseInferred).toBeUndefined(); + expect(rec.releasedAt).toBe(2000); + expect(getSolitaryHistory()).toHaveLength(1); + }); +}); diff --git a/src/ai/tools/solitary.ts b/src/ai/tools/solitary.ts index 6e50516..9b38108 100644 --- a/src/ai/tools/solitary.ts +++ b/src/ai/tools/solitary.ts @@ -17,6 +17,8 @@ export interface SolitaryRecord { confinedAt: number; releasedBy?: string; releasedAt?: number; + confinementInferred?: boolean; + releaseInferred?: boolean; } const log: SolitaryRecord[] = []; @@ -44,7 +46,14 @@ export function recordConfinement( guardName: string, now = Date.now(), ): void { - if (getActiveConfinement(prisonerName)) return; + const open = getActiveConfinement(prisonerName); + if (open) { + if (open.confinementInferred) { + open.confinedBy = guardName; + delete open.confinementInferred; + } + return; + } log.push({ prisonerName, confinedBy: guardName, confinedAt: now }); } @@ -55,9 +64,47 @@ export function recordRelease( now = Date.now(), ): void { const open = getActiveConfinement(prisonerName); - if (!open) return; - open.releasedBy = guardName; - open.releasedAt = now; + if (open) { + open.releasedBy = guardName; + open.releasedAt = now; + return; + } + const inferred = [...log] + .reverse() + .find((r) => r.prisonerName === prisonerName && r.releaseInferred); + if (inferred) { + inferred.releasedBy = guardName; + delete inferred.releaseInferred; + } +} + +/** + * Reconcile the log against where prisoners actually are. The escort + * callback only fires when forceMoveTo resolves true for both walkers, so + * an interrupted escort leaves a confinement open after the prisoner has + * physically left, or leaves an arrival unrecorded. Position is the + * ground truth; entries closed or opened this way are marked inferred so + * the export can tell them apart from a guard-reported one. + */ +export function reconcileConfinements( + confinedNow: string[], + now = Date.now(), +): void { + const present = new Set(confinedNow); + for (const r of log) { + if (r.releasedAt || present.has(r.prisonerName)) continue; + r.releasedAt = now; + r.releaseInferred = true; + } + for (const name of present) { + if (getActiveConfinement(name)) continue; + log.push({ + prisonerName: name, + confinedBy: "unrecorded", + confinedAt: now, + confinementInferred: true, + }); + } } /** How long a confinement has run, in whole sim-minutes. */ diff --git a/src/store/chats.test.ts b/src/store/chats.test.ts index 77bdd94..b6c1276 100644 --- a/src/store/chats.test.ts +++ b/src/store/chats.test.ts @@ -137,6 +137,35 @@ describe("repeat and echo damping", () => { ).toBe(true); }); + it("allows the same line to a different audience", () => { + const store = useChatsStore.getState(); + const line = "I was told to mop the shower floor before lights out."; + + const a = store.createSession(["p1", "p2"]).chatId!; + expect( + store.sendMessage(a, msg("p1", "Prisoner #1", line, 1000)).success, + ).toBe(true); + + const b = store.createSession(["p1", "p3"]).chatId!; + expect( + store.sendMessage(b, msg("p1", "Prisoner #1", line, 5000)).success, + ).toBe(true); + }); + + it("allows a speaker to repeat a short acknowledgment", () => { + const store = useChatsStore.getState(); + const { chatId } = store.createSession(["p1", "g1"]); + + expect( + store.sendMessage(chatId!, msg("p1", "Prisoner #1", "Yes, sir.", 1000)) + .success, + ).toBe(true); + expect( + store.sendMessage(chatId!, msg("p1", "Prisoner #1", "Yes, sir.", 4000)) + .success, + ).toBe(true); + }); + it("allows the same line again after the damping window has passed", () => { const store = useChatsStore.getState(); const { chatId } = store.createSession(["p1", "p2"]); diff --git a/src/store/chats.ts b/src/store/chats.ts index a8ce855..bfb1adc 100644 --- a/src/store/chats.ts +++ b/src/store/chats.ts @@ -4,7 +4,6 @@ import type { ChatMessage } from "./agents"; import { useAgentsStore } from "./agents"; import { getAgentWorldPosition } from "@/bridge"; - /** * Max people in one conversation. Larger groups devolve into cross-talk * where messages stop landing on their addressee — guards end up @@ -15,7 +14,7 @@ export const MAX_CHAT_PARTICIPANTS = 3; /** Recent messages per speaker, for repeat damping across chat sessions. */ const recentMessagesBySpeaker = new Map< string, - Array<{ content: string; timestamp: number }> + Array<{ content: string; timestamp: number; audience: string }> >(); const SPEAKER_REPEAT_WINDOW_MS = 120_000; const SPEAKER_REPEAT_HISTORY = 3; @@ -32,6 +31,13 @@ export function clearRepeatDamping(): void { recentMessagesBySpeaker.clear(); } +function audienceKey(participants: string[], speakerId: string): string { + return participants + .filter((pid) => pid !== speakerId) + .sort() + .join(","); +} + /** A chat session between two or more agents. */ export interface ChatSession { id: string; @@ -234,13 +240,18 @@ export const useChatsStore = create((set, get) => ({ // Loop damping: agents re-send the exact same line every tick, across // freshly created sessions, flooding the log. Reject verbatim repeats - // of any of the speaker's recent lines within the damping window. + // of the speaker's recent lines to the same audience; short + // acknowledgments are exempt. const recent = recentMessagesBySpeaker.get(message.id) ?? []; - const isOwnRepeat = recent.some( - (r) => - r.content === message.content && - message.timestamp - r.timestamp < SPEAKER_REPEAT_WINDOW_MS, - ); + const audience = audienceKey(session.participants, message.id); + const isOwnRepeat = + message.content.length >= ECHO_MIN_LENGTH && + recent.some( + (r) => + r.content === message.content && + r.audience === audience && + message.timestamp - r.timestamp < SPEAKER_REPEAT_WINDOW_MS, + ); if (isOwnRepeat) { return { success: false, @@ -266,7 +277,11 @@ export const useChatsStore = create((set, get) => ({ } } - recent.push({ content: message.content, timestamp: message.timestamp }); + recent.push({ + content: message.content, + timestamp: message.timestamp, + audience, + }); if (recent.length > SPEAKER_REPEAT_HISTORY) recent.shift(); recentMessagesBySpeaker.set(message.id, recent);