@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 +6 -0
- package/dist/types/types.d.ts +21 -7
- package/package.json +7 -7
- package/src/agent-loop.ts +205 -111
- package/src/types.ts +21 -7
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
|
package/dist/types/types.d.ts
CHANGED
|
@@ -390,15 +390,22 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
390
390
|
*/
|
|
391
391
|
getCwd?: () => string | undefined;
|
|
392
392
|
/**
|
|
393
|
-
* Called
|
|
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
|
-
*
|
|
399
|
-
*
|
|
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
|
|
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
|
-
*
|
|
481
|
-
*
|
|
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.
|
|
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.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "17.1.
|
|
40
|
-
"@oh-my-pi/pi-natives": "17.1.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.1.
|
|
42
|
-
"@oh-my-pi/pi-wire": "17.1.
|
|
43
|
-
"@oh-my-pi/snapcompact": "17.1.
|
|
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
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
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
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
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 (
|
|
2360
|
-
|
|
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
|
|
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
|
-
*
|
|
465
|
-
*
|
|
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
|
|
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
|
-
*
|
|
553
|
-
*
|
|
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. */
|