@coder/ai-sdk-agent 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +377 -23
- package/dist/index.d.ts +36 -1
- package/dist/index.js +87 -30
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -324,19 +324,19 @@ for (const ev of events) {
|
|
|
324
324
|
`timestamp` (`Date.now()` at observation — comparable to server‑side timestamps
|
|
325
325
|
such as a message's `created_at`, for delivery‑lag measurements):
|
|
326
326
|
|
|
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 |
|
|
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`, `reader` (identifies the `streamChatEvents` call), `attempt` (1‑based per reader, increments per redial), `url` |
|
|
333
|
+
| `ws:open` | the WebSocket handshake completes | `chatId`, `reader`, `attempt` |
|
|
334
|
+
| `ws:event` | a decoded stream event arrives | `chatId`, `reader`, `attempt`, `event` (the decoded `ChatStreamEvent`, by reference — don't mutate) |
|
|
335
|
+
| `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) |
|
|
336
|
+
| `ws:error` | a socket error or unparseable frame | `chatId`, `reader`, `attempt`, `message` |
|
|
337
|
+
| `ws:redial` | a dropped connection is about to be redialed | `chatId`, `reader`, `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`, `reader` (the reader that served the segment; absent if none was acquired), `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 |
|
|
340
340
|
|
|
341
341
|
Semantics worth knowing:
|
|
342
342
|
|
|
@@ -348,8 +348,18 @@ Semantics worth knowing:
|
|
|
348
348
|
`Coder-Session-Token` header, which is deliberately excluded); `path`/`url`
|
|
349
349
|
never contain credentials.
|
|
350
350
|
- `ws:event` fires at arrival, **before** replay dedup: after a redial, chatd's
|
|
351
|
-
replay of the in‑progress episode is visible here (correlate with `attempt`)
|
|
351
|
+
replay of the in‑progress episode is visible here (correlate with `reader`/`attempt`)
|
|
352
352
|
even though the reader suppresses the duplicates it forwards to the turn.
|
|
353
|
+
- Every `ws:*` event carries `reader` — a monotonic id for the
|
|
354
|
+
`streamChatEvents` call (reader) behind the connection, allocated from one
|
|
355
|
+
process‑wide counter so it stays unique across model and client instances.
|
|
356
|
+
`attempt` restarts at 1 per reader, so identify a connection as
|
|
357
|
+
`(chatId, reader, attempt)`, never `(chatId, attempt)` alone: a client‑tool
|
|
358
|
+
pause the caller abandons is closed fire‑and‑forget when the next turn dials
|
|
359
|
+
its replacement, and the superseded reader's late `ws:close` (or a raced‑in
|
|
360
|
+
frame) can emit after the new reader's `ws:dial` — the reader id is what
|
|
361
|
+
tells them apart. `segment:settle` names the reader that served the segment;
|
|
362
|
+
`segment:start` predates stream acquisition and carries none.
|
|
353
363
|
- A multi‑step turn that drives client tools emits one `segment:start`/
|
|
354
364
|
`segment:settle` pair per round‑trip, all riding **one** `ws:dial`ed
|
|
355
365
|
connection — the stream is retained across `requires_action` pauses. A pause
|
|
@@ -816,7 +826,7 @@ sequence pinpoints _where_ a turn died:
|
|
|
816
826
|
| `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
827
|
| 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
828
|
| `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)
|
|
829
|
+
| 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); if a crash left the pause behind, reconcile effects first ([Make client tools crash-safe](#make-client-tools-crash-safe)) |
|
|
820
830
|
| `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 |
|
|
821
831
|
|
|
822
832
|
## Configuration
|
|
@@ -1031,7 +1041,11 @@ try {
|
|
|
1031
1041
|
server interrupt is asynchronous and best‑effort, so the timed‑out run may
|
|
1032
1042
|
still be committing — run the same interrupt‑and‑wait‑for‑terminal‑status
|
|
1033
1043
|
recovery as after a crash (below) before the retry, or its trailing output
|
|
1034
|
-
is absorbed into the retried turn.
|
|
1044
|
+
is absorbed into the retried turn. A timed‑out run can also have _finished_
|
|
1045
|
+
before that interrupt landed — but because the interrupt was already fired,
|
|
1046
|
+
the recovery's [result check](#recover-the-result-before-resubmitting)
|
|
1047
|
+
must treat the attempt as cut short (pin `cutShort` there) rather than
|
|
1048
|
+
trusting its own 409.
|
|
1035
1049
|
- **`CoderStreamError`** (an AI SDK `APICallError`) — the stream could not be
|
|
1036
1050
|
re‑established. `isRetryable: true` only when the failed turn had just
|
|
1037
1051
|
created its chat _and_ had no external effects a replay would repeat (no
|
|
@@ -1096,27 +1110,365 @@ checkpoint:
|
|
|
1096
1110
|
resumed turn seeds its message cursor _before_ submitting — whatever the
|
|
1097
1111
|
old run commits after that point would be absorbed into the resumed turn's
|
|
1098
1112
|
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
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1113
|
+
chat is paused in `requires_action`, where new messages wait forever — and
|
|
1114
|
+
if the tool's external effect committed before the crash, that pause is the
|
|
1115
|
+
only pending record of it, so reconcile the tool ledger **before** any
|
|
1116
|
+
interrupt ([Make client tools crash-safe](#make-client-tools-crash-safe)).
|
|
1117
|
+
Both cases have one remedy — stop what the crash left behind and **wait
|
|
1118
|
+
until the chat actually settles** before deciding: a client‑tool pause is
|
|
1119
|
+
reconciled, never interrupted away; anything else is interrupted:
|
|
1103
1120
|
`interrupt()` resolves on acknowledgment while the run keeps winding down
|
|
1104
1121
|
(and committing) for a few more seconds, so poll the chat's status to a
|
|
1105
1122
|
terminal one first. On a chat with no live run the interrupt rejects with a
|
|
1106
1123
|
409 `CoderApiError`; that's the good case — ignore it:
|
|
1107
1124
|
|
|
1108
1125
|
```ts
|
|
1126
|
+
// Both shared with "Make client tools crash-safe" (below): the ledger
|
|
1127
|
+
// reconciliation resolves to its `unstarted` — true only when it fell back
|
|
1128
|
+
// to interrupting — and journals each action write-ahead, so a recovery
|
|
1129
|
+
// pass that dies mid-recovery is visible — and classifiable — to the next.
|
|
1130
|
+
declare function reconcileClientTools(): Promise<boolean>;
|
|
1131
|
+
// Entries are per-ATTEMPT: clear/supersede them transactionally with
|
|
1132
|
+
// recording the attempt's result, or a stale verdict misclassifies the
|
|
1133
|
+
// next attempt on this chat — attempt-scoped journal keys close the
|
|
1134
|
+
// remaining double-crash window (see
|
|
1135
|
+
// https://github.com/coder/ai-sdk/issues/92).
|
|
1136
|
+
declare const journal: {
|
|
1137
|
+
get(chatId: string): Promise<"resumed" | "cut-short" | undefined>;
|
|
1138
|
+
set(chatId: string, state: "resumed" | "cut-short"): Promise<void>;
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1109
1141
|
// Bounded: a stalled deployment must fail the step, not hang it.
|
|
1110
1142
|
const deadline = AbortSignal.timeout(15_000);
|
|
1111
|
-
|
|
1112
|
-
|
|
1143
|
+
// Whether an interrupt stopped a live run drives the resubmit decision
|
|
1144
|
+
// below: a 409 means there was nothing to stop.
|
|
1145
|
+
let cutShort = true;
|
|
1146
|
+
let { status } = await agent.client.getChat(agent.chatId!, deadline);
|
|
1147
|
+
const journaled = await journal.get(agent.chatId!);
|
|
1148
|
+
if (status === "requires_action") {
|
|
1149
|
+
// The crash hit a client tool mid-execution: this pause is the only
|
|
1150
|
+
// pending record of its committed effects. Never interrupt it away —
|
|
1151
|
+
// reconcile the ledger instead (below): it submits recorded results
|
|
1152
|
+
// (reviving the run — nothing was cut short) or interrupts itself only
|
|
1153
|
+
// when nothing committed.
|
|
1154
|
+
cutShort = await reconcileClientTools();
|
|
1155
|
+
} else if (journaled) {
|
|
1156
|
+
// A previous recovery pass acted on this chat, then died before the
|
|
1157
|
+
// step's result was recorded. Its journaled state is the verdict:
|
|
1158
|
+
// "resumed" — it revived the run by submitting recorded results;
|
|
1159
|
+
// interrupting now would cut down a run doing the step's work, and the
|
|
1160
|
+
// resubmitted prompt would mint NEW tool-call ids, sidestepping the
|
|
1161
|
+
// idempotency keys. Let it finish; nothing was cut short.
|
|
1162
|
+
// "cut-short" — it went on to interrupt (an unstarted sibling in the
|
|
1163
|
+
// batch); the attempt is truncated, so the check below must resubmit.
|
|
1164
|
+
cutShort = journaled === "cut-short";
|
|
1165
|
+
if (cutShort) {
|
|
1166
|
+
// The journal records write-ahead INTENT, not a landed interrupt: the
|
|
1167
|
+
// pass may have died in between, leaving the revived run working.
|
|
1168
|
+
// Re-drive the interrupt to make the intent true — a 409 means the
|
|
1169
|
+
// chat already settled; the cut-short verdict stands either way.
|
|
1170
|
+
await agent.interrupt({ signal: deadline }).catch((err) => {
|
|
1171
|
+
if (!(err instanceof CoderApiError && err.status === 409)) throw err;
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
} else {
|
|
1175
|
+
await agent.interrupt({ signal: deadline }).catch((err) => {
|
|
1176
|
+
// A 409 proves nothing is stoppable NOW — that means "never cut short"
|
|
1177
|
+
// only if the probe saw no interrupt already in flight. A probed
|
|
1178
|
+
// "interrupting" is an earlier interrupt (a timeout's best-effort one,
|
|
1179
|
+
// a sweeper) already truncating the attempt; this 409 just says it
|
|
1180
|
+
// landed. Keep cutShort = true then.
|
|
1181
|
+
if (err instanceof CoderApiError && err.status === 409) cutShort = status === "interrupting";
|
|
1182
|
+
else throw err;
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
// The 15 s interrupt bound must not cap what follows: a reconciled pause
|
|
1186
|
+
// revives a full model/tool continuation. Budget the settle poll — and the
|
|
1187
|
+
// history reads after it — on the turn scale, like the step itself.
|
|
1188
|
+
const settle = AbortSignal.timeout(600_000);
|
|
1113
1189
|
do {
|
|
1114
|
-
({ status } = await agent.client.getChat(agent.chatId!,
|
|
1190
|
+
({ status } = await agent.client.getChat(agent.chatId!, settle));
|
|
1115
1191
|
if (status === "waiting" || status === "completed" || status === "error") break;
|
|
1192
|
+
// With no accepted interrupt racing to clear it, requires_action is a
|
|
1193
|
+
// STABLE pause: a run resumed by submitted tool results called a NEW
|
|
1194
|
+
// client tool. Exit and reconcile the ledger again (below) instead of
|
|
1195
|
+
// polling out the deadline.
|
|
1196
|
+
if (status === "requires_action" && !cutShort) break;
|
|
1116
1197
|
await new Promise((r) => setTimeout(r, 1_000)); // acknowledged ≠ settled — wait it out
|
|
1117
1198
|
} while (true);
|
|
1118
1199
|
```
|
|
1119
1200
|
|
|
1201
|
+
Settled is necessary, not sufficient: a terminal status doesn't say whether
|
|
1202
|
+
the crashed attempt already _finished the step's work_ — check history
|
|
1203
|
+
before resubmitting
|
|
1204
|
+
([Recover the result before resubmitting](#recover-the-result-before-resubmitting)).
|
|
1205
|
+
|
|
1206
|
+
### Make client tools crash-safe
|
|
1207
|
+
|
|
1208
|
+
Interrupting un‑strands a `requires_action` pause — but if the crashed
|
|
1209
|
+
process died _between_ a non‑idempotent client tool committing its external
|
|
1210
|
+
effect (a payment, a deploy, an email) and that result reaching the server,
|
|
1211
|
+
the pause is the **only pending record of the execution**. Interrupt it away
|
|
1212
|
+
and the resubmitted prompt is free to call the tool again — and duplicate the
|
|
1213
|
+
effect. Close the window with a tool‑invocation ledger in the same durable
|
|
1214
|
+
store as the `chatId` checkpoint, keyed by **chat id + `toolCallId`** — the
|
|
1215
|
+
server assigns the call id and commits it to history, so both sides of the
|
|
1216
|
+
reconciliation survive the crash. The chat id must be part of the key (and
|
|
1217
|
+
of the idempotency key handed to the external system): call ids are only
|
|
1218
|
+
unique _within_ a chat, and a store shared across workflows must never let
|
|
1219
|
+
one chat's `done` entry answer — or deduplicate — another chat's call. In
|
|
1220
|
+
multi‑tenant stores, fold the tenant in too:
|
|
1221
|
+
|
|
1222
|
+
```ts
|
|
1223
|
+
import { tool } from "ai";
|
|
1224
|
+
import { z } from "zod";
|
|
1225
|
+
import { CoderAgent } from "@coder/ai-sdk-agent";
|
|
1226
|
+
|
|
1227
|
+
type LedgerEntry = { state: "committing" } | { state: "done"; output: string };
|
|
1228
|
+
declare const ledger: {
|
|
1229
|
+
get(key: string): Promise<LedgerEntry | undefined>;
|
|
1230
|
+
set(key: string, entry: LedgerEntry): Promise<void>;
|
|
1231
|
+
};
|
|
1232
|
+
declare const payments: {
|
|
1233
|
+
charge(amountCents: number, opts: { idempotencyKey: string }): Promise<{ receiptId: string }>;
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
const ledgerKey = (chatId: string, toolCallId: string) => `${chatId}/${toolCallId}`;
|
|
1237
|
+
const ChargeArgs = z.object({ amountCents: z.number().int() });
|
|
1238
|
+
|
|
1239
|
+
// A factory taking a getter, not an id: the chat may not exist until the
|
|
1240
|
+
// turn creates it, so the id must be read lazily — it is set before any
|
|
1241
|
+
// tool runs.
|
|
1242
|
+
const chargeCard = (chatId: () => string) =>
|
|
1243
|
+
tool({
|
|
1244
|
+
description: "Charge the customer's card.",
|
|
1245
|
+
inputSchema: ChargeArgs, // shared with recovery's re-drive dispatch below
|
|
1246
|
+
execute: async ({ amountCents }, { toolCallId }) => {
|
|
1247
|
+
const key = ledgerKey(chatId(), toolCallId);
|
|
1248
|
+
// Write-ahead, BEFORE the effect: a crash between the two writes leaves
|
|
1249
|
+
// "committing" (ambiguous, but resolvable below); no entry at all proves
|
|
1250
|
+
// the effect never started.
|
|
1251
|
+
await ledger.set(key, { state: "committing" });
|
|
1252
|
+
// Hand the external system the SAME key as its idempotency key, so
|
|
1253
|
+
// re-driving this exact call can never double-charge.
|
|
1254
|
+
const { receiptId } = await payments.charge(amountCents, { idempotencyKey: key });
|
|
1255
|
+
await ledger.set(key, { state: "done", output: `charged: receipt ${receiptId}` });
|
|
1256
|
+
return `charged: receipt ${receiptId}`;
|
|
1257
|
+
},
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
// Wire-up: a direct self-reference in the constructor call is circular for
|
|
1261
|
+
// the type checker — defer it through a box assigned right after.
|
|
1262
|
+
let self: { chatId?: string } | undefined;
|
|
1263
|
+
const agent = new CoderAgent({
|
|
1264
|
+
/* …base options as in runTurn… */
|
|
1265
|
+
tools: { charge_card: chargeCard(() => self!.chatId!) },
|
|
1266
|
+
});
|
|
1267
|
+
self = agent;
|
|
1268
|
+
```
|
|
1269
|
+
|
|
1270
|
+
On crash recovery, when the checkpointed chat is paused in `requires_action`,
|
|
1271
|
+
reconcile _before_ any interrupt. The pending calls are in committed history —
|
|
1272
|
+
the same derivation the SDK itself uses to recover a lost `action_required`
|
|
1273
|
+
event — and the ledger holds the verdict on each:
|
|
1274
|
+
|
|
1275
|
+
```ts
|
|
1276
|
+
// The step's configured ToolSet names: history does not record which calls
|
|
1277
|
+
// were client tools (see [Rehydrating chat history]), so — like the SDK's
|
|
1278
|
+
// own derivation — restrict to the names this step registers; a server/MCP
|
|
1279
|
+
// call must never reach the ledger as "unstarted".
|
|
1280
|
+
const clientTools = new Set(["charge_card"]);
|
|
1281
|
+
|
|
1282
|
+
// Pending = the last assistant message's client (non-provider-executed,
|
|
1283
|
+
// ToolSet-named) tool calls, minus ids answered by a later message's tool
|
|
1284
|
+
// result.
|
|
1285
|
+
const { messages } = await agent.client.getMessages(agent.chatId!, { limit: 200 }, deadline);
|
|
1286
|
+
const ordered = [...messages].sort((a, b) => a.id - b.id);
|
|
1287
|
+
const lastAssistant = ordered.findLast((m) => m.role === "assistant");
|
|
1288
|
+
const handled = new Set(
|
|
1289
|
+
ordered
|
|
1290
|
+
.filter((m) => m.id > (lastAssistant?.id ?? 0))
|
|
1291
|
+
.flatMap((m) => m.content ?? [])
|
|
1292
|
+
.filter((p) => p.type === "tool-result")
|
|
1293
|
+
.map((p) => p.tool_call_id),
|
|
1294
|
+
);
|
|
1295
|
+
const pending = (lastAssistant?.content ?? []).filter(
|
|
1296
|
+
(p) =>
|
|
1297
|
+
p.type === "tool-call" &&
|
|
1298
|
+
!p.provider_executed &&
|
|
1299
|
+
clientTools.has(p.tool_name!) &&
|
|
1300
|
+
!handled.has(p.tool_call_id),
|
|
1301
|
+
);
|
|
1302
|
+
|
|
1303
|
+
const results: { tool_call_id: string; output: unknown }[] = [];
|
|
1304
|
+
let unstarted = false;
|
|
1305
|
+
for (const call of pending) {
|
|
1306
|
+
const key = ledgerKey(agent.chatId!, call.tool_call_id!);
|
|
1307
|
+
const entry = await ledger.get(key);
|
|
1308
|
+
if (entry?.state === "done") {
|
|
1309
|
+
// Effect committed, result stranded: submit the record — the pause is
|
|
1310
|
+
// answered and nothing re-executes.
|
|
1311
|
+
results.push({ tool_call_id: call.tool_call_id!, output: entry.output });
|
|
1312
|
+
} else if (entry) {
|
|
1313
|
+
// "committing" is ambiguous — the crash hit between the write-ahead and
|
|
1314
|
+
// "done". Safe to re-drive: the SAME idempotency key makes a committed
|
|
1315
|
+
// effect replay as a no-op, and the call's args are committed in history.
|
|
1316
|
+
// Dispatch by tool name — every ledgered tool needs its own re-drive
|
|
1317
|
+
// path, its args validated exactly as the live loop would have.
|
|
1318
|
+
if (call.tool_name !== "charge_card")
|
|
1319
|
+
throw new Error(`no re-drive path for tool ${call.tool_name}`);
|
|
1320
|
+
const { amountCents } = ChargeArgs.parse(call.args);
|
|
1321
|
+
const { receiptId } = await payments.charge(amountCents, { idempotencyKey: key });
|
|
1322
|
+
const output = `charged: receipt ${receiptId}`;
|
|
1323
|
+
await ledger.set(key, { state: "done", output });
|
|
1324
|
+
results.push({ tool_call_id: call.tool_call_id!, output });
|
|
1325
|
+
} else {
|
|
1326
|
+
unstarted = true; // the tool never ran — interrupting loses nothing
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
if (results.length > 0 || unstarted) {
|
|
1330
|
+
// ONE write-ahead with the batch's FINAL verdict, before any network
|
|
1331
|
+
// call — a mixed batch journals "cut-short" before even its submission,
|
|
1332
|
+
// so no crash point can leave a stale "resumed" masking the pending
|
|
1333
|
+
// interrupt. Every crash then either re-reconciles the stable pause or
|
|
1334
|
+
// re-drives the journaled intent. Clear the entry only once the step's
|
|
1335
|
+
// result is finally recorded.
|
|
1336
|
+
await journal.set(agent.chatId!, unstarted ? "cut-short" : "resumed");
|
|
1337
|
+
}
|
|
1338
|
+
if (results.length > 0) await agent.client.submitToolResults(agent.chatId!, { results }, deadline);
|
|
1339
|
+
if (unstarted) await agent.client.interruptChat(agent.chatId!, deadline);
|
|
1340
|
+
// As `reconcileClientTools()` in the settle-wait snippet above: resolve to
|
|
1341
|
+
// `unstarted` — cutShort is true only when this fell back to interrupting.
|
|
1342
|
+
```
|
|
1343
|
+
|
|
1344
|
+
- **Only interrupt when the ledger shows no committed effect.** Submitted
|
|
1345
|
+
results are committed to chat history, so even when a mixed batch (one call
|
|
1346
|
+
done, a sibling never started) still ends in an interrupt, the effect's
|
|
1347
|
+
recorded outcome survives for the resubmitted turn to see.
|
|
1348
|
+
- **Submitting answers the pause; it does not revive your tool loop.** Once
|
|
1349
|
+
every pending call is answered, the turn resumes **server‑side** with no
|
|
1350
|
+
process streaming it. That is why the settle‑wait's reconcile branch never
|
|
1351
|
+
interrupts a pause — nothing stranded is left to stop, and an interrupt
|
|
1352
|
+
now would cut down the very run the submission revived — and why the
|
|
1353
|
+
resolved `unstarted` leaves **`cutShort = false`** after a submission:
|
|
1354
|
+
nothing was cut short, and the next section's `!cutShort` check must
|
|
1355
|
+
classify the resumed run's finish as a completed attempt, not resubmit
|
|
1356
|
+
it. Give the poll a turn‑scale deadline rather than the 15 s interrupt
|
|
1357
|
+
bound; it exits on `requires_action` (stable here, with no accepted
|
|
1358
|
+
interrupt clearing it), which means the resumed run called a _new_ tool —
|
|
1359
|
+
reconcile again; a call with no ledger entry is safe to interrupt.
|
|
1360
|
+
- **The finished run's output lands only in history** — recover it like any
|
|
1361
|
+
completed‑but‑unrecorded attempt (next section) instead of resubmitting.
|
|
1362
|
+
|
|
1363
|
+
### Recover the result before resubmitting
|
|
1364
|
+
|
|
1365
|
+
The settle‑wait resolves overlap with a still‑live run, but not the last
|
|
1366
|
+
crash window: **the server finished the turn, and the process died before the
|
|
1367
|
+
step's return value was recorded.** History now holds a perfectly good
|
|
1368
|
+
result; a retried step that blindly resubmits runs the whole turn again —
|
|
1369
|
+
duplicated output, repeated workspace/MCP/tool effects. This is the
|
|
1370
|
+
at‑least‑once boundary every durable engine has around external effects, and
|
|
1371
|
+
the chat itself is the record that closes it. Make attempts matchable first —
|
|
1372
|
+
the server stores the prompt verbatim, so embed a step‑scoped marker:
|
|
1373
|
+
|
|
1374
|
+
```ts
|
|
1375
|
+
const marker = `[${workflowId}/step-2]`; // stable across this step's retries
|
|
1376
|
+
const { text } = await agent.generate({ prompt: `${marker} ${prompt}` });
|
|
1377
|
+
```
|
|
1378
|
+
|
|
1379
|
+
Then, after the settle‑wait, three states are distinguishable — and only one
|
|
1380
|
+
of them resubmits:
|
|
1381
|
+
|
|
1382
|
+
```ts
|
|
1383
|
+
import { chatMessagesToUIMessages } from "@coder/ai-sdk-agent";
|
|
1384
|
+
|
|
1385
|
+
// A long turn can commit more than a page of messages after the prompt —
|
|
1386
|
+
// page newest-first (the endpoint's default order) until the turn's user
|
|
1387
|
+
// message is on hand; "never submitted" must never be concluded from a
|
|
1388
|
+
// truncated read, or this recovery resubmits exactly the turn it was meant
|
|
1389
|
+
// to deduplicate.
|
|
1390
|
+
const fetched = [];
|
|
1391
|
+
let page = await agent.client.getMessages(agent.chatId!, { limit: 200 }, settle);
|
|
1392
|
+
fetched.push(...page.messages);
|
|
1393
|
+
while (!fetched.some((m) => m.role === "user") && page.has_more) {
|
|
1394
|
+
const oldestId = Math.min(...fetched.map((m) => m.id));
|
|
1395
|
+
page = await agent.client.getMessages(agent.chatId!, { before_id: oldestId, limit: 200 }, settle);
|
|
1396
|
+
fetched.push(...page.messages);
|
|
1397
|
+
}
|
|
1398
|
+
const transcript = chatMessagesToUIMessages(fetched); // chronological
|
|
1399
|
+
const lastUser = transcript.findLast((m) => m.role === "user");
|
|
1400
|
+
const submitted = lastUser?.parts.some((p) => p.type === "text" && p.text.includes(marker));
|
|
1401
|
+
|
|
1402
|
+
if (submitted && !cutShort && (status === "waiting" || status === "completed")) {
|
|
1403
|
+
// The attempt finished; only the recording was lost. Recover what
|
|
1404
|
+
// `generate().text` would have returned — the FINAL step's text. A turn
|
|
1405
|
+
// that crossed tool calls commits narration between them too, so cut the
|
|
1406
|
+
// turn's parts after the last tool part and join the text that follows.
|
|
1407
|
+
const parts = transcript
|
|
1408
|
+
.slice(transcript.indexOf(lastUser!) + 1)
|
|
1409
|
+
.filter((m) => m.role === "assistant")
|
|
1410
|
+
.flatMap((m) => m.parts);
|
|
1411
|
+
return parts
|
|
1412
|
+
.slice(parts.findLastIndex((p) => p.type === "dynamic-tool") + 1)
|
|
1413
|
+
.filter((p) => p.type === "text")
|
|
1414
|
+
.map((p) => p.text)
|
|
1415
|
+
.join("");
|
|
1416
|
+
}
|
|
1417
|
+
// Resubmit only what never finished: the prompt never reached the server
|
|
1418
|
+
// (!submitted), the turn failed (status "error" — normal retry judgment
|
|
1419
|
+
// applies), or the interrupt cut a live run short (cutShort — the model sees
|
|
1420
|
+
// its own aborted attempt in history and continues from it). One exit is
|
|
1421
|
+
// neither: a requires_action settle goes back to ledger reconciliation
|
|
1422
|
+
// (previous section) — never to recovery or resubmission.
|
|
1423
|
+
```
|
|
1424
|
+
|
|
1425
|
+
- **`cutShort` is the completed/interrupted discriminator — but a 409 only
|
|
1426
|
+
proves nothing is running _now_.** An interrupted attempt leaves the same
|
|
1427
|
+
marker and a truncated answer in history, so history alone can't tell
|
|
1428
|
+
"finished" from "stopped"; what can is whether any interrupt stopped the
|
|
1429
|
+
run. The inference "409 ⇒ the attempt finished" is therefore sound only
|
|
1430
|
+
when recovery's interrupt is the _first_ one that could have: true after a
|
|
1431
|
+
hard crash, false after a `requestTimeoutMs` expiry — the expiry already
|
|
1432
|
+
fired the SDK's own best‑effort interrupt, which may have stopped the run
|
|
1433
|
+
before recovery ever looked. Carry the reason recovery ran: when it follows
|
|
1434
|
+
a known timeout or abort — or a journaled earlier interrupt (write‑ahead,
|
|
1435
|
+
like the tool ledger, if the recovery pass can itself crash between its
|
|
1436
|
+
interrupt and the resubmit) — pin `cutShort` to `true` and let only the
|
|
1437
|
+
hard‑crash path trust the 409.
|
|
1438
|
+
- **The cut lands after the last tool part of _either_ kind.** History does
|
|
1439
|
+
not mark which tool calls were client tools
|
|
1440
|
+
([Rehydrating chat history](#rehydrating-chat-history)), so when a final
|
|
1441
|
+
step narrates _before_ a server‑side tool call, that preamble is dropped
|
|
1442
|
+
too — the recovered text is the answer following the turn's last tool
|
|
1443
|
+
activity.
|
|
1444
|
+
- **Steps with structured results recover the call's input, not the text.**
|
|
1445
|
+
A [structured output](#structured-output) step's answer is the
|
|
1446
|
+
`structured_output` call's typed input, which rehydrates as a
|
|
1447
|
+
`dynamic-tool` part on the recovered turn
|
|
1448
|
+
([Rehydrating chat history](#rehydrating-chat-history)) — the text join
|
|
1449
|
+
above would slice it away and return only the ack prose that follows.
|
|
1450
|
+
Recover the filed call instead — this scan replaces the text join
|
|
1451
|
+
_inside_ the recovery branch above, reusing its `parts` — validated
|
|
1452
|
+
client‑side exactly like the live path (rule 2 there — the schema is the
|
|
1453
|
+
real gate):
|
|
1454
|
+
|
|
1455
|
+
```ts
|
|
1456
|
+
// Scan backward to the last call that VALIDATES, as in the live path — a
|
|
1457
|
+
// schema-invalid re-file must not shadow a valid answer.
|
|
1458
|
+
const filed = parts
|
|
1459
|
+
.filter((p) => p.type === "dynamic-tool")
|
|
1460
|
+
.filter((p) => p.toolName === "structured_output");
|
|
1461
|
+
let answer: z.infer<typeof Answer> | undefined;
|
|
1462
|
+
for (const call of filed.reverse()) {
|
|
1463
|
+
const parsed = Answer.safeParse(call.input);
|
|
1464
|
+
if (parsed.success) {
|
|
1465
|
+
answer = parsed.data;
|
|
1466
|
+
break;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
// recovered ⇔ answer !== undefined
|
|
1470
|
+
```
|
|
1471
|
+
|
|
1120
1472
|
### Watch turn health from inside the step
|
|
1121
1473
|
|
|
1122
1474
|
[Transport events](#observability) classify how each segment ended without
|
|
@@ -1223,6 +1575,8 @@ export async function archiveWorkflowChat(workflowId: string): Promise<void> {
|
|
|
1223
1575
|
total wall‑clock.
|
|
1224
1576
|
- Let redials self‑heal; own every retry decision — a re‑run step resubmits
|
|
1225
1577
|
its prompt as a new user turn.
|
|
1578
|
+
- Before resubmitting after a crash: reconcile the tool ledger before any
|
|
1579
|
+
interrupt, and recover a finished attempt's result from history.
|
|
1226
1580
|
- Keep concurrent steps' fan‑out within workspace quota —
|
|
1227
1581
|
[Workspaces & quota](#workspaces--quota).
|
|
1228
1582
|
- Steps that don't need server‑side tools (plan / extract / synthesize) are
|
package/dist/index.d.ts
CHANGED
|
@@ -382,9 +382,24 @@ interface HttpErrorTransportEvent extends HttpTransportEventBase {
|
|
|
382
382
|
}
|
|
383
383
|
interface StreamTransportEventBase extends TransportEventBase {
|
|
384
384
|
chatId: string;
|
|
385
|
+
/**
|
|
386
|
+
* Identifies the reader (one `streamChatEvents` call) this event belongs
|
|
387
|
+
* to: monotonically increasing, allocated per call from one module-global
|
|
388
|
+
* (process-wide) counter — NOT per model or client instance, so
|
|
389
|
+
* `(chatId, reader, attempt)` stays unique for the lifetime of any
|
|
390
|
+
* subscriber, even one shared across instances (issue #94). Readers on
|
|
391
|
+
* other chats/clients advance the same counter, so ids are ordered but not
|
|
392
|
+
* dense per chat. Use it to tell a superseded reader's late events apart
|
|
393
|
+
* from the live reader's: a client-tool pause abandoned by the caller is
|
|
394
|
+
* closed fire-and-forget when the next turn dials its replacement, and the
|
|
395
|
+
* old reader's `ws:close` (or a raced-in frame) can emit after the new
|
|
396
|
+
* reader's `ws:dial` — both would otherwise be ambiguous at `attempt` 1.
|
|
397
|
+
*/
|
|
398
|
+
reader: number;
|
|
385
399
|
/**
|
|
386
400
|
* 1-based connection attempt within one reader (`streamChatEvents` call —
|
|
387
|
-
* with stream retention (#44), one per turn). Increments on every redial
|
|
401
|
+
* with stream retention (#44), one per turn). Increments on every redial
|
|
402
|
+
* and restarts at 1 for each new reader.
|
|
388
403
|
*/
|
|
389
404
|
attempt: number;
|
|
390
405
|
}
|
|
@@ -446,6 +461,17 @@ interface SegmentTransportEventBase extends TransportEventBase {
|
|
|
446
461
|
segment: number;
|
|
447
462
|
/** Undefined on a first turn's start until the chat is created. */
|
|
448
463
|
chatId?: string;
|
|
464
|
+
/**
|
|
465
|
+
* The reader (`streamChatEvents` call — the `reader` on `ws:*` events) that
|
|
466
|
+
* served this segment, correlating it with the connection's `ws:*` events.
|
|
467
|
+
* Absent on `segment:start`, which is emitted before the segment acquires
|
|
468
|
+
* its stream (a fresh turn dials only once the chat exists, and even a
|
|
469
|
+
* stream retained by a client-tool pause can still be replaced — stamping a
|
|
470
|
+
* predicted reader could lie), and on a settle when the segment failed
|
|
471
|
+
* before acquiring one. Segments of one turn share the reader: the stream
|
|
472
|
+
* is retained across `requires_action` pauses (#44).
|
|
473
|
+
*/
|
|
474
|
+
reader?: number;
|
|
449
475
|
}
|
|
450
476
|
/**
|
|
451
477
|
* A segment (one model round-trip: submission plus the server run until it
|
|
@@ -531,6 +557,13 @@ interface StreamChatEventsOptions {
|
|
|
531
557
|
* listeners are registered. See {@link CoderTransportEvent}.
|
|
532
558
|
*/
|
|
533
559
|
onTransportEvent?: TransportEventHandler;
|
|
560
|
+
/**
|
|
561
|
+
* Reader id stamped on this call's `ws:*` events, for callers that need to
|
|
562
|
+
* know it (the model records it to stamp `segment:settle` with the reader
|
|
563
|
+
* that served the segment). Must come from {@link nextStreamReaderId};
|
|
564
|
+
* allocated automatically when absent.
|
|
565
|
+
*/
|
|
566
|
+
reader?: number;
|
|
534
567
|
}
|
|
535
568
|
/**
|
|
536
569
|
* Opens the chatd `/stream` WebSocket and yields decoded {@link ChatStreamEvent}s
|
|
@@ -678,6 +711,8 @@ declare class CoderChatClient {
|
|
|
678
711
|
streamEvents(chatId: string, opts?: {
|
|
679
712
|
afterId?: number;
|
|
680
713
|
signal?: AbortSignal;
|
|
714
|
+
/** Pre-allocated reader id for `ws:*` events — see {@link streamChatEvents}. */
|
|
715
|
+
reader?: number;
|
|
681
716
|
}): AsyncGenerator<ChatStreamEvent, void, void>;
|
|
682
717
|
/**
|
|
683
718
|
* Watch lifecycle events (status/title changes, creation, deletion, …) for
|