@oh-my-pi/pi-agent-core 17.1.6 → 17.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.1.7] - 2026-07-27
6
+
7
+ ### Changed
8
+
9
+ - `beforeToolCall` now runs during arg-prep in a pre-dispatch prepare phase — on the streamed path before the assistant message's `message_start`/`message_end` are emitted, and always ahead of concurrency resolution, `tool_execution_start`, telemetry span start, and `tool.execute` — instead of inside the already-scheduled execution slot. It receives the resolved `tool` in its context and may return `args` to replace the call's arguments; a replacement is revalidated against the tool schema, written back to the assistant message's tool-call block, and re-resolves argument-dependent interruptibility, making it the single source of truth for history, persistence, provider replay, scheduling, execution events, and `tool.execute`. Argument validation moved into the same prepare phase, so functional `concurrency` resolvers now see validated (and possibly revised) arguments rather than raw pre-validation ones. The hook now receives the run's request abort signal rather than the per-tool signal.
10
+
5
11
  ## [17.1.6] - 2026-07-27
6
12
 
7
13
  ### Added
@@ -390,15 +390,22 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
390
390
  */
391
391
  getCwd?: () => string | undefined;
392
392
  /**
393
- * Called after a tool call has been validated and is about to execute.
393
+ * Called once per tool call after argument validation, in call order, before
394
+ * the call is scheduled — ahead of concurrency resolution,
395
+ * `tool_execution_start`, and `tool.execute`. On the streamed path it runs
396
+ * before the assistant message's `message_start`/`message_end` are emitted.
394
397
  *
395
398
  * Return `{ block: true }` to prevent execution. The loop emits an error tool
396
399
  * result instead (using `reason` as the error text, or a default if omitted).
397
400
  *
398
- * Mutating `context.args` in place changes the arguments passed to `tool.execute`
399
- * the loop does **not** re-validate after this hook runs.
401
+ * Return `{ args }` to replace the arguments the call runs with. The
402
+ * replacement is revalidated against the tool schema and written back to the
403
+ * tool-call block, making it the single source of truth: history, execution
404
+ * events, persistence, provider replay, concurrency scheduling, and
405
+ * `tool.execute` all see the revised arguments. Mutating `context.args` in
406
+ * place also survives into execution, but a returned `args` object wins.
400
407
  *
401
- * The hook receives the tool abort signal (`signal`) and is responsible for
408
+ * The hook receives the run's request abort signal and is responsible for
402
409
  * honoring it. Throwing surfaces as a tool-error result and does not abort the
403
410
  * rest of the batch.
404
411
  */
