@coder/ai-sdk-agent 0.2.1 → 0.3.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/README.md CHANGED
@@ -43,10 +43,12 @@ pure text/JSON steps and the Agent only for the steps that touch tools.
43
43
  ## Install
44
44
 
45
45
  ```bash
46
- pnpm add @coder/ai-sdk-agent ai zod
46
+ pnpm add @coder/ai-sdk-agent ai@^6 zod
47
47
  ```
48
48
 
49
- Requires Node ≥ 20 and `ai` v6.
49
+ Requires Node ≥ 20 and `ai` v6 — the constructors throw an actionable error when
50
+ another `ai` major is detected (the guard fails open when the installed version
51
+ can't be resolved), instead of failing cryptically mid‑generation.
50
52
 
51
53
  ## Quick start
52
54
 
@@ -114,6 +116,12 @@ AI SDK tool loop — your `execute` runs in your process.
114
116
  appear in the transcript as `providerExecuted` tool calls/results — you observe them, you
115
117
  don't execute them.
116
118
 
119
+ Migration note: since v0.2.1 server‑executed tools stream with `dynamic: true`
120
+ (they aren't in your `ToolSet`, and the AI SDK only accepts unknown tool names on
121
+ dynamic calls). In UI message streams they therefore surface as `dynamic-tool`
122
+ parts rather than `tool-{name}` parts — key off `toolName`, not `part.type`, when
123
+ rendering them.
124
+
117
125
  ## Files
118
126
 
119
127
  There are two distinct ways to get a file to the agent, depending on whether the model should
@@ -203,13 +211,81 @@ created on the first turn and reused for subsequent `generate()`/`stream()` call
203
211
  conversation with server‑side history). `agent.chatId` is the current chat id.
204
212
 
