@slopus/happy-agent-base 0.0.34 → 0.0.36
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 +13 -3
- package/dist/Agent.d.ts +1 -1
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +1 -1
- package/dist/Agent.js.map +1 -1
- package/dist/AgentBase.d.ts +4 -3
- package/dist/AgentBase.d.ts.map +1 -1
- package/dist/AgentBase.js +278 -65
- package/dist/AgentBase.js.map +1 -1
- package/dist/AgentBaseHooks.d.ts +13 -14
- package/dist/AgentBaseHooks.d.ts.map +1 -1
- package/dist/AgentModule.d.ts +4 -4
- package/dist/AgentModule.d.ts.map +1 -1
- package/dist/AgentProviderContext.d.ts.map +1 -1
- package/dist/AgentProviderContext.js +5 -1
- package/dist/AgentProviderContext.js.map +1 -1
- package/dist/AgentSystem.d.ts +1 -1
- package/dist/AgentSystemLocal.d.ts +4 -3
- package/dist/AgentSystemLocal.d.ts.map +1 -1
- package/dist/AgentSystemLocal.js +4 -3
- package/dist/AgentSystemLocal.js.map +1 -1
- package/dist/AgentTool.d.ts +32 -0
- package/dist/AgentTool.d.ts.map +1 -1
- package/dist/AgentTool.js.map +1 -1
- package/package.json +2 -2
package/dist/AgentBase.js
CHANGED
|
@@ -18,6 +18,10 @@ import { agentToolArgumentsError } from "./AgentToolArgumentsError.js";
|
|
|
18
18
|
import { setAgentSpanAttributes } from "./AgentSpanAttributes.js";
|
|
19
19
|
/** Race winner when an abort interrupts a wait on the stream or a running tool. */
|
|
20
20
|
const ABORTED = Symbol("aborted");
|
|
21
|
+
/** Race winner when drain or shutdown stops waiting for a steerable tool execution. */
|
|
22
|
+
const STEERABLE_STOPPED = Symbol("steerable-stopped");
|
|
23
|
+
/** Race winner when drain or shutdown leaves a reloadable call for the next process. */
|
|
24
|
+
const RELOADABLE_STOPPED = Symbol("reloadable-stopped");
|
|
21
25
|
/** Marks a provider context measurement that inference preparation has not evaluated yet. */
|
|
22
26
|
const UNPREPARED_CONTEXT = Symbol("unprepared context");
|
|
23
27
|
/**
|
|
@@ -122,7 +126,7 @@ const storedToolResultSchema = Type.Object({
|
|
|
122
126
|
* results, settle their own calls with error results before appending anything behind them.
|
|
123
127
|
* - A conversation loaded with a call stranded under later messages is repaired atomically at
|
|
124
128
|
* load, since the answer belongs beside its call rather than at the end.
|
|
125
|
-
* - A
|
|
129
|
+
* - A tool that is neither durable nor reloadable never runs twice; either retry-safe flag may.
|
|
126
130
|
*
|
|
127
131
|
* ## Compaction
|
|
128
132
|
*
|
|
@@ -277,6 +281,8 @@ export class AgentBase {
|
|
|
277
281
|
#providerToolIds = new Map();
|
|
278
282
|
/** The durable steering queue, in the order its keys sort. */
|
|
279
283
|
#steering = [];
|
|
284
|
+
/** Steering IDs accepted but not consumed, including ones not yet merged into memory. */
|
|
285
|
+
#pendingSteeringInterruptions = new Set();
|
|
280
286
|
/** The durable send queue, in the order its keys sort. */
|
|
281
287
|
#sends = [];
|
|
282
288
|
/** A committed outer-transaction queue write not yet merged into the in-memory queues. */
|
|
@@ -396,6 +402,20 @@ export class AgentBase {
|
|
|
396
402
|
#admitted = new Set();
|
|
397
403
|
/** Queue acceptances the run loop must publish before it may decide its queues are empty. */
|
|
398
404
|
#messageAdmissions = new Set();
|
|
405
|
+
/** Active tool executions whose own definition opts into cooperative steering cancellation. */
|
|
406
|
+
#steerableToolExecutions = new Set();
|
|
407
|
+
/** Raised once sticky drain or shutdown stops accepting steerable execution latency. */
|
|
408
|
+
#stopSteerableToolsController = new AbortController();
|
|
409
|
+
/** One shared race edge, so completed tools do not leave listeners behind until shutdown. */
|
|
410
|
+
#stopSteerableTools = new Promise((resolve) => {
|
|
411
|
+
this.#stopSteerableToolsController.signal.addEventListener("abort", () => resolve(STEERABLE_STOPPED), { once: true });
|
|
412
|
+
});
|
|
413
|
+
/** Raised once drain or shutdown may leave reloadable calls pending for the next process. */
|
|
414
|
+
#stopReloadableToolsController = new AbortController();
|
|
415
|
+
/** One shared reload edge, avoiding one shutdown listener per completed reloadable tool. */
|
|
416
|
+
#stopReloadableTools = new Promise((resolve) => {
|
|
417
|
+
this.#stopReloadableToolsController.signal.addEventListener("abort", () => resolve(RELOADABLE_STOPPED), { once: true });
|
|
418
|
+
});
|
|
399
419
|
/**
|
|
400
420
|
* Work from an earlier response that is still unwinding: a provider stream that has not
|
|
401
421
|
* finished closing, or a tool that was settled in the conversation by an abort and is still
|
|
@@ -978,9 +998,15 @@ export class AgentBase {
|
|
|
978
998
|
: { metadata: request.metadata }),
|
|
979
999
|
options: request.options,
|
|
980
1000
|
});
|
|
1001
|
+
if (request.kind === "steering") {
|
|
1002
|
+
this.#pendingSteeringInterruptions.add(request.id);
|
|
1003
|
+
}
|
|
981
1004
|
}
|
|
982
1005
|
this.#turnRequested = true;
|
|
983
1006
|
this.#startRun();
|
|
1007
|
+
if (accepted.some(({ request }) => request.kind === "steering")) {
|
|
1008
|
+
this.#interruptSteerableTools();
|
|
1009
|
+
}
|
|
984
1010
|
});
|
|
985
1011
|
});
|
|
986
1012
|
return results;
|
|
@@ -1026,8 +1052,14 @@ export class AgentBase {
|
|
|
1026
1052
|
if (staged?.activated === true)
|
|
1027
1053
|
await this.#announceActivation(ctx, false);
|
|
1028
1054
|
const offeredIds = batch.map(({ id }) => id);
|
|
1055
|
+
const acceptedSteeringIds = results.flatMap((result, index) => result.accepted === "created" && result.delivery === "steer" ? [batch[index].id] : []);
|
|
1029
1056
|
afterCommit(ctx, async () => {
|
|
1030
|
-
|
|
1057
|
+
for (const id of acceptedSteeringIds)
|
|
1058
|
+
this.#pendingSteeringInterruptions.add(id);
|
|
1059
|
+
const activated = this.#activateCommittedMessages(offeredIds, staged);
|
|
1060
|
+
if (acceptedSteeringIds.length > 0)
|
|
1061
|
+
this.#interruptSteerableTools();
|
|
1062
|
+
await activated;
|
|
1031
1063
|
});
|
|
1032
1064
|
return results;
|
|
1033
1065
|
}
|
|
@@ -1061,6 +1093,9 @@ export class AgentBase {
|
|
|
1061
1093
|
this.#offeredMessageIds.add(id);
|
|
1062
1094
|
if (staged === undefined)
|
|
1063
1095
|
return;
|
|
1096
|
+
// The queue already committed. Raise the merge marker synchronously so a steerable tool
|
|
1097
|
+
// woken by that commit cannot reach the next inference before the message is visible.
|
|
1098
|
+
this.#committedQueueDirty = true;
|
|
1064
1099
|
// The commit published the staged activation announcement along with the batch, so the
|
|
1065
1100
|
// current active period is now an announced one.
|
|
1066
1101
|
if (staged.activated) {
|
|
@@ -1083,11 +1118,15 @@ export class AgentBase {
|
|
|
1083
1118
|
if (this.#runPromise === undefined) {
|
|
1084
1119
|
this.#adoptPendingState(pending);
|
|
1085
1120
|
}
|
|
1086
|
-
this.#committedQueueDirty = true;
|
|
1087
1121
|
});
|
|
1088
1122
|
this.#turnRequested = true;
|
|
1089
1123
|
this.#startRun();
|
|
1090
1124
|
}
|
|
1125
|
+
/** Abort only active tool execution signals that explicitly opted into steering. */
|
|
1126
|
+
#interruptSteerableTools() {
|
|
1127
|
+
for (const execution of this.#steerableToolExecutions)
|
|
1128
|
+
execution.abort();
|
|
1129
|
+
}
|
|
1091
1130
|
/** Make one committed pending record the exact in-memory lifecycle state. */
|
|
1092
1131
|
#adoptPendingState(pending) {
|
|
1093
1132
|
this.#pending = pending;
|
|
@@ -1141,11 +1180,14 @@ export class AgentBase {
|
|
|
1141
1180
|
this.#startRun();
|
|
1142
1181
|
}
|
|
1143
1182
|
/**
|
|
1144
|
-
* Stop this loop at its next durable edge
|
|
1145
|
-
*
|
|
1183
|
+
* Stop this loop at its next durable edge. Ordinary current operations finish; a steerable
|
|
1184
|
+
* tool receives a cancelled lifetime and is no longer awaited. The mode is sticky: later
|
|
1185
|
+
* queue writes remain durable but cannot start another run in this process.
|
|
1146
1186
|
*/
|
|
1147
1187
|
drain() {
|
|
1148
1188
|
this.#draining = true;
|
|
1189
|
+
this.#stopSteerableToolsController.abort();
|
|
1190
|
+
this.#stopReloadableToolsController.abort();
|
|
1149
1191
|
this.#drainPromise ??= this.#finishDrain();
|
|
1150
1192
|
return this.#drainPromise;
|
|
1151
1193
|
}
|
|
@@ -1257,15 +1299,22 @@ export class AgentBase {
|
|
|
1257
1299
|
}
|
|
1258
1300
|
}
|
|
1259
1301
|
/**
|
|
1260
|
-
*
|
|
1261
|
-
*
|
|
1262
|
-
*
|
|
1302
|
+
* Resolve one complete provider configuration. Sharing this snapshot between instructions,
|
|
1303
|
+
* the provider descriptor array, and streamed-call policy keeps one inference or compaction
|
|
1304
|
+
* internally consistent even when a hook computes its tools dynamically.
|
|
1263
1305
|
*/
|
|
1264
|
-
async #
|
|
1265
|
-
|
|
1266
|
-
|
|
1306
|
+
async #configuration(ctx) {
|
|
1307
|
+
// Preserve the established correctness-hook order: instructions first, then tools.
|
|
1308
|
+
const hookedInstructions = await this.#hooks.instructions?.(this.#workContext(ctx));
|
|
1309
|
+
const tools = await this.#tools(ctx);
|
|
1310
|
+
const instructions = [
|
|
1311
|
+
this.state.instructions,
|
|
1312
|
+
hookedInstructions ?? "",
|
|
1313
|
+
toolCapabilityInstructions(tools),
|
|
1314
|
+
]
|
|
1267
1315
|
.filter((text) => text.length > 0)
|
|
1268
1316
|
.join("\n\n");
|
|
1317
|
+
return { instructions, tools };
|
|
1269
1318
|
}
|
|
1270
1319
|
/**
|
|
1271
1320
|
* The tools for the next request or execution: the mutable state extended by the hook's
|
|
@@ -1277,6 +1326,10 @@ export class AgentBase {
|
|
|
1277
1326
|
const tools = [...this.state.tools, ...(hooked ?? [])];
|
|
1278
1327
|
const names = new Set();
|
|
1279
1328
|
for (const tool of tools) {
|
|
1329
|
+
if (tool.server === undefined &&
|
|
1330
|
+
(tool.persistInHistory === false || tool.visibleToUser === false)) {
|
|
1331
|
+
throw new Error(`Tool "${tool.name}" may hide publication only when the provider owns it through a server descriptor.`);
|
|
1332
|
+
}
|
|
1280
1333
|
const key = `${tool.namespace ?? ""}\u0000${tool.name}`;
|
|
1281
1334
|
if (names.has(key)) {
|
|
1282
1335
|
throw new Error(tool.namespace === undefined
|
|
@@ -1356,9 +1409,10 @@ export class AgentBase {
|
|
|
1356
1409
|
// because the work it would abandon is the caller itself.
|
|
1357
1410
|
const fromInsideOwnLoop = this.#insideOwnLoop();
|
|
1358
1411
|
this.#closed = true;
|
|
1359
|
-
// Graceful shutdown
|
|
1360
|
-
//
|
|
1361
|
-
|
|
1412
|
+
// Graceful shutdown lets ordinary tools reach the next durable edge, but steerable tools
|
|
1413
|
+
// explicitly opt out of holding shutdown open. A direct close still abandons every tool.
|
|
1414
|
+
this.#stopSteerableToolsController.abort();
|
|
1415
|
+
this.#stopReloadableToolsController.abort();
|
|
1362
1416
|
if (!fromInsideOwnLoop && !this.#stopAtSafeEdgeRequested()) {
|
|
1363
1417
|
this.#closeController.abort();
|
|
1364
1418
|
}
|
|
@@ -2087,8 +2141,7 @@ export class AgentBase {
|
|
|
2087
2141
|
* arrived before the request was made, since there is no response to account for.
|
|
2088
2142
|
*/
|
|
2089
2143
|
async #requestInference(ctx, abortPromise) {
|
|
2090
|
-
const instructions = await this.#
|
|
2091
|
-
const tools = await this.#tools(ctx);
|
|
2144
|
+
const { instructions, tools } = await this.#configuration(ctx);
|
|
2092
2145
|
const session = await this.#ensureSession(instructions, tools);
|
|
2093
2146
|
// Nothing from the previous response may still be holding the session — but that
|
|
2094
2147
|
// unwinding was detached from an earlier abort precisely so it could never hold a
|
|
@@ -2127,7 +2180,7 @@ export class AgentBase {
|
|
|
2127
2180
|
...(this.#effort === undefined ? {} : { effort: this.#effort }),
|
|
2128
2181
|
...(this.#serviceTier === undefined ? {} : { serviceTier: this.#serviceTier }),
|
|
2129
2182
|
});
|
|
2130
|
-
const { content, state, errorMessage, tokens } = await this.#collect(ctx, stream, abortPromise);
|
|
2183
|
+
const { content, state, errorMessage, tokens } = await this.#collect(ctx, stream, abortPromise, tools);
|
|
2131
2184
|
// A cancellation or a stream ending without a done event did not answer an appended
|
|
2132
2185
|
// notice. Keep that obligation and reopen it under a fresh turn scope. Every terminal
|
|
2133
2186
|
// provider outcome counts as the response, including an error.
|
|
@@ -2253,8 +2306,8 @@ export class AgentBase {
|
|
|
2253
2306
|
return;
|
|
2254
2307
|
try {
|
|
2255
2308
|
await this.#enterStage(ctx, "compaction");
|
|
2256
|
-
const instructions = await this.#
|
|
2257
|
-
const session = await this.#ensureSession(instructions,
|
|
2309
|
+
const { instructions, tools } = await this.#configuration(ctx);
|
|
2310
|
+
const session = await this.#ensureSession(instructions, tools);
|
|
2258
2311
|
const snapshot = this.#messagesForProvider(this.#messages);
|
|
2259
2312
|
await this.#settled();
|
|
2260
2313
|
const compactionStart = {
|
|
@@ -2416,9 +2469,9 @@ export class AgentBase {
|
|
|
2416
2469
|
const result = entry.committed;
|
|
2417
2470
|
await this.#appendRecord(txCtx, toolContextRecord(result, this.#providerToolIds));
|
|
2418
2471
|
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
2419
|
-
//
|
|
2420
|
-
//
|
|
2421
|
-
//
|
|
2472
|
+
// Every Base-executed result reaches correctness and security observers.
|
|
2473
|
+
// Publication suppression is intentionally limited to provider-owned
|
|
2474
|
+
// server calls, which never enter the execution path.
|
|
2422
2475
|
await this.#invokeToolTransactHook(txCtx, entry.id, this.#hooks.afterToolCallTransact, result);
|
|
2423
2476
|
await this.#kv.scoped("call", entry.id).clear(txCtx);
|
|
2424
2477
|
}
|
|
@@ -2571,8 +2624,16 @@ export class AgentBase {
|
|
|
2571
2624
|
// The durable queue, not memory, decides what is left to consume after a restart.
|
|
2572
2625
|
const durable = new Set((await this.#persistence.readValues(lockCtx, prefix)).map(({ key }) => key));
|
|
2573
2626
|
const remaining = queue.filter((entry) => durable.has(entry.key));
|
|
2574
|
-
if (remaining.length !== queue.length)
|
|
2627
|
+
if (remaining.length !== queue.length) {
|
|
2628
|
+
if (kind === "steering") {
|
|
2629
|
+
for (const entry of queue) {
|
|
2630
|
+
if (!durable.has(entry.key)) {
|
|
2631
|
+
this.#pendingSteeringInterruptions.delete(entry.id);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2575
2635
|
queue.splice(0, queue.length, ...remaining);
|
|
2636
|
+
}
|
|
2576
2637
|
if (queue.length === 0)
|
|
2577
2638
|
return false;
|
|
2578
2639
|
const count = mode === "all" ? queue.length : 1;
|
|
@@ -2771,6 +2832,10 @@ export class AgentBase {
|
|
|
2771
2832
|
...(entry.metadata === undefined ? {} : { metadata: entry.metadata }),
|
|
2772
2833
|
})));
|
|
2773
2834
|
queue.splice(0, count);
|
|
2835
|
+
if (kind === "steering") {
|
|
2836
|
+
for (const entry of batch)
|
|
2837
|
+
this.#pendingSteeringInterruptions.delete(entry.id);
|
|
2838
|
+
}
|
|
2774
2839
|
if (reset) {
|
|
2775
2840
|
this.#messages = injected === undefined ? [] : [injected];
|
|
2776
2841
|
this.#providerToolIds.clear();
|
|
@@ -2887,6 +2952,9 @@ export class AgentBase {
|
|
|
2887
2952
|
const measured = context[0]?.value;
|
|
2888
2953
|
this.#contextTokens = measured?.tokens;
|
|
2889
2954
|
this.#steering = steering.map(({ key, value }) => this.#restoreQueueEntry(key, value));
|
|
2955
|
+
for (const entry of this.#steering) {
|
|
2956
|
+
this.#pendingSteeringInterruptions.add(entry.id);
|
|
2957
|
+
}
|
|
2890
2958
|
this.#sends = sends.map(({ key, value }) => this.#restoreQueueEntry(key, value));
|
|
2891
2959
|
this.#injections = injections.map(({ key, value }) => ({
|
|
2892
2960
|
key,
|
|
@@ -2933,9 +3001,9 @@ export class AgentBase {
|
|
|
2933
3001
|
* result. All calls run in parallel, but results land strictly in call order: a finished
|
|
2934
3002
|
* result waits until every earlier call in the batch has committed, and each commit appends
|
|
2935
3003
|
* the tool record and removes the pending entry in one transaction before memory changes.
|
|
2936
|
-
* On resume,
|
|
2937
|
-
* settles every call still running as an aborted error result, so the batch always
|
|
2938
|
-
* complete context behind.
|
|
3004
|
+
* On resume, durable and reloadable tools execute again; the rest become error results. An
|
|
3005
|
+
* abort settles every call still running as an aborted error result, so the batch always
|
|
3006
|
+
* leaves a complete context behind.
|
|
2939
3007
|
*/
|
|
2940
3008
|
async #runToolBatch(ctx, entries, resume, signal, abortPromise) {
|
|
2941
3009
|
return await this.#span(ctx, "agent.tools", { "agent.tool.count": entries.length, "agent.tool.resume": resume }, (batchCtx) => this.#dispatchToolBatch(batchCtx, entries, resume, signal, abortPromise));
|
|
@@ -2966,6 +3034,7 @@ export class AgentBase {
|
|
|
2966
3034
|
// Every execution actually started, whether or not its result reached the conversation.
|
|
2967
3035
|
const running = [];
|
|
2968
3036
|
let closedDuringTools = false;
|
|
3037
|
+
let reloadableStopped = false;
|
|
2969
3038
|
let committed = 0;
|
|
2970
3039
|
// A failed commit blocks the turn with its pending calls intact for resume. A sibling
|
|
2971
3040
|
// still running at that moment no longer owns the append-only tail: its result could
|
|
@@ -3037,7 +3106,7 @@ export class AgentBase {
|
|
|
3037
3106
|
if (entry.committed !== undefined) {
|
|
3038
3107
|
outcome = entry.committed;
|
|
3039
3108
|
}
|
|
3040
|
-
else if (resume && !(await this.#
|
|
3109
|
+
else if (resume && !(await this.#isRetryable(ctx, entry.call))) {
|
|
3041
3110
|
outcome = toolFailure(entry.id, "The tool call was interrupted by a restart and was not retried.");
|
|
3042
3111
|
}
|
|
3043
3112
|
else {
|
|
@@ -3055,6 +3124,10 @@ export class AgentBase {
|
|
|
3055
3124
|
running.push(execution);
|
|
3056
3125
|
outcome = await Promise.race([execution, abortPromise, this.#closingTools()]);
|
|
3057
3126
|
}
|
|
3127
|
+
if (outcome === RELOADABLE_STOPPED) {
|
|
3128
|
+
reloadableStopped = true;
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3058
3131
|
if (outcome === ABORTED && !signal.aborted)
|
|
3059
3132
|
closedDuringTools = true;
|
|
3060
3133
|
results[index] =
|
|
@@ -3085,7 +3158,7 @@ export class AgentBase {
|
|
|
3085
3158
|
// An abort or failure may settle the conversation or block the batch without settling the
|
|
3086
3159
|
// actual execution. Track that unwinding so another in-process attempt cannot overlap it;
|
|
3087
3160
|
// the batch itself does not wait, so an uncooperative tool cannot hold this turn open.
|
|
3088
|
-
return closedDuringTools;
|
|
3161
|
+
return closedDuringTools || reloadableStopped;
|
|
3089
3162
|
}
|
|
3090
3163
|
/**
|
|
3091
3164
|
* Settles once close begins, so a batch stops waiting for tools that a shutdown may itself
|
|
@@ -3101,10 +3174,10 @@ export class AgentBase {
|
|
|
3101
3174
|
});
|
|
3102
3175
|
});
|
|
3103
3176
|
}
|
|
3104
|
-
/** Whether this call's tool may safely
|
|
3105
|
-
async #
|
|
3177
|
+
/** Whether this call's tool may safely execute again after a crash or deliberate reload. */
|
|
3178
|
+
async #isRetryable(ctx, call) {
|
|
3106
3179
|
const tool = (await this.#tools(ctx)).find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
|
|
3107
|
-
return tool?.durable === true;
|
|
3180
|
+
return tool?.durable === true || tool?.reloadable === true;
|
|
3108
3181
|
}
|
|
3109
3182
|
/** Every client tool call in the conversation that has no matching result yet. */
|
|
3110
3183
|
#unansweredCalls(messages) {
|
|
@@ -3406,46 +3479,81 @@ export class AgentBase {
|
|
|
3406
3479
|
const runCtx = decision?.permissionMode === undefined
|
|
3407
3480
|
? callCtx
|
|
3408
3481
|
: withAgentPermissionMode(callCtx, decision.permissionMode);
|
|
3409
|
-
const
|
|
3410
|
-
|
|
3411
|
-
|
|
3482
|
+
const reloadable = ran.reloadable === true;
|
|
3483
|
+
const steeringLifetime = ran.steerable === true ? new AbortController() : undefined;
|
|
3484
|
+
const executionCtx = withLifetime(runCtx, AbortSignal.any([
|
|
3485
|
+
...(runCtx.lifetime === undefined ? [] : [runCtx.lifetime]),
|
|
3486
|
+
callLifetime.signal,
|
|
3487
|
+
...(steeringLifetime === undefined
|
|
3488
|
+
? []
|
|
3489
|
+
: [steeringLifetime.signal, this.#stopSteerableToolsController.signal]),
|
|
3490
|
+
...(reloadable ? [this.#stopReloadableToolsController.signal] : []),
|
|
3491
|
+
]));
|
|
3412
3492
|
const toolCall = {
|
|
3413
3493
|
id: entry.id,
|
|
3414
3494
|
kv: boundedCallKV,
|
|
3415
3495
|
commit,
|
|
3416
3496
|
};
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3497
|
+
if (steeringLifetime !== undefined) {
|
|
3498
|
+
this.#steerableToolExecutions.add(steeringLifetime);
|
|
3499
|
+
if (this.#pendingSteeringInterruptions.size > 0)
|
|
3500
|
+
steeringLifetime.abort();
|
|
3501
|
+
}
|
|
3502
|
+
try {
|
|
3503
|
+
const returned = ran.transactional === true
|
|
3504
|
+
? this.#persistence.transaction(executionCtx, async (txCtx) => {
|
|
3505
|
+
const result = await ran.execute(txCtx, ranArguments, toolCall);
|
|
3506
|
+
if (!Value.Check(ran.returnType, result)) {
|
|
3507
|
+
throw new Error(`Tool "${ran.name}" returned an invalid result.`);
|
|
3508
|
+
}
|
|
3509
|
+
return await commit(txCtx, result);
|
|
3510
|
+
})
|
|
3511
|
+
: Promise.resolve(ran.execute(executionCtx, ranArguments, toolCall));
|
|
3512
|
+
const execution = returned.then((result) => ({ type: "returned", result }), (error) => ({ type: "threw", error }));
|
|
3513
|
+
const settled = await Promise.race([
|
|
3514
|
+
execution,
|
|
3515
|
+
committed.then((committed) => ({ type: "committed", committed })),
|
|
3516
|
+
...(reloadable
|
|
3517
|
+
? [this.#stopReloadableTools]
|
|
3518
|
+
: steeringLifetime === undefined
|
|
3519
|
+
? []
|
|
3520
|
+
: [this.#stopSteerableTools]),
|
|
3521
|
+
]);
|
|
3522
|
+
if (settled === RELOADABLE_STOPPED) {
|
|
3523
|
+
throw RELOADABLE_STOPPED;
|
|
3524
|
+
}
|
|
3525
|
+
else if (settled === STEERABLE_STOPPED) {
|
|
3526
|
+
throw new Error("The tool call was interrupted while the agent was stopping.");
|
|
3527
|
+
}
|
|
3528
|
+
else if (settled.type === "committed") {
|
|
3529
|
+
outcome = settled.committed;
|
|
3530
|
+
}
|
|
3531
|
+
else if (settled.type === "threw") {
|
|
3532
|
+
throw settled.error;
|
|
3533
|
+
}
|
|
3534
|
+
else if (committedOutcome !== undefined) {
|
|
3535
|
+
outcome = committedOutcome;
|
|
3536
|
+
}
|
|
3537
|
+
else {
|
|
3538
|
+
if (!Value.Check(ran.returnType, settled.result)) {
|
|
3421
3539
|
throw new Error(`Tool "${ran.name}" returned an invalid result.`);
|
|
3422
3540
|
}
|
|
3423
|
-
|
|
3424
|
-
}
|
|
3425
|
-
: Promise.resolve(ran.execute(executionCtx, ranArguments, toolCall));
|
|
3426
|
-
const execution = returned.then((result) => ({ type: "returned", result }), (error) => ({ type: "threw", error }));
|
|
3427
|
-
const settled = await Promise.race([
|
|
3428
|
-
execution,
|
|
3429
|
-
committed.then((committed) => ({ type: "committed", committed })),
|
|
3430
|
-
]);
|
|
3431
|
-
if (settled.type === "committed") {
|
|
3432
|
-
outcome = settled.committed;
|
|
3433
|
-
}
|
|
3434
|
-
else if (settled.type === "threw") {
|
|
3435
|
-
throw settled.error;
|
|
3436
|
-
}
|
|
3437
|
-
else if (committedOutcome !== undefined) {
|
|
3438
|
-
outcome = committedOutcome;
|
|
3541
|
+
outcome = outcomeFor(settled.result);
|
|
3542
|
+
}
|
|
3439
3543
|
}
|
|
3440
|
-
|
|
3441
|
-
if (
|
|
3442
|
-
|
|
3544
|
+
finally {
|
|
3545
|
+
if (steeringLifetime !== undefined) {
|
|
3546
|
+
this.#steerableToolExecutions.delete(steeringLifetime);
|
|
3547
|
+
steeringLifetime.abort();
|
|
3443
3548
|
}
|
|
3444
|
-
outcome = outcomeFor(settled.result);
|
|
3445
3549
|
}
|
|
3446
3550
|
}
|
|
3447
3551
|
}
|
|
3448
3552
|
catch (error) {
|
|
3553
|
+
if (error === RELOADABLE_STOPPED) {
|
|
3554
|
+
callLifetime.abort();
|
|
3555
|
+
return RELOADABLE_STOPPED;
|
|
3556
|
+
}
|
|
3449
3557
|
outcome =
|
|
3450
3558
|
committedOutcome ??
|
|
3451
3559
|
{
|
|
@@ -3515,13 +3623,15 @@ export class AgentBase {
|
|
|
3515
3623
|
* the model actually finished saying: a response cut off mid-block keeps the finished blocks
|
|
3516
3624
|
* alone, so memory never differs from what a reload would rebuild.
|
|
3517
3625
|
*/
|
|
3518
|
-
async #collect(ctx, stream, abortPromise) {
|
|
3626
|
+
async #collect(ctx, stream, abortPromise, tools) {
|
|
3519
3627
|
const content = [];
|
|
3520
3628
|
// Blocks that finished and were durably appended. An abort keeps exactly these, so the
|
|
3521
3629
|
// in-memory assistant message never diverges from what a reload would rebuild.
|
|
3522
3630
|
const persisted = [];
|
|
3523
3631
|
const toolCallIndexes = new Map();
|
|
3524
3632
|
const toolResultIndexes = new Map();
|
|
3633
|
+
const toolPolicies = new Map();
|
|
3634
|
+
const deferredToolCalls = new Map();
|
|
3525
3635
|
// Provider IDs remain only in the private context blocks. Every event and transactional
|
|
3526
3636
|
// projection leaving this method uses the generated Base ID instead.
|
|
3527
3637
|
const responseToolIds = new Map();
|
|
@@ -3542,6 +3652,24 @@ export class AgentBase {
|
|
|
3542
3652
|
});
|
|
3543
3653
|
persisted.push(block);
|
|
3544
3654
|
};
|
|
3655
|
+
const persistPair = async (call, callEvent, result) => {
|
|
3656
|
+
const callRecord = assistantContextRecord(call, this.#providerToolIds);
|
|
3657
|
+
const resultRecord = assistantContextRecord(result, this.#providerToolIds);
|
|
3658
|
+
await this.#runPersistenceStep(this.#workContext(ctx), (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
3659
|
+
await this.#appendRecord(txCtx, callRecord);
|
|
3660
|
+
await this.#appendRecord(txCtx, resultRecord);
|
|
3661
|
+
if (callEvent !== undefined) {
|
|
3662
|
+
await this.#withTransactionalContext(txCtx, (hookCtx) => this.#hooks.onEventTransact?.(hookCtx, callEvent));
|
|
3663
|
+
}
|
|
3664
|
+
}));
|
|
3665
|
+
persisted.push(call, result);
|
|
3666
|
+
};
|
|
3667
|
+
const flushCompletedCallOnlyTools = async () => {
|
|
3668
|
+
for (const deferred of deferredToolCalls.values()) {
|
|
3669
|
+
await persist(deferred.block, undefined);
|
|
3670
|
+
}
|
|
3671
|
+
deferredToolCalls.clear();
|
|
3672
|
+
};
|
|
3545
3673
|
const iterator = stream[Symbol.asyncIterator]();
|
|
3546
3674
|
// A response usually ends before its stream does — at the done event, or at an abort —
|
|
3547
3675
|
// and the provider holds a connection behind that stream. Whichever way this method
|
|
@@ -3563,7 +3691,20 @@ export class AgentBase {
|
|
|
3563
3691
|
}
|
|
3564
3692
|
const providerEvent = next.value;
|
|
3565
3693
|
const event = baseSessionEvent(providerEvent, responseToolIds, this.#providerToolIds);
|
|
3566
|
-
|
|
3694
|
+
if (event.type === "toolcall_start") {
|
|
3695
|
+
const tool = tools.find((candidate) => candidate.name === event.name &&
|
|
3696
|
+
candidate.namespace === event.namespace);
|
|
3697
|
+
const policy = {
|
|
3698
|
+
persistInHistory: event.server !== true || tool?.persistInHistory !== false,
|
|
3699
|
+
persistWithResult: event.server === true && tool?.persistInHistory === false,
|
|
3700
|
+
visibleToUser: event.server !== true || tool?.visibleToUser !== false,
|
|
3701
|
+
};
|
|
3702
|
+
toolPolicies.set(event.callId, policy);
|
|
3703
|
+
}
|
|
3704
|
+
const policyCallId = toolLifecycleCallId(event);
|
|
3705
|
+
const toolPolicy = policyCallId === undefined ? undefined : toolPolicies.get(policyCallId);
|
|
3706
|
+
if (toolPolicy?.visibleToUser !== false)
|
|
3707
|
+
await this.#emit(ctx, event);
|
|
3567
3708
|
switch (event.type) {
|
|
3568
3709
|
case "text_start":
|
|
3569
3710
|
content.push({ type: "text", text: "" });
|
|
@@ -3580,6 +3721,10 @@ export class AgentBase {
|
|
|
3580
3721
|
}
|
|
3581
3722
|
case "text_end": {
|
|
3582
3723
|
const last = content[content.length - 1];
|
|
3724
|
+
// Provider-owned results precede the model synthesis. If the provider
|
|
3725
|
+
// exposes no result block, commit its completed call immediately before
|
|
3726
|
+
// that synthesis so restart replay preserves the logical output order.
|
|
3727
|
+
await flushCompletedCallOnlyTools();
|
|
3583
3728
|
await persist(last?.type === "text" ? last : undefined, last?.type === "text" ? { ...event, block: last } : undefined);
|
|
3584
3729
|
break;
|
|
3585
3730
|
}
|
|
@@ -3606,6 +3751,7 @@ export class AgentBase {
|
|
|
3606
3751
|
: { reasoning: event.reasoning }),
|
|
3607
3752
|
};
|
|
3608
3753
|
content[content.length - 1] = finished;
|
|
3754
|
+
await flushCompletedCallOnlyTools();
|
|
3609
3755
|
await persist(finished, { ...event, block: finished });
|
|
3610
3756
|
}
|
|
3611
3757
|
break;
|
|
@@ -3631,22 +3777,34 @@ export class AgentBase {
|
|
|
3631
3777
|
break;
|
|
3632
3778
|
}
|
|
3633
3779
|
case "toolcall_end": {
|
|
3780
|
+
if (providerEvent.type !== "toolcall_end") {
|
|
3781
|
+
throw new Error("The provider tool-call end changed event type.");
|
|
3782
|
+
}
|
|
3634
3783
|
const index = toolCallIndexes.get(event.callId);
|
|
3635
3784
|
const block = index === undefined ? undefined : content[index];
|
|
3636
3785
|
if (index !== undefined && block?.type === "tool_call") {
|
|
3786
|
+
const finalVendor = providerEvent.vendor;
|
|
3637
3787
|
const finished = {
|
|
3638
3788
|
...block,
|
|
3639
3789
|
arguments: event.arguments,
|
|
3790
|
+
...(finalVendor === undefined ? {} : { vendor: finalVendor }),
|
|
3640
3791
|
...(event.incomplete === undefined
|
|
3641
3792
|
? {}
|
|
3642
3793
|
: { incomplete: event.incomplete }),
|
|
3643
3794
|
};
|
|
3644
3795
|
content[index] = finished;
|
|
3645
3796
|
const { vendor: _vendor, ...publicBlock } = finished;
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3797
|
+
const persistedEvent = { ...event, block: publicBlock };
|
|
3798
|
+
const policy = toolPolicies.get(event.callId);
|
|
3799
|
+
if (policy?.persistWithResult === true) {
|
|
3800
|
+
deferredToolCalls.set(event.callId, {
|
|
3801
|
+
block: finished,
|
|
3802
|
+
event: persistedEvent,
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
3805
|
+
else {
|
|
3806
|
+
await persist(finished, policy?.persistInHistory === false ? undefined : persistedEvent);
|
|
3807
|
+
}
|
|
3650
3808
|
}
|
|
3651
3809
|
break;
|
|
3652
3810
|
}
|
|
@@ -3692,10 +3850,30 @@ export class AgentBase {
|
|
|
3692
3850
|
: { incomplete: event.incomplete }),
|
|
3693
3851
|
};
|
|
3694
3852
|
content[resultIndex] = finished;
|
|
3695
|
-
|
|
3853
|
+
const policy = toolPolicies.get(event.callId);
|
|
3854
|
+
if (policy?.persistWithResult === true) {
|
|
3855
|
+
const deferred = deferredToolCalls.get(event.callId);
|
|
3856
|
+
if (deferred === undefined) {
|
|
3857
|
+
throw new Error("A provider-local tool result has no completed call to persist.");
|
|
3858
|
+
}
|
|
3859
|
+
await persistPair(deferred.block, policy.persistInHistory ? deferred.event : undefined, finished);
|
|
3860
|
+
deferredToolCalls.delete(event.callId);
|
|
3861
|
+
}
|
|
3862
|
+
else {
|
|
3863
|
+
await persist(finished, undefined);
|
|
3864
|
+
}
|
|
3696
3865
|
break;
|
|
3697
3866
|
}
|
|
3698
3867
|
case "done":
|
|
3868
|
+
if (event.state === "normal" ||
|
|
3869
|
+
event.state === "tool_call" ||
|
|
3870
|
+
event.state === "length") {
|
|
3871
|
+
// Some provider-owned tools, notably Claude Code built-ins, consume
|
|
3872
|
+
// their result internally and expose only a completed call. Keep that
|
|
3873
|
+
// call after a successful response, while an interrupted response
|
|
3874
|
+
// retains neither half of a call/result pair.
|
|
3875
|
+
await flushCompletedCallOnlyTools();
|
|
3876
|
+
}
|
|
3699
3877
|
return {
|
|
3700
3878
|
// Only blocks that finished, which are exactly the blocks that were
|
|
3701
3879
|
// durably appended. A response ending mid-block leaves half of
|
|
@@ -3771,6 +3949,40 @@ export class AgentBase {
|
|
|
3771
3949
|
}
|
|
3772
3950
|
}
|
|
3773
3951
|
}
|
|
3952
|
+
/** A model-facing section derived from one resolved tool array. */
|
|
3953
|
+
function toolCapabilityInstructions(tools) {
|
|
3954
|
+
const capabilities = [];
|
|
3955
|
+
const seen = new Set();
|
|
3956
|
+
for (const tool of tools) {
|
|
3957
|
+
for (const raw of tool.capabilities ?? []) {
|
|
3958
|
+
const capability = raw.trim();
|
|
3959
|
+
if (capability.length === 0)
|
|
3960
|
+
continue;
|
|
3961
|
+
const key = capability.toLowerCase();
|
|
3962
|
+
if (seen.has(key))
|
|
3963
|
+
continue;
|
|
3964
|
+
seen.add(key);
|
|
3965
|
+
capabilities.push(capability);
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
if (capabilities.length === 0)
|
|
3969
|
+
return "";
|
|
3970
|
+
return ["Tool capabilities:", ...capabilities.map((capability) => `- ${capability}`)].join("\n");
|
|
3971
|
+
}
|
|
3972
|
+
/** The Base call identity carried by provider call/result lifecycle events. */
|
|
3973
|
+
function toolLifecycleCallId(event) {
|
|
3974
|
+
switch (event.type) {
|
|
3975
|
+
case "toolcall_start":
|
|
3976
|
+
case "toolcall_delta":
|
|
3977
|
+
case "toolcall_end":
|
|
3978
|
+
case "toolcall_result_start":
|
|
3979
|
+
case "toolcall_result_delta":
|
|
3980
|
+
case "toolcall_result_end":
|
|
3981
|
+
return event.callId;
|
|
3982
|
+
default:
|
|
3983
|
+
return undefined;
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3774
3986
|
/** The result that stands in for a call the agent could not, or must not, carry out. */
|
|
3775
3987
|
function toolFailure(id, reason) {
|
|
3776
3988
|
return {
|
|
@@ -3797,6 +4009,7 @@ function sessionConfigKey(provider, model, instructions, tools) {
|
|
|
3797
4009
|
tool.description ?? null,
|
|
3798
4010
|
tool.parameters ?? null,
|
|
3799
4011
|
tool.defer ?? null,
|
|
4012
|
+
tool.searchKeywords ?? null,
|
|
3800
4013
|
tool.server ?? null,
|
|
3801
4014
|
tool.grammar ?? null,
|
|
3802
4015
|
]),
|