@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.
- package/README.md +121 -39
- 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
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
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<
|
|
1138
|
-
set(chatId: string,
|
|
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
|
-
|
|
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
|
|
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.
|
|
1335
|
-
//
|
|
1336
|
-
await journal.set(agent.chatId!,
|
|
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
|
|
1372
|
-
the server stores the prompt verbatim, so
|
|
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.
|
|
1405
|
-
//
|
|
1406
|
-
//
|
|
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
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
// (previous section) — never to
|
|
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
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
-
|
|
1439
|
-
not
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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)) —
|
|
1449
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
45
|
+
"@ai-sdk/provider": "4.0.8",
|
|
46
46
|
"ws": "^8.21.3"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@ai-sdk/react": "4.0.
|
|
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.
|
|
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.
|
|
56
|
+
"vitest": "^4.1.11",
|
|
57
57
|
"zod": "4.4.3"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|