@coder/ai-sdk-agent 0.7.0 → 0.7.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.
Files changed (2) hide show
  1. package/README.md +121 -39
  2. package/package.json +5 -5
package/README.md CHANGED
@@ -1128,15 +1128,31 @@ checkpoint:
1128
1128
  // to interrupting — and journals each action write-ahead, so a recovery
1129
1129
  // pass that dies mid-recovery is visible — and classifiable — to the next.
1130
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).
1131
+ // Journal entries are per-ATTEMPT, so the chat id alone cannot scope them:
1132
+ // a verdict from attempt N must not classify attempt N+1 (double crash),
1133
+ // and a later step's recovery on the same chat must not inherit this
1134
+ // one's. Every entry pins the step marker and an attempt count at write
1135
+ // time; a consult applies an entry only while both still match, so each
1136
+ // resubmission retires every earlier verdict by itself — there is no
1137
+ // clear step to forget.
1138
+ type JournalEntry = {
1139
+ // "submitting" is written ahead of every send on a checkpointed chat;
1140
+ // "resumed" / "cut-short" are the ledger reconcile's verdict (both in
1141
+ // the next sections).
1142
+ state: "submitting" | "resumed" | "cut-short";
1143
+ marker: string; // the step-scoped marker ("Recover the result…", below)
1144
+ attempt: number; // markerAttempts() at write time
1145
+ at: number; // write time — bounds the in-flight-send wait below
1146
+ };
1136
1147
  declare const journal: {
1137
- get(chatId: string): Promise<"resumed" | "cut-short" | undefined>;
1138
- set(chatId: string, state: "resumed" | "cut-short"): Promise<void>;
1148
+ get(chatId: string): Promise<JournalEntry | undefined>;
1149
+ set(chatId: string, entry: JournalEntry): Promise<void>;
1139
1150
  };
1151
+ // This step's attempts committed so far: user messages in the converted
1152
+ // transcript carrying the marker. Steps run sequentially, so they sit
1153
+ // contiguous at the tail of history — page newest-first (as the recovery
1154
+ // scan below already does) and stop at the first user message without it.
1155
+ declare function markerAttempts(): Promise<number>;
1140
1156
 
1141
1157
  // Bounded: a stalled deployment must fail the step, not hang it.
1142
1158
  const deadline = AbortSignal.timeout(15_000);
@@ -1144,7 +1160,11 @@ checkpoint:
1144
1160
  // below: a 409 means there was nothing to stop.
1145
1161
  let cutShort = true;
1146
1162
  let { status } = await agent.client.getChat(agent.chatId!, deadline);
1147
- const journaled = await journal.get(agent.chatId!);
1163
+ // Recompute what the entry pinned at write time; a mismatch on either
1164
+ // means it classifies an earlier attempt (or another step's) — dead, skip.
1165
+ const entry = await journal.get(agent.chatId!);
1166
+ const attempts = await markerAttempts();
1167
+ const journaled = entry?.marker === marker && entry.attempt === attempts ? entry : undefined;
1148
1168
  if (status === "requires_action") {
1149
1169
  // The crash hit a client tool mid-execution: this pause is the only
1150
1170
  // pending record of its committed effects. Never interrupt it away —
@@ -1152,8 +1172,8 @@ checkpoint:
1152
1172
  // (reviving the run — nothing was cut short) or interrupts itself only
1153
1173
  // when nothing committed.
1154
1174
  cutShort = await reconcileClientTools();
1155
- } else if (journaled) {
1156
- // A previous recovery pass acted on this chat, then died before the
1175
+ } else if (journaled && journaled.state !== "submitting") {
1176
+ // A previous recovery pass acted on this attempt, then died before the
1157
1177
  // step's result was recorded. Its journaled state is the verdict:
1158
1178
  // "resumed" — it revived the run by submitting recorded results;
1159
1179
  // interrupting now would cut down a run doing the step's work, and the
@@ -1161,7 +1181,7 @@ checkpoint:
1161
1181
  // idempotency keys. Let it finish; nothing was cut short.
1162
1182
  // "cut-short" — it went on to interrupt (an unstarted sibling in the
1163
1183
  // batch); the attempt is truncated, so the check below must resubmit.
1164
- cutShort = journaled === "cut-short";
1184
+ cutShort = journaled.state === "cut-short";
1165
1185
  if (cutShort) {
1166
1186
  // The journal records write-ahead INTENT, not a landed interrupt: the
1167
1187
  // pass may have died in between, leaving the revived run working.
@@ -1182,6 +1202,24 @@ checkpoint:
1182
1202
  else throw err;
1183
1203
  });
