@slopus/happy-agent-base 0.0.6 → 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 +49 -11
- package/dist/Agent.d.ts +24 -22
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +113 -111
- package/dist/Agent.js.map +1 -1
- package/dist/AgentBase.d.ts +18 -14
- package/dist/AgentBase.d.ts.map +1 -1
- package/dist/AgentBase.js +476 -122
- package/dist/AgentBase.js.map +1 -1
- package/dist/AgentBaseHooks.d.ts +41 -28
- 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 +9 -9
- package/dist/AgentConfig.d.ts.map +1 -1
- package/dist/AgentConfig.js +8 -8
- 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 +3 -3
- package/dist/AgentMetadata.js +3 -3
- 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} +2 -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 +7 -4
- 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 +12 -9
- package/dist/AgentRef.d.ts.map +1 -1
- package/dist/AgentRef.js +8 -8
- 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 +10 -7
- 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 +17 -14
- package/dist/AgentSystemLocal.d.ts.map +1 -1
- package/dist/AgentSystemLocal.js +120 -29
- package/dist/AgentSystemLocal.js.map +1 -1
- package/dist/AgentSystemRef.d.ts +9 -7
- package/dist/AgentSystemRef.d.ts.map +1 -1
- package/dist/AgentSystemRef.js +17 -4
- 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 +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/dist/AgentFeature.d.ts +0 -184
- 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,9 +1,10 @@
|
|
|
1
1
|
import { areProviderModelsCompatible } from "@slopus/happy-providers";
|
|
2
2
|
import { createId } from "@paralleldrive/cuid2";
|
|
3
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
4
5
|
import { Value } from "@sinclair/typebox/value";
|
|
5
|
-
import { asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
|
|
6
|
-
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";
|
|
7
8
|
import { agentConfig, ownAgentConfig, withAgentConfig } from "./AgentConfig.js";
|
|
8
9
|
import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
|
|
9
10
|
import { AgentKV } from "./AgentKV.js";
|
|
@@ -36,6 +37,27 @@ const insidePersistenceLocks = new AsyncLocalStorage();
|
|
|
36
37
|
* hears the shutdown finish, short enough that one still holding the loop is told promptly.
|
|
37
38
|
*/
|
|
38
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
|
+
});
|
|
39
61
|
/**
|
|
40
62
|
* A single agent session over one provider. Messages arrive through two FIFO queues: steering
|
|
41
63
|
* messages inject as soon as the current assistant response and its tool batch finish, while
|
|
@@ -119,7 +141,7 @@ const INSIDE_CLOSE_REPORT_MS = 15;
|
|
|
119
141
|
* running under rather than being told.
|
|
120
142
|
*
|
|
121
143
|
* The loop enforces nothing. It has no idea what any particular tool touches, and a runtime that
|
|
122
|
-
* 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
|
|
123
145
|
* tools that do know; the loop's whole part is to carry the mode, make its changes durable, and
|
|
124
146
|
* report them.
|
|
125
147
|
*
|
|
@@ -289,6 +311,13 @@ export class AgentBase {
|
|
|
289
311
|
* about the agent is answered from here without touching the disk.
|
|
290
312
|
*/
|
|
291
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;
|
|
292
321
|
/**
|
|
293
322
|
* The pending state this instance last wrote, so a write that would change nothing is
|
|
294
323
|
* skipped. The loop passes through the same stage many times in a turn, and a store is not
|
|
@@ -326,6 +355,8 @@ export class AgentBase {
|
|
|
326
355
|
#contextTokens;
|
|
327
356
|
/** Whether the current turn was cancelled before it could finish. */
|
|
328
357
|
#turnAborted = false;
|
|
358
|
+
/** A staged tool result could not settle, so this run must leave its pending state intact. */
|
|
359
|
+
#durableWorkBlocked = false;
|
|
329
360
|
/** Whether something has asked for a turn that has not been answered yet. */
|
|
330
361
|
#turnRequested = false;
|
|
331
362
|
/** The run loop while it is running; the field is cleared once it has actually stopped. */
|
|
@@ -350,6 +381,8 @@ export class AgentBase {
|
|
|
350
381
|
* owners of one session at once.
|
|
351
382
|
*/
|
|
352
383
|
#streamCleanup = new Set();
|
|
384
|
+
/** IDs offered by this process, for immediate self-reentrant acceptance answers. */
|
|
385
|
+
#offeredMessageIds = new Set();
|
|
353
386
|
/**
|
|
354
387
|
* A new agent, wired to its options and touching no storage at all. Use this for an identity
|
|
355
388
|
* with no durable state yet; whatever the agent needs from the store is read by its first
|
|
@@ -401,6 +434,10 @@ export class AgentBase {
|
|
|
401
434
|
this.#inherited = stored;
|
|
402
435
|
this.#inheritedRead = true;
|
|
403
436
|
this.#pending = stored;
|
|
437
|
+
this.#loopId = stored?.loopId;
|
|
438
|
+
this.#turnId = stored?.turnId;
|
|
439
|
+
this.#inferenceId = stored?.inferenceId;
|
|
440
|
+
this.#settlementId = stored?.settlementId;
|
|
404
441
|
this.#pendingWritten =
|
|
405
442
|
stored === undefined ? undefined : deterministicStringify(stored);
|
|
406
443
|
});
|
|
@@ -417,7 +454,7 @@ export class AgentBase {
|
|
|
417
454
|
// An agent is its own lifetime. Whatever call happened to construct it — a tool of
|
|
418
455
|
// another agent, most often — is not a loop this one runs inside, so an inherited
|
|
419
456
|
// marker is dropped rather than carried into work that outlives that call.
|
|
420
|
-
this.#baseCtx = withAgentConfig(insideTurn.set(ctx, [options.id]), this.#config);
|
|
457
|
+
this.#baseCtx = withoutAgentStorageTransaction(withAgentDatabase(withAgentConfig(insideTurn.set(ctx, [options.id]), this.#config), options.persistence.database));
|
|
421
458
|
this.#providers = options.providers;
|
|
422
459
|
this.#providerId = options.provider;
|
|
423
460
|
this.#persistence = options.persistence;
|
|
@@ -450,7 +487,8 @@ export class AgentBase {
|
|
|
450
487
|
/** Add this agent's selection and stores to a caller context without losing its transaction. */
|
|
451
488
|
#hookContext(ctx) {
|
|
452
489
|
const selected = withAgentContext(ctx, this.#selection());
|
|
453
|
-
|
|
490
|
+
const database = agentDatabase(ctx) ?? this.#persistence.database;
|
|
491
|
+
return withAgentRunKV(withAgentKV(withAgentDatabase(selected, database), this.#kv), this.#runKV);
|
|
454
492
|
}
|
|
455
493
|
/** Load a directly owned configuration written by `updateMetadata`, when one exists. */
|
|
456
494
|
async #loadConfig(ctx) {
|
|
@@ -489,7 +527,8 @@ export class AgentBase {
|
|
|
489
527
|
* whatever else that transaction is writing, which is how a consumed message and the
|
|
490
528
|
* inference it owes become durable as one fact rather than two.
|
|
491
529
|
*/
|
|
492
|
-
async #recordPending(ctx,
|
|
530
|
+
async #recordPending(ctx, stage, force = false) {
|
|
531
|
+
const pending = this.#pendingState(stage);
|
|
493
532
|
const serialized = deterministicStringify(pending);
|
|
494
533
|
if (!force && this.#pendingWritten === serialized)
|
|
495
534
|
return;
|
|
@@ -497,6 +536,17 @@ export class AgentBase {
|
|
|
497
536
|
this.#pending = pending;
|
|
498
537
|
this.#pendingWritten = serialized;
|
|
499
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
|
+
}
|
|
500
550
|
/**
|
|
501
551
|
* Record the stage the run has reached, taking the store lock when not already inside it.
|
|
502
552
|
*
|
|
@@ -506,7 +556,7 @@ export class AgentBase {
|
|
|
506
556
|
* starting here.
|
|
507
557
|
*/
|
|
508
558
|
async #enterStage(stage, transact) {
|
|
509
|
-
const pending =
|
|
559
|
+
const pending = this.#pendingState(stage);
|
|
510
560
|
if (transact === undefined &&
|
|
511
561
|
deterministicStringify(pending) === this.#pendingWritten &&
|
|
512
562
|
this.#inheritedRead) {
|
|
@@ -519,14 +569,14 @@ export class AgentBase {
|
|
|
519
569
|
this.#inherited = await agentBasePendingStateOf(lockCtx, this.#persistence);
|
|
520
570
|
}
|
|
521
571
|
if (transact === undefined) {
|
|
522
|
-
await this.#recordPending(lockCtx,
|
|
572
|
+
await this.#recordPending(lockCtx, stage);
|
|
523
573
|
return undefined;
|
|
524
574
|
}
|
|
525
575
|
return await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
526
576
|
// The state is staged first. The callback then writes against this exact
|
|
527
577
|
// transaction, so neither its conclusion nor the state it observed can land
|
|
528
578
|
// without the other.
|
|
529
|
-
await this.#recordPending(txCtx,
|
|
579
|
+
await this.#recordPending(txCtx, stage, true);
|
|
530
580
|
return await this.#withTransactionalContext(txCtx, transact);
|
|
531
581
|
});
|
|
532
582
|
});
|
|
@@ -552,28 +602,29 @@ export class AgentBase {
|
|
|
552
602
|
/**
|
|
553
603
|
* Queue a user message that injects as soon as the current assistant response and its tool
|
|
554
604
|
* batch finish; steering always takes precedence over sent messages. Returns once the message
|
|
555
|
-
* has been handed to the agent
|
|
556
|
-
*
|
|
557
|
-
*
|
|
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.
|
|
558
608
|
*/
|
|
559
609
|
async steer(ctx, message, options) {
|
|
560
|
-
await this.#offer(ctx, "steering", message, options);
|
|
610
|
+
return await this.#offer(ctx, "steering", message, options);
|
|
561
611
|
}
|
|
562
612
|
/**
|
|
563
613
|
* Queue a user message that waits until the agent would otherwise stop — no tool calls or
|
|
564
614
|
* steering remain — before injecting. Returns once the message has been handed to the agent,
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
* 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.
|
|
568
618
|
*/
|
|
569
619
|
async send(ctx, message, options) {
|
|
570
|
-
await this.#offer(ctx, "send", message, options);
|
|
620
|
+
return await this.#offer(ctx, "send", message, options);
|
|
571
621
|
}
|
|
572
622
|
/**
|
|
573
623
|
* Shallow-merge fields into this agent's immutable metadata. The complete AgentConfig and
|
|
574
624
|
* transactional hook writes commit together; observing hooks run only after that commit.
|
|
575
625
|
*/
|
|
576
626
|
async updateMetadata(ctx, update) {
|
|
627
|
+
this.#assertOutsideStorageTransaction(ctx, "metadata update");
|
|
577
628
|
const ownedUpdate = ownAgentMetadata(update);
|
|
578
629
|
if (ownedUpdate === undefined)
|
|
579
630
|
throw new Error("The agent metadata is not valid.");
|
|
@@ -616,16 +667,20 @@ export class AgentBase {
|
|
|
616
667
|
* the agent's own close still drains it, so nothing is dropped by not looking.
|
|
617
668
|
*/
|
|
618
669
|
async #offer(ctx, kind, message, options) {
|
|
619
|
-
|
|
670
|
+
this.#assertOutsideStorageTransaction(ctx, "message delivery");
|
|
671
|
+
const { await: requestedWait, id = createId(), metadata: suppliedMetadata, ...settings } = options ?? {};
|
|
620
672
|
if (!Value.Check(cuid2Schema, id)) {
|
|
621
673
|
throw new Error("The message ID must be a cuid2 identity.");
|
|
622
674
|
}
|
|
675
|
+
const wait = requestedWait ?? !insideTurn.get(ctx).includes(this.id);
|
|
623
676
|
const metadata = ownAgentMessageMetadata(suppliedMetadata);
|
|
624
677
|
// Refusing the flag rather than the operation: a closed agent and a re-entrant wait are
|
|
625
678
|
// both caller mistakes, and both are reported before any work is started.
|
|
626
679
|
this.#assertCanWait(ctx, wait, kind === "steering" ? "a steered message" : "a sent message");
|
|
627
680
|
if (this.#closed)
|
|
628
681
|
throw new Error("The agent has been closed.");
|
|
682
|
+
const knownInProcess = this.#offeredMessageIds.has(id);
|
|
683
|
+
this.#offeredMessageIds.add(id);
|
|
629
684
|
const accepted = this.#enqueue(ctx, [
|
|
630
685
|
{
|
|
631
686
|
kind,
|
|
@@ -635,9 +690,29 @@ export class AgentBase {
|
|
|
635
690
|
options: settings,
|
|
636
691
|
},
|
|
637
692
|
]);
|
|
638
|
-
if (wait)
|
|
639
|
-
|
|
640
|
-
|
|
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
|
+
};
|
|
641
716
|
}
|
|
642
717
|
/**
|
|
643
718
|
* Refuse a wait that could never end. A hook or a tool runs while its agent's loop waits for
|
|
@@ -666,6 +741,12 @@ export class AgentBase {
|
|
|
666
741
|
#insideOwnPersistenceLock() {
|
|
667
742
|
return insidePersistenceLocks.getStore()?.includes(this.id) === true;
|
|
668
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
|
+
}
|
|
669
750
|
/** Hold the persistence lock while marking it independently of the caller's Context. */
|
|
670
751
|
async #runInPersistenceLock(ctx, work) {
|
|
671
752
|
return await this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
|
|
@@ -683,17 +764,23 @@ export class AgentBase {
|
|
|
683
764
|
*/
|
|
684
765
|
async #enqueue(ctx, batch) {
|
|
685
766
|
if (batch.length === 0)
|
|
686
|
-
return;
|
|
767
|
+
return [];
|
|
687
768
|
if (this.#closed)
|
|
688
769
|
throw new Error("The agent has been closed.");
|
|
689
770
|
// Admitted: from here on the messages are the agent's responsibility, and a close that
|
|
690
771
|
// begins now waits for them rather than resolving over the top of them.
|
|
691
772
|
const admitted = this.#runInPersistenceLock(ctx, async (lockCtx) => {
|
|
692
773
|
const accepted = [];
|
|
693
|
-
|
|
774
|
+
const results = [];
|
|
775
|
+
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
694
776
|
for (const request of batch) {
|
|
695
777
|
const identityKey = `message.${request.id}`;
|
|
696
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
|
+
});
|
|
697
784
|
continue;
|
|
698
785
|
}
|
|
699
786
|
const key = await this.#queueKey(txCtx, `${request.kind}.`);
|
|
@@ -704,13 +791,18 @@ export class AgentBase {
|
|
|
704
791
|
options: request.options,
|
|
705
792
|
});
|
|
706
793
|
accepted.push({ key, request });
|
|
794
|
+
results.push({
|
|
795
|
+
id: request.id,
|
|
796
|
+
delivery: request.kind === "steering" ? "steer" : "send",
|
|
797
|
+
accepted: "created",
|
|
798
|
+
});
|
|
707
799
|
}
|
|
708
800
|
if (accepted.length === 0)
|
|
709
801
|
return;
|
|
710
802
|
// Accepting a message is what makes the work owed: the same transaction that
|
|
711
803
|
// admits it records that the agent owes an answer, so a process that dies right
|
|
712
804
|
// here is discovered still owing it rather than looking idle over a full queue.
|
|
713
|
-
await this.#recordPending(txCtx,
|
|
805
|
+
await this.#recordPending(txCtx, "inference");
|
|
714
806
|
});
|
|
715
807
|
for (const { key, request } of accepted) {
|
|
716
808
|
// The queue is resolved inside the lock: a history load running just before this
|
|
@@ -725,14 +817,15 @@ export class AgentBase {
|
|
|
725
817
|
options: request.options,
|
|
726
818
|
});
|
|
727
819
|
}
|
|
728
|
-
if (accepted.length
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
820
|
+
if (accepted.length > 0) {
|
|
821
|
+
this.#turnRequested = true;
|
|
822
|
+
this.#startRun();
|
|
823
|
+
}
|
|
824
|
+
return results;
|
|
732
825
|
});
|
|
733
826
|
this.#admitted.add(admitted);
|
|
734
827
|
try {
|
|
735
|
-
await admitted;
|
|
828
|
+
return await admitted;
|
|
736
829
|
}
|
|
737
830
|
finally {
|
|
738
831
|
this.#admitted.delete(admitted);
|
|
@@ -771,6 +864,7 @@ export class AgentBase {
|
|
|
771
864
|
* compaction rather than queueing another.
|
|
772
865
|
*/
|
|
773
866
|
async compact(ctx, options) {
|
|
867
|
+
this.#assertOutsideStorageTransaction(ctx, "compaction");
|
|
774
868
|
const wait = options?.await ?? false;
|
|
775
869
|
this.#assertCanWait(ctx, wait, "a compaction");
|
|
776
870
|
if (this.#closed)
|
|
@@ -896,6 +990,7 @@ export class AgentBase {
|
|
|
896
990
|
* when it finished.
|
|
897
991
|
*/
|
|
898
992
|
async abort(ctx, options) {
|
|
993
|
+
this.#assertOutsideStorageTransaction(ctx, "abort");
|
|
899
994
|
const wait = options?.await ?? false;
|
|
900
995
|
this.#assertCanWait(ctx, wait, "an abort");
|
|
901
996
|
const run = this.#signalAbort();
|
|
@@ -1038,9 +1133,20 @@ export class AgentBase {
|
|
|
1038
1133
|
* more work, so the loop hooks always bracket a settled-to-settled span.
|
|
1039
1134
|
*/
|
|
1040
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
|
+
}
|
|
1041
1145
|
// The outer loop reopens when an `afterAgentLoop` action requests more work, so the
|
|
1042
1146
|
// loop hooks always bracket a settled-to-settled span.
|
|
1043
1147
|
do {
|
|
1148
|
+
this.#loopId ??= createId();
|
|
1149
|
+
const loop = { loopId: this.#loopId };
|
|
1044
1150
|
// The abort scope opens before the loop hook, not just before the turn. An abort
|
|
1045
1151
|
// owns everything the run does — its opening hook as much as its inference — so a
|
|
1046
1152
|
// run cancelled while it is still starting up never reaches the model at all.
|
|
@@ -1049,10 +1155,14 @@ export class AgentBase {
|
|
|
1049
1155
|
// crash could interrupt. What it records is refined as the run reaches each stage;
|
|
1050
1156
|
// what matters at this point is that the record exists at all, since its absence is
|
|
1051
1157
|
// what a later process reads as an agent that finished.
|
|
1052
|
-
await this.#enterStage("inference", this.#hooks.beforeAgentLoopTransact
|
|
1053
|
-
|
|
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);
|
|
1054
1162
|
do {
|
|
1055
1163
|
this.#turnAborted = false;
|
|
1164
|
+
this.#durableWorkBlocked = false;
|
|
1165
|
+
this.#turnId ??= createId();
|
|
1056
1166
|
// Claimed before any awaiting, so a request raised while the turn is still
|
|
1057
1167
|
// starting up survives into another turn instead of being cleared by it. The
|
|
1058
1168
|
// redundant turn this can cost is cheap: an empty queue drains without any
|
|
@@ -1076,9 +1186,12 @@ export class AgentBase {
|
|
|
1076
1186
|
? loadFailure.message
|
|
1077
1187
|
: String(loadFailure),
|
|
1078
1188
|
});
|
|
1189
|
+
this.#turnId = undefined;
|
|
1079
1190
|
break;
|
|
1080
1191
|
}
|
|
1081
1192
|
const turnStart = {
|
|
1193
|
+
loopId: loop.loopId,
|
|
1194
|
+
turnId: this.#turnId,
|
|
1082
1195
|
contextTokens: this.#contextTokens,
|
|
1083
1196
|
};
|
|
1084
1197
|
await this.#enterStage("inference", this.#hooks.beforeTurnTransact === undefined
|
|
@@ -1086,13 +1199,25 @@ export class AgentBase {
|
|
|
1086
1199
|
: (hookCtx) => this.#hooks.beforeTurnTransact?.(hookCtx, turnStart));
|
|
1087
1200
|
await this.#applyActions(this.#hooks.beforeTurn, abort.signal, turnStart);
|
|
1088
1201
|
await this.#runInference(abort);
|
|
1202
|
+
if (this.#durableWorkBlocked)
|
|
1203
|
+
return;
|
|
1089
1204
|
const turn = {
|
|
1205
|
+
loopId: loop.loopId,
|
|
1206
|
+
turnId: turnStart.turnId,
|
|
1090
1207
|
contextTokens: this.#contextTokens,
|
|
1091
1208
|
aborted: this.#turnAborted,
|
|
1092
1209
|
};
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
+
}
|
|
1096
1221
|
await this.#applyActions(this.#hooks.afterTurn, abort.signal, turn);
|
|
1097
1222
|
if (!this.#turnRequested || this.#closed)
|
|
1098
1223
|
break;
|
|
@@ -1100,14 +1225,21 @@ export class AgentBase {
|
|
|
1100
1225
|
// keeps the run's first turn under the scope its opening hook already ran in.
|
|
1101
1226
|
abort = this.#openAbortScope();
|
|
1102
1227
|
} while (true);
|
|
1103
|
-
await this.#enterStage("inference", this.#hooks.afterAgentLoopTransact
|
|
1104
|
-
|
|
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);
|
|
1105
1232
|
} while (this.#turnRequested && !this.#closed);
|
|
1106
1233
|
// Nothing is asked for any more, so the outstanding work is erased. That erasure is what
|
|
1107
1234
|
// makes the agent idle, and it commits together with whatever the settling hooks write,
|
|
1108
1235
|
// so no owner can ever see the agent finished without their conclusions or their
|
|
1109
1236
|
// conclusions without the agent being finished.
|
|
1110
|
-
|
|
1237
|
+
this.#settlementId ??= createId();
|
|
1238
|
+
await this.#enterStage("settlement");
|
|
1239
|
+
await this.#settleDurably({
|
|
1240
|
+
loopId: this.#loopId ?? createId(),
|
|
1241
|
+
settlementId: this.#settlementId,
|
|
1242
|
+
});
|
|
1111
1243
|
}
|
|
1112
1244
|
/**
|
|
1113
1245
|
* Erase the outstanding work and let the transactional settling hooks write in the same
|
|
@@ -1115,17 +1247,22 @@ export class AgentBase {
|
|
|
1115
1247
|
* is resumed and finds nothing to do, while one wrongly believed to be finished is never
|
|
1116
1248
|
* resumed at all.
|
|
1117
1249
|
*/
|
|
1118
|
-
async #settleDurably() {
|
|
1250
|
+
async #settleDurably(settlement) {
|
|
1119
1251
|
try {
|
|
1120
1252
|
await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1121
1253
|
await this.#clearPending(txCtx);
|
|
1122
|
-
await this.#invokeTransactionalSettle(txCtx);
|
|
1254
|
+
await this.#invokeTransactionalSettle(txCtx, settlement);
|
|
1123
1255
|
// The run store is erased last, so a settling hook can still read what the
|
|
1124
1256
|
// run concluded and keep whatever part of it belongs to the conversation.
|
|
1125
1257
|
// It commits with the settlement: the run is over and its notes are gone as
|
|
1126
1258
|
// one fact, never one without the other.
|
|
1127
1259
|
await this.#clearRunStore(txCtx);
|
|
1128
1260
|
}));
|
|
1261
|
+
this.#loopId = undefined;
|
|
1262
|
+
this.#turnId = undefined;
|
|
1263
|
+
this.#inferenceId = undefined;
|
|
1264
|
+
this.#settlementId = undefined;
|
|
1265
|
+
this.#settlement = settlement;
|
|
1129
1266
|
}
|
|
1130
1267
|
catch {
|
|
1131
1268
|
// The run itself is over and succeeded; only the record of its ending failed.
|
|
@@ -1138,11 +1275,11 @@ export class AgentBase {
|
|
|
1138
1275
|
* with it, because a hook here is writing a conclusion about the very fact being committed,
|
|
1139
1276
|
* and half of that pair is worse than neither.
|
|
1140
1277
|
*/
|
|
1141
|
-
async #invokeTransactionalSettle(txCtx) {
|
|
1278
|
+
async #invokeTransactionalSettle(txCtx, settlement) {
|
|
1142
1279
|
const hook = this.#hooks.afterAgentSettledTransact;
|
|
1143
1280
|
if (hook === undefined)
|
|
1144
1281
|
return;
|
|
1145
|
-
await this.#withTransactionalContext(insideTurn.set(txCtx, []), hook);
|
|
1282
|
+
await this.#withTransactionalContext(insideTurn.set(txCtx, []), (liveCtx) => hook(liveCtx, settlement));
|
|
1146
1283
|
}
|
|
1147
1284
|
/**
|
|
1148
1285
|
* Erase everything the run wrote about itself, inside the transaction that settles the agent:
|
|
@@ -1175,7 +1312,11 @@ export class AgentBase {
|
|
|
1175
1312
|
// The settle runs once the loop has stopped, so its hook is not inside a turn and
|
|
1176
1313
|
// its context does not claim to be: a compaction it waits for reaches a loop that
|
|
1177
1314
|
// can still be started.
|
|
1178
|
-
|
|
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
|
+
}
|
|
1179
1320
|
})();
|
|
1180
1321
|
this.#admitted.add(announced);
|
|
1181
1322
|
void announced.finally(() => this.#admitted.delete(announced));
|
|
@@ -1194,7 +1335,7 @@ export class AgentBase {
|
|
|
1194
1335
|
await this.#invokeHookOn(this.#ctx, hook, ...args);
|
|
1195
1336
|
}
|
|
1196
1337
|
/**
|
|
1197
|
-
* Lend a hook the transaction's context and
|
|
1338
|
+
* Lend a hook the transaction's context and module stores for exactly one callback. Keeping
|
|
1198
1339
|
* the context after the callback cannot leak a transaction past its commit.
|
|
1199
1340
|
*/
|
|
1200
1341
|
async #withTransactionalContext(txCtx, work) {
|
|
@@ -1370,8 +1511,19 @@ export class AgentBase {
|
|
|
1370
1511
|
// either.
|
|
1371
1512
|
if ((await Promise.race([this.#settled(), abortPromise])) === ABORTED)
|
|
1372
1513
|
continue;
|
|
1373
|
-
|
|
1374
|
-
|
|
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);
|
|
1375
1527
|
const stream = session.run(this.#ctx, {
|
|
1376
1528
|
context: {
|
|
1377
1529
|
instructions,
|
|
@@ -1385,6 +1537,7 @@ export class AgentBase {
|
|
|
1385
1537
|
// A cancelled or failed response measures nothing, so the conversation keeps
|
|
1386
1538
|
// the last real measurement instead of forgetting how large it had become.
|
|
1387
1539
|
const inference = {
|
|
1540
|
+
...inferenceStart,
|
|
1388
1541
|
state,
|
|
1389
1542
|
tokens,
|
|
1390
1543
|
...(errorMessage === undefined ? {} : { errorMessage }),
|
|
@@ -1392,11 +1545,19 @@ export class AgentBase {
|
|
|
1392
1545
|
const afterInferenceTransact = this.#hooks.afterInferenceTransact === undefined
|
|
1393
1546
|
? undefined
|
|
1394
1547
|
: (hookCtx) => this.#hooks.afterInferenceTransact?.(hookCtx, inference);
|
|
1395
|
-
|
|
1396
|
-
|
|
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
|
+
}
|
|
1397
1557
|
}
|
|
1398
|
-
|
|
1399
|
-
|
|
1558
|
+
catch (error) {
|
|
1559
|
+
this.#inferenceId = completedInferenceId;
|
|
1560
|
+
throw error;
|
|
1400
1561
|
}
|
|
1401
1562
|
await this.#invokeHook(this.#hooks.afterInference, inference);
|
|
1402
1563
|
if (content.length > 0) {
|
|
@@ -1418,10 +1579,7 @@ export class AgentBase {
|
|
|
1418
1579
|
const calls = content.filter((block) => block.type === "tool_call" && block.server !== true);
|
|
1419
1580
|
if (calls.length === 0)
|
|
1420
1581
|
continue;
|
|
1421
|
-
const closedDuringTools = await this.#runToolBatch(calls.map((call, index) => (
|
|
1422
|
-
key: this.#toolKey(index, call.callId),
|
|
1423
|
-
call,
|
|
1424
|
-
})), false, abort.signal, abortPromise);
|
|
1582
|
+
const closedDuringTools = await this.#runToolBatch(calls.map((call, index) => this.#newToolEntry(index, call)), false, abort.signal, abortPromise);
|
|
1425
1583
|
if (closedDuringTools)
|
|
1426
1584
|
break;
|
|
1427
1585
|
needsInference = true;
|
|
@@ -1505,6 +1663,7 @@ export class AgentBase {
|
|
|
1505
1663
|
}
|
|
1506
1664
|
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1507
1665
|
await write(txCtx);
|
|
1666
|
+
await this.#recordPending(txCtx, "inference", true);
|
|
1508
1667
|
await this.#withTransactionalContext(txCtx, transact);
|
|
1509
1668
|
});
|
|
1510
1669
|
});
|
|
@@ -1589,32 +1748,55 @@ export class AgentBase {
|
|
|
1589
1748
|
if (owed.length === 0)
|
|
1590
1749
|
return true;
|
|
1591
1750
|
let settled = false;
|
|
1751
|
+
let staged = false;
|
|
1592
1752
|
try {
|
|
1593
1753
|
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1594
1754
|
// A call the durable batch still holds belongs to the resume, which answers it
|
|
1595
1755
|
// properly — and re-executes it when the tool is durable. Settling it here as
|
|
1596
1756
|
// well would give the conversation two results for one call.
|
|
1597
1757
|
const pending = await this.#persistence.readValues(lockCtx, "tool.");
|
|
1598
|
-
const dispatched = new Set(pending.map(({ value }) => value.
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
.map((call) => toolFailure(call.callId, reason));
|
|
1602
|
-
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;
|
|
1603
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.
|
|
1604
1773
|
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1605
|
-
for (const
|
|
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;
|
|
1780
|
+
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1781
|
+
for (const entry of entries) {
|
|
1782
|
+
const result = entry.committed;
|
|
1606
1783
|
await this.#appendRecord(txCtx, { type: "tool", message: result });
|
|
1784
|
+
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
1607
1785
|
// A result the conversation records is a result the hook sees, however
|
|
1608
1786
|
// little of a run produced it. A hook that fails here leaves the calls
|
|
1609
1787
|
// unsettled, which is what lets a later attempt answer them properly.
|
|
1610
|
-
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);
|
|
1611
1790
|
}
|
|
1791
|
+
await this.#recordPending(txCtx, "inference");
|
|
1612
1792
|
});
|
|
1613
|
-
this.#messages.push(...
|
|
1793
|
+
this.#messages.push(...entries.map(({ committed }) => committed));
|
|
1794
|
+
settled = true;
|
|
1614
1795
|
});
|
|
1615
|
-
settled = true;
|
|
1616
1796
|
}
|
|
1617
1797
|
catch {
|
|
1798
|
+
if (staged)
|
|
1799
|
+
this.#durableWorkBlocked = true;
|
|
1618
1800
|
// The turn is already failing; a restart settles what this could not, as long as
|
|
1619
1801
|
// nothing is written over the top of the call in the meantime.
|
|
1620
1802
|
}
|
|
@@ -1631,6 +1813,7 @@ export class AgentBase {
|
|
|
1631
1813
|
for (const record of records) {
|
|
1632
1814
|
if (record.type === "user") {
|
|
1633
1815
|
await this.#persistence.deleteValue(ctx, `message.${record.id}`);
|
|
1816
|
+
this.#offeredMessageIds.delete(record.id);
|
|
1634
1817
|
}
|
|
1635
1818
|
}
|
|
1636
1819
|
}
|
|
@@ -1643,12 +1826,20 @@ export class AgentBase {
|
|
|
1643
1826
|
// agent skip the write that actually records what it is doing.
|
|
1644
1827
|
const pending = this.#pending;
|
|
1645
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;
|
|
1646
1833
|
try {
|
|
1647
1834
|
return await this.#persistence.transaction(ctx, work);
|
|
1648
1835
|
}
|
|
1649
1836
|
catch (error) {
|
|
1650
1837
|
this.#pending = pending;
|
|
1651
1838
|
this.#pendingWritten = written;
|
|
1839
|
+
this.#loopId = loopId;
|
|
1840
|
+
this.#turnId = turnId;
|
|
1841
|
+
this.#inferenceId = inferenceId;
|
|
1842
|
+
this.#settlementId = settlementId;
|
|
1652
1843
|
throw error;
|
|
1653
1844
|
}
|
|
1654
1845
|
}
|
|
@@ -1734,7 +1925,7 @@ export class AgentBase {
|
|
|
1734
1925
|
}
|
|
1735
1926
|
}
|
|
1736
1927
|
// The mode the messages make effective, kept apart from the rest because it is the one
|
|
1737
|
-
// 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
|
|
1738
1929
|
// from it commits with the message that carried it.
|
|
1739
1930
|
const modeChange = permissionMode === this.#permissionMode
|
|
1740
1931
|
? undefined
|
|
@@ -1858,7 +2049,7 @@ export class AgentBase {
|
|
|
1858
2049
|
// Consuming a message is precisely the act that makes an inference owed, so
|
|
1859
2050
|
// the two commit as one. A crash cannot land between them and leave a
|
|
1860
2051
|
// message in the conversation that nothing remembers having to answer.
|
|
1861
|
-
await this.#recordPending(txCtx,
|
|
2052
|
+
await this.#recordPending(txCtx, "inference");
|
|
1862
2053
|
// Last, so a hook writing its own account of the consumption sees a transaction
|
|
1863
2054
|
// holding all of it. The mode comes before the messages: it is what they were
|
|
1864
2055
|
// said under, and a listener recording them wants to know that first.
|
|
@@ -1985,10 +2176,7 @@ export class AgentBase {
|
|
|
1985
2176
|
}
|
|
1986
2177
|
this.#ctx = this.#deriveCtx();
|
|
1987
2178
|
}
|
|
1988
|
-
this.#pendingTools = pendingTools.map(({ key, value }) => (
|
|
1989
|
-
key,
|
|
1990
|
-
call: value,
|
|
1991
|
-
}));
|
|
2179
|
+
this.#pendingTools = pendingTools.map(({ key, value }) => this.#restoreToolEntry(key, value));
|
|
1992
2180
|
this.#pendingToolsUndispatched = false;
|
|
1993
2181
|
if (this.#pendingTools.length === 0) {
|
|
1994
2182
|
// A crash between the response's last block and the batch commit leaves calls
|
|
@@ -1998,10 +2186,7 @@ export class AgentBase {
|
|
|
1998
2186
|
// dispatched.
|
|
1999
2187
|
const owed = this.#unansweredCalls(restored);
|
|
2000
2188
|
if (owed.length > 0) {
|
|
2001
|
-
this.#pendingTools = owed.map((call, index) => (
|
|
2002
|
-
key: this.#toolKey(index, call.callId),
|
|
2003
|
-
call,
|
|
2004
|
-
}));
|
|
2189
|
+
this.#pendingTools = owed.map((call, index) => this.#newToolEntry(index, call));
|
|
2005
2190
|
this.#pendingToolsUndispatched = true;
|
|
2006
2191
|
}
|
|
2007
2192
|
}
|
|
@@ -2021,16 +2206,16 @@ export class AgentBase {
|
|
|
2021
2206
|
if (!resume) {
|
|
2022
2207
|
await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
2023
2208
|
for (const entry of entries) {
|
|
2024
|
-
await this.#persistence.writeValue(txCtx, entry.key, entry
|
|
2209
|
+
await this.#persistence.writeValue(txCtx, entry.key, this.#storedToolEntry(entry));
|
|
2025
2210
|
}
|
|
2026
2211
|
// The batch and the stage that describes it commit together. A crash can
|
|
2027
2212
|
// then never find calls owed with no record of a run owing them, nor a run
|
|
2028
2213
|
// recorded as running tools that were never written.
|
|
2029
|
-
await this.#recordPending(txCtx,
|
|
2214
|
+
await this.#recordPending(txCtx, "tools");
|
|
2030
2215
|
// Last, so a hook noting a call about to happen sees a transaction holding
|
|
2031
2216
|
// the whole batch it belongs to.
|
|
2032
2217
|
for (const entry of entries) {
|
|
2033
|
-
await this.#invokeToolTransactHook(txCtx, entry.
|
|
2218
|
+
await this.#invokeToolTransactHook(txCtx, entry.id, entry.providerCallId, this.#hooks.beforeToolCallTransact, entry.call);
|
|
2034
2219
|
}
|
|
2035
2220
|
}));
|
|
2036
2221
|
}
|
|
@@ -2054,29 +2239,43 @@ export class AgentBase {
|
|
|
2054
2239
|
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
2055
2240
|
while (committed < entries.length) {
|
|
2056
2241
|
const entry = entries[committed];
|
|
2057
|
-
const
|
|
2058
|
-
if (entry === undefined ||
|
|
2242
|
+
const proposed = results[committed];
|
|
2243
|
+
if (entry === undefined || proposed === undefined)
|
|
2059
2244
|
return;
|
|
2245
|
+
let winner = proposed;
|
|
2060
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;
|
|
2061
2260
|
await this.#appendRecord(txCtx, {
|
|
2062
2261
|
type: "tool",
|
|
2063
2262
|
message: result,
|
|
2064
2263
|
});
|
|
2065
|
-
// The call is answered, so
|
|
2066
|
-
//
|
|
2067
|
-
// stays: that is the tool's state, not the batch's bookkeeping, and
|
|
2068
|
-
// 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.
|
|
2069
2266
|
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
2070
|
-
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);
|
|
2071
2270
|
});
|
|
2072
|
-
this.#messages.push(
|
|
2271
|
+
this.#messages.push(winner);
|
|
2073
2272
|
committed += 1;
|
|
2074
2273
|
}
|
|
2075
2274
|
// The batch is fully answered, so its results are what the model is owed a
|
|
2076
2275
|
// response to. Recording that here means a crash between the last result and
|
|
2077
2276
|
// the next request resumes as an inference rather than as a finished batch.
|
|
2078
2277
|
if (committed === entries.length) {
|
|
2079
|
-
await this.#recordPending(lockCtx,
|
|
2278
|
+
await this.#recordPending(lockCtx, "inference");
|
|
2080
2279
|
}
|
|
2081
2280
|
});
|
|
2082
2281
|
}
|
|
@@ -2088,12 +2287,15 @@ export class AgentBase {
|
|
|
2088
2287
|
this.#toolsRunning += 1;
|
|
2089
2288
|
const batch = Promise.all(entries.map(async (entry, index) => {
|
|
2090
2289
|
let outcome;
|
|
2091
|
-
if (
|
|
2290
|
+
if (entry.committed !== undefined) {
|
|
2291
|
+
outcome = entry.committed;
|
|
2292
|
+
}
|
|
2293
|
+
else if (resume && !(await this.#isDurable(entry.call))) {
|
|
2092
2294
|
outcome = toolFailure(entry.call.callId, "The tool call was interrupted by a restart and was not retried.");
|
|
2093
2295
|
}
|
|
2094
2296
|
else {
|
|
2095
2297
|
const toolLifetime = AbortSignal.any([signal, this.#closeController.signal]);
|
|
2096
|
-
const execution = this.#executeToolCall(withLifetime(this.#ctx, toolLifetime), entry
|
|
2298
|
+
const execution = this.#executeToolCall(withLifetime(this.#ctx, toolLifetime), entry);
|
|
2097
2299
|
running.push(execution);
|
|
2098
2300
|
outcome = await Promise.race([execution, abortPromise, this.#closingTools()]);
|
|
2099
2301
|
}
|
|
@@ -2160,29 +2362,77 @@ export class AgentBase {
|
|
|
2160
2362
|
return [];
|
|
2161
2363
|
return last.content.filter((block) => block.type === "tool_call" && block.server !== true);
|
|
2162
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
|
+
}
|
|
2163
2409
|
/** Sorted by position in the batch; only one batch is ever pending at a time. */
|
|
2164
|
-
#toolKey(index,
|
|
2165
|
-
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}`;
|
|
2166
2416
|
}
|
|
2167
2417
|
/**
|
|
2168
|
-
* The scope one call owns: state
|
|
2169
|
-
* scope, and
|
|
2170
|
-
*
|
|
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.
|
|
2171
2421
|
*/
|
|
2172
|
-
#callScoped(ctx,
|
|
2173
|
-
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));
|
|
2174
2424
|
}
|
|
2175
2425
|
/**
|
|
2176
2426
|
* Call a tool hook that writes inside a transaction of its own call's. The lifetime ends with
|
|
2177
2427
|
* the callback, so a context kept afterwards cannot outlive the transaction it belongs to.
|
|
2178
2428
|
* Its failure is not contained: it rolls that transaction back.
|
|
2179
2429
|
*/
|
|
2180
|
-
async #invokeToolTransactHook(txCtx,
|
|
2430
|
+
async #invokeToolTransactHook(txCtx, id, providerCallId, hook, argument) {
|
|
2181
2431
|
if (hook === undefined)
|
|
2182
2432
|
return;
|
|
2183
2433
|
const lifetime = new AbortController();
|
|
2184
2434
|
try {
|
|
2185
|
-
await hook(this.#callScoped(withLifetime(txCtx, lifetime.signal),
|
|
2435
|
+
await hook(this.#callScoped(withLifetime(txCtx, lifetime.signal), id, providerCallId), argument);
|
|
2186
2436
|
}
|
|
2187
2437
|
finally {
|
|
2188
2438
|
lifetime.abort();
|
|
@@ -2193,7 +2443,8 @@ export class AgentBase {
|
|
|
2193
2443
|
* The context carries the turn's abort signal as its lifetime, so a running tool can
|
|
2194
2444
|
* observe cancellation and stop its own work.
|
|
2195
2445
|
*/
|
|
2196
|
-
async #executeToolCall(ctx,
|
|
2446
|
+
async #executeToolCall(ctx, entry) {
|
|
2447
|
+
const { call } = entry;
|
|
2197
2448
|
const failure = (text) => ({
|
|
2198
2449
|
role: "tool",
|
|
2199
2450
|
callId: call.callId,
|
|
@@ -2217,13 +2468,90 @@ export class AgentBase {
|
|
|
2217
2468
|
if (tool.parameters !== undefined && !Value.Check(tool.parameters, args)) {
|
|
2218
2469
|
return failure(`The arguments for "${call.name}" did not match its schema.`);
|
|
2219
2470
|
}
|
|
2220
|
-
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);
|
|
2221
2476
|
// From here the call is one the two tool hooks bracket: a tool that exists, a call that
|
|
2222
2477
|
// finished, and arguments its schema accepts. A call refused before that reaches neither
|
|
2223
2478
|
// hook, because there is nothing yet to decide about or to report.
|
|
2224
2479
|
let ran = tool;
|
|
2225
2480
|
let ranArguments = args;
|
|
2226
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
|
+
};
|
|
2227
2555
|
try {
|
|
2228
2556
|
const decision = await this.#hooks.beforeToolCall?.(callCtx, {
|
|
2229
2557
|
callId: call.callId,
|
|
@@ -2256,38 +2584,64 @@ export class AgentBase {
|
|
|
2256
2584
|
const runCtx = decision?.permissionMode === undefined
|
|
2257
2585
|
? callCtx
|
|
2258
2586
|
: withAgentPermissionMode(callCtx, decision.permissionMode);
|
|
2259
|
-
const
|
|
2260
|
-
|
|
2261
|
-
|
|
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);
|
|
2262
2614
|
}
|
|
2263
|
-
outcome = {
|
|
2264
|
-
callId: call.callId,
|
|
2265
|
-
tool: ran,
|
|
2266
|
-
arguments: ranArguments,
|
|
2267
|
-
content: [...ran.toLLM(result)],
|
|
2268
|
-
isError: ran.isError?.(result) === true,
|
|
2269
|
-
result,
|
|
2270
|
-
};
|
|
2271
2615
|
}
|
|
2272
2616
|
}
|
|
2273
2617
|
catch (error) {
|
|
2274
|
-
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",
|
|
2275
2637
|
callId: call.callId,
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
content: [
|
|
2279
|
-
{ type: "text", text: error instanceof Error ? error.message : String(error) },
|
|
2280
|
-
],
|
|
2281
|
-
isError: true,
|
|
2638
|
+
content: outcome.content,
|
|
2639
|
+
...(outcome.isError ? { isError: true } : {}),
|
|
2282
2640
|
};
|
|
2283
2641
|
}
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
callId: call.callId,
|
|
2288
|
-
content: outcome.content,
|
|
2289
|
-
...(outcome.isError ? { isError: true } : {}),
|
|
2290
|
-
};
|
|
2642
|
+
finally {
|
|
2643
|
+
callLifetime.abort();
|
|
2644
|
+
}
|
|
2291
2645
|
}
|
|
2292
2646
|
/**
|
|
2293
2647
|
* A key that sorts after every entry the queue already holds. The order comes from the store
|