@coder/ai-sdk-agent 0.6.3 → 0.7.1
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 +145 -53
- package/dist/index.d.ts +36 -1
- package/dist/index.js +32 -9
- 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
|
|
@@ -1118,15 +1128,31 @@ checkpoint:
|
|
|
1118
1128
|
// to interrupting — and journals each action write-ahead, so a recovery
|
|
1119
1129
|
// pass that dies mid-recovery is visible — and classifiable — to the next.
|
|
1120
1130
|
declare function reconcileClientTools(): Promise<boolean>;
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
1125
|
-
//
|
|
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
|
+
};
|
|
1126
1147
|
declare const journal: {
|
|
1127
|
-
get(chatId: string): Promise<
|
|
1128
|
-
set(chatId: string,
|
|
1148
|
+
get(chatId: string): Promise<JournalEntry | undefined>;
|
|
1149
|
+
set(chatId: string, entry: JournalEntry): Promise<void>;
|
|
1129
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>;
|
|
1130
1156
|
|
|
1131
1157
|
// Bounded: a stalled deployment must fail the step, not hang it.
|
|
1132
1158
|
const deadline = AbortSignal.timeout(15_000);
|
|
@@ -1134,7 +1160,11 @@ checkpoint:
|
|
|
1134
1160
|
// below: a 409 means there was nothing to stop.
|
|
1135
1161
|
let cutShort = true;
|
|
1136
1162
|
let { status } = await agent.client.getChat(agent.chatId!, deadline);
|
|
1137
|
-
|
|
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;
|
|
1138
1168
|
if (status === "requires_action") {
|
|
1139
1169
|
// The crash hit a client tool mid-execution: this pause is the only
|
|
1140
1170
|
// pending record of its committed effects. Never interrupt it away —
|
|
@@ -1142,8 +1172,8 @@ checkpoint:
|
|
|
1142
1172
|
// (reviving the run — nothing was cut short) or interrupts itself only
|
|
1143
1173
|
// when nothing committed.
|
|
1144
1174
|
cutShort = await reconcileClientTools();
|
|
1145
|
-
} else if (journaled) {
|
|
1146
|
-
// 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
|
|
1147
1177
|
// step's result was recorded. Its journaled state is the verdict:
|
|
1148
1178
|
// "resumed" — it revived the run by submitting recorded results;
|
|
1149
1179
|
// interrupting now would cut down a run doing the step's work, and the
|
|
@@ -1151,7 +1181,7 @@ checkpoint:
|
|
|
1151
1181
|
// idempotency keys. Let it finish; nothing was cut short.
|
|
1152
1182
|
// "cut-short" — it went on to interrupt (an unstarted sibling in the
|
|
1153
1183
|
// batch); the attempt is truncated, so the check below must resubmit.
|
|
1154
|
-
cutShort = journaled === "cut-short";
|
|
1184
|
+
cutShort = journaled.state === "cut-short";
|
|
1155
1185
|
if (cutShort) {
|
|
1156
1186
|
// The journal records write-ahead INTENT, not a landed interrupt: the
|
|
1157
1187
|
// pass may have died in between, leaving the revived run working.
|
|
@@ -1172,6 +1202,24 @@ checkpoint:
|
|
|
1172
1202
|
else throw err;
|
|
1173
1203
|
});
|
|
1174
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
|
+
}
|
|
1175
1223
|
// The 15 s interrupt bound must not cap what follows: a reconciled pause
|
|
1176
1224
|
// revives a full model/tool continuation. Budget the settle poll — and the
|
|
1177
1225
|
// history reads after it — on the turn scale, like the step itself.
|
|
@@ -1266,7 +1314,8 @@ event — and the ledger holds the verdict on each:
|
|
|
1266
1314
|
// The step's configured ToolSet names: history does not record which calls
|
|
1267
1315
|
// were client tools (see [Rehydrating chat history]), so — like the SDK's
|
|
1268
1316
|
// own derivation — restrict to the names this step registers; a server/MCP
|
|
1269
|
-
// 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.)
|
|
1270
1319
|
const clientTools = new Set(["charge_card"]);
|
|
1271
1320
|
|
|
1272
1321
|
// Pending = the last assistant message's client (non-provider-executed,
|
|
@@ -1321,9 +1370,14 @@ if (results.length > 0 || unstarted) {
|
|
|
1321
1370
|
// call — a mixed batch journals "cut-short" before even its submission,
|
|
1322
1371
|
// so no crash point can leave a stale "resumed" masking the pending
|
|
1323
1372
|
// interrupt. Every crash then either re-reconciles the stable pause or
|
|
1324
|
-
// re-drives the journaled intent.
|
|
1325
|
-
//
|
|
1326
|
-
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
|
+
});
|
|
1327
1381
|
}
|
|
1328
1382
|
if (results.length > 0) await agent.client.submitToolResults(agent.chatId!, { results }, deadline);
|
|
1329
1383
|
if (unstarted) await agent.client.interruptChat(agent.chatId!, deadline);
|
|
@@ -1358,11 +1412,29 @@ step's return value was recorded.** History now holds a perfectly good
|
|
|
1358
1412
|
result; a retried step that blindly resubmits runs the whole turn again —
|
|
1359
1413
|
duplicated output, repeated workspace/MCP/tool effects. This is the
|
|
1360
1414
|
at‑least‑once boundary every durable engine has around external effects, and
|
|
1361
|
-
the chat itself is the record that
|
|
1362
|
-
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:
|
|
1363
1419
|
|
|
1364
1420
|
```ts
|
|
1365
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
|
+
}
|
|
1366
1438
|
const { text } = await agent.generate({ prompt: `${marker} ${prompt}` });
|
|
1367
1439
|
```
|
|
1368
1440
|
|
|
@@ -1391,25 +1463,27 @@ const submitted = lastUser?.parts.some((p) => p.type === "text" && p.text.includ
|
|
|
1391
1463
|
|
|
1392
1464
|
if (submitted && !cutShort && (status === "waiting" || status === "completed")) {
|
|
1393
1465
|
// The attempt finished; only the recording was lost. Recover what
|
|
1394
|
-
// `generate().text` would have returned — the FINAL step's text.
|
|
1395
|
-
//
|
|
1396
|
-
//
|
|
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.
|
|
1397
1470
|
const parts = transcript
|
|
1398
1471
|
.slice(transcript.indexOf(lastUser!) + 1)
|
|
1399
1472
|
.filter((m) => m.role === "assistant")
|
|
1400
1473
|
.flatMap((m) => m.parts);
|
|
1401
1474
|
return parts
|
|
1402
|
-
.slice(parts.findLastIndex((p) => p.type === "dynamic-tool") + 1)
|
|
1475
|
+
.slice(parts.findLastIndex((p) => p.type === "dynamic-tool" && clientTools.has(p.toolName)) + 1)
|
|
1403
1476
|
.filter((p) => p.type === "text")
|
|
1404
1477
|
.map((p) => p.text)
|
|
1405
1478
|
.join("");
|
|
1406
1479
|
}
|
|
1407
1480
|
// Resubmit only what never finished: the prompt never reached the server
|
|
1408
|
-
// (!submitted
|
|
1409
|
-
//
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
// (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.
|
|
1413
1487
|
```
|
|
1414
1488
|
|
|
1415
1489
|
- **`cutShort` is the completed/interrupted discriminator — but a 409 only
|
|
@@ -1421,22 +1495,39 @@ if (submitted && !cutShort && (status === "waiting" || status === "completed"))
|
|
|
1421
1495
|
hard crash, false after a `requestTimeoutMs` expiry — the expiry already
|
|
1422
1496
|
fired the SDK's own best‑effort interrupt, which may have stopped the run
|
|
1423
1497
|
before recovery ever looked. Carry the reason recovery ran: when it follows
|
|
1424
|
-
a known timeout or abort — or a journaled
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
-
|
|
1429
|
-
not
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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.
|
|
1434
1524
|
- **Steps with structured results recover the call's input, not the text.**
|
|
1435
1525
|
A [structured output](#structured-output) step's answer is the
|
|
1436
1526
|
`structured_output` call's typed input, which rehydrates as a
|
|
1437
1527
|
`dynamic-tool` part on the recovered turn
|
|
1438
|
-
([Rehydrating chat history](#rehydrating-chat-history)) —
|
|
1439
|
-
|
|
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.
|
|
1440
1531
|
Recover the filed call instead — this scan replaces the text join
|
|
1441
1532
|
_inside_ the recovery branch above, reusing its `parts` — validated
|
|
1442
1533
|
client‑side exactly like the live path (rule 2 there — the schema is the
|
|
@@ -1566,7 +1657,8 @@ export async function archiveWorkflowChat(workflowId: string): Promise<void> {
|
|
|
1566
1657
|
- Let redials self‑heal; own every retry decision — a re‑run step resubmits
|
|
1567
1658
|
its prompt as a new user turn.
|
|
1568
1659
|
- Before resubmitting after a crash: reconcile the tool ledger before any
|
|
1569
|
-
interrupt,
|
|
1660
|
+
interrupt, recover a finished attempt's result from history, and trust
|
|
1661
|
+
"never submitted" only past the journaled send's commit window.
|
|
1570
1662
|
- Keep concurrent steps' fan‑out within workspace quota —
|
|
1571
1663
|
[Workspaces & quota](#workspaces--quota).
|
|
1572
1664
|
- 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
|
package/dist/index.js
CHANGED
|
@@ -185,6 +185,11 @@ function httpToWs(baseUrl) {
|
|
|
185
185
|
if (baseUrl.startsWith("http://")) return `ws://${baseUrl.slice("http://".length)}`;
|
|
186
186
|
return baseUrl;
|
|
187
187
|
}
|
|
188
|
+
var readerSeq = 0;
|
|
189
|
+
function nextStreamReaderId() {
|
|
190
|
+
readerSeq += 1;
|
|
191
|
+
return readerSeq;
|
|
192
|
+
}
|
|
188
193
|
var STREAM_BACKOFF_INITIAL_MS = 1e3;
|
|
189
194
|
var STREAM_BACKOFF_CAP_MS = 3e4;
|
|
190
195
|
var STREAM_MAX_CONSECUTIVE_FAILURES = 5;
|
|
@@ -197,7 +202,8 @@ function streamChatEvents(options) {
|
|
|
197
202
|
if (external.aborted) chain();
|
|
198
203
|
else external.addEventListener("abort", chain, { once: true });
|
|
199
204
|
}
|
|
200
|
-
const
|
|
205
|
+
const reader = options.reader ?? nextStreamReaderId();
|
|
206
|
+
const inner = streamChatEventsLoop({ ...options, reader, signal: controller.signal });
|
|
201
207
|
const detach = () => external?.removeEventListener("abort", chain);
|
|
202
208
|
const finish = async () => {
|
|
203
209
|
controller.abort();
|
|
@@ -240,7 +246,7 @@ function streamChatEvents(options) {
|
|
|
240
246
|
};
|
|
241
247
|
}
|
|
242
248
|
async function* streamChatEventsLoop(options) {
|
|
243
|
-
const { baseUrl, token, chatId, signal } = options;
|
|
249
|
+
const { baseUrl, token, chatId, signal, reader } = options;
|
|
244
250
|
const factory = options.webSocketFactory ?? defaultFactory;
|
|
245
251
|
const emit = safeTransportEmitter(options.onTransportEvent);
|
|
246
252
|
let attempt = 0;
|
|
@@ -269,15 +275,15 @@ async function* streamChatEventsLoop(options) {
|
|
|
269
275
|
resolveNext = void 0;
|
|
270
276
|
};
|
|
271
277
|
attempt += 1;
|
|
272
|
-
emit?.({ type: "ws:dial", chatId, attempt, url, timestamp: Date.now() });
|
|
278
|
+
emit?.({ type: "ws:dial", chatId, reader, attempt, url, timestamp: Date.now() });
|
|
273
279
|
let ws;
|
|
274
280
|
try {
|
|
275
281
|
ws = factory(url, { headers: { "Coder-Session-Token": token } });
|
|
276
282
|
} catch (err) {
|
|
277
283
|
if (emit) {
|
|
278
284
|
const message = err instanceof Error ? err.message : String(err);
|
|
279
|
-
emit({ type: "ws:error", chatId, attempt, message, timestamp: Date.now() });
|
|
280
|
-
emit({ type: "ws:close", chatId, attempt, timestamp: Date.now() });
|
|
285
|
+
emit({ type: "ws:error", chatId, reader, attempt, message, timestamp: Date.now() });
|
|
286
|
+
emit({ type: "ws:close", chatId, reader, attempt, timestamp: Date.now() });
|
|
281
287
|
}
|
|
282
288
|
throw err;
|
|
283
289
|
}
|
|
@@ -307,6 +313,7 @@ async function* streamChatEventsLoop(options) {
|
|
|
307
313
|
emit?.({
|
|
308
314
|
type: "ws:error",
|
|
309
315
|
chatId,
|
|
316
|
+
reader,
|
|
310
317
|
attempt,
|
|
311
318
|
message: "failed to parse chat stream frame",
|
|
312
319
|
timestamp: Date.now()
|
|
@@ -323,6 +330,7 @@ async function* streamChatEventsLoop(options) {
|
|
|
323
330
|
emit?.({
|
|
324
331
|
type: "ws:event",
|
|
325
332
|
chatId,
|
|
333
|
+
reader,
|
|
326
334
|
attempt,
|
|
327
335
|
event: e,
|
|
328
336
|
timestamp: Date.now()
|
|
@@ -333,6 +341,7 @@ async function* streamChatEventsLoop(options) {
|
|
|
333
341
|
emit?.({
|
|
334
342
|
type: "ws:event",
|
|
335
343
|
chatId,
|
|
344
|
+
reader,
|
|
336
345
|
attempt,
|
|
337
346
|
event: batch,
|
|
338
347
|
timestamp: Date.now()
|
|
@@ -343,7 +352,7 @@ async function* streamChatEventsLoop(options) {
|
|
|
343
352
|
const onError = (ev) => {
|
|
344
353
|
if (finished) return;
|
|
345
354
|
const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "chat stream socket error";
|
|
346
|
-
emit?.({ type: "ws:error", chatId, attempt, message, timestamp: Date.now() });
|
|
355
|
+
emit?.({ type: "ws:error", chatId, reader, attempt, message, timestamp: Date.now() });
|
|
347
356
|
const status = upgradeStatus(message);
|
|
348
357
|
failure = status !== void 0 && status >= 400 && status < 500 && !TRANSIENT_UPGRADE_STATUSES.has(status) ? new CoderApiError({ status, method: "GET", path, message }) : new CoderAgentError(message);
|
|
349
358
|
finished = true;
|
|
@@ -355,6 +364,7 @@ async function* streamChatEventsLoop(options) {
|
|
|
355
364
|
const closeEvent = {
|
|
356
365
|
type: "ws:close",
|
|
357
366
|
chatId,
|
|
367
|
+
reader,
|
|
358
368
|
attempt,
|
|
359
369
|
timestamp: Date.now()
|
|
360
370
|
};
|
|
@@ -365,7 +375,7 @@ async function* streamChatEventsLoop(options) {
|
|
|
365
375
|
finished = true;
|
|
366
376
|
wake();
|
|
367
377
|
};
|
|
368
|
-
const onOpen = emit ? () => emit({ type: "ws:open", chatId, attempt, timestamp: Date.now() }) : void 0;
|
|
378
|
+
const onOpen = emit ? () => emit({ type: "ws:open", chatId, reader, attempt, timestamp: Date.now() }) : void 0;
|
|
369
379
|
if (onOpen) ws.addEventListener("open", onOpen);
|
|
370
380
|
ws.addEventListener("message", onMessage);
|
|
371
381
|
ws.addEventListener("error", onError);
|
|
@@ -426,7 +436,7 @@ async function* streamChatEventsLoop(options) {
|
|
|
426
436
|
}
|
|
427
437
|
if (emit && !closeEmitted) {
|
|
428
438
|
closeEmitted = true;
|
|
429
|
-
emit({ type: "ws:close", chatId, attempt, timestamp: Date.now() });
|
|
439
|
+
emit({ type: "ws:close", chatId, reader, attempt, timestamp: Date.now() });
|
|
430
440
|
}
|
|
431
441
|
}
|
|
432
442
|
if (signal?.aborted) return;
|
|
@@ -451,6 +461,7 @@ async function* streamChatEventsLoop(options) {
|
|
|
451
461
|
emit?.({
|
|
452
462
|
type: "ws:redial",
|
|
453
463
|
chatId,
|
|
464
|
+
reader,
|
|
454
465
|
attempt,
|
|
455
466
|
consecutiveFailures: failures,
|
|
456
467
|
maxConsecutiveFailures: STREAM_MAX_CONSECUTIVE_FAILURES,
|
|
@@ -867,6 +878,7 @@ var CoderChatClient = class {
|
|
|
867
878
|
chatId,
|
|
868
879
|
afterId: opts?.afterId,
|
|
869
880
|
signal: opts?.signal,
|
|
881
|
+
reader: opts?.reader,
|
|
870
882
|
webSocketFactory: this.#webSocketFactory,
|
|
871
883
|
onTransportEvent: this.#onTransportEvent
|
|
872
884
|
});
|
|
@@ -1145,6 +1157,12 @@ function dynamicToolNames(tools) {
|
|
|
1145
1157
|
// src/model/session-stream.ts
|
|
1146
1158
|
var SessionChatStream = class {
|
|
1147
1159
|
chatId;
|
|
1160
|
+
/**
|
|
1161
|
+
* The reader id stamped on the wrapped reader's `ws:*` transport events —
|
|
1162
|
+
* the model stamps it on `segment:settle` so segments correlate with the
|
|
1163
|
+
* connection that served them (issue #94).
|
|
1164
|
+
*/
|
|
1165
|
+
reader;
|
|
1148
1166
|
#events;
|
|
1149
1167
|
#controller = new AbortController();
|
|
1150
1168
|
#pending;
|
|
@@ -1153,6 +1171,7 @@ var SessionChatStream = class {
|
|
|
1153
1171
|
#closePromise;
|
|
1154
1172
|
constructor(opts) {
|
|
1155
1173
|
this.chatId = opts.chatId;
|
|
1174
|
+
this.reader = opts.reader;
|
|
1156
1175
|
this.#events = opts.open(this.#controller.signal);
|
|
1157
1176
|
}
|
|
1158
1177
|
get closed() {
|
|
@@ -1811,9 +1830,11 @@ var CoderLanguageModel = class {
|
|
|
1811
1830
|
}
|
|
1812
1831
|
this.#retainedStream = void 0;
|
|
1813
1832
|
if (retained) void retained.close();
|
|
1833
|
+
const reader = nextStreamReaderId();
|
|
1814
1834
|
const created = new SessionChatStream({
|
|
1815
1835
|
chatId,
|
|
1816
|
-
|
|
1836
|
+
reader,
|
|
1837
|
+
open: (streamSignal) => this.#config.client.streamEvents(chatId, { afterId, signal: streamSignal, reader })
|
|
1817
1838
|
});
|
|
1818
1839
|
this.#retainedStream = created;
|
|
1819
1840
|
return created;
|
|
@@ -1977,6 +1998,7 @@ var CoderLanguageModel = class {
|
|
|
1977
1998
|
};
|
|
1978
1999
|
const chatId = segmentChat.chatId ?? this.#chatId;
|
|
1979
2000
|
if (chatId !== void 0) settleEvent.chatId = chatId;
|
|
2001
|
+
if (segmentChat.reader !== void 0) settleEvent.reader = segmentChat.reader;
|
|
1980
2002
|
if (!failed && finishReason === void 0) {
|
|
1981
2003
|
if (segmentChat.timeoutError) {
|
|
1982
2004
|
failure = segmentChat.timeoutError;
|
|
@@ -2140,6 +2162,7 @@ var CoderLanguageModel = class {
|
|
|
2140
2162
|
let recoveryAttempted = false;
|
|
2141
2163
|
const stream = this.#acquireStream(action.kind === "resume", chatId, afterId);
|
|
2142
2164
|
streamAcquired = true;
|
|
2165
|
+
if (segmentChat) segmentChat.reader = stream.reader;
|
|
2143
2166
|
let onSegmentAbort;
|
|
2144
2167
|
const segmentAborted = signal ? new Promise((resolve) => {
|
|
2145
2168
|
onSegmentAbort = () => resolve(SEGMENT_ABORTED);
|