1184
1204
  }
1205
+ // One thing can still be in flight here: the crashed attempt's own SEND. A
1206
+ // matching "submitting" entry — its attempt count has not grown — means the
1207
+ // process died with `createChatMessage` possibly en route, and the server
1208
+ // can commit it AFTER every read above (the 409 only said nothing was
1209
+ // running THEN). Sleep out what remains of the entry's commit window —
1210
+ // requestTimeoutMs past the write is the conservative horizon, and a
1211
+ // restart has usually consumed it already — so a late-landing turn starts
1212
+ // before the settle poll below and the next section's negative history
1213
+ // read is past the window. Then recount: a send that never landed OVER AN
1214
+ // EXISTING attempt pins cutShort — the step only ever plans a resubmission
1215
+ // over a cut-short, errored, or unsubmitted attempt, so the write-ahead
1216
+ // that superseded a "cut-short" verdict still carries its consequence, and
1217
+ // the scan below cannot mistake the old attempt's truncated tail for a
1218
+ // finished result.
1219
+ if (journaled?.state === "submitting") {
1220
+ await new Promise((r) => setTimeout(r, Math.max(0, journaled.at + 300_000 - Date.now())));
1221
+ if ((await markerAttempts()) === journaled.attempt && journaled.attempt > 0) cutShort = true;
1222
+ }
1185
1223
  // The 15 s interrupt bound must not cap what follows: a reconciled pause
1186
1224
  // revives a full model/tool continuation. Budget the settle poll — and the
1187
1225
  // history reads after it — on the turn scale, like the step itself.
@@ -1276,7 +1314,8 @@ event — and the ledger holds the verdict on each:
1276
1314
  // The step's configured ToolSet names: history does not record which calls
1277
1315
  // were client tools (see [Rehydrating chat history]), so — like the SDK's
1278
1316
  // own derivation — restrict to the names this step registers; a server/MCP
1279
- // call must never reach the ledger as "unstarted".
1317
+ // call must never reach the ledger as "unstarted". (The recovery scan's
1318
+ // final-text cut reuses this set — next section.)
1280
1319
  const clientTools = new Set(["charge_card"]);
1281
1320
 
1282
1321
  // Pending = the last assistant message's client (non-provider-executed,
@@ -1331,9 +1370,14 @@ if (results.length > 0 || unstarted) {
1331
1370
  // call — a mixed batch journals "cut-short" before even its submission,
1332
1371
  // so no crash point can leave a stale "resumed" masking the pending
1333
1372
  // 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");
1373
+ // re-drives the journaled intent. Scoped to THIS attempt: a resubmission
1374
+ // grows the marker count and retires the verdict on its own.
1375
+ await journal.set(agent.chatId!, {
1376
+ state: unstarted ? "cut-short" : "resumed",
1377
+ marker,
1378
+ attempt: await markerAttempts(),
1379
+ at: Date.now(),
1380
+ });
1337
1381
  }
1338
1382
  if (results.length > 0) await agent.client.submitToolResults(agent.chatId!, { results }, deadline);
1339
1383
  if (unstarted) await agent.client.interruptChat(agent.chatId!, deadline);
@@ -1368,11 +1412,29 @@ step's return value was recorded.** History now holds a perfectly good
1368
1412
  result; a retried step that blindly resubmits runs the whole turn again —
1369
1413
  duplicated output, repeated workspace/MCP/tool effects. This is the
