@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/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 non-durable tool never runs twice; a durable one may.
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
- await this.#activateCommittedMessages(offeredIds, staged);
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 without cancelling its current operation. The mode
1145
- * is sticky: later queue writes remain durable but cannot start another run in this process.
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
- * The system prompt for the next request: the mutable state extended by the hook's answer.
1261
- * Instructions and tools are correctness hooks a failure here fails the turn loudly
1262
- * instead of silently running with a wrong configuration.
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 #instructions(ctx) {
1265
- const hooked = await this.#hooks.instructions?.(this.#workContext(ctx));
1266
- return [this.state.instructions, hooked ?? ""]
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 stops at the next operation boundary. Do not abort a tool that is
1360
- // already running; the coordinator's timeout and the daemon's hard exit bound one that
1361
- // never returns.
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.#instructions(ctx);
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.#instructions(ctx);
2257
- const session = await this.#ensureSession(instructions, await this.#tools(ctx));
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
- // A result the conversation records is a result the hook sees, however
2420
- // little of a run produced it. A hook that fails here leaves the calls
2421
- // unsettled, which is what lets a later attempt answer them properly.
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, only durable tools execute again; the rest become error results. An abort
2937
- * settles every call still running as an aborted error result, so the batch always leaves a
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.#isDurable(ctx, entry.call))) {
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 be executed again after a restart interrupted it. */
3105
- async #isDurable(ctx, call) {
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 executionCtx = withLifetime(runCtx, AbortSignal.any(runCtx.lifetime === undefined
3410
- ? [callLifetime.signal]
3411
- : [runCtx.lifetime, callLifetime.signal]));
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
- const returned = ran.transactional === true
3418
- ? this.#persistence.transaction(executionCtx, async (txCtx) => {
3419
- const result = await ran.execute(txCtx, ranArguments, toolCall);
3420
- if (!Value.Check(ran.returnType, result)) {
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
- return await commit(txCtx, result);
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
- else {
3441
- if (!Value.Check(ran.returnType, settled.result)) {
3442
- throw new Error(`Tool "${ran.name}" returned an invalid result.`);
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
- await this.#emit(ctx, event);
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
- await persist(finished, {
3647
- ...event,
3648
- block: publicBlock,
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
- await persist(finished, undefined);
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
  ]),