agents
Version:
A home for your AI agents
698 lines (697 loc) • 22.7 kB
JavaScript
import "./wire-types-CnMt6_HR.js";
import { nanoid } from "nanoid";
//#region src/chat/replay-batch.ts
/**
* Batches chunks replayed during stream resume.
*
* Replaying every chunk separately can exceed React's update limit. Adjacent
* deltas for the same message part are merged until the replay pauses or ends.
*/
var ReplayChunkBatch = class {
constructor(controller, closeWindow = (flush) => {
const timer = setTimeout(flush, 0);
return () => clearTimeout(timer);
}) {
this.controller = controller;
this.closeWindow = closeWindow;
this.chunks = [];
this.tailMergeKey = null;
}
get bufferedStartMessageId() {
return this.startMessageId;
}
get isEmpty() {
return this.chunks.length === 0;
}
push(chunk) {
const key = mergeKeyOf(chunk);
const tail = this.chunks[this.chunks.length - 1];
if (tail !== void 0 && key !== null && key === this.tailMergeKey) {
appendDeltaText(tail, chunk);
return;
}
if (this.chunks.length === 0) this.cancelWindow = this.closeWindow(() => {
this.cancelWindow = void 0;
this.flush();
});
if (chunk.type === "start") this.startMessageId = chunk.messageId;
this.chunks.push(chunk);
this.tailMergeKey = key;
}
discard() {
this.cancelWindow?.();
this.cancelWindow = void 0;
this.chunks = [];
this.tailMergeKey = null;
this.startMessageId = void 0;
}
flush() {
this.cancelWindow?.();
this.cancelWindow = void 0;
if (this.chunks.length === 0) return;
const batch = this.chunks;
this.chunks = [];
this.tailMergeKey = null;
this.startMessageId = void 0;
try {
for (const chunk of batch) this.controller.enqueue(chunk);
} catch {}
}
enqueueNow(chunk) {
this.controller.enqueue(chunk);
}
closeNow() {
this.controller.close();
}
errorNow(error) {
this.controller.error(error);
}
};
/** Buffers replay chunks and flushes them before live or boundary frames. */
function applyChatResponseFrame(batch, frame) {
const endsReplayBurst = frame.replay !== true || frame.done === true || frame.replayComplete === true;
const body = frame.body?.trim();
if (!body) {
if (endsReplayBurst) batch.flush();
return;
}
let chunk;
try {
chunk = JSON.parse(body);
} catch {
if (endsReplayBurst) batch.flush();
return;
}
if (endsReplayBurst) {
batch.flush();
batch.enqueueNow(chunk);
return;
}
if (supersedesBufferedPass(batch, chunk, frame)) batch.discard();
batch.push(chunk);
}
function supersedesBufferedPass(batch, chunk, frame) {
return chunk.type === "start" && frame.continuation !== true && chunk.messageId !== void 0 && chunk.messageId === batch.bufferedStartMessageId;
}
/**
* Reports an error after buffered content. `controller.error()` would discard
* chunks that have not reached the consumer.
*/
function failChatStream(batch, message) {
if (batch.isEmpty) {
batch.errorNow(new Error(message));
return;
}
batch.flush();
batch.enqueueNow({
errorText: message,
type: "error"
});
batch.closeNow();
}
function mergeKeyOf(chunk) {
switch (chunk.type) {
case "text-delta":
case "reasoning-delta": return chunk.providerMetadata === void 0 ? `${chunk.type}\u0000${chunk.id}` : null;
case "tool-input-delta": return `tool-input-delta\u0000${chunk.toolCallId}`;
default: return null;
}
}
function appendDeltaText(target, source) {
if (target.type === "tool-input-delta" && source.type === "tool-input-delta") {
target.inputTextDelta += source.inputTextDelta;
return;
}
if ((target.type === "text-delta" || target.type === "reasoning-delta") && (source.type === "text-delta" || source.type === "reasoning-delta")) target.delta += source.delta;
}
//#endregion
//#region src/chat/ws-chat-transport.ts
/**
* Short safety-net timeout for a resume probe when the server has said nothing.
* Under normal operation the server answers a `STREAM_RESUME_REQUEST` with
* `STREAM_RESUMING`, `STREAM_RESUME_NONE`, or `STREAM_PENDING` well before this.
*/
const RESUME_PROBE_TIMEOUT_MS = 5e3;
/**
* Extended backstop applied once the server says a turn is pending
* (`STREAM_PENDING`, #1784). The pre-stream window (queueing, MCP setup,
* debounce, model latency) can exceed the short probe timeout, and the server
* guarantees a follow-up `STREAM_RESUMING` or `STREAM_RESUME_NONE` — so we wait
* much longer (refreshed on every keep-waiting frame) but still cap it so a
* dropped follow-up degrades to a null resolve instead of hanging forever.
*/
const RESUME_PENDING_TIMEOUT_MS = 6e4;
/**
* ChatTransport that sends messages over WebSocket and returns a
* ReadableStream<UIMessageChunk> that the AI SDK's useChat consumes directly.
*
* This low-level transport handles new and regenerated request streams plus
* cancellation. Higher-level protocol coordination such as automatic reconnect
* resume, cross-tab transcript synchronization, and client-tool continuations
* is provided by integrations such as `useAgentChat`.
*/
var WebSocketChatTransport = class {
constructor(options) {
this._resumeResolver = null;
this._resumeNoneResolver = null;
this._onStreamPending = null;
this._retryResumeProbe = null;
this._expectToolContinuation = false;
this._abortToolContinuation = null;
this._activeServerTurnId = null;
this._cancelAttachedStream = null;
this._detachResumeStream = null;
this.agent = options.agent;
this.prepareBody = options.prepareBody;
this.activeRequestIds = options.activeRequestIds;
this.cancelOnClientAbort = options.cancelOnClientAbort ?? false;
}
/**
* Point the singleton transport at a new Agent connection. A pending resolver
* belongs to the old Chat/socket generation and must settle before messages
* from the replacement connection can be consumed (#1914 review).
*/
setAgent(agent) {
if (this.agent === agent) return;
this.resetResumeState();
this.agent = agent;
}
setCancelOnClientAbort(cancelOnClientAbort) {
this.cancelOnClientAbort = cancelOnClientAbort;
}
/**
* Explicitly cancel the active server turn, if any.
* This is separate from generic client-side abort/cancel lifecycle so
* clients can detach locally without stopping server work.
*/
cancelActiveServerTurn() {
const requestId = this._activeServerTurnId;
let cancelledRequest = false;
if (requestId) {
this.sendCancelFrame(requestId);
this._cancelAttachedStream?.();
this.clearActiveServerTurn(requestId);
cancelledRequest = true;
}
const cancelledToolContinuation = this.abortActiveToolContinuation();
return cancelledRequest || cancelledToolContinuation;
}
sendCancelFrame(requestId) {
try {
this.agent.send(JSON.stringify({
id: requestId,
type: "cf_agent_chat_request_cancel"
}));
} catch {}
}
setActiveServerTurn(requestId, cancelAttachedStream) {
this._activeServerTurnId = requestId;
this._cancelAttachedStream = cancelAttachedStream;
}
clearActiveServerTurn(requestId) {
if (this._activeServerTurnId === requestId) {
this._activeServerTurnId = null;
this._cancelAttachedStream = null;
}
}
/**
* Mark that the next reconnectToStream() call should attach to a
* server-initiated tool continuation rather than a page-load resume.
*/
expectToolContinuation() {
this._expectToolContinuation = true;
}
/**
* Abort the active client-side tool continuation stream, if one is attached
* to a server request id.
*/
abortActiveToolContinuation() {
return this._abortToolContinuation?.() ?? false;
}
/**
* True when the transport is waiting for a resume handshake.
*/
isAwaitingResume() {
return this._resumeResolver !== null || this._resumeNoneResolver !== null;
}
/**
* Settle and detach the current handshake without interpreting it as a
* server-idle response. Used when the owning hook/agent generation changes.
*/
cancelPendingResume() {
const resolveNone = this._resumeNoneResolver;
if (!resolveNone) return false;
return resolveNone({});
}
/**
* Invalidate all client-side resume state for an obsolete hook/agent
* generation without cancelling its durable server turn.
*/
resetResumeState() {
this._expectToolContinuation = false;
this.cancelPendingResume();
this._detachResumeStream?.();
}
/**
* Re-send the active handshake request on the latest socket generation. This
* preserves one AI SDK resume operation while recovering a request/reply lost
* with the previous WebSocket.
*/
retryPendingResume() {
const retry = this._retryResumeProbe;
if (!retry) return false;
retry();
return true;
}
/**
* Called by onAgentMessage when it receives CF_AGENT_STREAM_RESUMING.
* If reconnectToStream is waiting, this handles the resume handshake
* (ACK + stream creation) and returns true. Otherwise returns false
* so the caller can use its own fallback path.
*/
handleStreamResuming(data) {
if (!this._resumeResolver) return false;
this._resumeResolver(data);
return true;
}
/**
* Called by onAgentMessage when it receives CF_AGENT_STREAM_RESUME_NONE.
* If reconnectToStream is waiting, resolves the promise with null
* immediately (no 5-second timeout). Returns true if handled.
*/
handleStreamResumeNone(data = {}) {
if (!this._resumeNoneResolver) return false;
return this._resumeNoneResolver(data);
}
/**
* Called by onAgentMessage when it receives CF_AGENT_STREAM_PENDING (#1784):
* the server accepted a turn but its stream has not started yet. If a resume
* path is awaiting, extend its probe timeout (so it keeps waiting for the
* eventual STREAM_RESUMING / STREAM_RESUME_NONE instead of resolving null
* after the short window). Returns true if a waiting path consumed it.
*/
handleStreamPending() {
if (!this._onStreamPending) return false;
this._onStreamPending();
return true;
}
/**
* Called by the hook's shared message handler when a server turn finishes
* outside the currently attached transport stream, such as after local-only
* client cleanup.
*/
handleServerTurnCompleted(requestId) {
this.clearActiveServerTurn(requestId);
}
/**
* Register a server turn that is being rendered outside a transport-owned
* stream, such as the hook's fallback cross-tab/resume observer path.
*/
observeServerTurn(requestId) {
this.setActiveServerTurn(requestId, null);
}
async sendMessages(options) {
const requestId = nanoid(8);
const abortController = new AbortController();
let completed = false;
let requestSent = false;
let extraBody = {};
if (this.prepareBody) extraBody = await this.prepareBody({
messages: options.messages,
trigger: options.trigger,
messageId: options.messageId
});
if (options.body) extraBody = {
...extraBody,
...options.body
};
const bodyPayload = JSON.stringify({
messages: options.messages,
trigger: options.trigger,
...extraBody
});
this.activeRequestIds?.add(requestId);
const agent = this.agent;
const activeIds = this.activeRequestIds;
const finish = (action, keepId = false, clearServerTurn = true) => {
if (completed) return;
completed = true;
if (clearServerTurn) this.clearActiveServerTurn(requestId);
try {
action();
} catch {}
if (!keepId) activeIds?.delete(requestId);
abortController.abort();
};
const abortError = /* @__PURE__ */ new Error("Aborted");
abortError.name = "AbortError";
const cancelActiveRequest = () => {
if (completed) return false;
finish(() => streamController.error(abortError), true);
return true;
};
this.setActiveServerTurn(requestId, cancelActiveRequest);
const onAbort = () => {
if (completed) return;
if (this.cancelOnClientAbort) {
if (requestSent) this.sendCancelFrame(requestId);
finish(() => streamController.error(abortError), requestSent);
} else finish(() => streamController.error(abortError), false, !requestSent);
};
let streamController;
const stream = new ReadableStream({
start(controller) {
streamController = controller;
const onMessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "cf_agent_use_chat_response") return;
if (data.id !== requestId) return;
if (data.error) {
finish(() => controller.error(new Error(data.body || "Stream error")));
return;
}
if (data.body?.trim()) try {
const chunk = JSON.parse(data.body);
controller.enqueue(chunk);
} catch {}
if (data.done) finish(() => controller.close());
} catch {}
};
const onClose = () => {
finish(() => controller.close(), false, false);
};
agent.addEventListener("message", onMessage, { signal: abortController.signal });
agent.addEventListener("close", onClose, { signal: abortController.signal });
},
cancel() {
onAbort();
}
});
if (options.abortSignal) {
options.abortSignal.addEventListener("abort", onAbort, { once: true });
if (options.abortSignal.aborted) onAbort();
}
if (completed) return stream;
requestSent = true;
agent.send(JSON.stringify({
id: requestId,
init: {
method: "POST",
body: bodyPayload
},
type: "cf_agent_use_chat_request"
}));
return stream;
}
async reconnectToStream(_options) {
if (this.isAwaitingResume()) return null;
if (this._expectToolContinuation) {
this._expectToolContinuation = false;
return this._createToolContinuationStream();
}
const activeIds = this.activeRequestIds;
const probeId = nanoid(8);
return new Promise((resolve) => {
let resolved = false;
let timeout;
let resumeResolver = null;
let resumeNoneResolver = null;
let onStreamPending = null;
let retryResumeProbe = null;
const clearOwnedCallbacks = () => {
if (resumeResolver && this._resumeResolver === resumeResolver) this._resumeResolver = null;
if (resumeNoneResolver && this._resumeNoneResolver === resumeNoneResolver) this._resumeNoneResolver = null;
if (onStreamPending && this._onStreamPending === onStreamPending) this._onStreamPending = null;
if (retryResumeProbe && this._retryResumeProbe === retryResumeProbe) this._retryResumeProbe = null;
};
const done = (value) => {
if (resolved) return;
resolved = true;
clearOwnedCallbacks();
if (timeout) clearTimeout(timeout);
resolve(value);
};
const armTimeout = (delay) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => done(null), delay);
};
onStreamPending = () => {
if (resolved) return;
armTimeout(RESUME_PENDING_TIMEOUT_MS);
};
resumeNoneResolver = (data) => {
if (data.probeId && data.probeId !== probeId) return false;
done(null);
return true;
};
resumeResolver = (data) => {
const requestId = data.id;
activeIds?.add(requestId);
const stream = this._createResumeStream(requestId);
this.agent.send(JSON.stringify({
type: "cf_agent_stream_resume_ack",
id: requestId
}));
done(stream);
};
retryResumeProbe = () => {
if (resolved) return;
armTimeout(RESUME_PROBE_TIMEOUT_MS);
try {
this.agent.send(JSON.stringify({
type: "cf_agent_stream_resume_request",
probeId
}));
} catch {}
};
this._onStreamPending = onStreamPending;
this._resumeNoneResolver = resumeNoneResolver;
this._resumeResolver = resumeResolver;
this._retryResumeProbe = retryResumeProbe;
retryResumeProbe();
});
}
/**
* Creates a deferred ReadableStream for client-side tool continuations.
* The stream is returned immediately so AI SDK status becomes "submitted"
* right after addToolOutput()/addToolApprovalResponse(), then it waits for
* the server to announce the continuation via STREAM_RESUMING.
*/
_createToolContinuationStream() {
const agent = this.agent;
const activeIds = this.activeRequestIds;
const streamController = new AbortController();
const abortError = /* @__PURE__ */ new Error("Aborted");
abortError.name = "AbortError";
let completed = false;
let requestId = null;
let readerController = null;
let replayBatch = null;
const probeId = nanoid(8);
let onResumeRef = null;
let onResumeNoneRef = null;
let onStreamPendingRef = null;
let retryResumeProbeRef = null;
let abortToolContinuationRef = null;
let detachResumeStreamRef = null;
const clearOwnedHandshake = () => {
if (onResumeRef && this._resumeResolver === onResumeRef) this._resumeResolver = null;
if (onResumeNoneRef && this._resumeNoneResolver === onResumeNoneRef) this._resumeNoneResolver = null;
if (onStreamPendingRef && this._onStreamPending === onStreamPendingRef) this._onStreamPending = null;
if (retryResumeProbeRef && this._retryResumeProbe === retryResumeProbeRef) this._retryResumeProbe = null;
};
const finish = (action, keepRequestId = false) => {
if (completed) return;
completed = true;
if (this._abortToolContinuation === abortToolContinuationRef) this._abortToolContinuation = null;
if (this._detachResumeStream === detachResumeStreamRef) this._detachResumeStream = null;
clearOwnedHandshake();
try {
action();
} catch {}
if (requestId && !keepRequestId) activeIds?.delete(requestId);
streamController.abort();
};
const transport = this;
abortToolContinuationRef = () => {
if (completed) return false;
if (requestId === null) {
finish(() => readerController?.error(abortError));
return true;
}
try {
agent.send(JSON.stringify({
type: "cf_agent_chat_request_cancel",
id: requestId
}));
} catch {}
finish(() => readerController?.error(abortError), true);
return true;
};
this._abortToolContinuation = abortToolContinuationRef;
detachResumeStreamRef = () => {
if (completed) return false;
finish(() => {
replayBatch?.flush();
readerController?.close();
});
return true;
};
this._detachResumeStream = detachResumeStreamRef;
return new ReadableStream({
start(controller) {
readerController = controller;
const batch = new ReplayChunkBatch(controller);
replayBatch = batch;
let timeout;
const armTimeout = (delay) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => finish(() => controller.close()), delay);
};
const onResumeNone = (data) => {
if (data.probeId && data.probeId !== probeId) return false;
finish(() => controller.close());
return true;
};
const onResume = (data) => {
if (requestId) return;
requestId = data.id;
activeIds?.add(requestId);
clearOwnedHandshake();
if (timeout) clearTimeout(timeout);
agent.send(JSON.stringify({
type: "cf_agent_stream_resume_ack",
id: requestId
}));
};
const onStreamPending = () => {
if (completed) return;
armTimeout(RESUME_PENDING_TIMEOUT_MS);
};
const retryResumeProbe = () => {
if (completed || requestId !== null) return;
armTimeout(RESUME_PROBE_TIMEOUT_MS);
try {
transport.agent.send(JSON.stringify({
type: "cf_agent_stream_resume_request",
probeId
}));
} catch {}
};
onResumeRef = onResume;
onResumeNoneRef = onResumeNone;
onStreamPendingRef = onStreamPending;
retryResumeProbeRef = retryResumeProbe;
transport._resumeResolver = onResume;
transport._resumeNoneResolver = onResumeNone;
transport._onStreamPending = onStreamPending;
transport._retryResumeProbe = retryResumeProbe;
const onMessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "cf_agent_use_chat_response" || requestId == null || data.id !== requestId) return;
if (data.error) {
finish(() => failChatStream(batch, data.body || "Stream error"));
return;
}
applyChatResponseFrame(batch, data);
if (data.done) finish(() => controller.close());
} catch {}
};
const onClose = () => finish(() => {
batch.flush();
controller.close();
});
agent.addEventListener("message", onMessage, { signal: streamController.signal });
agent.addEventListener("close", onClose, { signal: streamController.signal });
retryResumeProbe();
},
cancel() {
if (requestId && transport.cancelOnClientAbort) {
transport.sendCancelFrame(requestId);
finish(() => {}, true);
} else finish(() => {});
}
});
}
/**
* Creates a ReadableStream that receives resumed stream chunks
* and forwards them to useChat as UIMessageChunk objects.
*/
_createResumeStream(requestId) {
const agent = this.agent;
const activeIds = this.activeRequestIds;
const chunkController = new AbortController();
const abortError = /* @__PURE__ */ new Error("Aborted");
abortError.name = "AbortError";
let completed = false;
let detachResumeStream = null;
const finish = (action, keepId = false, clearServerTurn = true) => {
if (completed) return;
completed = true;
if (clearServerTurn) this.clearActiveServerTurn(requestId);
if (this._detachResumeStream === detachResumeStream) this._detachResumeStream = null;
try {
action();
} catch {}
if (!keepId) activeIds?.delete(requestId);
chunkController.abort();
};
let streamController = null;
let replayBatch = null;
const cancelActiveRequest = () => {
if (completed) return false;
finish(() => streamController?.error(abortError), true);
return true;
};
this.setActiveServerTurn(requestId, cancelActiveRequest);
const transport = this;
detachResumeStream = () => {
if (completed) return false;
finish(() => {
replayBatch?.flush();
streamController?.close();
});
return true;
};
this._detachResumeStream = detachResumeStream;
return new ReadableStream({
start(controller) {
streamController = controller;
const batch = new ReplayChunkBatch(controller);
replayBatch = batch;
const onMessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "cf_agent_use_chat_response") return;
if (data.id !== requestId) return;
if (data.error) {
finish(() => failChatStream(batch, data.body || "Stream error"));
return;
}
applyChatResponseFrame(batch, data);
if (data.done) finish(() => controller.close());
} catch {}
};
const onClose = () => {
finish(() => {
batch.flush();
controller.close();
}, false, false);
};
agent.addEventListener("message", onMessage, { signal: chunkController.signal });
agent.addEventListener("close", onClose, { signal: chunkController.signal });
},
cancel() {
if (transport.cancelOnClientAbort) {
transport.sendCancelFrame(requestId);
finish(() => {}, true);
} else finish(() => {}, false, false);
}
});
}
};
//#endregion
export { WebSocketChatTransport as t };
//# sourceMappingURL=ws-chat-transport-rWwta645.js.map