1370
1414
  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:
1415
+ the chat itself is the record that narrows it to one honest window (below).
1416
+ Make attempts matchable first — the server stores the prompt verbatim, so
1417
+ embed a step‑scoped marker — and journal every send before it leaves the
1418
+ process:
1373
1419
 
1374
1420
  ```ts
1375
1421
  const marker = `[${workflowId}/step-2]`; // stable across this step's retries
1422
+ // Write-ahead, BEFORE the send — first attempt and recovery resubmit alike.
1423
+ // A crash before this write proves no send ever started; a crash after it
1424
+ // leaves a dated entry whose commit window the settle-wait sleeps out. On a
1425
+ // resubmission this write supersedes the "cut-short" verdict it acts on —
1426
+ // safely: an unlanded send over an existing attempt makes the settle-wait
1427
+ // pin cutShort right back. A first turn has no chat id to journal under: its
1428
+ // send-crash window belongs to the empty-checkpoint sweep
1429
+ // ("Recover after a crash").
1430
+ if (agent.chatId) {
1431
+ await journal.set(agent.chatId, {
1432
+ state: "submitting",
1433
+ marker,
1434
+ attempt: await markerAttempts(),
1435
+ at: Date.now(),
1436
+ });
1437
+ }
1376
1438
  const { text } = await agent.generate({ prompt: `${marker} ${prompt}` });
1377
1439
  ```
1378
1440
 
