@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
JavaScript
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