@coder/ai-sdk-agent 0.8.0 → 0.9.1

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
@@ -223,9 +223,11 @@ conversation with server‑side history). `agent.chatId` is the current chat id.
223
223
  - `agent.interrupt({ signal? })` — interrupt an in‑flight generation.
224
224
  - `agent.archive({ signal? })` — archive the underlying chat (cleanup; see [Cleanup](#cleanup)).
225
225
  - `agent.listModels()` — list the deployment's model configs, so you don't have to guess the `model` hint.
226
- - Resume a prior chat: `new CoderAgent({ …, chatId: "…" })` — see
227
- [Durable workflows](#durable-workflows-persist-resume-recover) for the full
228
- resumption how‑to.
226
+ - Resume a prior chat: `new CoderAgent({ …, chatId: "…" })` — optionally with
227
+ `lastSeenMessageId` (the persisted resume cursor, read from
228
+ `agent.lastSeenMessageId`) to skip the resumed turn's pre‑prompt history
229
+ probe. See [Durable workflows](#durable-workflows-persist-resume-recover)
230
+ for the full resumption how‑to.
229
231
 
230
232
  Interrupting is asynchronous on the server: `interrupt()` resolves as soon as the
231
233
  interrupt is acknowledged, and the run keeps winding down for a few seconds
@@ -321,7 +323,9 @@ await agent.generate({ prompt: "…" });
321
323
  // e.g. attribute where the turn spent its time:
322
324
  for (const ev of events) {
323
325
  if (ev.type === "http:response")
324
- console.log(`${ev.method} ${ev.path} → ${ev.status} in ${ev.durationMs.toFixed(0)}ms`);
326
+ console.log(
327
+ `${ev.op}: ${ev.method} ${ev.path} → ${ev.status} in ${ev.durationMs.toFixed(0)}ms`,
328
+ );
325
329
  if (ev.type === "ws:event" && ev.event.type === "action_required")
326
330
  console.log(`tool calls arrived at +${ev.timestamp - events[0]!.timestamp}ms`);
327
331
  if (ev.type === "segment:settle")
@@ -335,12 +339,12 @@ such as a message's `created_at`, for delivery‑lag measurements):
335
339
 
336
340
  | event | when | payload (besides `timestamp`) |
337
341
  | ---------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
338
- | `http:request` | a REST request is sent | `id` (correlates the pair), `method`, `path` |
339
- | `http:response` | response headers arrive (incl. non‑2xx, `ok:false`) | `id`, `method`, `path`, `status`, `ok`, `durationMs` |
340
- | `http:error` | the fetch itself rejects (network failure, abort) | `id`, `method`, `path`, `message`, `durationMs` |
342
+ | `http:request` | a REST request is sent | `id` (correlates the pair), `op` (the client operation, e.g. `"createChatMessage"`), `method`, `path` |
343
+ | `http:response` | response headers arrive (incl. non‑2xx, `ok:false`) | `id`, `op`, `method`, `path`, `status`, `ok`, `durationMs` |
344
+ | `http:error` | the fetch itself rejects (network failure, abort) | `id`, `op`, `method`, `path`, `message`, `durationMs` |
341
345
  | `ws:dial` | a stream connection attempt starts | `chatId`, `reader` (identifies the `streamChatEvents` call), `attempt` (1‑based per reader, increments per redial), `url` |
342
346
  | `ws:open` | the WebSocket handshake completes | `chatId`, `reader`, `attempt` |
343
- | `ws:event` | a decoded stream event arrives | `chatId`, `reader`, `attempt`, `event` (the decoded `ChatStreamEvent`, by reference — don't mutate) |
347
+ | `ws:event` | a decoded stream event arrives | `chatId`, `reader`, `attempt`, `event` (the decoded `ChatStreamEvent`, by reference — don't mutate), `forwarded` (the reader's replay verdict) |
344
348
  | `ws:close` | the connection ends (exactly one per dial) | `chatId`, `reader`, `attempt`, `code`/`reason` when the server/network closed it; absent when the reader closed it (settle, teardown, redial) |
345
349
  | `ws:error` | a socket error or unparseable frame | `chatId`, `reader`, `attempt`, `message` |
346
350
  | `ws:redial` | a dropped connection is about to be redialed | `chatId`, `reader`, `attempt` (the ended connection), `consecutiveFailures`, `maxConsecutiveFailures`, `backoffMs` |
@@ -356,9 +360,24 @@ Semantics worth knowing:
356
360
  - **No secrets** — events carry no headers and no tokens (auth travels in the
357
361
  `Coder-Session-Token` header, which is deliberately excluded); `path`/`url`
358
362
  never contain credentials.
359
- - `ws:event` fires at arrival, **before** replay dedup: after a redial, chatd's
360
- replay of the in‑progress episode is visible here (correlate with `reader`/`attempt`)
361
- even though the reader suppresses the duplicates it forwards to the turn.
363
+ - `http:*` events name their operation: `op` is the public `CoderChatClient`
364
+ method performing the exchange (`"createChat"`, `"createChatMessage"`,
365
+ `"getMessages"`, `"submitToolResults"`, … — the `CoderClientOperation`
366
+ union), so per‑operation classification never has to reverse‑engineer
367
+ `path`. `method`/`path` stay for generic consumers; `archiveChat` stamps its
368
+ own `op` even though it issues the same `PATCH` as `updateChat`.
369
+ - `ws:event` fires at arrival: after a redial, chatd's replay of the
370
+ in‑progress episode is visible here (correlate with `reader`/`attempt`),
371
+ stamped with the reader's own replay verdict — `forwarded: false` exactly on
372
+ the duplicate deltas the reader suppresses from the turn, so subscribers
373
+ never re‑derive the episode filter.
374
+ - `forwarded` does **not** reflect snapshot dedup: repeated or revised
375
+ `message` snapshots are always `forwarded: true`, because reconciling them
376
+ is deliberately the consumer's job past the transport layer —
377
+ `TurnTranslator`'s per‑message ledger decides what a revision re‑emits, and
378
+ that disposition is not stamped on transport frames. Use `ws:event` for
379
+ span pairing and replay accounting; subscribers needing content fidelity
380
+ must consume model output (or `TurnTranslator`), not transport frames.
362
381
  - Every `ws:*` event carries `reader` — a monotonic id for the
363
382
  `streamChatEvents` call (reader) behind the connection, allocated from one
364
383
  process‑wide counter so it stays unique across model and client instances.
@@ -436,6 +455,25 @@ for a few seconds, during which archiving 409s — `archive()` retries those 409
436
455
  `settleRetryDelayMs`) and rethrows the last one if the chat never settles. Any
437
456
  other failure, including your own abort, rethrows immediately.
438
457
 
458
+ `archive()` and `interrupt()` target the session's chat — or, after the session
459
+ was dropped (`resetSession()`, or the automatic discard after a fresh‑chat
460
+ stream failure — see [Handling errors](#handling-errors)), the **last‑known
461
+ chat id** (`agent.lastKnownChatId`), so a stranded chat is still cleaned up
462
+ instead of leaking. Generation never uses the last‑known id: a turn after a
463
+ drop creates a fresh chat as always. Every chat stranded by an _automatic_
464
+ discard is also recorded on a ledger (`agent.strandedChatIds`, oldest first) —
465
+ with `maxRetries` several failed attempts can strand one chat each while only
466
+ the final attempt's error surfaces — and one `archive()` retires them all,
467
+ oldest first, alongside its primary target. Both methods report what they
468
+ acted on instead of silently no‑oping: `archive()` resolves
469
+ `{ archived: true, chatId, archivedChatIds }` (each archived id is cleared as
470
+ a cleanup target) or `{ archived: false }` when no chat exists at all;
471
+ `interrupt()` resolves `{ interrupted: true, chatId }` /
472
+ `{ interrupted: false }` the same way. Deliberate abandonment is different:
473
+ `resetSession()` does **not** add to the ledger (you may want that chat kept),
474
+ so after a manual reset the old chat is targetable only until a new chat
475
+ supersedes `lastKnownChatId`.
476
+
439
477
  To make cleanup ride scope exit instead of a `finally` you have to remember, the
440
478
  agent is an **async disposable**:
441
479
 
@@ -469,7 +507,11 @@ carry structured detail you can branch on:
469
507
  repeat (workspace/MCP tooling, fresh attachment uploads); otherwise it is
470
508
  `false` — a retry would resubmit the prompt as a new user turn and could
471
509
  duplicate those effects. A failure mid‑`stream()` surfaces on the stream,
472
- outside the retry wrapper. The last transport failure is in `cause`.
510
+ outside the retry wrapper. The last transport failure is in `cause`, and
511
+ `chatId` names the chat the failed turn had created or attached to (absent
512
+ when it failed before a chat existed) — after the fresh‑chat discard the
513
+ chat still exists server‑side, and `archive()` keeps targeting it via
514
+ `agent.lastKnownChatId` ([Cleanup](#cleanup)).
473
515
 
474
516
  ```ts
475
517
  import { CoderApiError, CoderChatError } from "@coder/ai-sdk-agent";
@@ -843,24 +885,25 @@ sequence pinpoints _where_ a turn died:
843
885
 
844
886
  `CoderAgentSettings`:
845
887
 
846
- | field | description |
847
- | --------------------------------- | ------------------------------------------------------------------------------------------------ |
848
- | `client` \| (`baseUrl` + `token`) | connection (one or the other; `baseUrl`/`token` default from `CODER_URL`/`CODER_SESSION_TOKEN`) |
849
- | `organizationId` | org UUID that owns the chat (required) |
850
- | `model` | model hint: UUID, `provider:model`, model id, or display‑name substring |
851
- | `instructions` | system prompt |
852
- | `tools` | AI SDK `ToolSet` (client‑executed) |
853
- | `workspaceId` | bind the chat to a Coder workspace (enables workspace‑scoped tools) |
854
- | `workspaceFiles` | adapter enabling `uploadToWorkspace()` (write files to the workspace FS) |
855
- | `mcpServerIds` | server‑side MCP servers to enable |
856
- | `planMode` | enable plan mode (`"plan"`) |
857
- | `stopWhen` | AI SDK stop condition(s); default `stepCountIs(64)` |
858
- | `maxRetries` | default `0` — SDK retries can duplicate server‑side turns; override with care |
859
- | `requestTimeoutMs` | per‑turn time budget (ms); interrupts the run and rejects (`kind: "timeout"`) instead of hanging |
860
- | `onTransportEvent` | observability hook for typed transport events (see [Observability](#observability)) |
861
- | `settleDeadlineMs` | overall deadline for bounded cleanup (`archive()` 409 retries, disposal); default 15 000 |
862
- | `settleRetryDelayMs` | pause between `archive()` retries while the chat settles; default 1000 |
863
- | `chatId` | resume an existing chat |
888
+ | field | description |
889
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
890
+ | `client` \| (`baseUrl` + `token`) | connection (one or the other; `baseUrl`/`token` default from `CODER_URL`/`CODER_SESSION_TOKEN`) |
891
+ | `organizationId` | org UUID that owns the chat (required) |
892
+ | `model` | model hint: UUID, `provider:model`, model id, or display‑name substring |
893
+ | `instructions` | system prompt |
894
+ | `tools` | AI SDK `ToolSet` (client‑executed) |
895
+ | `workspaceId` | bind the chat to a Coder workspace (enables workspace‑scoped tools) |
896
+ | `workspaceFiles` | adapter enabling `uploadToWorkspace()` (write files to the workspace FS) |
897
+ | `mcpServerIds` | server‑side MCP servers to enable |
898
+ | `planMode` | enable plan mode (`"plan"`) |
899
+ | `stopWhen` | AI SDK stop condition(s); default `stepCountIs(64)` |
900
+ | `maxRetries` | default `0` — SDK retries can duplicate server‑side turns; override with care |
901
+ | `requestTimeoutMs` | per‑turn time budget (ms); interrupts the run and rejects (`kind: "timeout"`) instead of hanging |
902
+ | `onTransportEvent` | observability hook for typed transport events (see [Observability](#observability)) |
903
+ | `settleDeadlineMs` | overall deadline for bounded cleanup (`archive()` 409 retries, disposal); default 15 000 |
904
+ | `settleRetryDelayMs` | pause between `archive()` retries while the chat settles; default 1000 |
905
+ | `chatId` | resume an existing chat |
906
+ | `lastSeenMessageId` | resume cursor for `chatId` (persist `agent.lastSeenMessageId`) — skips the resumed turn's pre‑prompt history probe |
864
907
 
865
908
  The `model` hint resolves against the deployment's model configs in order: a
866
909
  config UUID is used as‑is, then an exact `provider:model` match, an exact model
@@ -903,7 +946,9 @@ Two facts make the pattern work:
903
946
 
904
947
  - **All chat state lives on the Coder server.** Messages, tool activity, the
905
948
  run itself — none of it is in your process. The only durable thing a
906
- workflow has to carry between steps is **`agent.chatId`: a string.**
949
+ workflow has to carry between steps is **`agent.chatId`: a string** — plus,
950
+ optionally, **`agent.lastSeenMessageId`: the resume cursor**, which saves the
951
+ resumed turn one serial round‑trip (below).
907
952
  - **`CoderAgent` can't ride a `fetch`‑shim durability layer** (it talks REST +
908
953
  WebSocket through its own client), so each turn runs **inside** a durable
909
954
  step — and the checkpointed chat id is the thread between steps.
@@ -916,18 +961,29 @@ on another machine, hours apart, retried after failures.
916
961
  ```ts
917
962
  import { CoderAgent, type CoderTransportEvent } from "@coder/ai-sdk-agent";
918
963
 
964
+ interface WorkflowCheckpoint {
965
+ chatId: string;
966
+ /** The resume cursor — pairs with chatId, never persisted without it. */
967
+ lastSeenMessageId?: number;
968
+ }
969
+
919
970
  // Any durable KV your engine gives you: step state, a job row, a DB table.
920
971
  declare const checkpoints: {
921
- get(workflowId: string): Promise<string | undefined>;
922
- set(workflowId: string, chatId: string): Promise<void>;
972
+ get(workflowId: string): Promise<WorkflowCheckpoint | undefined>;
973
+ set(workflowId: string, checkpoint: WorkflowCheckpoint): Promise<void>;
923
974
  };
924
975
 
925
976
  export async function runTurn(workflowId: string, prompt: string): Promise<string> {
977
+ const checkpoint = await checkpoints.get(workflowId); // undefined on the first step → the turn creates the chat
926
978
  const agent = new CoderAgent({
927
979
  baseUrl: process.env.CODER_URL!,
928
980
  token: process.env.CODER_SESSION_TOKEN!, // read per step — never checkpoint or log it
929
981
  organizationId: process.env.CODER_ORG_ID!,
930
- chatId: await checkpoints.get(workflowId), // undefined on the first step → the turn creates the chat
982
+ chatId: checkpoint?.chatId,
983
+ // The previous step's cursor lets this step skip its pre-prompt history
984
+ // probe; absent (first resume, older checkpoints), the turn seeds it from
985
+ // the chat's newest message instead — one extra GET, same result.
986
+ lastSeenMessageId: checkpoint?.lastSeenMessageId,
931
987
  requestTimeoutMs: 300_000, // always bound workflow steps — see below
932
988
  onTransportEvent: observe, // per-step telemetry — see below
933
989
  });
@@ -946,9 +1002,19 @@ export async function runTurn(workflowId: string, prompt: string): Promise<strin
946
1002
  // still persist the id, or the retried step orphans a live chat and its
947
1003
  // partial effects. Exception: after an exhausted stream failure on a
948
1004
  // chat this very call created, the SDK has already discarded the dead
949
- // session (agent.chatId is undefined) and nothing is written — see the
1005
+ // session (agent.chatId is undefined) and nothing is written — the thrown
1006
+ // CoderStreamError's chatId still names the stranded chat, and
1007
+ // agent.archive() retires it via agent.lastKnownChatId — see the
950
1008
  // CoderStreamError notes below for what retrying means then.
951
- if (agent.chatId) await checkpoints.set(workflowId, agent.chatId);
1009
+ if (agent.chatId) {
1010
+ const next: WorkflowCheckpoint = { chatId: agent.chatId };
1011
+ // The cursor is optional sugar: persist it when a turn established one
1012
+ // (a turn that failed before streaming may not have), skip it otherwise.
1013
+ if (agent.lastSeenMessageId !== undefined) {
1014
+ next.lastSeenMessageId = agent.lastSeenMessageId;
1015
+ }
1016
+ await checkpoints.set(workflowId, next);
1017
+ }
952
1018
  }
953
1019
  }
954
1020
  ```
@@ -981,12 +1047,16 @@ await archiveWorkflowChat("wf-1042");
981
1047
 
982
1048
  What resuming does (and doesn't do):
983
1049
 
984
- - **Construction is free.** `new CoderAgent({ …, chatId })` performs no I/O
985
- and no validation — a bad or archived id fails the _turn_, not the
986
- constructor.
1050
+ - **Construction is free.** `new CoderAgent({ …, chatId })` performs no I/O —
1051
+ a bad or archived id fails the _turn_, not the constructor. (The one
1052
+ exception is local validation of `lastSeenMessageId`: a cursor that isn't a
1053
+ positive integer, or arrives without its `chatId`, throws immediately.)
987
1054
  - **The first resumed turn seeds its cursor** from the chat's newest message
988
1055
  (one single‑message history probe), so it streams only its own events —
989
- it never re‑absorbs earlier turns' content or usage.
1056
+ it never re‑absorbs earlier turns' content or usage. Supplying the
1057
+ checkpointed `lastSeenMessageId` skips that probe: the previous step already
1058
+ knew the cursor, so the resumed prompt goes out without any pre‑prompt
1059
+ round‑trip.
990
1060
  - **Re‑supply process‑local configuration.** Client `tools` live in your
991
1061
  process, not on the server, so each step that expects tool calls must pass
992
1062
  them again. The workspace binding is the opposite: fixed at creation,
@@ -994,7 +1064,10 @@ What resuming does (and doesn't do):
994
1064
  - **Sessions stay single‑flight across the whole system**, not just within one
995
1065
  process: run steps strictly sequentially per chat. A second job posting to
996
1066
  the same chat queues behind the live run and burns its own
997
- `requestTimeoutMs` waiting.
1067
+ `requestTimeoutMs` waiting — it attributes nothing of the live run's output
1068
+ to itself (its window opens only when its own queued prompt materializes),
1069
+ and if it times out first, it withdraws its queued prompt rather than
1070
+ leaving it to run unattended.
998
1071
 
999
1072
  ### Let the SDK absorb drops — and handle what it re‑throws
1000
1073
 
@@ -1063,12 +1136,15 @@ try {
1063
1136
  the dead session so a retry — `maxRetries` or a re‑run step — starts clean
1064
1137
  on a fresh chat. The discard happens for _every_ chat the failed turn
1065
1138
  itself created — **including the effectful, `isRetryable: false` case**:
1066
- the id is gone, the `finally` checkpoint writes nothing, and the
1067
- interrupted chat — whose workspace/MCP tools may already have acted —
1068
- becomes an unacknowledged orphan for the reconciliation sweep (below). A
1069
- retried step then starts a fresh chat that has _not_ seen the orphan's
1070
- effects: gate that retry on the same idempotency judgment as any
1071
- re‑submission. **On a resumed chat it is always `isRetryable: false`:**
1139
+ `agent.chatId` is `undefined` and the `finally` checkpoint writes nothing —
1140
+ but the stranded chat is not nameless: the error's **`chatId`** field names
1141
+ it, and `interrupt()`/`archive()` keep targeting it via
1142
+ `agent.lastKnownChatId` ([Cleanup](#cleanup)), so the step's error path can
1143
+ retire the orphan directly — or checkpoint `err.chatId` for the
1144
+ reconciliation sweep (below) when its workspace/MCP effects need
1145
+ reconciling first. A retried step then starts a fresh chat that has _not_
1146
+ seen the orphan's effects: gate that retry on the same idempotency judgment
1147
+ as any re‑submission. **On a resumed chat it is always `isRetryable: false`:**
1072
1148
  re‑running the step resubmits the same prompt as a _new user turn_, with the
1073
1149
  failed attempt's partial output still in history (usually fine — the model
1074
1150
  sees its own aborted attempt), and any workspace/MCP tool effects that ran
@@ -1117,9 +1193,11 @@ checkpoint:
1117
1193
  turns into resuming the dead chat.)
1118
1194
  - **Crashed after the checkpoint** — the chat is resumable, but don't just
1119
1195
  resubmit. The crashed attempt's run may still be live server‑side, and a
1120
- resumed turn seeds its message cursor _before_ submitting — whatever the
1121
- old run commits after that point would be absorbed into the resumed turn's
1122
- output and usage. A crash _inside one of your client tools_ is worse: the
1196
+ resubmission then queues behind it: nothing the old run commits is
1197
+ attributed to the resumed turn (its window opens only at its own queued
1198
+ prompt's materialization), but the step burns its `requestTimeoutMs`
1199
+ waiting for the old run to finish first — and a timeout withdraws the
1200
+ queued prompt again. A crash _inside one of your client tools_ is worse: the
1123
1201
  chat is paused in `requires_action`, where new messages wait forever — and
1124
1202
  if the tool's external effect committed before the crash, that pause is the
1125
1203
  only pending record of it, so reconcile the tool ledger **before** any