@coder/ai-sdk-agent 0.9.0 → 0.9.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 +67 -34
- package/dist/index.d.ts +104 -15
- package/dist/index.js +222 -7
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -824,6 +824,23 @@ var CoderChatClient = class {
|
|
|
824
824
|
opts?.signal
|
|
825
825
|
);
|
|
826
826
|
}
|
|
827
|
+
/**
|
|
828
|
+
* Withdraw a QUEUED (not yet materialized) message from the chat's
|
|
829
|
+
* submission queue (`DELETE /queue/{id}`). `queuedMessageId` is the
|
|
830
|
+
* queue-entry id from `CreateChatMessageResponse.queued_message` — a
|
|
831
|
+
* different id space from committed chat message ids. Rejects with a
|
|
832
|
+
* {@link CoderApiError}: 404 when the entry no longer exists (already
|
|
833
|
+
* promoted into history, or deleted), 409 when the chat has no queue.
|
|
834
|
+
*/
|
|
835
|
+
deleteQueuedMessage(chatId, queuedMessageId, signal) {
|
|
836
|
+
return this.#request(
|
|
837
|
+
"deleteQueuedMessage",
|
|
838
|
+
"DELETE",
|
|
839
|
+
`${API_PREFIX}/${chatId}/queue/${queuedMessageId}`,
|
|
840
|
+
void 0,
|
|
841
|
+
signal
|
|
842
|
+
);
|
|
843
|
+
}
|
|
827
844
|
updateChat(chatId, req, signal) {
|
|
828
845
|
return this.#request("updateChat", "PATCH", `${API_PREFIX}/${chatId}`, req, signal);
|
|
829
846
|
}
|
|
@@ -1348,6 +1365,16 @@ function joinContent(content, kind) {
|
|
|
1348
1365
|
for (const p of content) if (p.type === kind) full += p.text ?? "";
|
|
1349
1366
|
return full;
|
|
1350
1367
|
}
|
|
1368
|
+
function canonicalJson(value) {
|
|
1369
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
1370
|
+
if (value !== null && typeof value === "object") {
|
|
1371
|
+
const entries = Object.entries(value);
|
|
1372
|
+
entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1373
|
+
const body = entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
|
|
1374
|
+
return `{${body.join(",")}}`;
|
|
1375
|
+
}
|
|
1376
|
+
return JSON.stringify(value) ?? "null";
|
|
1377
|
+
}
|
|
1351
1378
|
var TurnTranslator = class {
|
|
1352
1379
|
#dynamicToolNames;
|
|
1353
1380
|
#submittedToolCallIds;
|
|
@@ -1398,13 +1425,30 @@ var TurnTranslator = class {
|
|
|
1398
1425
|
* to a segment that already streamed) — they arrive only as replays: the
|
|
1399
1426
|
* initial sync when resuming a chat, or a mid-turn `history_reset` re-send
|
|
1400
1427
|
* of the full history. Ingest skips them entirely so a replay can neither
|
|
1401
|
-
* re-emit earlier turns' content nor inflate this turn's usage.
|
|
1428
|
+
* re-emit earlier turns' content nor inflate this turn's usage. Mutable for
|
|
1429
|
+
* exactly one transition: a QUEUED submission starts at the pre-turn cursor
|
|
1430
|
+
* and advances to the materialized user message's id when the gate anchors
|
|
1431
|
+
* (see {@link QueuedSubmissionGate}) — replayed pre-anchor tail messages
|
|
1432
|
+
* then stay filtered on every redial, which reuses the turn's ORIGINAL
|
|
1433
|
+
* `after_id` and therefore re-delivers them.
|
|
1402
1434
|
*/
|
|
1403
1435
|
#turnCursor;
|
|
1436
|
+
/** The queued-submission attribution gate; undefined once anchored (#114). */
|
|
1437
|
+
#queuedGate;
|
|
1404
1438
|
constructor(opts) {
|
|
1405
1439
|
this.#dynamicToolNames = opts.dynamicToolNames;
|
|
1406
1440
|
this.#turnCursor = opts.turnCursor ?? 0;
|
|
1407
1441
|
this.#submittedToolCallIds = opts.submittedToolCallIds ?? /* @__PURE__ */ new Set();
|
|
1442
|
+
const queued = opts.queuedSubmission;
|
|
1443
|
+
if (queued) {
|
|
1444
|
+
this.#queuedGate = {
|
|
1445
|
+
queuedId: queued.id,
|
|
1446
|
+
expected: queued.content !== void 0 ? canonicalJson(queued.content) : void 0,
|
|
1447
|
+
candidateId: void 0,
|
|
1448
|
+
buffered: [],
|
|
1449
|
+
deadEnd: void 0
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1408
1452
|
}
|
|
1409
1453
|
get terminalStatus() {
|
|
1410
1454
|
return this.#terminalStatus;
|
|
@@ -1419,6 +1463,32 @@ var TurnTranslator = class {
|
|
|
1419
1463
|
get error() {
|
|
1420
1464
|
return this.#error;
|
|
1421
1465
|
}
|
|
1466
|
+
/**
|
|
1467
|
+
* The turn's EFFECTIVE attribution cursor: the constructor's `turnCursor`
|
|
1468
|
+
* until a queued submission anchors, the materialized user message's id
|
|
1469
|
+
* after. Distinct from the stream's redial cursor, which deliberately stays
|
|
1470
|
+
* the turn's original `after_id` (see `streamChatEvents`).
|
|
1471
|
+
*/
|
|
1472
|
+
get turnCursor() {
|
|
1473
|
+
return this.#turnCursor;
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Whether a queued submission is still awaiting its own user-message
|
|
1477
|
+
* snapshot (#114). While true, nothing is attributed to this turn — no
|
|
1478
|
+
* content, usage, tool calls, or settle statuses — because the stream
|
|
1479
|
+
* window still belongs to the chat's concurrent run.
|
|
1480
|
+
*/
|
|
1481
|
+
get awaitingQueuedSubmission() {
|
|
1482
|
+
return this.#queuedGate !== void 0;
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Set when a queued submission's wait can never complete (see
|
|
1486
|
+
* {@link QueuedSubmissionGate}); the model layer throws it so the turn
|
|
1487
|
+
* fails fast instead of hanging until timeout/abort.
|
|
1488
|
+
*/
|
|
1489
|
+
get queuedSubmissionDeadEnd() {
|
|
1490
|
+
return this.#queuedGate?.deadEnd;
|
|
1491
|
+
}
|
|
1422
1492
|
// --- block helpers --------------------------------------------------------
|
|
1423
1493
|
#openText(out) {
|
|
1424
1494
|
if (this.#reasoning.id) this.#closeReasoning(out);
|
|
@@ -1561,6 +1631,8 @@ var TurnTranslator = class {
|
|
|
1561
1631
|
}
|
|
1562
1632
|
// --- ingest ---------------------------------------------------------------
|
|
1563
1633
|
ingest(ev) {
|
|
1634
|
+
const gate = this.#queuedGate;
|
|
1635
|
+
if (gate) return this.#ingestGated(gate, ev);
|
|
1564
1636
|
const out = [];
|
|
1565
1637
|
switch (ev.type) {
|
|
1566
1638
|
case "message_part":
|
|
@@ -1609,6 +1681,82 @@ var TurnTranslator = class {
|
|
|
1609
1681
|
}
|
|
1610
1682
|
return out;
|
|
1611
1683
|
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Ingest while the queued-submission gate is up (#114): the stream window
|
|
1686
|
+
* still belongs to the chat's concurrent run, so nothing is attributed —
|
|
1687
|
+
* this only advances cursor bookkeeping, runs the anchor correlation
|
|
1688
|
+
* protocol (see {@link QueuedSubmissionGate}), and detects dead ends.
|
|
1689
|
+
* Returns parts only at the anchoring transition, when buffered snapshots
|
|
1690
|
+
* of this turn's own already-committed messages are replayed through the
|
|
1691
|
+
* normal path.
|
|
1692
|
+
*/
|
|
1693
|
+
#ingestGated(gate, ev) {
|
|
1694
|
+
switch (ev.type) {
|
|
1695
|
+
case "message": {
|
|
1696
|
+
const message = ev.message;
|
|
1697
|
+
if (!message) break;
|
|
1698
|
+
if (message.id > this.#maxMessageId) this.#maxMessageId = message.id;
|
|
1699
|
+
if (message.id <= this.#turnCursor) break;
|
|
1700
|
+
if (message.role === "user" && this.#matchesQueuedContent(gate, message)) {
|
|
1701
|
+
if (gate.queuedId === void 0) return this.#anchorQueuedTurn(message.id);
|
|
1702
|
+
gate.candidateId = message.id;
|
|
1703
|
+
gate.buffered = [];
|
|
1704
|
+
} else if (gate.candidateId !== void 0) {
|
|
1705
|
+
gate.buffered.push(message);
|
|
1706
|
+
}
|
|
1707
|
+
break;
|
|
1708
|
+
}
|
|
1709
|
+
case "queue_update": {
|
|
1710
|
+
if (gate.queuedId === void 0) break;
|
|
1711
|
+
const queue = ev.queued_messages ?? [];
|
|
1712
|
+
if (queue.some((entry) => entry.id === gate.queuedId)) {
|
|
1713
|
+
gate.candidateId = void 0;
|
|
1714
|
+
gate.buffered = [];
|
|
1715
|
+
break;
|
|
1716
|
+
}
|
|
1717
|
+
if (gate.candidateId !== void 0) {
|
|
1718
|
+
const buffered = gate.buffered;
|
|
1719
|
+
const out = this.#anchorQueuedTurn(gate.candidateId);
|
|
1720
|
+
for (const message of buffered) this.#ingestMessage(out, message);
|
|
1721
|
+
return out;
|
|
1722
|
+
}
|
|
1723
|
+
gate.deadEnd = {
|
|
1724
|
+
message: "The queued submission left the chat's queue without materializing as this turn's user message (deleted externally, or its materialized form could not be correlated); this turn never started.",
|
|
1725
|
+
kind: "queued_submission_lost",
|
|
1726
|
+
retryable: false
|
|
1727
|
+
};
|
|
1728
|
+
break;
|
|
1729
|
+
}
|
|
1730
|
+
case "error": {
|
|
1731
|
+
if (!ev.error) break;
|
|
1732
|
+
gate.deadEnd = {
|
|
1733
|
+
message: "The chat's active run failed while this turn's submission was queued behind it; the queued message never materialized, so this turn never started.",
|
|
1734
|
+
detail: ev.error.detail ? `${ev.error.message}: ${ev.error.detail}` : ev.error.message,
|
|
1735
|
+
kind: "queued_submission_lost",
|
|
1736
|
+
retryable: false
|
|
1737
|
+
};
|
|
1738
|
+
break;
|
|
1739
|
+
}
|
|
1740
|
+
default:
|
|
1741
|
+
break;
|
|
1742
|
+
}
|
|
1743
|
+
return [];
|
|
1744
|
+
}
|
|
1745
|
+
/** Whether a user-message snapshot's content matches the queued entry's. */
|
|
1746
|
+
#matchesQueuedContent(gate, message) {
|
|
1747
|
+
if (gate.expected === void 0) return true;
|
|
1748
|
+
return canonicalJson(message.content ?? []) === gate.expected;
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Opens the turn's attribution window at `anchorId` — this turn's own user
|
|
1752
|
+
* message — and drops the gate. Replays of the pre-anchor tail (redials
|
|
1753
|
+
* reuse the turn's ORIGINAL cursor) stay filtered by the advanced cursor.
|
|
1754
|
+
*/
|
|
1755
|
+
#anchorQueuedTurn(anchorId) {
|
|
1756
|
+
this.#turnCursor = anchorId;
|
|
1757
|
+
this.#queuedGate = void 0;
|
|
1758
|
+
return [];
|
|
1759
|
+
}
|
|
1612
1760
|
#ingestMessagePart(out, ev) {
|
|
1613
1761
|
const mp = ev.message_part;
|
|
1614
1762
|
if (!mp || mp.role !== "assistant" && mp.role !== "tool") return;
|
|
@@ -1823,11 +1971,36 @@ var CoderLanguageModel = class {
|
|
|
1823
1971
|
this.modelId = config.model ?? "chatd";
|
|
1824
1972
|
this.#chatId = config.chatId;
|
|
1825
1973
|
this.#lastKnownChatId = config.chatId;
|
|
1974
|
+
if (config.lastSeenMessageId !== void 0) {
|
|
1975
|
+
if (!Number.isSafeInteger(config.lastSeenMessageId) || config.lastSeenMessageId <= 0) {
|
|
1976
|
+
throw new CoderAgentError(
|
|
1977
|
+
`lastSeenMessageId must be a positive integer (got ${config.lastSeenMessageId}); persist the value of the lastSeenMessageId getter and supply it verbatim.`
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1980
|
+
if (!config.chatId) {
|
|
1981
|
+
throw new CoderAgentError(
|
|
1982
|
+
"lastSeenMessageId requires chatId: a resume cursor is meaningless without the chat it belongs to."
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
this.#lastSeenMessageId = config.lastSeenMessageId;
|
|
1986
|
+
}
|
|
1826
1987
|
this.#emitTransportEvent = safeTransportEmitter(config.onTransportEvent);
|
|
1827
1988
|
}
|
|
1828
1989
|
get chatId() {
|
|
1829
1990
|
return this.#chatId;
|
|
1830
1991
|
}
|
|
1992
|
+
/**
|
|
1993
|
+
* The resume cursor: the newest chat message id this session has observed,
|
|
1994
|
+
* or `undefined` before any turn has streamed (nothing to persist then —
|
|
1995
|
+
* a fresh instance seeds from history instead). Persist it alongside
|
|
1996
|
+
* {@link chatId} once a turn settles and supply both to a future instance
|
|
1997
|
+
* ({@link CoderLanguageModelConfig.lastSeenMessageId}) to skip the resumed
|
|
1998
|
+
* turn's pre-prompt seed GET. Read it between turns, not mid-generation:
|
|
1999
|
+
* the cursor advances when a segment ends.
|
|
2000
|
+
*/
|
|
2001
|
+
get lastSeenMessageId() {
|
|
2002
|
+
return this.#lastSeenMessageId > 0 ? this.#lastSeenMessageId : void 0;
|
|
2003
|
+
}
|
|
1831
2004
|
/**
|
|
1832
2005
|
* The last chat id this model created or attached to, for CLEANUP targeting
|
|
1833
2006
|
* only — generation always targets {@link chatId}. It survives
|
|
@@ -1981,9 +2154,9 @@ var CoderLanguageModel = class {
|
|
|
1981
2154
|
* pending calls — nothing to recover, the caller keeps waiting on the
|
|
1982
2155
|
* stream, bounded by `requestTimeoutMs`/abort.
|
|
1983
2156
|
*/
|
|
1984
|
-
async #recoverRequiresAction(chatId, dynamicNames,
|
|
2157
|
+
async #recoverRequiresAction(chatId, dynamicNames, turnCursor, signal) {
|
|
1985
2158
|
const { messages } = await this.#config.client.getMessages(chatId, { limit: 200 }, signal);
|
|
1986
|
-
const turnMessages = messages.filter((m) => m.id >
|
|
2159
|
+
const turnMessages = messages.filter((m) => m.id > turnCursor).sort((a, b) => a.id - b.id);
|
|
1987
2160
|
const lastAssistant = turnMessages.findLast((m) => m.role === "assistant");
|
|
1988
2161
|
if (!lastAssistant) return [];
|
|
1989
2162
|
const handled = /* @__PURE__ */ new Set();
|
|
@@ -2137,10 +2310,20 @@ var CoderLanguageModel = class {
|
|
|
2137
2310
|
let discardSession = false;
|
|
2138
2311
|
let interruptSent = false;
|
|
2139
2312
|
let turnChatId;
|
|
2313
|
+
let queuedTurn = false;
|
|
2314
|
+
let queuedEntry;
|
|
2140
2315
|
const interrupt = () => {
|
|
2141
2316
|
const id = turnChatId ?? this.#chatId;
|
|
2142
2317
|
if (interruptSent || !id) return;
|
|
2143
2318
|
interruptSent = true;
|
|
2319
|
+
if (queuedTurn && (translator === void 0 || translator.awaitingQueuedSubmission)) {
|
|
2320
|
+
const entryId = queuedEntry?.id;
|
|
2321
|
+
if (entryId !== void 0) {
|
|
2322
|
+
void this.#config.client.deleteQueuedMessage(id, entryId).catch(() => {
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2144
2327
|
void this.#config.client.interruptChat(id).catch(() => {
|
|
2145
2328
|
});
|
|
2146
2329
|
};
|
|
@@ -2224,7 +2407,15 @@ var CoderLanguageModel = class {
|
|
|
2224
2407
|
},
|
|
2225
2408
|
signal
|
|
2226
2409
|
);
|
|
2227
|
-
|
|
2410
|
+
if (resp.message) {
|
|
2411
|
+
afterId = resp.message.id;
|
|
2412
|
+
} else if (resp.queued) {
|
|
2413
|
+
afterId = this.#lastSeenMessageId;
|
|
2414
|
+
queuedTurn = true;
|
|
2415
|
+
queuedEntry = resp.queued_message;
|
|
2416
|
+
} else {
|
|
2417
|
+
afterId = this.#lastSeenMessageId;
|
|
2418
|
+
}
|
|
2228
2419
|
}
|
|
2229
2420
|
} else {
|
|
2230
2421
|
if (!this.#chatId)
|
|
@@ -2243,14 +2434,21 @@ var CoderLanguageModel = class {
|
|
|
2243
2434
|
this.#lastKnownChatId = chatId;
|
|
2244
2435
|
if (segmentChat) segmentChat.chatId = chatId;
|
|
2245
2436
|
const dynamicNames = dynamicToolNames(options.tools);
|
|
2246
|
-
|
|
2437
|
+
const translatorOptions = {
|
|
2247
2438
|
dynamicToolNames: dynamicNames,
|
|
2248
2439
|
turnCursor: afterId ?? 0,
|
|
2249
2440
|
// The retained stream redials with the TURN's original cursor, so a
|
|
2250
2441
|
// reconnect can replay an earlier segment's `action_required`
|
|
2251
2442
|
// snapshot; calls this session already answered must not run again.
|
|
2252
2443
|
submittedToolCallIds: this.#submittedToolCallIds
|
|
2253
|
-
}
|
|
2444
|
+
};
|
|
2445
|
+
if (queuedTurn) {
|
|
2446
|
+
const queuedSubmission = {};
|
|
2447
|
+
if (queuedEntry?.id !== void 0) queuedSubmission.id = queuedEntry.id;
|
|
2448
|
+
if (queuedEntry?.content !== void 0) queuedSubmission.content = queuedEntry.content;
|
|
2449
|
+
translatorOptions.queuedSubmission = queuedSubmission;
|
|
2450
|
+
}
|
|
2451
|
+
translator = new TurnTranslator(translatorOptions);
|
|
2254
2452
|
let sinceRequiresAction = 0;
|
|
2255
2453
|
let grace;
|
|
2256
2454
|
let recovery;
|
|
@@ -2291,7 +2489,11 @@ var CoderLanguageModel = class {
|
|
|
2291
2489
|
recovery = this.#recoverRequiresAction(
|
|
2292
2490
|
chatId,
|
|
2293
2491
|
dynamicNames,
|
|
2294
|
-
|
|
2492
|
+
// The turn's EFFECTIVE cursor: for a queued submission it has
|
|
2493
|
+
// advanced past the materialized user message (a pause can
|
|
2494
|
+
// only arm after the gate anchored), so the derivation never
|
|
2495
|
+
// sees the concurrent predecessor run's tail (#114).
|
|
2496
|
+
translator.turnCursor,
|
|
2295
2497
|
signal ? AbortSignal.any([signal, recoveryAbort.signal]) : recoveryAbort.signal
|
|
2296
2498
|
).catch(() => []);
|
|
2297
2499
|
continue;
|
|
@@ -2315,6 +2517,8 @@ var CoderLanguageModel = class {
|
|
|
2315
2517
|
stream.consumed(next);
|
|
2316
2518
|
if (raced.done) break;
|
|
2317
2519
|
const parts = translator.ingest(raced.value);
|
|
2520
|
+
const deadEnd = translator.queuedSubmissionDeadEnd;
|
|
2521
|
+
if (deadEnd) throw new CoderChatError(deadEnd);
|
|
2318
2522
|
if (raced.value.type === "error" && translator.error && !translator.terminalStatus) {
|
|
2319
2523
|
const peek = stream.read();
|
|
2320
2524
|
const bound = startGraceTimer(0);
|
|
@@ -2604,6 +2808,7 @@ var CoderAgent = class {
|
|
|
2604
2808
|
mcpServerIds: settings.mcpServerIds,
|
|
2605
2809
|
planMode: settings.planMode,
|
|
2606
2810
|
chatId: settings.chatId,
|
|
2811
|
+
lastSeenMessageId: settings.lastSeenMessageId,
|
|
2607
2812
|
requestTimeoutMs: settings.requestTimeoutMs,
|
|
2608
2813
|
onTransportEvent: settings.onTransportEvent
|
|
2609
2814
|
});
|
|
@@ -2632,6 +2837,16 @@ var CoderAgent = class {
|
|
|
2632
2837
|
get chatId() {
|
|
2633
2838
|
return this.#model.chatId;
|
|
2634
2839
|
}
|
|
2840
|
+
/**
|
|
2841
|
+
* The session's resume cursor: the newest chat message id observed, or
|
|
2842
|
+
* `undefined` before any turn has streamed. Persist it alongside
|
|
2843
|
+
* {@link chatId} when a turn ends and supply both to a future instance
|
|
2844
|
+
* ({@link CoderAgentSettings.lastSeenMessageId}) to skip the resumed turn's
|
|
2845
|
+
* pre-prompt seed GET. Read it between turns, not mid-generation.
|
|
2846
|
+
*/
|
|
2847
|
+
get lastSeenMessageId() {
|
|
2848
|
+
return this.#model.lastSeenMessageId;
|
|
2849
|
+
}
|
|
2635
2850
|
/**
|
|
2636
2851
|
* The last chat id this agent created or attached to, retained for CLEANUP
|
|
2637
2852
|
* even after the session was dropped — a fresh-chat stream failure discards
|