@@ -477,12 +484,16 @@ export type AgentToolCall = Extract<AssistantMessage["content"][number], {
477
484
  * Set `block: true` to prevent the tool from executing. The loop emits an error tool
478
485
  * result instead, using `reason` as the error text (or a default if omitted).
479
486
  *
480
- * Mutating the `args` reference passed in `BeforeToolCallContext` is supported and
481
- * survives into execution the loop does **not** re-validate after this hook runs.
487
+ * Set `args` to replace the tool-call arguments. The replacement is revalidated
488
+ * against the tool schema (a failure surfaces as a validation-error tool result),
489
+ * written back to the tool-call block on the assistant message, and seen by
490
+ * history, scheduling, execution events, and `tool.execute` alike. It is
491
+ * ignored when `block` is true.
482
492
  */
483
493
  export interface BeforeToolCallResult {
484
494
  block?: boolean;
485
495
  reason?: string;
496
+ args?: Record<string, unknown>;
486
497
  }
487
498
  /**
488
499
  * Partial override returned from `afterToolCall`.
@@ -508,9 +519,12 @@ export interface BeforeToolCallContext {
508
519
  assistantMessage: AssistantMessage;
509
520
  /** The raw tool call block from `assistantMessage.content`. */
510
521
  toolCall: AgentToolCall;
522
+ /** The resolved tool the call dispatches to. */
523
+ tool: AgentTool<any>;
511
524
  /**
512
525
  * Validated tool arguments. The same reference is forwarded to `tool.execute`
513
- * (after any `transformToolCallArguments` pass), so in-place mutations stick.
526
+ * (after any `transformToolCallArguments` pass), so in-place mutations stick;
527
+ * a returned `BeforeToolCallResult.args` replaces them entirely.
514
528
  */
515
529
  args: Record<string, unknown>;
516
530
  /** Current agent context at the time the tool call is prepared. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "17.1.6",
4
+ "version": "17.1.7",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "17.1.6",
39
- "@oh-my-pi/pi-catalog": "17.1.6",
40
- "@oh-my-pi/pi-natives": "17.1.6",
41
- "@oh-my-pi/pi-utils": "17.1.6",
42
- "@oh-my-pi/pi-wire": "17.1.6",
43
- "@oh-my-pi/snapcompact": "17.1.6",
38
+ "@oh-my-pi/pi-ai": "17.1.7",
39
+ "@oh-my-pi/pi-catalog": "17.1.7",
40
+ "@oh-my-pi/pi-natives": "17.1.7",
41
+ "@oh-my-pi/pi-utils": "17.1.7",
42
+ "@oh-my-pi/pi-wire": "17.1.7",
43
+ "@oh-my-pi/snapcompact": "17.1.7",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -70,9 +70,11 @@ import type {
70
70
  AgentMessage,
71
71
  AgentPreModelCallResult,
72
72
  AgentTool,
73
+ AgentToolCall,
73
74
  AgentToolResult,
74
75
  AgentTurnEndContext,
75
76
  AsideMessage,
77
+ BeforeToolCallResult,
76
78
  SoftToolRequirement,
77
79
  SteeringInterruptSource,
78
80
  SteeringQueueState,
@@ -1752,6 +1754,17 @@ async function streamAssistantResponse(
1752
1754
  if (config.transformAssistantMessage) {
1753
1755
  await config.transformAssistantMessage(finalMessage, requestSignal);
1754
1756
  }
1757
+ // Prepare tool dispatch (validation + the `beforeToolCall` hook)
1758
+ // BEFORE the message is snapshotted for consumers: a hook args
1759
+ // revision is written back into this message's toolCall blocks,
1760
+ // so history, the UI, persistence, provider replay, scheduling,
1761
+ // and execution all carry the revised arguments.
1762
+ if (finalMessage.content.some(c => c.type === "toolCall")) {
1763
+ preparedDispatchByMessage.set(
1764
+ finalMessage,
1765
+ await prepareToolCallDispatch(finalMessage, context, config, requestSignal),
1766
+ );
1767
+ }
1755
1768
  if (addedPartial) {
1756
1769
  context.messages[context.messages.length - 1] = finalMessage;
1757
1770
  } else {
@@ -2051,6 +2064,136 @@ function emitAbortedAssistantMessage(
2051
2064
  return abortedMessage;
2052
2065
  }
2053
2066
 
2067
+ /** Per-call outcome of the pre-dispatch prepare phase (validation + `beforeToolCall`). */
2068
+ interface PreparedToolCall {
2069
+ tool: AgentTool<any> | undefined;
2070
+ /** Validated (possibly hook-revised) execution args; raw args when validation failed. */
2071
+ args: Record<string, unknown>;
2072
+ validationErrorMessage?: string;
2073
+ blocked?: boolean;
2074
+ blockReason?: string;
2075
+ prepareError?: unknown;
2076
+ }
2077
+
2078
+ /**
2079
+ * Prepare results computed in the stream-done branch (before `message_start`/
2080
+ * `message_end`) so a `beforeToolCall` args revision is baked into the message
2081
+ * every consumer snapshots. `executeToolCalls` consumes them; a message that
2082
+ * bypassed the streamed path (e.g. Harmony-recovered) is prepared at dispatch
2083
+ * time instead.
2084
+ */
2085
+ const preparedDispatchByMessage = new WeakMap<AssistantMessage, Map<string, PreparedToolCall>>();
2086
+
2087
+ function resolveToolForCall(
2088
+ tools: AgentTool<any>[] | undefined,
2089
+ toolCall: AgentToolCall,
2090
+ resolveFallbackTool: AgentLoopConfig["resolveFallbackTool"],
2091
+ ): AgentTool<any> | undefined {
2092
+ // Tools emitted via OpenAI's custom-tool path (e.g. `apply_patch` on GPT-5)
2093
+ // come back under their wire-level name, which may differ from the
2094
+ // harness-internal `name`. Match on either, preferring `name` for
2095
+ // determinism if both somehow collide.
2096
+ return (
2097
+ tools?.find(t => t.name === toolCall.name) ??
2098
+ tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name) ??
2099
+ // Not in the advertised set: let the host route side-transport tools
2100
+ // (e.g. xd:// device mounts) called by their top-level name.
2101
+ resolveFallbackTool?.(toolCall.name)
2102
+ );
2103
+ }
2104
+
2105
+ /**
2106
+ * Pre-dispatch phase for every pending tool call on `assistantMessage`, run in
2107
+ * call order: intent extraction, argument validation, and the `beforeToolCall`
2108
+ * hook. A hook `args` revision is revalidated against the tool schema and
2109
+ * written back to `toolCall.arguments`; run before `message_start`/`message_end`
2110
+ * (the streamed path) that makes the revision the single source of truth —
2111
+ * history, execution events, persistence, provider replay, concurrency
2112
+ * scheduling, and `tool.execute` all agree. Failures are recorded per call and
2113
+ * surfaced by `executeToolCalls` at the record's scheduled slot.
2114
+ */
2115
+ async function prepareToolCallDispatch(
2116
+ assistantMessage: AssistantMessage,
2117
+ context: AgentContext,
2118
+ config: AgentLoopConfig,
2119
+ signal: AbortSignal | undefined,
2120
+ ): Promise<Map<string, PreparedToolCall>> {
2121
+ const { resolveFallbackTool, intentTracing, beforeToolCall } = config;
2122
+ const prepared = new Map<string, PreparedToolCall>();
2123
+ for (const toolCall of assistantMessage.content) {
2124
+ if (toolCall.type !== "toolCall") continue;
2125
+ if ((toolCall as CursorExecResolvedCarrier)[kCursorExecResolved] === true) continue;
2126
+ const tool = resolveToolForCall(context.tools, toolCall, resolveFallbackTool);
2127
+ const entry: PreparedToolCall = { tool, args: toolCall.arguments as Record<string, unknown> };
2128
+ prepared.set(toolCall.id, entry);
2129
+ let argsForExecution = toolCall.arguments as Record<string, unknown>;
2130
+ if (intentTracing) {
2131
+ const { intent, strippedArgs } = extractIntent(toolCall.arguments);
2132
+ argsForExecution = strippedArgs;
2133
+ if (intent) {
2134
+ toolCall.intent = intent;
2135
+ } else if (typeof tool?.intent === "function") {
2136
+ try {
2137
+ const derived = tool.intent(strippedArgs as never)?.trim();
2138
+ if (derived) {
2139
+ toolCall.intent = derived;
2140
+ }
2141
+ } catch {
2142
+ // intent function must never break tool execution
2143
+ }
2144
+ }
2145
+ }
2146
+ const validate = (args: Record<string, unknown>): Record<string, unknown> | undefined => {
2147
+ try {
2148
+ if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
2149
+ return validateToolArguments(tool, { ...toolCall, arguments: args });
2150
+ } catch (validationError) {
2151
+ if (tool?.lenientArgValidation) {
2152
+ const fallback = { ...args };
2153
+ delete fallback.__parseError;
2154
+ delete fallback.__rawJson;
2155
+ return fallback;
2156
+ }
2157
+ entry.args = "__parseError" in args ? { __parseError: args.__parseError } : args;
2158
+ entry.validationErrorMessage =
2159
+ validationError instanceof Error ? validationError.message : String(validationError);
2160
+ return undefined;
2161
+ }
2162
+ };
2163
+ const effectiveArgs = validate(argsForExecution);
2164
+ if (effectiveArgs === undefined) continue;
2165
+ entry.args = effectiveArgs;
2166
+ if (!beforeToolCall || !tool) continue;
2167
+ let beforeResult: BeforeToolCallResult | undefined;
2168
+ try {
2169
+ beforeResult = await beforeToolCall(
2170
+ { assistantMessage, toolCall, tool, args: effectiveArgs, context },
2171
+ signal,
2172
+ );
2173
+ } catch (e) {
2174
+ // Contract: a throwing hook surfaces as a tool-error result without
2175
+ // aborting the batch — rethrown inside the execution span in runTool.
2176
+ entry.prepareError = e;
2177
+ continue;
2178
+ }
2179
+ if (beforeResult?.block) {
2180
+ entry.blocked = true;
2181
+ entry.blockReason = beforeResult.reason;
2182
+ continue;
2183
+ }
2184
+ if (beforeResult?.args !== undefined) {
2185
+ // Revalidate: a hook revision is untrusted input to the tool schema.
2186
+ const revised = validate(beforeResult.args);
2187
+ if (revised === undefined) continue;
2188
+ // Bake the revision into the message itself. On the streamed path this
2189
+ // precedes every consumer snapshot, so there is exactly one version of
2190
+ // the call anywhere downstream.
2191
+ toolCall.arguments = beforeResult.args;
2192
+ entry.args = revised;
2193
+ }
2194
+ }
2195
+ return prepared;
2196
+ }
2054
2197
  /**
2055
2198
  * Execute tool calls from an assistant message.
2056
2199
  */
