@coder/ai-sdk-agent 0.2.0 → 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
 
@@ -96,6 +98,7 @@ pnpm example:stream # streaming via textStream
96
98
  pnpm example:tool # custom (client-executed) tool round-trip
97
99
  pnpm example:multi-turn # multi-turn session memory
98
100
  pnpm example:file # attach a file to a chat (optional: pass a path)
101
+ pnpm example:structured # typed structured output via the structured_output tool
99
102
  ```
100
103
 
101
104
  Each example creates a new chat and archives it when done — it never touches workspaces. See
@@ -113,6 +116,12 @@ AI SDK tool loop — your `execute` runs in your process.
113
116
  appear in the transcript as `providerExecuted` tool calls/results — you observe them, you
114
117
  don't execute them.
115
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
+
116
125
  ## Files
117
126
 
118
127
  There are two distinct ways to get a file to the agent, depending on whether the model should
@@ -202,13 +211,81 @@ created on the first turn and reused for subsequent `generate()`/`stream()` call
202
211
  conversation with server‑side history). `agent.chatId` is the current chat id.
203
212
 
204
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).
205
- - `agent.interrupt()` — interrupt an in‑flight generation.
206
- - `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)).
207
216
  - `agent.listModels()` — list the deployment's model configs, so you don't have to guess the `model` hint.
208
217
  - Resume a prior chat: `new CoderAgent({ …, chatId: "…" })`.
209
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
+
210
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)).
211
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
+
212
289
  ## Timeouts & cancellation
213
290
 
214
291
  Pass an `abortSignal` to `generate()`/`stream()` to cancel a turn. Aborting
@@ -241,17 +318,28 @@ truncated result as if the turn had finished.
241
318
  ## Cleanup
242
319
 
243
320
  `archive()` soft‑hides the chat (it stays in listings as `archived: true`; there
244
- is no hard delete yet). To make cleanup ride scope exit instead of a `finally` you
245
- 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**:
246
329
 
247
330
  ```ts
248
331
  await using agent = new CoderAgent({
249
332
  /* … */
250
333
  });
251
334
  const { text } = await agent.generate({ prompt: "…" });
252
- // agent.archive() runs automatically when the scope exits (best‑effort).
335
+ // agent.interrupt() + agent.archive() run automatically when the scope exits.
253
336
  ```
254
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
+
255
343
  In a request handler that returns before a fire‑and‑forget `archive()` settles, the
256
344
  archive can be abandoned — `await using` (or an awaited `archive()` in `finally`)
257
345
  avoids accumulating live chats.
