@slopus/happy-agent-base 0.0.2 → 0.0.3
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 +20 -635
- package/dist/Agent.d.ts +7 -0
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +11 -0
- package/dist/Agent.js.map +1 -1
- package/dist/AgentBase.d.ts +15 -17
- package/dist/AgentBase.d.ts.map +1 -1
- package/dist/AgentBase.js +130 -229
- package/dist/AgentBase.js.map +1 -1
- package/dist/AgentBaseHooks.d.ts +1 -5
- package/dist/AgentBaseHooks.d.ts.map +1 -1
- package/dist/AgentBasePending.d.ts +3 -8
- package/dist/AgentBasePending.d.ts.map +1 -1
- package/dist/AgentBasePending.js +4 -13
- package/dist/AgentBasePending.js.map +1 -1
- package/dist/AgentFeature.d.ts +14 -1
- package/dist/AgentFeature.d.ts.map +1 -1
- package/dist/AgentKV.d.ts +0 -5
- package/dist/AgentKV.d.ts.map +1 -1
- package/dist/AgentKV.js +0 -8
- package/dist/AgentKV.js.map +1 -1
- package/dist/AgentPersistence.d.ts +0 -30
- package/dist/AgentPersistence.d.ts.map +1 -1
- package/dist/AgentProviders.d.ts +19 -9
- package/dist/AgentProviders.d.ts.map +1 -1
- package/dist/AgentProviders.js +15 -12
- package/dist/AgentProviders.js.map +1 -1
- package/dist/AgentStorage.d.ts +20 -0
- package/dist/AgentStorage.d.ts.map +1 -1
- package/dist/AgentStorage.js +33 -0
- package/dist/AgentStorage.js.map +1 -1
- package/dist/AgentSystem.d.ts +2 -0
- package/dist/AgentSystem.d.ts.map +1 -1
- package/dist/AgentSystemLocal.d.ts +12 -11
- package/dist/AgentSystemLocal.d.ts.map +1 -1
- package/dist/AgentSystemLocal.js +186 -67
- package/dist/AgentSystemLocal.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/AgentBaseStoreLock.d.ts +0 -16
- package/dist/AgentBaseStoreLock.d.ts.map +0 -1
- package/dist/AgentBaseStoreLock.js +0 -37
- package/dist/AgentBaseStoreLock.js.map +0 -1
package/dist/AgentBase.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { areProviderModelsCompatible } from "@slopus/happy-providers";
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
3
|
import { Value } from "@sinclair/typebox/value";
|
|
5
4
|
import { asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
|
|
6
5
|
import { withAgentContext, withAgentKV, withAgentRunKV } from "./AgentContexts.js";
|
|
7
6
|
import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
|
|
8
7
|
import { AgentKV } from "./AgentKV.js";
|
|
9
8
|
import { AGENT_BASE_PENDING_KEY, agentBasePendingStateOf, } from "./AgentBasePending.js";
|
|
10
|
-
import { agentBaseStoreLock, agentBaseWithStoreStill } from "./AgentBaseStoreLock.js";
|
|
11
9
|
import { AgentProviders } from "./AgentProviders.js";
|
|
12
10
|
/** Race winner when an abort interrupts a wait on the stream or a running tool. */
|
|
13
11
|
const ABORTED = Symbol("aborted");
|
|
@@ -32,8 +30,6 @@ const insideLoops = new AsyncLocalStorage();
|
|
|
32
30
|
* hears the shutdown finish, short enough that one still holding the loop is told promptly.
|
|
33
31
|
*/
|
|
34
32
|
const INSIDE_CLOSE_REPORT_MS = 15;
|
|
35
|
-
/** Rolls a consumption back when every entry in its batch was already taken by another owner. */
|
|
36
|
-
const LOST_QUEUE_RACE = Symbol("lostQueueRace");
|
|
37
33
|
/**
|
|
38
34
|
* A single agent session over one provider. Messages arrive through two FIFO queues: steering
|
|
39
35
|
* messages inject as soon as the current assistant response and its tool batch finish, while
|
|
@@ -43,8 +39,7 @@ const LOST_QUEUE_RACE = Symbol("lostQueueRace");
|
|
|
43
39
|
*
|
|
44
40
|
* The rest of this comment is the list of promises the implementation has to keep. They are
|
|
45
41
|
* written down because most of them are invisible in ordinary use and only show themselves when
|
|
46
|
-
* a process dies
|
|
47
|
-
* bought with a bug found by `tests/chaos/`, and each has a focused test that fails without it.
|
|
42
|
+
* a process dies or a caller races the loop. Each has focused test coverage.
|
|
48
43
|
*
|
|
49
44
|
* ## Serialization
|
|
50
45
|
*
|
|
@@ -63,24 +58,19 @@ const LOST_QUEUE_RACE = Symbol("lostQueueRace");
|
|
|
63
58
|
* acceptance is the same one, and a close still waits for it.
|
|
64
59
|
* - Messages a hook returns from one decision are accepted as one batch. A caller arriving while
|
|
65
60
|
* that batch is being written lands after all of it, never between two halves of one thought.
|
|
66
|
-
* - Queue keys order by what the store already holds
|
|
67
|
-
* writer, so two owners accepting in the same millisecond may order arbitrarily but can never
|
|
68
|
-
* overwrite one another.
|
|
61
|
+
* - Queue keys order by what the store already holds, including when the clock moves backwards.
|
|
69
62
|
* - An agent holding a durable message never describes itself as settled.
|
|
70
63
|
*
|
|
71
64
|
* ## Consuming a message
|
|
72
65
|
*
|
|
73
|
-
* A consumption
|
|
74
|
-
*
|
|
75
|
-
* that claims nothing rolls back having changed nothing. A message is never durable in both the
|
|
76
|
-
* queue and the context, or in neither, and memory changes only after the commit.
|
|
66
|
+
* A consumption deletes each entry inside the transaction that appends it to the context. A
|
|
67
|
+
* message is never durable in both places, or in neither, and memory changes only after commit.
|
|
77
68
|
*
|
|
78
69
|
* ## Turns
|
|
79
70
|
*
|
|
80
|
-
* A turn
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* answered, rather than buying an extra turn with an empty queue and a full set of hooks.
|
|
71
|
+
* A turn reloads the durable conversation before it decides anything, keeping the store
|
|
72
|
+
* authoritative across restarts. A turn that consumes the last queued work clears the request it
|
|
73
|
+
* just answered rather than buying an extra empty turn with a full set of hooks.
|
|
84
74
|
*
|
|
85
75
|
* ## Tool calls
|
|
86
76
|
*
|
|
@@ -102,10 +92,8 @@ const LOST_QUEUE_RACE = Symbol("lostQueueRace");
|
|
|
102
92
|
* ## Compaction
|
|
103
93
|
*
|
|
104
94
|
* A compaction runs before a turn's first inference, so the model always receives a settled
|
|
105
|
-
* conversation. It replaces the history whole or not at all
|
|
106
|
-
*
|
|
107
|
-
* another owner committed while the provider was summarizing survives. A compaction nobody will
|
|
108
|
-
* carry out is rejected rather than left waiting.
|
|
95
|
+
* conversation. It replaces the history whole or not at all. A compaction nobody will carry out
|
|
96
|
+
* is rejected rather than left waiting.
|
|
109
97
|
*
|
|
110
98
|
* ## Model changes
|
|
111
99
|
*
|
|
@@ -255,13 +243,6 @@ export class AgentBase {
|
|
|
255
243
|
#toolsRunning = 0;
|
|
256
244
|
/** The in-flight or finished load of the durable state; cleared at the start of every turn. */
|
|
257
245
|
#loaded;
|
|
258
|
-
/**
|
|
259
|
-
* Identifies this instance's writes, so no other writer can produce one of its keys. It is a
|
|
260
|
-
* UUID rather than a number drawn from the general-purpose generator, because two owners of
|
|
261
|
-
* one store acknowledging a message each are relying on it to keep their keys apart, and a
|
|
262
|
-
* generator that can be seeded — or replaced — would let both of them claim the same one.
|
|
263
|
-
*/
|
|
264
|
-
#writer = randomUUID();
|
|
265
246
|
/**
|
|
266
247
|
* The kind of the last durable record, which says what the conversation is waiting for far
|
|
267
248
|
* more precisely than the message it ends on: a consumed message, a tool result or the note
|
|
@@ -269,19 +250,6 @@ export class AgentBase {
|
|
|
269
250
|
* compaction is owed nothing at all.
|
|
270
251
|
*/
|
|
271
252
|
#lastRecordType;
|
|
272
|
-
/**
|
|
273
|
-
* Whether that last record was a replacement that ended on a message still owed an answer.
|
|
274
|
-
* Only the rewrite that wrote it can tell a summary's own last message from a suffix it kept.
|
|
275
|
-
*/
|
|
276
|
-
#lastRecordContinuesInference = false;
|
|
277
|
-
/**
|
|
278
|
-
* How many durable records the in-memory conversation accounts for: the ones it was loaded
|
|
279
|
-
* from, plus every one this instance has appended since. A rewrite replaces exactly those.
|
|
280
|
-
* Counting the store afresh would treat records this instance has never seen as already
|
|
281
|
-
* summarized and erase them; forgetting its own appends would carry records the summary
|
|
282
|
-
* already covers into the replacement a second time.
|
|
283
|
-
*/
|
|
284
|
-
#loadedRecordCount = 0;
|
|
285
253
|
/**
|
|
286
254
|
* Whether this instance has checked whether a cut-off run should resume inference. The
|
|
287
255
|
* question is only meaningful once, against the state the agent first loaded: afterwards a
|
|
@@ -424,7 +392,7 @@ export class AgentBase {
|
|
|
424
392
|
this.#providers = options.providers;
|
|
425
393
|
this.#providerId = options.provider;
|
|
426
394
|
this.#persistence = options.persistence;
|
|
427
|
-
this.#persistenceLock =
|
|
395
|
+
this.#persistenceLock = asyncLock({ reentry: "block" });
|
|
428
396
|
this.#hooks = options.hooks ?? {};
|
|
429
397
|
this.state = {
|
|
430
398
|
instructions: options.initialState?.instructions ?? "",
|
|
@@ -871,12 +839,27 @@ export class AgentBase {
|
|
|
871
839
|
}),
|
|
872
840
|
]);
|
|
873
841
|
if (!settled) {
|
|
842
|
+
// The caller cannot wait for its own turn, but the agent is closing all the same.
|
|
843
|
+
// Revoke the caller's tool/store capability before reporting the cyclic wait so it
|
|
844
|
+
// cannot resume later and write after the owning system releases its store lock.
|
|
845
|
+
this.#closeController.abort();
|
|
874
846
|
throw new Error("Closing the agent from inside its own run loop would wait for a turn that " +
|
|
875
847
|
"cannot finish. The shutdown was started and will complete once this " +
|
|
876
848
|
"caller returns.");
|
|
877
849
|
}
|
|
878
850
|
await this.#closing;
|
|
879
851
|
}
|
|
852
|
+
/**
|
|
853
|
+
* Wait for a close that has already been requested to finish. Unlike `close`, this never
|
|
854
|
+
* initiates shutdown, so owners can separate the caller-facing reentrancy report from the
|
|
855
|
+
* underlying lifetime barrier.
|
|
856
|
+
*/
|
|
857
|
+
async waitForClosed() {
|
|
858
|
+
const closing = this.#closing;
|
|
859
|
+
if (closing === undefined)
|
|
860
|
+
throw new Error("The agent has not been asked to close.");
|
|
861
|
+
await closing;
|
|
862
|
+
}
|
|
880
863
|
/**
|
|
881
864
|
* Make sure the run loop is running. A loop already in flight picks up the request on its
|
|
882
865
|
* next pass, so this never starts a second one.
|
|
@@ -927,10 +910,8 @@ export class AgentBase {
|
|
|
927
910
|
// redundant turn this can cost is cheap: an empty queue drains without any
|
|
928
911
|
// inference.
|
|
929
912
|
this.#turnRequested = false;
|
|
930
|
-
// Every turn starts from
|
|
931
|
-
//
|
|
932
|
-
// or changed the selection since, and answering out of a stale memory would
|
|
933
|
-
// reply to a conversation that no longer exists.
|
|
913
|
+
// Every turn starts from durable state rather than from what this instance last
|
|
914
|
+
// remembered, so the store remains authoritative after recovery.
|
|
934
915
|
this.#loaded = undefined;
|
|
935
916
|
// The durable history has to be loaded before anything else: a turn that cannot
|
|
936
917
|
// read the conversation cannot answer it, and must not write to it either —
|
|
@@ -1298,26 +1279,15 @@ export class AgentBase {
|
|
|
1298
1279
|
* and so owes an inference nobody asked for again.
|
|
1299
1280
|
*
|
|
1300
1281
|
* What is outstanding is read from the conversation: a tail that is a consumed message, a
|
|
1301
|
-
* tool result, or the note a failed turn left behind is owed an answer
|
|
1302
|
-
*
|
|
1303
|
-
*
|
|
1304
|
-
* The pending record deliberately does not decide this, because one store may have several
|
|
1305
|
-
* live owners and there is only one record. An owner working right now leaves behind exactly
|
|
1306
|
-
* what a process that died would have left, so deciding from the record alone would have
|
|
1307
|
-
* each owner treat the others' work as abandoned and answer it a second time. What the
|
|
1308
|
-
* record adds is the knowledge that some run reached the model: a listener shown the
|
|
1282
|
+
* tool result, or the note a failed turn left behind is owed an answer. The pending record
|
|
1283
|
+
* adds the knowledge that the interrupted run reached the model: a listener shown the
|
|
1309
1284
|
* beginning of a block that will now never arrive is told to drop it. Only finished blocks
|
|
1310
|
-
* are
|
|
1285
|
+
* are persisted, so the conversation is intact and it is the view being corrected.
|
|
1311
1286
|
*/
|
|
1312
1287
|
#resumesInterruptedRun() {
|
|
1313
1288
|
const owed = this.#lastRecordType === "user" ||
|
|
1314
1289
|
this.#lastRecordType === "tool" ||
|
|
1315
|
-
this.#lastRecordType === "system"
|
|
1316
|
-
// A replacement record is not a question in itself, however it happens to end — but
|
|
1317
|
-
// it keeps the suffix that joined the conversation after its snapshot, and a consumed
|
|
1318
|
-
// message in that suffix still needs inference. Which kind of message ends the
|
|
1319
|
-
// replacement is not visible in the messages, so the rewrite that knew records it.
|
|
1320
|
-
(this.#lastRecordType === "compaction" && this.#lastRecordContinuesInference);
|
|
1290
|
+
this.#lastRecordType === "system";
|
|
1321
1291
|
if (owed && this.#inherited?.stage === "inference")
|
|
1322
1292
|
this.#emit({ type: "block_reset" });
|
|
1323
1293
|
return owed;
|
|
@@ -1347,12 +1317,11 @@ export class AgentBase {
|
|
|
1347
1317
|
}
|
|
1348
1318
|
}
|
|
1349
1319
|
/**
|
|
1350
|
-
* Run the pending compaction, if any.
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1355
|
-
* history untouched.
|
|
1320
|
+
* Run the pending compaction, if any. This pass is the only history writer, so the provider
|
|
1321
|
+
* summarizes exactly the conversation that the replacement supersedes. The replacement is
|
|
1322
|
+
* appended as a compaction record — the load-time reset point — and settles the shared
|
|
1323
|
+
* promise for every caller awaiting it. A provider failure rejects them and leaves history
|
|
1324
|
+
* untouched.
|
|
1356
1325
|
*/
|
|
1357
1326
|
async #runCompaction(signal) {
|
|
1358
1327
|
const pending = this.#compaction;
|
|
@@ -1362,15 +1331,6 @@ export class AgentBase {
|
|
|
1362
1331
|
await this.#enterStage("compaction");
|
|
1363
1332
|
const instructions = await this.#instructions();
|
|
1364
1333
|
const session = await this.#ensureSession(instructions, await this.#tools());
|
|
1365
|
-
// The snapshot is the durable conversation, counted as records: everything appended
|
|
1366
|
-
// after this point is a suffix the replacement has to keep, whoever wrote it. Taking
|
|
1367
|
-
// the boundary from the store rather than from this instance's own memory means a
|
|
1368
|
-
// record another owner committed while the provider was summarizing survives the
|
|
1369
|
-
// clear-and-replace instead of being erased by it.
|
|
1370
|
-
// The boundary is the prefix this instance's memory was built from, not whatever
|
|
1371
|
-
// the store holds now: the provider is about to summarize that memory, and counting
|
|
1372
|
-
// a newer store would describe records it never saw as summarized.
|
|
1373
|
-
const snapshotCount = this.#loadedRecordCount;
|
|
1374
1334
|
const snapshot = [...this.#messages];
|
|
1375
1335
|
await this.#settled();
|
|
1376
1336
|
// Provider compaction is this turn's work, so it runs on this turn's lifetime: an
|
|
@@ -1385,27 +1345,17 @@ export class AgentBase {
|
|
|
1385
1345
|
}
|
|
1386
1346
|
if (result.status === "completed") {
|
|
1387
1347
|
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
1388
|
-
const records = await this.#persistence.load(lockCtx);
|
|
1389
|
-
const suffix = messagesFromRecords(records.slice(snapshotCount));
|
|
1390
|
-
const replaced = [...result.context.messages, ...suffix];
|
|
1391
|
-
// Only a message in the live suffix can require another inference. The
|
|
1392
|
-
// summary's own final message is provider-authored context, not a request.
|
|
1393
|
-
const continuesInference = suffix.length > 0 && needsInference(replaced);
|
|
1394
1348
|
// Physically delete the superseded records and write the replacement —
|
|
1395
1349
|
// which keeps the messages that stay — in one atomic step.
|
|
1396
1350
|
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1397
1351
|
await this.#persistence.clearRecords(txCtx);
|
|
1398
1352
|
await this.#persistence.append(txCtx, {
|
|
1399
1353
|
type: "compaction",
|
|
1400
|
-
messages:
|
|
1401
|
-
...(continuesInference ? { continuesInference: true } : {}),
|
|
1354
|
+
messages: result.context.messages,
|
|
1402
1355
|
});
|
|
1403
1356
|
});
|
|
1404
|
-
this.#messages = [...
|
|
1357
|
+
this.#messages = [...result.context.messages];
|
|
1405
1358
|
this.#lastRecordType = "compaction";
|
|
1406
|
-
this.#lastRecordContinuesInference = continuesInference;
|
|
1407
|
-
// The store is now the one replacement record, and memory is exactly it.
|
|
1408
|
-
this.#loadedRecordCount = 1;
|
|
1409
1359
|
});
|
|
1410
1360
|
// The conversation the measurement described is gone; its size is unknown
|
|
1411
1361
|
// again until the next response measures the replacement.
|
|
@@ -1464,21 +1414,15 @@ export class AgentBase {
|
|
|
1464
1414
|
return settled;
|
|
1465
1415
|
}
|
|
1466
1416
|
/**
|
|
1467
|
-
* Append one record
|
|
1468
|
-
* its memory accounts for, and a rewrite has to know exactly where its own knowledge ends —
|
|
1469
|
-
* so appending and counting are one step rather than two a caller could get out of order.
|
|
1417
|
+
* Append one record to the durable conversation.
|
|
1470
1418
|
*/
|
|
1471
1419
|
async #appendRecord(ctx, record) {
|
|
1472
1420
|
await this.#persistence.append(ctx, record);
|
|
1473
|
-
this.#loadedRecordCount += 1;
|
|
1474
1421
|
}
|
|
1475
1422
|
/**
|
|
1476
|
-
* A transaction whose
|
|
1477
|
-
* transaction that rolls back were never written, and memory never took them either, so the
|
|
1478
|
-
* count must not go on claiming them.
|
|
1423
|
+
* A transaction whose pending-state cache unwinds with it.
|
|
1479
1424
|
*/
|
|
1480
1425
|
async #recordTransaction(ctx, work) {
|
|
1481
|
-
const counted = this.#loadedRecordCount;
|
|
1482
1426
|
// The outstanding work unwinds with the records for the same reason: a stage staged by a
|
|
1483
1427
|
// transaction that rolled back was never written, and memory claiming it would make the
|
|
1484
1428
|
// agent skip the write that actually records what it is doing.
|
|
@@ -1488,7 +1432,6 @@ export class AgentBase {
|
|
|
1488
1432
|
return await this.#persistence.transaction(ctx, work);
|
|
1489
1433
|
}
|
|
1490
1434
|
catch (error) {
|
|
1491
|
-
this.#loadedRecordCount = counted;
|
|
1492
1435
|
this.#pending = pending;
|
|
1493
1436
|
this.#pendingWritten = written;
|
|
1494
1437
|
throw error;
|
|
@@ -1527,9 +1470,7 @@ export class AgentBase {
|
|
|
1527
1470
|
return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
1528
1471
|
if (queue.length === 0)
|
|
1529
1472
|
return false;
|
|
1530
|
-
// The durable queue, not memory, decides what is left to consume
|
|
1531
|
-
// the same store may have taken these entries already, and a message answered twice
|
|
1532
|
-
// is as wrong as one answered never.
|
|
1473
|
+
// The durable queue, not memory, decides what is left to consume after a restart.
|
|
1533
1474
|
const durable = new Set((await this.#persistence.readValues(lockCtx, prefix)).map(({ key }) => key));
|
|
1534
1475
|
const remaining = queue.filter((entry) => durable.has(entry.key));
|
|
1535
1476
|
if (remaining.length !== queue.length)
|
|
@@ -1595,127 +1536,97 @@ export class AgentBase {
|
|
|
1595
1536
|
reset = model !== this.#model;
|
|
1596
1537
|
}
|
|
1597
1538
|
}
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1539
|
+
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1540
|
+
if (selectionChanged) {
|
|
1541
|
+
if (this.#hooks.modelChanged !== undefined && model !== undefined) {
|
|
1542
|
+
// The hook runs while the persistence lock is held and inside the
|
|
1543
|
+
// transaction that commits the switch, so its store executes directly on
|
|
1544
|
+
// that transaction: what it writes lands and rolls back with the change
|
|
1545
|
+
// it was told about, never on its own. The context it is given ends with
|
|
1546
|
+
// the transaction, so a store it keeps cannot outlive the switch.
|
|
1547
|
+
const committed = new AbortController();
|
|
1548
|
+
// Derived from the transaction's own context, which is what makes
|
|
1549
|
+
// the hook's writes part of the switch rather than a second,
|
|
1550
|
+
// separate commit, and ending with it.
|
|
1551
|
+
const changeLifetime = withLifetime(withAgentContext(txCtx, {
|
|
1552
|
+
id: this.id,
|
|
1553
|
+
provider,
|
|
1554
|
+
model,
|
|
1555
|
+
effort,
|
|
1556
|
+
serviceTier,
|
|
1557
|
+
}), committed.signal);
|
|
1558
|
+
const changeCtx = withAgentRunKV(withAgentKV(changeLifetime, this.#kv), this.#runKV);
|
|
1559
|
+
try {
|
|
1560
|
+
injected = await this.#hooks.modelChanged(changeCtx, {
|
|
1561
|
+
previousModel: this.#model,
|
|
1615
1562
|
model,
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
}
|
|
1632
|
-
catch {
|
|
1633
|
-
// A failing handoff must not cost the conversation: an incompatible
|
|
1634
|
-
// switch is rejected outright — the previous selection stays
|
|
1635
|
-
// effective and the history is not cleared. A compatible change
|
|
1636
|
-
// proceeds; the hook only observed it.
|
|
1637
|
-
if (reset) {
|
|
1638
|
-
provider = this.#providerId;
|
|
1639
|
-
model = this.#model;
|
|
1640
|
-
reset = false;
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
finally {
|
|
1644
|
-
// The store belonged to the hook's call, not to the hook.
|
|
1645
|
-
committed.abort();
|
|
1563
|
+
previousProvider: this.#providerId,
|
|
1564
|
+
provider,
|
|
1565
|
+
providers: this.#providers,
|
|
1566
|
+
wasReset: reset,
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
catch {
|
|
1570
|
+
// A failing handoff must not cost the conversation: an incompatible
|
|
1571
|
+
// switch is rejected outright — the previous selection stays
|
|
1572
|
+
// effective and the history is not cleared. A compatible change
|
|
1573
|
+
// proceeds; the hook only observed it.
|
|
1574
|
+
if (reset) {
|
|
1575
|
+
provider = this.#providerId;
|
|
1576
|
+
model = this.#model;
|
|
1577
|
+
reset = false;
|
|
1646
1578
|
}
|
|
1647
|
-
if (!reset)
|
|
1648
|
-
injected = undefined;
|
|
1649
1579
|
}
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
// owner is the one that took it, so a message shared by two live owners over
|
|
1654
|
-
// one store is answered exactly once. Claiming first also means losing the
|
|
1655
|
-
// whole batch rolls the transaction back before it has changed anything.
|
|
1656
|
-
for (const entry of batch) {
|
|
1657
|
-
const claimed = await this.#persistence.deleteValueIfPresent(txCtx, entry.key);
|
|
1658
|
-
if (claimed)
|
|
1659
|
-
consumed.push(entry);
|
|
1660
|
-
}
|
|
1661
|
-
if (consumed.length === 0)
|
|
1662
|
-
throw LOST_QUEUE_RACE;
|
|
1663
|
-
if (reset) {
|
|
1664
|
-
await this.#persistence.clearRecords(txCtx);
|
|
1665
|
-
this.#loadedRecordCount = 0;
|
|
1666
|
-
// The erased conversation is what the measurement described.
|
|
1667
|
-
await this.#persistence.deleteValue(txCtx, "context");
|
|
1668
|
-
if (injected !== undefined) {
|
|
1669
|
-
await this.#appendRecord(txCtx, {
|
|
1670
|
-
type: "system",
|
|
1671
|
-
message: injected,
|
|
1672
|
-
});
|
|
1580
|
+
finally {
|
|
1581
|
+
// The store belonged to the hook's call, not to the hook.
|
|
1582
|
+
committed.abort();
|
|
1673
1583
|
}
|
|
1584
|
+
if (!reset)
|
|
1585
|
+
injected = undefined;
|
|
1674
1586
|
}
|
|
1675
|
-
|
|
1587
|
+
}
|
|
1588
|
+
// The queue move is atomic with appending the consumed messages and recording
|
|
1589
|
+
// the inference they make due. The store has one owner, so ordinary deletes
|
|
1590
|
+
// are sufficient.
|
|
1591
|
+
for (const entry of batch) {
|
|
1592
|
+
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
1593
|
+
}
|
|
1594
|
+
if (reset) {
|
|
1595
|
+
await this.#persistence.clearRecords(txCtx);
|
|
1596
|
+
// The erased conversation is what the measurement described.
|
|
1597
|
+
await this.#persistence.deleteValue(txCtx, "context");
|
|
1598
|
+
if (injected !== undefined) {
|
|
1676
1599
|
await this.#appendRecord(txCtx, {
|
|
1677
|
-
type: "
|
|
1678
|
-
message:
|
|
1679
|
-
});
|
|
1680
|
-
}
|
|
1681
|
-
if (changed) {
|
|
1682
|
-
await this.#persistence.writeValue(txCtx, "settings", {
|
|
1683
|
-
provider,
|
|
1684
|
-
...(model === undefined ? {} : { model }),
|
|
1685
|
-
...(effort === undefined ? {} : { effort }),
|
|
1686
|
-
...(serviceTier === undefined ? {} : { serviceTier }),
|
|
1600
|
+
type: "system",
|
|
1601
|
+
message: injected,
|
|
1687
1602
|
});
|
|
1688
1603
|
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
if (
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1604
|
+
}
|
|
1605
|
+
for (const entry of batch) {
|
|
1606
|
+
await this.#appendRecord(txCtx, {
|
|
1607
|
+
type: "user",
|
|
1608
|
+
message: entry.message,
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
if (changed) {
|
|
1612
|
+
await this.#persistence.writeValue(txCtx, "settings", {
|
|
1613
|
+
provider,
|
|
1614
|
+
...(model === undefined ? {} : { model }),
|
|
1615
|
+
...(effort === undefined ? {} : { effort }),
|
|
1616
|
+
...(serviceTier === undefined ? {} : { serviceTier }),
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
// Consuming a message is precisely the act that makes an inference owed, so
|
|
1620
|
+
// the two commit as one. A crash cannot land between them and leave a
|
|
1621
|
+
// message in the conversation that nothing remembers having to answer.
|
|
1622
|
+
await this.#recordPending(txCtx, { stage: "inference" });
|
|
1623
|
+
});
|
|
1703
1624
|
queue.splice(0, count);
|
|
1704
1625
|
if (reset) {
|
|
1705
1626
|
this.#messages = injected === undefined ? [] : [injected];
|
|
1706
1627
|
this.#contextTokens = undefined;
|
|
1707
1628
|
}
|
|
1708
|
-
|
|
1709
|
-
const session = this.#session;
|
|
1710
|
-
this.#session = undefined;
|
|
1711
|
-
try {
|
|
1712
|
-
await session?.destroy();
|
|
1713
|
-
}
|
|
1714
|
-
catch {
|
|
1715
|
-
// The change already committed; a failing destroy must not undo it.
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
|
-
this.#messages.push(...consumed.map((entry) => entry.message));
|
|
1629
|
+
this.#messages.push(...batch.map((entry) => entry.message));
|
|
1719
1630
|
// This turn is answering the request that these messages raised. A send accepted
|
|
1720
1631
|
// while the turn was already running raised it again, and letting that stand would
|
|
1721
1632
|
// buy an extra turn with an empty queue and a full set of lifecycle hooks.
|
|
@@ -1745,9 +1656,6 @@ export class AgentBase {
|
|
|
1745
1656
|
const records = await this.#persistence.load(lockCtx);
|
|
1746
1657
|
const last = records[records.length - 1];
|
|
1747
1658
|
this.#lastRecordType = last?.type;
|
|
1748
|
-
this.#lastRecordContinuesInference =
|
|
1749
|
-
last?.type === "compaction" && last.continuesInference === true;
|
|
1750
|
-
this.#loadedRecordCount = records.length;
|
|
1751
1659
|
let restored = messagesFromRecords(records);
|
|
1752
1660
|
const steering = await this.#persistence.readValues(lockCtx, "steering.");
|
|
1753
1661
|
const sends = await this.#persistence.readValues(lockCtx, "send.");
|
|
@@ -1877,7 +1785,8 @@ export class AgentBase {
|
|
|
1877
1785
|
outcome = toolFailure(entry.call.callId, "The tool call was interrupted by a restart and was not retried.");
|
|
1878
1786
|
}
|
|
1879
1787
|
else {
|
|
1880
|
-
const
|
|
1788
|
+
const toolLifetime = AbortSignal.any([signal, this.#closeController.signal]);
|
|
1789
|
+
const execution = this.#executeToolCall(withLifetime(this.#ctx, toolLifetime), entry.call);
|
|
1881
1790
|
running.push(execution);
|
|
1882
1791
|
outcome = await Promise.race([execution, abortPromise, this.#closingTools()]);
|
|
1883
1792
|
}
|
|
@@ -2007,20 +1916,15 @@ export class AgentBase {
|
|
|
2007
1916
|
}
|
|
2008
1917
|
}
|
|
2009
1918
|
/**
|
|
2010
|
-
* A key that sorts after every entry the queue already holds
|
|
2011
|
-
*
|
|
2012
|
-
*
|
|
2013
|
-
* trailing writer segment settles the rest: two owners that read the same tail at the same
|
|
2014
|
-
* millisecond still produce different keys, so an acknowledged message can never be
|
|
2015
|
-
* overwritten by one accepted elsewhere — only ordered arbitrarily against it, which is all
|
|
2016
|
-
* that simultaneous acceptance can mean. Reading the tail also keeps the order right when
|
|
2017
|
-
* the clock goes backwards.
|
|
1919
|
+
* A key that sorts after every entry the queue already holds. The order comes from the store
|
|
1920
|
+
* rather than from an in-memory counter, because a restarted agent begins counting again.
|
|
1921
|
+
* Reading the tail also keeps order correct when the clock moves backwards.
|
|
2018
1922
|
*/
|
|
2019
1923
|
async #queueKey(ctx, prefix) {
|
|
2020
1924
|
const existing = await this.#persistence.readValues(ctx, prefix);
|
|
2021
1925
|
const last = existing[existing.length - 1]?.key;
|
|
2022
1926
|
const time = String(Date.now()).padStart(14, "0");
|
|
2023
|
-
const key = (slot, sequence) => `${prefix}${slot}.${String(sequence).padStart(6, "0")}
|
|
1927
|
+
const key = (slot, sequence) => `${prefix}${slot}.${String(sequence).padStart(6, "0")}`;
|
|
2024
1928
|
if (last === undefined)
|
|
2025
1929
|
return key(time, 0);
|
|
2026
1930
|
const [lastSlot, lastSequence] = last.slice(prefix.length).split(".");
|
|
@@ -2191,7 +2095,7 @@ export class AgentBase {
|
|
|
2191
2095
|
* that moment; an unregistered ID fails the turn like any thrown error.
|
|
2192
2096
|
*/
|
|
2193
2097
|
async #ensureSession(instructions, tools) {
|
|
2194
|
-
const key = sessionConfigKey(instructions, tools);
|
|
2098
|
+
const key = sessionConfigKey(this.#providerId, this.#model, instructions, tools);
|
|
2195
2099
|
if (this.#session !== undefined && this.#sessionConfig !== key) {
|
|
2196
2100
|
const session = this.#session;
|
|
2197
2101
|
this.#session = undefined;
|
|
@@ -2206,7 +2110,7 @@ export class AgentBase {
|
|
|
2206
2110
|
}
|
|
2207
2111
|
}
|
|
2208
2112
|
if (this.#session === undefined) {
|
|
2209
|
-
const provider = this.#providers.
|
|
2113
|
+
const provider = await this.#providers.resolve(this.#providerId, this.#model);
|
|
2210
2114
|
if (provider === null) {
|
|
2211
2115
|
throw new Error(`Provider "${this.#providerId}" is not registered.`);
|
|
2212
2116
|
}
|
|
@@ -2228,11 +2132,6 @@ export class AgentBase {
|
|
|
2228
2132
|
}
|
|
2229
2133
|
}
|
|
2230
2134
|
}
|
|
2231
|
-
/** Whether a conversation ends on something the model has not answered. */
|
|
2232
|
-
function needsInference(messages) {
|
|
2233
|
-
const last = messages[messages.length - 1];
|
|
2234
|
-
return last?.role === "user" || last?.role === "tool" || last?.role === "system";
|
|
2235
|
-
}
|
|
2236
2135
|
/**
|
|
2237
2136
|
* The conversation a run of records spells out. A compaction record carries the complete
|
|
2238
2137
|
* replacement context and supersedes everything before it; consecutive blocks belong to one
|
|
@@ -2277,8 +2176,10 @@ function toolFailure(callId, reason) {
|
|
|
2277
2176
|
* sees participate, so re-created tool objects with identical descriptors do not churn the
|
|
2278
2177
|
* session.
|
|
2279
2178
|
*/
|
|
2280
|
-
function sessionConfigKey(instructions, tools) {
|
|
2179
|
+
function sessionConfigKey(provider, model, instructions, tools) {
|
|
2281
2180
|
return deterministicStringify([
|
|
2181
|
+
provider,
|
|
2182
|
+
model ?? null,
|
|
2282
2183
|
instructions,
|
|
2283
2184
|
tools.map((tool) => [
|
|
2284
2185
|
tool.name,
|