@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 +529 -46
- package/dist/index.d.ts +12 -9
- package/dist/index.js +150 -79
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
running
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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;
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
- **
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
|