@@ -283,15 +371,183 @@ try {
283
371
  SDK‑level retry could duplicate a turn. Prefer catching `retryable` errors and
284
372
  retrying the whole step deliberately.
285
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
+
286
404
  ## Structured output
287
405
 
288
- `CoderAgent` does **not** constrain output to a JSON schema — chatd has no
289
- server‑side `response_format`, so a `responseFormat` / `experimental_output`
290
- request emits a warning and is best‑effort at most. For reliable
291
- schema‑constrained output, use **[`@coder/ai-sdk-provider`](../provider)** with
292
- `generateObject` / `Output.object` (requires AI Gateway enabled on the
293
- deployment). Use the Agent for the steps that need server‑side tools; use the
294
- provider for pure text‑in / JSON‑out steps.
406
+ Coder Agents has no server‑side `response_format`, so `CoderAgent` cannot
407
+ constrain what the model **says** to a JSON schema — a `responseFormat` /
408
+ `experimental_output` request emits a warning and is best‑effort at most. Pick
409
+ by what the step needs:
410
+
411
+ - **Pure text‑in / JSON‑out, no server‑side tools** → use
412
+ **[`@coder/ai-sdk-provider`](../provider)** with `generateObject` /
413
+ `Output.object` (schema‑constrained; requires AI Gateway on the deployment).
414
+ - **The answer must come out of an agent run** (server‑side tools, MCP, a
415
+ workspace) → use the **`structured_output` tool pattern** below. What the
416
+ model _says_ isn't schema‑constrained, but what it passes **into a tool** is
417
+ typed — so have it submit its answer by _calling a tool_ whose `inputSchema`
418
+ is your Zod schema. The answer arrives as the tool call's typed `input`; no
419
+ fishing JSON out of prose.
420
+
421
+ ```ts
422
+ import { stepCountIs, tool } from "ai";
423
+ import { z } from "zod";
424
+
425
+ const Answer = z.object({ severity: z.enum(["critical", "major", "minor"]), summary: z.string() });
426
+
427
+ const agent = new CoderAgent({
428
+ /* … */
429
+ instructions: "… Submit your final answer by calling the structured_output tool exactly once.",
430
+ tools: {
431
+ structured_output: tool({
432
+ description:
433
+ "Submit your final structured answer as JSON. Call this exactly once, when your work is complete.",
434
+ inputSchema: Answer, // your schema IS the tool's input schema
435
+ // Ack instead of stopping the turn: the model finishes naturally and can
436
+ // wind down anything it still has running (dev servers, watchers, …).
437
+ execute: async () =>
438
+ "Output received. Wind down and end your turn. Do not call structured_output again.",
439
+ }),
440
+ },
441
+ stopWhen: stepCountIs(6), // happy path is 2 steps: file + ack, wind down
442
+ });
443
+
444
+ const result = await agent.generate({ prompt: "…" });
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
448
+ .flatMap((s) => s.toolCalls)
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)");
460
+ ```
461
+
462
+ Rules that keep it robust — each guards against a failure mode observed live:
463
+
464
+ 1. **Don't force `toolChoice`, don't stop on the call.** `toolChoice` is
465
+ construction‑time and applies to _every_ segment, so after the ack it would
466
+ force the tool again and again up to the step ceiling (and it blocks any
467
+ other tools the step needs). A `hasToolCall` stop is worse: the server only
468
+ receives a client tool result as a side effect of the _next_ loop segment,
469
+ so ending the loop on the call strands the chat in `requires_action` —
470
+ follow‑up messages queue forever and `archive()` 409s. Instructions plus the
471
+ tool's own description are enough; models file unprompted most of the time.
472
+ 2. **Validate client‑side.** The schema is not enforced server‑side —
473
+ `schema.safeParse` on the tool input is the real gate. (Schema‑invalid calls
474
+ that the AI SDK catches in‑loop are automatically answered with a
475
+ `tool-error` result the model retries against.)
476
+ 3. **Nudge at most once, and only an idle chat.** If the turn ends in prose
477
+ (`finishReason: "stop"`) without a valid call, send one typed re‑prompt
478
+ ("Call the structured_output tool now …"), then fail into your normal error
479
+ handling. Never re‑prompt a chat that isn't idle — the message would queue
480
+ behind whatever the server is still doing.
481
+ 4. **Settle a turn that stopped on a tool call.** If the loop stops on a
482
+ tool‑call step — e.g. your `stopWhen` ceiling lands exactly on the
483
+ `structured_output` call (`finishReason: "tool-calls"`) — the tool results
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.
501
+
502
+ [`examples/06-structured-output.ts`](./examples/06-structured-output.ts) packages
503
+ all four rules into a small copyable helper — `structuredOutput(schema)` returns
504
+ `agentOpts` to spread into the constructor plus a typed `ask(agent, prompt)`
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.
295
551
 
296
552
  ## Workspaces & quota
297
553
 
@@ -325,8 +581,18 @@ running many agents at once:
325
581
  | `stopWhen` | AI SDK stop condition(s); default `stepCountIs(64)` |
326
582
  | `maxRetries` | default `0` — SDK retries can duplicate server‑side turns; override with care |
327
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 |
328
586
  | `chatId` | resume an existing chat |
329
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
+
330
596
  ## How it works
331
597
 
332
598
  ```