205
213
  - `agent.resetSession()` — start a fresh chat on the next turn (reuse one instance for sequential turns; you don't need a new agent per turn).
206
- - `agent.interrupt()` — interrupt an in‑flight generation.
207
- - `agent.archive()` — archive the underlying chat (cleanup; see [Cleanup](#cleanup)).
214
+ - `agent.interrupt({ signal? })` — interrupt an in‑flight generation.
215
+ - `agent.archive({ signal? })` — archive the underlying chat (cleanup; see [Cleanup](#cleanup)).
208
216
  - `agent.listModels()` — list the deployment's model configs, so you don't have to guess the `model` hint.
209
217
  - Resume a prior chat: `new CoderAgent({ …, chatId: "…" })`.
210
218
 
219
+ Interrupting is asynchronous on the server: `interrupt()` resolves as soon as the
220
+ interrupt is acknowledged, and the run keeps winding down for a few seconds
221
+ afterwards. The client‑level `client.interruptChat(chatId, { wait: true })` sends
222
+ `?wait=true` to ask the server to hold the response until the run has stopped —
223
+ current Coder servers ignore the unknown parameter and still return immediately,
224
+ so confirm completion via the event stream (e.g. [`watchChats`](#watching-chats))
225
+ rather than relying on it.
226
+
211
227
  A single instance is **single‑flight** — don't run concurrent generations against it. For concurrency, use one instance per session (and see [Workspaces & quota](#workspaces--quota)).
212
228
 
229
+ ## Rehydrating chat history
230
+
231
+ Chat history lives on the server. To render an existing chat in a UI (e.g. after
232
+ a reload), fetch its messages with the `CoderChatClient` (`agent.client`, or one
233
+ you construct — see [Auth](#auth)) and convert them with
234
+ `chatMessagesToUIMessages` — the mapping mirrors what a live‑streamed transcript
235
+ of the same turn looks like:
236
+
237
+ ```ts
238
+ import { chatMessagesToUIMessages } from "@coder/ai-sdk-agent";
239
+
240
+ const { messages } = await client.getMessages(chatId);
241
+ const uiMessages = chatMessagesToUIMessages(messages);
242
+ // e.g. in React: useChat({ messages: uiMessages })
243
+ ```
244
+
245
+ The converter sorts by message id, so the endpoint's newest‑first default page
246
+ order (and any pagination order) is safe to pass straight in — `useChat` always
247
+ receives a chronological transcript.
248
+
249
+ Tool calls become `dynamic-tool` parts with their results folded in, `source`
250
+ parts become `source-url` parts, and unknown part kinds are skipped silently, so
251
+ history written by newer Coder servers degrades gracefully. One caveat: history
252
+ does not record which tool names were client (`ToolSet`) tools, so _every_ tool
253
+ call rehydrates as `dynamic-tool` — live, client tools stream as statically
254
+ typed `tool-{name}` parts. Render tools by name (ai's
255
+ `isToolOrDynamicToolUIPart` and `getToolOrDynamicToolName`) rather than by
256
+ exact `part.type` and the difference disappears. Persisted `file`
257
+ parts carry only a `file_id` (no bytes, usually no URL), so pass a `fileUrl`
258
+ resolver to keep attachments visible — download the bytes with
259
+ `client.getChatFile(fileId)` and return a data:/object/proxy URL; parts that end
260
+ up without a URL are skipped:
261
+
262
+ ```ts
263
+ chatMessagesToUIMessages(messages, {
264
+ fileUrl: (part) => (part.file_id ? `/api/files/${part.file_id}` : undefined),
265
+ });
266
+ ```
267
+
268
+ ## Watching chats
269
+
270
+ `client.watchChats({ signal })` yields lifecycle events (status/title changes,
271
+ creation, deletion, …) for **every chat visible to the authenticated user** as an
272
+ async iterable, backed by the `/api/experimental/chats/watch` WebSocket:
273
+
274
+ ```ts
275
+ for await (const event of client.watchChats({ signal })) {
276
+ if (event.kind === "status_change") console.log(event.chat.id, event.chat.status);
277
+ }
278
+ ```
279
+
280
+ Unlike the per‑chat event stream, this is a long‑lived subscription: dropped
281
+ connections are redialed automatically with exponential backoff (1s doubling to
282
+ a 30s cap, reset once an event arrives). Iteration ends only when the signal
283
+ aborts, or with a terminal `CoderApiError` when the server rejects the upgrade
284
+ with a 4xx — bad/expired token, or an older Coder server without the endpoint
285
+ (404). For custom plumbing (own client, browser sockets), the standalone
286
+ `watchChatEvents({ baseUrl, token, signal, webSocketFactory })` export provides
287
+ the same stream without a `CoderChatClient`.
288
+
213
289
  ## Timeouts & cancellation
214
290
 
215
291
  Pass an `abortSignal` to `generate()`/`stream()` to cancel a turn. Aborting
@@ -242,17 +318,28 @@ truncated result as if the turn had finished.
242
318
  ## Cleanup
243
319
 
244
320
  `archive()` soft‑hides the chat (it stays in listings as `archived: true`; there
245
- is no hard delete yet). To make cleanup ride scope exit instead of a `finally` you
246
- have to remember, the agent is an **async disposable**:
321
+ is no hard delete yet). A freshly interrupted chat keeps winding down server‑side
322
+ for a few seconds, during which archiving 409s — `archive()` retries those 409s
323
+ (~1s apart, up to ~15s overall; tune with `settleDeadlineMs` /
324
+ `settleRetryDelayMs`) and rethrows the last one if the chat never settles. Any
325
+ other failure, including your own abort, rethrows immediately.
326
+
327
+ To make cleanup ride scope exit instead of a `finally` you have to remember, the
328
+ agent is an **async disposable**:
247
329
 
248
330
  ```ts
249
331
  await using agent = new CoderAgent({
250
332
  /* … */
251
333
  });
252
334
  const { text } = await agent.generate({ prompt: "…" });
253
- // agent.archive() runs automatically when the scope exits (best‑effort).
335
+ // agent.interrupt() + agent.archive() run automatically when the scope exits.
254
336
  ```
255
337
 
338
+ Disposal interrupts any in‑flight run, then archives. It is **bounded and never
339
+ throws** (~15s overall, best‑effort): disposal errors are swallowed so they can't
340
+ mask the scope's own error. Call `archive()` directly when you need guaranteed
341
+ cleanup.
342
+
256
343
  In a request handler that returns before a fire‑and‑forget `archive()` settles, the
257
344
  archive can be abandoned — `await using` (or an awaited `archive()` in `finally`)
258
345
  avoids accumulating live chats.
@@ -284,6 +371,36 @@ try {
284
371
  SDK‑level retry could duplicate a turn. Prefer catching `retryable` errors and
285
372
  retrying the whole step deliberately.
286
373
 
374
+ ## Usage & cost
375
+
376
+ Results carry normalized token usage in `usage`, plus the verbatim snake_case
377
+ wire usage in `usage.raw` for fields the normalized shape has no slot for
378
+ (`context_limit`, cost, runtime, …). When the server reports them,
379
+ `total_cost_micros` (micro‑USD) and `total_runtime_ms` are also mirrored under
380
+ `providerMetadata.coder` on finish. Both are **absence‑tolerant mirrors**:
381
+ today's Coder servers only expose cost on the aggregate cost endpoints
382
+ (`/api/experimental/chats/cost/*`), so expect these fields to be absent —
383
+ nothing is emitted when the server sends neither.
384
+
385
+ Forward usage to a UI via message metadata:
386
+
387
+ ```ts
388
+ const result = await agent.stream({ prompt: "…" });
389
+ return result.toUIMessageStream({
390
+ messageMetadata: ({ part }) =>
391
+ part.type === "finish-step"
392
+ ? { usage: part.usage, coder: part.providerMetadata?.coder }
393
+ : undefined,
394
+ });
395
+ ```
396
+
397
+ ## Sources
398
+
399
+ Model configs with web search enabled emit `source` parts. These flow through to
400
+ `result.sources` and, in UI message streams, `source-url` parts (pass
401
+ `sendSources: true` to `toUIMessageStream` — the AI SDK omits them by default).
402
+ Earlier releases dropped them.
403
+
287
404
  ## Structured output
288
405
 
289
406
  Coder Agents has no server‑side `response_format`, so `CoderAgent` cannot
@@ -325,13 +442,21 @@ const agent = new CoderAgent({
325
442
  });
326
443
 
327
444
  const result = await agent.generate({ prompt: "…" });
328
- // toolCalls only holds the LAST step's calls — scan all steps, last call wins.
329
- const raw = result.steps
445
+ // toolCalls only holds the LAST step's calls — scan all steps. Take the last call
446
+ // that VALIDATES: a schema-invalid re-file must not shadow a valid answer (rule 2).
447
+ const filed = result.steps
330
448
  .flatMap((s) => s.toolCalls)
331
- .findLast((c) => c.toolName === "structured_output")?.input;
332
- if (raw === undefined)
333
- throw new Error("model never called structured_output — nudge once on an idle chat (rule 3)");
334
- const answer = Answer.parse(raw); // typed: { severity: "critical" | "major" | "minor"; summary: string }
449
+ .filter((c) => c.toolName === "structured_output");
450
+ let answer: z.infer<typeof Answer> | undefined;
451
+ for (const call of filed.reverse()) {
452
+ const parsed = Answer.safeParse(call.input);
453
+ if (parsed.success) {
454
+ answer = parsed.data; // typed: { severity: "critical" | "major" | "minor"; summary: string }
455
+ break;
456
+ }
457
+ }
458
+ if (answer === undefined)
459
+ throw new Error("no valid structured_output call — nudge once on an idle chat (rule 3)");
335
460
  ```
336
461
 
337
462
  Rules that keep it robust — each guards against a failure mode observed live:
@@ -356,19 +481,73 @@ Rules that keep it robust — each guards against a failure mode observed live:
356
481
  4. **Settle a turn that stopped on a tool call.** If the loop stops on a
357
482
  tool‑call step — e.g. your `stopWhen` ceiling lands exactly on the
358
483
  `structured_output` call (`finishReason: "tool-calls"`) — the tool results
359
- ran locally but never reached the server. Submit the stranded step's
360
- (`result.steps.at(-1)`) locally‑executed client results directly via
361
- `agent.client.submitToolResults(agent.chatId, { results: [{ tool_call_id, output }] })`
362
- before touching the chat again, or it strands as in rule 1; if a pending
363
- call has no local result (or the submit fails), `agent.interrupt()` ends
364
- the stranded turn instead. A settled chat resumes its wind‑down server‑side
365
- for a few seconds, so retry a 409ing `archive()` under a short deadline
366
- instead of giving up.
484
+ ran locally but never reached the server. Guard on `agent.chatId` (it is
485
+ `undefined` until the first turn creates the chat), then submit the
486
+ stranded step's (`result.steps.at(-1)`) locally‑executed client outcomes
487
+ directly via
488
+ `agent.client.submitToolResults(chatId, { results: [{ tool_call_id, output, is_error }] }, AbortSignal.timeout(8_000))`
489
+ before touching the chat again, or it strands as in rule 1. Read the
490
+ outcomes off the step's **content parts**: a `tool-result` part is a
491
+ success, a `tool-error` part (the tool's `execute` threw) must be submitted
492
+ with `is_error: true` — mirroring what the resume path would have sent. If
493
+ a pending call has no local outcome (or the submit fails), end the stranded
494
+ turn with `agent.client.interruptChat(chatId, AbortSignal.timeout(8_000))`
495
+ instead. **Bound every one of these recovery requests with an
496
+ `AbortSignal`** — they target a server that may already be stalled, and the
497
+ bare `agent.interrupt()` / `agent.archive()` helpers carry no timeout. A
498
+ settled chat resumes its wind‑down server‑side for a few seconds, so retry
499
+ a 409ing archive (`agent.client.archiveChat(chatId, signal)`, per‑attempt
500
+ bound) under a short deadline instead of giving up.
367
501
 
368
502
  [`examples/06-structured-output.ts`](./examples/06-structured-output.ts) packages
369
503
  all four rules into a small copyable helper — `structuredOutput(schema)` returns
370
504
  `agentOpts` to spread into the constructor plus a typed `ask(agent, prompt)`
371
505
  that runs the settle + one‑nudge ladder and returns a `z.infer<typeof schema>`.
506
+ Compose additional client tools through the helper —
507
+ `structuredOutput(schema, { tools: { myTool } })` merges them into one ToolSet —
508
+ rather than passing `tools:` to the constructor next to the spread, where the
509
+ later key silently clobbers the other map.
510
+
511
+ ## Workspace previews
512
+
513
+ When the agent is bound to a workspace (the `workspaceId` setting), you can
514
+ resolve — and share — the browser URL where a port on that workspace is served,
515
+ e.g. the dev server the agent just started:
516
+
517
+ ```ts
518
+ const { url } = await agent.getPreview({ port: 3000 });
519
+ // → https://3000--main--dev--alice.apps.example.com (private to the workspace owner)
520
+
521
+ const shared = await agent.sharePreview({ port: 3000, shareLevel: "authenticated" });
522
+ // shared.url is now reachable by any logged-in user; shared.shareLevel is the level in effect
523
+ ```
524
+
525
+ Both are built on the stable v2 workspace APIs (workspace lookup + the wildcard
526
+ apps host; `sharePreview` adds a port‑share upsert), so they work against old
527
+ Coder servers — no experimental endpoints.
528
+
529
+ - `getPreview({ port, agentName?, protocol?, signal? })` composes the subdomain
530
+ URL. The URL honors the port's current share level — private to the workspace
531
+ owner unless shared. `agentName` is optional when the workspace has exactly one
532
+ agent (with several, the error lists the candidates); `protocol: "https"` means
533
+ the app speaks TLS _inside the workspace_ (it adds the `s` label suffix,
534
+ `3000s--…`) and does not affect the browser scheme.
535
+ - `sharePreview({ port, shareLevel?, … })` additionally upserts the port's share
536
+ level (re‑invoking updates it in place) and returns the level in effect.
537
+ `shareLevel` is `"authenticated"` (any logged‑in user; the default),
538
+ `"organization"` (members of the workspace's organization; requires a newer
539
+ Coder server), or `"public"` (no auth at all — mind what the port serves).
540
+ Reverting to owner‑only means deleting the share; `"owner"` is not accepted on
541
+ upsert.
542
+ - Clear failures instead of broken URLs: a deployment without a wildcard access
543
+ URL (`--wildcard-access-url`) yields an explanatory error, and a server that
544
+ predates port sharing (< Coder v2.9) yields a 404 `CoderApiError` saying so.
545
+ Ports below 1000 are rejected up front for the same reason — Coder subdomain
546
+ URLs only encode 4–5 digit ports, so `80--agent--…` would be parsed as an app
547
+ named "80" and never resolve; serve the preview on a higher port.
548
+
549
+ The preview helpers call non‑chat endpoints, so they need `baseUrl` + `token`
550
+ credentials — pass them alongside `client` if you construct one yourself.
372
551
 
373
552
  ## Workspaces & quota
374
553
 
@@ -402,8 +581,18 @@ running many agents at once:
402
581
  | `stopWhen` | AI SDK stop condition(s); default `stepCountIs(64)` |
403
582
  | `maxRetries` | default `0` — SDK retries can duplicate server‑side turns; override with care |
404
583
  | `requestTimeoutMs` | per‑turn time budget (ms); interrupts the run and rejects (`kind: "timeout"`) instead of hanging |
584
+ | `settleDeadlineMs` | overall deadline for bounded cleanup (`archive()` 409 retries, disposal); default 15 000 |
585
+ | `settleRetryDelayMs` | pause between `archive()` retries while the chat settles; default 1000 |
405
586
  | `chatId` | resume an existing chat |
406
587
 
588
+ The `model` hint resolves against the deployment's model configs in order: a
589
+ config UUID is used as‑is, then an exact `provider:model` match, an exact model
590
+ id, a display‑name substring (case‑insensitive), and finally a model‑id
591
+ substring. Partial payloads from older/newer servers are tolerated (entries
592
+ match on the fields they carry), and an unresolvable hint falls back to the
593
+ server's default model instead of failing. Use `agent.listModels()` to see
594
+ what's available.
595
+
407
596
  ## How it works
408
597
 
409
598
  ```