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;" />

548 lines (546 loc) 21.9 kB
import "reflect-metadata"; import { AgentRunner } from "./agent-runner.mjs"; import { AG_UI_CHANNEL_EVENT, finalizeRunEvents, phoenixExponentialBackoff } from "@copilotkit/shared"; import { Observable } from "rxjs"; import { randomUUID as randomUUID$1 } from "node:crypto"; import { EventType } from "@ag-ui/client"; import { Socket } from "phoenix"; //#region src/v2/runtime/runner/intelligence.ts const MAX_CONSECUTIVE_SOCKET_ERRORS = 5; const EVENT_RETRY_BASE_MS = 100; const EVENT_RETRY_MAX_MS = 2e3; const RUNNER_EVENT_BATCH_CAPABILITY = "runner_event_batch_v1"; const MAX_RUNNER_EVENT_BATCH_SIZE = 32; const RUNNER_EVENT_BATCH_FLUSH_MS = 5; const EVENT_DURABILITY_DEADLINE_MS = 6e4; var IntelligenceAgentRunner = class extends AgentRunner { constructor(options) { super(); this.threads = /* @__PURE__ */ new Map(); this.options = options; } /** * Create a new Phoenix socket with explicit exponential backoff. * * Each run/connect gets its own socket so that: * - A socket failure only affects a single thread, not all threads. * - Cleanup is simple: channel.leave() + socket.disconnect() tears * down everything for that run with no shared-state concerns. * - Each run gets its own independent retry budget. * * reconnectAfterMs — delay before Phoenix reconnects the WebSocket * after an unclean close. 100ms base, doubling up to maxReconnectMs (default 10s). * * rejoinAfterMs — delay before Phoenix re-joins a channel that * entered the "errored" state. 1s base, doubling up to maxRejoinMs (default 30s). * * These are set explicitly because Phoenix's default schedule is a * fixed stepped array (not exponential), and any code that calls * socket.disconnect() in an onError handler will set * closeWasClean = true and reset the reconnect timer — permanently * killing retries. */ createSocket(authToken = this.options.authToken) { const socket = new Socket(this.options.url, { ...authToken ? { authToken } : {}, reconnectAfterMs: phoenixExponentialBackoff(100, this.options.maxReconnectMs ?? 1e4), rejoinAfterMs: phoenixExponentialBackoff(1e3, this.options.maxRejoinMs ?? 3e4) }); socket.connect(); return socket; } createRunnerEventPayload(event, request, state) { const payload = { ...this.stampRunnerMetadata(this.stampCanonicalRunOwnership(event, request), state) }; payload.threadId = request.threadId; payload.runId = request.input.runId; payload.thread_id = request.threadId; payload.run_id = request.input.runId; return payload; } stampCanonicalRunOwnership(event, request) { const eventRecord = event; eventRecord.threadId = request.threadId; eventRecord.runId = request.input.runId; return eventRecord; } stampRunnerMetadata(event, state) { const eventRecord = event; const existingMetadata = eventRecord.metadata ?? {}; const hasEventId = typeof existingMetadata.cpki_event_id === "string"; const hasEventSeq = typeof existingMetadata.cpki_event_seq === "number"; if (hasEventId && hasEventSeq) { const eventSeq = existingMetadata.cpki_event_seq; state.nextEventSeq = Math.max(state.nextEventSeq, eventSeq + 1); return eventRecord; } const eventSeq = state.nextEventSeq++; eventRecord.metadata = { ...existingMetadata, cpki_event_id: typeof existingMetadata.cpki_event_id === "string" ? existingMetadata.cpki_event_id : randomUUID$1(), cpki_event_seq: eventSeq }; return eventRecord; } run(request) { return this.createRunObservable(request); } runWithStartupBoundary(request) { let resolveStartup; let rejectStartup; const startup = new Promise((resolve, reject) => { resolveStartup = resolve; rejectStartup = reject; }); return { events: this.createRunObservable(request, { resolveStartup: () => resolveStartup?.(), rejectStartup: (error) => rejectStartup?.(error) }), startup }; } createRunObservable(request, startupBoundary) { const { threadId, agent, input } = request; if (this.threads.get(threadId)?.isRunning) throw new Error("Thread already running"); return new Observable((observer) => { if (this.threads.get(threadId)?.isRunning) { observer.error(/* @__PURE__ */ new Error("Thread already running")); return; } const socket = this.createSocket(request.authToken); const channel = socket.channel(`ingestion:${input.runId}`, { thread_id: threadId, run_id: input.runId }); const state = { threadId, runId: input.runId, socket, channel, isRunning: true, stopRequested: false, agent, currentEvents: [], nextEventSeq: 1, hasRunStarted: false, hasJoined: false, supportsRunnerEventBatch: false, producerFinished: false, pendingEvents: /* @__PURE__ */ new Map(), activeEventBatch: null, nextEventPushAttempt: 0, eventRetryTimer: null, eventFlushTimer: null, eventDeadlineTimer: null, socketReconnectWatchdog: null, eventRetryAttempt: 0, completeRun: () => observer.complete(), failRun: (error) => observer.error(error) }; this.threads.set(threadId, state); let consecutiveSocketErrors = 0; let plannedRestart = false; socket.onClose((event) => { if (!this.isCurrentThreadState(threadId, state)) return; plannedRestart = plannedRestart || event?.code === 1012; if (event?.code !== 1e3 && state.socketReconnectWatchdog === null) state.socketReconnectWatchdog = setTimeout(() => { state.socketReconnectWatchdog = null; if (!state.isRunning || socket.isConnected()) return; socket.disconnect(() => { if (state.isRunning && !socket.isConnected()) socket.connect(); }); }, 1e3); }); socket.onOpen(() => { if (!this.isCurrentThreadState(threadId, state)) return; if (state.socketReconnectWatchdog !== null) { clearTimeout(state.socketReconnectWatchdog); state.socketReconnectWatchdog = null; } consecutiveSocketErrors = 0; plannedRestart = false; }); socket.onError(() => { if (!this.isCurrentThreadState(threadId, state)) return; if (plannedRestart || state.hasJoined) return; consecutiveSocketErrors += 1; if (consecutiveSocketErrors >= MAX_CONSECUTIVE_SOCKET_ERRORS) state.agent?.abortRun(); }); channel.on(AG_UI_CHANNEL_EVENT, (payload) => { if (this.isCurrentThreadState(threadId, state) && payload.type === EventType.CUSTOM && payload.name === "stop") this.stop({ threadId, runId: state.runId }); }); channel.join().receive("ok", (response) => { if (!this.isCurrentThreadState(threadId, state)) return; const supportsRunnerEventBatch = this.supportsRunnerEventBatch(response); const activeEventBatch = state.supportsRunnerEventBatch === supportsRunnerEventBatch ? state.activeEventBatch : null; state.supportsRunnerEventBatch = supportsRunnerEventBatch; if (state.hasJoined) { this.resetPendingEventRetry(state); if (activeEventBatch !== null) { state.activeEventBatch = activeEventBatch; this.retryActiveEventBatch(state); } else this.replayPendingEvents(state); return; } state.hasJoined = true; startupBoundary?.resolveStartup(); this.executeAgentRun(request, state, threadId, (event) => { observer.next(event); }); }).receive("error", (resp) => { if (!this.isCurrentThreadState(threadId, state)) return; if (state.hasJoined) { if (this.isPermanentEventFailure(resp)) { const reason = typeof resp.reason === "string" ? `: ${resp.reason}` : ""; this.failThread(threadId, state, /* @__PURE__ */ new Error(`Gateway permanently rejected channel rejoin${reason}`)); } return; } if (this.isRetryableJoinError(resp)) return; const error = /* @__PURE__ */ new Error(`Failed to join channel: ${JSON.stringify(resp)}`); const errorEvent = { type: EventType.RUN_ERROR, message: error.message, code: "CHANNEL_JOIN_ERROR" }; observer.next(errorEvent); state.currentEvents.push(errorEvent); this.removeThread(threadId, state); startupBoundary?.rejectStartup(error); observer.complete(); }).receive("timeout", () => { if (!this.isCurrentThreadState(threadId, state)) return; if (state.hasJoined) return; const error = /* @__PURE__ */ new Error("Timed out joining channel"); const errorEvent = { type: EventType.RUN_ERROR, message: error.message, code: "CHANNEL_JOIN_TIMEOUT" }; observer.next(errorEvent); state.currentEvents.push(errorEvent); this.removeThread(threadId, state); startupBoundary?.rejectStartup(error); observer.complete(); }); return () => { this.removeThread(threadId, state); }; }); } connect(request) { const { threadId } = request; return new Observable((observer) => { const socket = this.createSocket(); const channel = socket.channel(`thread:${threadId}`); channel.on("ag_ui_event", (payload) => { observer.next(payload); if (payload.type === EventType.RUN_FINISHED || payload.type === EventType.RUN_ERROR) observer.complete(); }); const cleanup = () => { channel.leave(); socket.disconnect(); }; channel.join().receive("ok", () => void 0).receive("error", (resp) => { observer.error(/* @__PURE__ */ new Error(`Failed to join channel: ${JSON.stringify(resp)}`)); cleanup(); }).receive("timeout", () => { observer.error(/* @__PURE__ */ new Error("Timed out joining channel")); cleanup(); }); return () => { cleanup(); }; }); } isRunning(request) { const state = this.threads.get(request.threadId); return Promise.resolve(state?.isRunning ?? false); } stop(request) { const state = this.threads.get(request.threadId); if (!state || !state.isRunning || state.stopRequested) return Promise.resolve(false); if (request.runId !== void 0 && state.runId !== request.runId) return Promise.resolve(false); state.stopRequested = true; if (state.agent) try { state.agent.abortRun(); } catch {} return Promise.resolve(true); } async executeAgentRun(request, state, threadId, onRunError) { const { currentEvents } = state; const pushCanonicalEvent = (event) => { if (!this.isCurrentThreadState(threadId, state)) return; const canonicalEvent = this.stampRunnerMetadata(this.stampCanonicalRunOwnership(event, request), state); currentEvents.push(canonicalEvent); if (canonicalEvent.type === EventType.RUN_STARTED) state.hasRunStarted = true; this.queueRunnerEvent(this.createRunnerEventPayload(canonicalEvent, request, state), state); }; const getPersistedInputMessages = () => request.persistedInputMessages ?? request.input.messages; const buildRunStartedEvent = (source) => { const baseInput = source?.input ?? request.input; const persistedInputMessages = getPersistedInputMessages(); const event = source ?? { type: EventType.RUN_STARTED, threadId: request.threadId, runId: request.input.runId }; event.threadId = request.threadId; event.runId = request.input.runId; event.input = { ...baseInput, threadId: request.threadId, runId: request.input.runId, ...persistedInputMessages !== void 0 ? { messages: persistedInputMessages } : {} }; return event; }; const ensureRunStarted = () => { if (!state.hasRunStarted) { state.hasRunStarted = true; pushCanonicalEvent(buildRunStartedEvent()); } }; try { await request.agent.runAgent(request.input, { onEvent: ({ event }) => { if (event.type === EventType.RUN_STARTED) { pushCanonicalEvent(buildRunStartedEvent(event)); return; } ensureRunStarted(); pushCanonicalEvent(event); } }); } catch (error) { if (!this.isCurrentThreadState(threadId, state)) return; ensureRunStarted(); const existingError = currentEvents.find((event) => event.type === EventType.RUN_ERROR); if (existingError) onRunError(existingError); else { const errorEvent = { type: EventType.RUN_ERROR, message: error instanceof Error ? error.message : String(error) }; pushCanonicalEvent(errorEvent); onRunError(errorEvent); } } finally { if (!this.isCurrentThreadState(threadId, state)) return; ensureRunStarted(); const appended = finalizeRunEvents(currentEvents, { stopRequested: state.stopRequested }); for (const event of appended) { const canonicalEvent = this.stampRunnerMetadata(this.stampCanonicalRunOwnership(event, request), state); this.queueRunnerEvent(this.createRunnerEventPayload(canonicalEvent, request, state), state); } state.producerFinished = true; this.completeWhenDurable(threadId, state); } } /** Queue one immutable event payload until Redis-backed gateway acknowledgement. */ queueRunnerEvent(payload, state) { if (!this.isCurrentThreadState(state.threadId, state)) return; const eventId = this.runnerEventId(payload); if (!state.pendingEvents.has(eventId)) state.pendingEvents.set(eventId, { payload: structuredClone(payload), queuedAt: Date.now() }); this.scheduleEventDeadline(state); this.replayPendingEvents(state); } pushPendingEventBatch(eventIds, events, state) { if (!this.isCurrentThreadState(state.threadId, state) || this.failIfEventDeadlineExceeded(state) || state.channel.state !== "joined" || !state.socket.isConnected()) return; const attempt = ++state.nextEventPushAttempt; state.activeEventBatch = { eventIds, attempt }; const payloads = events.map((event) => event.payload); const isBatch = state.supportsRunnerEventBatch; state.channel.push(isBatch ? "events" : "event", isBatch ? { events: payloads } : payloads[0]).receive("ok", () => { if (!this.isCurrentThreadState(state.threadId, state) || state.activeEventBatch?.attempt !== attempt) return; if (this.failIfEventDeadlineExceeded(state)) return; for (const eventId of eventIds) state.pendingEvents.delete(eventId); state.activeEventBatch = null; state.eventRetryAttempt = 0; if (state.pendingEvents.size === 0) { this.clearPendingEventRetry(state); this.clearPendingEventFlush(state); } this.scheduleEventDeadline(state); this.completeWhenDurable(state.threadId, state); this.replayPendingEvents(state); }).receive("error", (response) => this.handlePendingEventFailure(state, attempt, response)).receive("timeout", () => this.handlePendingEventFailure(state, attempt)); } handlePendingEventFailure(state, attempt, response) { if (!this.isCurrentThreadState(state.threadId, state) || state.activeEventBatch?.attempt !== attempt) return; if (this.failIfEventDeadlineExceeded(state)) return; if (this.isPermanentEventFailure(response)) { const reason = typeof response === "object" && response !== null && typeof response.reason === "string" ? response.reason : "permanent_gateway_rejection"; this.failThread(state.threadId, state, /* @__PURE__ */ new Error(`Runner event durability failed: ${reason}`)); return; } this.schedulePendingEventRetry(state); } schedulePendingEventRetry(state) { if (!this.isCurrentThreadState(state.threadId, state) || state.pendingEvents.size === 0 || state.eventRetryTimer !== null) return; const delay = Math.min(EVENT_RETRY_BASE_MS * 2 ** state.eventRetryAttempt, EVENT_RETRY_MAX_MS); state.eventRetryAttempt += 1; state.eventRetryTimer = setTimeout(() => { state.eventRetryTimer = null; if (!this.isCurrentThreadState(state.threadId, state) || state.pendingEvents.size === 0) return; this.retryActiveEventBatch(state); }, delay); } clearPendingEventRetry(state) { if (state.eventRetryTimer !== null) { clearTimeout(state.eventRetryTimer); state.eventRetryTimer = null; } } schedulePendingEventFlush(state) { if (!this.isCurrentThreadState(state.threadId, state) || state.pendingEvents.size === 0 || state.activeEventBatch !== null || state.eventFlushTimer !== null) return; const oldestQueuedAt = Math.min(...[...state.pendingEvents.values()].map((event) => event.queuedAt)); const delay = Math.max(0, oldestQueuedAt + RUNNER_EVENT_BATCH_FLUSH_MS - Date.now()); state.eventFlushTimer = setTimeout(() => { state.eventFlushTimer = null; if (!this.isCurrentThreadState(state.threadId, state)) return; this.flushPendingEventBatch(state); }, delay); } clearPendingEventFlush(state) { if (state.eventFlushTimer !== null) { clearTimeout(state.eventFlushTimer); state.eventFlushTimer = null; } } resetPendingEventRetry(state) { this.clearPendingEventRetry(state); this.clearPendingEventFlush(state); state.eventRetryAttempt = 0; state.activeEventBatch = null; } replayPendingEvents(state) { if (!this.isCurrentThreadState(state.threadId, state) || state.activeEventBatch !== null) return; const pendingEvents = [...state.pendingEvents.entries()].sort(([, left], [, right]) => this.runnerEventSeq(left.payload) - this.runnerEventSeq(right.payload)); if (pendingEvents.length === 0) return; const batchSize = state.supportsRunnerEventBatch ? MAX_RUNNER_EVENT_BATCH_SIZE : 1; if (!state.supportsRunnerEventBatch || pendingEvents.length >= batchSize) { this.clearPendingEventFlush(state); const batch = pendingEvents.slice(0, batchSize); this.pushPendingEventBatch(batch.map(([eventId]) => eventId), batch.map(([, event]) => event), state); return; } this.schedulePendingEventFlush(state); } flushPendingEventBatch(state) { if (!this.isCurrentThreadState(state.threadId, state) || state.activeEventBatch !== null || state.pendingEvents.size === 0) return; const batch = [...state.pendingEvents.entries()].sort(([, left], [, right]) => this.runnerEventSeq(left.payload) - this.runnerEventSeq(right.payload)).slice(0, MAX_RUNNER_EVENT_BATCH_SIZE); this.pushPendingEventBatch(batch.map(([eventId]) => eventId), batch.map(([, event]) => event), state); } retryActiveEventBatch(state) { if (!this.isCurrentThreadState(state.threadId, state) || this.failIfEventDeadlineExceeded(state)) return; const activeBatch = state.activeEventBatch; if (activeBatch === null) { this.replayPendingEvents(state); return; } const events = activeBatch.eventIds.map((eventId) => state.pendingEvents.get(eventId)).filter((event) => event !== void 0); if (events.length !== activeBatch.eventIds.length) { state.activeEventBatch = null; this.replayPendingEvents(state); return; } this.pushPendingEventBatch(activeBatch.eventIds, events, state); } completeWhenDurable(threadId, state) { if (!this.isCurrentThreadState(threadId, state) || !state.producerFinished || state.pendingEvents.size !== 0) return; if (this.threads.get(threadId) !== state) return; this.removeThread(threadId, state); state.completeRun(); } runnerEventId(payload) { return payload.metadata.cpki_event_id; } runnerEventSeq(payload) { return payload.metadata.cpki_event_seq; } supportsRunnerEventBatch(response) { if (typeof response !== "object" || response === null) return false; const capabilities = response.capabilities; return Array.isArray(capabilities) && capabilities.includes(RUNNER_EVENT_BATCH_CAPABILITY); } isRetryableJoinError(response) { if (typeof response !== "object" || response === null) return false; const value = response; if (value.retryable === false) return false; return value.retryable === true || value.reason === "gateway_draining"; } isPermanentEventFailure(response) { return typeof response === "object" && response !== null && response.retryable === false; } scheduleEventDeadline(state) { if (state.eventDeadlineTimer !== null) { clearTimeout(state.eventDeadlineTimer); state.eventDeadlineTimer = null; } if (!this.isCurrentThreadState(state.threadId, state) || state.pendingEvents.size === 0) return; const deadline = Math.min(...[...state.pendingEvents.values()].map((event) => event.queuedAt)) + EVENT_DURABILITY_DEADLINE_MS; state.eventDeadlineTimer = setTimeout(() => { state.eventDeadlineTimer = null; if (!this.isCurrentThreadState(state.threadId, state)) return; if (!this.failIfEventDeadlineExceeded(state)) this.scheduleEventDeadline(state); }, Math.max(0, deadline - Date.now())); } failThread(threadId, state, error) { if (!this.isCurrentThreadState(threadId, state)) return; this.removeThread(threadId, state); try { state.agent?.abortRun(); } catch {} state.failRun(error); } failIfEventDeadlineExceeded(state) { if (state.pendingEvents.size === 0) return false; const oldestQueuedAt = Math.min(...[...state.pendingEvents.values()].map((event) => event.queuedAt)); if (Date.now() < oldestQueuedAt + EVENT_DURABILITY_DEADLINE_MS) return false; this.failThread(state.threadId, state, /* @__PURE__ */ new Error("Timed out trying to durably deliver runner events")); return true; } isCurrentThreadState(threadId, state) { return state.isRunning && this.threads.get(threadId) === state; } /** * Tear down all resources for a thread: leave the channel, * disconnect the per-run socket, and remove the thread state. * * Idempotent — safe to call multiple times for the same threadId * (e.g. from join error handlers, finalize, and Observable teardown). */ removeThread(threadId, state) { if (this.threads.get(threadId) !== state) return; this.threads.delete(threadId); state.isRunning = false; this.clearPendingEventRetry(state); this.clearPendingEventFlush(state); if (state.eventDeadlineTimer !== null) { clearTimeout(state.eventDeadlineTimer); state.eventDeadlineTimer = null; } state.activeEventBatch = null; if (state.socketReconnectWatchdog !== null) { clearTimeout(state.socketReconnectWatchdog); state.socketReconnectWatchdog = null; } try { state.channel.leave(); } catch {} try { state.socket.disconnect(); } catch {} } }; //#endregion export { IntelligenceAgentRunner }; //# sourceMappingURL=intelligence.mjs.map