@camelai/run 0.0.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +62 -1
- package/dist/clients/agents.d.ts +265 -0
- package/dist/clients/agents.js +283 -0
- package/dist/clients/ai-sdk.d.ts +137 -0
- package/dist/clients/ai-sdk.js +461 -0
- package/dist/clients/chat.d.ts +237 -0
- package/dist/clients/chat.js +633 -0
- package/dist/clients/handler.d.ts +116 -0
- package/dist/clients/handler.js +513 -0
- package/dist/clients/markdown.d.ts +72 -0
- package/dist/clients/markdown.js +385 -0
- package/dist/clients/mcp.d.ts +13 -0
- package/dist/clients/mcp.js +38 -0
- package/dist/clients/node.d.ts +21 -0
- package/dist/clients/node.js +76 -0
- package/dist/clients/server.d.ts +83 -0
- package/dist/clients/server.js +187 -0
- package/dist/clients/testing.d.ts +60 -0
- package/dist/clients/testing.js +53 -0
- package/dist/clients/types.d.ts +264 -0
- package/dist/clients/types.js +6 -0
- package/dist/clients/typescript.d.ts +1161 -0
- package/dist/clients/typescript.js +1035 -0
- package/dist/clients/watch.d.ts +90 -0
- package/dist/clients/watch.js +482 -0
- package/dist/shared/client-protocol.d.ts +106 -0
- package/dist/shared/client-protocol.js +9 -0
- package/package.json +78 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { type ChatMessage, type InputAnswer, type InputValue } from "./chat.ts";
|
|
2
|
+
import type { AgentInput } from "./typescript.ts";
|
|
3
|
+
/** The parts of the AI SDK's UIMessage this transport reads and writes. */
|
|
4
|
+
export interface AgentUIMessage {
|
|
5
|
+
id: string;
|
|
6
|
+
role: "system" | "user" | "assistant";
|
|
7
|
+
metadata?: unknown;
|
|
8
|
+
parts: Array<{
|
|
9
|
+
type: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
/** The AI SDK's UIMessageChunk, as far as this transport sends it. */
|
|
14
|
+
export type AgentUIMessageChunk = {
|
|
15
|
+
type: "start";
|
|
16
|
+
messageId?: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: "start-step";
|
|
19
|
+
} | {
|
|
20
|
+
type: "finish-step";
|
|
21
|
+
} | {
|
|
22
|
+
type: "text-start" | "text-end" | "reasoning-start" | "reasoning-end";
|
|
23
|
+
id: string;
|
|
24
|
+
} | {
|
|
25
|
+
type: "text-delta" | "reasoning-delta";
|
|
26
|
+
id: string;
|
|
27
|
+
delta: string;
|
|
28
|
+
} | {
|
|
29
|
+
type: "tool-input-start";
|
|
30
|
+
toolCallId: string;
|
|
31
|
+
toolName: string;
|
|
32
|
+
dynamic: true;
|
|
33
|
+
} | {
|
|
34
|
+
type: "tool-input-delta";
|
|
35
|
+
toolCallId: string;
|
|
36
|
+
inputTextDelta: string;
|
|
37
|
+
} | {
|
|
38
|
+
type: "tool-input-available";
|
|
39
|
+
toolCallId: string;
|
|
40
|
+
toolName: string;
|
|
41
|
+
input: unknown;
|
|
42
|
+
dynamic: true;
|
|
43
|
+
} | {
|
|
44
|
+
type: "tool-output-available";
|
|
45
|
+
toolCallId: string;
|
|
46
|
+
output: unknown;
|
|
47
|
+
dynamic: true;
|
|
48
|
+
} | {
|
|
49
|
+
type: "tool-output-error";
|
|
50
|
+
toolCallId: string;
|
|
51
|
+
errorText: string;
|
|
52
|
+
dynamic: true;
|
|
53
|
+
} | {
|
|
54
|
+
type: "tool-approval-request";
|
|
55
|
+
approvalId: string;
|
|
56
|
+
toolCallId: string;
|
|
57
|
+
} | {
|
|
58
|
+
type: "data-agent-input";
|
|
59
|
+
id: string;
|
|
60
|
+
data: AgentInput;
|
|
61
|
+
} | {
|
|
62
|
+
type: "file";
|
|
63
|
+
url: string;
|
|
64
|
+
mediaType: string;
|
|
65
|
+
} | {
|
|
66
|
+
type: "error";
|
|
67
|
+
errorText: string;
|
|
68
|
+
} | {
|
|
69
|
+
type: "abort";
|
|
70
|
+
reason?: string;
|
|
71
|
+
} | {
|
|
72
|
+
type: "finish";
|
|
73
|
+
finishReason?: "stop" | "error" | "other";
|
|
74
|
+
};
|
|
75
|
+
export interface AgentRuntimeChatTransportOptions {
|
|
76
|
+
/** Your agent handler's URL, e.g. "/api/agent". */
|
|
77
|
+
endpoint: string;
|
|
78
|
+
/** The handler's `thread`. Default: the chat's id (`useChat({ id })`). */
|
|
79
|
+
thread?: string | null;
|
|
80
|
+
headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
|
|
81
|
+
credentials?: RequestCredentials;
|
|
82
|
+
/** What a message sent while the agent works does ("queue", the default, or "steer"). */
|
|
83
|
+
whileRunning?: "queue" | "steer";
|
|
84
|
+
fetch?: typeof globalThis.fetch;
|
|
85
|
+
}
|
|
86
|
+
type SendOptions = {
|
|
87
|
+
trigger: "submit-message" | "regenerate-message";
|
|
88
|
+
chatId: string;
|
|
89
|
+
messageId: string | undefined;
|
|
90
|
+
messages: AgentUIMessage[];
|
|
91
|
+
abortSignal: AbortSignal | undefined;
|
|
92
|
+
headers?: Record<string, string> | Headers;
|
|
93
|
+
body?: object;
|
|
94
|
+
metadata?: unknown;
|
|
95
|
+
};
|
|
96
|
+
/** A ChatTransport for the AI SDK's useChat, over your agent handler. */
|
|
97
|
+
export declare class AgentRuntimeChatTransport {
|
|
98
|
+
private readonly options;
|
|
99
|
+
private readonly doFetch;
|
|
100
|
+
constructor(options: AgentRuntimeChatTransportOptions);
|
|
101
|
+
private call;
|
|
102
|
+
/** The chat this transport last sent for or reconnected to. */
|
|
103
|
+
private lastChatId;
|
|
104
|
+
private threadOf;
|
|
105
|
+
/** A watcher of the user's agent, once it is following the live stream. */
|
|
106
|
+
private watch;
|
|
107
|
+
sendMessages(options: SendOptions): Promise<ReadableStream<AgentUIMessageChunk>>;
|
|
108
|
+
/** The run going on now, if any (after a reload, or an answer): its reply from where it stands. */
|
|
109
|
+
reconnectToStream(options: {
|
|
110
|
+
chatId: string;
|
|
111
|
+
headers?: Record<string, string> | Headers;
|
|
112
|
+
}): Promise<ReadableStream<AgentUIMessageChunk> | null>;
|
|
113
|
+
/**
|
|
114
|
+
* Answer what the agent waits on (a `data-agent-input` part's input), then call `resumeStream()`. It goes
|
|
115
|
+
* to the chat's thread: `chatId` (useChat's `id`), else the chat this transport last sent for.
|
|
116
|
+
*/
|
|
117
|
+
answer(input: AgentInput, value: InputValue | InputAnswer, options?: {
|
|
118
|
+
chatId?: string;
|
|
119
|
+
}): Promise<void>;
|
|
120
|
+
/** Runs an answer resumed, by thread, for the next reconnectToStream. */
|
|
121
|
+
private readonly resuming;
|
|
122
|
+
/** The agent's history as the AI SDK's messages, for `useChat({ messages })`. */
|
|
123
|
+
loadMessages(options?: {
|
|
124
|
+
chatId?: string;
|
|
125
|
+
limit?: number;
|
|
126
|
+
}): Promise<AgentUIMessage[]>;
|
|
127
|
+
/**
|
|
128
|
+
* Watch the agent, start (`begin`), and stream what follows as chunks until the run ends. With `gate`,
|
|
129
|
+
* nothing is streamed before the user message the run answers arrives (an earlier run may be finishing).
|
|
130
|
+
*/
|
|
131
|
+
private follow;
|
|
132
|
+
/** One delta of the assistant message streaming now, as chunks. */
|
|
133
|
+
private delta;
|
|
134
|
+
}
|
|
135
|
+
/** Chat messages (from `@camelai/run/chat`) as the AI SDK's UIMessages. */
|
|
136
|
+
export declare function toUIMessages(messages: ChatMessage[]): AgentUIMessage[];
|
|
137
|
+
export {};
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use the AI SDK's `useChat` (and anything built on its `ChatTransport`, like AI Elements) with an
|
|
3
|
+
* agent on the runtime, through your agent handler (`createAgentHandler`):
|
|
4
|
+
*
|
|
5
|
+
* import { useChat } from "@ai-sdk/react";
|
|
6
|
+
* import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
|
|
7
|
+
* import { AgentRuntimeChatTransport } from "@camelai/run/ai-sdk";
|
|
8
|
+
*
|
|
9
|
+
* const { messages, sendMessage, addToolApprovalResponse, stop } = useChat({
|
|
10
|
+
* transport: new AgentRuntimeChatTransport({ endpoint: "/api/agent" }),
|
|
11
|
+
* sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
12
|
+
* });
|
|
13
|
+
*
|
|
14
|
+
* The agent keeps the conversation: only the new message is sent (the chat's history is the agent's),
|
|
15
|
+
* `chatId` is the handler's `thread`, and `stop()` stops the agent's run. Tool calls arrive as
|
|
16
|
+
* `dynamic-tool` parts; an approval the agent waits on is a tool approval request (answer it with
|
|
17
|
+
* `addToolApprovalResponse`); its other questions arrive as `data-agent-input` parts: answer them with
|
|
18
|
+
* `transport.answer(input, value, { chatId })`, then `resumeStream()`. `loadMessages()` reads the history for
|
|
19
|
+
* `useChat({ messages })`. The types are the AI SDK's (v5 and later), declared here so this module
|
|
20
|
+
* needs no dependency on it.
|
|
21
|
+
*/
|
|
22
|
+
import { watchAgent } from "./watch.js";
|
|
23
|
+
import { answerValue, projectMessages, readsFrom } from "./chat.js";
|
|
24
|
+
const CLIENT_ID = /^[A-Za-z0-9_-]{8,80}$/;
|
|
25
|
+
const textOf = (message) => message.parts.flatMap(part => part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
|
|
26
|
+
const resultOf = (result) => {
|
|
27
|
+
const content = result?.content;
|
|
28
|
+
if (!Array.isArray(content))
|
|
29
|
+
return result;
|
|
30
|
+
const text = content.flatMap(part => part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/** A ChatTransport for the AI SDK's useChat, over your agent handler. */
|
|
39
|
+
export class AgentRuntimeChatTransport {
|
|
40
|
+
options;
|
|
41
|
+
doFetch;
|
|
42
|
+
constructor(options) {
|
|
43
|
+
this.options = options;
|
|
44
|
+
this.doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
45
|
+
}
|
|
46
|
+
async call(action, thread, body = {}, headers) {
|
|
47
|
+
const own = typeof this.options.headers === "function" ? await this.options.headers() : this.options.headers ?? {};
|
|
48
|
+
const extra = headers instanceof Headers ? Object.fromEntries(headers) : headers ?? {};
|
|
49
|
+
const response = await this.doFetch(this.options.endpoint, {
|
|
50
|
+
method: "POST", credentials: this.options.credentials ?? "same-origin",
|
|
51
|
+
headers: { "Content-Type": "application/json", ...own, ...extra },
|
|
52
|
+
body: JSON.stringify({ action, ...(thread !== null ? { thread } : {}), ...body }),
|
|
53
|
+
});
|
|
54
|
+
const value = await response.json().catch(() => ({}));
|
|
55
|
+
if (!response.ok)
|
|
56
|
+
throw new Error(typeof value.error === "object" ? value.error.message ?? `HTTP ${response.status}` : value.error ?? `${this.options.endpoint}: HTTP ${response.status}`);
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
/** The chat this transport last sent for or reconnected to. */
|
|
60
|
+
lastChatId;
|
|
61
|
+
threadOf(chatId) {
|
|
62
|
+
if (chatId !== undefined)
|
|
63
|
+
this.lastChatId = chatId;
|
|
64
|
+
return this.options.thread !== undefined ? this.options.thread : chatId ?? null;
|
|
65
|
+
}
|
|
66
|
+
/** A watcher of the user's agent, once it is following the live stream. */
|
|
67
|
+
async watch(thread, onEvent, onChange) {
|
|
68
|
+
const minted = await this.call("token", thread);
|
|
69
|
+
const ready = Promise.withResolvers();
|
|
70
|
+
const reads = readsFrom(minted, this.options.endpoint, thread, this.doFetch, this.options.headers, this.options.credentials);
|
|
71
|
+
const read = async (path) => {
|
|
72
|
+
const response = await reads.fetch(`${reads.url.replace(/\/+$/, "")}/v1/agents/${encodeURIComponent(minted.agentId)}${path}`, { headers: { Authorization: `Bearer ${minted.token}` } });
|
|
73
|
+
if (!response.ok)
|
|
74
|
+
throw new Error(`${path}: HTTP ${response.status}`);
|
|
75
|
+
return response.json();
|
|
76
|
+
};
|
|
77
|
+
let watcher;
|
|
78
|
+
watcher = watchAgent({
|
|
79
|
+
...reads,
|
|
80
|
+
agentId: minted.agentId, token: minted.token, expiresAt: minted.expiresAt,
|
|
81
|
+
getToken: async () => { const renewed = await this.call("token", thread); return { token: renewed.token, expiresAt: renewed.expiresAt }; },
|
|
82
|
+
onEvent,
|
|
83
|
+
onChange: state => { if (state.connected)
|
|
84
|
+
ready.resolve(); if (watcher)
|
|
85
|
+
onChange(watcher); },
|
|
86
|
+
onError: error => { if (!watcher?.state.connected)
|
|
87
|
+
ready.reject(error); },
|
|
88
|
+
});
|
|
89
|
+
await ready.promise;
|
|
90
|
+
return { watcher, read };
|
|
91
|
+
}
|
|
92
|
+
async sendMessages(options) {
|
|
93
|
+
const thread = this.threadOf(options.chatId);
|
|
94
|
+
const last = options.messages.at(-1);
|
|
95
|
+
// Approvals answered in the chat (addToolApprovalResponse): answer them, and follow the resumed run.
|
|
96
|
+
const approvals = last?.role === "assistant" ? last.parts.flatMap(part => {
|
|
97
|
+
const approval = part.approval;
|
|
98
|
+
return part.state === "approval-responded" && approval?.id && typeof approval.approved === "boolean" ? [{ id: approval.id, approved: approval.approved, reason: approval.reason }] : [];
|
|
99
|
+
}) : [];
|
|
100
|
+
if (approvals.length) {
|
|
101
|
+
return this.follow(thread, options, async () => {
|
|
102
|
+
let resumed;
|
|
103
|
+
for (const approval of approvals) {
|
|
104
|
+
const answered = await this.call("answer", thread, { inputId: approval.id, answer: approval.approved ? { action: "accept" } : { action: "decline", ...(approval.reason ? { content: { reason: approval.reason } } : {}) } }, options.headers);
|
|
105
|
+
resumed = answered.request?.id ?? resumed;
|
|
106
|
+
}
|
|
107
|
+
return resumed ? { requestId: resumed, gate: false } : null;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (!last || last.role !== "user")
|
|
111
|
+
throw new Error("The agent keeps its own history: send a user message (regenerating an answer is not supported)");
|
|
112
|
+
const text = textOf(last);
|
|
113
|
+
const clientId = CLIENT_ID.test(last.id) ? last.id : `cm_${globalThis.crypto.randomUUID().replace(/-/g, "")}`;
|
|
114
|
+
const data = options.body && Object.keys(options.body).length ? options.body : undefined;
|
|
115
|
+
return this.follow(thread, options, async () => {
|
|
116
|
+
const sent = await this.call("send", thread, { text, clientId, ...(data ? { data } : {}), ...(this.options.whileRunning ? { whileRunning: this.options.whileRunning } : {}) }, options.headers);
|
|
117
|
+
return { requestId: sent.steeredInto ?? sent.requestId, gate: !sent.steeredInto, clientId };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** The run going on now, if any (after a reload, or an answer): its reply from where it stands. */
|
|
121
|
+
async reconnectToStream(options) {
|
|
122
|
+
const thread = this.threadOf(options.chatId);
|
|
123
|
+
const resumed = this.resuming.get(thread);
|
|
124
|
+
this.resuming.delete(thread);
|
|
125
|
+
// Whether there is anything to follow is known once the watcher is connected.
|
|
126
|
+
const following = Promise.withResolvers();
|
|
127
|
+
const stream = await this.follow(thread, { abortSignal: undefined, headers: options.headers }, async (watcher, read) => {
|
|
128
|
+
const state = await read("/state");
|
|
129
|
+
const record = resumed ? state.requests?.find(request => request.id === resumed)
|
|
130
|
+
: state.requests?.find(request => request.state === "running" && ["prompt", "continue", "resume"].includes(request.method));
|
|
131
|
+
// The run the answer resumed may have finished before this watcher connected: then its outcome
|
|
132
|
+
// never comes on the stream, and what it said is in the history.
|
|
133
|
+
if (resumed && record?.state === "completed")
|
|
134
|
+
return { requestId: resumed, gate: false, replay: await replay(read, record.startedAt ?? 0, record.outcome?.error) };
|
|
135
|
+
if (!resumed && !record)
|
|
136
|
+
return null;
|
|
137
|
+
return { requestId: resumed ?? record.id, gate: false, join: record?.startedAt ?? Number.MAX_SAFE_INTEGER };
|
|
138
|
+
}, following.resolve);
|
|
139
|
+
return await following.promise ? stream : null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Answer what the agent waits on (a `data-agent-input` part's input), then call `resumeStream()`. It goes
|
|
143
|
+
* to the chat's thread: `chatId` (useChat's `id`), else the chat this transport last sent for.
|
|
144
|
+
*/
|
|
145
|
+
async answer(input, value, options = {}) {
|
|
146
|
+
const answer = typeof value === "object" && value !== null && !Array.isArray(value) && "action" in value ? value : answerValue(input, value);
|
|
147
|
+
const chatId = options.chatId ?? this.lastChatId;
|
|
148
|
+
if (chatId === undefined && this.options.thread === undefined)
|
|
149
|
+
throw new Error("Say which chat this answers: transport.answer(input, value, { chatId })");
|
|
150
|
+
const thread = this.threadOf(chatId);
|
|
151
|
+
const answered = await this.call("answer", thread, { inputId: input.id, answer });
|
|
152
|
+
// The run it resumes (once the last input is answered): resumeStream() follows it, even before it starts.
|
|
153
|
+
if (answered.request?.id)
|
|
154
|
+
this.resuming.set(thread, answered.request.id);
|
|
155
|
+
}
|
|
156
|
+
/** Runs an answer resumed, by thread, for the next reconnectToStream. */
|
|
157
|
+
resuming = new Map();
|
|
158
|
+
/** The agent's history as the AI SDK's messages, for `useChat({ messages })`. */
|
|
159
|
+
async loadMessages(options = {}) {
|
|
160
|
+
const thread = this.threadOf(options.chatId);
|
|
161
|
+
const minted = await this.call("token", thread);
|
|
162
|
+
const reads = readsFrom(minted, this.options.endpoint, thread, this.doFetch, this.options.headers, this.options.credentials);
|
|
163
|
+
const response = await reads.fetch(`${reads.url.replace(/\/+$/, "")}/v1/agents/${encodeURIComponent(minted.agentId)}/history?limit=${options.limit ?? 50}`, { headers: { Authorization: `Bearer ${minted.token}` } });
|
|
164
|
+
if (!response.ok)
|
|
165
|
+
throw new Error(`history: HTTP ${response.status}`);
|
|
166
|
+
const page = await response.json();
|
|
167
|
+
return toUIMessages(projectMessages({ messages: page.entries.map(entry => entry.message), indexes: page.entries.map(entry => entry.index), partial: null, running: false }));
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Watch the agent, start (`begin`), and stream what follows as chunks until the run ends. With `gate`,
|
|
171
|
+
* nothing is streamed before the user message the run answers arrives (an earlier run may be finishing).
|
|
172
|
+
*/
|
|
173
|
+
async follow(thread, options, begin, started = () => { }) {
|
|
174
|
+
let controller;
|
|
175
|
+
let run = null;
|
|
176
|
+
let open = false, ended = false, assistants = 0;
|
|
177
|
+
const blocks = new Map();
|
|
178
|
+
const pending = [];
|
|
179
|
+
const outputs = new Set();
|
|
180
|
+
const output = (toolCallId, result, isError) => {
|
|
181
|
+
// A call waiting on a person has no result yet (its input_required says what it waits on).
|
|
182
|
+
if (result?.details?.inputRequired || outputs.has(toolCallId))
|
|
183
|
+
return;
|
|
184
|
+
outputs.add(toolCallId);
|
|
185
|
+
push(isError
|
|
186
|
+
? { type: "tool-output-error", toolCallId, errorText: String(resultOf(result)), dynamic: true }
|
|
187
|
+
: { type: "tool-output-available", toolCallId, output: resultOf(result), dynamic: true });
|
|
188
|
+
};
|
|
189
|
+
let watcher;
|
|
190
|
+
const push = (chunk) => { if (!ended)
|
|
191
|
+
controller.enqueue(chunk); };
|
|
192
|
+
const end = (chunk) => {
|
|
193
|
+
if (ended)
|
|
194
|
+
return;
|
|
195
|
+
if (chunk)
|
|
196
|
+
push(chunk);
|
|
197
|
+
push({ type: "finish" });
|
|
198
|
+
ended = true;
|
|
199
|
+
controller.close();
|
|
200
|
+
watcher?.close();
|
|
201
|
+
};
|
|
202
|
+
const handle = (event) => {
|
|
203
|
+
if (!run) {
|
|
204
|
+
pending.push(event);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!open) {
|
|
208
|
+
if (run.gate && !(event.type === "message_start" && event.message.role === "user" && event.message.requestId === run.clientId))
|
|
209
|
+
return;
|
|
210
|
+
open = true;
|
|
211
|
+
push({ type: "start" });
|
|
212
|
+
if (event.type === "message_start" && event.message.role === "user")
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
switch (event.type) {
|
|
216
|
+
case "message_start":
|
|
217
|
+
if (event.message.role === "assistant") {
|
|
218
|
+
assistants++;
|
|
219
|
+
blocks.clear();
|
|
220
|
+
push({ type: "start-step" });
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
case "message_update":
|
|
224
|
+
this.delta(event.assistantMessageEvent, assistants, blocks, push);
|
|
225
|
+
break;
|
|
226
|
+
case "message_end":
|
|
227
|
+
// A call's result (a resumed call has no tool_execution_end of its own).
|
|
228
|
+
if (event.message.role === "toolResult" && !outputs.has(event.message.toolCallId))
|
|
229
|
+
output(event.message.toolCallId, event.message, event.message.isError);
|
|
230
|
+
if (event.message.role === "assistant") {
|
|
231
|
+
for (const block of blocks.values())
|
|
232
|
+
if (block.kind !== "tool")
|
|
233
|
+
push({ type: `${block.kind}-end`, id: block.id });
|
|
234
|
+
blocks.clear();
|
|
235
|
+
push({ type: "finish-step" });
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
case "tool_execution_end":
|
|
239
|
+
output(event.toolCallId, event.result, event.isError);
|
|
240
|
+
break;
|
|
241
|
+
case "input_required":
|
|
242
|
+
if (event.input.kind === "approval")
|
|
243
|
+
push({ type: "tool-approval-request", approvalId: event.input.id, toolCallId: event.input.toolCallId });
|
|
244
|
+
else
|
|
245
|
+
push({ type: "data-agent-input", id: event.input.id, data: event.input });
|
|
246
|
+
break;
|
|
247
|
+
case "file_presented":
|
|
248
|
+
if (event.url)
|
|
249
|
+
push({ type: "file", url: event.url, mediaType: event.file.contentType });
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const stream = new ReadableStream({
|
|
254
|
+
start: async (streamController) => {
|
|
255
|
+
controller = streamController;
|
|
256
|
+
try {
|
|
257
|
+
// The run is over once its outcome arrives (or, joining whatever runs, once nothing does).
|
|
258
|
+
const settled = (current) => {
|
|
259
|
+
const outcome = current.state.lastOutcome;
|
|
260
|
+
if (!run || ended || !outcome)
|
|
261
|
+
return;
|
|
262
|
+
if (run.requestId ? outcome.id !== run.requestId : current.state.running)
|
|
263
|
+
return;
|
|
264
|
+
if (!open) {
|
|
265
|
+
open = true;
|
|
266
|
+
push({ type: "start" });
|
|
267
|
+
}
|
|
268
|
+
end(outcome.error ? { type: "error", errorText: outcome.error } : undefined);
|
|
269
|
+
};
|
|
270
|
+
const watching = await this.watch(thread, handle, settled);
|
|
271
|
+
watcher = watching.watcher;
|
|
272
|
+
const run0 = await begin(watcher, watching.read);
|
|
273
|
+
started(!!run0);
|
|
274
|
+
if (!run0) {
|
|
275
|
+
end();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (run0.replay) {
|
|
279
|
+
// A run that already ended: what it said, then the end.
|
|
280
|
+
for (const chunk of run0.replay)
|
|
281
|
+
push(chunk);
|
|
282
|
+
end();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (run0.join !== undefined) {
|
|
286
|
+
// Joining a run under way: what it did so far comes from the watcher's state, which has taken in
|
|
287
|
+
// every event received until now (the snapshot's and those since). So those events, held while
|
|
288
|
+
// the run was looked up, are not passed on too: each word shows once. Then the rest, live.
|
|
289
|
+
open = true;
|
|
290
|
+
push({ type: "start" });
|
|
291
|
+
const { messages, indexes, partial } = watcher.state;
|
|
292
|
+
messages.forEach((message, at) => {
|
|
293
|
+
if ((message.timestamp ?? 0) < run0.join)
|
|
294
|
+
return;
|
|
295
|
+
for (const chunk of messageChunks(indexes[at], message)) {
|
|
296
|
+
if (chunk.type === "tool-output-available" || chunk.type === "tool-output-error")
|
|
297
|
+
outputs.add(chunk.toolCallId);
|
|
298
|
+
push(chunk);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
if (partial) {
|
|
302
|
+
assistants++;
|
|
303
|
+
push({ type: "start-step" });
|
|
304
|
+
partial.content.forEach((block, at) => {
|
|
305
|
+
if (block.type === "text")
|
|
306
|
+
this.delta({ type: "text_delta", contentIndex: at, delta: block.text }, assistants, blocks, push);
|
|
307
|
+
else if (block.type === "thinking")
|
|
308
|
+
this.delta({ type: "thinking_delta", contentIndex: at, delta: block.thinking }, assistants, blocks, push);
|
|
309
|
+
else if (block.type === "toolCall" && block.id)
|
|
310
|
+
this.delta({ type: "toolcall_start", contentIndex: at, id: block.id, name: block.name }, assistants, blocks, push);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
pending.length = 0;
|
|
314
|
+
}
|
|
315
|
+
run = run0;
|
|
316
|
+
for (const event of pending.splice(0))
|
|
317
|
+
handle(event);
|
|
318
|
+
settled(watcher);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
started(false);
|
|
322
|
+
if (!ended) {
|
|
323
|
+
push({ type: "error", errorText: error.message });
|
|
324
|
+
ended = true;
|
|
325
|
+
controller.close();
|
|
326
|
+
}
|
|
327
|
+
watcher?.close();
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
cancel: () => { ended = true; watcher?.close(); },
|
|
331
|
+
});
|
|
332
|
+
options.abortSignal?.addEventListener("abort", () => {
|
|
333
|
+
// useChat's stop(): stop the agent's run too.
|
|
334
|
+
void this.call("stop", thread).catch(() => { });
|
|
335
|
+
if (!ended) {
|
|
336
|
+
push({ type: "abort" });
|
|
337
|
+
ended = true;
|
|
338
|
+
controller.close();
|
|
339
|
+
}
|
|
340
|
+
watcher?.close();
|
|
341
|
+
}, { once: true });
|
|
342
|
+
return stream;
|
|
343
|
+
}
|
|
344
|
+
/** One delta of the assistant message streaming now, as chunks. */
|
|
345
|
+
delta(delta, message, blocks, push) {
|
|
346
|
+
if (!("contentIndex" in delta))
|
|
347
|
+
return;
|
|
348
|
+
const at = delta.contentIndex;
|
|
349
|
+
const id = `${message}:${at}`;
|
|
350
|
+
const open = (kind) => {
|
|
351
|
+
if (blocks.get(at))
|
|
352
|
+
return;
|
|
353
|
+
blocks.set(at, { id, kind });
|
|
354
|
+
push({ type: `${kind}-start`, id });
|
|
355
|
+
};
|
|
356
|
+
switch (delta.type) {
|
|
357
|
+
case "text_start":
|
|
358
|
+
open("text");
|
|
359
|
+
break;
|
|
360
|
+
case "text_delta":
|
|
361
|
+
open("text");
|
|
362
|
+
if (delta.delta)
|
|
363
|
+
push({ type: "text-delta", id, delta: delta.delta });
|
|
364
|
+
break;
|
|
365
|
+
case "thinking_start":
|
|
366
|
+
open("reasoning");
|
|
367
|
+
break;
|
|
368
|
+
case "thinking_delta":
|
|
369
|
+
open("reasoning");
|
|
370
|
+
if (delta.delta)
|
|
371
|
+
push({ type: "reasoning-delta", id, delta: delta.delta });
|
|
372
|
+
break;
|
|
373
|
+
case "text_end":
|
|
374
|
+
case "thinking_end": {
|
|
375
|
+
const block = blocks.get(at);
|
|
376
|
+
if (block && block.kind !== "tool") {
|
|
377
|
+
push({ type: `${block.kind}-end`, id });
|
|
378
|
+
blocks.delete(at);
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
case "toolcall_start":
|
|
383
|
+
if (delta.id) {
|
|
384
|
+
blocks.set(at, { id, kind: "tool", toolCallId: delta.id, toolName: delta.name ?? "tool" });
|
|
385
|
+
push({ type: "tool-input-start", toolCallId: delta.id, toolName: delta.name ?? "tool", dynamic: true });
|
|
386
|
+
}
|
|
387
|
+
break;
|
|
388
|
+
case "toolcall_delta": {
|
|
389
|
+
const block = blocks.get(at);
|
|
390
|
+
if (block?.toolCallId && delta.delta)
|
|
391
|
+
push({ type: "tool-input-delta", toolCallId: block.toolCallId, inputTextDelta: delta.delta });
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
case "toolcall_end":
|
|
395
|
+
blocks.delete(at);
|
|
396
|
+
push({ type: "tool-input-available", toolCallId: delta.toolCall.id, toolName: delta.toolCall.name, input: delta.toolCall.arguments, dynamic: true });
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** What a finished run said (the messages from `since` on, from the newest page of history), as chunks. */
|
|
402
|
+
async function replay(read, since, error) {
|
|
403
|
+
const page = await read("/history?limit=50");
|
|
404
|
+
const chunks = [{ type: "start" }];
|
|
405
|
+
for (const { index, message } of page.entries)
|
|
406
|
+
if ((message.timestamp ?? 0) >= since)
|
|
407
|
+
chunks.push(...messageChunks(index, message));
|
|
408
|
+
if (error)
|
|
409
|
+
chunks.push({ type: "error", errorText: error });
|
|
410
|
+
return chunks;
|
|
411
|
+
}
|
|
412
|
+
/** A finished message as chunks: an assistant's text, reasoning and tool calls; a tool's result. */
|
|
413
|
+
function messageChunks(index, message) {
|
|
414
|
+
const chunks = [];
|
|
415
|
+
if (message.role === "assistant") {
|
|
416
|
+
chunks.push({ type: "start-step" });
|
|
417
|
+
(message.content ?? []).forEach((block, at) => {
|
|
418
|
+
const id = `r${index}:${at}`;
|
|
419
|
+
if (block?.type === "text" && block.text)
|
|
420
|
+
chunks.push({ type: "text-start", id }, { type: "text-delta", id, delta: block.text }, { type: "text-end", id });
|
|
421
|
+
else if (block?.type === "thinking" && block.thinking)
|
|
422
|
+
chunks.push({ type: "reasoning-start", id }, { type: "reasoning-delta", id, delta: block.thinking }, { type: "reasoning-end", id });
|
|
423
|
+
else if (block?.type === "toolCall")
|
|
424
|
+
chunks.push({ type: "tool-input-available", toolCallId: block.id, toolName: block.name, input: block.arguments, dynamic: true });
|
|
425
|
+
});
|
|
426
|
+
chunks.push({ type: "finish-step" });
|
|
427
|
+
}
|
|
428
|
+
else if (message.role === "toolResult" && !message.details?.inputRequired) {
|
|
429
|
+
chunks.push(message.isError
|
|
430
|
+
? { type: "tool-output-error", toolCallId: message.toolCallId, errorText: String(resultOf(message)), dynamic: true }
|
|
431
|
+
: { type: "tool-output-available", toolCallId: message.toolCallId, output: resultOf(message), dynamic: true });
|
|
432
|
+
}
|
|
433
|
+
return chunks;
|
|
434
|
+
}
|
|
435
|
+
/** Chat messages (from `@camelai/run/chat`) as the AI SDK's UIMessages. */
|
|
436
|
+
export function toUIMessages(messages) {
|
|
437
|
+
return messages.map(message => {
|
|
438
|
+
if (message.role === "user")
|
|
439
|
+
return { id: message.id, role: "user", parts: message.parts.map(part => part.type === "text" ? { type: "text", text: part.text } : { type: "file", url: part.url, mediaType: part.mimeType }) };
|
|
440
|
+
return {
|
|
441
|
+
id: message.id, role: "assistant",
|
|
442
|
+
parts: message.parts.flatMap((part) => {
|
|
443
|
+
switch (part.type) {
|
|
444
|
+
case "text": return [{ type: "text", text: part.text, state: "done" }];
|
|
445
|
+
case "reasoning": return part.redacted ? [] : [{ type: "reasoning", text: part.text, state: "done" }];
|
|
446
|
+
case "file": return part.url ? [{ type: "file", url: part.url, mediaType: part.contentType ?? "application/octet-stream", filename: part.name }] : [];
|
|
447
|
+
case "tool": {
|
|
448
|
+
const base = { type: "dynamic-tool", toolName: part.name, toolCallId: part.id, input: part.args };
|
|
449
|
+
if (part.state === "done")
|
|
450
|
+
return [{ ...base, state: "output-available", output: part.result?.data ?? part.result?.text }];
|
|
451
|
+
if (part.state === "error")
|
|
452
|
+
return [{ ...base, state: "output-error", errorText: part.result?.text ?? "failed" }];
|
|
453
|
+
if (part.input?.kind === "approval")
|
|
454
|
+
return [{ ...base, state: "approval-requested", approval: { id: part.input.id } }];
|
|
455
|
+
return [{ ...base, state: "input-available" }];
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}),
|
|
459
|
+
};
|
|
460
|
+
});
|
|
461
|
+
}
|