@coder/ai-sdk-agent 0.6.0 → 0.6.2

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
@@ -214,7 +214,9 @@ conversation with server‑side history). `agent.chatId` is the current chat id.
214
214
  - `agent.interrupt({ signal? })` — interrupt an in‑flight generation.
215
215
  - `agent.archive({ signal? })` — archive the underlying chat (cleanup; see [Cleanup](#cleanup)).
216
216
  - `agent.listModels()` — list the deployment's model configs, so you don't have to guess the `model` hint.
217
- - Resume a prior chat: `new CoderAgent({ …, chatId: "…" })`.
217
+ - Resume a prior chat: `new CoderAgent({ …, chatId: "…" })` — see
218
+ [Durable workflows](#durable-workflows-persist-resume-recover) for the full
219
+ resumption how‑to.
218
220
 
219
221
  Interrupting is asynchronous on the server: `interrupt()` resolves as soon as the
220
222
  interrupt is acknowledged, and the run keeps winding down for a few seconds
@@ -322,19 +324,19 @@ for (const ev of events) {
322
324
  `timestamp` (`Date.now()` at observation — comparable to server‑side timestamps
323
325
  such as a message's `created_at`, for delivery‑lag measurements):
324
326
 
325
- | event | when | payload (besides `timestamp`) |
326
- | ---------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
327
- | `http:request` | a REST request is sent | `id` (correlates the pair), `method`, `path` |
328
- | `http:response` | response headers arrive (incl. non‑2xx, `ok:false`) | `id`, `method`, `path`, `status`, `ok`, `durationMs` |
329
- | `http:error` | the fetch itself rejects (network failure, abort) | `id`, `method`, `path`, `message`, `durationMs` |
330
- | `ws:dial` | a stream connection attempt starts | `chatId`, `attempt` (1‑based, increments per redial), `url` |
331
- | `ws:open` | the WebSocket handshake completes | `chatId`, `attempt` |
332
- | `ws:event` | a decoded stream event arrives | `chatId`, `attempt`, `event` (the decoded `ChatStreamEvent`, by reference — don't mutate) |
333
- | `ws:close` | the connection ends (exactly one per dial) | `chatId`, `attempt`, `code`/`reason` when the server/network closed it; absent when the reader closed it (settle, teardown, redial) |
334
- | `ws:error` | a socket error or unparseable frame | `chatId`, `attempt`, `message` |
335
- | `ws:redial` | a dropped connection is about to be redialed | `chatId`, `attempt` (the ended connection), `consecutiveFailures`, `maxConsecutiveFailures`, `backoffMs` |
336
- | `segment:start` | a turn segment (one model round‑trip) starts | `segment` (1‑based per model instance), `chatId` (absent before the first turn creates the chat) |
337
- | `segment:settle` | the segment ends (exactly one per start) | `segment`, `chatId`, and: `status` + `finishReason` on a clean settle, `error` (`{name, message}`, plus `status` if the run still settled terminally) on failure, neither on a teardown |
327
+ | event | when | payload (besides `timestamp`) |
328
+ | ---------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
329
+ | `http:request` | a REST request is sent | `id` (correlates the pair), `method`, `path` |
330
+ | `http:response` | response headers arrive (incl. non‑2xx, `ok:false`) | `id`, `method`, `path`, `status`, `ok`, `durationMs` |
331
+ | `http:error` | the fetch itself rejects (network failure, abort) | `id`, `method`, `path`, `message`, `durationMs` |
332
+ | `ws:dial` | a stream connection attempt starts | `chatId`, `attempt` (1‑based, increments per redial), `url` |
333
+ | `ws:open` | the WebSocket handshake completes | `chatId`, `attempt` |
334
+ | `ws:event` | a decoded stream event arrives | `chatId`, `attempt`, `event` (the decoded `ChatStreamEvent`, by reference — don't mutate) |
335
+ | `ws:close` | the connection ends (exactly one per dial) | `chatId`, `attempt`, `code`/`reason` when the server/network closed it; absent when the reader closed it (settle, teardown, redial) |
336
+ | `ws:error` | a socket error or unparseable frame | `chatId`, `attempt`, `message` |
337
+ | `ws:redial` | a dropped connection is about to be redialed | `chatId`, `attempt` (the ended connection), `consecutiveFailures`, `maxConsecutiveFailures`, `backoffMs` |
338
+ | `segment:start` | a turn segment (one model round‑trip) starts | `segment` (1‑based per model instance), `chatId` (absent before the first turn creates the chat) |
339
+ | `segment:settle` | the segment ends (exactly one per start) | `segment`, `chatId`, `durationMs`, and: `status` + `finishReason` on a clean settle, `error` (`{name, message}`, plus `status` if the run still settled terminally) on failure, neither on a teardown |
338
340
 
339
341
  Semantics worth knowing:
340
342
 
@@ -668,16 +670,154 @@ credentials — pass them alongside `client` if you construct one yourself.
668
670
  ## Workspaces & quota
669
671
 
670
672
  A `CoderAgent` is one server‑side chat, and — depending on its configuration and
671
- the deployment — a chat may provision a **Coder workspace** to run its tools. A
672
- deployment caps how many workspaces an account may run at once, so **N agents
673
- running concurrently can need N free workspace slots.** Past the cap, a turn can
674
- sit unscheduled and never settle. This is the most important operational fact when
675
- running many agents at once:
676
-
677
- - Keep your own concurrency below the deployment's workspace limit.
678
- - Set `requestTimeoutMs` so an unschedulable turn fails loudly instead of hanging.
679
- - `archive()` / `await using` each agent so finished chats stop holding resources.
680
- - For steps that don't need server‑side tools, prefer the provider — it never touches a workspace.
673
+ the deployment — a chat may be backed by a **Coder workspace** that runs its
674
+ tools. Workspaces are the scarce resource: a deployment budgets how many an
675
+ account may run at once, so **N agents running concurrently can need N
676
+ schedulable workspaces.** Past that bound, a turn can sit unscheduled and never
677
+ settle. This section is the operational guide for running fleets of agents:
678
+ how the binding works, how to size concurrency, what to clean up, and how to
679
+ diagnose a chat that is stuck.
680
+
681
+ ### How a chat binds to a workspace
682
+
683
+ - **One chat, at most one workspace, fixed at creation.** `workspaceId` is sent
684
+ as `workspace_id` when the chat is created; nothing can rebind it afterwards —
685
+ message and update requests carry no workspace field. To move work to another
686
+ workspace, start a new agent/chat.
687
+ - **This SDK never provisions workspaces.** A `workspaceId` you pass must be an
688
+ existing workspace (provision one with
689
+ [`@coder/ai-sdk-sandbox`](../sandbox)'s `ensureCoderWorkspace`, the CLI, or
690
+ the v2 API). A chat created _without_ `workspaceId` can still come back
691
+ workspace‑backed — deployments may assign one server‑side; the SDK reads the
692
+ created chat's `workspace_id` and treats the chat as workspace‑backed from
693
+ then on (which matters for retry ownership, below).
694
+ - **Chat cleanup does not release the workspace.** `archive()` soft‑hides the
695
+ chat only; the workspace keeps running until template autostop or an explicit
696
+ stop — and stopping releases only stop‑scoped quota; persistent resources
697
+ (disks, volumes) keep consuming their cost until the workspace is deleted.
698
+
699
+ ### Sizing a fleet
700
+
701
+ The structural rule: **workspaces that must be running concurrently ≤
702
+ schedulable workspaces.** With one workspace per chat — the common fleet shape,
703
+ and what a deployment that auto‑assigns workspaces produces — that means
704
+ concurrent chats ≤ schedulable workspaces. Chats explicitly bound to a shared
705
+ `workspaceId` count that workspace once, so their concurrency is not
706
+ quota‑bound — at the price of sharing one filesystem and tool environment,
707
+ which is only acceptable within a single tenant / trust boundary.
708
+ What counts as "schedulable" is a deployment property, not an SDK knob —
709
+ whichever of these binds first:
710
+
711
+ - **Workspace quota (premium deployments).** Templates declare per‑resource
712
+ costs; a user's budget is the sum of their groups' quota allowances, enforced
713
+ when a workspace build starts or stops. A start that would exceed the budget
714
+ **fails the build** (error code `INSUFFICIENT_QUOTA`, "insufficient quota"),
715
+ so the turn never gets its workspace — see
716
+ [resource quotas](https://coder.com/docs/admin/users/quotas). Note that a
717
+ _stopped_ workspace typically still consumes its persistent resources' cost,
718
+ so a fleet that only ever stops (never deletes) scratch workspaces converges
719
+ on a full budget.
720
+ - **Infrastructure.** Without quotas there is no per‑user workspace limit by
721
+ default ([workspace lifecycle](https://coder.com/docs/user-guides/workspace-lifecycle)) —
722
+ the bound is provisioner throughput and cluster capacity, and exceeding it
723
+ looks like slow or failing builds rather than a crisp quota error.
724
+
725
+ Practical sizing:
726
+
727
+ - Read headroom before fanning out:
728
+ `GET /api/v2/organizations/{org}/members/{user}/workspace-quota` returns
729
+ `{ "credits_consumed": …, "budget": … }`. Quota is denominated in credits,
730
+ not slots: admit another workspace only while `budget − credits_consumed`
731
+ covers _that workspace's_ cost (the sum of its template's `daily_cost`
732
+ declarations). "Free slots = headroom ÷ cost" only holds for a homogeneous
733
+ fleet on one template; with mixed templates, size against each planned
734
+ workspace's own cost.
735
+ - Keep fan‑out width within that headroom and queue the rest client‑side — an
736
+ unschedulable turn does not queue usefully on the server (see
737
+ [Preventing stuck turns](#preventing-stuck-turns)).
738
+ - Reuse one bound workspace across sequential turns and sessions instead of
739
+ provisioning per request — the workspace is the expensive part, the chat is
740
+ cheap. Reuse only **within one tenant / trust boundary**: workspace‑bound
741
+ agents have file and shell tools, so a reused filesystem carries one
742
+ session's artifacts (and secrets) into the next — provision per tenant, or
743
+ securely reset a workspace before reassigning it.
744
+ - Steps that don't need server‑side tools belong on the
745
+ [provider](../provider) — it never touches a workspace.
746
+
747
+ ### Autostop & cleanup
748
+
749
+ Two lifetimes to manage, separately:
750
+
751
+ - **Chats** — `archive()` / `await using` every agent ([Cleanup](#cleanup)), or
752
+ finished chats keep holding server resources.
753
+ - **Workspaces** — rely on template‑level scheduling rather than manual
754
+ hygiene:
755
+ - **Autostop TTL.** Give fleet templates a default TTL long enough to survive
756
+ a normal session (including idle gaps between turns), short enough that a
757
+ leaked workspace stops burning running‑cost within hours — without
758
+ autostop, a leaked workspace pins its full quota until someone notices.
759
+ Note that stopping only releases the quota of resources that go away on
760
+ stop; persistent resources (disks, volumes) keep consuming their
761
+ `daily_cost`, so a scratch fleet that only ever stops still converges on a
762
+ full budget — pair the TTL with dormancy auto‑deletion or explicit
763
+ deletion. With [`@coder/ai-sdk-sandbox`](../sandbox), `stopAfter: "8h"`
764
+ sets the TTL (`ttl_ms`) at creation.
765
+ - **Activity bump** (default 1 h) extends a running workspace's deadline when
766
+ Coder detects sessions — check
767
+ [what counts as activity](https://coder.com/docs/user-guides/workspace-scheduling)
768
+ before assuming an agent's server‑side tool use keeps its workspace alive.
769
+ - **Dormancy / failure cleanup** reap abandoned and repeatedly‑failing
770
+ workspaces automatically — see
771
+ [template scheduling](https://coder.com/docs/admin/templates/managing-templates/schedule).
772
+
773
+ ### Preventing stuck turns
774
+
775
+ The signature failure mode of an over‑committed fleet: the chat is created, the
776
+ stream opens, and then — nothing. There is **no distinct "quota exceeded" error
777
+ kind on the chat stream**; a chat whose workspace can't be scheduled surfaces
778
+ either a generic turn error or, worse, a chat that sits in a non‑terminal
779
+ status indefinitely. Defend in this order:
780
+
781
+ 1. **Set `requestTimeoutMs` — always, in fleets.** It is unbounded by default.
782
+ On expiry the call rejects with a `CoderChatError` (`kind: "timeout"`,
783
+ `retryable: true`) so your dispatcher gets its slot back, and a server‑side
784
+ interrupt is fired **best‑effort** — fire‑and‑forget, and unreachable when
785
+ the timeout lands before chat creation returned an id — so the run usually
786
+ stops, but is not guaranteed released ([Timeouts](#timeouts--cancellation)).
787
+ Pair it with reconciliation: periodically sweep for non‑terminal chats older
788
+ than your budget and interrupt/archive them.
789
+ 2. **Cap total wall‑clock** with `abortSignal: AbortSignal.timeout(…)` —
790
+ `requestTimeoutMs` bounds each segment, not a whole multi‑tool call.
791
+ 3. **Own the retries.** Workspace‑backed turns are **never auto‑retried**: the
792
+ SDK marks a stream‑loss error `isRetryable` only when the failed turn
793
+ created its chat _and_ had no workspace, no MCP servers, and no fresh
794
+ uploads ([Timeouts](#timeouts--cancellation)). Your dispatcher owns the
795
+ retry decision — and should re‑check quota headroom first, or it re‑queues
796
+ into the same wall.
797
+ 4. **Watch the fleet.** [`watchChats`](#watching-chats) yields status changes
798
+ for every chat **visible to the authenticated user** — with chats spread
799
+ across per‑tenant credentials, run one watcher per identity (or watch with
800
+ a credential that can see them all). Alert on chats sitting in a
801
+ non‑terminal status (e.g. `pending`) longer than your `requestTimeoutMs`.
802
+ Events alone aren't a complete monitor: reconnects resubscribe fresh (no
803
+ cursor or replay), so a chat already stuck before the watcher started — or
804
+ one that transitioned during a gap — never emits an event to start your
805
+ timer from. Seed and periodically reconcile against a chat listing
806
+ (`GET /api/experimental/chats`) instead of trusting the event stream alone.
807
+
808
+ ### Troubleshooting: unschedulable & stuck chats
809
+
810
+ Wire [`onTransportEvent`](#observability) into fleet telemetry — the event
811
+ sequence pinpoints _where_ a turn died:
812
+
813
+ | symptom (transport events) | likely cause | fix |
814
+ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
815
+ | `segment:start` and `ws:open` fired, initial status events, then silence — no `message_part`, no `segment:settle` | if the chat's status is `pending` (or its workspace build is `pending`/`failed`): the workspace can't be scheduled — quota exhausted, no free provisioner, failing template. If the status is `running` with a healthy build, it may just be a slow model/tool step — silence alone doesn't prove scheduling | check quota headroom (`workspace-quota` endpoint) and the workspace's build — a quota failure logs `INSUFFICIENT_QUOTA`; reclaim enough _credits_ for the planned build (stopping helps only for stop‑scoped costs — delete workspaces to reclaim persistent costs, or raise the group allowance) and set `requestTimeoutMs` so this fails loudly next time |
816
+ | `segment:settle` carries `error: { name: "CoderChatError", message: "…requestTimeoutMs budget…" }` | the per‑segment bound expired — wedged server, slow model, or an unschedulable workspace | inspect the workspace via the v2 API/UI: a `pending`/`failed` build means the row above; `running` means the turn was genuinely slow — raise `requestTimeoutMs` for long tool work |
817
+ | repeated `ws:redial` with `consecutiveFailures` climbing toward `maxConsecutiveFailures` (5; backoff 1 s → 2 s → 4 s → 8 s, ≈15 s of redialing without forward progress), then a `CoderStreamError` | the network path to the deployment is failing — not workspace scheduling (the server keeps generating through short gaps) | fix connectivity; mind retry ownership above — on workspace‑backed chats the error is `isRetryable: false`, so the replay decision is yours |
818
+ | `segment:settle` with `status: "error"` and an `error` payload | the turn failed server‑side — a provider/model error, a tool failure, or a scheduling/build failure that terminated the turn instead of leaving it pending | the settle event's `error` carries only `{ name, message }` — branch on `kind` / `retryable` / `statusCode` by catching the thrown `CoderChatError`: at the `generate()` call site, or — for `stream()` — around stream consumption, since mid‑stream failures surface on the stream, not from `await agent.stream()` ([Handling errors](#handling-errors)); check the workspace build state to rule scheduling in or out |
819
+ | turn settled `status: "requires_action"`, follow‑up messages queue forever | the loop ended on an unanswered client tool call | submit the stranded results or interrupt — see rule 4 under [Structured output](#structured-output) |
820
+ | `archive()` keeps returning 409 and rethrows after ~15 s | the chat never settled server‑side — usually a stuck run still holding its workspace | `interrupt()` with a bounded signal, then re‑archive; if the run stays wedged, stop the workspace itself |
681
821
 
682
822
  ## Configuration
683
823
 
@@ -723,30 +863,373 @@ CoderAgent (implements ai.Agent)
723
863
  - One `doStream` call advances the chat until it **settles** (`waiting`/`completed`) or
724
864
  **pauses** for a client tool (`requires_action`). The SDK loop and the server‑side loop
725
865
  mesh at the client‑tool boundary, so there's no double loop.
726
- - Streaming text is emitted from `message_part` deltas; fast turns that only produce a full
727
- `message` snapshot are diffed against an emitted‑length cursor — so neither double‑counts.
728
-
729
- ## Durable workflows (Vercel Workflow, step functions, …)
730
-
731
- `CoderAgent` talks to Coder over its own REST + WebSocket client, so it can't ride
732
- a `fetch`‑shim durability layer — each turn must run **inside** a durable step. A
733
- few rules keep it well‑behaved across replays:
734
-
735
- - **One turn per step.** Create the agent, run a single `generate()` (not
736
- `stream()`, so the checkpointed value is the finished result), return.
737
- - **Don't persist the instance across steps.** Persist `agent.chatId` (a string)
738
- and resume with `new CoderAgent({ …, chatId })` in the next step. Never persist
739
- or log the token — read it from the environment in each step.
740
- - **Clean up in the step.** `await using` the agent (or `await agent.archive()` in
741
- a `finally`) so a step that returns early doesn't abandon the chat.
742
- - **Bound each step.** Set `requestTimeoutMs` so a wedged turn fails the step (and
743
- lets the workflow retry) instead of hanging the whole run.
744
- - **Mind concurrency vs. workspaces.** Keep fan‑out width under the deployment's
745
- workspace cap — see [Workspaces & quota](#workspaces--quota).
746
- - **Use the provider for pure steps.** Steps that don't need server‑side tools
747
- (plan / extract / synthesize) are cheaper and natively structured through
866
+ - Streaming text is emitted from `message_part` deltas; every `message` snapshot is then
867
+ reconciled against a per‑message emitted‑content ledger, so nothing double‑counts: a
868
+ trailing snapshot after deltas is a no‑op, a fast snapshot‑only turn emits in full, a
869
+ message that commits while the stream is redialing yields exactly its missing tail, and a
870
+ revision that appends to an earlier message yields the appended suffix (rewrites that
871
+ can't be expressed as deltas are safely suppressed).
872
+
873
+ ## Durable workflows: persist, resume, recover
874
+
875
+ How to run **one agent session across process boundaries** — queue jobs,
876
+ durable‑workflow steps (Vercel Workflow, step functions, Temporal, …), cron
877
+ ticks — and survive the crashes, stream drops, and timeouts in between. The
878
+ mechanics this how‑to leans on are specified in [Sessions](#sessions),
879
+ [Timeouts & cancellation](#timeouts--cancellation),
880
+ [Handling errors](#handling-errors), and [Observability](#observability).
881
+
882
+ Two facts make the pattern work:
883
+
884
+ - **All chat state lives on the Coder server.** Messages, tool activity, the
885
+ run itself — none of it is in your process. The only durable thing a
886
+ workflow has to carry between steps is **`agent.chatId`: a string.**
887
+ - **`CoderAgent` can't ride a `fetch`‑shim durability layer** (it talks REST +
888
+ WebSocket through its own client), so each turn runs **inside** a durable
889
+ step — and the checkpointed chat id is the thread between steps.
890
+
891
+ Running example: a pipeline whose steps each run as their own job — possibly
892
+ on another machine, hours apart, retried after failures.
893
+
894
+ ### Shape each step: one turn, then checkpoint
895
+
896
+ ```ts
897
+ import { CoderAgent, type CoderTransportEvent } from "@coder/ai-sdk-agent";
898
+
899
+ // Any durable KV your engine gives you: step state, a job row, a DB table.
900
+ declare const checkpoints: {
901
+ get(workflowId: string): Promise<string | undefined>;
902
+ set(workflowId: string, chatId: string): Promise<void>;
903
+ };
904
+
905
+ export async function runTurn(workflowId: string, prompt: string): Promise<string> {
906
+ const agent = new CoderAgent({
907
+ baseUrl: process.env.CODER_URL!,
908
+ token: process.env.CODER_SESSION_TOKEN!, // read per step — never checkpoint or log it
909
+ organizationId: process.env.CODER_ORG_ID!,
910
+ chatId: await checkpoints.get(workflowId), // undefined on the first step → the turn creates the chat
911
+ requestTimeoutMs: 300_000, // always bound workflow steps — see below
912
+ onTransportEvent: observe, // per-step telemetry — see below
913
+ });
914
+
915
+ try {
916
+ const { text } = await agent.generate({
917
+ prompt,
918
+ // Total wall-clock for the step: requestTimeoutMs bounds each segment,
919
+ // not a whole multi-segment (client-tool) turn — see "Bound every step".
920
+ abortSignal: AbortSignal.timeout(600_000),
921
+ });
922
+ return text;
923
+ } finally {
924
+ // The durable resume handle — written even when the turn failed: a first
925
+ // step that fails AFTER creating its chat (timeout, stream loss) must
926
+ // still persist the id, or the retried step orphans a live chat and its
927
+ // partial effects. Exception: after an exhausted stream failure on a
928
+ // chat this very call created, the SDK has already discarded the dead
929
+ // session (agent.chatId is undefined) and nothing is written — see the
930
+ // CoderStreamError notes below for what retrying means then.
931
+ if (agent.chatId) await checkpoints.set(workflowId, agent.chatId);
932
+ }
933
+ }
934
+ ```
935
+
936
+ The shape matters:
937
+
938
+ - **One turn per step, `generate()` not `stream()`.** The checkpointed unit is
939
+ a finished result — and a mid‑`stream()` failure surfaces on the stream,
940
+ outside `generate()`'s error contract, which complicates step retry logic.
941
+ - **Persist the id, not the instance** (and never the token — read it from
942
+ each step's environment).
943
+ - **Checkpoint in `finally`, not only on success**, so failed-but-alive chats
944
+ stay reachable. What remains is the hard-crash window between chat creation
945
+ and the checkpoint write (below).
946
+ - **No `await using` here.** The chat outlives the step; disposal archives it
947
+ and archiving ends resumability (below).
948
+
949
+ ### Resume in the next step — any process, any machine
950
+
951
+ ```ts
952
+ // Step 1 — a queue job on machine A:
953
+ await runTurn("wf-1042", "Investigate the failing nightly build and propose a fix.");
954
+
955
+ // Step 2 — hours later, on machine B: same chat, full server-side history.
956
+ await runTurn("wf-1042", "Apply the fix you proposed and summarize what changed.");
957
+
958
+ // Final step — cleanup belongs to the workflow, not to each step (see below).
959
+ await archiveWorkflowChat("wf-1042");
960
+ ```
961
+
962
+ What resuming does (and doesn't do):
963
+
964
+ - **Construction is free.** `new CoderAgent({ …, chatId })` performs no I/O
965
+ and no validation — a bad or archived id fails the _turn_, not the
966
+ constructor.
967
+ - **The first resumed turn seeds its cursor** from the chat's newest message
968
+ (one single‑message history probe), so it streams only its own events —
969
+ it never re‑absorbs earlier turns' content or usage.
970
+ - **Re‑supply process‑local configuration.** Client `tools` live in your
971
+ process, not on the server, so each step that expects tool calls must pass
972
+ them again. The workspace binding is the opposite: fixed at creation,
973
+ carried by the chat, unchangeable on resume.
974
+ - **Sessions stay single‑flight across the whole system**, not just within one
975
+ process: run steps strictly sequentially per chat. A second job posting to
976
+ the same chat queues behind the live run and burns its own
977
+ `requestTimeoutMs` waiting.
978
+
979
+ ### Let the SDK absorb drops — and handle what it re‑throws
980
+
981
+ Self‑healed, invisible to the step (the run is **not** interrupted):
982
+
983
+ - **Transient stream drops.** The server keeps generating through the gap; the
984
+ SDK redials with backoff and replays what it missed — committed messages
985
+ past the turn's cursor plus the in‑progress message's deltas — deduplicating
986
+ on receipt. A message that committed _while the connection was down_ arrives
987
+ as exactly its missing tail. Drops cost latency, never content, and never
988
+ duplicate content.
989
+ - **Client‑tool pauses ride one connection.** A multi‑segment turn keeps its
990
+ stream across `requires_action` pauses, and a drop while your tool executes
991
+ redials in the background.
992
+ - **A lost tool‑call event.** If a turn pauses for client tools but the pause's
993
+ tool‑call event doesn't arrive within ~2 s, the SDK recovers the pending
994
+ calls from committed history over REST and the turn continues.
995
+
996
+ Surfaced to the step once self‑healing is exhausted:
997
+
998
+ ```ts
999
+ import { CoderApiError, CoderChatError, CoderStreamError } from "@coder/ai-sdk-agent";
1000
+
1001
+ try {
1002
+ await runTurn(workflowId, prompt);
1003
+ } catch (err) {
1004
+ if (err instanceof CoderChatError && err.retryable) {
1005
+ // Timeout or transient turn failure. The run was interrupted (best-effort)
1006
+ // and the chat survives — but a re-run resubmits the prompt as a new user
1007
+ // turn: retry only steps designed to tolerate that (below).
1008
+ } else if (err instanceof CoderStreamError) {
1009
+ // Redial budget exhausted (~15s without forward progress). On a resumed
1010
+ // chat this is never auto-retried — the retry decision is yours (below).
1011
+ } else if (err instanceof CoderApiError) {
1012
+ // Branch on err.status: back off and retry 408/425/429/5xx; fail the workflow
1013
+ // on the rest (expired token, archived/deleted chat, …).
1014
+ }
1015
+ throw err;
1016
+ }
1017
+ ```
1018
+
1019
+ - **`CoderChatError` with `retryable: true`** — the segment's
1020
+ `requestTimeoutMs` expired (`kind: "timeout"`) or the turn failed
1021
+ transiently (`kind: "stream_closed"`, an upstream 5xx). `retryable`
1022
+ classifies the _failure_ as transient — it does not make a re‑run free.
1023
+ Unlike the guarded `CoderStreamError` path below, the session is **not**
1024
+ discarded: the retried step reloads the checkpointed chat id (the `finally`
1025
+ checkpoint above is what makes this hold even when the _first_ step fails
1026
+ after creating its chat) and resubmits the same prompt as a new user turn,
1027
+ with the aborted attempt's partial output in history and any tool effects
1028
+ that ran before the failure not undone. The same idempotency judgment as
1029
+ below applies — retry steps designed to tolerate re‑submission; reconcile
1030
+ first when they aren't. And don't resubmit _immediately_: the expiry's
1031
+ server interrupt is asynchronous and best‑effort, so the timed‑out run may
1032
+ still be committing — run the same interrupt‑and‑wait‑for‑terminal‑status
1033
+ recovery as after a crash (below) before the retry, or its trailing output
1034
+ is absorbed into the retried turn.
1035
+ - **`CoderStreamError`** (an AI SDK `APICallError`) — the stream could not be
1036
+ re‑established. `isRetryable: true` only when the failed turn had just
1037
+ created its chat _and_ had no external effects a replay would repeat (no
1038
+ workspace, no MCP servers, no fresh inline uploads); the SDK then discards
1039
+ the dead session so a retry — `maxRetries` or a re‑run step — starts clean
1040
+ on a fresh chat. The discard happens for _every_ chat the failed turn
1041
+ itself created — **including the effectful, `isRetryable: false` case**:
1042
+ the id is gone, the `finally` checkpoint writes nothing, and the
1043
+ interrupted chat — whose workspace/MCP tools may already have acted —
1044
+ becomes an unacknowledged orphan for the reconciliation sweep (below). A
1045
+ retried step then starts a fresh chat that has _not_ seen the orphan's
1046
+ effects: gate that retry on the same idempotency judgment as any
1047
+ re‑submission. **On a resumed chat it is always `isRetryable: false`:**
1048
+ re‑running the step resubmits the same prompt as a _new user turn_, with the
1049
+ failed attempt's partial output still in history (usually fine — the model
1050
+ sees its own aborted attempt), and any workspace/MCP tool effects that ran
1051
+ before the drop are not undone. That judgment call belongs to your workflow,
1052
+ which is exactly why it isn't automatic.
1053
+ - **`CoderApiError`** — an HTTP request failed; every non‑2xx wraps in this
1054
+ error, so branch on `status` before deciding. Rate limiting and transient
1055
+ server failures (408, 425, 429, 5xx) deserve backoff and a step retry (with
1056
+ the same re‑submission caveat as above). Auth failures and archived/deleted
1057
+ chats (401/403/404, the archived‑chat 400) hit the same wall on every
1058
+ attempt — fail the workflow.
1059
+
1060
+ ### Recover after a crash
1061
+
1062
+ A hard crash (OOM kill, host loss) runs no `finally` blocks. Reason from the
1063
+ checkpoint:
1064
+
1065
+ - **Crashed with an empty checkpoint** — usually nothing exists server‑side
1066
+ and the retried step just creates a fresh chat. But an empty checkpoint is
1067
+ not proof of nothing: a crash or `requestTimeoutMs` expiry while the create
1068
+ request was _in flight_ can leave a chat the server committed but whose id
1069
+ never arrived — unreachable even by the SDK's own interrupt. Treat "no
1070
+ checkpoint" as _up to one unacknowledged chat per attempt may exist_ — not
1071
+ as a clean slate, and automatic step retries can stack several. The
1072
+ reconciliation sweep below must therefore retire _every_ matching orphan
1073
+ (and its workspace/MCP effects, if the deployment auto‑attached any), not
1074
+ stop at the first.
1075
+ - **Crashed after creation, before the checkpoint** — the chat is orphaned:
1076
+ its run keeps generating until it settles on its own — or, if the crash hit
1077
+ one of your client tools, never settles: it stays paused in
1078
+ `requires_action`, holding its workspace, invisible to the retried step
1079
+ (which has no id to interrupt). Either way the retried step starts a fresh
1080
+ chat, so orphans are found only by the reconciliation sweep from
1081
+ [Preventing stuck turns](#preventing-stuck-turns) — that sweep is not
1082
+ optional in this pattern, and it needs a **wider filter** here: the fleet
1083
+ sweep watches for _non‑terminal_ chats stuck past their budget, but an
1084
+ orphaned first turn may settle terminally on its own and then sit as a
1085
+ live, unarchived chat forever. Sweep every chat no checkpoint accounts
1086
+ for — regardless of status — and interrupt/archive it. (To shrink the window, checkpoint eagerly from the
1087
+ first `ws:dial` transport event — it carries the chat id as soon as the chat
1088
+ exists — but then the checkpoint's lifecycle is yours too: the retried step
1089
+ resumes a chat holding a dead half‑turn, and whenever a `CoderStreamError`
1090
+ killed a chat this step created — retryable or not: the SDK discards every
1091
+ such session, but only in memory — you must clear the eagerly checkpointed
1092
+ id yourself before any retry, or "starts fresh after a discard" silently
1093
+ turns into resuming the dead chat.)
1094
+ - **Crashed after the checkpoint** — the chat is resumable, but don't just
1095
+ resubmit. The crashed attempt's run may still be live server‑side, and a
1096
+ resumed turn seeds its message cursor _before_ submitting — whatever the
1097
+ old run commits after that point would be absorbed into the resumed turn's
1098
+ output and usage. A crash _inside one of your client tools_ is worse: the
1099
+ chat is paused in `requires_action`, where new messages wait forever (and
1100
+ see [#86](https://github.com/coder/ai-sdk/issues/86) if that tool's effect
1101
+ was non‑idempotent). Both cases have one remedy — after a crash, interrupt
1102
+ the chat and **wait until it actually settles** before resuming:
1103
+ `interrupt()` resolves on acknowledgment while the run keeps winding down
1104
+ (and committing) for a few more seconds, so poll the chat's status to a
1105
+ terminal one first. On a chat with no live run the interrupt rejects with a
1106
+ 409 `CoderApiError`; that's the good case — ignore it:
1107
+
1108
+ ```ts
1109
+ // Bounded: a stalled deployment must fail the step, not hang it.
1110
+ const deadline = AbortSignal.timeout(15_000);
1111
+ await agent.interrupt({ signal: deadline }).catch(() => {}); // 409 = nothing to stop
1112
+ let status;
1113
+ do {
1114
+ ({ status } = await agent.client.getChat(agent.chatId!, deadline));
1115
+ if (status === "waiting" || status === "completed" || status === "error") break;
1116
+ await new Promise((r) => setTimeout(r, 1_000)); // acknowledged ≠ settled — wait it out
1117
+ } while (true);
1118
+ ```
1119
+
1120
+ ### Watch turn health from inside the step
1121
+
1122
+ [Transport events](#observability) classify how each segment ended without
1123
+ parsing errors or stream frames — emit them as step metrics/traces:
1124
+
1125
+ ```ts
1126
+ declare const metrics: {
1127
+ ok(segment: number, status: string, ms: number): void;
1128
+ fail(segment: number, error: string, detail: string): void;
1129
+ teardown(segment: number): void;
1130
+ };
1131
+
1132
+ function observe(ev: CoderTransportEvent): void {
1133
+ if (ev.type === "ws:redial") {
1134
+ // Early warning: the stream dropped and is reconnecting. The server keeps
1135
+ // generating — nothing is lost yet; the turn fails if this hits the cap.
1136
+ console.warn(
1137
+ `redial ${ev.consecutiveFailures}/${ev.maxConsecutiveFailures}, next in ${ev.backoffMs}ms`,
1138
+ );
1139
+ } else if (ev.type === "segment:settle") {
1140
+ if (ev.finishReason) metrics.ok(ev.segment, ev.status!, ev.durationMs);
1141
+ else if (ev.error) metrics.fail(ev.segment, ev.error.name, ev.error.message);
1142
+ else metrics.teardown(ev.segment);
1143
+ }
1144
+ }
1145
+ ```
1146
+
1147
+ `segment:settle` fires exactly once per `segment:start` and separates the
1148
+ three endings a workflow cares about:
1149
+
1150
+ - **Clean settle** — `status` + `finishReason` present. `finishReason:
1151
+ "tool-calls"` (status `requires_action`) is a healthy mid‑turn pause for
1152
+ your client tools; terminal statuses end the turn.
1153
+ - **Failure** — `error: { name, message }` present (plus `status` when the
1154
+ server run itself still settled terminally): server‑side turn errors,
1155
+ transport failure after redials were exhausted, `requestTimeoutMs` expiry
1156
+ (`error.name: "CoderChatError"`), and caller aborts — which settle as
1157
+ failures carrying the abort reason's name, never as teardowns:
1158
+ `"TimeoutError"` for an `AbortSignal.timeout(…)` deadline (the running
1159
+ example), `"AbortError"` for an explicit abort.
1160
+ - **Teardown** — neither: the stream consumer itself cancelled the turn
1161
+ (`stream()`'s `ReadableStream.cancel()` with no abort of its own) — rare in
1162
+ `generate()`‑shaped steps.
1163
+
1164
+ The hook is observability‑only — handler exceptions are swallowed and can't
1165
+ disturb the turn, and an unset hook costs nothing. For monitoring _across_
1166
+ steps (chats stuck before a watcher started, fleet‑level sweeps), see
1167
+ [Preventing stuck turns](#preventing-stuck-turns).
1168
+
1169
+ ### Bound every step
1170
+
1171
+ `requestTimeoutMs` arms **one timer per segment** covering everything the
1172
+ segment does: the REST phase (chat creation, message/tool‑result submission,
1173
+ uploads), the stream — and, the part that matters here, **any redial backoff
1174
+ inside the segment**. A segment that spends 12 s reconnecting has 12 s less
1175
+ budget; redials never reset the clock, so a flaky network can't stretch a step
1176
+ past its bound. On expiry the call rejects with `CoderChatError`
1177
+ (`kind: "timeout"`, `retryable: true`) and the server run is interrupted
1178
+ best‑effort.
1179
+
1180
+ Two workflow‑specific notes:
1181
+
1182
+ - A turn that times out (or crashes) before its chat id arrived leaves nothing
1183
+ to _resume_ — the checkpoint stays empty and the retried step starts fresh —
1184
+ but possibly an unacknowledged chat to sweep (see
1185
+ [Recover after a crash](#recover-after-a-crash)).
1186
+ - `requestTimeoutMs` bounds each _segment_; a turn driving client tools runs
1187
+ several. Cap a step's total wall‑clock with
1188
+ `abortSignal: AbortSignal.timeout(…)` —
1189
+ [Timeouts & cancellation](#timeouts--cancellation).
1190
+
1191
+ ### Archive at the end — and only at the end
1192
+
1193
+ **An archived chat cannot be resumed:** the server rejects new messages on it
1194
+ (400, `Cannot send messages to an archived chat`). The per‑request habits from
1195
+ [Cleanup](#cleanup) — `await using`, `archive()` in a `finally` — would
1196
+ destroy the session from inside an intermediate step, so here the _workflow_
1197
+ owns the chat's end of life: archive once, in the final step, and in the
1198
+ workflow's failure/compensation handler so abandoned runs don't accumulate
1199
+ live chats.
1200
+
1201
+ ```ts
1202
+ export async function archiveWorkflowChat(workflowId: string): Promise<void> {
1203
+ const chatId = await checkpoints.get(workflowId);
1204
+ if (!chatId) return; // the workflow died before its first turn created a chat
1205
+ const agent = new CoderAgent({
1206
+ baseUrl: process.env.CODER_URL!,
1207
+ token: process.env.CODER_SESSION_TOKEN!,
1208
+ organizationId: process.env.CODER_ORG_ID!,
1209
+ chatId,
1210
+ });
1211
+ // A crashed step may have left a run live — stop it first, bounded. On an
1212
+ // already-settled chat the interrupt rejects with a 409; ignore it.
1213
+ await agent.interrupt({ signal: AbortSignal.timeout(15_000) }).catch(() => {});
1214
+ await agent.archive(); // retries 409s while the interrupted run winds down (~15s)
1215
+ }
1216
+ ```
1217
+
1218
+ ### Checklist
1219
+
1220
+ - One turn per durable step; `generate()`, not `stream()`.
1221
+ - Persist `agent.chatId` (a string) — never the instance, never the token.
1222
+ - Bound every step: `requestTimeoutMs` per segment, an abort deadline for
1223
+ total wall‑clock.
1224
+ - Let redials self‑heal; own every retry decision — a re‑run step resubmits
1225
+ its prompt as a new user turn.
1226
+ - Keep concurrent steps' fan‑out within workspace quota —
1227
+ [Workspaces & quota](#workspaces--quota).
1228
+ - Steps that don't need server‑side tools (plan / extract / synthesize) are
1229
+ cheaper and natively structured through
748
1230
  [`@coder/ai-sdk-provider`](../provider) + `generateObject` — no chat, no
749
1231
  workspace, no cleanup.
1232
+ - Archive in the final step / failure handler — never per step.
750
1233
 
751
1234
  ## Testing
752
1235