@@ -1401,25 +1463,27 @@ const submitted = lastUser?.parts.some((p) => p.type === "text" && p.text.includ
1401
1463
 
1402
1464
  if (submitted && !cutShort && (status === "waiting" || status === "completed")) {
1403
1465
  // 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.
1466
+ // `generate().text` would have returned — the FINAL step's text. Only a
1467
+ // CLIENT tool ends an AI SDK step, and narration lands on both sides of a
1468
+ // server-side call, so cut after the last part named in `clientTools`
1469
+ // (shared with the reconcile above) and join the text that follows.
1407
1470
  const parts = transcript
1408
1471
  .slice(transcript.indexOf(lastUser!) + 1)
1409
1472
  .filter((m) => m.role === "assistant")
1410
1473
  .flatMap((m) => m.parts);
1411
1474
  return parts
1412
- .slice(parts.findLastIndex((p) => p.type === "dynamic-tool") + 1)
1475
+ .slice(parts.findLastIndex((p) => p.type === "dynamic-tool" && clientTools.has(p.toolName)) + 1)
1413
1476
  .filter((p) => p.type === "text")
1414
1477
  .map((p) => p.text)
1415
1478
  .join("");
1416
1479
  }
1417
1480
  // 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.
1481
+ // (!submitted — sound only behind the write-ahead; see below), the turn
1482
+ // failed (status "error" — normal retry judgment applies), or the interrupt
1483
+ // cut a live run short (cutShort — the model sees its own aborted attempt in
1484
+ // history and continues from it). One exit is neither: a requires_action
1485
+ // settle goes back to ledger reconciliation (previous section) — never to
1486
+ // recovery or resubmission.
1423
1487
  ```
1424
1488
 
1425
1489
  - **`cutShort` is the completed/interrupted discriminator — but a 409 only
@@ -1431,22 +1495,39 @@ if (submitted && !cutShort && (status === "waiting" || status === "completed"))
1431
1495
  hard crash, false after a `requestTimeoutMs` expiry — the expiry already
1432
1496
  fired the SDK's own best‑effort interrupt, which may have stopped the run
1433
1497
  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.
1498
+ a known timeout or abort — or a matching journaled `"cut-short"`, or an
1499
+ unlanded `"submitting"` over an existing attempt (the settle‑wait already
1500
+ pins both) — pin `cutShort` to `true` and let only the hard‑crash path
1501
+ trust the 409.
1502
+ - **`!submitted` races the crashed attempt's own send.** A crash kills the
1503
+ process, not necessarily its in‑flight `createChatMessage` — the server can
1504
+ commit that request _after_ an interrupt 409'd and a history read saw no
1505
+ marker, and a resubmit on that evidence queues the turn twice, duplicating
1506
+ its workspace/MCP/tool effects. The write‑ahead is what makes the negative
1507
+ read honest: no matching `"submitting"` entry proves no send ever started,
1508
+ and a matching one held recovery at the settle‑wait until the entry's
1509
+ commit horizon passed. That narrows the window; only server‑enforced
1510
+ submission idempotency could close it, and `POST /chats/{id}/messages`
1511
+ offers none today. A commit that outlasts even the horizon still lands
1512
+ under the same marker, so the duplicate is at least visible — in history,
1513
+ and to the next consult's attempt count.
1514
+ - **The cut lands at the last _configured client‑tool_ boundary.** History
1515
+ does not mark which calls were client tools
1516
+ ([Rehydrating chat history](#rehydrating-chat-history)), so the cut
1517
+ classifies by name against the reconcile's `clientTools` set — narration
1518
+ before a trailing _server‑side_ call survives, matching what
1519
+ `generate().text` aggregates. What name‑matching cannot see: a call
1520
+ recorded under a name the step no longer configures (a tool renamed
1521
+ between deploys) reads as server‑side and leaks earlier‑step narration
1522
+ into the result, and a server tool sharing a configured name still cuts
1523
+ final‑step narration away.
1444
1524
  - **Steps with structured results recover the call's input, not the text.**
1445
1525
  A [structured output](#structured-output) step's answer is the
1446
1526
  `structured_output` call's typed input, which rehydrates as a
1447
1527
  `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.
1528
+ ([Rehydrating chat history](#rehydrating-chat-history)) — and
1529
+ `structured_output` is one of the step's configured client tools, so the
1530
+ text join above cuts at it and returns only the ack prose that follows.
1450
1531
  Recover the filed call instead — this scan replaces the text join
1451
1532
  _inside_ the recovery branch above, reusing its `parts` — validated
1452
1533
  client‑side exactly like the live path (rule 2 there — the schema is the
@@ -1576,7 +1657,8 @@ export async function archiveWorkflowChat(workflowId: string): Promise<void> {
1576
1657
  - Let redials self‑heal; own every retry decision — a re‑run step resubmits
1577
1658
  its prompt as a new user turn.
1578
1659
  - Before resubmitting after a crash: reconcile the tool ledger before any
1579
- interrupt, and recover a finished attempt's result from history.
1660
+ interrupt, recover a finished attempt's result from history, and trust
1661
+ "never submitted" only past the journaled send's commit window.
1580
1662
  - Keep concurrent steps' fan‑out within workspace quota —
1581
1663
  [Workspaces & quota](#workspaces--quota).
1582
1664
  - Steps that don't need server‑side tools (plan / extract / synthesize) are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coder/ai-sdk-agent",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "A Vercel AI SDK-compliant agent backed by Coder Agents, Coder's server-side agent runtime. `new CoderAgent()` returns a real AI SDK Agent (generate/stream).",
5
5
  "keywords": [
6
6
  "agent",
@@ -42,18 +42,18 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@ai-sdk/provider": "4.0.7",
45
+ "@ai-sdk/provider": "4.0.8",
46
46
  "ws": "^8.21.3"
47
47
  },
48
48
  "devDependencies": {
49
- "@ai-sdk/react": "4.0.69",
49
+ "@ai-sdk/react": "4.0.82",
50
50
  "@types/node": "^26.2.0",
51
51
  "@types/ws": "^8.18.1",
52
- "ai": "^7.0.66",
52
+ "ai": "^7.0.79",
53
53
  "tsup": "^8.5.1",
54
54
  "tsx": "^4.23.12",
55
55
  "typescript": "^6.0.3",
56
- "vitest": "^4.1.10",
56
+ "vitest": "^4.1.11",
57
57
  "zod": "4.4.3"
58
58
  },
59
59
  "peerDependencies": {