@@ -2071,8 +2214,6 @@ async function executeToolCalls(
2071
2214
  getToolContext,
2072
2215
  transformToolCallArguments,
2073
2216
  resolveFallbackTool,
2074
- intentTracing,
2075
- beforeToolCall,
2076
2217
  afterToolCall,
2077
2218
  } = config;
2078
2219
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
@@ -2106,22 +2247,25 @@ async function executeToolCalls(
2106
2247
  : AbortSignal.any([steeringAbortController.signal, ircAbortController.signal]);
2107
2248
  const interruptState: { triggered: boolean; source?: SteeringInterruptSource | "irc" } = { triggered: false };
2108
2249
 
2250
+ // Streamed messages were prepared (validation + `beforeToolCall`) before
2251
+ // `message_end`, so hook revisions are already part of the message; anything
2252
+ // that bypassed the streamed path is prepared here instead.
2253
+ const preparedDispatch =
2254
+ preparedDispatchByMessage.get(assistantMessage) ??
2255
+ (await prepareToolCallDispatch(assistantMessage, currentContext, config, signal));
2256
+
2109
2257
  const records = toolCalls.map(toolCall => {
2110
- // Tools emitted via OpenAI's custom-tool path (e.g. `apply_patch` on GPT-5)
2111
- // come back under their wire-level name, which may differ from the
2112
- // harness-internal `name`. Match on either, preferring `name` for
2113
- // determinism if both somehow collide.
2114
- const tool =
2115
- tools?.find(t => t.name === toolCall.name) ??
2116
- tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name) ??
2117
- // Not in the advertised set: let the host route side-transport tools
2118
- // (e.g. xd:// device mounts) called by their top-level name.
2119
- resolveFallbackTool?.(toolCall.name);
2120
- const args = toolCall.arguments as Record<string, unknown>;
2258
+ const prepared = preparedDispatch.get(toolCall.id) ?? {
2259
+ tool: resolveToolForCall(tools, toolCall, resolveFallbackTool),
2260
+ args: toolCall.arguments as Record<string, unknown>,
2261
+ };
2262
+ const { tool, args } = prepared;
2121
2263
  const interruptibleMode = tool?.interruptible;
2122
2264
  let interruptible = false;
2123
2265
  if (typeof interruptibleMode === "function") {
2124
2266
  try {
2267
+ // Resolved from the prepared (possibly hook-revised) args so an
2268
+ // argument-dependent policy governs the call that actually runs.
2125
2269
  interruptible = interruptibleMode(args);
2126
2270
  } catch {
2127
2271
  // Resolver failures default to preserving the tool's outcome.
@@ -2142,6 +2286,10 @@ async function executeToolCalls(
2142
2286
  skipped: false,
2143
2287
  toolResultMessage: undefined as ToolResultMessage | undefined,
2144
2288
  resultEmitted: false,
2289
+ validationErrorMessage: prepared.validationErrorMessage,
2290
+ blocked: prepared.blocked === true,
2291
+ blockReason: prepared.blockReason,
2292
+ prepareError: prepared.prepareError,
2145
2293
  };
2146
2294
  });
2147
2295
 
@@ -2257,61 +2405,21 @@ async function executeToolCalls(
2257
2405
  if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(record.signal);
2258
2406
 
2259
2407
  const { toolCall, tool } = record;
2260
- let argsForExecution = toolCall.arguments as Record<string, unknown>;
2261
- if (intentTracing) {
2262
- const { intent, strippedArgs } = extractIntent(toolCall.arguments);
2263
- argsForExecution = strippedArgs;
2264
- if (intent) {
2265
- toolCall.intent = intent;
2266
- } else if (typeof tool?.intent === "function") {
2267
- try {
2268
- const derived = tool.intent(strippedArgs as never)?.trim();
2269
- if (derived) {
2270
- toolCall.intent = derived;
2271
- }
2272
- } catch {
2273
- // intent function must never break tool execution
2274
- }
2275
- }
2276
- }
2277
- let effectiveArgs: Record<string, unknown>;
2278
- try {
2279
- if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
2280
- effectiveArgs = validateToolArguments(tool, { ...toolCall, arguments: argsForExecution });
2281
- } catch (validationError) {
2282
- if (tool?.lenientArgValidation) {
2283
- effectiveArgs = { ...argsForExecution };
2284
- delete effectiveArgs.__parseError;
2285
- delete effectiveArgs.__rawJson;
2286
- } else {
2287
- if ("__parseError" in argsForExecution) {
2288
- record.args = {
2289
- __parseError: argsForExecution.__parseError,
2290
- };
2291
- } else {
2292
- record.args = argsForExecution;
2293
- }
2294
- emitToolResult(
2295
- record,
2296
- {
2297
- content: [
2298
- {
2299
- type: "text" as const,
2300
- text: validationError instanceof Error ? validationError.message : String(validationError),
2301
- },
2302
- ],
2303
- details: {
2304
- isError: true,
2305
- error: validationError instanceof Error ? validationError.message : String(validationError),
2306
- },
2307
- },
2308
- true,
2309
- );
2310
- return;
2311
- }
2408
+ // Validation (and the beforeToolCall hook) ran in the prepare phase; a
2409
+ // failure recorded there surfaces here at the record's scheduled slot so
2410
+ // result emission keeps batch order.
2411
+ if (record.validationErrorMessage !== undefined) {
2412
+ emitToolResult(
2413
+ record,
2414
+ {
2415
+ content: [{ type: "text" as const, text: record.validationErrorMessage }],
2416
+ details: { isError: true, error: record.validationErrorMessage },
2417
+ },
2418
+ true,
2419
+ );
2420
+ return;
2312
2421
  }
2313
-
2314
- record.args = effectiveArgs;
2422
+ const effectiveArgs = record.args;
2315
2423
  if (record.signal.aborted) {
2316
2424
  record.skipped = true;
2317
2425
  recordSkippedTool(telemetry, {
@@ -2356,24 +2464,9 @@ async function executeToolCalls(
2356
2464
  return;
2357
2465
  }
2358
2466
 
2359
- if (beforeToolCall) {
2360
- const beforeResult = await beforeToolCall(
2361
- {
2362
- assistantMessage,
2363
- toolCall,
2364
- args: effectiveArgs,
2365
- context: currentContext,
2366
- },
2367
- record.signal,
2368
- );
2369
- if (beforeResult?.block) {
2370
- throw new ToolCallBlockedError(beforeResult.reason);
2371
- }
2372
- }
2373
- if (record.signal.aborted) {
2374
- result = createToolSignalAbortedResult(record.signal);
2375
- isError = true;
2376
- return;
2467
+ if (record.prepareError !== undefined) throw record.prepareError;
2468
+ if (record.blocked) {
2469
+ throw new ToolCallBlockedError(record.blockReason);
2377
2470
  }
2378
2471
  const executionArgs = transformToolCallArguments
2379
2472
  ? transformToolCallArguments(effectiveArgs, toolCall.name)
@@ -2508,32 +2601,6 @@ async function executeToolCalls(
2508
2601
  let sharedTasks: Promise<void>[] = [];
2509
2602
  const tasks: Promise<void>[] = [];
2510
2603
 
2511
- for (let index = 0; index < records.length; index++) {
2512
- const record = records[index];
2513
- const concurrencyMode = record.tool?.concurrency;
2514
- let concurrency: "shared" | "exclusive";
2515
- if (typeof concurrencyMode === "function") {
2516
- // Resolved from raw pre-validation args; a throwing resolver must not
2517
- // take down the whole batch, so fall back to the safe (serial) mode.
2518
- try {
2519
- concurrency = concurrencyMode(record.args);
2520
- } catch {
2521
- concurrency = "exclusive";
2522
- }
2523
- } else {
2524
- concurrency = concurrencyMode ?? "shared";
2525
- }
2526
- const start = concurrency === "exclusive" ? Promise.all([lastExclusive, ...sharedTasks]) : lastExclusive;
2527
- const task = start.then(() => runTool(record, index));
2528
- tasks.push(task);
2529
- if (concurrency === "exclusive") {
2530
- lastExclusive = task;
2531
- sharedTasks = [];
2532
- } else {
2533
- sharedTasks.push(task);
2534
- }
2535
- }
2536
-
2537
2604
  // While tool calls are in flight, queued steering or interrupting IRC would
2538
2605
  // otherwise wait out the tools' own window. Poll only non-consuming queues:
2539
2606
  // detection hard-aborts interruptible waits, soft-signals cooperative tools
@@ -2590,6 +2657,33 @@ async function executeToolCalls(
2590
2657
  STEERING_INTERRUPT_POLL_MS,
2591
2658
  )
2592
2659
  : undefined;
2660
+ for (let index = 0; index < records.length; index++) {
2661
+ const record = records[index];
2662
+ const concurrencyMode = record.tool?.concurrency;
2663
+ let concurrency: "shared" | "exclusive";
2664
+ if (typeof concurrencyMode === "function") {
2665
+ // Resolved from the prepared (possibly hook-revised) args — raw args
2666
+ // only when validation failed, and those records error out before
2667
+ // executing. A throwing resolver must not take down the whole batch,
2668
+ // so fall back to the safe (serial) mode.
2669
+ try {
2670
+ concurrency = concurrencyMode(record.args);
2671
+ } catch {
2672
+ concurrency = "exclusive";
2673
+ }
2674
+ } else {
2675
+ concurrency = concurrencyMode ?? "shared";
2676
+ }
2677
+ const start = concurrency === "exclusive" ? Promise.all([lastExclusive, ...sharedTasks]) : lastExclusive;
2678
+ const task = start.then(() => runTool(record, index));
2679
+ tasks.push(task);
2680
+ if (concurrency === "exclusive") {
2681
+ lastExclusive = task;
2682
+ sharedTasks = [];
2683
+ } else {
2684
+ sharedTasks.push(task);
2685
+ }
2686
+ }
2593
2687
  try {
2594
2688
  await Promise.allSettled(tasks);
2595
2689
  } finally {
package/src/types.ts CHANGED
@@ -456,15 +456,22 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
456
456
  getCwd?: () => string | undefined;
457
457
 
458
458
  /**
459
- * Called after a tool call has been validated and is about to execute.
459
+ * Called once per tool call after argument validation, in call order, before
460
+ * the call is scheduled — ahead of concurrency resolution,
461
+ * `tool_execution_start`, and `tool.execute`. On the streamed path it runs
462
+ * before the assistant message's `message_start`/`message_end` are emitted.
460
463
  *
461
464
  * Return `{ block: true }` to prevent execution. The loop emits an error tool
462
465
  * result instead (using `reason` as the error text, or a default if omitted).
463
466
  *
464
- * Mutating `context.args` in place changes the arguments passed to `tool.execute`
465
- * the loop does **not** re-validate after this hook runs.
467
+ * Return `{ args }` to replace the arguments the call runs with. The
468
+ * replacement is revalidated against the tool schema and written back to the
469
+ * tool-call block, making it the single source of truth: history, execution
470
+ * events, persistence, provider replay, concurrency scheduling, and
471
+ * `tool.execute` all see the revised arguments. Mutating `context.args` in
472
+ * place also survives into execution, but a returned `args` object wins.
466
473
  *
467
- * The hook receives the tool abort signal (`signal`) and is responsible for
474
+ * The hook receives the run's request abort signal and is responsible for
468
475
  * honoring it. Throwing surfaces as a tool-error result and does not abort the
469
476
  * rest of the batch.
470
477
  */
@@ -549,12 +556,16 @@ export type AgentToolCall = Extract<AssistantMessage["content"][number], { type:
549
556
  * Set `block: true` to prevent the tool from executing. The loop emits an error tool
550
557
  * result instead, using `reason` as the error text (or a default if omitted).
551
558
  *
552
- * Mutating the `args` reference passed in `BeforeToolCallContext` is supported and
553
- * survives into execution the loop does **not** re-validate after this hook runs.
559
+ * Set `args` to replace the tool-call arguments. The replacement is revalidated
560
+ * against the tool schema (a failure surfaces as a validation-error tool result),
561
+ * written back to the tool-call block on the assistant message, and seen by
562
+ * history, scheduling, execution events, and `tool.execute` alike. It is
563
+ * ignored when `block` is true.
554
564
  */
555
565
  export interface BeforeToolCallResult {
556
566
  block?: boolean;
557
567
  reason?: string;
568
+ args?: Record<string, unknown>;
558
569
  }
559
570
 
560
571
  /**
@@ -582,9 +593,12 @@ export interface BeforeToolCallContext {
582
593
  assistantMessage: AssistantMessage;
583
594
  /** The raw tool call block from `assistantMessage.content`. */
584
595
  toolCall: AgentToolCall;
596
+ /** The resolved tool the call dispatches to. */
597
+ tool: AgentTool<any>;
585
598
  /**
586
599
  * Validated tool arguments. The same reference is forwarded to `tool.execute`
587
- * (after any `transformToolCallArguments` pass), so in-place mutations stick.
600
+ * (after any `transformToolCallArguments` pass), so in-place mutations stick;
601
+ * a returned `BeforeToolCallResult.args` replaces them entirely.
588
602
  */
589
603
  args: Record<string, unknown>;
590
604
  /** Current agent context at the time the tool call is prepared. */