@slopus/happy-agent-base 0.0.5 → 0.0.7
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 +62 -9
- package/dist/Agent.d.ts +27 -22
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +120 -110
- package/dist/Agent.js.map +1 -1
- package/dist/AgentBase.d.ts +27 -13
- package/dist/AgentBase.d.ts.map +1 -1
- package/dist/AgentBase.js +657 -151
- package/dist/AgentBase.js.map +1 -1
- package/dist/AgentBaseHooks.d.ts +58 -32
- package/dist/AgentBaseHooks.d.ts.map +1 -1
- package/dist/AgentBasePending.d.ts +6 -2
- package/dist/AgentBasePending.d.ts.map +1 -1
- package/dist/AgentBasePending.js +7 -0
- package/dist/AgentBasePending.js.map +1 -1
- package/dist/AgentConfig.d.ts +20 -13
- package/dist/AgentConfig.d.ts.map +1 -1
- package/dist/AgentConfig.js +32 -12
- package/dist/AgentConfig.js.map +1 -1
- package/dist/AgentContexts.d.ts +18 -1
- package/dist/AgentContexts.d.ts.map +1 -1
- package/dist/AgentContexts.js +24 -1
- package/dist/AgentContexts.js.map +1 -1
- package/dist/AgentDatabase.d.ts +35 -0
- package/dist/AgentDatabase.d.ts.map +1 -0
- package/dist/AgentDatabase.js +30 -0
- package/dist/AgentDatabase.js.map +1 -0
- package/dist/AgentKV.d.ts +19 -6
- package/dist/AgentKV.d.ts.map +1 -1
- package/dist/AgentKV.js +110 -43
- package/dist/AgentKV.js.map +1 -1
- package/dist/AgentMessageAcceptance.d.ts +14 -0
- package/dist/AgentMessageAcceptance.d.ts.map +1 -0
- package/dist/AgentMessageAcceptance.js +2 -0
- package/dist/AgentMessageAcceptance.js.map +1 -0
- package/dist/AgentMetadata.d.ts +37 -0
- package/dist/AgentMetadata.d.ts.map +1 -0
- package/dist/AgentMetadata.js +74 -0
- package/dist/AgentMetadata.js.map +1 -0
- package/dist/AgentModule.d.ts +209 -0
- package/dist/AgentModule.d.ts.map +1 -0
- package/dist/AgentModule.js +2 -0
- package/dist/AgentModule.js.map +1 -0
- package/dist/{AgentFeatureAction.d.ts → AgentModuleAction.d.ts} +7 -2
- package/dist/AgentModuleAction.d.ts.map +1 -0
- package/dist/AgentModuleAction.js +2 -0
- package/dist/AgentModuleAction.js.map +1 -0
- package/dist/AgentPermissionMode.d.ts +1 -1
- package/dist/AgentPermissionMode.js +1 -1
- package/dist/AgentPersistence.d.ts +20 -7
- package/dist/AgentPersistence.d.ts.map +1 -1
- package/dist/AgentPersistenceDrizzle.d.ts +31 -0
- package/dist/AgentPersistenceDrizzle.d.ts.map +1 -0
- package/dist/AgentPersistenceDrizzle.js +99 -0
- package/dist/AgentPersistenceDrizzle.js.map +1 -0
- package/dist/AgentRef.d.ts +17 -9
- package/dist/AgentRef.d.ts.map +1 -1
- package/dist/AgentRef.js +16 -9
- package/dist/AgentRef.js.map +1 -1
- package/dist/AgentStorage.d.ts +33 -13
- package/dist/AgentStorage.d.ts.map +1 -1
- package/dist/AgentStorage.js +174 -3
- package/dist/AgentStorage.js.map +1 -1
- package/dist/AgentSystem.d.ts +34 -12
- package/dist/AgentSystem.d.ts.map +1 -1
- package/dist/AgentSystemContext.d.ts +3 -3
- package/dist/AgentSystemContext.d.ts.map +1 -1
- package/dist/AgentSystemContext.js +4 -4
- package/dist/AgentSystemContext.js.map +1 -1
- package/dist/AgentSystemLocal.d.ts +30 -21
- package/dist/AgentSystemLocal.d.ts.map +1 -1
- package/dist/AgentSystemLocal.js +226 -59
- package/dist/AgentSystemLocal.js.map +1 -1
- package/dist/AgentSystemRef.d.ts +21 -10
- package/dist/AgentSystemRef.d.ts.map +1 -1
- package/dist/AgentSystemRef.js +41 -9
- package/dist/AgentSystemRef.js.map +1 -1
- package/dist/AgentTool.d.ts +16 -1
- package/dist/AgentTool.d.ts.map +1 -1
- package/dist/AgentTool.js.map +1 -1
- package/dist/index.d.ts +10 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -6
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/dist/AgentFeature.d.ts +0 -177
- package/dist/AgentFeature.d.ts.map +0 -1
- package/dist/AgentFeature.js +0 -2
- package/dist/AgentFeature.js.map +0 -1
- package/dist/AgentFeatureAction.d.ts.map +0 -1
- package/dist/AgentFeatureAction.js +0 -2
- package/dist/AgentFeatureAction.js.map +0 -1
package/dist/AgentBase.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { areProviderModelsCompatible } from "@slopus/happy-providers";
|
|
2
|
+
import { createId } from "@paralleldrive/cuid2";
|
|
2
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
3
5
|
import { Value } from "@sinclair/typebox/value";
|
|
4
|
-
import { asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
|
|
5
|
-
import { withAgentContext, withAgentKV, withAgentPermissionMode, withAgentRunKV, } from "./AgentContexts.js";
|
|
6
|
+
import { afterCommit, asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
|
|
7
|
+
import { agentDatabase, agentKV, agentStorageTransaction, withAgentContext, withAgentDatabase, withAgentKV, withAgentPermissionMode, withAgentRunKV, withoutAgentStorageTransaction, } from "./AgentContexts.js";
|
|
8
|
+
import { agentConfig, ownAgentConfig, withAgentConfig } from "./AgentConfig.js";
|
|
6
9
|
import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
|
|
7
10
|
import { AgentKV } from "./AgentKV.js";
|
|
8
11
|
import { AGENT_BASE_PENDING_KEY, agentBasePendingStateOf, } from "./AgentBasePending.js";
|
|
12
|
+
import { cuid2Schema, ownAgentMessageMetadata, ownAgentMetadata, } from "./AgentMetadata.js";
|
|
9
13
|
import { DEFAULT_AGENT_PERMISSION_MODE, isAgentPermissionMode, } from "./AgentPermissionMode.js";
|
|
10
14
|
import { AgentProviders } from "./AgentProviders.js";
|
|
11
15
|
/** Race winner when an abort interrupts a wait on the stream or a running tool. */
|
|
@@ -25,12 +29,35 @@ const insideTurn = createContextNamespace("agentInsideTurn", []);
|
|
|
25
29
|
* its own and outlives whatever happened to start it.
|
|
26
30
|
*/
|
|
27
31
|
const insideLoops = new AsyncLocalStorage();
|
|
32
|
+
/** Persistence locks held by the current asynchronous call chain, independent of Context. */
|
|
33
|
+
const insidePersistenceLocks = new AsyncLocalStorage();
|
|
28
34
|
/**
|
|
29
35
|
* How long a close asked for from inside the agent's own run loop waits for the shutdown before
|
|
30
36
|
* telling its caller it cannot be waited for. Long enough that a caller which has already let go
|
|
31
37
|
* hears the shutdown finish, short enough that one still holding the loop is told promptly.
|
|
32
38
|
*/
|
|
33
39
|
const INSIDE_CLOSE_REPORT_MS = 15;
|
|
40
|
+
const storedToolCallSchema = Type.Object({
|
|
41
|
+
id: cuid2Schema,
|
|
42
|
+
providerCallId: Type.String(),
|
|
43
|
+
call: Type.Object({
|
|
44
|
+
type: Type.Literal("tool_call"),
|
|
45
|
+
name: Type.String(),
|
|
46
|
+
arguments: Type.String(),
|
|
47
|
+
namespace: Type.Optional(Type.String()),
|
|
48
|
+
incomplete: Type.Optional(Type.Boolean()),
|
|
49
|
+
vendor: Type.Optional(Type.Unknown()),
|
|
50
|
+
}),
|
|
51
|
+
committed: Type.Optional(Type.Unknown()),
|
|
52
|
+
});
|
|
53
|
+
/** A durable first-writer-wins claim on the result one call will append. */
|
|
54
|
+
const storedToolResultSchema = Type.Object({
|
|
55
|
+
role: Type.Literal("tool"),
|
|
56
|
+
callId: Type.String(),
|
|
57
|
+
content: Type.Array(Type.Unknown()),
|
|
58
|
+
isError: Type.Optional(Type.Boolean()),
|
|
59
|
+
vendor: Type.Optional(Type.Unknown()),
|
|
60
|
+
});
|
|
34
61
|
/**
|
|
35
62
|
* A single agent session over one provider. Messages arrive through two FIFO queues: steering
|
|
36
63
|
* messages inject as soon as the current assistant response and its tool batch finish, while
|
|
@@ -114,7 +141,7 @@ const INSIDE_CLOSE_REPORT_MS = 15;
|
|
|
114
141
|
* running under rather than being told.
|
|
115
142
|
*
|
|
116
143
|
* The loop enforces nothing. It has no idea what any particular tool touches, and a runtime that
|
|
117
|
-
* guessed would be wrong about tools it has never seen. Enforcement belongs to the
|
|
144
|
+
* guessed would be wrong about tools it has never seen. Enforcement belongs to the modules and
|
|
118
145
|
* tools that do know; the loop's whole part is to carry the mode, make its changes durable, and
|
|
119
146
|
* report them.
|
|
120
147
|
*
|
|
@@ -203,6 +230,8 @@ export class AgentBase {
|
|
|
203
230
|
#baseCtx;
|
|
204
231
|
/** The base context extended with the effective selection and the agent's key-value store. */
|
|
205
232
|
#ctx;
|
|
233
|
+
/** The immutable configuration snapshot carried by every hook context. */
|
|
234
|
+
#config;
|
|
206
235
|
/** The registry the provider ID is resolved through, each time a session is created. */
|
|
207
236
|
#providers;
|
|
208
237
|
/** The registry ID of the provider in force; durable, so a restart resumes on the same one. */
|
|
@@ -282,6 +311,13 @@ export class AgentBase {
|
|
|
282
311
|
* about the agent is answered from here without touching the disk.
|
|
283
312
|
*/
|
|
284
313
|
#pending;
|
|
314
|
+
/** Stable Base-owned lifecycle identities persisted as part of the pending run. */
|
|
315
|
+
#loopId;
|
|
316
|
+
#turnId;
|
|
317
|
+
#inferenceId;
|
|
318
|
+
#settlementId;
|
|
319
|
+
/** The durable settlement awaiting its post-commit observing hook. */
|
|
320
|
+
#settlement;
|
|
285
321
|
/**
|
|
286
322
|
* The pending state this instance last wrote, so a write that would change nothing is
|
|
287
323
|
* skipped. The loop passes through the same stage many times in a turn, and a store is not
|
|
@@ -319,6 +355,8 @@ export class AgentBase {
|
|
|
319
355
|
#contextTokens;
|
|
320
356
|
/** Whether the current turn was cancelled before it could finish. */
|
|
321
357
|
#turnAborted = false;
|
|
358
|
+
/** A staged tool result could not settle, so this run must leave its pending state intact. */
|
|
359
|
+
#durableWorkBlocked = false;
|
|
322
360
|
/** Whether something has asked for a turn that has not been answered yet. */
|
|
323
361
|
#turnRequested = false;
|
|
324
362
|
/** The run loop while it is running; the field is cleared once it has actually stopped. */
|
|
@@ -343,6 +381,8 @@ export class AgentBase {
|
|
|
343
381
|
* owners of one session at once.
|
|
344
382
|
*/
|
|
345
383
|
#streamCleanup = new Set();
|
|
384
|
+
/** IDs offered by this process, for immediate self-reentrant acceptance answers. */
|
|
385
|
+
#offeredMessageIds = new Set();
|
|
346
386
|
/**
|
|
347
387
|
* A new agent, wired to its options and touching no storage at all. Use this for an identity
|
|
348
388
|
* with no durable state yet; whatever the agent needs from the store is read by its first
|
|
@@ -388,11 +428,16 @@ export class AgentBase {
|
|
|
388
428
|
* by, so reading it here leaves the later stage writes nothing to learn from the store.
|
|
389
429
|
*/
|
|
390
430
|
async #loadPendingState() {
|
|
391
|
-
await this.#
|
|
431
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
432
|
+
await this.#loadConfig(lockCtx);
|
|
392
433
|
const stored = await agentBasePendingStateOf(lockCtx, this.#persistence);
|
|
393
434
|
this.#inherited = stored;
|
|
394
435
|
this.#inheritedRead = true;
|
|
395
436
|
this.#pending = stored;
|
|
437
|
+
this.#loopId = stored?.loopId;
|
|
438
|
+
this.#turnId = stored?.turnId;
|
|
439
|
+
this.#inferenceId = stored?.inferenceId;
|
|
440
|
+
this.#settlementId = stored?.settlementId;
|
|
396
441
|
this.#pendingWritten =
|
|
397
442
|
stored === undefined ? undefined : deterministicStringify(stored);
|
|
398
443
|
});
|
|
@@ -405,10 +450,11 @@ export class AgentBase {
|
|
|
405
450
|
*/
|
|
406
451
|
constructor(ctx, options) {
|
|
407
452
|
this.id = options.id;
|
|
453
|
+
this.#config = ownAgentConfig(agentConfig(ctx) ?? {});
|
|
408
454
|
// An agent is its own lifetime. Whatever call happened to construct it — a tool of
|
|
409
455
|
// another agent, most often — is not a loop this one runs inside, so an inherited
|
|
410
456
|
// marker is dropped rather than carried into work that outlives that call.
|
|
411
|
-
this.#baseCtx = insideTurn.set(ctx, [options.id]);
|
|
457
|
+
this.#baseCtx = withoutAgentStorageTransaction(withAgentDatabase(withAgentConfig(insideTurn.set(ctx, [options.id]), this.#config), options.persistence.database));
|
|
412
458
|
this.#providers = options.providers;
|
|
413
459
|
this.#providerId = options.provider;
|
|
414
460
|
this.#persistence = options.persistence;
|
|
@@ -436,8 +482,24 @@ export class AgentBase {
|
|
|
436
482
|
* the selection changes.
|
|
437
483
|
*/
|
|
438
484
|
#deriveCtx() {
|
|
439
|
-
|
|
440
|
-
|
|
485
|
+
return this.#hookContext(this.#baseCtx);
|
|
486
|
+
}
|
|
487
|
+
/** Add this agent's selection and stores to a caller context without losing its transaction. */
|
|
488
|
+
#hookContext(ctx) {
|
|
489
|
+
const selected = withAgentContext(ctx, this.#selection());
|
|
490
|
+
const database = agentDatabase(ctx) ?? this.#persistence.database;
|
|
491
|
+
return withAgentRunKV(withAgentKV(withAgentDatabase(selected, database), this.#kv), this.#runKV);
|
|
492
|
+
}
|
|
493
|
+
/** Load a directly owned configuration written by `updateMetadata`, when one exists. */
|
|
494
|
+
async #loadConfig(ctx) {
|
|
495
|
+
const stored = await this.#persistence.readValues(ctx, "agentConfig");
|
|
496
|
+
const exact = stored.find(({ key }) => key === "agentConfig")?.value;
|
|
497
|
+
if (exact === undefined)
|
|
498
|
+
return;
|
|
499
|
+
const config = ownAgentConfig(exact);
|
|
500
|
+
this.#config = config;
|
|
501
|
+
this.#baseCtx = withAgentConfig(this.#baseCtx, config);
|
|
502
|
+
this.#ctx = this.#deriveCtx();
|
|
441
503
|
}
|
|
442
504
|
/** Everything about what the agent is currently running on, as one value to carry. */
|
|
443
505
|
#selection() {
|
|
@@ -465,7 +527,8 @@ export class AgentBase {
|
|
|
465
527
|
* whatever else that transaction is writing, which is how a consumed message and the
|
|
466
528
|
* inference it owes become durable as one fact rather than two.
|
|
467
529
|
*/
|
|
468
|
-
async #recordPending(ctx,
|
|
530
|
+
async #recordPending(ctx, stage, force = false) {
|
|
531
|
+
const pending = this.#pendingState(stage);
|
|
469
532
|
const serialized = deterministicStringify(pending);
|
|
470
533
|
if (!force && this.#pendingWritten === serialized)
|
|
471
534
|
return;
|
|
@@ -473,6 +536,17 @@ export class AgentBase {
|
|
|
473
536
|
this.#pending = pending;
|
|
474
537
|
this.#pendingWritten = serialized;
|
|
475
538
|
}
|
|
539
|
+
/** The complete durable lifecycle identity state for one outstanding stage. */
|
|
540
|
+
#pendingState(stage) {
|
|
541
|
+
this.#loopId ??= createId();
|
|
542
|
+
return {
|
|
543
|
+
stage,
|
|
544
|
+
loopId: this.#loopId,
|
|
545
|
+
...(this.#turnId === undefined ? {} : { turnId: this.#turnId }),
|
|
546
|
+
...(this.#inferenceId === undefined ? {} : { inferenceId: this.#inferenceId }),
|
|
547
|
+
...(this.#settlementId === undefined ? {} : { settlementId: this.#settlementId }),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
476
550
|
/**
|
|
477
551
|
* Record the stage the run has reached, taking the store lock when not already inside it.
|
|
478
552
|
*
|
|
@@ -482,27 +556,27 @@ export class AgentBase {
|
|
|
482
556
|
* starting here.
|
|
483
557
|
*/
|
|
484
558
|
async #enterStage(stage, transact) {
|
|
485
|
-
const pending =
|
|
559
|
+
const pending = this.#pendingState(stage);
|
|
486
560
|
if (transact === undefined &&
|
|
487
561
|
deterministicStringify(pending) === this.#pendingWritten &&
|
|
488
562
|
this.#inheritedRead) {
|
|
489
563
|
return undefined;
|
|
490
564
|
}
|
|
491
565
|
try {
|
|
492
|
-
return await this.#
|
|
566
|
+
return await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
493
567
|
if (!this.#inheritedRead) {
|
|
494
568
|
this.#inheritedRead = true;
|
|
495
569
|
this.#inherited = await agentBasePendingStateOf(lockCtx, this.#persistence);
|
|
496
570
|
}
|
|
497
571
|
if (transact === undefined) {
|
|
498
|
-
await this.#recordPending(lockCtx,
|
|
572
|
+
await this.#recordPending(lockCtx, stage);
|
|
499
573
|
return undefined;
|
|
500
574
|
}
|
|
501
575
|
return await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
502
576
|
// The state is staged first. The callback then writes against this exact
|
|
503
577
|
// transaction, so neither its conclusion nor the state it observed can land
|
|
504
578
|
// without the other.
|
|
505
|
-
await this.#recordPending(txCtx,
|
|
579
|
+
await this.#recordPending(txCtx, stage, true);
|
|
506
580
|
return await this.#withTransactionalContext(txCtx, transact);
|
|
507
581
|
});
|
|
508
582
|
});
|
|
@@ -528,22 +602,64 @@ export class AgentBase {
|
|
|
528
602
|
/**
|
|
529
603
|
* Queue a user message that injects as soon as the current assistant response and its tool
|
|
530
604
|
* batch finish; steering always takes precedence over sent messages. Returns once the message
|
|
531
|
-
* has been handed to the agent
|
|
532
|
-
*
|
|
533
|
-
*
|
|
605
|
+
* has been handed to the agent and returns its acceptance identity. Outside the target's own
|
|
606
|
+
* loop it waits for the durable created/existing result by default; a failed write rejects and
|
|
607
|
+
* keeps the message out of the conversation entirely.
|
|
534
608
|
*/
|
|
535
609
|
async steer(ctx, message, options) {
|
|
536
|
-
await this.#offer(ctx, "steering", message, options);
|
|
610
|
+
return await this.#offer(ctx, "steering", message, options);
|
|
537
611
|
}
|
|
538
612
|
/**
|
|
539
613
|
* Queue a user message that waits until the agent would otherwise stop — no tool calls or
|
|
540
614
|
* steering remain — before injecting. Returns once the message has been handed to the agent,
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
* of the conversation entirely.
|
|
615
|
+
* without waiting for the turn that answers it and returns its acceptance identity. Outside
|
|
616
|
+
* the target's own loop it waits for the durable created/existing result by default; a failed
|
|
617
|
+
* write rejects and keeps the message out of the conversation entirely.
|
|
544
618
|
*/
|
|
545
619
|
async send(ctx, message, options) {
|
|
546
|
-
await this.#offer(ctx, "send", message, options);
|
|
620
|
+
return await this.#offer(ctx, "send", message, options);
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Shallow-merge fields into this agent's immutable metadata. The complete AgentConfig and
|
|
624
|
+
* transactional hook writes commit together; observing hooks run only after that commit.
|
|
625
|
+
*/
|
|
626
|
+
async updateMetadata(ctx, update) {
|
|
627
|
+
this.#assertOutsideStorageTransaction(ctx, "metadata update");
|
|
628
|
+
const ownedUpdate = ownAgentMetadata(update);
|
|
629
|
+
if (ownedUpdate === undefined)
|
|
630
|
+
throw new Error("The agent metadata is not valid.");
|
|
631
|
+
if (this.#closed)
|
|
632
|
+
throw new Error("The agent has been closed.");
|
|
633
|
+
if (insideTurn.get(ctx).includes(this.id) ||
|
|
634
|
+
this.#insideOwnLoop() ||
|
|
635
|
+
this.#insideOwnPersistenceLock()) {
|
|
636
|
+
throw new Error("Updating metadata from inside this agent's current operation would wait for " +
|
|
637
|
+
"that same operation to finish. Update it after the hook or tool returns.");
|
|
638
|
+
}
|
|
639
|
+
let change;
|
|
640
|
+
let next;
|
|
641
|
+
await this.#runInPersistenceLock(ctx, async (lockCtx) => {
|
|
642
|
+
const previousMetadata = ownAgentMetadata(this.#config.metadata ?? {});
|
|
643
|
+
const metadata = ownAgentMetadata({ ...previousMetadata, ...ownedUpdate });
|
|
644
|
+
if (previousMetadata === undefined || metadata === undefined) {
|
|
645
|
+
throw new Error("The agent metadata is not valid.");
|
|
646
|
+
}
|
|
647
|
+
next = ownAgentConfig({ ...this.#config, metadata });
|
|
648
|
+
change = {
|
|
649
|
+
agentId: this.id,
|
|
650
|
+
previousMetadata,
|
|
651
|
+
update: ownedUpdate,
|
|
652
|
+
metadata,
|
|
653
|
+
};
|
|
654
|
+
await this.#persistence.transaction(lockCtx, async (txCtx) => {
|
|
655
|
+
await this.#persistence.writeValue(txCtx, "agentConfig", next);
|
|
656
|
+
await this.#withTransactionalContext(withAgentConfig(txCtx, next), async (hookCtx) => await this.#hooks.metadataChangedTransact?.(this.#hookContext(hookCtx), change));
|
|
657
|
+
});
|
|
658
|
+
this.#config = next;
|
|
659
|
+
this.#baseCtx = withAgentConfig(this.#baseCtx, next);
|
|
660
|
+
this.#ctx = this.#deriveCtx();
|
|
661
|
+
});
|
|
662
|
+
await this.#invokeHookOn(this.#hookContext(withAgentConfig(ctx, next)), this.#hooks.metadataChanged, change);
|
|
547
663
|
}
|
|
548
664
|
/**
|
|
549
665
|
* Hand one message to a durable queue. The acceptance runs whether or not the caller waits
|
|
@@ -551,16 +667,52 @@ export class AgentBase {
|
|
|
551
667
|
* the agent's own close still drains it, so nothing is dropped by not looking.
|
|
552
668
|
*/
|
|
553
669
|
async #offer(ctx, kind, message, options) {
|
|
554
|
-
|
|
670
|
+
this.#assertOutsideStorageTransaction(ctx, "message delivery");
|
|
671
|
+
const { await: requestedWait, id = createId(), metadata: suppliedMetadata, ...settings } = options ?? {};
|
|
672
|
+
if (!Value.Check(cuid2Schema, id)) {
|
|
673
|
+
throw new Error("The message ID must be a cuid2 identity.");
|
|
674
|
+
}
|
|
675
|
+
const wait = requestedWait ?? !insideTurn.get(ctx).includes(this.id);
|
|
676
|
+
const metadata = ownAgentMessageMetadata(suppliedMetadata);
|
|
555
677
|
// Refusing the flag rather than the operation: a closed agent and a re-entrant wait are
|
|
556
678
|
// both caller mistakes, and both are reported before any work is started.
|
|
557
679
|
this.#assertCanWait(ctx, wait, kind === "steering" ? "a steered message" : "a sent message");
|
|
558
680
|
if (this.#closed)
|
|
559
681
|
throw new Error("The agent has been closed.");
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
682
|
+
const knownInProcess = this.#offeredMessageIds.has(id);
|
|
683
|
+
this.#offeredMessageIds.add(id);
|
|
684
|
+
const accepted = this.#enqueue(ctx, [
|
|
685
|
+
{
|
|
686
|
+
kind,
|
|
687
|
+
id,
|
|
688
|
+
message: structuredClone(message),
|
|
689
|
+
...(metadata === undefined ? {} : { metadata }),
|
|
690
|
+
options: settings,
|
|
691
|
+
},
|
|
692
|
+
]);
|
|
693
|
+
if (wait) {
|
|
694
|
+
try {
|
|
695
|
+
const [result] = await accepted;
|
|
696
|
+
if (result === undefined) {
|
|
697
|
+
throw new Error("The message acceptance result was lost.");
|
|
698
|
+
}
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
if (!knownInProcess)
|
|
703
|
+
this.#offeredMessageIds.delete(id);
|
|
704
|
+
throw error;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
accepted.catch(() => {
|
|
708
|
+
if (!knownInProcess)
|
|
709
|
+
this.#offeredMessageIds.delete(id);
|
|
710
|
+
});
|
|
711
|
+
return {
|
|
712
|
+
id,
|
|
713
|
+
delivery: kind === "steering" ? "steer" : "send",
|
|
714
|
+
accepted: knownInProcess ? "existing" : "created",
|
|
715
|
+
};
|
|
564
716
|
}
|
|
565
717
|
/**
|
|
566
718
|
* Refuse a wait that could never end. A hook or a tool runs while its agent's loop waits for
|
|
@@ -585,6 +737,25 @@ export class AgentBase {
|
|
|
585
737
|
#insideOwnLoop() {
|
|
586
738
|
return insideLoops.getStore()?.includes(this.id) === true;
|
|
587
739
|
}
|
|
740
|
+
/** Whether this call chain already holds this agent's persistence lock. */
|
|
741
|
+
#insideOwnPersistenceLock() {
|
|
742
|
+
return insidePersistenceLocks.getStore()?.includes(this.id) === true;
|
|
743
|
+
}
|
|
744
|
+
/** Live agent commands cannot be staged or undone by an enclosing database transaction. */
|
|
745
|
+
#assertOutsideStorageTransaction(ctx, operation) {
|
|
746
|
+
if (agentStorageTransaction(ctx) !== undefined) {
|
|
747
|
+
throw new Error(`Agent ${operation} cannot run from inside an outer storage transaction.`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
/** Hold the persistence lock while marking it independently of the caller's Context. */
|
|
751
|
+
async #runInPersistenceLock(ctx, work) {
|
|
752
|
+
return await this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
|
|
753
|
+
const held = insidePersistenceLocks.getStore() ?? [];
|
|
754
|
+
return await insidePersistenceLocks.run([...held, this.id], async () => {
|
|
755
|
+
return await work(lockCtx);
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
}
|
|
588
759
|
/**
|
|
589
760
|
* Accept a batch of messages as one durable step. Every message is written under the same
|
|
590
761
|
* hold of the persistence lock and inside one transaction, so a caller arriving while a
|
|
@@ -593,40 +764,68 @@ export class AgentBase {
|
|
|
593
764
|
*/
|
|
594
765
|
async #enqueue(ctx, batch) {
|
|
595
766
|
if (batch.length === 0)
|
|
596
|
-
return;
|
|
767
|
+
return [];
|
|
597
768
|
if (this.#closed)
|
|
598
769
|
throw new Error("The agent has been closed.");
|
|
599
770
|
// Admitted: from here on the messages are the agent's responsibility, and a close that
|
|
600
771
|
// begins now waits for them rather than resolving over the top of them.
|
|
601
|
-
const admitted = this.#
|
|
772
|
+
const admitted = this.#runInPersistenceLock(ctx, async (lockCtx) => {
|
|
602
773
|
const accepted = [];
|
|
603
|
-
|
|
774
|
+
const results = [];
|
|
775
|
+
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
604
776
|
for (const request of batch) {
|
|
777
|
+
const identityKey = `message.${request.id}`;
|
|
778
|
+
if (!(await this.#persistence.writeValueIfAbsent(txCtx, identityKey, true))) {
|
|
779
|
+
results.push({
|
|
780
|
+
id: request.id,
|
|
781
|
+
delivery: request.kind === "steering" ? "steer" : "send",
|
|
782
|
+
accepted: "existing",
|
|
783
|
+
});
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
605
786
|
const key = await this.#queueKey(txCtx, `${request.kind}.`);
|
|
606
787
|
await this.#persistence.writeValue(txCtx, key, {
|
|
788
|
+
id: request.id,
|
|
607
789
|
message: request.message,
|
|
790
|
+
...(request.metadata === undefined ? {} : { metadata: request.metadata }),
|
|
608
791
|
options: request.options,
|
|
609
792
|
});
|
|
610
793
|
accepted.push({ key, request });
|
|
794
|
+
results.push({
|
|
795
|
+
id: request.id,
|
|
796
|
+
delivery: request.kind === "steering" ? "steer" : "send",
|
|
797
|
+
accepted: "created",
|
|
798
|
+
});
|
|
611
799
|
}
|
|
800
|
+
if (accepted.length === 0)
|
|
801
|
+
return;
|
|
612
802
|
// Accepting a message is what makes the work owed: the same transaction that
|
|
613
803
|
// admits it records that the agent owes an answer, so a process that dies right
|
|
614
804
|
// here is discovered still owing it rather than looking idle over a full queue.
|
|
615
|
-
await this.#recordPending(txCtx,
|
|
805
|
+
await this.#recordPending(txCtx, "inference");
|
|
616
806
|
});
|
|
617
807
|
for (const { key, request } of accepted) {
|
|
618
808
|
// The queue is resolved inside the lock: a history load running just before this
|
|
619
809
|
// one replaces the queue arrays wholesale, and a reference taken before the wait
|
|
620
810
|
// would push the message into an array nobody reads again.
|
|
621
811
|
const queue = request.kind === "steering" ? this.#steering : this.#sends;
|
|
622
|
-
queue.push({
|
|
812
|
+
queue.push({
|
|
813
|
+
key,
|
|
814
|
+
id: request.id,
|
|
815
|
+
message: request.message,
|
|
816
|
+
...(request.metadata === undefined ? {} : { metadata: request.metadata }),
|
|
817
|
+
options: request.options,
|
|
818
|
+
});
|
|
623
819
|
}
|
|
624
|
-
|
|
625
|
-
|
|
820
|
+
if (accepted.length > 0) {
|
|
821
|
+
this.#turnRequested = true;
|
|
822
|
+
this.#startRun();
|
|
823
|
+
}
|
|
824
|
+
return results;
|
|
626
825
|
});
|
|
627
826
|
this.#admitted.add(admitted);
|
|
628
827
|
try {
|
|
629
|
-
await admitted;
|
|
828
|
+
return await admitted;
|
|
630
829
|
}
|
|
631
830
|
finally {
|
|
632
831
|
this.#admitted.delete(admitted);
|
|
@@ -665,6 +864,7 @@ export class AgentBase {
|
|
|
665
864
|
* compaction rather than queueing another.
|
|
666
865
|
*/
|
|
667
866
|
async compact(ctx, options) {
|
|
867
|
+
this.#assertOutsideStorageTransaction(ctx, "compaction");
|
|
668
868
|
const wait = options?.await ?? false;
|
|
669
869
|
this.#assertCanWait(ctx, wait, "a compaction");
|
|
670
870
|
if (this.#closed)
|
|
@@ -790,6 +990,7 @@ export class AgentBase {
|
|
|
790
990
|
* when it finished.
|
|
791
991
|
*/
|
|
792
992
|
async abort(ctx, options) {
|
|
993
|
+
this.#assertOutsideStorageTransaction(ctx, "abort");
|
|
793
994
|
const wait = options?.await ?? false;
|
|
794
995
|
this.#assertCanWait(ctx, wait, "an abort");
|
|
795
996
|
const run = this.#signalAbort();
|
|
@@ -932,9 +1133,20 @@ export class AgentBase {
|
|
|
932
1133
|
* more work, so the loop hooks always bracket a settled-to-settled span.
|
|
933
1134
|
*/
|
|
934
1135
|
async #runLoop() {
|
|
1136
|
+
if (this.#pending?.stage === "settlement") {
|
|
1137
|
+
this.#loopId ??= createId();
|
|
1138
|
+
this.#settlementId ??= createId();
|
|
1139
|
+
await this.#settleDurably({
|
|
1140
|
+
loopId: this.#loopId,
|
|
1141
|
+
settlementId: this.#settlementId,
|
|
1142
|
+
});
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
935
1145
|
// The outer loop reopens when an `afterAgentLoop` action requests more work, so the
|
|
936
1146
|
// loop hooks always bracket a settled-to-settled span.
|
|
937
1147
|
do {
|
|
1148
|
+
this.#loopId ??= createId();
|
|
1149
|
+
const loop = { loopId: this.#loopId };
|
|
938
1150
|
// The abort scope opens before the loop hook, not just before the turn. An abort
|
|
939
1151
|
// owns everything the run does — its opening hook as much as its inference — so a
|
|
940
1152
|
// run cancelled while it is still starting up never reaches the model at all.
|
|
@@ -943,10 +1155,14 @@ export class AgentBase {
|
|
|
943
1155
|
// crash could interrupt. What it records is refined as the run reaches each stage;
|
|
944
1156
|
// what matters at this point is that the record exists at all, since its absence is
|
|
945
1157
|
// what a later process reads as an agent that finished.
|
|
946
|
-
await this.#enterStage("inference", this.#hooks.beforeAgentLoopTransact
|
|
947
|
-
|
|
1158
|
+
await this.#enterStage("inference", this.#hooks.beforeAgentLoopTransact === undefined
|
|
1159
|
+
? undefined
|
|
1160
|
+
: (hookCtx) => this.#hooks.beforeAgentLoopTransact?.(hookCtx, loop));
|
|
1161
|
+
await this.#invokeHook(this.#hooks.beforeAgentLoop, loop);
|
|
948
1162
|
do {
|
|
949
1163
|
this.#turnAborted = false;
|
|
1164
|
+
this.#durableWorkBlocked = false;
|
|
1165
|
+
this.#turnId ??= createId();
|
|
950
1166
|
// Claimed before any awaiting, so a request raised while the turn is still
|
|
951
1167
|
// starting up survives into another turn instead of being cleared by it. The
|
|
952
1168
|
// redundant turn this can cost is cheap: an empty queue drains without any
|
|
@@ -962,7 +1178,7 @@ export class AgentBase {
|
|
|
962
1178
|
// durable exactly as it was for the next attempt.
|
|
963
1179
|
const loadFailure = await this.#ensureLoaded().then(() => undefined, (error) => error);
|
|
964
1180
|
if (loadFailure !== undefined) {
|
|
965
|
-
this.#emit({
|
|
1181
|
+
await this.#emit({
|
|
966
1182
|
type: "done",
|
|
967
1183
|
state: "error",
|
|
968
1184
|
kind: "internal_error",
|
|
@@ -970,9 +1186,12 @@ export class AgentBase {
|
|
|
970
1186
|
? loadFailure.message
|
|
971
1187
|
: String(loadFailure),
|
|
972
1188
|
});
|
|
1189
|
+
this.#turnId = undefined;
|
|
973
1190
|
break;
|
|
974
1191
|
}
|
|
975
1192
|
const turnStart = {
|
|
1193
|
+
loopId: loop.loopId,
|
|
1194
|
+
turnId: this.#turnId,
|
|
976
1195
|
contextTokens: this.#contextTokens,
|
|
977
1196
|
};
|
|
978
1197
|
await this.#enterStage("inference", this.#hooks.beforeTurnTransact === undefined
|
|
@@ -980,13 +1199,25 @@ export class AgentBase {
|
|
|
980
1199
|
: (hookCtx) => this.#hooks.beforeTurnTransact?.(hookCtx, turnStart));
|
|
981
1200
|
await this.#applyActions(this.#hooks.beforeTurn, abort.signal, turnStart);
|
|
982
1201
|
await this.#runInference(abort);
|
|
1202
|
+
if (this.#durableWorkBlocked)
|
|
1203
|
+
return;
|
|
983
1204
|
const turn = {
|
|
1205
|
+
loopId: loop.loopId,
|
|
1206
|
+
turnId: turnStart.turnId,
|
|
984
1207
|
contextTokens: this.#contextTokens,
|
|
985
1208
|
aborted: this.#turnAborted,
|
|
986
1209
|
};
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1210
|
+
const completedTurnId = this.#turnId;
|
|
1211
|
+
this.#turnId = undefined;
|
|
1212
|
+
try {
|
|
1213
|
+
await this.#enterStage("inference", this.#hooks.afterTurnTransact === undefined
|
|
1214
|
+
? undefined
|
|
1215
|
+
: (hookCtx) => this.#hooks.afterTurnTransact?.(hookCtx, turn));
|
|
1216
|
+
}
|
|
1217
|
+
catch (error) {
|
|
1218
|
+
this.#turnId = completedTurnId;
|
|
1219
|
+
throw error;
|
|
1220
|
+
}
|
|
990
1221
|
await this.#applyActions(this.#hooks.afterTurn, abort.signal, turn);
|
|
991
1222
|
if (!this.#turnRequested || this.#closed)
|
|
992
1223
|
break;
|
|
@@ -994,14 +1225,21 @@ export class AgentBase {
|
|
|
994
1225
|
// keeps the run's first turn under the scope its opening hook already ran in.
|
|
995
1226
|
abort = this.#openAbortScope();
|
|
996
1227
|
} while (true);
|
|
997
|
-
await this.#enterStage("inference", this.#hooks.afterAgentLoopTransact
|
|
998
|
-
|
|
1228
|
+
await this.#enterStage("inference", this.#hooks.afterAgentLoopTransact === undefined
|
|
1229
|
+
? undefined
|
|
1230
|
+
: (hookCtx) => this.#hooks.afterAgentLoopTransact?.(hookCtx, loop));
|
|
1231
|
+
await this.#applyActions(this.#hooks.afterAgentLoop, abort.signal, loop);
|
|
999
1232
|
} while (this.#turnRequested && !this.#closed);
|
|
1000
1233
|
// Nothing is asked for any more, so the outstanding work is erased. That erasure is what
|
|
1001
1234
|
// makes the agent idle, and it commits together with whatever the settling hooks write,
|
|
1002
1235
|
// so no owner can ever see the agent finished without their conclusions or their
|
|
1003
1236
|
// conclusions without the agent being finished.
|
|
1004
|
-
|
|
1237
|
+
this.#settlementId ??= createId();
|
|
1238
|
+
await this.#enterStage("settlement");
|
|
1239
|
+
await this.#settleDurably({
|
|
1240
|
+
loopId: this.#loopId ?? createId(),
|
|
1241
|
+
settlementId: this.#settlementId,
|
|
1242
|
+
});
|
|
1005
1243
|
}
|
|
1006
1244
|
/**
|
|
1007
1245
|
* Erase the outstanding work and let the transactional settling hooks write in the same
|
|
@@ -1009,17 +1247,22 @@ export class AgentBase {
|
|
|
1009
1247
|
* is resumed and finds nothing to do, while one wrongly believed to be finished is never
|
|
1010
1248
|
* resumed at all.
|
|
1011
1249
|
*/
|
|
1012
|
-
async #settleDurably() {
|
|
1250
|
+
async #settleDurably(settlement) {
|
|
1013
1251
|
try {
|
|
1014
|
-
await this.#
|
|
1252
|
+
await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1015
1253
|
await this.#clearPending(txCtx);
|
|
1016
|
-
await this.#invokeTransactionalSettle(txCtx);
|
|
1254
|
+
await this.#invokeTransactionalSettle(txCtx, settlement);
|
|
1017
1255
|
// The run store is erased last, so a settling hook can still read what the
|
|
1018
1256
|
// run concluded and keep whatever part of it belongs to the conversation.
|
|
1019
1257
|
// It commits with the settlement: the run is over and its notes are gone as
|
|
1020
1258
|
// one fact, never one without the other.
|
|
1021
1259
|
await this.#clearRunStore(txCtx);
|
|
1022
1260
|
}));
|
|
1261
|
+
this.#loopId = undefined;
|
|
1262
|
+
this.#turnId = undefined;
|
|
1263
|
+
this.#inferenceId = undefined;
|
|
1264
|
+
this.#settlementId = undefined;
|
|
1265
|
+
this.#settlement = settlement;
|
|
1023
1266
|
}
|
|
1024
1267
|
catch {
|
|
1025
1268
|
// The run itself is over and succeeded; only the record of its ending failed.
|
|
@@ -1032,11 +1275,11 @@ export class AgentBase {
|
|
|
1032
1275
|
* with it, because a hook here is writing a conclusion about the very fact being committed,
|
|
1033
1276
|
* and half of that pair is worse than neither.
|
|
1034
1277
|
*/
|
|
1035
|
-
async #invokeTransactionalSettle(txCtx) {
|
|
1278
|
+
async #invokeTransactionalSettle(txCtx, settlement) {
|
|
1036
1279
|
const hook = this.#hooks.afterAgentSettledTransact;
|
|
1037
1280
|
if (hook === undefined)
|
|
1038
1281
|
return;
|
|
1039
|
-
await this.#withTransactionalContext(insideTurn.set(txCtx, []), hook);
|
|
1282
|
+
await this.#withTransactionalContext(insideTurn.set(txCtx, []), (liveCtx) => hook(liveCtx, settlement));
|
|
1040
1283
|
}
|
|
1041
1284
|
/**
|
|
1042
1285
|
* Erase everything the run wrote about itself, inside the transaction that settles the agent:
|
|
@@ -1069,7 +1312,11 @@ export class AgentBase {
|
|
|
1069
1312
|
// The settle runs once the loop has stopped, so its hook is not inside a turn and
|
|
1070
1313
|
// its context does not claim to be: a compaction it waits for reaches a loop that
|
|
1071
1314
|
// can still be started.
|
|
1072
|
-
|
|
1315
|
+
const settlement = this.#settlement;
|
|
1316
|
+
if (settlement !== undefined) {
|
|
1317
|
+
await this.#invokeHookOn(insideTurn.set(this.#ctx, []), this.#hooks.afterAgentSettled, settlement);
|
|
1318
|
+
this.#settlement = undefined;
|
|
1319
|
+
}
|
|
1073
1320
|
})();
|
|
1074
1321
|
this.#admitted.add(announced);
|
|
1075
1322
|
void announced.finally(() => this.#admitted.delete(announced));
|
|
@@ -1088,7 +1335,7 @@ export class AgentBase {
|
|
|
1088
1335
|
await this.#invokeHookOn(this.#ctx, hook, ...args);
|
|
1089
1336
|
}
|
|
1090
1337
|
/**
|
|
1091
|
-
* Lend a hook the transaction's context and
|
|
1338
|
+
* Lend a hook the transaction's context and module stores for exactly one callback. Keeping
|
|
1092
1339
|
* the context after the callback cannot leak a transaction past its commit.
|
|
1093
1340
|
*/
|
|
1094
1341
|
async #withTransactionalContext(txCtx, work) {
|
|
@@ -1164,9 +1411,21 @@ export class AgentBase {
|
|
|
1164
1411
|
this.#ensureCompaction().catch(() => undefined);
|
|
1165
1412
|
continue;
|
|
1166
1413
|
}
|
|
1414
|
+
const id = action.id ?? createId();
|
|
1415
|
+
if (!Value.Check(cuid2Schema, id))
|
|
1416
|
+
continue;
|
|
1417
|
+
let metadata;
|
|
1418
|
+
try {
|
|
1419
|
+
metadata = ownAgentMessageMetadata(action.metadata);
|
|
1420
|
+
}
|
|
1421
|
+
catch {
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1167
1424
|
batch.push({
|
|
1168
1425
|
kind: action.type === "steer" ? "steering" : "send",
|
|
1169
|
-
|
|
1426
|
+
id,
|
|
1427
|
+
message: structuredClone(action.message),
|
|
1428
|
+
...(metadata === undefined ? {} : { metadata }),
|
|
1170
1429
|
options: {},
|
|
1171
1430
|
});
|
|
1172
1431
|
}
|
|
@@ -1213,7 +1472,8 @@ export class AgentBase {
|
|
|
1213
1472
|
let needsInference = resumed.length > 0;
|
|
1214
1473
|
if (!this.#recoveryChecked) {
|
|
1215
1474
|
this.#recoveryChecked = true;
|
|
1216
|
-
|
|
1475
|
+
if (await this.#resumesInterruptedRun())
|
|
1476
|
+
needsInference = true;
|
|
1217
1477
|
}
|
|
1218
1478
|
// Each cycle first drains the queues, then runs one inference. Steering injects at
|
|
1219
1479
|
// every stop between responses and always outranks sends; sent messages inject
|
|
@@ -1232,7 +1492,7 @@ export class AgentBase {
|
|
|
1232
1492
|
if (abort.signal.aborted) {
|
|
1233
1493
|
const hasPendingWork = needsInference || this.#steering.length > 0 || this.#sends.length > 0;
|
|
1234
1494
|
if (hasPendingWork)
|
|
1235
|
-
this.#emit({ type: "done", state: "cancelled" });
|
|
1495
|
+
await this.#emit({ type: "done", state: "cancelled" });
|
|
1236
1496
|
break;
|
|
1237
1497
|
}
|
|
1238
1498
|
let injected = await this.#consumeQueue(this.#steering, this.#steeringMode, "steering");
|
|
@@ -1251,8 +1511,19 @@ export class AgentBase {
|
|
|
1251
1511
|
// either.
|
|
1252
1512
|
if ((await Promise.race([this.#settled(), abortPromise])) === ABORTED)
|
|
1253
1513
|
continue;
|
|
1254
|
-
|
|
1255
|
-
|
|
1514
|
+
this.#inferenceId ??= createId();
|
|
1515
|
+
const inferenceStart = {
|
|
1516
|
+
loopId: this.#loopId ?? createId(),
|
|
1517
|
+
turnId: this.#turnId ?? createId(),
|
|
1518
|
+
inferenceId: this.#inferenceId,
|
|
1519
|
+
contextTokens: this.#contextTokens,
|
|
1520
|
+
};
|
|
1521
|
+
this.#loopId = inferenceStart.loopId;
|
|
1522
|
+
this.#turnId = inferenceStart.turnId;
|
|
1523
|
+
await this.#enterStage("inference", this.#hooks.beforeInferenceTransact === undefined
|
|
1524
|
+
? undefined
|
|
1525
|
+
: (hookCtx) => this.#hooks.beforeInferenceTransact?.(hookCtx, inferenceStart));
|
|
1526
|
+
await this.#invokeHook(this.#hooks.beforeInference, inferenceStart);
|
|
1256
1527
|
const stream = session.run(this.#ctx, {
|
|
1257
1528
|
context: {
|
|
1258
1529
|
instructions,
|
|
@@ -1266,6 +1537,7 @@ export class AgentBase {
|
|
|
1266
1537
|
// A cancelled or failed response measures nothing, so the conversation keeps
|
|
1267
1538
|
// the last real measurement instead of forgetting how large it had become.
|
|
1268
1539
|
const inference = {
|
|
1540
|
+
...inferenceStart,
|
|
1269
1541
|
state,
|
|
1270
1542
|
tokens,
|
|
1271
1543
|
...(errorMessage === undefined ? {} : { errorMessage }),
|
|
@@ -1273,11 +1545,19 @@ export class AgentBase {
|
|
|
1273
1545
|
const afterInferenceTransact = this.#hooks.afterInferenceTransact === undefined
|
|
1274
1546
|
? undefined
|
|
1275
1547
|
: (hookCtx) => this.#hooks.afterInferenceTransact?.(hookCtx, inference);
|
|
1276
|
-
|
|
1277
|
-
|
|
1548
|
+
const completedInferenceId = this.#inferenceId;
|
|
1549
|
+
this.#inferenceId = undefined;
|
|
1550
|
+
try {
|
|
1551
|
+
if (tokens === undefined) {
|
|
1552
|
+
await this.#enterStage("inference", afterInferenceTransact);
|
|
1553
|
+
}
|
|
1554
|
+
else {
|
|
1555
|
+
await this.#recordContextTokens(tokens.input + tokens.output, afterInferenceTransact);
|
|
1556
|
+
}
|
|
1278
1557
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1558
|
+
catch (error) {
|
|
1559
|
+
this.#inferenceId = completedInferenceId;
|
|
1560
|
+
throw error;
|
|
1281
1561
|
}
|
|
1282
1562
|
await this.#invokeHook(this.#hooks.afterInference, inference);
|
|
1283
1563
|
if (content.length > 0) {
|
|
@@ -1299,10 +1579,7 @@ export class AgentBase {
|
|
|
1299
1579
|
const calls = content.filter((block) => block.type === "tool_call" && block.server !== true);
|
|
1300
1580
|
if (calls.length === 0)
|
|
1301
1581
|
continue;
|
|
1302
|
-
const closedDuringTools = await this.#runToolBatch(calls.map((call, index) => (
|
|
1303
|
-
key: this.#toolKey(index, call.callId),
|
|
1304
|
-
call,
|
|
1305
|
-
})), false, abort.signal, abortPromise);
|
|
1582
|
+
const closedDuringTools = await this.#runToolBatch(calls.map((call, index) => this.#newToolEntry(index, call)), false, abort.signal, abortPromise);
|
|
1306
1583
|
if (closedDuringTools)
|
|
1307
1584
|
break;
|
|
1308
1585
|
needsInference = true;
|
|
@@ -1323,7 +1600,7 @@ export class AgentBase {
|
|
|
1323
1600
|
}
|
|
1324
1601
|
}
|
|
1325
1602
|
catch (error) {
|
|
1326
|
-
this.#emit({
|
|
1603
|
+
await this.#emit({
|
|
1327
1604
|
type: "done",
|
|
1328
1605
|
state: "error",
|
|
1329
1606
|
kind: "internal_error",
|
|
@@ -1350,12 +1627,13 @@ export class AgentBase {
|
|
|
1350
1627
|
* beginning of a block that will now never arrive is told to drop it. Only finished blocks
|
|
1351
1628
|
* are persisted, so the conversation is intact and it is the view being corrected.
|
|
1352
1629
|
*/
|
|
1353
|
-
#resumesInterruptedRun() {
|
|
1630
|
+
async #resumesInterruptedRun() {
|
|
1354
1631
|
const owed = this.#lastRecordType === "user" ||
|
|
1355
1632
|
this.#lastRecordType === "tool" ||
|
|
1356
1633
|
this.#lastRecordType === "system";
|
|
1357
|
-
if (owed && this.#inherited?.stage === "inference")
|
|
1358
|
-
this.#emit({ type: "block_reset" });
|
|
1634
|
+
if (owed && this.#inherited?.stage === "inference") {
|
|
1635
|
+
await this.#emit({ type: "block_reset" });
|
|
1636
|
+
}
|
|
1359
1637
|
return owed;
|
|
1360
1638
|
}
|
|
1361
1639
|
/** Load the durable state once. A failed load is not sticky: the next turn retries it. */
|
|
@@ -1375,7 +1653,7 @@ export class AgentBase {
|
|
|
1375
1653
|
const previousTokens = this.#contextTokens;
|
|
1376
1654
|
this.#contextTokens = tokens;
|
|
1377
1655
|
try {
|
|
1378
|
-
await this.#
|
|
1656
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1379
1657
|
const write = (writeCtx) => tokens === undefined
|
|
1380
1658
|
? this.#persistence.deleteValue(writeCtx, "context")
|
|
1381
1659
|
: this.#persistence.writeValue(writeCtx, "context", { tokens });
|
|
@@ -1385,6 +1663,7 @@ export class AgentBase {
|
|
|
1385
1663
|
}
|
|
1386
1664
|
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1387
1665
|
await write(txCtx);
|
|
1666
|
+
await this.#recordPending(txCtx, "inference", true);
|
|
1388
1667
|
await this.#withTransactionalContext(txCtx, transact);
|
|
1389
1668
|
});
|
|
1390
1669
|
});
|
|
@@ -1426,10 +1705,11 @@ export class AgentBase {
|
|
|
1426
1705
|
throw new Error(result.message);
|
|
1427
1706
|
}
|
|
1428
1707
|
if (result.status === "completed") {
|
|
1429
|
-
await this.#
|
|
1708
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1430
1709
|
// Physically delete the superseded records and write the replacement —
|
|
1431
1710
|
// which keeps the messages that stay — in one atomic step.
|
|
1432
1711
|
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1712
|
+
await this.#deleteMessageIdentities(txCtx, await this.#persistence.load(txCtx));
|
|
1433
1713
|
await this.#persistence.clearRecords(txCtx);
|
|
1434
1714
|
await this.#persistence.append(txCtx, {
|
|
1435
1715
|
type: "compaction",
|
|
@@ -1468,32 +1748,55 @@ export class AgentBase {
|
|
|
1468
1748
|
if (owed.length === 0)
|
|
1469
1749
|
return true;
|
|
1470
1750
|
let settled = false;
|
|
1751
|
+
let staged = false;
|
|
1471
1752
|
try {
|
|
1472
|
-
await this.#
|
|
1753
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1473
1754
|
// A call the durable batch still holds belongs to the resume, which answers it
|
|
1474
1755
|
// properly — and re-executes it when the tool is durable. Settling it here as
|
|
1475
1756
|
// well would give the conversation two results for one call.
|
|
1476
1757
|
const pending = await this.#persistence.readValues(lockCtx, "tool.");
|
|
1477
|
-
const dispatched = new Set(pending.map(({ value }) => value.
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
.map((call) => toolFailure(call.callId, reason));
|
|
1481
|
-
if (results.length === 0)
|
|
1758
|
+
const dispatched = new Set(pending.map(({ key, value }) => this.#restoreToolEntry(key, value).providerCallId));
|
|
1759
|
+
if (owed.some((call) => dispatched.has(call.callId))) {
|
|
1760
|
+
settled = true;
|
|
1482
1761
|
return;
|
|
1762
|
+
}
|
|
1763
|
+
const entries = owed.map((call, index) => {
|
|
1764
|
+
const entry = this.#newToolEntry(index, call);
|
|
1765
|
+
return {
|
|
1766
|
+
...entry,
|
|
1767
|
+
committed: toolFailure(entry.providerCallId, reason),
|
|
1768
|
+
};
|
|
1769
|
+
});
|
|
1770
|
+
// Give every otherwise-undispatched call a durable internal identity and staged
|
|
1771
|
+
// result first. If the settlement transaction below fails, restart resumes these
|
|
1772
|
+
// exact entries and commits their staged errors without executing the tools.
|
|
1773
|
+
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1774
|
+
for (const entry of entries) {
|
|
1775
|
+
await this.#persistence.writeValue(txCtx, entry.key, this.#storedToolEntry(entry));
|
|
1776
|
+
}
|
|
1777
|
+
await this.#recordPending(txCtx, "tools");
|
|
1778
|
+
});
|
|
1779
|
+
staged = true;
|
|
1483
1780
|
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1484
|
-
for (const
|
|
1781
|
+
for (const entry of entries) {
|
|
1782
|
+
const result = entry.committed;
|
|
1485
1783
|
await this.#appendRecord(txCtx, { type: "tool", message: result });
|
|
1784
|
+
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
1486
1785
|
// A result the conversation records is a result the hook sees, however
|
|
1487
1786
|
// little of a run produced it. A hook that fails here leaves the calls
|
|
1488
1787
|
// unsettled, which is what lets a later attempt answer them properly.
|
|
1489
|
-
await this.#invokeToolTransactHook(txCtx,
|
|
1788
|
+
await this.#invokeToolTransactHook(txCtx, entry.id, entry.providerCallId, this.#hooks.afterToolCallTransact, result);
|
|
1789
|
+
await this.#kv.scoped("call", entry.id).clear(txCtx);
|
|
1490
1790
|
}
|
|
1791
|
+
await this.#recordPending(txCtx, "inference");
|
|
1491
1792
|
});
|
|
1492
|
-
this.#messages.push(...
|
|
1793
|
+
this.#messages.push(...entries.map(({ committed }) => committed));
|
|
1794
|
+
settled = true;
|
|
1493
1795
|
});
|
|
1494
|
-
settled = true;
|
|
1495
1796
|
}
|
|
1496
1797
|
catch {
|
|
1798
|
+
if (staged)
|
|
1799
|
+
this.#durableWorkBlocked = true;
|
|
1497
1800
|
// The turn is already failing; a restart settles what this could not, as long as
|
|
1498
1801
|
// nothing is written over the top of the call in the meantime.
|
|
1499
1802
|
}
|
|
@@ -1505,6 +1808,15 @@ export class AgentBase {
|
|
|
1505
1808
|
async #appendRecord(ctx, record) {
|
|
1506
1809
|
await this.#persistence.append(ctx, record);
|
|
1507
1810
|
}
|
|
1811
|
+
/** Remove deduplication identities for user records a history replacement is deleting. */
|
|
1812
|
+
async #deleteMessageIdentities(ctx, records) {
|
|
1813
|
+
for (const record of records) {
|
|
1814
|
+
if (record.type === "user") {
|
|
1815
|
+
await this.#persistence.deleteValue(ctx, `message.${record.id}`);
|
|
1816
|
+
this.#offeredMessageIds.delete(record.id);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1508
1820
|
/**
|
|
1509
1821
|
* A transaction whose pending-state cache unwinds with it.
|
|
1510
1822
|
*/
|
|
@@ -1514,12 +1826,20 @@ export class AgentBase {
|
|
|
1514
1826
|
// agent skip the write that actually records what it is doing.
|
|
1515
1827
|
const pending = this.#pending;
|
|
1516
1828
|
const written = this.#pendingWritten;
|
|
1829
|
+
const loopId = this.#loopId;
|
|
1830
|
+
const turnId = this.#turnId;
|
|
1831
|
+
const inferenceId = this.#inferenceId;
|
|
1832
|
+
const settlementId = this.#settlementId;
|
|
1517
1833
|
try {
|
|
1518
1834
|
return await this.#persistence.transaction(ctx, work);
|
|
1519
1835
|
}
|
|
1520
1836
|
catch (error) {
|
|
1521
1837
|
this.#pending = pending;
|
|
1522
1838
|
this.#pendingWritten = written;
|
|
1839
|
+
this.#loopId = loopId;
|
|
1840
|
+
this.#turnId = turnId;
|
|
1841
|
+
this.#inferenceId = inferenceId;
|
|
1842
|
+
this.#settlementId = settlementId;
|
|
1523
1843
|
throw error;
|
|
1524
1844
|
}
|
|
1525
1845
|
}
|
|
@@ -1538,7 +1858,7 @@ export class AgentBase {
|
|
|
1538
1858
|
content: [{ type: "text", text: `The last turn failed: ${message}` }],
|
|
1539
1859
|
};
|
|
1540
1860
|
try {
|
|
1541
|
-
await this.#
|
|
1861
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1542
1862
|
await this.#appendRecord(lockCtx, { type: "system", message: failure });
|
|
1543
1863
|
this.#messages.push(failure);
|
|
1544
1864
|
});
|
|
@@ -1561,7 +1881,7 @@ export class AgentBase {
|
|
|
1561
1881
|
/** Filled in once the consumption has committed, and reported after the lock is released. */
|
|
1562
1882
|
const accepted = [];
|
|
1563
1883
|
let permissionChange;
|
|
1564
|
-
const consumed = await this.#
|
|
1884
|
+
const consumed = await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1565
1885
|
if (queue.length === 0)
|
|
1566
1886
|
return false;
|
|
1567
1887
|
// The durable queue, not memory, decides what is left to consume after a restart.
|
|
@@ -1605,7 +1925,7 @@ export class AgentBase {
|
|
|
1605
1925
|
}
|
|
1606
1926
|
}
|
|
1607
1927
|
// The mode the messages make effective, kept apart from the rest because it is the one
|
|
1608
|
-
// setting with hooks of its own: a change is announced, and what a
|
|
1928
|
+
// setting with hooks of its own: a change is announced, and what a module concludes
|
|
1609
1929
|
// from it commits with the message that carried it.
|
|
1610
1930
|
const modeChange = permissionMode === this.#permissionMode
|
|
1611
1931
|
? undefined
|
|
@@ -1698,6 +2018,7 @@ export class AgentBase {
|
|
|
1698
2018
|
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
1699
2019
|
}
|
|
1700
2020
|
if (reset) {
|
|
2021
|
+
await this.#deleteMessageIdentities(txCtx, await this.#persistence.load(txCtx));
|
|
1701
2022
|
await this.#persistence.clearRecords(txCtx);
|
|
1702
2023
|
// The erased conversation is what the measurement described.
|
|
1703
2024
|
await this.#persistence.deleteValue(txCtx, "context");
|
|
@@ -1711,7 +2032,9 @@ export class AgentBase {
|
|
|
1711
2032
|
for (const entry of batch) {
|
|
1712
2033
|
await this.#appendRecord(txCtx, {
|
|
1713
2034
|
type: "user",
|
|
2035
|
+
id: entry.id,
|
|
1714
2036
|
message: entry.message,
|
|
2037
|
+
...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
|
|
1715
2038
|
});
|
|
1716
2039
|
}
|
|
1717
2040
|
if (changed) {
|
|
@@ -1726,7 +2049,7 @@ export class AgentBase {
|
|
|
1726
2049
|
// Consuming a message is precisely the act that makes an inference owed, so
|
|
1727
2050
|
// the two commit as one. A crash cannot land between them and leave a
|
|
1728
2051
|
// message in the conversation that nothing remembers having to answer.
|
|
1729
|
-
await this.#recordPending(txCtx,
|
|
2052
|
+
await this.#recordPending(txCtx, "inference");
|
|
1730
2053
|
// Last, so a hook writing its own account of the consumption sees a transaction
|
|
1731
2054
|
// holding all of it. The mode comes before the messages: it is what they were
|
|
1732
2055
|
// said under, and a listener recording them wants to know that first.
|
|
@@ -1735,13 +2058,23 @@ export class AgentBase {
|
|
|
1735
2058
|
await this.#invokeTransactHook(txCtx, selection, this.#hooks.permissionModeChangedTransact, modeChange);
|
|
1736
2059
|
}
|
|
1737
2060
|
for (const entry of batch) {
|
|
1738
|
-
await this.#invokeTransactHook(txCtx, selection, this.#hooks.messageAcceptedTransact, {
|
|
2061
|
+
await this.#invokeTransactHook(txCtx, selection, this.#hooks.messageAcceptedTransact, {
|
|
2062
|
+
id: entry.id,
|
|
2063
|
+
kind,
|
|
2064
|
+
message: entry.message,
|
|
2065
|
+
...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
|
|
2066
|
+
});
|
|
1739
2067
|
}
|
|
1740
2068
|
});
|
|
1741
2069
|
// Committed: from here the messages are part of the conversation, so what has to be
|
|
1742
2070
|
// announced about them is decided now and reported once the lock is released.
|
|
1743
2071
|
permissionChange = modeChange;
|
|
1744
|
-
accepted.push(...batch.map((entry) => ({
|
|
2072
|
+
accepted.push(...batch.map((entry) => ({
|
|
2073
|
+
id: entry.id,
|
|
2074
|
+
kind,
|
|
2075
|
+
message: entry.message,
|
|
2076
|
+
...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
|
|
2077
|
+
})));
|
|
1745
2078
|
queue.splice(0, count);
|
|
1746
2079
|
if (reset) {
|
|
1747
2080
|
this.#messages = injected === undefined ? [] : [injected];
|
|
@@ -1794,7 +2127,7 @@ export class AgentBase {
|
|
|
1794
2127
|
* not-yet-consumed queues. Consecutive block records reassemble into one assistant message.
|
|
1795
2128
|
*/
|
|
1796
2129
|
async #loadHistory() {
|
|
1797
|
-
await this.#
|
|
2130
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1798
2131
|
const records = await this.#persistence.load(lockCtx);
|
|
1799
2132
|
const last = records[records.length - 1];
|
|
1800
2133
|
this.#lastRecordType = last?.type;
|
|
@@ -1811,7 +2144,17 @@ export class AgentBase {
|
|
|
1811
2144
|
this.#contextTokens = measured?.tokens;
|
|
1812
2145
|
const entry = (key, value) => {
|
|
1813
2146
|
const envelope = value;
|
|
1814
|
-
|
|
2147
|
+
if (!Value.Check(cuid2Schema, envelope.id)) {
|
|
2148
|
+
throw new Error(`The queued message under "${key}" has an invalid ID.`);
|
|
2149
|
+
}
|
|
2150
|
+
const metadata = ownAgentMessageMetadata(envelope.metadata);
|
|
2151
|
+
return {
|
|
2152
|
+
key,
|
|
2153
|
+
id: envelope.id,
|
|
2154
|
+
message: envelope.message,
|
|
2155
|
+
...(metadata === undefined ? {} : { metadata }),
|
|
2156
|
+
options: envelope.options ?? {},
|
|
2157
|
+
};
|
|
1815
2158
|
};
|
|
1816
2159
|
this.#steering = steering.map(({ key, value }) => entry(key, value));
|
|
1817
2160
|
this.#sends = sends.map(({ key, value }) => entry(key, value));
|
|
@@ -1833,10 +2176,7 @@ export class AgentBase {
|
|
|
1833
2176
|
}
|
|
1834
2177
|
this.#ctx = this.#deriveCtx();
|
|
1835
2178
|
}
|
|
1836
|
-
this.#pendingTools = pendingTools.map(({ key, value }) => (
|
|
1837
|
-
key,
|
|
1838
|
-
call: value,
|
|
1839
|
-
}));
|
|
2179
|
+
this.#pendingTools = pendingTools.map(({ key, value }) => this.#restoreToolEntry(key, value));
|
|
1840
2180
|
this.#pendingToolsUndispatched = false;
|
|
1841
2181
|
if (this.#pendingTools.length === 0) {
|
|
1842
2182
|
// A crash between the response's last block and the batch commit leaves calls
|
|
@@ -1846,10 +2186,7 @@ export class AgentBase {
|
|
|
1846
2186
|
// dispatched.
|
|
1847
2187
|
const owed = this.#unansweredCalls(restored);
|
|
1848
2188
|
if (owed.length > 0) {
|
|
1849
|
-
this.#pendingTools = owed.map((call, index) => (
|
|
1850
|
-
key: this.#toolKey(index, call.callId),
|
|
1851
|
-
call,
|
|
1852
|
-
}));
|
|
2189
|
+
this.#pendingTools = owed.map((call, index) => this.#newToolEntry(index, call));
|
|
1853
2190
|
this.#pendingToolsUndispatched = true;
|
|
1854
2191
|
}
|
|
1855
2192
|
}
|
|
@@ -1867,18 +2204,18 @@ export class AgentBase {
|
|
|
1867
2204
|
*/
|
|
1868
2205
|
async #runToolBatch(entries, resume, signal, abortPromise) {
|
|
1869
2206
|
if (!resume) {
|
|
1870
|
-
await this.#
|
|
2207
|
+
await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1871
2208
|
for (const entry of entries) {
|
|
1872
|
-
await this.#persistence.writeValue(txCtx, entry.key, entry
|
|
2209
|
+
await this.#persistence.writeValue(txCtx, entry.key, this.#storedToolEntry(entry));
|
|
1873
2210
|
}
|
|
1874
2211
|
// The batch and the stage that describes it commit together. A crash can
|
|
1875
2212
|
// then never find calls owed with no record of a run owing them, nor a run
|
|
1876
2213
|
// recorded as running tools that were never written.
|
|
1877
|
-
await this.#recordPending(txCtx,
|
|
2214
|
+
await this.#recordPending(txCtx, "tools");
|
|
1878
2215
|
// Last, so a hook noting a call about to happen sees a transaction holding
|
|
1879
2216
|
// the whole batch it belongs to.
|
|
1880
2217
|
for (const entry of entries) {
|
|
1881
|
-
await this.#invokeToolTransactHook(txCtx, entry.
|
|
2218
|
+
await this.#invokeToolTransactHook(txCtx, entry.id, entry.providerCallId, this.#hooks.beforeToolCallTransact, entry.call);
|
|
1882
2219
|
}
|
|
1883
2220
|
}));
|
|
1884
2221
|
}
|
|
@@ -1899,32 +2236,46 @@ export class AgentBase {
|
|
|
1899
2236
|
if (commitFailed)
|
|
1900
2237
|
return;
|
|
1901
2238
|
try {
|
|
1902
|
-
await this.#
|
|
2239
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1903
2240
|
while (committed < entries.length) {
|
|
1904
2241
|
const entry = entries[committed];
|
|
1905
|
-
const
|
|
1906
|
-
if (entry === undefined ||
|
|
2242
|
+
const proposed = results[committed];
|
|
2243
|
+
if (entry === undefined || proposed === undefined)
|
|
1907
2244
|
return;
|
|
2245
|
+
let winner = proposed;
|
|
1908
2246
|
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
2247
|
+
const resultKey = this.#toolResultKey(entry.id);
|
|
2248
|
+
await this.#persistence.writeValueIfAbsent(txCtx, resultKey, proposed);
|
|
2249
|
+
const claims = await this.#persistence.readValues(txCtx, resultKey);
|
|
2250
|
+
const stored = claims.find(({ key }) => key === resultKey)?.value;
|
|
2251
|
+
if (!Value.Check(storedToolResultSchema, stored)) {
|
|
2252
|
+
throw new Error(`The committed result claim for tool "${entry.id}" is not valid.`);
|
|
2253
|
+
}
|
|
2254
|
+
const result = stored;
|
|
2255
|
+
if (result.callId !== entry.providerCallId) {
|
|
2256
|
+
throw new Error(`The committed result claim for tool "${entry.id}" has the wrong provider call ID.`);
|
|
2257
|
+
}
|
|
2258
|
+
winner = result;
|
|
2259
|
+
results[committed] = result;
|
|
1909
2260
|
await this.#appendRecord(txCtx, {
|
|
1910
2261
|
type: "tool",
|
|
1911
2262
|
message: result,
|
|
1912
2263
|
});
|
|
1913
|
-
// The call is answered, so
|
|
1914
|
-
//
|
|
1915
|
-
// stays: that is the tool's state, not the batch's bookkeeping, and
|
|
1916
|
-
// an owner may still want to read what a finished call recorded.
|
|
2264
|
+
// The call is answered, so both its retry record and its temporary
|
|
2265
|
+
// invocation store disappear in this same result transaction.
|
|
1917
2266
|
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
1918
|
-
await this.#
|
|
2267
|
+
await this.#persistence.deleteValue(txCtx, resultKey);
|
|
2268
|
+
await this.#invokeToolTransactHook(txCtx, entry.id, entry.providerCallId, this.#hooks.afterToolCallTransact, result);
|
|
2269
|
+
await this.#kv.scoped("call", entry.id).clear(txCtx);
|
|
1919
2270
|
});
|
|
1920
|
-
this.#messages.push(
|
|
2271
|
+
this.#messages.push(winner);
|
|
1921
2272
|
committed += 1;
|
|
1922
2273
|
}
|
|
1923
2274
|
// The batch is fully answered, so its results are what the model is owed a
|
|
1924
2275
|
// response to. Recording that here means a crash between the last result and
|
|
1925
2276
|
// the next request resumes as an inference rather than as a finished batch.
|
|
1926
2277
|
if (committed === entries.length) {
|
|
1927
|
-
await this.#recordPending(lockCtx,
|
|
2278
|
+
await this.#recordPending(lockCtx, "inference");
|
|
1928
2279
|
}
|
|
1929
2280
|
});
|
|
1930
2281
|
}
|
|
@@ -1936,12 +2287,15 @@ export class AgentBase {
|
|
|
1936
2287
|
this.#toolsRunning += 1;
|
|
1937
2288
|
const batch = Promise.all(entries.map(async (entry, index) => {
|
|
1938
2289
|
let outcome;
|
|
1939
|
-
if (
|
|
2290
|
+
if (entry.committed !== undefined) {
|
|
2291
|
+
outcome = entry.committed;
|
|
2292
|
+
}
|
|
2293
|
+
else if (resume && !(await this.#isDurable(entry.call))) {
|
|
1940
2294
|
outcome = toolFailure(entry.call.callId, "The tool call was interrupted by a restart and was not retried.");
|
|
1941
2295
|
}
|
|
1942
2296
|
else {
|
|
1943
2297
|
const toolLifetime = AbortSignal.any([signal, this.#closeController.signal]);
|
|
1944
|
-
const execution = this.#executeToolCall(withLifetime(this.#ctx, toolLifetime), entry
|
|
2298
|
+
const execution = this.#executeToolCall(withLifetime(this.#ctx, toolLifetime), entry);
|
|
1945
2299
|
running.push(execution);
|
|
1946
2300
|
outcome = await Promise.race([execution, abortPromise, this.#closingTools()]);
|
|
1947
2301
|
}
|
|
@@ -2008,29 +2362,77 @@ export class AgentBase {
|
|
|
2008
2362
|
return [];
|
|
2009
2363
|
return last.content.filter((block) => block.type === "tool_call" && block.server !== true);
|
|
2010
2364
|
}
|
|
2365
|
+
/** Allocate one internal identity before the call becomes durable or executable. */
|
|
2366
|
+
#newToolEntry(index, call) {
|
|
2367
|
+
const id = createId();
|
|
2368
|
+
return {
|
|
2369
|
+
key: this.#toolKey(index, id),
|
|
2370
|
+
id,
|
|
2371
|
+
providerCallId: call.callId,
|
|
2372
|
+
call,
|
|
2373
|
+
};
|
|
2374
|
+
}
|
|
2375
|
+
/** Restore the exact internal/provider identity pair that was dispatched before a restart. */
|
|
2376
|
+
#restoreToolEntry(key, value) {
|
|
2377
|
+
if (!Value.Check(storedToolCallSchema, value)) {
|
|
2378
|
+
throw new Error(`The pending tool call under "${key}" is not valid.`);
|
|
2379
|
+
}
|
|
2380
|
+
const stored = value;
|
|
2381
|
+
if (stored.committed !== undefined &&
|
|
2382
|
+
!Value.Check(storedToolResultSchema, stored.committed)) {
|
|
2383
|
+
throw new Error(`The committed result under "${key}" is not valid.`);
|
|
2384
|
+
}
|
|
2385
|
+
const call = {
|
|
2386
|
+
...stored.call,
|
|
2387
|
+
callId: stored.providerCallId,
|
|
2388
|
+
};
|
|
2389
|
+
return {
|
|
2390
|
+
key,
|
|
2391
|
+
id: stored.id,
|
|
2392
|
+
providerCallId: stored.providerCallId,
|
|
2393
|
+
call,
|
|
2394
|
+
...(stored.committed === undefined
|
|
2395
|
+
? {}
|
|
2396
|
+
: { committed: stored.committed }),
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
/** The explicit durable representation keeps provider and internal identities separate. */
|
|
2400
|
+
#storedToolEntry(entry) {
|
|
2401
|
+
const { callId: _providerCallId, server: _server, ...call } = entry.call;
|
|
2402
|
+
return {
|
|
2403
|
+
id: entry.id,
|
|
2404
|
+
providerCallId: entry.providerCallId,
|
|
2405
|
+
call,
|
|
2406
|
+
...(entry.committed === undefined ? {} : { committed: entry.committed }),
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
2011
2409
|
/** Sorted by position in the batch; only one batch is ever pending at a time. */
|
|
2012
|
-
#toolKey(index,
|
|
2013
|
-
return `tool.${String(index).padStart(6, "0")}.${
|
|
2410
|
+
#toolKey(index, id) {
|
|
2411
|
+
return `tool.${String(index).padStart(6, "0")}.${id}`;
|
|
2412
|
+
}
|
|
2413
|
+
/** The first-writer-wins durable claim shared by tool commit and ordinary settlement. */
|
|
2414
|
+
#toolResultKey(id) {
|
|
2415
|
+
return `toolResult.${id}`;
|
|
2014
2416
|
}
|
|
2015
2417
|
/**
|
|
2016
|
-
* The scope one call owns: state
|
|
2017
|
-
* scope, and
|
|
2018
|
-
*
|
|
2418
|
+
* The scope one call owns: state lives under its internal ID, never under the provider's
|
|
2419
|
+
* opaque ID or another call's scope, and is erased when the call commits. The task context
|
|
2420
|
+
* still locates the provider call in conversation history.
|
|
2019
2421
|
*/
|
|
2020
|
-
#callScoped(ctx,
|
|
2021
|
-
return withAgentTaskContext(withAgentRunKV(withAgentKV(ctx, this.#kv.scoped("call",
|
|
2422
|
+
#callScoped(ctx, id, providerCallId) {
|
|
2423
|
+
return withAgentTaskContext(withAgentRunKV(withAgentKV(ctx, this.#kv.scoped("call", id)), this.#runKV.scoped("call", id)), taskContextBeforeToolCall(this.#messages, providerCallId));
|
|
2022
2424
|
}
|
|
2023
2425
|
/**
|
|
2024
2426
|
* Call a tool hook that writes inside a transaction of its own call's. The lifetime ends with
|
|
2025
2427
|
* the callback, so a context kept afterwards cannot outlive the transaction it belongs to.
|
|
2026
2428
|
* Its failure is not contained: it rolls that transaction back.
|
|
2027
2429
|
*/
|
|
2028
|
-
async #invokeToolTransactHook(txCtx,
|
|
2430
|
+
async #invokeToolTransactHook(txCtx, id, providerCallId, hook, argument) {
|
|
2029
2431
|
if (hook === undefined)
|
|
2030
2432
|
return;
|
|
2031
2433
|
const lifetime = new AbortController();
|
|
2032
2434
|
try {
|
|
2033
|
-
await hook(this.#callScoped(withLifetime(txCtx, lifetime.signal),
|
|
2435
|
+
await hook(this.#callScoped(withLifetime(txCtx, lifetime.signal), id, providerCallId), argument);
|
|
2034
2436
|
}
|
|
2035
2437
|
finally {
|
|
2036
2438
|
lifetime.abort();
|
|
@@ -2041,7 +2443,8 @@ export class AgentBase {
|
|
|
2041
2443
|
* The context carries the turn's abort signal as its lifetime, so a running tool can
|
|
2042
2444
|
* observe cancellation and stop its own work.
|
|
2043
2445
|
*/
|
|
2044
|
-
async #executeToolCall(ctx,
|
|
2446
|
+
async #executeToolCall(ctx, entry) {
|
|
2447
|
+
const { call } = entry;
|
|
2045
2448
|
const failure = (text) => ({
|
|
2046
2449
|
role: "tool",
|
|
2047
2450
|
callId: call.callId,
|
|
@@ -2065,13 +2468,90 @@ export class AgentBase {
|
|
|
2065
2468
|
if (tool.parameters !== undefined && !Value.Check(tool.parameters, args)) {
|
|
2066
2469
|
return failure(`The arguments for "${call.name}" did not match its schema.`);
|
|
2067
2470
|
}
|
|
2068
|
-
const
|
|
2471
|
+
const callKV = this.#kv.scoped("call", entry.id).serialized();
|
|
2472
|
+
const callLifetime = new AbortController();
|
|
2473
|
+
let committing = false;
|
|
2474
|
+
const boundedCallKV = callKV.until(callLifetime.signal, () => !committing);
|
|
2475
|
+
const callCtx = withAgentKV(this.#callScoped(ctx, entry.id, entry.providerCallId), boundedCallKV);
|
|
2069
2476
|
// From here the call is one the two tool hooks bracket: a tool that exists, a call that
|
|
2070
2477
|
// finished, and arguments its schema accepts. A call refused before that reaches neither
|
|
2071
2478
|
// hook, because there is nothing yet to decide about or to report.
|
|
2072
2479
|
let ran = tool;
|
|
2073
2480
|
let ranArguments = args;
|
|
2074
2481
|
let outcome;
|
|
2482
|
+
let committedOutcome;
|
|
2483
|
+
let commitAttempt;
|
|
2484
|
+
let resolveCommitted;
|
|
2485
|
+
const committed = new Promise((resolve) => {
|
|
2486
|
+
resolveCommitted = resolve;
|
|
2487
|
+
});
|
|
2488
|
+
const outcomeFor = (result) => ({
|
|
2489
|
+
callId: call.callId,
|
|
2490
|
+
tool: ran,
|
|
2491
|
+
arguments: ranArguments,
|
|
2492
|
+
content: [...ran.toLLM(result)],
|
|
2493
|
+
isError: ran.isError?.(result) === true,
|
|
2494
|
+
result,
|
|
2495
|
+
});
|
|
2496
|
+
const commit = async (commitCtx, result) => {
|
|
2497
|
+
if (commitAttempt !== undefined)
|
|
2498
|
+
return await commitAttempt;
|
|
2499
|
+
if (callLifetime.signal.aborted || commitCtx.lifetime?.aborted === true) {
|
|
2500
|
+
throw new Error("The tool call can no longer commit a result.");
|
|
2501
|
+
}
|
|
2502
|
+
if (!Value.Check(ran.returnType, result)) {
|
|
2503
|
+
throw new Error(`Tool "${ran.name}" committed an invalid result.`);
|
|
2504
|
+
}
|
|
2505
|
+
const candidate = outcomeFor(result);
|
|
2506
|
+
const message = {
|
|
2507
|
+
role: "tool",
|
|
2508
|
+
callId: entry.providerCallId,
|
|
2509
|
+
content: candidate.content,
|
|
2510
|
+
...(candidate.isError ? { isError: true } : {}),
|
|
2511
|
+
};
|
|
2512
|
+
committing = true;
|
|
2513
|
+
const attempt = (async () => {
|
|
2514
|
+
if (agentKV(commitCtx)?.prefix !== callKV.prefix) {
|
|
2515
|
+
throw new Error("A tool result can only be committed with its own live call context.");
|
|
2516
|
+
}
|
|
2517
|
+
await this.#persistence.transaction(commitCtx, async (txCtx) => {
|
|
2518
|
+
const claimed = await this.#persistence.writeValueIfAbsent(txCtx, this.#toolResultKey(entry.id), message);
|
|
2519
|
+
if (!claimed) {
|
|
2520
|
+
throw new Error("The tool call already has a committed result.");
|
|
2521
|
+
}
|
|
2522
|
+
const pending = await this.#persistence.readValues(txCtx, entry.key);
|
|
2523
|
+
const stored = pending.find(({ key }) => key === entry.key);
|
|
2524
|
+
if (stored === undefined) {
|
|
2525
|
+
throw new Error("The tool call has already been settled.");
|
|
2526
|
+
}
|
|
2527
|
+
const current = this.#restoreToolEntry(entry.key, stored.value);
|
|
2528
|
+
if (current.id !== entry.id) {
|
|
2529
|
+
throw new Error("The pending tool identity changed before commit.");
|
|
2530
|
+
}
|
|
2531
|
+
if (current.committed === undefined) {
|
|
2532
|
+
await this.#persistence.writeValue(txCtx, entry.key, this.#storedToolEntry({ ...entry, committed: message }));
|
|
2533
|
+
await callKV.clear(txCtx);
|
|
2534
|
+
}
|
|
2535
|
+
afterCommit(txCtx, () => {
|
|
2536
|
+
committedOutcome = candidate;
|
|
2537
|
+
callLifetime.abort();
|
|
2538
|
+
resolveCommitted(candidate);
|
|
2539
|
+
});
|
|
2540
|
+
});
|
|
2541
|
+
return result;
|
|
2542
|
+
})();
|
|
2543
|
+
commitAttempt = attempt;
|
|
2544
|
+
try {
|
|
2545
|
+
return await attempt;
|
|
2546
|
+
}
|
|
2547
|
+
catch (error) {
|
|
2548
|
+
if (commitAttempt === attempt) {
|
|
2549
|
+
commitAttempt = undefined;
|
|
2550
|
+
committing = false;
|
|
2551
|
+
}
|
|
2552
|
+
throw error;
|
|
2553
|
+
}
|
|
2554
|
+
};
|
|
2075
2555
|
try {
|
|
2076
2556
|
const decision = await this.#hooks.beforeToolCall?.(callCtx, {
|
|
2077
2557
|
callId: call.callId,
|
|
@@ -2104,38 +2584,64 @@ export class AgentBase {
|
|
|
2104
2584
|
const runCtx = decision?.permissionMode === undefined
|
|
2105
2585
|
? callCtx
|
|
2106
2586
|
: withAgentPermissionMode(callCtx, decision.permissionMode);
|
|
2107
|
-
const
|
|
2108
|
-
|
|
2109
|
-
|
|
2587
|
+
const executionCtx = withLifetime(runCtx, AbortSignal.any(runCtx.lifetime === undefined
|
|
2588
|
+
? [callLifetime.signal]
|
|
2589
|
+
: [runCtx.lifetime, callLifetime.signal]));
|
|
2590
|
+
const execution = Promise.resolve(ran.execute(executionCtx, ranArguments, {
|
|
2591
|
+
id: entry.id,
|
|
2592
|
+
providerCallId: entry.providerCallId,
|
|
2593
|
+
kv: boundedCallKV,
|
|
2594
|
+
commit,
|
|
2595
|
+
})).then((result) => ({ type: "returned", result }), (error) => ({ type: "threw", error }));
|
|
2596
|
+
const settled = await Promise.race([
|
|
2597
|
+
execution,
|
|
2598
|
+
committed.then((committed) => ({ type: "committed", committed })),
|
|
2599
|
+
]);
|
|
2600
|
+
if (settled.type === "committed") {
|
|
2601
|
+
outcome = settled.committed;
|
|
2602
|
+
}
|
|
2603
|
+
else if (settled.type === "threw") {
|
|
2604
|
+
throw settled.error;
|
|
2605
|
+
}
|
|
2606
|
+
else if (committedOutcome !== undefined) {
|
|
2607
|
+
outcome = committedOutcome;
|
|
2608
|
+
}
|
|
2609
|
+
else {
|
|
2610
|
+
if (!Value.Check(ran.returnType, settled.result)) {
|
|
2611
|
+
throw new Error(`Tool "${ran.name}" returned an invalid result.`);
|
|
2612
|
+
}
|
|
2613
|
+
outcome = outcomeFor(settled.result);
|
|
2110
2614
|
}
|
|
2111
|
-
outcome = {
|
|
2112
|
-
callId: call.callId,
|
|
2113
|
-
tool: ran,
|
|
2114
|
-
arguments: ranArguments,
|
|
2115
|
-
content: [...ran.toLLM(result)],
|
|
2116
|
-
isError: ran.isError?.(result) === true,
|
|
2117
|
-
result,
|
|
2118
|
-
};
|
|
2119
2615
|
}
|
|
2120
2616
|
}
|
|
2121
2617
|
catch (error) {
|
|
2122
|
-
outcome =
|
|
2618
|
+
outcome =
|
|
2619
|
+
committedOutcome ??
|
|
2620
|
+
{
|
|
2621
|
+
callId: call.callId,
|
|
2622
|
+
tool: ran,
|
|
2623
|
+
arguments: ranArguments,
|
|
2624
|
+
content: [
|
|
2625
|
+
{
|
|
2626
|
+
type: "text",
|
|
2627
|
+
text: error instanceof Error ? error.message : String(error),
|
|
2628
|
+
},
|
|
2629
|
+
],
|
|
2630
|
+
isError: true,
|
|
2631
|
+
};
|
|
2632
|
+
}
|
|
2633
|
+
try {
|
|
2634
|
+
await this.#invokeHookOn(callCtx, this.#hooks.afterToolCall, outcome);
|
|
2635
|
+
return {
|
|
2636
|
+
role: "tool",
|
|
2123
2637
|
callId: call.callId,
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
content: [
|
|
2127
|
-
{ type: "text", text: error instanceof Error ? error.message : String(error) },
|
|
2128
|
-
],
|
|
2129
|
-
isError: true,
|
|
2638
|
+
content: outcome.content,
|
|
2639
|
+
...(outcome.isError ? { isError: true } : {}),
|
|
2130
2640
|
};
|
|
2131
2641
|
}
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
callId: call.callId,
|
|
2136
|
-
content: outcome.content,
|
|
2137
|
-
...(outcome.isError ? { isError: true } : {}),
|
|
2138
|
-
};
|
|
2642
|
+
finally {
|
|
2643
|
+
callLifetime.abort();
|
|
2644
|
+
}
|
|
2139
2645
|
}
|
|
2140
2646
|
/**
|
|
2141
2647
|
* A key that sorts after every entry the queue already holds. The order comes from the store
|
|
@@ -2171,7 +2677,7 @@ export class AgentBase {
|
|
|
2171
2677
|
const persist = async (event) => {
|
|
2172
2678
|
if (event === undefined)
|
|
2173
2679
|
return;
|
|
2174
|
-
await this.#
|
|
2680
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
2175
2681
|
if (this.#hooks.onEventTransact === undefined) {
|
|
2176
2682
|
await this.#appendRecord(lockCtx, { type: "block", block: event.block });
|
|
2177
2683
|
}
|
|
@@ -2196,7 +2702,7 @@ export class AgentBase {
|
|
|
2196
2702
|
const next = await Promise.race([iterator.next(), abortPromise]);
|
|
2197
2703
|
if (next === ABORTED) {
|
|
2198
2704
|
// Drop the unfinished block and end the turn.
|
|
2199
|
-
this.#emit({ type: "done", state: "cancelled" });
|
|
2705
|
+
await this.#emit({ type: "done", state: "cancelled" });
|
|
2200
2706
|
return { content: persisted, state: "cancelled" };
|
|
2201
2707
|
}
|
|
2202
2708
|
if (next.done === true) {
|
|
@@ -2204,7 +2710,7 @@ export class AgentBase {
|
|
|
2204
2710
|
break;
|
|
2205
2711
|
}
|
|
2206
2712
|
const event = next.value;
|
|
2207
|
-
this.#emit(event);
|
|
2713
|
+
await this.#emit(event);
|
|
2208
2714
|
switch (event.type) {
|
|
2209
2715
|
case "text_start":
|
|
2210
2716
|
content.push({ type: "text", text: "" });
|
|
@@ -2355,9 +2861,9 @@ export class AgentBase {
|
|
|
2355
2861
|
return this.#session;
|
|
2356
2862
|
}
|
|
2357
2863
|
/** Report one stream event to the hooks. Hooks observe the stream; they never fail a run. */
|
|
2358
|
-
#emit(event) {
|
|
2864
|
+
async #emit(event) {
|
|
2359
2865
|
try {
|
|
2360
|
-
this.#hooks.onEvent?.(this.#ctx, event);
|
|
2866
|
+
await this.#hooks.onEvent?.(this.#ctx, event);
|
|
2361
2867
|
}
|
|
2362
2868
|
catch {
|
|
2363
2869
|
// Hooks observe the stream; they never fail a run.
|