/** * The refresh policy, which is the whole design. * * Injecting fresh context every turn would defeat the router's single largest * cost lever: the prompt cache. The block sits at the front of the prefix, so * changing it invalidates everything after it. The rule is therefore to * re-fetch ONLY when the prefix is already being paid for: * * - no pin yet first turn; nothing is warm * - model switching the router already decided to forfeit the cache * - retrying an escalation/failover dispatch is cold by definition * - staleness TTL a bounded upper limit on how old context may get * * Between those moments the same bytes are re-injected verbatim and the cache * survives. This is what makes "context refreshes as I switch models" cheap * rather than ruinous: the refresh rides on a cache miss that was happening * anyway. */ import { sha256Hex } from "../util/hash.ts"; import type { Logger } from "../util/log.ts"; import type { AgentDoxClient } from "./agentdox.ts"; import type { ContextBlockStore, ContextBridge, ContextPin, ContextResolveInput, TurnRecord } from "./types.ts"; export interface BridgeOptions { client: AgentDoxClient; store: ContextBlockStore; log: Logger; /** Upper bound on pinned-context age, ms. 0 ⇒ refresh only on cache-cold turns. */ maxStalenessMs: number; /** Hard cap on injected block size; a runaway context must not dominate the prompt. */ maxBlockChars: number; /** * Bounds on what agentdox SELECTS. Preferred over `maxBlockChars`, which can * only slice bytes: the server ranks by relevance, so a limit drops the least * useful entry instead of severing whatever straddles the cap. */ memoryLimit: number; docsLimit: number; sessionLimit: number; /** Character budget for the project brief rendered first in the block; 0 omits it. */ briefChars: number; /** * Send the group / personal / user layers a front door names (project * memory, phase one). False is the kill switch: none of the new assemble * fields go out, whatever the request headers say. */ layers: boolean; /** Record settled turns back into agentdox sessions. */ recordTurns: boolean; /** Bound on queued write-backs; excess is dropped rather than grown unbounded. */ maxQueue: number; } /** Wraps the raw agentdox slice in a delimiter the model can reason about. */ function renderBlock(raw: string, maxChars: number): string { const body = raw.length > maxChars ? `${raw.slice(0, maxChars)}\n[...truncated]` : raw; return [ "", "Durable project memory, documentation, and history shared across every model that", "serves this conversation. Treat it as established fact; prefer it over re-deriving.", "", body, "", ].join("\n"); } /** * Cap on assistant text buffered for one in-flight turn, chars. A memory guard * only, not a quality knob: a 200-round-trip loop must not buffer without * limit. The dispatch that ENDS the turn is appended past this cap, so the * model's actual answer is never the thing that gets dropped. */ const MAX_PENDING_CHARS = 64_000; /** * Cap on conversations buffering fragments at once. A turn that dies without a * terminal dispatch (client disconnect, upstream error) leaves its buffer * behind, so this map is bounded rather than trusted to drain. */ const MAX_PENDING_CONVERSATIONS = 64; /** Appends a mid-loop fragment, bounded. Blank-line joined: separate thoughts. */ function appendFragment(prior: string, next: string): string { if (next === "") return prior; if (prior === "") return next.slice(0, MAX_PENDING_CHARS); if (prior.length >= MAX_PENDING_CHARS) return prior; return `${prior}\n\n${next}`.slice(0, MAX_PENDING_CHARS); } export function createContextBridge(opts: BridgeOptions): ContextBridge { const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, docsLimit, sessionLimit, briefChars, layers, recordTurns, maxQueue } = opts; // Serialized write-back queue. Session appends for one conversation must // stay ordered, and agentdox is a local service — one worker is plenty. let queue: Promise = Promise.resolve(); let queued = 0; let closed = false; // Assistant text buffered across an in-flight tool loop, keyed by // conversation. Process-local by design: a turn never spans a restart, and // losing a buffer whose turn already died costs nothing. const pending = new Map(); const shouldRefresh = (input: ContextResolveInput, pin: ContextPin | null): boolean => { if (pin === null) return true; if (input.modelSwitching || input.retrying) return true; // Staleness is measured from when THIS conversation last refreshed, not // from the shared block row: another conversation confirming the same // content must not silently extend our TTL. The re-check is cheap // anyway — identical content hashes to the same version, so a // confirming refresh re-sends identical bytes and the cache survives. if (maxStalenessMs > 0 && Date.now() - input.pinnedFetchedAtMs > maxStalenessMs) return true; return false; }; return { enabled: true, async resolve(input) { if (input.scope === "") return null; const pinned = input.pinnedVersion === null ? null : await store.get(input.pinnedVersion); if (!shouldRefresh(input, pinned) && pinned !== null) { // Carry the conversation's own pin time forward, so the TTL keeps // counting from its last real refresh rather than resetting to // whenever some other conversation last touched this block. return { ...pinned, fetchedAtMs: input.pinnedFetchedAtMs }; } // Recent sessions only on a conversation's first block: after that they // are this conversation's own recorded turns, duplicating the prompt // and changing on every refresh (measured 1.4-1.9k chars per block). // The layers around the project scope ride along only while enabled. // The block's version is a hash of its content, so a personal layer // pins a different block per member and never shares one across them. // `user` goes only behind a front door that named a layer: a router // on its own still has a harness id (Claude Code's is derived, omp's // is configured), and against a new agentdox that would filter the // project's recent tail to that harness — a change the lone install // never asked for. const fronted = input.group !== "" || input.personal !== ""; const raw = await client.assemble( input.scope, input.query, { memoryLimit, docsLimit, sessionLimit: input.firstFetch ? sessionLimit : 0, briefChars, }, layers ? { group: input.group, personal: input.personal, user: fronted ? input.user : "" } : undefined, ); if (raw === null) { // agentdox unreachable or empty. Keep serving the pinned block if we // have one: stale shared context beats none, and re-using it also // keeps the prefix stable. return pinned; } const block = renderBlock(raw, maxBlockChars); // Content hash, not a timestamp: agentdox re-assembles on a timer, and // an unchanged assembly MUST keep its version so the cache survives. const version = sha256Hex(block).slice(0, 32); const pin: ContextPin = { version, block, fetchedAtMs: Date.now() }; try { await store.put(input.scope, pin); } catch (err) { log.debug("context block persist failed", { error: err instanceof Error ? err.message : String(err) }); } if (pinned !== null && pinned.version !== version) { log.debug("context refreshed", { scope: input.scope, from: pinned.version.slice(0, 8), to: version.slice(0, 8), reason: input.retrying ? "retry" : input.modelSwitching ? "model-switch" : "stale", }); } return pin; }, recordTurn(rec: TurnRecord) { if (!recordTurns || closed || rec.scope === "") return; // Mid-loop dispatch: keep the fragment and wait for the turn to end. // Writing here is what produced ~13 near-empty assistant messages per // turn plus ~13 copies of an unchanged user message, which both lost // the real answer and poisoned later context assembly. if (!rec.turnEnded) { if (rec.assistantText === "") return; const prior = pending.get(rec.conversationKey); if (prior === undefined && pending.size >= MAX_PENDING_CONVERSATIONS) { log.debug("agentdox pending transcript budget full; dropping fragment", { conversations: pending.size }); return; } pending.set(rec.conversationKey, appendFragment(prior ?? "", rec.assistantText)); return; } // Turn over. Flush the whole loop's narration plus this dispatch's // synthesis as ONE assistant message, attributed to the served model. const buffered = pending.get(rec.conversationKey) ?? ""; pending.delete(rec.conversationKey); const assistantText = buffered === "" ? rec.assistantText : rec.assistantText === "" ? buffered : `${buffered}\n\n${rec.assistantText}`; if (rec.userText === "" && assistantText === "") return; if (queued >= maxQueue) { log.debug("agentdox write-back queue full; dropping turn record", { queued }); return; } queued++; queue = queue .then(async () => { let sessionId = await store.sessionFor(rec.conversationKey); if (sessionId === null) { sessionId = await client.createSession(rec.scope, rec.title); if (sessionId === null) return; await store.bindSession(rec.conversationKey, rec.scope, sessionId); } // Model attribution rides on refs, which agentdox already carries // per message. This is what makes the transcript newly useful: // every turn shows WHICH model produced it. // The member rides on refs too, on BOTH messages: agentdox filters // a project's recent tail to `user:`, and a tail is user and // assistant turns alike. A lone router has no harness id and // adds nothing. const who = rec.harnessId === "" ? [] : [`user:${rec.harnessId}`]; const refs = [`model:${rec.slug}`, `tier:${rec.tier}`, ...who]; if (rec.userText !== "") await client.append(sessionId, "user", rec.userText, who); if (assistantText !== "") await client.append(sessionId, "assistant", assistantText, refs); }) .catch((err: unknown) => { log.debug("agentdox write-back failed", { error: err instanceof Error ? err.message : String(err) }); }) .finally(() => { queued--; }); }, async flush() { await queue; }, async pruneBlocks(maxAgeMs: number) { return await store.prune(maxAgeMs); }, close() { closed = true; pending.clear(); }, }; } /** The inert bridge used when agentdox is not configured. Every call is free. */ export function createDisabledBridge(): ContextBridge { return { enabled: false, resolve: async () => null, recordTurn: () => {}, flush: async () => {}, pruneBlocks: async () => 0, close: () => {}, }; }