@slopus/happy-agent-base 0.0.11 → 0.0.13
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/dist/AgentBase.d.ts.map +1 -1
- package/dist/AgentBase.js +299 -228
- package/dist/AgentBase.js.map +1 -1
- package/dist/AgentContexts.d.ts +0 -2
- package/dist/AgentContexts.d.ts.map +1 -1
- package/dist/AgentContexts.js +35 -18
- package/dist/AgentContexts.js.map +1 -1
- package/dist/AgentSystemLocal.d.ts.map +1 -1
- package/dist/AgentSystemLocal.js +39 -25
- package/dist/AgentSystemLocal.js.map +1 -1
- package/dist/AgentTaskContext.d.ts.map +1 -1
- package/dist/AgentTaskContext.js +7 -2
- package/dist/AgentTaskContext.js.map +1 -1
- package/package.json +2 -2
package/dist/AgentBase.js
CHANGED
|
@@ -3,8 +3,8 @@ import { createId } from "@paralleldrive/cuid2";
|
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
4
|
import { Type } from "@sinclair/typebox";
|
|
5
5
|
import { Value } from "@sinclair/typebox/value";
|
|
6
|
-
import { afterCommit, asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
|
|
7
|
-
import { agentDatabase, agentKV, agentStorageTransaction, withAgentContext, withAgentDatabase, withAgentHistoryKV, withAgentKV, withAgentPermissionMode, withAgentRunKV,
|
|
6
|
+
import { afterCommit, asyncLock, createContextNamespace, detach, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
|
|
7
|
+
import { agentDatabase, agentKV, agentStorageTransaction, withAgentContext, withAgentDatabase, withAgentHistoryKV, withAgentKV, withAgentPermissionMode, withAgentRunKV, } from "./AgentContexts.js";
|
|
8
8
|
import { agentConfig, ownAgentConfig, withAgentConfig } from "./AgentConfig.js";
|
|
9
9
|
import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
|
|
10
10
|
import { AgentKV } from "./AgentKV.js";
|
|
@@ -18,8 +18,13 @@ const ABORTED = Symbol("aborted");
|
|
|
18
18
|
* The agents whose run loop the current execution is running inside. Hooks and tool executions
|
|
19
19
|
* receive a context carrying this, so an operation that would wait for the very loop it is part
|
|
20
20
|
* of can say so instead of hanging for ever.
|
|
21
|
+
*
|
|
22
|
+
* Not detachable. Being inside a loop is a fact about the call in progress, and work that detaches
|
|
23
|
+
* to a lifetime of its own is by definition no longer inside it.
|
|
21
24
|
*/
|
|
22
|
-
const insideTurn = createContextNamespace("agentInsideTurn", []
|
|
25
|
+
const insideTurn = createContextNamespace("agentInsideTurn", [], {
|
|
26
|
+
detachable: false,
|
|
27
|
+
});
|
|
23
28
|
/**
|
|
24
29
|
* The same fact as `insideTurn`, tracked by the runtime rather than carried by a context. Not
|
|
25
30
|
* every operation takes one — `close` is the whole agent's lifetime and has no call to carry it —
|
|
@@ -459,10 +464,14 @@ export class AgentBase {
|
|
|
459
464
|
constructor(ctx, options) {
|
|
460
465
|
this.id = options.id;
|
|
461
466
|
this.#config = ownAgentConfig(agentConfig(ctx) ?? {});
|
|
462
|
-
// An agent is its own lifetime
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
|
|
467
|
+
// An agent is its own lifetime, so it makes a root of the context it was handed rather
|
|
468
|
+
// than working on it. Whatever call happened to construct it — a tool of another agent,
|
|
469
|
+
// most often — takes its cancellation, its open transaction, and the loop it was itself
|
|
470
|
+
// running inside with it when it returns, and none of that may reach work that goes on
|
|
471
|
+
// long afterwards. What survives is what describes the world the agent runs in: its
|
|
472
|
+
// logger, its tracer, and its configuration. Everything the agent owns is put on
|
|
473
|
+
// deliberately, beginning with the storage the detached root no longer carries.
|
|
474
|
+
this.#baseCtx = withAgentDatabase(withAgentConfig(insideTurn.set(detach(ctx).named(`agent.${options.id}`), [options.id]), this.#config), options.persistence.database);
|
|
466
475
|
this.#providers = options.providers;
|
|
467
476
|
this.#providerId = options.provider;
|
|
468
477
|
this.#persistence = options.persistence;
|
|
@@ -488,11 +497,21 @@ export class AgentBase {
|
|
|
488
497
|
/**
|
|
489
498
|
* The context everything the agent does runs on: its identity and effective selection, plus
|
|
490
499
|
* the session-scoped key-value store and the store of the run in progress. Rebuilt whenever
|
|
491
|
-
* the selection changes.
|
|
500
|
+
* the selection changes. It is what the agent does its own work on, outside a run.
|
|
492
501
|
*/
|
|
493
502
|
#deriveCtx() {
|
|
494
503
|
return this.#hookContext(this.#baseCtx);
|
|
495
504
|
}
|
|
505
|
+
/**
|
|
506
|
+
* The agent's selection, configuration, and stores put onto the context a stretch of work is
|
|
507
|
+
* running on. The run loop hands each piece of its work the context of the tracing span that
|
|
508
|
+
* work belongs to, and every use of that context goes through here, so what the agent is
|
|
509
|
+
* running on is read at the moment of use: a model switch or a metadata change made in the
|
|
510
|
+
* middle of a turn is seen by the rest of it, and the span the work opened under is kept.
|
|
511
|
+
*/
|
|
512
|
+
#workContext(ctx) {
|
|
513
|
+
return this.#hookContext(withAgentConfig(ctx, this.#config));
|
|
514
|
+
}
|
|
496
515
|
/** Add this agent's selection and stores to a caller context without losing its transaction. */
|
|
497
516
|
#hookContext(ctx) {
|
|
498
517
|
const selected = withAgentContext(ctx, this.#selection());
|
|
@@ -575,7 +594,7 @@ export class AgentBase {
|
|
|
575
594
|
* is doing, and a run interrupted by a dead process would be indistinguishable from the one
|
|
576
595
|
* starting here.
|
|
577
596
|
*/
|
|
578
|
-
async #enterStage(stage, transact) {
|
|
597
|
+
async #enterStage(ctx, stage, transact) {
|
|
579
598
|
const pending = this.#pendingState(stage);
|
|
580
599
|
if (transact === undefined &&
|
|
581
600
|
deterministicStringify(pending) === this.#pendingWritten &&
|
|
@@ -583,7 +602,7 @@ export class AgentBase {
|
|
|
583
602
|
return undefined;
|
|
584
603
|
}
|
|
585
604
|
try {
|
|
586
|
-
return await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
605
|
+
return await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
|
|
587
606
|
if (!this.#inheritedRead) {
|
|
588
607
|
this.#inheritedRead = true;
|
|
589
608
|
this.#inherited = await agentBasePendingStateOf(lockCtx, this.#persistence);
|
|
@@ -995,8 +1014,8 @@ export class AgentBase {
|
|
|
995
1014
|
* Instructions and tools are correctness hooks — a failure here fails the turn loudly
|
|
996
1015
|
* instead of silently running with a wrong configuration.
|
|
997
1016
|
*/
|
|
998
|
-
async #instructions() {
|
|
999
|
-
const hooked = await this.#hooks.instructions?.(this.#ctx);
|
|
1017
|
+
async #instructions(ctx) {
|
|
1018
|
+
const hooked = await this.#hooks.instructions?.(this.#workContext(ctx));
|
|
1000
1019
|
return [this.state.instructions, hooked ?? ""]
|
|
1001
1020
|
.filter((text) => text.length > 0)
|
|
1002
1021
|
.join("\n\n");
|
|
@@ -1006,8 +1025,8 @@ export class AgentBase {
|
|
|
1006
1025
|
* answer. Two tools sharing one name and namespace are a configuration error that fails
|
|
1007
1026
|
* the turn, since the provider would receive ambiguous descriptors.
|
|
1008
1027
|
*/
|
|
1009
|
-
async #tools() {
|
|
1010
|
-
const hooked = await this.#hooks.tools?.(this.#ctx);
|
|
1028
|
+
async #tools(ctx) {
|
|
1029
|
+
const hooked = await this.#hooks.tools?.(this.#workContext(ctx));
|
|
1011
1030
|
const tools = [...this.state.tools, ...(hooked ?? [])];
|
|
1012
1031
|
const names = new Set();
|
|
1013
1032
|
for (const tool of tools) {
|
|
@@ -1179,10 +1198,18 @@ export class AgentBase {
|
|
|
1179
1198
|
* more work, so the loop hooks always bracket a settled-to-settled span.
|
|
1180
1199
|
*/
|
|
1181
1200
|
async #runLoop() {
|
|
1201
|
+
// The run is a stretch of work of its own, and the context of its span is the one the
|
|
1202
|
+
// whole of it runs on. Everything below is handed that context rather than reading a
|
|
1203
|
+
// scope back off the agent, so each turn, inference, tool call, and hook is placed in
|
|
1204
|
+
// the run it actually belongs to even while another agent is running alongside it.
|
|
1205
|
+
await this.#ctx.span("agent.run", (ctx) => this.#runTurns(ctx));
|
|
1206
|
+
}
|
|
1207
|
+
/** The run itself, on the context of the span the whole of it belongs to. */
|
|
1208
|
+
async #runTurns(ctx) {
|
|
1182
1209
|
if (this.#pending?.stage === "settlement") {
|
|
1183
1210
|
this.#loopId ??= createId();
|
|
1184
1211
|
this.#settlementId ??= createId();
|
|
1185
|
-
await this.#settleDurably({
|
|
1212
|
+
await this.#settleDurably(ctx, {
|
|
1186
1213
|
loopId: this.#loopId,
|
|
1187
1214
|
settlementId: this.#settlementId,
|
|
1188
1215
|
});
|
|
@@ -1201,102 +1228,117 @@ export class AgentBase {
|
|
|
1201
1228
|
// crash could interrupt. What it records is refined as the run reaches each stage;
|
|
1202
1229
|
// what matters at this point is that the record exists at all, since its absence is
|
|
1203
1230
|
// what a later process reads as an agent that finished.
|
|
1204
|
-
await this.#enterStage("inference", this.#hooks.beforeAgentLoopTransact === undefined
|
|
1231
|
+
await this.#enterStage(ctx, "inference", this.#hooks.beforeAgentLoopTransact === undefined
|
|
1205
1232
|
? undefined
|
|
1206
1233
|
: (hookCtx) => this.#hooks.beforeAgentLoopTransact?.(hookCtx, loop));
|
|
1207
|
-
await this.#invokeHook(this.#hooks.beforeAgentLoop, loop);
|
|
1234
|
+
await this.#invokeHook(ctx, this.#hooks.beforeAgentLoop, loop);
|
|
1208
1235
|
do {
|
|
1209
|
-
this.#
|
|
1210
|
-
|
|
1211
|
-
this.#turnId ??= createId();
|
|
1212
|
-
// Claimed before any awaiting, so a request raised while the turn is still
|
|
1213
|
-
// starting up survives into another turn instead of being cleared by it. The
|
|
1214
|
-
// redundant turn this can cost is cheap: an empty queue drains without any
|
|
1215
|
-
// inference.
|
|
1216
|
-
this.#turnRequested = false;
|
|
1217
|
-
// Every turn starts from durable state rather than from what this instance last
|
|
1218
|
-
// remembered, so the store remains authoritative after recovery.
|
|
1219
|
-
this.#loaded = undefined;
|
|
1220
|
-
// The durable history has to be loaded before anything else: a turn that cannot
|
|
1221
|
-
// read the conversation cannot answer it, and must not write to it either —
|
|
1222
|
-
// appending to a conversation it cannot see is how a message ends up after a
|
|
1223
|
-
// tool call nobody answered. The turn ends here instead, leaving everything
|
|
1224
|
-
// durable exactly as it was for the next attempt.
|
|
1225
|
-
const loadFailure = await this.#ensureLoaded().then(() => undefined, (error) => error);
|
|
1226
|
-
if (loadFailure !== undefined) {
|
|
1227
|
-
await this.#emit({
|
|
1228
|
-
type: "done",
|
|
1229
|
-
state: "error",
|
|
1230
|
-
kind: "internal_error",
|
|
1231
|
-
message: loadFailure instanceof Error
|
|
1232
|
-
? loadFailure.message
|
|
1233
|
-
: String(loadFailure),
|
|
1234
|
-
});
|
|
1235
|
-
this.#turnId = undefined;
|
|
1236
|
-
break;
|
|
1237
|
-
}
|
|
1238
|
-
const turnStart = {
|
|
1239
|
-
loopId: loop.loopId,
|
|
1240
|
-
turnId: this.#turnId,
|
|
1241
|
-
contextTokens: this.#contextTokens,
|
|
1242
|
-
};
|
|
1243
|
-
await this.#enterStage("inference", this.#hooks.beforeTurnTransact === undefined
|
|
1244
|
-
? undefined
|
|
1245
|
-
: (hookCtx) => this.#hooks.beforeTurnTransact?.(hookCtx, turnStart));
|
|
1246
|
-
await this.#applyActions(this.#hooks.beforeTurn, abort.signal, turnStart);
|
|
1247
|
-
await this.#runInference(abort);
|
|
1248
|
-
if (this.#durableWorkBlocked)
|
|
1236
|
+
const outcome = await ctx.span("agent.turn", (turnCtx) => this.#runTurn(turnCtx, loop, abort));
|
|
1237
|
+
if (outcome === "blocked")
|
|
1249
1238
|
return;
|
|
1250
|
-
|
|
1251
|
-
loopId: loop.loopId,
|
|
1252
|
-
turnId: turnStart.turnId,
|
|
1253
|
-
contextTokens: this.#contextTokens,
|
|
1254
|
-
aborted: this.#turnAborted,
|
|
1255
|
-
};
|
|
1256
|
-
const completedTurnId = this.#turnId;
|
|
1257
|
-
this.#turnId = undefined;
|
|
1258
|
-
try {
|
|
1259
|
-
await this.#enterStage("inference", this.#hooks.afterTurnTransact === undefined
|
|
1260
|
-
? undefined
|
|
1261
|
-
: (hookCtx) => this.#hooks.afterTurnTransact?.(hookCtx, turn));
|
|
1262
|
-
}
|
|
1263
|
-
catch (error) {
|
|
1264
|
-
this.#turnId = completedTurnId;
|
|
1265
|
-
throw error;
|
|
1266
|
-
}
|
|
1267
|
-
await this.#applyActions(this.#hooks.afterTurn, abort.signal, turn);
|
|
1268
|
-
if (!this.#turnRequested || (this.#closed && !this.#hasNoticeWorkToFinish())) {
|
|
1239
|
+
if (outcome === "stop")
|
|
1269
1240
|
break;
|
|
1270
|
-
}
|
|
1271
1241
|
// Each turn cancels on its own scope. Reopening it here rather than at the top
|
|
1272
1242
|
// keeps the run's first turn under the scope its opening hook already ran in.
|
|
1273
1243
|
abort = this.#openAbortScope();
|
|
1274
1244
|
} while (true);
|
|
1275
|
-
await this.#enterStage("inference", this.#hooks.afterAgentLoopTransact === undefined
|
|
1245
|
+
await this.#enterStage(ctx, "inference", this.#hooks.afterAgentLoopTransact === undefined
|
|
1276
1246
|
? undefined
|
|
1277
1247
|
: (hookCtx) => this.#hooks.afterAgentLoopTransact?.(hookCtx, loop));
|
|
1278
|
-
await this.#applyActions(this.#hooks.afterAgentLoop, abort.signal, loop);
|
|
1248
|
+
await this.#applyActions(ctx, this.#hooks.afterAgentLoop, abort.signal, loop);
|
|
1279
1249
|
} while (this.#turnRequested && (!this.#closed || this.#hasNoticeWorkToFinish()));
|
|
1280
1250
|
// Nothing is asked for any more, so the outstanding work is erased. That erasure is what
|
|
1281
1251
|
// makes the agent idle, and it commits together with whatever the settling hooks write,
|
|
1282
1252
|
// so no owner can ever see the agent finished without their conclusions or their
|
|
1283
1253
|
// conclusions without the agent being finished.
|
|
1284
1254
|
this.#settlementId ??= createId();
|
|
1285
|
-
await this.#enterStage("settlement");
|
|
1286
|
-
await this.#settleDurably({
|
|
1255
|
+
await this.#enterStage(ctx, "settlement");
|
|
1256
|
+
await this.#settleDurably(ctx, {
|
|
1287
1257
|
loopId: this.#loopId ?? createId(),
|
|
1288
1258
|
settlementId: this.#settlementId,
|
|
1289
1259
|
});
|
|
1290
1260
|
}
|
|
1261
|
+
/**
|
|
1262
|
+
* One turn: reload the durable state, ask the pre-turn hooks what to do, run the inference
|
|
1263
|
+
* and its tools, then ask the post-turn hooks. It answers with what the run should do next —
|
|
1264
|
+
* open another turn, stop because nothing more is asked for, or give up entirely because a
|
|
1265
|
+
* staged tool result could not settle and this run must leave its pending state intact.
|
|
1266
|
+
*/
|
|
1267
|
+
async #runTurn(ctx, loop, abort) {
|
|
1268
|
+
this.#turnAborted = false;
|
|
1269
|
+
this.#durableWorkBlocked = false;
|
|
1270
|
+
this.#turnId ??= createId();
|
|
1271
|
+
// Claimed before any awaiting, so a request raised while the turn is still starting up
|
|
1272
|
+
// survives into another turn instead of being cleared by it. The redundant turn this can
|
|
1273
|
+
// cost is cheap: an empty queue drains without any inference.
|
|
1274
|
+
this.#turnRequested = false;
|
|
1275
|
+
// Every turn starts from durable state rather than from what this instance last
|
|
1276
|
+
// remembered, so the store remains authoritative after recovery.
|
|
1277
|
+
this.#loaded = undefined;
|
|
1278
|
+
// The durable history has to be loaded before anything else: a turn that cannot read the
|
|
1279
|
+
// conversation cannot answer it, and must not write to it either — appending to a
|
|
1280
|
+
// conversation it cannot see is how a message ends up after a tool call nobody answered.
|
|
1281
|
+
// The turn ends here instead, leaving everything durable exactly as it was for the next
|
|
1282
|
+
// attempt.
|
|
1283
|
+
const loadFailure = await this.#ensureLoaded(ctx).then(() => undefined, (error) => error);
|
|
1284
|
+
if (loadFailure !== undefined) {
|
|
1285
|
+
await this.#emit(ctx, {
|
|
1286
|
+
type: "done",
|
|
1287
|
+
state: "error",
|
|
1288
|
+
kind: "internal_error",
|
|
1289
|
+
message: loadFailure instanceof Error ? loadFailure.message : String(loadFailure),
|
|
1290
|
+
});
|
|
1291
|
+
this.#turnId = undefined;
|
|
1292
|
+
return "stop";
|
|
1293
|
+
}
|
|
1294
|
+
const turnStart = {
|
|
1295
|
+
loopId: loop.loopId,
|
|
1296
|
+
turnId: this.#turnId,
|
|
1297
|
+
contextTokens: this.#contextTokens,
|
|
1298
|
+
};
|
|
1299
|
+
await this.#enterStage(ctx, "inference", this.#hooks.beforeTurnTransact === undefined
|
|
1300
|
+
? undefined
|
|
1301
|
+
: (hookCtx) => this.#hooks.beforeTurnTransact?.(hookCtx, turnStart));
|
|
1302
|
+
await this.#applyActions(ctx, this.#hooks.beforeTurn, abort.signal, turnStart);
|
|
1303
|
+
await this.#runInference(ctx, abort);
|
|
1304
|
+
if (this.#durableWorkBlocked)
|
|
1305
|
+
return "blocked";
|
|
1306
|
+
const turn = {
|
|
1307
|
+
loopId: loop.loopId,
|
|
1308
|
+
turnId: turnStart.turnId,
|
|
1309
|
+
contextTokens: this.#contextTokens,
|
|
1310
|
+
aborted: this.#turnAborted,
|
|
1311
|
+
};
|
|
1312
|
+
const completedTurnId = this.#turnId;
|
|
1313
|
+
this.#turnId = undefined;
|
|
1314
|
+
try {
|
|
1315
|
+
await this.#enterStage(ctx, "inference", this.#hooks.afterTurnTransact === undefined
|
|
1316
|
+
? undefined
|
|
1317
|
+
: (hookCtx) => this.#hooks.afterTurnTransact?.(hookCtx, turn));
|
|
1318
|
+
}
|
|
1319
|
+
catch (error) {
|
|
1320
|
+
this.#turnId = completedTurnId;
|
|
1321
|
+
throw error;
|
|
1322
|
+
}
|
|
1323
|
+
await this.#applyActions(ctx, this.#hooks.afterTurn, abort.signal, turn);
|
|
1324
|
+
if (!this.#turnRequested || (this.#closed && !this.#hasNoticeWorkToFinish())) {
|
|
1325
|
+
return "stop";
|
|
1326
|
+
}
|
|
1327
|
+
return "continue";
|
|
1328
|
+
}
|
|
1291
1329
|
/**
|
|
1292
1330
|
* Erase the outstanding work and let the transactional settling hooks write in the same
|
|
1293
1331
|
* transaction. A failure leaves the record in place: an agent wrongly believed to be working
|
|
1294
1332
|
* is resumed and finds nothing to do, while one wrongly believed to be finished is never
|
|
1295
1333
|
* resumed at all.
|
|
1296
1334
|
*/
|
|
1297
|
-
async #settleDurably(settlement) {
|
|
1335
|
+
async #settleDurably(ctx, settlement) {
|
|
1336
|
+
await ctx.span("agent.settle", (settleCtx) => this.#settleRecord(settleCtx, settlement));
|
|
1337
|
+
}
|
|
1338
|
+
/** The settlement itself, on the context of the span it belongs to. */
|
|
1339
|
+
async #settleRecord(ctx, settlement) {
|
|
1298
1340
|
try {
|
|
1299
|
-
await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1341
|
+
await this.#runInPersistenceLock(this.#workContext(ctx), (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1300
1342
|
await this.#clearPending(txCtx);
|
|
1301
1343
|
await this.#invokeTransactionalSettle(txCtx, settlement);
|
|
1302
1344
|
// The run store is erased last, so a settling hook can still read what the
|
|
@@ -1377,9 +1419,9 @@ export class AgentBase {
|
|
|
1377
1419
|
// Hooks observe the run; they never fail it.
|
|
1378
1420
|
}
|
|
1379
1421
|
}
|
|
1380
|
-
/** Call an observing hook on the
|
|
1381
|
-
async #invokeHook(hook, ...args) {
|
|
1382
|
-
await this.#invokeHookOn(this.#ctx, hook, ...args);
|
|
1422
|
+
/** Call an observing hook on the context the work in progress is running on. */
|
|
1423
|
+
async #invokeHook(ctx, hook, ...args) {
|
|
1424
|
+
await this.#invokeHookOn(this.#workContext(ctx), hook, ...args);
|
|
1383
1425
|
}
|
|
1384
1426
|
/**
|
|
1385
1427
|
* Lend a hook the transaction's context and module stores for exactly one callback. Keeping
|
|
@@ -1403,19 +1445,19 @@ export class AgentBase {
|
|
|
1403
1445
|
* out of work the caller had just cancelled. The answer is dropped rather than deferred,
|
|
1404
1446
|
* since it was a decision about a turn that no longer exists.
|
|
1405
1447
|
*/
|
|
1406
|
-
async #applyActions(hook, signal, ...args) {
|
|
1448
|
+
async #applyActions(ctx, hook, signal, ...args) {
|
|
1407
1449
|
if (hook === undefined)
|
|
1408
1450
|
return;
|
|
1409
1451
|
let actions;
|
|
1410
1452
|
try {
|
|
1411
|
-
actions = await hook(this.#ctx, ...args);
|
|
1453
|
+
actions = await hook(this.#workContext(ctx), ...args);
|
|
1412
1454
|
}
|
|
1413
1455
|
catch {
|
|
1414
1456
|
return;
|
|
1415
1457
|
}
|
|
1416
1458
|
if (signal.aborted)
|
|
1417
1459
|
return;
|
|
1418
|
-
await this.#carryOutActions(actions);
|
|
1460
|
+
await this.#carryOutActions(ctx, actions);
|
|
1419
1461
|
}
|
|
1420
1462
|
/**
|
|
1421
1463
|
* Ask a lifecycle hook what to do next and carry its actions out: queue steering, sent
|
|
@@ -1424,31 +1466,32 @@ export class AgentBase {
|
|
|
1424
1466
|
* Neither a throwing hook nor a failing action ever fails the run. Unlike `#applyActions`
|
|
1425
1467
|
* this belongs to no turn's scope, so nothing can cancel the answer out from under it.
|
|
1426
1468
|
*/
|
|
1427
|
-
async #applyActionsAlways(hook, ...args) {
|
|
1469
|
+
async #applyActionsAlways(ctx, hook, ...args) {
|
|
1428
1470
|
if (hook === undefined)
|
|
1429
1471
|
return;
|
|
1430
1472
|
let actions;
|
|
1431
1473
|
try {
|
|
1432
|
-
actions = await hook(this.#ctx, ...args);
|
|
1474
|
+
actions = await hook(this.#workContext(ctx), ...args);
|
|
1433
1475
|
}
|
|
1434
1476
|
catch {
|
|
1435
1477
|
return;
|
|
1436
1478
|
}
|
|
1437
|
-
await this.#carryOutActions(actions);
|
|
1479
|
+
await this.#carryOutActions(ctx, actions);
|
|
1438
1480
|
}
|
|
1439
1481
|
/**
|
|
1440
1482
|
* Carry out what a hook asked for. User messages keep their ordinary queues; system notices
|
|
1441
1483
|
* enter a separate durable queue consumed only at a safe history boundary.
|
|
1442
1484
|
*/
|
|
1443
|
-
async #carryOutActions(actions) {
|
|
1485
|
+
async #carryOutActions(ctx, actions) {
|
|
1444
1486
|
const batch = [];
|
|
1445
1487
|
const injections = [];
|
|
1446
1488
|
const flush = async () => {
|
|
1447
1489
|
const pending = batch.splice(0, batch.length);
|
|
1448
1490
|
const pendingInjections = injections.splice(0, injections.length);
|
|
1491
|
+
const workCtx = this.#workContext(ctx);
|
|
1449
1492
|
try {
|
|
1450
|
-
await this.#enqueue(
|
|
1451
|
-
await this.#enqueueInjections(
|
|
1493
|
+
await this.#enqueue(workCtx, pending);
|
|
1494
|
+
await this.#enqueueInjections(workCtx, pendingInjections);
|
|
1452
1495
|
}
|
|
1453
1496
|
catch {
|
|
1454
1497
|
// A hook-driven action must not fail the run.
|
|
@@ -1490,7 +1533,7 @@ export class AgentBase {
|
|
|
1490
1533
|
* until nothing is owed an answer. Every failure is caught here and surfaced to the
|
|
1491
1534
|
* conversation, so a turn ends with a complete context whatever went wrong.
|
|
1492
1535
|
*/
|
|
1493
|
-
async #runInference(abort) {
|
|
1536
|
+
async #runInference(ctx, abort) {
|
|
1494
1537
|
// One shared promise for the turn's scope keeps races from piling up listeners on the
|
|
1495
1538
|
// signal, and a scope that was aborted before this point settles it immediately: a
|
|
1496
1539
|
// listener added afterwards would never hear the event that already happened.
|
|
@@ -1500,7 +1543,7 @@ export class AgentBase {
|
|
|
1500
1543
|
abort.signal.addEventListener("abort", () => resolve(ABORTED), { once: true });
|
|
1501
1544
|
});
|
|
1502
1545
|
try {
|
|
1503
|
-
await this.#ensureLoaded();
|
|
1546
|
+
await this.#ensureLoaded(ctx);
|
|
1504
1547
|
// Resume a tool batch that was dispatched but cut off before its results landed, so
|
|
1505
1548
|
// the interrupted results reach the main store before any queued message.
|
|
1506
1549
|
const resumed = this.#pendingTools;
|
|
@@ -1511,21 +1554,21 @@ export class AgentBase {
|
|
|
1511
1554
|
// A batch that was never committed has certainly not run — the commit precedes
|
|
1512
1555
|
// every execution — so it is dispatched as the fresh batch it never got to be,
|
|
1513
1556
|
// rather than resumed, which would refuse the non-durable calls.
|
|
1514
|
-
if (await this.#runToolBatch(resumed, !undispatched, abort.signal, abortPromise)) {
|
|
1557
|
+
if (await this.#runToolBatch(ctx, resumed, !undispatched, abort.signal, abortPromise)) {
|
|
1515
1558
|
return;
|
|
1516
1559
|
}
|
|
1517
1560
|
}
|
|
1518
1561
|
let needsInference = resumed.length > 0;
|
|
1519
1562
|
// A requested compaction runs before this turn's first inference, so the model
|
|
1520
1563
|
// always receives a settled conversation — never one still owing tool results.
|
|
1521
|
-
await this.#runCompaction(abort.signal);
|
|
1564
|
+
await this.#runCompaction(ctx, abort.signal);
|
|
1522
1565
|
// An inference is needed without any injection when tool results from a resumed batch
|
|
1523
1566
|
// end the context, or — checked once, against the freshly loaded durable state —
|
|
1524
1567
|
// when a cut-off run left its trailing user or tool message unanswered. Afterwards
|
|
1525
1568
|
// a trailing user message can be legitimate: a response may have zero blocks.
|
|
1526
1569
|
if (!this.#recoveryChecked) {
|
|
1527
1570
|
this.#recoveryChecked = true;
|
|
1528
|
-
if (await this.#resumesInterruptedRun())
|
|
1571
|
+
if (await this.#resumesInterruptedRun(ctx))
|
|
1529
1572
|
needsInference = true;
|
|
1530
1573
|
}
|
|
1531
1574
|
// Each cycle first drains the queues, then runs one inference. Steering injects at
|
|
@@ -1549,100 +1592,34 @@ export class AgentBase {
|
|
|
1549
1592
|
this.#injections.length > 0 ||
|
|
1550
1593
|
this.#noticeAwaitingResponse;
|
|
1551
1594
|
if (hasPendingWork)
|
|
1552
|
-
await this.#emit({ type: "done", state: "cancelled" });
|
|
1595
|
+
await this.#emit(ctx, { type: "done", state: "cancelled" });
|
|
1553
1596
|
break;
|
|
1554
1597
|
}
|
|
1555
|
-
let injected = await this.#consumeQueue(this.#steering, this.#steeringMode, "steering");
|
|
1598
|
+
let injected = await this.#consumeQueue(ctx, this.#steering, this.#steeringMode, "steering");
|
|
1556
1599
|
if (!injected && !needsInference) {
|
|
1557
|
-
injected = await this.#consumeQueue(this.#sends, this.#sendMode, "send");
|
|
1600
|
+
injected = await this.#consumeQueue(ctx, this.#sends, this.#sendMode, "send");
|
|
1558
1601
|
}
|
|
1559
1602
|
// Notices follow queue consumption so a model-switching message can replace
|
|
1560
1603
|
// history first. Tool settlement and compaction already finished above.
|
|
1561
|
-
await this.#consumeInjections();
|
|
1604
|
+
await this.#consumeInjections(ctx);
|
|
1562
1605
|
// Nothing to answer — a start() on an idle history, or the queues ran dry.
|
|
1563
1606
|
if (!this.#noticeAwaitingResponse && !injected && !needsInference)
|
|
1564
1607
|
break;
|
|
1565
|
-
const
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
// that unwinding was detached from an earlier abort precisely so it could never
|
|
1570
|
-
// hold a cancellation open, so a new cancellation must not start waiting for it
|
|
1571
|
-
// either.
|
|
1572
|
-
if ((await Promise.race([this.#settled(), abortPromise])) === ABORTED)
|
|
1608
|
+
const response = await ctx.span("agent.inference", (inferenceCtx) => this.#requestInference(inferenceCtx, abortPromise));
|
|
1609
|
+
// A cancellation arrived before the request was made, so the turn cycles rather
|
|
1610
|
+
// than talking to a session it may no longer own.
|
|
1611
|
+
if (response === undefined)
|
|
1573
1612
|
continue;
|
|
1574
|
-
|
|
1575
|
-
const inferenceStart = {
|
|
1576
|
-
loopId: this.#loopId ?? createId(),
|
|
1577
|
-
turnId: this.#turnId ?? createId(),
|
|
1578
|
-
inferenceId: this.#inferenceId,
|
|
1579
|
-
contextTokens: this.#contextTokens,
|
|
1580
|
-
};
|
|
1581
|
-
this.#loopId = inferenceStart.loopId;
|
|
1582
|
-
this.#turnId = inferenceStart.turnId;
|
|
1583
|
-
await this.#enterStage("inference", this.#hooks.beforeInferenceTransact === undefined
|
|
1584
|
-
? undefined
|
|
1585
|
-
: (hookCtx) => this.#hooks.beforeInferenceTransact?.(hookCtx, inferenceStart));
|
|
1586
|
-
await this.#invokeHook(this.#hooks.beforeInference, inferenceStart);
|
|
1587
|
-
const stream = session.run(this.#ctx, {
|
|
1588
|
-
context: {
|
|
1589
|
-
instructions,
|
|
1590
|
-
messages: [...this.#messages],
|
|
1591
|
-
},
|
|
1592
|
-
...(this.#model === undefined ? {} : { model: this.#model }),
|
|
1593
|
-
...(this.#effort === undefined ? {} : { effort: this.#effort }),
|
|
1594
|
-
...(this.#serviceTier === undefined ? {} : { serviceTier: this.#serviceTier }),
|
|
1595
|
-
});
|
|
1596
|
-
const { content, state, errorMessage, tokens } = await this.#collect(stream, abortPromise);
|
|
1597
|
-
// A cancellation or a stream ending without a done event did not answer an
|
|
1598
|
-
// appended notice. Keep that obligation and reopen it under a fresh turn scope.
|
|
1599
|
-
// Every terminal provider outcome counts as the response, including an error.
|
|
1600
|
-
if (this.#noticeAwaitingResponse &&
|
|
1601
|
-
(state === "cancelled" || state === undefined)) {
|
|
1602
|
-
this.#turnRequested = true;
|
|
1603
|
-
}
|
|
1604
|
-
else {
|
|
1605
|
-
this.#noticeAwaitingResponse = false;
|
|
1606
|
-
this.#clearTurnRequestIfNoPendingInput();
|
|
1607
|
-
}
|
|
1608
|
-
// A cancelled or failed response measures nothing, so the conversation keeps
|
|
1609
|
-
// the last real measurement instead of forgetting how large it had become.
|
|
1610
|
-
const inference = {
|
|
1611
|
-
...inferenceStart,
|
|
1612
|
-
state,
|
|
1613
|
-
tokens,
|
|
1614
|
-
...(errorMessage === undefined ? {} : { errorMessage }),
|
|
1615
|
-
};
|
|
1616
|
-
const afterInferenceTransact = this.#hooks.afterInferenceTransact === undefined
|
|
1617
|
-
? undefined
|
|
1618
|
-
: (hookCtx) => this.#hooks.afterInferenceTransact?.(hookCtx, inference);
|
|
1619
|
-
const completedInferenceId = this.#inferenceId;
|
|
1620
|
-
this.#inferenceId = undefined;
|
|
1621
|
-
try {
|
|
1622
|
-
if (tokens === undefined) {
|
|
1623
|
-
await this.#enterStage("inference", afterInferenceTransact);
|
|
1624
|
-
}
|
|
1625
|
-
else {
|
|
1626
|
-
await this.#recordContextTokens(tokens.input + tokens.output, afterInferenceTransact);
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
catch (error) {
|
|
1630
|
-
this.#inferenceId = completedInferenceId;
|
|
1631
|
-
throw error;
|
|
1632
|
-
}
|
|
1633
|
-
await this.#invokeHook(this.#hooks.afterInference, inference);
|
|
1634
|
-
if (content.length > 0) {
|
|
1635
|
-
this.#messages.push({ role: "assistant", content });
|
|
1636
|
-
}
|
|
1613
|
+
const { content, state } = response;
|
|
1637
1614
|
needsInference = false;
|
|
1638
|
-
pendingError = state === "error" ? errorMessage : undefined;
|
|
1615
|
+
pendingError = state === "error" ? response.errorMessage : undefined;
|
|
1639
1616
|
if (state !== "tool_call") {
|
|
1640
1617
|
// A response can carry a tool call and still not end in one — a stream that
|
|
1641
1618
|
// failed or was cut off after the call was emitted. Nothing will dispatch
|
|
1642
1619
|
// it, so it is settled here rather than left in the conversation for ever.
|
|
1643
1620
|
// A settling the store refused ends the turn instead: the call stays last,
|
|
1644
1621
|
// where a later attempt can still answer it.
|
|
1645
|
-
if (!(await this.#settleUnansweredCalls("The response ended before this tool call was dispatched."))) {
|
|
1622
|
+
if (!(await this.#settleUnansweredCalls(ctx, "The response ended before this tool call was dispatched."))) {
|
|
1646
1623
|
break;
|
|
1647
1624
|
}
|
|
1648
1625
|
}
|
|
@@ -1650,7 +1627,7 @@ export class AgentBase {
|
|
|
1650
1627
|
const calls = content.filter((block) => block.type === "tool_call" && block.server !== true);
|
|
1651
1628
|
if (calls.length === 0)
|
|
1652
1629
|
continue;
|
|
1653
|
-
const closedDuringTools = await this.#runToolBatch(calls.map((call, index) => this.#newToolEntry(index, call)), false, abort.signal, abortPromise);
|
|
1630
|
+
const closedDuringTools = await this.#runToolBatch(ctx, calls.map((call, index) => this.#newToolEntry(index, call)), false, abort.signal, abortPromise);
|
|
1654
1631
|
if (closedDuringTools)
|
|
1655
1632
|
break;
|
|
1656
1633
|
needsInference = true;
|
|
@@ -1667,11 +1644,11 @@ export class AgentBase {
|
|
|
1667
1644
|
if (pendingError !== undefined &&
|
|
1668
1645
|
this.#loaded !== undefined &&
|
|
1669
1646
|
this.#unansweredCalls(this.#messages).length === 0) {
|
|
1670
|
-
await this.#appendFailure(pendingError);
|
|
1647
|
+
await this.#appendFailure(ctx, pendingError);
|
|
1671
1648
|
}
|
|
1672
1649
|
}
|
|
1673
1650
|
catch (error) {
|
|
1674
|
-
await this.#emit({
|
|
1651
|
+
await this.#emit(ctx, {
|
|
1675
1652
|
type: "done",
|
|
1676
1653
|
state: "error",
|
|
1677
1654
|
kind: "internal_error",
|
|
@@ -1682,14 +1659,93 @@ export class AgentBase {
|
|
|
1682
1659
|
// outright and no later turn would ever repair. When even that write is refused, the
|
|
1683
1660
|
// note is not written either — the call stays last, and the next run settles it
|
|
1684
1661
|
// before anything else is said.
|
|
1685
|
-
if (await this.#settleUnansweredCalls("The turn failed before this tool call finished.")) {
|
|
1686
|
-
await this.#appendFailure(error instanceof Error ? error.message : String(error));
|
|
1662
|
+
if (await this.#settleUnansweredCalls(ctx, "The turn failed before this tool call finished.")) {
|
|
1663
|
+
await this.#appendFailure(ctx, error instanceof Error ? error.message : String(error));
|
|
1687
1664
|
}
|
|
1688
1665
|
this.#noticeAwaitingResponse = false;
|
|
1689
1666
|
this.#clearTurnRequestIfNoPendingInput();
|
|
1690
1667
|
}
|
|
1691
1668
|
this.#turnAborted = abort.signal.aborted;
|
|
1692
1669
|
}
|
|
1670
|
+
/**
|
|
1671
|
+
* Make one request of the provider and take its answer: the instructions and tools the turn
|
|
1672
|
+
* is running with, the session they belong to, the inference brackets around the request, and
|
|
1673
|
+
* the blocks the model actually finished saying. Nothing comes back when a cancellation
|
|
1674
|
+
* arrived before the request was made, since there is no response to account for.
|
|
1675
|
+
*/
|
|
1676
|
+
async #requestInference(ctx, abortPromise) {
|
|
1677
|
+
const instructions = await this.#instructions(ctx);
|
|
1678
|
+
const tools = await this.#tools(ctx);
|
|
1679
|
+
const session = await this.#ensureSession(instructions, tools);
|
|
1680
|
+
// Nothing from the previous response may still be holding the session — but that
|
|
1681
|
+
// unwinding was detached from an earlier abort precisely so it could never hold a
|
|
1682
|
+
// cancellation open, so a new cancellation must not start waiting for it either.
|
|
1683
|
+
if ((await Promise.race([this.#settled(), abortPromise])) === ABORTED)
|
|
1684
|
+
return undefined;
|
|
1685
|
+
this.#inferenceId ??= createId();
|
|
1686
|
+
const inferenceStart = {
|
|
1687
|
+
loopId: this.#loopId ?? createId(),
|
|
1688
|
+
turnId: this.#turnId ?? createId(),
|
|
1689
|
+
inferenceId: this.#inferenceId,
|
|
1690
|
+
contextTokens: this.#contextTokens,
|
|
1691
|
+
};
|
|
1692
|
+
this.#loopId = inferenceStart.loopId;
|
|
1693
|
+
this.#turnId = inferenceStart.turnId;
|
|
1694
|
+
await this.#enterStage(ctx, "inference", this.#hooks.beforeInferenceTransact === undefined
|
|
1695
|
+
? undefined
|
|
1696
|
+
: (hookCtx) => this.#hooks.beforeInferenceTransact?.(hookCtx, inferenceStart));
|
|
1697
|
+
await this.#invokeHook(ctx, this.#hooks.beforeInference, inferenceStart);
|
|
1698
|
+
const stream = session.run(this.#workContext(ctx), {
|
|
1699
|
+
context: {
|
|
1700
|
+
instructions,
|
|
1701
|
+
messages: [...this.#messages],
|
|
1702
|
+
},
|
|
1703
|
+
...(this.#model === undefined ? {} : { model: this.#model }),
|
|
1704
|
+
...(this.#effort === undefined ? {} : { effort: this.#effort }),
|
|
1705
|
+
...(this.#serviceTier === undefined ? {} : { serviceTier: this.#serviceTier }),
|
|
1706
|
+
});
|
|
1707
|
+
const { content, state, errorMessage, tokens } = await this.#collect(ctx, stream, abortPromise);
|
|
1708
|
+
// A cancellation or a stream ending without a done event did not answer an appended
|
|
1709
|
+
// notice. Keep that obligation and reopen it under a fresh turn scope. Every terminal
|
|
1710
|
+
// provider outcome counts as the response, including an error.
|
|
1711
|
+
if (this.#noticeAwaitingResponse && (state === "cancelled" || state === undefined)) {
|
|
1712
|
+
this.#turnRequested = true;
|
|
1713
|
+
}
|
|
1714
|
+
else {
|
|
1715
|
+
this.#noticeAwaitingResponse = false;
|
|
1716
|
+
this.#clearTurnRequestIfNoPendingInput();
|
|
1717
|
+
}
|
|
1718
|
+
// A cancelled or failed response measures nothing, so the conversation keeps the last
|
|
1719
|
+
// real measurement instead of forgetting how large it had become.
|
|
1720
|
+
const inference = {
|
|
1721
|
+
...inferenceStart,
|
|
1722
|
+
state,
|
|
1723
|
+
tokens,
|
|
1724
|
+
...(errorMessage === undefined ? {} : { errorMessage }),
|
|
1725
|
+
};
|
|
1726
|
+
const afterInferenceTransact = this.#hooks.afterInferenceTransact === undefined
|
|
1727
|
+
? undefined
|
|
1728
|
+
: (hookCtx) => this.#hooks.afterInferenceTransact?.(hookCtx, inference);
|
|
1729
|
+
const completedInferenceId = this.#inferenceId;
|
|
1730
|
+
this.#inferenceId = undefined;
|
|
1731
|
+
try {
|
|
1732
|
+
if (tokens === undefined) {
|
|
1733
|
+
await this.#enterStage(ctx, "inference", afterInferenceTransact);
|
|
1734
|
+
}
|
|
1735
|
+
else {
|
|
1736
|
+
await this.#recordContextTokens(ctx, tokens.input + tokens.output, afterInferenceTransact);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
catch (error) {
|
|
1740
|
+
this.#inferenceId = completedInferenceId;
|
|
1741
|
+
throw error;
|
|
1742
|
+
}
|
|
1743
|
+
await this.#invokeHook(ctx, this.#hooks.afterInference, inference);
|
|
1744
|
+
if (content.length > 0) {
|
|
1745
|
+
this.#messages.push({ role: "assistant", content });
|
|
1746
|
+
}
|
|
1747
|
+
return { content, state, ...(errorMessage === undefined ? {} : { errorMessage }) };
|
|
1748
|
+
}
|
|
1693
1749
|
/**
|
|
1694
1750
|
* Whether this agent is picking up a run that was cut off rather than starting a fresh one,
|
|
1695
1751
|
* and so owes an inference nobody asked for again.
|
|
@@ -1700,18 +1756,18 @@ export class AgentBase {
|
|
|
1700
1756
|
* beginning of a block that will now never arrive is told to drop it. Only finished blocks
|
|
1701
1757
|
* are persisted, so the conversation is intact and it is the view being corrected.
|
|
1702
1758
|
*/
|
|
1703
|
-
async #resumesInterruptedRun() {
|
|
1759
|
+
async #resumesInterruptedRun(ctx) {
|
|
1704
1760
|
const owed = this.#lastRecordType === "user" ||
|
|
1705
1761
|
this.#lastRecordType === "tool" ||
|
|
1706
1762
|
this.#lastRecordType === "system";
|
|
1707
1763
|
if (owed && this.#inherited?.stage === "inference") {
|
|
1708
|
-
await this.#emit({ type: "block_reset" });
|
|
1764
|
+
await this.#emit(ctx, { type: "block_reset" });
|
|
1709
1765
|
}
|
|
1710
1766
|
return owed;
|
|
1711
1767
|
}
|
|
1712
1768
|
/** Load the durable state once. A failed load is not sticky: the next turn retries it. */
|
|
1713
|
-
async #ensureLoaded() {
|
|
1714
|
-
this.#loaded ??= this.#loadHistory().catch((error) => {
|
|
1769
|
+
async #ensureLoaded(ctx) {
|
|
1770
|
+
this.#loaded ??= this.#loadHistory(ctx).catch((error) => {
|
|
1715
1771
|
this.#loaded = undefined;
|
|
1716
1772
|
throw error;
|
|
1717
1773
|
});
|
|
@@ -1722,11 +1778,11 @@ export class AgentBase {
|
|
|
1722
1778
|
* lets a restarted agent keep knowing how large the conversation is without inferring it;
|
|
1723
1779
|
* a failed write costs only that knowledge and never the response that produced it.
|
|
1724
1780
|
*/
|
|
1725
|
-
async #recordContextTokens(tokens, transact) {
|
|
1781
|
+
async #recordContextTokens(ctx, tokens, transact) {
|
|
1726
1782
|
const previousTokens = this.#contextTokens;
|
|
1727
1783
|
this.#contextTokens = tokens;
|
|
1728
1784
|
try {
|
|
1729
|
-
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1785
|
+
await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
|
|
1730
1786
|
const write = (writeCtx) => tokens === undefined
|
|
1731
1787
|
? this.#persistence.deleteValue(writeCtx, "context")
|
|
1732
1788
|
: this.#persistence.writeValue(writeCtx, "context", { tokens });
|
|
@@ -1757,14 +1813,20 @@ export class AgentBase {
|
|
|
1757
1813
|
* promise for every caller awaiting it. A provider failure rejects them and leaves history
|
|
1758
1814
|
* untouched.
|
|
1759
1815
|
*/
|
|
1760
|
-
async #runCompaction(signal) {
|
|
1816
|
+
async #runCompaction(ctx, signal) {
|
|
1817
|
+
if (this.#compaction === undefined)
|
|
1818
|
+
return;
|
|
1819
|
+
await ctx.span("agent.compaction", (compactionCtx) => this.#compactHistory(compactionCtx, signal));
|
|
1820
|
+
}
|
|
1821
|
+
/** The compaction itself, on the context of the span it belongs to. */
|
|
1822
|
+
async #compactHistory(ctx, signal) {
|
|
1761
1823
|
const pending = this.#compaction;
|
|
1762
1824
|
if (pending === undefined)
|
|
1763
1825
|
return;
|
|
1764
1826
|
try {
|
|
1765
|
-
await this.#enterStage("compaction");
|
|
1766
|
-
const instructions = await this.#instructions();
|
|
1767
|
-
const session = await this.#ensureSession(instructions, await this.#tools());
|
|
1827
|
+
await this.#enterStage(ctx, "compaction");
|
|
1828
|
+
const instructions = await this.#instructions(ctx);
|
|
1829
|
+
const session = await this.#ensureSession(instructions, await this.#tools(ctx));
|
|
1768
1830
|
const snapshot = [...this.#messages];
|
|
1769
1831
|
await this.#settled();
|
|
1770
1832
|
const compactionStart = {
|
|
@@ -1775,16 +1837,16 @@ export class AgentBase {
|
|
|
1775
1837
|
};
|
|
1776
1838
|
this.#loopId = compactionStart.loopId;
|
|
1777
1839
|
this.#turnId = compactionStart.turnId;
|
|
1778
|
-
await this.#invokeHook(this.#hooks.beforeCompaction, compactionStart);
|
|
1840
|
+
await this.#invokeHook(ctx, this.#hooks.beforeCompaction, compactionStart);
|
|
1779
1841
|
// Provider compaction is this turn's work, so it runs on this turn's lifetime: an
|
|
1780
1842
|
// abort reaches the provider operation itself rather than waiting for it to finish
|
|
1781
1843
|
// work nobody wants any more.
|
|
1782
|
-
const result = await session.compact(withLifetime(this.#ctx, signal), {
|
|
1844
|
+
const result = await session.compact(withLifetime(this.#workContext(ctx), signal), {
|
|
1783
1845
|
context: { instructions, messages: snapshot },
|
|
1784
1846
|
...(this.#model === undefined ? {} : { model: this.#model }),
|
|
1785
1847
|
});
|
|
1786
1848
|
if (result.status === "failed") {
|
|
1787
|
-
await this.#invokeHook(this.#hooks.afterCompaction, {
|
|
1849
|
+
await this.#invokeHook(ctx, this.#hooks.afterCompaction, {
|
|
1788
1850
|
...compactionStart,
|
|
1789
1851
|
result,
|
|
1790
1852
|
});
|
|
@@ -1792,7 +1854,7 @@ export class AgentBase {
|
|
|
1792
1854
|
}
|
|
1793
1855
|
if (result.status === "completed") {
|
|
1794
1856
|
const completed = { ...compactionStart, result };
|
|
1795
|
-
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1857
|
+
await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
|
|
1796
1858
|
// Physically delete the superseded records and write the replacement —
|
|
1797
1859
|
// which keeps the messages that stay — in one atomic step.
|
|
1798
1860
|
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
@@ -1813,9 +1875,9 @@ export class AgentBase {
|
|
|
1813
1875
|
});
|
|
1814
1876
|
// The conversation the measurement described is gone; its size is unknown
|
|
1815
1877
|
// again until the next response measures the replacement.
|
|
1816
|
-
await this.#recordContextTokens(undefined);
|
|
1878
|
+
await this.#recordContextTokens(ctx, undefined);
|
|
1817
1879
|
}
|
|
1818
|
-
await this.#invokeHook(this.#hooks.afterCompaction, {
|
|
1880
|
+
await this.#invokeHook(ctx, this.#hooks.afterCompaction, {
|
|
1819
1881
|
...compactionStart,
|
|
1820
1882
|
result,
|
|
1821
1883
|
});
|
|
@@ -1836,7 +1898,7 @@ export class AgentBase {
|
|
|
1836
1898
|
* happen must append nothing either — leaving the call last is what lets a later attempt,
|
|
1837
1899
|
* here or after a restart, still answer it.
|
|
1838
1900
|
*/
|
|
1839
|
-
async #settleUnansweredCalls(reason) {
|
|
1901
|
+
async #settleUnansweredCalls(ctx, reason) {
|
|
1840
1902
|
// Nothing is known about the conversation, so nothing may be said about it.
|
|
1841
1903
|
if (this.#loaded === undefined)
|
|
1842
1904
|
return false;
|
|
@@ -1846,7 +1908,7 @@ export class AgentBase {
|
|
|
1846
1908
|
let settled = false;
|
|
1847
1909
|
let staged = false;
|
|
1848
1910
|
try {
|
|
1849
|
-
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1911
|
+
await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
|
|
1850
1912
|
// A call the durable batch still holds belongs to the resume, which answers it
|
|
1851
1913
|
// properly — and re-executes it when the tool is durable. Settling it here as
|
|
1852
1914
|
// well would give the conversation two results for one call.
|
|
@@ -1946,7 +2008,7 @@ export class AgentBase {
|
|
|
1946
2008
|
* there is no context to append to; its own failure is swallowed, so surfacing a failure can
|
|
1947
2009
|
* never cause another.
|
|
1948
2010
|
*/
|
|
1949
|
-
async #appendFailure(message) {
|
|
2011
|
+
async #appendFailure(ctx, message) {
|
|
1950
2012
|
if (this.#loaded === undefined)
|
|
1951
2013
|
return;
|
|
1952
2014
|
const failure = {
|
|
@@ -1954,7 +2016,7 @@ export class AgentBase {
|
|
|
1954
2016
|
content: [{ type: "text", text: `The last turn failed: ${message}` }],
|
|
1955
2017
|
};
|
|
1956
2018
|
try {
|
|
1957
|
-
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
2019
|
+
await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
|
|
1958
2020
|
await this.#appendRecord(lockCtx, { type: "system", message: failure });
|
|
1959
2021
|
this.#messages.push(failure);
|
|
1960
2022
|
});
|
|
@@ -1964,8 +2026,8 @@ export class AgentBase {
|
|
|
1964
2026
|
}
|
|
1965
2027
|
}
|
|
1966
2028
|
/** Move every pending hook notice into history as one atomic, ordered append batch. */
|
|
1967
|
-
async #consumeInjections() {
|
|
1968
|
-
return await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
2029
|
+
async #consumeInjections(ctx) {
|
|
2030
|
+
return await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
|
|
1969
2031
|
if (this.#injections.length === 0)
|
|
1970
2032
|
return false;
|
|
1971
2033
|
const durable = new Set((await this.#persistence.readValues(lockCtx, "inject.")).map(({ key }) => key));
|
|
@@ -2017,12 +2079,12 @@ export class AgentBase {
|
|
|
2017
2079
|
* told a message has landed may perfectly well answer by sending another one, and doing that
|
|
2018
2080
|
* while this still held the store lock would be the hook waiting for its own caller.
|
|
2019
2081
|
*/
|
|
2020
|
-
async #consumeQueue(queue, mode, kind) {
|
|
2082
|
+
async #consumeQueue(ctx, queue, mode, kind) {
|
|
2021
2083
|
const prefix = `${kind}.`;
|
|
2022
2084
|
/** Filled in once the consumption has committed, and reported after the lock is released. */
|
|
2023
2085
|
const accepted = [];
|
|
2024
2086
|
let permissionChange;
|
|
2025
|
-
const consumed = await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
2087
|
+
const consumed = await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
|
|
2026
2088
|
if (queue.length === 0)
|
|
2027
2089
|
return false;
|
|
2028
2090
|
// The durable queue, not memory, decides what is left to consume after a restart.
|
|
@@ -2204,7 +2266,9 @@ export class AgentBase {
|
|
|
2204
2266
|
id: entry.id,
|
|
2205
2267
|
kind,
|
|
2206
2268
|
message: entry.message,
|
|
2207
|
-
...(entry.metadata === undefined
|
|
2269
|
+
...(entry.metadata === undefined
|
|
2270
|
+
? {}
|
|
2271
|
+
: { metadata: entry.metadata }),
|
|
2208
2272
|
});
|
|
2209
2273
|
}
|
|
2210
2274
|
});
|
|
@@ -2239,13 +2303,13 @@ export class AgentBase {
|
|
|
2239
2303
|
}
|
|
2240
2304
|
return true;
|
|
2241
2305
|
});
|
|
2242
|
-
// Outside the lock, and on
|
|
2243
|
-
//
|
|
2306
|
+
// Outside the lock, and on a context carrying whatever these messages made effective:
|
|
2307
|
+
// the selection is read now rather than when the turn opened.
|
|
2244
2308
|
if (permissionChange !== undefined) {
|
|
2245
|
-
await this.#invokeHook(this.#hooks.permissionModeChanged, permissionChange);
|
|
2309
|
+
await this.#invokeHook(ctx, this.#hooks.permissionModeChanged, permissionChange);
|
|
2246
2310
|
}
|
|
2247
2311
|
for (const message of accepted) {
|
|
2248
|
-
await this.#invokeHook(this.#hooks.messageAccepted, message);
|
|
2312
|
+
await this.#invokeHook(ctx, this.#hooks.messageAccepted, message);
|
|
2249
2313
|
}
|
|
2250
2314
|
return consumed;
|
|
2251
2315
|
}
|
|
@@ -2266,8 +2330,8 @@ export class AgentBase {
|
|
|
2266
2330
|
* entirely: the main store rebuilds the context, and the sorted queue keys rebuild the
|
|
2267
2331
|
* not-yet-consumed queues. Consecutive block records reassemble into one assistant message.
|
|
2268
2332
|
*/
|
|
2269
|
-
async #loadHistory() {
|
|
2270
|
-
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
2333
|
+
async #loadHistory(ctx) {
|
|
2334
|
+
await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
|
|
2271
2335
|
const records = await this.#persistence.load(lockCtx);
|
|
2272
2336
|
const last = records[records.length - 1];
|
|
2273
2337
|
this.#lastRecordType = last?.type;
|
|
@@ -2347,9 +2411,13 @@ export class AgentBase {
|
|
|
2347
2411
|
* settles every call still running as an aborted error result, so the batch always leaves a
|
|
2348
2412
|
* complete context behind.
|
|
2349
2413
|
*/
|
|
2350
|
-
async #runToolBatch(entries, resume, signal, abortPromise) {
|
|
2414
|
+
async #runToolBatch(ctx, entries, resume, signal, abortPromise) {
|
|
2415
|
+
return await ctx.span("agent.tools", (batchCtx) => this.#dispatchToolBatch(batchCtx, entries, resume, signal, abortPromise));
|
|
2416
|
+
}
|
|
2417
|
+
/** The batch itself, on the context of the span it belongs to. */
|
|
2418
|
+
async #dispatchToolBatch(ctx, entries, resume, signal, abortPromise) {
|
|
2351
2419
|
if (!resume) {
|
|
2352
|
-
await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
2420
|
+
await this.#runInPersistenceLock(this.#workContext(ctx), (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
2353
2421
|
for (const entry of entries) {
|
|
2354
2422
|
await this.#persistence.writeValue(txCtx, entry.key, this.#storedToolEntry(entry));
|
|
2355
2423
|
}
|
|
@@ -2365,7 +2433,7 @@ export class AgentBase {
|
|
|
2365
2433
|
}));
|
|
2366
2434
|
}
|
|
2367
2435
|
else {
|
|
2368
|
-
await this.#enterStage("tools");
|
|
2436
|
+
await this.#enterStage(ctx, "tools");
|
|
2369
2437
|
}
|
|
2370
2438
|
const results = new Array(entries.length);
|
|
2371
2439
|
// Every execution actually started, whether or not its result reached the conversation.
|
|
@@ -2381,7 +2449,7 @@ export class AgentBase {
|
|
|
2381
2449
|
if (commitFailed)
|
|
2382
2450
|
return;
|
|
2383
2451
|
try {
|
|
2384
|
-
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
2452
|
+
await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
|
|
2385
2453
|
while (committed < entries.length) {
|
|
2386
2454
|
const entry = entries[committed];
|
|
2387
2455
|
const proposed = results[committed];
|
|
@@ -2435,12 +2503,15 @@ export class AgentBase {
|
|
|
2435
2503
|
if (entry.committed !== undefined) {
|
|
2436
2504
|
outcome = entry.committed;
|
|
2437
2505
|
}
|
|
2438
|
-
else if (resume && !(await this.#isDurable(entry.call))) {
|
|
2506
|
+
else if (resume && !(await this.#isDurable(ctx, entry.call))) {
|
|
2439
2507
|
outcome = toolFailure(entry.call.callId, "The tool call was interrupted by a restart and was not retried.");
|
|
2440
2508
|
}
|
|
2441
2509
|
else {
|
|
2442
2510
|
const toolLifetime = AbortSignal.any([signal, this.#closeController.signal]);
|
|
2443
|
-
|
|
2511
|
+
// The call's own span hangs off the batch's. Every call in the batch runs at
|
|
2512
|
+
// the same time, so each opens its span from the batch's context and carries
|
|
2513
|
+
// its own from there.
|
|
2514
|
+
const execution = ctx.span("agent.tool", (toolCtx) => this.#executeToolCall(withLifetime(this.#workContext(toolCtx), toolLifetime), entry));
|
|
2444
2515
|
running.push(execution);
|
|
2445
2516
|
outcome = await Promise.race([execution, abortPromise, this.#closingTools()]);
|
|
2446
2517
|
}
|
|
@@ -2492,8 +2563,8 @@ export class AgentBase {
|
|
|
2492
2563
|
});
|
|
2493
2564
|
}
|
|
2494
2565
|
/** Whether this call's tool may safely be executed again after a restart interrupted it. */
|
|
2495
|
-
async #isDurable(call) {
|
|
2496
|
-
const tool = (await this.#tools()).find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
|
|
2566
|
+
async #isDurable(ctx, call) {
|
|
2567
|
+
const tool = (await this.#tools(ctx)).find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
|
|
2497
2568
|
return tool?.durable === true;
|
|
2498
2569
|
}
|
|
2499
2570
|
/**
|
|
@@ -2596,7 +2667,7 @@ export class AgentBase {
|
|
|
2596
2667
|
content: [{ type: "text", text }],
|
|
2597
2668
|
isError: true,
|
|
2598
2669
|
});
|
|
2599
|
-
const tool = (await this.#tools()).find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
|
|
2670
|
+
const tool = (await this.#tools(ctx)).find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
|
|
2600
2671
|
if (tool === undefined) {
|
|
2601
2672
|
return failure(`Tool "${call.name}" is not available.`);
|
|
2602
2673
|
}
|
|
@@ -2823,7 +2894,7 @@ export class AgentBase {
|
|
|
2823
2894
|
* the model actually finished saying: a response cut off mid-block keeps the finished blocks
|
|
2824
2895
|
* alone, so memory never differs from what a reload would rebuild.
|
|
2825
2896
|
*/
|
|
2826
|
-
async #collect(stream, abortPromise) {
|
|
2897
|
+
async #collect(ctx, stream, abortPromise) {
|
|
2827
2898
|
const content = [];
|
|
2828
2899
|
// Blocks that finished and were durably appended. An abort keeps exactly these, so the
|
|
2829
2900
|
// in-memory assistant message never diverges from what a reload would rebuild.
|
|
@@ -2832,7 +2903,7 @@ export class AgentBase {
|
|
|
2832
2903
|
const persist = async (event) => {
|
|
2833
2904
|
if (event === undefined)
|
|
2834
2905
|
return;
|
|
2835
|
-
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
2906
|
+
await this.#runInPersistenceLock(this.#workContext(ctx), async (lockCtx) => {
|
|
2836
2907
|
if (this.#hooks.onEventTransact === undefined) {
|
|
2837
2908
|
await this.#appendRecord(lockCtx, { type: "block", block: event.block });
|
|
2838
2909
|
}
|
|
@@ -2857,7 +2928,7 @@ export class AgentBase {
|
|
|
2857
2928
|
const next = await Promise.race([iterator.next(), abortPromise]);
|
|
2858
2929
|
if (next === ABORTED) {
|
|
2859
2930
|
// Drop the unfinished block and end the turn.
|
|
2860
|
-
await this.#emit({ type: "done", state: "cancelled" });
|
|
2931
|
+
await this.#emit(ctx, { type: "done", state: "cancelled" });
|
|
2861
2932
|
return { content: persisted, state: "cancelled" };
|
|
2862
2933
|
}
|
|
2863
2934
|
if (next.done === true) {
|
|
@@ -2865,7 +2936,7 @@ export class AgentBase {
|
|
|
2865
2936
|
break;
|
|
2866
2937
|
}
|
|
2867
2938
|
const event = next.value;
|
|
2868
|
-
await this.#emit(event);
|
|
2939
|
+
await this.#emit(ctx, event);
|
|
2869
2940
|
switch (event.type) {
|
|
2870
2941
|
case "text_start":
|
|
2871
2942
|
content.push({ type: "text", text: "" });
|
|
@@ -3016,9 +3087,9 @@ export class AgentBase {
|
|
|
3016
3087
|
return this.#session;
|
|
3017
3088
|
}
|
|
3018
3089
|
/** Report one stream event to the hooks. Hooks observe the stream; they never fail a run. */
|
|
3019
|
-
async #emit(event) {
|
|
3090
|
+
async #emit(ctx, event) {
|
|
3020
3091
|
try {
|
|
3021
|
-
await this.#hooks.onEvent?.(this.#ctx, event);
|
|
3092
|
+
await this.#hooks.onEvent?.(this.#workContext(ctx), event);
|
|
3022
3093
|
}
|
|
3023
3094
|
catch {
|
|
3024
3095
|
// Hooks observe the stream; they never fail a run.
|