@slopus/happy-agent-base 0.0.4 → 0.0.6
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 +35 -1
- package/dist/Agent.d.ts +3 -0
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +56 -20
- package/dist/Agent.js.map +1 -1
- package/dist/AgentBase.d.ts +36 -0
- package/dist/AgentBase.d.ts.map +1 -1
- package/dist/AgentBase.js +376 -58
- package/dist/AgentBase.js.map +1 -1
- package/dist/AgentBaseHooks.d.ts +166 -20
- package/dist/AgentBaseHooks.d.ts.map +1 -1
- package/dist/AgentConfig.d.ts +12 -5
- package/dist/AgentConfig.d.ts.map +1 -1
- package/dist/AgentConfig.js +25 -5
- package/dist/AgentConfig.js.map +1 -1
- package/dist/AgentContexts.d.ts +19 -5
- package/dist/AgentContexts.d.ts.map +1 -1
- package/dist/AgentContexts.js +26 -6
- package/dist/AgentContexts.js.map +1 -1
- package/dist/AgentFeature.d.ts +48 -10
- package/dist/AgentFeature.d.ts.map +1 -1
- package/dist/AgentFeatureAction.d.ts +5 -0
- package/dist/AgentFeatureAction.d.ts.map +1 -1
- 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/AgentPermissionMode.d.ts +27 -0
- package/dist/AgentPermissionMode.d.ts.map +1 -0
- package/dist/AgentPermissionMode.js +48 -0
- package/dist/AgentPermissionMode.js.map +1 -0
- package/dist/AgentPersistence.d.ts +14 -4
- package/dist/AgentPersistence.d.ts.map +1 -1
- package/dist/AgentRef.d.ts +6 -1
- package/dist/AgentRef.d.ts.map +1 -1
- package/dist/AgentRef.js +8 -1
- package/dist/AgentRef.js.map +1 -1
- package/dist/AgentSystem.d.ts +26 -7
- package/dist/AgentSystem.d.ts.map +1 -1
- package/dist/AgentSystemLocal.d.ts +14 -8
- package/dist/AgentSystemLocal.d.ts.map +1 -1
- package/dist/AgentSystemLocal.js +108 -32
- package/dist/AgentSystemLocal.js.map +1 -1
- package/dist/AgentSystemRef.d.ts +14 -5
- package/dist/AgentSystemRef.d.ts.map +1 -1
- package/dist/AgentSystemRef.js +25 -6
- package/dist/AgentSystemRef.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -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";
|
|
3
4
|
import { Value } from "@sinclair/typebox/value";
|
|
4
5
|
import { asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
|
|
5
|
-
import { withAgentContext, withAgentKV, withAgentRunKV } from "./AgentContexts.js";
|
|
6
|
+
import { withAgentContext, withAgentKV, withAgentPermissionMode, withAgentRunKV, } from "./AgentContexts.js";
|
|
7
|
+
import { agentConfig, ownAgentConfig, withAgentConfig } from "./AgentConfig.js";
|
|
6
8
|
import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
|
|
7
9
|
import { AgentKV } from "./AgentKV.js";
|
|
8
10
|
import { AGENT_BASE_PENDING_KEY, agentBasePendingStateOf, } from "./AgentBasePending.js";
|
|
11
|
+
import { cuid2Schema, ownAgentMessageMetadata, ownAgentMetadata, } from "./AgentMetadata.js";
|
|
12
|
+
import { DEFAULT_AGENT_PERMISSION_MODE, isAgentPermissionMode, } from "./AgentPermissionMode.js";
|
|
9
13
|
import { AgentProviders } from "./AgentProviders.js";
|
|
10
14
|
/** Race winner when an abort interrupts a wait on the stream or a running tool. */
|
|
11
15
|
const ABORTED = Symbol("aborted");
|
|
@@ -24,6 +28,8 @@ const insideTurn = createContextNamespace("agentInsideTurn", []);
|
|
|
24
28
|
* its own and outlives whatever happened to start it.
|
|
25
29
|
*/
|
|
26
30
|
const insideLoops = new AsyncLocalStorage();
|
|
31
|
+
/** Persistence locks held by the current asynchronous call chain, independent of Context. */
|
|
32
|
+
const insidePersistenceLocks = new AsyncLocalStorage();
|
|
27
33
|
/**
|
|
28
34
|
* How long a close asked for from inside the agent's own run loop waits for the shutdown before
|
|
29
35
|
* telling its caller it cannot be waited for. Long enough that a caller which has already let go
|
|
@@ -103,6 +109,20 @@ const INSIDE_CLOSE_REPORT_MS = 15;
|
|
|
103
109
|
* capability released when the hook returns, so it cannot be retained to bypass the lock later.
|
|
104
110
|
* A failing handoff rejects an incompatible switch outright rather than costing the history.
|
|
105
111
|
*
|
|
112
|
+
* ## Permission modes
|
|
113
|
+
*
|
|
114
|
+
* How much of the machine the agent may touch travels with its messages, exactly like its model,
|
|
115
|
+
* and takes effect when the message is consumed rather than when it is queued: a response and the
|
|
116
|
+
* tools it dispatched are already running under the mode they were started with, and are left to
|
|
117
|
+
* finish under it. The mode is durable, so a restart resumes in the mode the conversation reached,
|
|
118
|
+
* and it is carried on every context the agent derives, so a hook or a tool reads what it is
|
|
119
|
+
* running under rather than being told.
|
|
120
|
+
*
|
|
121
|
+
* 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 features and
|
|
123
|
+
* tools that do know; the loop's whole part is to carry the mode, make its changes durable, and
|
|
124
|
+
* report them.
|
|
125
|
+
*
|
|
106
126
|
* ## Recovery
|
|
107
127
|
*
|
|
108
128
|
* Whether a restart owes a response is decided by the last durable record: a consumed message, a
|
|
@@ -188,6 +208,8 @@ export class AgentBase {
|
|
|
188
208
|
#baseCtx;
|
|
189
209
|
/** The base context extended with the effective selection and the agent's key-value store. */
|
|
190
210
|
#ctx;
|
|
211
|
+
/** The immutable configuration snapshot carried by every hook context. */
|
|
212
|
+
#config;
|
|
191
213
|
/** The registry the provider ID is resolved through, each time a session is created. */
|
|
192
214
|
#providers;
|
|
193
215
|
/** The registry ID of the provider in force; durable, so a restart resumes on the same one. */
|
|
@@ -200,6 +222,11 @@ export class AgentBase {
|
|
|
200
222
|
#effort;
|
|
201
223
|
/** The service tier in force. */
|
|
202
224
|
#serviceTier;
|
|
225
|
+
/**
|
|
226
|
+
* How much of the machine the agent may touch. Durable, so a restart resumes in the mode the
|
|
227
|
+
* conversation reached, and carried on every context the agent derives.
|
|
228
|
+
*/
|
|
229
|
+
#permissionMode;
|
|
203
230
|
/** The single set of hooks the run is observed by and its configuration extended from. */
|
|
204
231
|
#hooks;
|
|
205
232
|
/**
|
|
@@ -368,7 +395,8 @@ export class AgentBase {
|
|
|
368
395
|
* by, so reading it here leaves the later stage writes nothing to learn from the store.
|
|
369
396
|
*/
|
|
370
397
|
async #loadPendingState() {
|
|
371
|
-
await this.#
|
|
398
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
399
|
+
await this.#loadConfig(lockCtx);
|
|
372
400
|
const stored = await agentBasePendingStateOf(lockCtx, this.#persistence);
|
|
373
401
|
this.#inherited = stored;
|
|
374
402
|
this.#inheritedRead = true;
|
|
@@ -385,10 +413,11 @@ export class AgentBase {
|
|
|
385
413
|
*/
|
|
386
414
|
constructor(ctx, options) {
|
|
387
415
|
this.id = options.id;
|
|
416
|
+
this.#config = ownAgentConfig(agentConfig(ctx) ?? {});
|
|
388
417
|
// An agent is its own lifetime. Whatever call happened to construct it — a tool of
|
|
389
418
|
// another agent, most often — is not a loop this one runs inside, so an inherited
|
|
390
419
|
// marker is dropped rather than carried into work that outlives that call.
|
|
391
|
-
this.#baseCtx = insideTurn.set(ctx, [options.id]);
|
|
420
|
+
this.#baseCtx = withAgentConfig(insideTurn.set(ctx, [options.id]), this.#config);
|
|
392
421
|
this.#providers = options.providers;
|
|
393
422
|
this.#providerId = options.provider;
|
|
394
423
|
this.#persistence = options.persistence;
|
|
@@ -401,6 +430,7 @@ export class AgentBase {
|
|
|
401
430
|
this.#model = options.model;
|
|
402
431
|
this.#effort = options.effort;
|
|
403
432
|
this.#serviceTier = options.serviceTier;
|
|
433
|
+
this.#permissionMode = options.permissionMode ?? DEFAULT_AGENT_PERMISSION_MODE;
|
|
404
434
|
this.#kv = new AgentKV(this.#persistence, `kv.${options.id}.`);
|
|
405
435
|
this.#runKV = this.#kv.scoped("run");
|
|
406
436
|
// Everything the agent does — hooks and tool executions included — runs on a context
|
|
@@ -415,14 +445,34 @@ export class AgentBase {
|
|
|
415
445
|
* the selection changes.
|
|
416
446
|
*/
|
|
417
447
|
#deriveCtx() {
|
|
418
|
-
|
|
448
|
+
return this.#hookContext(this.#baseCtx);
|
|
449
|
+
}
|
|
450
|
+
/** Add this agent's selection and stores to a caller context without losing its transaction. */
|
|
451
|
+
#hookContext(ctx) {
|
|
452
|
+
const selected = withAgentContext(ctx, this.#selection());
|
|
453
|
+
return withAgentRunKV(withAgentKV(selected, this.#kv), this.#runKV);
|
|
454
|
+
}
|
|
455
|
+
/** Load a directly owned configuration written by `updateMetadata`, when one exists. */
|
|
456
|
+
async #loadConfig(ctx) {
|
|
457
|
+
const stored = await this.#persistence.readValues(ctx, "agentConfig");
|
|
458
|
+
const exact = stored.find(({ key }) => key === "agentConfig")?.value;
|
|
459
|
+
if (exact === undefined)
|
|
460
|
+
return;
|
|
461
|
+
const config = ownAgentConfig(exact);
|
|
462
|
+
this.#config = config;
|
|
463
|
+
this.#baseCtx = withAgentConfig(this.#baseCtx, config);
|
|
464
|
+
this.#ctx = this.#deriveCtx();
|
|
465
|
+
}
|
|
466
|
+
/** Everything about what the agent is currently running on, as one value to carry. */
|
|
467
|
+
#selection() {
|
|
468
|
+
return {
|
|
419
469
|
id: this.id,
|
|
420
470
|
provider: this.#providerId,
|
|
421
471
|
model: this.#model,
|
|
422
472
|
effort: this.#effort,
|
|
423
473
|
serviceTier: this.#serviceTier,
|
|
424
|
-
|
|
425
|
-
|
|
474
|
+
permissionMode: this.#permissionMode,
|
|
475
|
+
};
|
|
426
476
|
}
|
|
427
477
|
/**
|
|
428
478
|
* Whether the agent has anything left to do. This is the only thing about an agent's state
|
|
@@ -463,7 +513,7 @@ export class AgentBase {
|
|
|
463
513
|
return undefined;
|
|
464
514
|
}
|
|
465
515
|
try {
|
|
466
|
-
return await this.#
|
|
516
|
+
return await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
467
517
|
if (!this.#inheritedRead) {
|
|
468
518
|
this.#inheritedRead = true;
|
|
469
519
|
this.#inherited = await agentBasePendingStateOf(lockCtx, this.#persistence);
|
|
@@ -519,19 +569,72 @@ export class AgentBase {
|
|
|
519
569
|
async send(ctx, message, options) {
|
|
520
570
|
await this.#offer(ctx, "send", message, options);
|
|
521
571
|
}
|
|
572
|
+
/**
|
|
573
|
+
* Shallow-merge fields into this agent's immutable metadata. The complete AgentConfig and
|
|
574
|
+
* transactional hook writes commit together; observing hooks run only after that commit.
|
|
575
|
+
*/
|
|
576
|
+
async updateMetadata(ctx, update) {
|
|
577
|
+
const ownedUpdate = ownAgentMetadata(update);
|
|
578
|
+
if (ownedUpdate === undefined)
|
|
579
|
+
throw new Error("The agent metadata is not valid.");
|
|
580
|
+
if (this.#closed)
|
|
581
|
+
throw new Error("The agent has been closed.");
|
|
582
|
+
if (insideTurn.get(ctx).includes(this.id) ||
|
|
583
|
+
this.#insideOwnLoop() ||
|
|
584
|
+
this.#insideOwnPersistenceLock()) {
|
|
585
|
+
throw new Error("Updating metadata from inside this agent's current operation would wait for " +
|
|
586
|
+
"that same operation to finish. Update it after the hook or tool returns.");
|
|
587
|
+
}
|
|
588
|
+
let change;
|
|
589
|
+
let next;
|
|
590
|
+
await this.#runInPersistenceLock(ctx, async (lockCtx) => {
|
|
591
|
+
const previousMetadata = ownAgentMetadata(this.#config.metadata ?? {});
|
|
592
|
+
const metadata = ownAgentMetadata({ ...previousMetadata, ...ownedUpdate });
|
|
593
|
+
if (previousMetadata === undefined || metadata === undefined) {
|
|
594
|
+
throw new Error("The agent metadata is not valid.");
|
|
595
|
+
}
|
|
596
|
+
next = ownAgentConfig({ ...this.#config, metadata });
|
|
597
|
+
change = {
|
|
598
|
+
agentId: this.id,
|
|
599
|
+
previousMetadata,
|
|
600
|
+
update: ownedUpdate,
|
|
601
|
+
metadata,
|
|
602
|
+
};
|
|
603
|
+
await this.#persistence.transaction(lockCtx, async (txCtx) => {
|
|
604
|
+
await this.#persistence.writeValue(txCtx, "agentConfig", next);
|
|
605
|
+
await this.#withTransactionalContext(withAgentConfig(txCtx, next), async (hookCtx) => await this.#hooks.metadataChangedTransact?.(this.#hookContext(hookCtx), change));
|
|
606
|
+
});
|
|
607
|
+
this.#config = next;
|
|
608
|
+
this.#baseCtx = withAgentConfig(this.#baseCtx, next);
|
|
609
|
+
this.#ctx = this.#deriveCtx();
|
|
610
|
+
});
|
|
611
|
+
await this.#invokeHookOn(this.#hookContext(withAgentConfig(ctx, next)), this.#hooks.metadataChanged, change);
|
|
612
|
+
}
|
|
522
613
|
/**
|
|
523
614
|
* Hand one message to a durable queue. The acceptance runs whether or not the caller waits
|
|
524
615
|
* for it — an unwaited failure is still a message that never entered the conversation, and
|
|
525
616
|
* the agent's own close still drains it, so nothing is dropped by not looking.
|
|
526
617
|
*/
|
|
527
618
|
async #offer(ctx, kind, message, options) {
|
|
528
|
-
const { await: wait = false, ...settings } = options ?? {};
|
|
619
|
+
const { await: wait = false, id = createId(), metadata: suppliedMetadata, ...settings } = options ?? {};
|
|
620
|
+
if (!Value.Check(cuid2Schema, id)) {
|
|
621
|
+
throw new Error("The message ID must be a cuid2 identity.");
|
|
622
|
+
}
|
|
623
|
+
const metadata = ownAgentMessageMetadata(suppliedMetadata);
|
|
529
624
|
// Refusing the flag rather than the operation: a closed agent and a re-entrant wait are
|
|
530
625
|
// both caller mistakes, and both are reported before any work is started.
|
|
531
626
|
this.#assertCanWait(ctx, wait, kind === "steering" ? "a steered message" : "a sent message");
|
|
532
627
|
if (this.#closed)
|
|
533
628
|
throw new Error("The agent has been closed.");
|
|
534
|
-
const accepted = this.#enqueue(ctx, [
|
|
629
|
+
const accepted = this.#enqueue(ctx, [
|
|
630
|
+
{
|
|
631
|
+
kind,
|
|
632
|
+
id,
|
|
633
|
+
message: structuredClone(message),
|
|
634
|
+
...(metadata === undefined ? {} : { metadata }),
|
|
635
|
+
options: settings,
|
|
636
|
+
},
|
|
637
|
+
]);
|
|
535
638
|
if (wait)
|
|
536
639
|
return accepted;
|
|
537
640
|
accepted.catch(() => undefined);
|
|
@@ -559,6 +662,19 @@ export class AgentBase {
|
|
|
559
662
|
#insideOwnLoop() {
|
|
560
663
|
return insideLoops.getStore()?.includes(this.id) === true;
|
|
561
664
|
}
|
|
665
|
+
/** Whether this call chain already holds this agent's persistence lock. */
|
|
666
|
+
#insideOwnPersistenceLock() {
|
|
667
|
+
return insidePersistenceLocks.getStore()?.includes(this.id) === true;
|
|
668
|
+
}
|
|
669
|
+
/** Hold the persistence lock while marking it independently of the caller's Context. */
|
|
670
|
+
async #runInPersistenceLock(ctx, work) {
|
|
671
|
+
return await this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
|
|
672
|
+
const held = insidePersistenceLocks.getStore() ?? [];
|
|
673
|
+
return await insidePersistenceLocks.run([...held, this.id], async () => {
|
|
674
|
+
return await work(lockCtx);
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
}
|
|
562
678
|
/**
|
|
563
679
|
* Accept a batch of messages as one durable step. Every message is written under the same
|
|
564
680
|
* hold of the persistence lock and inside one transaction, so a caller arriving while a
|
|
@@ -572,17 +688,25 @@ export class AgentBase {
|
|
|
572
688
|
throw new Error("The agent has been closed.");
|
|
573
689
|
// Admitted: from here on the messages are the agent's responsibility, and a close that
|
|
574
690
|
// begins now waits for them rather than resolving over the top of them.
|
|
575
|
-
const admitted = this.#
|
|
691
|
+
const admitted = this.#runInPersistenceLock(ctx, async (lockCtx) => {
|
|
576
692
|
const accepted = [];
|
|
577
693
|
await this.#persistence.transaction(lockCtx, async (txCtx) => {
|
|
578
694
|
for (const request of batch) {
|
|
695
|
+
const identityKey = `message.${request.id}`;
|
|
696
|
+
if (!(await this.#persistence.writeValueIfAbsent(txCtx, identityKey, true))) {
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
579
699
|
const key = await this.#queueKey(txCtx, `${request.kind}.`);
|
|
580
700
|
await this.#persistence.writeValue(txCtx, key, {
|
|
701
|
+
id: request.id,
|
|
581
702
|
message: request.message,
|
|
703
|
+
...(request.metadata === undefined ? {} : { metadata: request.metadata }),
|
|
582
704
|
options: request.options,
|
|
583
705
|
});
|
|
584
706
|
accepted.push({ key, request });
|
|
585
707
|
}
|
|
708
|
+
if (accepted.length === 0)
|
|
709
|
+
return;
|
|
586
710
|
// Accepting a message is what makes the work owed: the same transaction that
|
|
587
711
|
// admits it records that the agent owes an answer, so a process that dies right
|
|
588
712
|
// here is discovered still owing it rather than looking idle over a full queue.
|
|
@@ -593,8 +717,16 @@ export class AgentBase {
|
|
|
593
717
|
// one replaces the queue arrays wholesale, and a reference taken before the wait
|
|
594
718
|
// would push the message into an array nobody reads again.
|
|
595
719
|
const queue = request.kind === "steering" ? this.#steering : this.#sends;
|
|
596
|
-
queue.push({
|
|
720
|
+
queue.push({
|
|
721
|
+
key,
|
|
722
|
+
id: request.id,
|
|
723
|
+
message: request.message,
|
|
724
|
+
...(request.metadata === undefined ? {} : { metadata: request.metadata }),
|
|
725
|
+
options: request.options,
|
|
726
|
+
});
|
|
597
727
|
}
|
|
728
|
+
if (accepted.length === 0)
|
|
729
|
+
return;
|
|
598
730
|
this.#turnRequested = true;
|
|
599
731
|
this.#startRun();
|
|
600
732
|
});
|
|
@@ -936,7 +1068,7 @@ export class AgentBase {
|
|
|
936
1068
|
// durable exactly as it was for the next attempt.
|
|
937
1069
|
const loadFailure = await this.#ensureLoaded().then(() => undefined, (error) => error);
|
|
938
1070
|
if (loadFailure !== undefined) {
|
|
939
|
-
this.#emit({
|
|
1071
|
+
await this.#emit({
|
|
940
1072
|
type: "done",
|
|
941
1073
|
state: "error",
|
|
942
1074
|
kind: "internal_error",
|
|
@@ -985,7 +1117,7 @@ export class AgentBase {
|
|
|
985
1117
|
*/
|
|
986
1118
|
async #settleDurably() {
|
|
987
1119
|
try {
|
|
988
|
-
await this.#
|
|
1120
|
+
await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
989
1121
|
await this.#clearPending(txCtx);
|
|
990
1122
|
await this.#invokeTransactionalSettle(txCtx);
|
|
991
1123
|
// The run store is erased last, so a settling hook can still read what the
|
|
@@ -1138,9 +1270,21 @@ export class AgentBase {
|
|
|
1138
1270
|
this.#ensureCompaction().catch(() => undefined);
|
|
1139
1271
|
continue;
|
|
1140
1272
|
}
|
|
1273
|
+
const id = action.id ?? createId();
|
|
1274
|
+
if (!Value.Check(cuid2Schema, id))
|
|
1275
|
+
continue;
|
|
1276
|
+
let metadata;
|
|
1277
|
+
try {
|
|
1278
|
+
metadata = ownAgentMessageMetadata(action.metadata);
|
|
1279
|
+
}
|
|
1280
|
+
catch {
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1141
1283
|
batch.push({
|
|
1142
1284
|
kind: action.type === "steer" ? "steering" : "send",
|
|
1143
|
-
|
|
1285
|
+
id,
|
|
1286
|
+
message: structuredClone(action.message),
|
|
1287
|
+
...(metadata === undefined ? {} : { metadata }),
|
|
1144
1288
|
options: {},
|
|
1145
1289
|
});
|
|
1146
1290
|
}
|
|
@@ -1187,7 +1331,8 @@ export class AgentBase {
|
|
|
1187
1331
|
let needsInference = resumed.length > 0;
|
|
1188
1332
|
if (!this.#recoveryChecked) {
|
|
1189
1333
|
this.#recoveryChecked = true;
|
|
1190
|
-
|
|
1334
|
+
if (await this.#resumesInterruptedRun())
|
|
1335
|
+
needsInference = true;
|
|
1191
1336
|
}
|
|
1192
1337
|
// Each cycle first drains the queues, then runs one inference. Steering injects at
|
|
1193
1338
|
// every stop between responses and always outranks sends; sent messages inject
|
|
@@ -1206,12 +1351,12 @@ export class AgentBase {
|
|
|
1206
1351
|
if (abort.signal.aborted) {
|
|
1207
1352
|
const hasPendingWork = needsInference || this.#steering.length > 0 || this.#sends.length > 0;
|
|
1208
1353
|
if (hasPendingWork)
|
|
1209
|
-
this.#emit({ type: "done", state: "cancelled" });
|
|
1354
|
+
await this.#emit({ type: "done", state: "cancelled" });
|
|
1210
1355
|
break;
|
|
1211
1356
|
}
|
|
1212
|
-
let injected = await this.#consumeQueue(this.#steering, this.#steeringMode, "steering
|
|
1357
|
+
let injected = await this.#consumeQueue(this.#steering, this.#steeringMode, "steering");
|
|
1213
1358
|
if (!injected && !needsInference) {
|
|
1214
|
-
injected = await this.#consumeQueue(this.#sends, this.#sendMode, "send
|
|
1359
|
+
injected = await this.#consumeQueue(this.#sends, this.#sendMode, "send");
|
|
1215
1360
|
}
|
|
1216
1361
|
// Nothing to answer — a start() on an idle history, or the queues ran dry.
|
|
1217
1362
|
if (!injected && !needsInference)
|
|
@@ -1297,7 +1442,7 @@ export class AgentBase {
|
|
|
1297
1442
|
}
|
|
1298
1443
|
}
|
|
1299
1444
|
catch (error) {
|
|
1300
|
-
this.#emit({
|
|
1445
|
+
await this.#emit({
|
|
1301
1446
|
type: "done",
|
|
1302
1447
|
state: "error",
|
|
1303
1448
|
kind: "internal_error",
|
|
@@ -1324,12 +1469,13 @@ export class AgentBase {
|
|
|
1324
1469
|
* beginning of a block that will now never arrive is told to drop it. Only finished blocks
|
|
1325
1470
|
* are persisted, so the conversation is intact and it is the view being corrected.
|
|
1326
1471
|
*/
|
|
1327
|
-
#resumesInterruptedRun() {
|
|
1472
|
+
async #resumesInterruptedRun() {
|
|
1328
1473
|
const owed = this.#lastRecordType === "user" ||
|
|
1329
1474
|
this.#lastRecordType === "tool" ||
|
|
1330
1475
|
this.#lastRecordType === "system";
|
|
1331
|
-
if (owed && this.#inherited?.stage === "inference")
|
|
1332
|
-
this.#emit({ type: "block_reset" });
|
|
1476
|
+
if (owed && this.#inherited?.stage === "inference") {
|
|
1477
|
+
await this.#emit({ type: "block_reset" });
|
|
1478
|
+
}
|
|
1333
1479
|
return owed;
|
|
1334
1480
|
}
|
|
1335
1481
|
/** Load the durable state once. A failed load is not sticky: the next turn retries it. */
|
|
@@ -1349,7 +1495,7 @@ export class AgentBase {
|
|
|
1349
1495
|
const previousTokens = this.#contextTokens;
|
|
1350
1496
|
this.#contextTokens = tokens;
|
|
1351
1497
|
try {
|
|
1352
|
-
await this.#
|
|
1498
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1353
1499
|
const write = (writeCtx) => tokens === undefined
|
|
1354
1500
|
? this.#persistence.deleteValue(writeCtx, "context")
|
|
1355
1501
|
: this.#persistence.writeValue(writeCtx, "context", { tokens });
|
|
@@ -1400,10 +1546,11 @@ export class AgentBase {
|
|
|
1400
1546
|
throw new Error(result.message);
|
|
1401
1547
|
}
|
|
1402
1548
|
if (result.status === "completed") {
|
|
1403
|
-
await this.#
|
|
1549
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1404
1550
|
// Physically delete the superseded records and write the replacement —
|
|
1405
1551
|
// which keeps the messages that stay — in one atomic step.
|
|
1406
1552
|
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1553
|
+
await this.#deleteMessageIdentities(txCtx, await this.#persistence.load(txCtx));
|
|
1407
1554
|
await this.#persistence.clearRecords(txCtx);
|
|
1408
1555
|
await this.#persistence.append(txCtx, {
|
|
1409
1556
|
type: "compaction",
|
|
@@ -1443,7 +1590,7 @@ export class AgentBase {
|
|
|
1443
1590
|
return true;
|
|
1444
1591
|
let settled = false;
|
|
1445
1592
|
try {
|
|
1446
|
-
await this.#
|
|
1593
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1447
1594
|
// A call the durable batch still holds belongs to the resume, which answers it
|
|
1448
1595
|
// properly — and re-executes it when the tool is durable. Settling it here as
|
|
1449
1596
|
// well would give the conversation two results for one call.
|
|
@@ -1457,6 +1604,10 @@ export class AgentBase {
|
|
|
1457
1604
|
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1458
1605
|
for (const result of results) {
|
|
1459
1606
|
await this.#appendRecord(txCtx, { type: "tool", message: result });
|
|
1607
|
+
// A result the conversation records is a result the hook sees, however
|
|
1608
|
+
// little of a run produced it. A hook that fails here leaves the calls
|
|
1609
|
+
// unsettled, which is what lets a later attempt answer them properly.
|
|
1610
|
+
await this.#invokeToolTransactHook(txCtx, result.callId, this.#hooks.afterToolCallTransact, result);
|
|
1460
1611
|
}
|
|
1461
1612
|
});
|
|
1462
1613
|
this.#messages.push(...results);
|
|
@@ -1475,6 +1626,14 @@ export class AgentBase {
|
|
|
1475
1626
|
async #appendRecord(ctx, record) {
|
|
1476
1627
|
await this.#persistence.append(ctx, record);
|
|
1477
1628
|
}
|
|
1629
|
+
/** Remove deduplication identities for user records a history replacement is deleting. */
|
|
1630
|
+
async #deleteMessageIdentities(ctx, records) {
|
|
1631
|
+
for (const record of records) {
|
|
1632
|
+
if (record.type === "user") {
|
|
1633
|
+
await this.#persistence.deleteValue(ctx, `message.${record.id}`);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1478
1637
|
/**
|
|
1479
1638
|
* A transaction whose pending-state cache unwinds with it.
|
|
1480
1639
|
*/
|
|
@@ -1508,7 +1667,7 @@ export class AgentBase {
|
|
|
1508
1667
|
content: [{ type: "text", text: `The last turn failed: ${message}` }],
|
|
1509
1668
|
};
|
|
1510
1669
|
try {
|
|
1511
|
-
await this.#
|
|
1670
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1512
1671
|
await this.#appendRecord(lockCtx, { type: "system", message: failure });
|
|
1513
1672
|
this.#messages.push(failure);
|
|
1514
1673
|
});
|
|
@@ -1521,9 +1680,17 @@ export class AgentBase {
|
|
|
1521
1680
|
* Move the oldest queued message — or, in "all" mode, every queued message — into the main
|
|
1522
1681
|
* context store and the in-memory history. The moves run in one transaction, so a message
|
|
1523
1682
|
* is never durable in both stores or neither, and memory changes only after the commit.
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1683
|
+
*
|
|
1684
|
+
* What the consumption has to announce is announced once the lock has been released. A hook
|
|
1685
|
+
* told a message has landed may perfectly well answer by sending another one, and doing that
|
|
1686
|
+
* while this still held the store lock would be the hook waiting for its own caller.
|
|
1687
|
+
*/
|
|
1688
|
+
async #consumeQueue(queue, mode, kind) {
|
|
1689
|
+
const prefix = `${kind}.`;
|
|
1690
|
+
/** Filled in once the consumption has committed, and reported after the lock is released. */
|
|
1691
|
+
const accepted = [];
|
|
1692
|
+
let permissionChange;
|
|
1693
|
+
const consumed = await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1527
1694
|
if (queue.length === 0)
|
|
1528
1695
|
return false;
|
|
1529
1696
|
// The durable queue, not memory, decides what is left to consume after a restart.
|
|
@@ -1542,6 +1709,7 @@ export class AgentBase {
|
|
|
1542
1709
|
let model = this.#model;
|
|
1543
1710
|
let effort = this.#effort;
|
|
1544
1711
|
let serviceTier = this.#serviceTier;
|
|
1712
|
+
let permissionMode = this.#permissionMode;
|
|
1545
1713
|
let changed = false;
|
|
1546
1714
|
for (const entry of batch) {
|
|
1547
1715
|
if (entry.options.provider !== undefined) {
|
|
@@ -1560,7 +1728,17 @@ export class AgentBase {
|
|
|
1560
1728
|
serviceTier = entry.options.serviceTier;
|
|
1561
1729
|
changed = true;
|
|
1562
1730
|
}
|
|
1731
|
+
if (entry.options.permissionMode !== undefined) {
|
|
1732
|
+
permissionMode = entry.options.permissionMode;
|
|
1733
|
+
changed = true;
|
|
1734
|
+
}
|
|
1563
1735
|
}
|
|
1736
|
+
// 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 feature concludes
|
|
1738
|
+
// from it commits with the message that carried it.
|
|
1739
|
+
const modeChange = permissionMode === this.#permissionMode
|
|
1740
|
+
? undefined
|
|
1741
|
+
: { previousMode: this.#permissionMode, mode: permissionMode };
|
|
1564
1742
|
// A provider or model change is checked against the provider-model compatibility
|
|
1565
1743
|
// matrix. An incompatible change resets the conversation: the history is erased
|
|
1566
1744
|
// completely, the old provider session is destroyed, and the `modelChanged` hook
|
|
@@ -1610,6 +1788,7 @@ export class AgentBase {
|
|
|
1610
1788
|
model,
|
|
1611
1789
|
effort,
|
|
1612
1790
|
serviceTier,
|
|
1791
|
+
permissionMode,
|
|
1613
1792
|
}), committed.signal);
|
|
1614
1793
|
const changeCtx = withAgentRunKV(withAgentKV(changeLifetime, this.#kv), this.#runKV);
|
|
1615
1794
|
try {
|
|
@@ -1648,6 +1827,7 @@ export class AgentBase {
|
|
|
1648
1827
|
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
1649
1828
|
}
|
|
1650
1829
|
if (reset) {
|
|
1830
|
+
await this.#deleteMessageIdentities(txCtx, await this.#persistence.load(txCtx));
|
|
1651
1831
|
await this.#persistence.clearRecords(txCtx);
|
|
1652
1832
|
// The erased conversation is what the measurement described.
|
|
1653
1833
|
await this.#persistence.deleteValue(txCtx, "context");
|
|
@@ -1661,7 +1841,9 @@ export class AgentBase {
|
|
|
1661
1841
|
for (const entry of batch) {
|
|
1662
1842
|
await this.#appendRecord(txCtx, {
|
|
1663
1843
|
type: "user",
|
|
1844
|
+
id: entry.id,
|
|
1664
1845
|
message: entry.message,
|
|
1846
|
+
...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
|
|
1665
1847
|
});
|
|
1666
1848
|
}
|
|
1667
1849
|
if (changed) {
|
|
@@ -1670,13 +1852,38 @@ export class AgentBase {
|
|
|
1670
1852
|
...(model === undefined ? {} : { model }),
|
|
1671
1853
|
...(effort === undefined ? {} : { effort }),
|
|
1672
1854
|
...(serviceTier === undefined ? {} : { serviceTier }),
|
|
1855
|
+
permissionMode,
|
|
1673
1856
|
});
|
|
1674
1857
|
}
|
|
1675
1858
|
// Consuming a message is precisely the act that makes an inference owed, so
|
|
1676
1859
|
// the two commit as one. A crash cannot land between them and leave a
|
|
1677
1860
|
// message in the conversation that nothing remembers having to answer.
|
|
1678
1861
|
await this.#recordPending(txCtx, { stage: "inference" });
|
|
1862
|
+
// Last, so a hook writing its own account of the consumption sees a transaction
|
|
1863
|
+
// holding all of it. The mode comes before the messages: it is what they were
|
|
1864
|
+
// said under, and a listener recording them wants to know that first.
|
|
1865
|
+
const selection = { provider, model, effort, serviceTier, permissionMode };
|
|
1866
|
+
if (modeChange !== undefined) {
|
|
1867
|
+
await this.#invokeTransactHook(txCtx, selection, this.#hooks.permissionModeChangedTransact, modeChange);
|
|
1868
|
+
}
|
|
1869
|
+
for (const entry of batch) {
|
|
1870
|
+
await this.#invokeTransactHook(txCtx, selection, this.#hooks.messageAcceptedTransact, {
|
|
1871
|
+
id: entry.id,
|
|
1872
|
+
kind,
|
|
1873
|
+
message: entry.message,
|
|
1874
|
+
...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1679
1877
|
});
|
|
1878
|
+
// Committed: from here the messages are part of the conversation, so what has to be
|
|
1879
|
+
// announced about them is decided now and reported once the lock is released.
|
|
1880
|
+
permissionChange = modeChange;
|
|
1881
|
+
accepted.push(...batch.map((entry) => ({
|
|
1882
|
+
id: entry.id,
|
|
1883
|
+
kind,
|
|
1884
|
+
message: entry.message,
|
|
1885
|
+
...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
|
|
1886
|
+
})));
|
|
1680
1887
|
queue.splice(0, count);
|
|
1681
1888
|
if (reset) {
|
|
1682
1889
|
this.#messages = injected === undefined ? [] : [injected];
|
|
@@ -1696,10 +1903,31 @@ export class AgentBase {
|
|
|
1696
1903
|
this.#model = model;
|
|
1697
1904
|
this.#effort = effort;
|
|
1698
1905
|
this.#serviceTier = serviceTier;
|
|
1906
|
+
this.#permissionMode = permissionMode;
|
|
1699
1907
|
this.#ctx = this.#deriveCtx();
|
|
1700
1908
|
}
|
|
1701
1909
|
return true;
|
|
1702
1910
|
});
|
|
1911
|
+
// Outside the lock, and on the agent's own context, which now carries whatever these
|
|
1912
|
+
// messages made effective.
|
|
1913
|
+
if (permissionChange !== undefined) {
|
|
1914
|
+
await this.#invokeHook(this.#hooks.permissionModeChanged, permissionChange);
|
|
1915
|
+
}
|
|
1916
|
+
for (const message of accepted) {
|
|
1917
|
+
await this.#invokeHook(this.#hooks.messageAccepted, message);
|
|
1918
|
+
}
|
|
1919
|
+
return consumed;
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Call a hook that writes inside the consumption's transaction, on a context carrying the
|
|
1923
|
+
* selection those messages made effective rather than the one they replaced. Its failure is
|
|
1924
|
+
* not contained: it rolls the whole consumption back, leaving the messages queued.
|
|
1925
|
+
*/
|
|
1926
|
+
async #invokeTransactHook(txCtx, selection, hook, argument) {
|
|
1927
|
+
if (hook === undefined)
|
|
1928
|
+
return;
|
|
1929
|
+
const hookCtx = withAgentContext(txCtx, { id: this.id, ...selection });
|
|
1930
|
+
await this.#withTransactionalContext(hookCtx, (liveCtx) => hook(liveCtx, argument));
|
|
1703
1931
|
}
|
|
1704
1932
|
/**
|
|
1705
1933
|
* Replace the in-memory state with the durable one. The persistence lock guarantees every
|
|
@@ -1708,7 +1936,7 @@ export class AgentBase {
|
|
|
1708
1936
|
* not-yet-consumed queues. Consecutive block records reassemble into one assistant message.
|
|
1709
1937
|
*/
|
|
1710
1938
|
async #loadHistory() {
|
|
1711
|
-
await this.#
|
|
1939
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1712
1940
|
const records = await this.#persistence.load(lockCtx);
|
|
1713
1941
|
const last = records[records.length - 1];
|
|
1714
1942
|
this.#lastRecordType = last?.type;
|
|
@@ -1725,7 +1953,17 @@ export class AgentBase {
|
|
|
1725
1953
|
this.#contextTokens = measured?.tokens;
|
|
1726
1954
|
const entry = (key, value) => {
|
|
1727
1955
|
const envelope = value;
|
|
1728
|
-
|
|
1956
|
+
if (!Value.Check(cuid2Schema, envelope.id)) {
|
|
1957
|
+
throw new Error(`The queued message under "${key}" has an invalid ID.`);
|
|
1958
|
+
}
|
|
1959
|
+
const metadata = ownAgentMessageMetadata(envelope.metadata);
|
|
1960
|
+
return {
|
|
1961
|
+
key,
|
|
1962
|
+
id: envelope.id,
|
|
1963
|
+
message: envelope.message,
|
|
1964
|
+
...(metadata === undefined ? {} : { metadata }),
|
|
1965
|
+
options: envelope.options ?? {},
|
|
1966
|
+
};
|
|
1729
1967
|
};
|
|
1730
1968
|
this.#steering = steering.map(({ key, value }) => entry(key, value));
|
|
1731
1969
|
this.#sends = sends.map(({ key, value }) => entry(key, value));
|
|
@@ -1738,6 +1976,13 @@ export class AgentBase {
|
|
|
1738
1976
|
this.#model = persisted.model;
|
|
1739
1977
|
this.#effort = persisted.effort;
|
|
1740
1978
|
this.#serviceTier = persisted.serviceTier;
|
|
1979
|
+
// The permission mode is the one setting whose absence is not a decision: a record
|
|
1980
|
+
// written before any message carried a mode says nothing about it, and a value
|
|
1981
|
+
// that is not a mode at all says nothing either. Both keep the mode the agent was
|
|
1982
|
+
// built with rather than running under something nothing can interpret.
|
|
1983
|
+
if (isAgentPermissionMode(persisted.permissionMode)) {
|
|
1984
|
+
this.#permissionMode = persisted.permissionMode;
|
|
1985
|
+
}
|
|
1741
1986
|
this.#ctx = this.#deriveCtx();
|
|
1742
1987
|
}
|
|
1743
1988
|
this.#pendingTools = pendingTools.map(({ key, value }) => ({
|
|
@@ -1774,7 +2019,7 @@ export class AgentBase {
|
|
|
1774
2019
|
*/
|
|
1775
2020
|
async #runToolBatch(entries, resume, signal, abortPromise) {
|
|
1776
2021
|
if (!resume) {
|
|
1777
|
-
await this.#
|
|
2022
|
+
await this.#runInPersistenceLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1778
2023
|
for (const entry of entries) {
|
|
1779
2024
|
await this.#persistence.writeValue(txCtx, entry.key, entry.call);
|
|
1780
2025
|
}
|
|
@@ -1782,6 +2027,11 @@ export class AgentBase {
|
|
|
1782
2027
|
// then never find calls owed with no record of a run owing them, nor a run
|
|
1783
2028
|
// recorded as running tools that were never written.
|
|
1784
2029
|
await this.#recordPending(txCtx, { stage: "tools" });
|
|
2030
|
+
// Last, so a hook noting a call about to happen sees a transaction holding
|
|
2031
|
+
// the whole batch it belongs to.
|
|
2032
|
+
for (const entry of entries) {
|
|
2033
|
+
await this.#invokeToolTransactHook(txCtx, entry.call.callId, this.#hooks.beforeToolCallTransact, entry.call);
|
|
2034
|
+
}
|
|
1785
2035
|
}));
|
|
1786
2036
|
}
|
|
1787
2037
|
else {
|
|
@@ -1801,7 +2051,7 @@ export class AgentBase {
|
|
|
1801
2051
|
if (commitFailed)
|
|
1802
2052
|
return;
|
|
1803
2053
|
try {
|
|
1804
|
-
await this.#
|
|
2054
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
1805
2055
|
while (committed < entries.length) {
|
|
1806
2056
|
const entry = entries[committed];
|
|
1807
2057
|
const result = results[committed];
|
|
@@ -1817,6 +2067,7 @@ export class AgentBase {
|
|
|
1817
2067
|
// stays: that is the tool's state, not the batch's bookkeeping, and
|
|
1818
2068
|
// an owner may still want to read what a finished call recorded.
|
|
1819
2069
|
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
2070
|
+
await this.#invokeToolTransactHook(txCtx, entry.call.callId, this.#hooks.afterToolCallTransact, result);
|
|
1820
2071
|
});
|
|
1821
2072
|
this.#messages.push(result);
|
|
1822
2073
|
committed += 1;
|
|
@@ -1913,6 +2164,30 @@ export class AgentBase {
|
|
|
1913
2164
|
#toolKey(index, callId) {
|
|
1914
2165
|
return `tool.${String(index).padStart(6, "0")}.${callId}`;
|
|
1915
2166
|
}
|
|
2167
|
+
/**
|
|
2168
|
+
* The scope one call owns: state persists under its own call ID, never in another call's
|
|
2169
|
+
* scope, and the task context ends where the call was made. The execution and all four tool
|
|
2170
|
+
* hooks share it, so what one of them writes about a call is where the others look for it.
|
|
2171
|
+
*/
|
|
2172
|
+
#callScoped(ctx, callId) {
|
|
2173
|
+
return withAgentTaskContext(withAgentRunKV(withAgentKV(ctx, this.#kv.scoped("call", callId)), this.#runKV.scoped("call", callId)), taskContextBeforeToolCall(this.#messages, callId));
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Call a tool hook that writes inside a transaction of its own call's. The lifetime ends with
|
|
2177
|
+
* the callback, so a context kept afterwards cannot outlive the transaction it belongs to.
|
|
2178
|
+
* Its failure is not contained: it rolls that transaction back.
|
|
2179
|
+
*/
|
|
2180
|
+
async #invokeToolTransactHook(txCtx, callId, hook, argument) {
|
|
2181
|
+
if (hook === undefined)
|
|
2182
|
+
return;
|
|
2183
|
+
const lifetime = new AbortController();
|
|
2184
|
+
try {
|
|
2185
|
+
await hook(this.#callScoped(withLifetime(txCtx, lifetime.signal), callId), argument);
|
|
2186
|
+
}
|
|
2187
|
+
finally {
|
|
2188
|
+
lifetime.abort();
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
1916
2191
|
/**
|
|
1917
2192
|
* Run one tool call; every failure becomes an error tool result instead of an exception.
|
|
1918
2193
|
* The context carries the turn's abort signal as its lifetime, so a running tool can
|
|
@@ -1942,34 +2217,77 @@ export class AgentBase {
|
|
|
1942
2217
|
if (tool.parameters !== undefined && !Value.Check(tool.parameters, args)) {
|
|
1943
2218
|
return failure(`The arguments for "${call.name}" did not match its schema.`);
|
|
1944
2219
|
}
|
|
2220
|
+
const callCtx = this.#callScoped(ctx, call.callId);
|
|
2221
|
+
// From here the call is one the two tool hooks bracket: a tool that exists, a call that
|
|
2222
|
+
// finished, and arguments its schema accepts. A call refused before that reaches neither
|
|
2223
|
+
// hook, because there is nothing yet to decide about or to report.
|
|
2224
|
+
let ran = tool;
|
|
2225
|
+
let ranArguments = args;
|
|
2226
|
+
let outcome;
|
|
1945
2227
|
try {
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
2228
|
+
const decision = await this.#hooks.beforeToolCall?.(callCtx, {
|
|
2229
|
+
callId: call.callId,
|
|
2230
|
+
tool,
|
|
2231
|
+
arguments: args,
|
|
2232
|
+
});
|
|
2233
|
+
if (decision?.type === "answer") {
|
|
2234
|
+
// The hook answered the model itself, so the tool never runs and there is no
|
|
2235
|
+
// structured result — only what the model is told.
|
|
2236
|
+
outcome = {
|
|
1953
2237
|
callId: call.callId,
|
|
1954
2238
|
tool,
|
|
1955
2239
|
arguments: args,
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
2240
|
+
content: [...decision.content],
|
|
2241
|
+
isError: decision.isError === true,
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
else {
|
|
2245
|
+
if (decision?.tool !== undefined)
|
|
2246
|
+
ran = decision.tool;
|
|
2247
|
+
if (decision?.arguments !== undefined)
|
|
2248
|
+
ranArguments = decision.arguments;
|
|
2249
|
+
// An amended call is validated again: the schema that mattered is the one belonging
|
|
2250
|
+
// to the tool that is about to run, on the arguments it is about to receive.
|
|
2251
|
+
if ((ran !== tool || ranArguments !== args) &&
|
|
2252
|
+
ran.parameters !== undefined &&
|
|
2253
|
+
!Value.Check(ran.parameters, ranArguments)) {
|
|
2254
|
+
throw new Error(`The arguments for "${ran.name}" did not match its schema.`);
|
|
2255
|
+
}
|
|
2256
|
+
const runCtx = decision?.permissionMode === undefined
|
|
2257
|
+
? callCtx
|
|
2258
|
+
: withAgentPermissionMode(callCtx, decision.permissionMode);
|
|
2259
|
+
const result = await ran.execute(runCtx, ranArguments);
|
|
2260
|
+
if (!Value.Check(ran.returnType, result)) {
|
|
2261
|
+
throw new Error(`Tool "${ran.name}" returned an invalid result.`);
|
|
2262
|
+
}
|
|
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
|
+
};
|
|
1960
2271
|
}
|
|
1961
|
-
const content = tool.toLLM(result);
|
|
1962
|
-
const isError = tool.isError?.(result) === true;
|
|
1963
|
-
return {
|
|
1964
|
-
role: "tool",
|
|
1965
|
-
callId: call.callId,
|
|
1966
|
-
content: [...content],
|
|
1967
|
-
...(isError ? { isError: true } : {}),
|
|
1968
|
-
};
|
|
1969
2272
|
}
|
|
1970
2273
|
catch (error) {
|
|
1971
|
-
|
|
2274
|
+
outcome = {
|
|
2275
|
+
callId: call.callId,
|
|
2276
|
+
tool: ran,
|
|
2277
|
+
arguments: ranArguments,
|
|
2278
|
+
content: [
|
|
2279
|
+
{ type: "text", text: error instanceof Error ? error.message : String(error) },
|
|
2280
|
+
],
|
|
2281
|
+
isError: true,
|
|
2282
|
+
};
|
|
1972
2283
|
}
|
|
2284
|
+
await this.#invokeHookOn(callCtx, this.#hooks.afterToolCall, outcome);
|
|
2285
|
+
return {
|
|
2286
|
+
role: "tool",
|
|
2287
|
+
callId: call.callId,
|
|
2288
|
+
content: outcome.content,
|
|
2289
|
+
...(outcome.isError ? { isError: true } : {}),
|
|
2290
|
+
};
|
|
1973
2291
|
}
|
|
1974
2292
|
/**
|
|
1975
2293
|
* A key that sorts after every entry the queue already holds. The order comes from the store
|
|
@@ -2005,7 +2323,7 @@ export class AgentBase {
|
|
|
2005
2323
|
const persist = async (event) => {
|
|
2006
2324
|
if (event === undefined)
|
|
2007
2325
|
return;
|
|
2008
|
-
await this.#
|
|
2326
|
+
await this.#runInPersistenceLock(this.#ctx, async (lockCtx) => {
|
|
2009
2327
|
if (this.#hooks.onEventTransact === undefined) {
|
|
2010
2328
|
await this.#appendRecord(lockCtx, { type: "block", block: event.block });
|
|
2011
2329
|
}
|
|
@@ -2030,7 +2348,7 @@ export class AgentBase {
|
|
|
2030
2348
|
const next = await Promise.race([iterator.next(), abortPromise]);
|
|
2031
2349
|
if (next === ABORTED) {
|
|
2032
2350
|
// Drop the unfinished block and end the turn.
|
|
2033
|
-
this.#emit({ type: "done", state: "cancelled" });
|
|
2351
|
+
await this.#emit({ type: "done", state: "cancelled" });
|
|
2034
2352
|
return { content: persisted, state: "cancelled" };
|
|
2035
2353
|
}
|
|
2036
2354
|
if (next.done === true) {
|
|
@@ -2038,7 +2356,7 @@ export class AgentBase {
|
|
|
2038
2356
|
break;
|
|
2039
2357
|
}
|
|
2040
2358
|
const event = next.value;
|
|
2041
|
-
this.#emit(event);
|
|
2359
|
+
await this.#emit(event);
|
|
2042
2360
|
switch (event.type) {
|
|
2043
2361
|
case "text_start":
|
|
2044
2362
|
content.push({ type: "text", text: "" });
|
|
@@ -2189,9 +2507,9 @@ export class AgentBase {
|
|
|
2189
2507
|
return this.#session;
|
|
2190
2508
|
}
|
|
2191
2509
|
/** Report one stream event to the hooks. Hooks observe the stream; they never fail a run. */
|
|
2192
|
-
#emit(event) {
|
|
2510
|
+
async #emit(event) {
|
|
2193
2511
|
try {
|
|
2194
|
-
this.#hooks.onEvent?.(this.#ctx, event);
|
|
2512
|
+
await this.#hooks.onEvent?.(this.#ctx, event);
|
|
2195
2513
|
}
|
|
2196
2514
|
catch {
|
|
2197
2515
|
// Hooks observe the stream; they never fail a run.
|