@cubos/agent-sdk 0.0.1136563

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.
@@ -0,0 +1,427 @@
1
+ import { type ConversationCache } from "./cache.js";
2
+ import { type ClientTool, type ClientToolsSession, type ServeClientToolsOptions } from "./client-tools.js";
3
+ import { type TokenSource } from "./http.js";
4
+ import { type FetchLike } from "./sse.js";
5
+ import type { Activity, ClientToolCall, Conversation, ConversationEvent, CreateConversationOptions, EnabledComponents, EventPage, Identity, ListConversationsOptions, Message, MessagePage, Page, PlanSnapshot, Todo, ToolActivity, WorkspaceDir } from "./types.js";
6
+ interface CommonClientOptions {
7
+ /** Where the Cubos Agent core is reachable, e.g. `https://agent.acme.com`.
8
+ * Pass `""` for a same-origin deployment. */
9
+ baseUrl: string;
10
+ /** Tenant slug. Resolved from the token via `/me` when omitted, at the cost
11
+ * of one request the first time it's needed. */
12
+ tenant?: string;
13
+ /** Override for runtimes whose global `fetch` needs wrapping (proxies,
14
+ * instrumentation, Node < 18 polyfills). */
15
+ fetch?: FetchLike;
16
+ /** Per-request ceiling in ms; `0` disables it. Streams are exempt — they are
17
+ * meant to stay open. Defaults to 30s. */
18
+ timeoutMs?: number;
19
+ /** How many times a 429 is retried, honouring the server's `Retry-After`.
20
+ * `0` surfaces the rate limit to you instead. Defaults to 2. */
21
+ maxRetries?: number;
22
+ /**
23
+ * Where already-loaded conversations are kept so reopening one is instant.
24
+ * Defaults to a bounded in-memory LRU; pass `null` to switch it off, or your
25
+ * own `ConversationCache` to survive a reload.
26
+ */
27
+ cache?: ConversationCache | null;
28
+ /** Messages retained per conversation. Older ones are dropped from the cache
29
+ * (not from the server) and come back through `loadOlder`. Defaults to 300. */
30
+ cacheMessageLimit?: number;
31
+ }
32
+ /**
33
+ * A short-lived token, either handed over once or fetched on demand.
34
+ *
35
+ * `getToken` is what a real app wants: tokens expire, and the SDK calls it
36
+ * again with `forceRefresh` after a 401. `token` exists because a script or a
37
+ * test that already holds one shouldn't have to write a closure for it — but a
38
+ * static token cannot be renewed, so it eventually 401s for good.
39
+ */
40
+ export type UserCredential = {
41
+ getToken: TokenSource;
42
+ } | {
43
+ token: string;
44
+ };
45
+ export type ClientOptions = CommonClientOptions & UserCredential;
46
+ export interface ConversationSubscription {
47
+ /** Stops the streams and releases the connections. Idempotent. */
48
+ close(): void;
49
+ }
50
+ export interface ConversationHandlers {
51
+ /** Fires for the backlog on connect and for each new message after. */
52
+ onMessage?: (message: Message) => void;
53
+ /**
54
+ * Every event, exactly as the server sent it, in stream order — before the
55
+ * handlers above see what it projected into.
56
+ *
57
+ * The curated handlers are a projection over this same frame, not a wall in
58
+ * front of it: taking `onEvent` costs no second connection, no second cursor
59
+ * and no second catch-up, and an app may take both. Reach for it when your UI
60
+ * shows the log itself rather than a conversation.
61
+ */
62
+ onEvent?: (event: ConversationEvent) => void;
63
+ /** The event stream is live — on first connect and after every reconnect.
64
+ * Whatever catches up on gaps belongs here, not before `subscribe`. */
65
+ onOpen?: () => void;
66
+ /** The agent called a client tool. Its turn is suspended until someone
67
+ * answers the call; see `serveClientTools`. */
68
+ onClientToolCall?: (call: ClientToolCall) => void;
69
+ /** The agent reached for a tool, or that call finished. Fires twice per client
70
+ * tool — once running, once resolved — and once for a server-side tool, which
71
+ * only reports its result. Fold them with `mergeToolActivities`. */
72
+ onToolActivity?: (activity: ToolActivity) => void;
73
+ onActivity?: (activity: Activity) => void;
74
+ /** A turn finished. The one signal that means the agent is done — unlike
75
+ * `onActivity`, which goes quiet mid-turn while a client tool is answered. */
76
+ onTurnDone?: (seq: number) => void;
77
+ /** The plan, with the `seq` that says which answer it belongs to. */
78
+ onTodos?: (todos: Todo[], seq: number) => void;
79
+ onConversation?: (conversation: Conversation) => void;
80
+ /** Every frame's `change_seq`, in the order delivered. Feed the last one back
81
+ * as `since` on the next connect — that is what lets a cache pick up where it
82
+ * left off instead of replaying. */
83
+ onCursor?: (changeSeq: number) => void;
84
+ /** Transient stream failures. The SDK is already reconnecting; this is for
85
+ * logging or a "reconnecting…" hint, not for recovery. */
86
+ onError?: (err: unknown) => void;
87
+ }
88
+ /** What `loadHistory` resolves to: a `MessagePage` plus where it came from. */
89
+ export interface HistoryStart {
90
+ messages: Message[];
91
+ /** The tools behind those messages, folded and ordered by `seq`. */
92
+ toolActivity: ToolActivity[];
93
+ /** Every plan revision behind them, ordered by `seq`. */
94
+ plans: PlanSnapshot[];
95
+ /** Newest finished turn, or null. */
96
+ lastTurnDoneSeq: number | null;
97
+ oldestSeq: number | null;
98
+ /** Pass to `subscribe` as `since`. Null only for a conversation with no
99
+ * events at all. */
100
+ latestChangeSeq: number | null;
101
+ hasOlder: boolean;
102
+ /** True when nothing was fetched. The stream still reconciles it. */
103
+ fromCache: boolean;
104
+ }
105
+ export interface ListHandlers {
106
+ onConversation: (conversation: Conversation) => void;
107
+ onError?: (err: unknown) => void;
108
+ }
109
+ export declare class AgentClient {
110
+ #private;
111
+ constructor(options: ClientOptions);
112
+ /** Who the current token acts as. Cached — call `refreshIdentity` after
113
+ * swapping to a token for a different user. */
114
+ me(): Promise<Identity>;
115
+ refreshIdentity(): Promise<Identity>;
116
+ listConversations(opts?: ListConversationsOptions): Promise<Page<Conversation>>;
117
+ /**
118
+ * Every conversation, newest activity first, fetching pages as you go.
119
+ *
120
+ * `listConversations` hands back a cursor to thread through yourself; this is
121
+ * the same thing when you just want them all:
122
+ *
123
+ * ```ts
124
+ * for await (const conversation of client.iterateConversations()) { … }
125
+ * ```
126
+ *
127
+ * Stops on `break` without fetching the next page.
128
+ */
129
+ iterateConversations(opts?: {
130
+ pageSize?: number;
131
+ signal?: AbortSignal;
132
+ }): AsyncGenerator<Conversation>;
133
+ /**
134
+ * The conversation's history, newest first, paging backwards as you go — so
135
+ * taking the first N gives you the N most recent messages.
136
+ *
137
+ * A voice message's clip and its transcription are paired within a page; if
138
+ * the two land on either side of a page edge, the clip surfaces with empty
139
+ * text. Raise `pageSize` if that matters for your history depth.
140
+ */
141
+ iterateMessages(id: string, opts?: {
142
+ pageSize?: number;
143
+ signal?: AbortSignal;
144
+ }): AsyncGenerator<Message>;
145
+ createConversation(opts?: CreateConversationOptions): Promise<Conversation>;
146
+ /**
147
+ * Enable component libraries on `id` — the catalogs of interactive blocks the
148
+ * agent may put in a reply. Replaces the whole list, so send it complete; an
149
+ * empty list takes the agent back to plain markdown.
150
+ *
151
+ * The libraries themselves are authored by the operator, not here: their
152
+ * entries are shown to the model verbatim, and writing prompt content needs an
153
+ * api_key this client will never hold. What you choose is which of them apply
154
+ * to the screen you have open.
155
+ *
156
+ * Returns the slugs together with **every tag they resolve to**. Check that
157
+ * against the components you can actually draw — the agent placing a tag your
158
+ * app has no renderer for is a silent hole in a reply, and this is the only
159
+ * moment both halves are in one place.
160
+ *
161
+ * Takes effect on the agent's next turn — a turn already running keeps the
162
+ * catalog it started with, so what the agent was told it could use and what it
163
+ * is held to are always the same list.
164
+ */
165
+ setComponentLibraries(id: string, libraries: string[], signal?: AbortSignal): Promise<EnabledComponents>;
166
+ /**
167
+ * Tell the agent what your app currently has on screen — the open page, the
168
+ * selected record, the filters in force. Free-form: prose or JSON, whatever
169
+ * the agent reads best.
170
+ *
171
+ * Cheap and safe to call as often as the screen moves, **including while a
172
+ * turn is running**: it writes no event and starts no turn. The server copies
173
+ * it into the conversation the next time a turn reads it, and only when it
174
+ * differs from what the model was last shown — so repeating an unchanged
175
+ * context costs nothing at all.
176
+ *
177
+ * That is the difference from putting the context in the message text, which
178
+ * is what apps do without this: there it is spent on every message, it shows
179
+ * up in the transcript unless every render path strips it, and only the app
180
+ * that owns the composer can send it.
181
+ *
182
+ * `null` stops reporting. What the model was already shown stays in the log —
183
+ * it is the record of the screen an earlier question was asked against.
184
+ */
185
+ setContext(id: string, context: string | null, signal?: AbortSignal): Promise<void>;
186
+ listComponentLibraries(id: string, signal?: AbortSignal): Promise<EnabledComponents>;
187
+ getConversation(id: string, signal?: AbortSignal): Promise<Conversation>;
188
+ renameConversation(id: string, title: string, signal?: AbortSignal): Promise<Conversation>;
189
+ archiveConversation(id: string, signal?: AbortSignal): Promise<Conversation>;
190
+ /** One page of history, oldest-first. `limit` counts events, so a page may
191
+ * hold fewer messages than you asked for — `iterateMessages` handles that. */
192
+ listMessages(id: string, opts?: {
193
+ before?: number;
194
+ limit?: number;
195
+ signal?: AbortSignal;
196
+ }): Promise<Message[]>;
197
+ /**
198
+ * The state to open a conversation with: cached if it has been opened before,
199
+ * fetched otherwise.
200
+ *
201
+ * A hit costs no request. It is not stale either — the caller is expected to
202
+ * `subscribe` with the returned `latestChangeSeq`, and the server replays
203
+ * every insert and every mutation past it, so anything that happened while
204
+ * the app was closed arrives as a delta.
205
+ *
206
+ * A cache that throws is treated as a miss: a corrupt store degrades to the
207
+ * network instead of breaking the conversation.
208
+ */
209
+ loadHistory(id: string, opts?: {
210
+ pageSize?: number;
211
+ signal?: AbortSignal;
212
+ }): Promise<HistoryStart>;
213
+ /**
214
+ * Records the conversation's current state for the next `loadHistory`.
215
+ *
216
+ * `latestChangeSeq` must be the highest cursor folded into `messages` — the
217
+ * page's, or the last one `subscribe`'s `onCursor` reported. Passing a higher
218
+ * one would make the next connect skip the events in between; passing a lower
219
+ * one only costs a replay.
220
+ *
221
+ * Silently does nothing without a cache, so callers need no branch.
222
+ */
223
+ saveHistory(id: string, state: {
224
+ messages: Message[];
225
+ toolActivity?: ToolActivity[];
226
+ plans?: PlanSnapshot[];
227
+ lastTurnDoneSeq?: number | null;
228
+ oldestSeq: number | null;
229
+ latestChangeSeq: number | null;
230
+ hasOlder: boolean;
231
+ }): Promise<void>;
232
+ /** Forgets one conversation, or every one of this user's. */
233
+ forgetHistory(id?: string): Promise<void>;
234
+ /**
235
+ * `listMessages` plus the cursors a paging UI needs: `oldestSeq` to ask for
236
+ * the page before this one, and `latestChangeSeq` to start a subscription
237
+ * from here instead of replaying everything.
238
+ *
239
+ * `hasOlder` is false only when the server returned fewer events than asked
240
+ * for — the one honest signal that the log is exhausted, since a page can
241
+ * hold events that are not messages.
242
+ */
243
+ /**
244
+ * One page of the conversation's **event log**, oldest-first — the same rows
245
+ * `listMessagesPage` projects into messages, handed over as they came.
246
+ *
247
+ * Reach for this when the curated view is too small a window: an operator
248
+ * console showing tool calls, an audit trail, anything that has to see the
249
+ * event types the chat surface deliberately drops. `ConversationEvent` tracks
250
+ * the server rather than promising stability across refactors — that is the
251
+ * trade, and it is the same one the REST API already offers.
252
+ */
253
+ listEventsPage(id: string, opts?: {
254
+ before?: number;
255
+ limit?: number;
256
+ signal?: AbortSignal;
257
+ }): Promise<EventPage>;
258
+ listMessagesPage(id: string, opts?: {
259
+ before?: number;
260
+ limit?: number;
261
+ signal?: AbortSignal;
262
+ }): Promise<MessagePage>;
263
+ sendMessage(id: string, content: string, signal?: AbortSignal): Promise<void>;
264
+ /**
265
+ * One image, with an optional caption that becomes the message's text.
266
+ *
267
+ * png, jpeg, webp and gif, up to 10 MB. The agent sees the picture natively
268
+ * when its chat model has vision, and otherwise a description from the
269
+ * agent's fallback vision model — either way this costs a turn, and is
270
+ * budgeted as one.
271
+ */
272
+ sendImage(id: string, image: Blob, opts?: {
273
+ filename?: string;
274
+ caption?: string;
275
+ label?: string;
276
+ signal?: AbortSignal;
277
+ }): Promise<void>;
278
+ /**
279
+ * Up to 10 images in ONE message, so the agent reasons over the set instead of
280
+ * one turn per picture. `label` names an image for the model, which lets it
281
+ * answer about "the receipt" rather than "the second image"; `caption` is the
282
+ * message's own text, shared by the set.
283
+ */
284
+ sendImages(id: string, images: Array<{
285
+ image: Blob;
286
+ filename?: string;
287
+ label?: string;
288
+ }>, opts?: {
289
+ caption?: string;
290
+ signal?: AbortSignal;
291
+ }): Promise<void>;
292
+ /**
293
+ * Sends a voice message. The agent's STT model transcribes it before the turn
294
+ * runs, so the reply answers what was said — the transcription then arrives as
295
+ * a normal `user` message on the stream, which is why nothing is returned
296
+ * here.
297
+ *
298
+ * `audio` is any `Blob`; a `MediaRecorder` chunk works as-is. Its `type`
299
+ * (codec included) is forwarded, so the provider gets what it needs to decode.
300
+ */
301
+ sendAudio(id: string, audio: Blob, opts?: {
302
+ filename?: string;
303
+ signal?: AbortSignal;
304
+ }): Promise<void>;
305
+ /**
306
+ * The bytes of one of a message's attachments, as a `Blob`.
307
+ *
308
+ * Bytes rather than a URL because the token travels in a header: an `<img
309
+ * src>` pointing at this route would arrive unauthenticated. Wrap it for the
310
+ * DOM, and revoke when the element goes away:
311
+ *
312
+ * ```ts
313
+ * const url = URL.createObjectURL(await client.fetchAttachment(convId, msg.id, att.id));
314
+ * ```
315
+ *
316
+ * `attachmentId` is optional only for a single-attachment message; omitted on
317
+ * a multi-image one, the server serves the first.
318
+ */
319
+ fetchAttachment(conversationId: string, messageId: string, attachmentId?: string, signal?: AbortSignal): Promise<Blob>;
320
+ /**
321
+ * Lists **one** directory of the conversation's files — never recursive, so a
322
+ * workspace with thousands of files is still one small response. `path`
323
+ * defaults to the root.
324
+ *
325
+ * With no `atSeq` you see exactly what the agent would be shown on its next
326
+ * turn, which is also the tree a write starts from — including empty, once a
327
+ * workspace has gone untouched for long enough to expire. `atSeq` is how you
328
+ * reach a past snapshot, expiry and all.
329
+ *
330
+ * None of the write methods below starts a turn. A file arriving is not a
331
+ * question: upload what the user dropped, then send a message if you want the
332
+ * agent to do something about it. It finds out either way — the harness tells
333
+ * it what changed at the start of its next request.
334
+ */
335
+ listFiles(id: string, opts?: {
336
+ path?: string;
337
+ atSeq?: number;
338
+ signal?: AbortSignal;
339
+ }): Promise<WorkspaceDir>;
340
+ /**
341
+ * One file's bytes, as a `Blob`. Bytes rather than a URL for the same reason
342
+ * as `fetchAttachment`: the token travels in a header, so an `<a href>` at
343
+ * this route would arrive unauthenticated.
344
+ */
345
+ readFile(id: string, path: string, opts?: {
346
+ atSeq?: number;
347
+ signal?: AbortSignal;
348
+ }): Promise<Blob>;
349
+ /** Creates or replaces one file, creating parent directories. */
350
+ writeFile(id: string, path: string, file: Blob, opts?: {
351
+ filename?: string;
352
+ signal?: AbortSignal;
353
+ }): Promise<void>;
354
+ /**
355
+ * Writes several files as **one** snapshot. Worth preferring over a loop of
356
+ * `writeFile`: the agent is told about the upload as a single change rather
357
+ * than as N, and nothing ever observes half a set.
358
+ */
359
+ writeFiles(id: string, files: Array<{
360
+ path: string;
361
+ file: Blob;
362
+ filename?: string;
363
+ }>, opts?: {
364
+ signal?: AbortSignal;
365
+ }): Promise<void>;
366
+ /** Removes a file, or a directory with everything under it. Earlier
367
+ * snapshots keep resolving through `atSeq`. */
368
+ deleteFile(id: string, path: string, signal?: AbortSignal): Promise<void>;
369
+ moveFile(id: string, from: string, to: string, signal?: AbortSignal): Promise<void>;
370
+ /**
371
+ * Declares the tool set on `id` without running anything — the half of
372
+ * `serveClientTools` that has to happen *before* the first message, since a
373
+ * tool the agent was never told about can't be called.
374
+ *
375
+ * Replaces the whole set, like `setComponents`.
376
+ */
377
+ setClientTools(id: string, tools: Record<string, ClientTool<never, unknown>>, signal?: AbortSignal): Promise<void>;
378
+ /**
379
+ * Declares your functions as tools on `id` and runs them as the agent calls
380
+ * them — the browser half of client tools, on the end-user token.
381
+ *
382
+ * Belongs on this client and not only on the operator one: the implementation
383
+ * runs where the app runs, and an api_key is tenant-wide and can never be
384
+ * shipped to a browser. Keep the session alive for as long as the
385
+ * conversation is on screen, and `stop()` it when it isn't.
386
+ *
387
+ * The runner opens its own event stream by default. If you already subscribe
388
+ * to the conversation, pass `watch: false` and call `session.poke()` from
389
+ * `onOpen` and `onClientToolCall` instead: same behaviour over one connection.
390
+ *
391
+ * ```ts
392
+ * const session = client.serveClientTools(id, { tools, watch: false });
393
+ * client.subscribe(id, {
394
+ * onOpen: () => session.poke(),
395
+ * onClientToolCall: () => session.poke(),
396
+ * });
397
+ * ```
398
+ */
399
+ serveClientTools(id: string, options: ServeClientToolsOptions): ClientToolsSession;
400
+ /** Injects an instruction mid-turn: unlike `sendMessage`, it lands while the
401
+ * agent is already working and redirects it. */
402
+ steer(id: string, content: string, signal?: AbortSignal): Promise<void>;
403
+ /**
404
+ * Live view of one conversation: messages, the agent's activity, and its plan.
405
+ * Resumes from the last frame after a drop, so a reconnect loses nothing.
406
+ *
407
+ * By default it also backfills the whole log on connect, which makes the
408
+ * subscription the single source of truth for a short conversation. Pass
409
+ * `since` — the newest `changeSeq` you already hold, from `listMessagesPage` —
410
+ * to skip that backfill and receive only what is new; that is what makes
411
+ * paging backwards meaningful, since otherwise the stream re-delivers the
412
+ * history you just paged through.
413
+ */
414
+ subscribe(id: string, handlers: ConversationHandlers, opts?: {
415
+ since?: number;
416
+ }): ConversationSubscription;
417
+ /** Live chat list. Fires per conversation whose activity advances; upsert by
418
+ * id and re-sort by `lastActivityAt` locally. */
419
+ subscribeToConversations(handlers: ListHandlers): ConversationSubscription;
420
+ }
421
+ /**
422
+ * Client for an end user in a browser or mobile app, authenticated with a
423
+ * short-lived user token. Reaches only the conversation surface, and only that
424
+ * user's own rows — the server enforces both.
425
+ */
426
+ export declare function createUserClient(options: ClientOptions): AgentClient;
427
+ export {};
@@ -0,0 +1,52 @@
1
+ /** Base for everything this SDK throws, so `catch (e) { if (e instanceof
2
+ * AgentError) }` covers both an HTTP failure and a connection that never got
3
+ * there. */
4
+ export declare class AgentError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ /** The server answered, and said no. */
8
+ export declare class AgentApiError extends AgentError {
9
+ readonly status: number;
10
+ /** Server's `x-request-id`, when present. Worth quoting in a bug report. */
11
+ readonly requestId: string | null;
12
+ /** The response body, verbatim and untruncated. Some routes explain the
13
+ * failure in there (an MCP probe's reason, a rejected cron) in a form worth
14
+ * showing the user; `message` only carries a truncated preview. */
15
+ readonly body: string;
16
+ constructor(message: string, status: number, requestId?: string | null, body?: string);
17
+ /** The body parsed as JSON, or `undefined` when it isn't. */
18
+ json<T = unknown>(): T | undefined;
19
+ /** The token was rejected. The client already retried once with a fresh one,
20
+ * so seeing this means `getToken` is handing back something unusable. */
21
+ get isAuthError(): boolean;
22
+ get isNotFound(): boolean;
23
+ /** The request clashed with current state — a slug already taken, a
24
+ * conversation that belongs to a channel, a user already blocked. */
25
+ get isConflict(): boolean;
26
+ /** Worth retrying after a pause: the server is overloaded or briefly down. */
27
+ get isRetryable(): boolean;
28
+ }
29
+ /**
30
+ * The client was constructed wrong — a `baseUrl` with no scheme, most often.
31
+ * Thrown at construction, not on the first call, so the stack points at the
32
+ * mistake.
33
+ */
34
+ export declare class AgentConfigError extends AgentError {
35
+ constructor(message: string);
36
+ }
37
+ /**
38
+ * The request never produced a response: DNS, TLS, a refused connection, CORS,
39
+ * or the timeout below.
40
+ *
41
+ * Without this, `fetch` rejects with a bare `TypeError: fetch failed` and the
42
+ * caller cannot tell a wrong `baseUrl` from a server that said 500 — the two
43
+ * need completely different fixes.
44
+ */
45
+ export declare class AgentNetworkError extends AgentError {
46
+ /** Whatever `fetch` (or the abort) threw. */
47
+ readonly cause: unknown;
48
+ /** True when the SDK's own timeout fired rather than the network failing. */
49
+ readonly timedOut: boolean;
50
+ constructor(message: string, cause: unknown, timedOut?: boolean);
51
+ }
52
+ export declare function raiseForStatus(res: Response, fallback: string): Promise<void>;