UNPKG

@copilotkit/runtime

Version:

<img src="https://github.com/user-attachments/assets/0a6b64d9-e193-4940-a3f6-60334ac34084" alt="banner" style="border-radius: 12px; border: 2px solid #d6d4fa;" />

541 lines (539 loc) 21.4 kB
import "reflect-metadata"; import { AgentRunner } from "./agent-runner.mjs"; import { finalizeRunEvents } from "@copilotkit/shared"; import { ReplaySubject } from "rxjs"; import { EventType, compactEvents } from "@ag-ui/client"; //#region src/v2/runtime/runner/in-memory.ts const ɵINMEMORY_DEFAULTS = { maxThreads: 1e3, maxRunsPerThread: 100, maxBytes: 512 * 1024 ** 2 }; /** * A limit value is well-formed iff it is a non-negative integer OR `+Infinity`. * `+Infinity` is the documented "disabled/unbounded" sentinel and `0` is the * documented run-cap disable sentinel; both are non-negative and pass. Every * enforcement site (`evictThreadsIfNeeded`, `enforceRunCap`, * `evictByBytesIfNeeded`) compares its counter against the limit with `>` in a * `while`/`if` guard, so only these shapes keep those loops finite and correct. * Rejected: negatives (drive `count > -1` true on an empty collection, so * `enforceRunCap` `shift()!`s `undefined` and throws), `-Infinity` (loops never * terminate their intent — always "over"), `NaN` (every `>` is false, silently * disabling the bound), and non-integer finites (fractional caps are nonsense). */ function ɵisValidLimit(value) { return value === Infinity || Number.isInteger(value) && value >= 0; } /** * Normalize a fully-resolved limits bag so every field is well-formed before it * can reach an enforcement loop. Each field is validated independently against * {@link ɵisValidLimit}; an invalid value is CLAMPED to its * {@link ɵINMEMORY_DEFAULTS} floor and a single `console.warn` naming the field * and the received value is emitted. * * Clamp-and-warn (rather than throw) is deliberate and matches this file's * established posture toward bad input: `ɵestimateBytes` swallows serialization * failures and returns 0, the limits-clobber path warns rather than throwing, * and both the eviction and clobber logs are wrapped so "logging must never * break construction/a run". Constructing a bounded in-memory runner is a * best-effort, non-durable convenience; a typo'd bound must degrade to a safe * default, never abort construction or (worse) surface later as an unhandled * rejection from the fire-and-forget finalize path. */ function ɵnormalizeLimits(limits) { const normalized = { ...limits }; for (const field of Object.keys(ɵINMEMORY_DEFAULTS)) { const value = limits[field]; if (!ɵisValidLimit(value)) { const fallback = ɵINMEMORY_DEFAULTS[field]; normalized[field] = fallback; try { console.warn(`[CopilotKit] InMemoryAgentRunner: invalid ${field} value ${String(value)} (expected a non-negative integer or Infinity); falling back to ${String(fallback)}.`); } catch {} } } return normalized; } const EVICTION_GUIDANCE = "[CopilotKit] InMemoryAgentRunner evicted in-memory thread history to stay under memory limits. This runner is bounded and non-durable by design. For durable or production threads, configure an Intelligence backend."; const LIMITS_CLOBBER_GUIDANCE = "[CopilotKit] InMemoryAgentRunner was constructed with in-memory limits that differ from the already-configured process-global store; the last-constructed runner's limits apply to ALL in-memory threads (the store is shared per-process). Configure a single consistent set of limits, or use an Intelligence backend for isolated bounds."; /** * Best-effort approximate byte size of a value, via serialized length. * Never throws — returns 0 when the value cannot be serialized. This is an * approximation (UTF-16 length, not exact heap bytes), used only for relative * accounting against `maxBytes`. */ function ɵestimateBytes(value) { try { return JSON.stringify(value)?.length ?? 0; } catch { return 0; } } var InMemoryEventStore = class { constructor(threadId) { this.threadId = threadId; this.subject = null; this.isRunning = false; this.currentRunId = null; this.historicRuns = []; this.agent = null; this.runSubject = null; this.stopRequested = false; this.activeFinalize = null; this.currentEvents = null; this.messagesSnapshot = []; this.approxMessagesSnapshotBytes = 0; this.createdAt = null; } }; var ɵBoundedThreadStore = class { constructor(limits) { this.map = /* @__PURE__ */ new Map(); this.totalBytes = 0; this.warned = false; this.limitsExplicitlySet = false; this.clobberWarned = false; this.limits = ɵnormalizeLimits(limits); } get byteTotal() { return this.totalBytes; } /** * The store's CURRENT effective bounds. Exposed (with the `ɵ` internal-API * prefix) so a partial `setLimits` can coalesce unspecified fields against the * live config rather than the hardcoded {@link ɵINMEMORY_DEFAULTS} — a partial * update must be a partial update, never a silent reset of the fields the * caller did not mention. Returns a copy so callers cannot mutate the store's * bounds through it. */ get ɵlimits() { return { ...this.limits }; } /** * Reconfigure the process-global store's bounds. Called by the * {@link InMemoryAgentRunner} constructor when limits are passed. Because the * store is a per-process singleton, this replaces the bounds for ALL in-memory * threads. Emits {@link LIMITS_CLOBBER_GUIDANCE} at most ONCE per store when a * SECOND (or later) explicit set arrives whose resolved values differ from the * prior explicit set — i.e. a genuine clobber of an already-customized config. * The first explicit customization (defaults → custom) is the intended * override and never warns; identical re-sets never warn. */ setLimits(limits) { const normalized = ɵnormalizeLimits(limits); if (this.limitsExplicitlySet && !this.clobberWarned && (normalized.maxThreads !== this.limits.maxThreads || normalized.maxRunsPerThread !== this.limits.maxRunsPerThread || normalized.maxBytes !== this.limits.maxBytes)) { this.clobberWarned = true; try { console.warn(LIMITS_CLOBBER_GUIDANCE); } catch {} } this.limitsExplicitlySet = true; this.limits = normalized; } get size() { return this.map.size; } /** Re-insert at the tail so Map iteration order stays LRU-first. */ touchOrder(threadId, store) { this.map.delete(threadId); this.map.set(threadId, store); } getOrCreate(threadId) { const existing = this.map.get(threadId); if (existing) { this.touchOrder(threadId, existing); return existing; } const store = new InMemoryEventStore(threadId); this.map.set(threadId, store); this.evictThreadsIfNeeded(threadId); return store; } get(threadId, opts) { const store = this.map.get(threadId); if (store && opts.touch) this.touchOrder(threadId, store); return store; } peek(threadId) { return this.map.get(threadId); } /** * Evict the least-recently-used thread that is neither running NOR * mid-finalization. Returns false if none evictable. The `protect` thread * (typically the one just created) is never evicted, so a fresh thread is not * immediately dropped when it is the only non-running candidate. * * A thread is skipped while `isRunning` OR `stopRequested` is set. * `stop()` flips `isRunning` to false the moment it aborts the agent, but the * run keeps finalizing asynchronously (the abort trips the `catch` in * `runAgent`, which later calls `appendRun`). During that window * `stopRequested` stays true; evicting the thread then would make the pending * `appendRun` hit `if (!store) return` and silently drop the aborted run's * history. Guarding on `stopRequested` keeps the thread alive until * finalization completes. */ evictOneLru(protect) { for (const [threadId, store] of this.map) { if (threadId === protect) continue; if (store.isRunning || store.stopRequested) continue; this.removeThread(threadId, store); this.noteEviction(); return true; } return false; } appendRun(threadId, run) { const store = this.map.get(threadId); if (!store) return; if (store.createdAt === null) store.createdAt = run.createdAt; if (run.messages.length > 0) { this.totalBytes -= store.approxMessagesSnapshotBytes; store.messagesSnapshot = run.messages; store.approxMessagesSnapshotBytes = ɵestimateBytes(run.messages); this.totalBytes += store.approxMessagesSnapshotBytes; } run.messages = []; run.approxMessageBytes = 0; run.approxEventBytes = ɵestimateBytes(run.events); store.historicRuns.push(run); this.totalBytes += run.approxEventBytes; this.touchOrder(threadId, store); this.enforceRunCap(store); this.evictByBytesIfNeeded(threadId); } enforceRunCap(store) { const cap = this.limits.maxRunsPerThread; if (!cap || cap === Infinity) return; while (store.historicRuns.length > cap) { const dropped = store.historicRuns.shift(); this.totalBytes -= dropped.approxEventBytes ?? 0; this.noteEviction(); } } /** * Trim the store back under the byte ceiling by evicting LRU non-running * threads. `protect` (the just-appended thread) is never self-evicted, so a * fresh run pushes OTHER threads out rather than dropping itself. */ evictByBytesIfNeeded(protect) { while (this.totalBytes > this.limits.maxBytes) if (!this.evictOneLru(protect)) break; } removeThread(threadId, store) { for (const run of store.historicRuns) this.totalBytes -= run.approxEventBytes ?? 0; this.totalBytes -= store.approxMessagesSnapshotBytes; this.map.delete(threadId); } evictThreadsIfNeeded(protect) { while (this.map.size > this.limits.maxThreads) if (!this.evictOneLru(protect)) break; } noteEviction() { if (this.warned) return; this.warned = true; try { console.warn(EVICTION_GUIDANCE); } catch {} } listThreads() { const threads = []; for (const [threadId, store] of this.map) { if (store.historicRuns.length === 0) continue; const lastRun = store.historicRuns[store.historicRuns.length - 1]; threads.push({ id: threadId, name: null, agentId: lastRun.agentId, organizationId: "", createdById: "", archived: false, createdAt: new Date(store.createdAt ?? lastRun.createdAt).toISOString(), updatedAt: new Date(lastRun.createdAt).toISOString() }); } return threads.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); } clear() { this.map.clear(); this.totalBytes = 0; this.warned = false; } }; /** * Process-wide singleton backing every {@link InMemoryAgentRunner}. Exported * (with the `ɵ` internal-API prefix) so tests can inspect the exact store the * runner writes to; not part of the public API. */ const ɵGLOBAL_STORE = new ɵBoundedThreadStore(ɵINMEMORY_DEFAULTS); const sharedStore = ɵGLOBAL_STORE; var InMemoryAgentRunner = class extends AgentRunner { /** * @param options Per-runner behavior (`onConcurrentRun`) plus optional bounds * for the in-memory store ({@link InMemoryLimits}). * * Note the differing scopes: `onConcurrentRun` is per-runner instance, while * the limits reconfigure the PROCESS-GLOBAL store shared by every * `InMemoryAgentRunner`. Omit the limits for safe defaults * ({@link ɵINMEMORY_DEFAULTS}); passing none leaves the store untouched. When * multiple runners are constructed with differing limits, the last-constructed * wins — in practice the OSS/SSE default construction passes nothing. If a * second (or later) runner is constructed with limits that DIFFER from an * already-customized store, a one-time `console.warn` is emitted to signal that * the shared store's bounds are being clobbered for ALL in-memory threads. */ constructor(options) { super(); this.ɵsupportsLocalThreadEndpoints = true; const { onConcurrentRun, ...limits } = options ?? {}; this.onConcurrentRun = onConcurrentRun ?? "throw"; if (limits.maxThreads !== void 0 || limits.maxRunsPerThread !== void 0 || limits.maxBytes !== void 0) { const current = sharedStore.ɵlimits; sharedStore.setLimits({ maxThreads: limits.maxThreads ?? current.maxThreads, maxRunsPerThread: limits.maxRunsPerThread ?? current.maxRunsPerThread, maxBytes: limits.maxBytes ?? current.maxBytes }); } } run(request) { const store = sharedStore.getOrCreate(request.threadId); if (store.isRunning || store.stopRequested) { if (this.onConcurrentRun !== "supersede") throw new Error("Thread already running"); const priorAgent = store.agent; const priorFinalize = store.activeFinalize; if (priorFinalize) priorFinalize.stopRequested = true; store.isRunning = false; if (priorAgent) try { priorAgent.abortRun(); } catch (error) { console.error("Failed to abort superseded run", error); } } store.isRunning = true; store.currentRunId = request.input.runId; store.agent = request.agent; store.stopRequested = false; const finalizeControl = { stopRequested: false }; store.activeFinalize = finalizeControl; const seenMessageIds = /* @__PURE__ */ new Set(); const currentRunEvents = []; store.currentEvents = currentRunEvents; const historicMessageIds = /* @__PURE__ */ new Set(); for (const run of store.historicRuns) for (const event of run.events) { if ("messageId" in event && typeof event.messageId === "string") historicMessageIds.add(event.messageId); if (event.type === EventType.RUN_STARTED) { const messages = event.input?.messages ?? []; for (const message of messages) historicMessageIds.add(message.id); } } const nextSubject = new ReplaySubject(Infinity); store.subject = nextSubject; const runSubject = new ReplaySubject(Infinity); store.runSubject = runSubject; const runAgent = async () => { const parentRunId = store.historicRuns[store.historicRuns.length - 1]?.runId ?? null; const finalizeRun = (opts) => { const isError = opts.interruptionMessage !== void 0; const preFinalizeEventCount = currentRunEvents.length; const appendedEvents = finalizeRunEvents(currentRunEvents, { stopRequested: finalizeControl.stopRequested, ...isError ? { interruptionMessage: opts.interruptionMessage } : {} }); for (const event of appendedEvents) { runSubject.next(event); nextSubject.next(event); } const ownsThread = store.currentRunId === request.input.runId; if (ownsThread && (!isError || preFinalizeEventCount > 0)) { const compactedEvents = compactEvents(currentRunEvents); sharedStore.appendRun(request.threadId, { threadId: request.threadId, runId: request.input.runId, agentId: request.agent.agentId ?? "default", parentRunId, events: compactedEvents, messages: Array.isArray(request.agent.messages) ? [...request.agent.messages] : [], createdAt: Date.now() }); } if (ownsThread) { store.currentEvents = null; store.currentRunId = null; store.agent = null; store.runSubject = null; store.stopRequested = false; store.isRunning = false; store.activeFinalize = null; } runSubject.complete(); nextSubject.complete(); if (store.subject === nextSubject) store.subject = null; }; try { await request.agent.runAgent(request.input, { onEvent: ({ event }) => { let processedEvent = event; if (event.type === EventType.RUN_STARTED) { const runStartedEvent = event; if (!runStartedEvent.input) { const sanitizedMessages = request.input.messages ? request.input.messages.filter((message) => !historicMessageIds.has(message.id)) : void 0; runStartedEvent.input = { ...request.input, ...sanitizedMessages !== void 0 ? { messages: sanitizedMessages } : {} }; processedEvent = runStartedEvent; } } runSubject.next(processedEvent); nextSubject.next(processedEvent); currentRunEvents.push(processedEvent); }, onNewMessage: ({ message }) => { if (!seenMessageIds.has(message.id)) seenMessageIds.add(message.id); }, onRunStartedEvent: () => { if (request.input.messages) { for (const message of request.input.messages) if (!seenMessageIds.has(message.id)) seenMessageIds.add(message.id); } } }); finalizeRun({}); } catch (error) { finalizeRun({ interruptionMessage: error instanceof Error ? error.message : String(error) }); } }; runAgent(); return runSubject.asObservable(); } connect(request) { const store = sharedStore.get(request.threadId, { touch: true }); const connectionSubject = new ReplaySubject(Infinity); if (!store) { connectionSubject.complete(); return connectionSubject.asObservable(); } const allHistoricEvents = []; for (const run of store.historicRuns) allHistoricEvents.push(...run.events); const compactedEvents = compactEvents(allHistoricEvents); const emittedMessageIds = /* @__PURE__ */ new Set(); for (const event of compactedEvents) { connectionSubject.next(event); if ("messageId" in event && typeof event.messageId === "string") emittedMessageIds.add(event.messageId); } if (store.subject && (store.isRunning || store.stopRequested)) store.subject.subscribe({ next: (event) => { if ("messageId" in event && typeof event.messageId === "string" && emittedMessageIds.has(event.messageId)) return; connectionSubject.next(event); }, complete: () => connectionSubject.complete(), error: (err) => connectionSubject.error(err) }); else connectionSubject.complete(); return connectionSubject.asObservable(); } isRunning(request) { const store = sharedStore.peek(request.threadId); return Promise.resolve(store?.isRunning ?? false); } stop(request) { const store = sharedStore.peek(request.threadId); if (!store || !store.isRunning) return Promise.resolve(false); if (request.runId !== void 0 && store.currentRunId !== request.runId) return Promise.resolve(false); if (store.stopRequested) return Promise.resolve(false); store.stopRequested = true; store.isRunning = false; const finalizeControl = store.activeFinalize; if (finalizeControl) finalizeControl.stopRequested = true; const agent = store.agent; if (!agent) { store.stopRequested = false; store.isRunning = false; if (finalizeControl) finalizeControl.stopRequested = false; return Promise.resolve(false); } try { agent.abortRun(); return Promise.resolve(true); } catch (error) { console.error("Failed to abort agent run", error); store.stopRequested = false; store.isRunning = true; if (finalizeControl) finalizeControl.stopRequested = false; return Promise.resolve(false); } } /** * Returns a summary of every thread that has been run through this runner. * * This powers the local-dev fallback for `GET /threads` when the Intelligence * platform is not configured. Each entry mirrors the shape of a platform * `ThreadRecord` so the HTTP handler can use the same response envelope. */ listThreads() { return sharedStore.listThreads(); } /** * Returns all messages for a thread, using the snapshot captured at the end * of the most recent run. * * This powers the local-dev fallback for `GET /threads/:threadId/messages` * when the Intelligence platform is not configured. The returned `Message[]` * objects come directly from the ag-ui agent, so their shape is compatible * with the Intelligence platform's `ThreadMessage` type. */ getThreadMessages(threadId) { const store = sharedStore.peek(threadId); if (!store) return []; return [...store.messagesSnapshot]; } /** * Returns all AG-UI events for a thread, compacted across historic runs. * * Powers the local-dev fallback for `GET /threads/:threadId/events` when the * Intelligence platform is not configured. The compaction logic matches * the connection-replay path in {@link connect}, so the stream a * late-joining inspector sees matches what this method returns. */ getThreadEvents(threadId) { const store = sharedStore.peek(threadId); if (!store || store.historicRuns.length === 0) return []; const all = []; for (const run of store.historicRuns) all.push(...run.events); return compactEvents(all); } /** * Returns the agent state snapshot for a thread. * * Derived from the last `STATE_SNAPSHOT` in the compacted event stream. The * AG-UI `compactEvents` helper consolidates STATE_DELTA events and produces * a single trailing STATE_SNAPSHOT when state changes exist, so this is a * faithful view of state at the end of the most recent run. * * Returns `null` when the thread has never emitted a STATE_SNAPSHOT. */ getThreadState(threadId) { const events = this.getThreadEvents(threadId); for (let i = events.length - 1; i >= 0; i--) { const event = events[i]; if (event.type === EventType.STATE_SNAPSHOT) { const snapshot = event.snapshot; if (snapshot && typeof snapshot === "object" && !Array.isArray(snapshot)) return { ...snapshot }; return null; } } return null; } /** * Clears all in-memory thread history. * * Powers the local-dev fallback for `POST /threads/clear`, letting consumers * (e.g. the demo's Clear button) reset to an empty thread list without * restarting the runtime. Intentionally not exposed on the Intelligence * platform path: there, thread history lives in a real database and must * not be wiped this way. */ clearThreads() { sharedStore.clear(); } }; //#endregion export { InMemoryAgentRunner, ɵBoundedThreadStore, ɵGLOBAL_STORE, ɵINMEMORY_DEFAULTS, ɵestimateBytes, ɵnormalizeLimits }; //# sourceMappingURL=in-memory.mjs.map