@convex-dev/agent
Version:
A agent component for Convex.
201 lines (193 loc) • 5.59 kB
text/typescript
import {
type ChunkDetector,
smoothStream,
type StreamTextTransform,
type ToolSet,
} from "ai";
import type {
ProviderOptions,
StreamDelta,
TextStreamPart,
} from "../validators.js";
import type { AgentComponent, MessageDoc } from "./index.js";
import type { RunActionCtx } from "./types.js";
export type StreamingOptions = {
/**
* The minimum granularity of deltas to save.
* Note: this is not a guarantee that every delta will be exactly one line.
* E.g. if "line" is specified, it won't save any deltas until it encounters
* a newline character.
* Defaults to a regex that chunks by punctuation followed by whitespace.
*/
chunking?: "word" | "line" | RegExp | ChunkDetector;
/**
* The minimum number of milliseconds to wait between saving deltas.
* Defaults to 250.
*/
throttleMs?: number;
};
export const DEFAULT_STREAMING_OPTIONS = {
// This chunks by sentences / clauses. Punctuation followed by whitespace.
chunking: /[\p{P}\s]/u,
throttleMs: 250,
} satisfies StreamingOptions;
export function mergeTransforms<TOOLS extends ToolSet>(
options: StreamingOptions | boolean | undefined,
existing:
| StreamTextTransform<TOOLS>
| Array<StreamTextTransform<TOOLS>>
| undefined
) {
if (!options) {
return existing;
}
const chunking =
typeof options === "boolean"
? DEFAULT_STREAMING_OPTIONS.chunking
: options.chunking;
const transforms = Array.isArray(existing)
? existing
: existing
? [existing]
: [];
transforms.push(smoothStream({ delayInMs: null, chunking }));
return transforms;
}
export class DeltaStreamer {
public streamId: string | undefined;
public readonly options: Required<StreamingOptions>;
#nextParts: TextStreamPart[] = [];
#nextOrder: number;
#nextStepOrder: number;
#latestWrite: number = 0;
#ongoingWrite: Promise<void> | undefined;
#cursor: number = 0;
public abortController: AbortController;
constructor(
public readonly component: AgentComponent,
public readonly ctx: RunActionCtx,
options: true | StreamingOptions,
public readonly metadata: {
threadId: string;
agentName: string | undefined;
model: string | undefined;
provider: string | undefined;
providerOptions: ProviderOptions | undefined;
userId: string | undefined;
order: number | undefined;
stepOrder: number | undefined;
abortSignal: AbortSignal | undefined;
}
) {
this.options =
typeof options === "boolean"
? DEFAULT_STREAMING_OPTIONS
: {
...DEFAULT_STREAMING_OPTIONS,
...options,
};
this.metadata = metadata;
this.#nextParts = [];
this.#nextOrder = metadata.order ?? 0;
this.#nextStepOrder = (metadata.stepOrder ?? 0) + 1;
this.abortController = new AbortController();
if (metadata.abortSignal) {
metadata.abortSignal.addEventListener("abort", () => {
this.abortController.abort();
});
}
}
public async addParts(parts: TextStreamPart[]) {
if (this.abortController.signal.aborted) {
return;
}
if (!this.streamId) {
this.streamId = await this.ctx.runMutation(
this.component.streams.create,
{
...this.metadata,
order: this.#nextOrder,
stepOrder: this.#nextStepOrder,
}
);
}
this.#nextParts.push(...parts);
if (
!this.#ongoingWrite &&
Date.now() - this.#latestWrite >= this.options.throttleMs
) {
this.#ongoingWrite = this.#sendDelta();
}
}
async #sendDelta() {
if (this.abortController.signal.aborted) {
return;
}
const delta = this.#createDelta();
this.#latestWrite = Date.now();
try {
const success = await this.ctx.runMutation(
this.component.streams.addDelta,
delta
);
if (!success) {
this.abortController.abort();
}
} catch (e) {
this.abortController.abort();
throw e;
}
// Now that we've sent the delta, check if we need to send another one.
if (
this.#nextParts.length > 0 &&
Date.now() - this.#latestWrite >= this.options.throttleMs
) {
// We send again immediately with the accumulated deltas.
this.#ongoingWrite = this.#sendDelta();
} else {
this.#ongoingWrite = undefined;
}
}
#createDelta(): StreamDelta {
const start = this.#cursor;
const end = start + this.#nextParts.length;
this.#cursor = end;
const parts = this.#nextParts;
this.#nextParts = [];
if (!this.streamId) {
throw new Error("Creating a delta before the stream is created");
}
return {
streamId: this.streamId,
start,
end,
parts,
};
}
public async finish(messages: MessageDoc[]) {
if (this.#ongoingWrite) {
await this.#ongoingWrite;
this.#ongoingWrite = undefined;
}
if (!this.streamId) {
throw new Error("Finish called before stream is created");
}
const lastMessage = messages.at(-1);
if (lastMessage) {
this.#nextOrder = lastMessage.order;
this.#nextStepOrder = lastMessage.stepOrder + 1;
} else {
console.warn("Step finished without generating a message");
}
const finalDelta =
this.#nextParts.length > 0 ? this.#createDelta() : undefined;
this.#nextParts = [];
const streamId = this.streamId;
this.streamId = undefined;
this.#cursor = 0;
await this.ctx.runMutation(this.component.streams.finish, {
streamId,
finalDelta,
});
}
}