@oh-my-pi/pi-agent-core 17.1.5 → 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/src/agent-loop.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  type Context,
11
11
  EventStream,
12
12
  isApiKeyResolver,
13
+ type Model,
13
14
  resolveApiKeyOnce,
14
15
  seedApiKeyResolver,
15
16
  streamSimple,
@@ -42,7 +43,7 @@ import {
42
43
  signalListLabel,
43
44
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
44
45
  import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
45
- import { sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
46
+ import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
46
47
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
47
48
  import { agentPauseGate } from "./pause";
48
49
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
@@ -67,10 +68,13 @@ import type {
67
68
  AgentEvent,
68
69
  AgentLoopConfig,
69
70
  AgentMessage,
71
+ AgentPreModelCallResult,
70
72
  AgentTool,
73
+ AgentToolCall,
71
74
  AgentToolResult,
72
75
  AgentTurnEndContext,
73
76
  AsideMessage,
77
+ BeforeToolCallResult,
74
78
  SoftToolRequirement,
75
79
  SteeringInterruptSource,
76
80
  SteeringQueueState,
@@ -526,14 +530,9 @@ export function agentLoop(
526
530
  };
527
531
 
528
532
  stream.push({ type: "agent_start" });
529
- stream.push({ type: "turn_start" });
530
- for (const prompt of prompts) {
531
- stream.push({ type: "message_start", message: prompt });
532
- stream.push({ type: "message_end", message: prompt });
533
- }
534
533
 
535
534
  try {
536
- await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
535
+ await runLoop(currentContext, newMessages, config, signal, stream, streamFn, prompts);
537
536
  } catch (err) {
538
537
  stream.fail(err);
539
538
  }
@@ -571,7 +570,6 @@ export function agentLoopContinue(
571
570
  const currentContext: AgentContext = { ...context, messages: [...context.messages] };
572
571
 
573
572
  stream.push({ type: "agent_start" });
574
- stream.push({ type: "turn_start" });
575
573
 
576
574
  try {
577
575
  await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
@@ -621,14 +619,36 @@ async function emitTurnEnd(
621
619
  config: AgentLoopConfig,
622
620
  signal?: AbortSignal,
623
621
  context?: Omit<AgentTurnEndContext, "message" | "toolResults">,
622
+ runHookOnAbortedMessage = false,
624
623
  ): Promise<void> {
625
624
  stream.push({ type: "turn_end", message, toolResults });
626
625
  const isAbortedOrError =
627
626
  message.role === "assistant" && (message.stopReason === "aborted" || message.stopReason === "error");
628
- if (signal?.aborted || isAbortedOrError) return;
627
+ if (signal?.aborted || (isAbortedOrError && !runHookOnAbortedMessage)) return;
629
628
  await config.onTurnEnd?.(currentContext.messages, signal, { message, toolResults, willContinue: false, ...context });
630
629
  }
631
630
 
631
+ function createGateStopMessage(model: Model, reason: string | undefined): AssistantMessage {
632
+ return {
633
+ role: "assistant",
634
+ content: [{ type: "text", text: "" }],
635
+ api: model.api,
636
+ provider: model.provider,
637
+ model: model.id,
638
+ usage: {
639
+ input: 0,
640
+ output: 0,
641
+ cacheRead: 0,
642
+ cacheWrite: 0,
643
+ totalTokens: 0,
644
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
645
+ },
646
+ stopReason: "aborted",
647
+ errorMessage: reason ?? "Stopped before model call",
648
+ timestamp: Date.now(),
649
+ };
650
+ }
651
+
632
652
  /**
633
653
  * Detailed-result handle returned by {@link agentLoopDetailed}. Adds the
634
654
  * run-level telemetry/coverage rollup to the existing `AgentMessage[]`
@@ -866,6 +886,7 @@ async function runLoop(
866
886
  signal: AbortSignal | undefined,
867
887
  stream: EventStream<AgentEvent, AgentMessage[]>,
868
888
  streamFn?: StreamFn,
889
+ initialMessages: AgentMessage[] = [],
869
890
  ): Promise<void> {
870
891
  const telemetry = resolveTelemetry(config.telemetry, config.sessionId);
871
892
  const invokeAgentSpan = startInvokeAgentSpan(telemetry, config.model);
@@ -882,6 +903,7 @@ async function runLoop(
882
903
  telemetry,
883
904
  invokeAgentSpan,
884
905
  stepCounter,
906
+ initialMessages,
885
907
  streamFn,
886
908
  ),
887
909
  );
@@ -913,6 +935,12 @@ function endAgentStream(
913
935
  stream.push(buildAgentEndEvent(newMessages, telemetry, stepCount));
914
936
  stream.end(newMessages);
915
937
  }
938
+ function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, messages: readonly AgentMessage[]): void {
939
+ for (const message of messages) {
940
+ stream.push({ type: "message_start", message });
941
+ stream.push({ type: "message_end", message });
942
+ }
943
+ }
916
944
 
917
945
  /**
918
946
  * Resolve aside entries at the moment the loop is about to inject them. Each entry
@@ -940,6 +968,7 @@ async function runLoopBody(
940
968
  telemetry: AgentTelemetry | undefined,
941
969
  invokeAgentSpan: Span | undefined,
942
970
  stepCounter: StepCounter,
971
+ initialMessages: AgentMessage[],
943
972
  streamFn?: StreamFn,
944
973
  ): Promise<void> {
945
974
  let deadlineTimer: Timer | undefined;
@@ -957,26 +986,33 @@ async function runLoopBody(
957
986
  signal = signal ? AbortSignal.any([signal, deadlineAbortController.signal]) : deadlineAbortController.signal;
958
987
  }
959
988
 
989
+ const softRequirementState = config.softToolRequirementState ?? { escalations: 0 };
990
+ let preserveSoftRequirementState = false;
991
+
960
992
  try {
961
- let firstTurn = true;
993
+ let messagesToEmit = [...initialMessages];
962
994
  if (isDeadlineExceeded(config.deadline)) {
995
+ emitInputMessages(stream, messagesToEmit);
963
996
  endAgentStream(stream, newMessages, telemetry, stepCounter.count);
964
997
  return;
965
998
  }
966
999
  // Check for steering messages at start (user may have typed while waiting).
967
1000
  // Skip when the run is already externally aborted — dequeuing would strand
968
1001
  // the messages in a run that is about to die.
969
- let pendingMessages: AgentMessage[] = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
1002
+ let pendingMessages: AgentMessage[];
1003
+ try {
1004
+ pendingMessages = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
1005
+ } catch (error) {
1006
+ stream.push({ type: "turn_start" });
1007
+ emitInputMessages(stream, messagesToEmit);
1008
+ throw error;
1009
+ }
970
1010
  let harmonyRetryAttempt = 0;
971
1011
  let harmonyTruncateResumeCount = 0;
972
1012
  let pausedTurnContinuations = 0;
973
1013
 
974
- // Soft tool requirement lifecycle (reminder escalate; see SoftToolRequirement).
975
- // `forcedToolChoice` carries a one-turn escalation into the next model call. It
976
- // overrides the static toolChoice but NEVER the host's hard getToolChoice().
977
- let softRequirementId: string | undefined;
978
- let forcedToolChoice: ToolChoice | undefined;
979
- let softEscalations = 0;
1014
+ // Soft tool requirement lifecycle (reminder then escalation; see SoftToolRequirement).
1015
+ // The host-owned state survives only a gate stop between Agent.prompt calls.
980
1016
  // Resolved once per logical turn at the fetch site below and reused across
981
1017
  // Harmony-leak re-samples (which re-enter the same turn) so the consuming
982
1018
  // getToolChoice is never advanced twice; the flag resets at the message boundary.
@@ -984,6 +1020,7 @@ async function runLoopBody(
984
1020
  let softRequiredTool: string | undefined;
985
1021
  let softSatisfies: SoftToolRequirement["satisfies"];
986
1022
  let directiveResolvedForTurn = false;
1023
+ let turnOpen = false;
987
1024
 
988
1025
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
989
1026
  while (true) {
@@ -992,6 +1029,7 @@ async function runLoopBody(
992
1029
  // Inner loop: process tool calls and steering messages
993
1030
  while (hasMoreToolCalls || pendingMessages.length > 0) {
994
1031
  if (isDeadlineExceeded(config.deadline)) {
1032
+ emitInputMessages(stream, messagesToEmit);
995
1033
  endAgentStream(stream, newMessages, telemetry, stepCounter.count);
996
1034
  return;
997
1035
  }
@@ -1002,62 +1040,109 @@ async function runLoopBody(
1002
1040
  // engaged (host /pause). An external abort releases the park so a
1003
1041
  // cancelled run still unwinds while everything else stays frozen.
1004
1042
  if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(signal);
1005
- if (!firstTurn) {
1006
- stream.push({ type: "turn_start" });
1007
- } else {
1008
- firstTurn = false;
1009
- }
1010
1043
 
1011
- // Process pending messages (inject before next assistant response)
1044
+ // Build the provider-bound context before opening the turn. Queue
1045
+ // messages are added now but their events remain deferred until
1046
+ // provider preparation either succeeds or opens an error turn.
1047
+ const turnMessages = messagesToEmit;
1048
+ messagesToEmit = [];
1012
1049
  if (pendingMessages.length > 0) {
1013
1050
  for (const message of pendingMessages) {
1014
- stream.push({ type: "message_start", message });
1015
- stream.push({ type: "message_end", message });
1016
1051
  currentContext.messages.push(message);
1017
1052
  newMessages.push(message);
1053
+ turnMessages.push(message);
1018
1054
  }
1019
1055
  pendingMessages = [];
1020
1056
  }
1021
1057
 
1022
- // Refresh prompt/tool context from live state before each model call
1023
- if (config.syncContextBeforeModelCall) {
1024
- await config.syncContextBeforeModelCall(currentContext);
1025
- }
1058
+ let preparedProviderCall: PreparedProviderCall;
1059
+ let gateResult: AgentPreModelCallResult;
1060
+ try {
1061
+ if (config.syncContextBeforeModelCall) {
1062
+ await config.syncContextBeforeModelCall(currentContext);
1063
+ }
1026
1064
 
1027
- // Resolve the per-turn tool-choice directive ONCE per logical turn. The
1028
- // host hard-choice path (getToolChoice nextToolChoice) is CONSUMING — it
1029
- // advances a generator on every call — so Harmony-leak retries, which
1030
- // re-sample the same turn via `continue` without a turn_end, must reuse the
1031
- // values fetched on the first attempt rather than double-advancing it.
1032
- // Fetched here (after pending-message flush + context sync, immediately
1033
- // before the call) so a throw in between cannot wedge an in-flight
1034
- // directive. A hard ToolChoice is applied verbatim; a SoftToolRequirement
1035
- // triggers the remind-then-escalate lifecycle: inject its reminder inline
1036
- // once per new id (toolChoice stays auto), and the gate below escalates to
1037
- // a forced choice only if the model declines. The host wrapper already
1038
- // dropped a soft requirement whose tool is inactive.
1039
- if (!directiveResolvedForTurn) {
1040
- const directive = signal?.aborted ? undefined : config.getToolChoice?.();
1041
- const softReq = isSoftToolRequirement(directive) ? directive : undefined;
1042
- hostToolChoice = directive === undefined || isSoftToolRequirement(directive) ? undefined : directive;
1043
- softRequiredTool = softReq?.toolName;
1044
- softSatisfies = softReq?.satisfies;
1045
- if (softReq !== undefined) {
1046
- if (softReq.id !== softRequirementId) {
1047
- softRequirementId = softReq.id;
1048
- softEscalations = 0;
1049
- for (const reminder of softReq.reminder) {
1050
- stream.push({ type: "message_start", message: reminder });
1051
- stream.push({ type: "message_end", message: reminder });
1052
- currentContext.messages.push(reminder);
1053
- newMessages.push(reminder);
1065
+ if (!directiveResolvedForTurn) {
1066
+ const directive = signal?.aborted ? undefined : config.getToolChoice?.();
1067
+ const softReq = isSoftToolRequirement(directive) ? directive : undefined;
1068
+ hostToolChoice = directive === undefined || isSoftToolRequirement(directive) ? undefined : directive;
1069
+ softRequiredTool = softReq?.toolName;
1070
+ softSatisfies = softReq?.satisfies;
1071
+ const softRequirementId = softRequirementState.id;
1072
+ if (softReq !== undefined) {
1073
+ if (softReq.id !== softRequirementId) {
1074
+ softRequirementState.id = softReq.id;
1075
+ softRequirementState.forcedToolChoice = undefined;
1076
+ softRequirementState.escalations = 0;
1077
+ for (const reminder of softReq.reminder) {
1078
+ currentContext.messages.push(reminder);
1079
+ newMessages.push(reminder);
1080
+ turnMessages.push(reminder);
1081
+ }
1054
1082
  }
1083
+ } else {
1084
+ softRequirementState.id = undefined;
1085
+ softRequirementState.forcedToolChoice = undefined;
1086
+ softRequirementState.escalations = 0;
1055
1087
  }
1056
- } else {
1057
- softRequirementId = undefined;
1058
- softEscalations = 0;
1088
+ directiveResolvedForTurn = true;
1089
+ }
1090
+
1091
+ preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
1092
+ gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
1093
+ } catch (error) {
1094
+ if (!turnOpen) {
1095
+ stream.push({ type: "turn_start" });
1096
+ emitInputMessages(stream, turnMessages);
1097
+ turnOpen = true;
1098
+ }
1099
+ throw error;
1100
+ }
1101
+ if (config.beforeModelCall && signal?.aborted) {
1102
+ gateResult = { stop: true };
1103
+ }
1104
+ if (gateResult?.stop) {
1105
+ if (gateResult.reason) {
1106
+ logger.debug("Agent loop stopped before the model call", { reason: gateResult.reason });
1107
+ }
1108
+ if (!turnOpen && !signal?.aborted) {
1109
+ try {
1110
+ config.onToolChoiceRejected?.();
1111
+ } catch (error) {
1112
+ stream.push({ type: "turn_start" });
1113
+ emitInputMessages(stream, turnMessages);
1114
+ turnOpen = true;
1115
+ throw error;
1116
+ }
1117
+ }
1118
+ emitInputMessages(stream, turnMessages);
1119
+ if (turnOpen) {
1120
+ const stopMessage = createGateStopMessage(preparedProviderCall.model, gateResult.reason);
1121
+ currentContext.messages.push(stopMessage);
1122
+ newMessages.push(stopMessage);
1123
+ stream.push({ type: "message_start", message: stopMessage });
1124
+ stream.push({ type: "message_end", message: stopMessage });
1125
+ await emitTurnEnd(
1126
+ stream,
1127
+ currentContext,
1128
+ stopMessage,
1129
+ [],
1130
+ config,
1131
+ signal,
1132
+ { willContinue: false },
1133
+ true,
1134
+ );
1135
+ turnOpen = false;
1059
1136
  }
1060
- directiveResolvedForTurn = true;
1137
+ preserveSoftRequirementState = !signal?.aborted;
1138
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1139
+ return;
1140
+ }
1141
+
1142
+ if (!turnOpen) {
1143
+ stream.push({ type: "turn_start" });
1144
+ emitInputMessages(stream, turnMessages);
1145
+ turnOpen = true;
1061
1146
  }
1062
1147
 
1063
1148
  // Stream assistant response
@@ -1075,7 +1160,8 @@ async function runLoopBody(
1075
1160
  streamFn,
1076
1161
  harmonyRetryAttempt,
1077
1162
  hostToolChoice,
1078
- forcedToolChoice,
1163
+ softRequirementState.forcedToolChoice,
1164
+ preparedProviderCall,
1079
1165
  );
1080
1166
  harmonyRetryAttempt = 0;
1081
1167
  harmonyTruncateResumeCount = 0;
@@ -1118,7 +1204,7 @@ async function runLoopBody(
1118
1204
 
1119
1205
  // The escalation choice (if any) applied to the call above; clear it so
1120
1206
  // only the single escalation turn carries the forced choice.
1121
- forcedToolChoice = undefined;
1207
+ softRequirementState.forcedToolChoice = undefined;
1122
1208
 
1123
1209
  // A fresh logical turn re-resolves the directive next iteration; a Harmony
1124
1210
  // retry `continue`s before this line and keeps the cached value.
@@ -1161,6 +1247,7 @@ async function runLoopBody(
1161
1247
  });
1162
1248
  }
1163
1249
  await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, { willContinue: false });
1250
+ turnOpen = false;
1164
1251
 
1165
1252
  stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
1166
1253
  stream.end(newMessages);
@@ -1212,7 +1299,7 @@ async function runLoopBody(
1212
1299
 
1213
1300
  const toolResults: ToolResultMessage[] = [];
1214
1301
  if (softNonCompliant && softRequiredTool !== undefined) {
1215
- if (softEscalations >= MAX_SOFT_TOOL_ESCALATIONS) {
1302
+ if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
1216
1303
  throw new Error(
1217
1304
  `Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`,
1218
1305
  );
@@ -1239,8 +1326,8 @@ async function runLoopBody(
1239
1326
  status: "skipped",
1240
1327
  });
1241
1328
  }
1242
- forcedToolChoice = { type: "tool", name: softRequiredTool };
1243
- softEscalations++;
1329
+ softRequirementState.forcedToolChoice = { type: "tool", name: softRequiredTool };
1330
+ softRequirementState.escalations++;
1244
1331
  hasMoreToolCalls = true;
1245
1332
  } else if (hasMoreToolCalls) {
1246
1333
  const executionResult = await executeToolCalls(
@@ -1306,6 +1393,7 @@ async function runLoopBody(
1306
1393
  await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
1307
1394
  willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
1308
1395
  });
1396
+ turnOpen = false;
1309
1397
 
1310
1398
  if (isDeadlineExceeded(config.deadline)) {
1311
1399
  endAgentStream(stream, newMessages, telemetry, stepCounter.count);
@@ -1361,6 +1449,11 @@ async function runLoopBody(
1361
1449
 
1362
1450
  endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1363
1451
  } finally {
1452
+ if (!preserveSoftRequirementState) {
1453
+ softRequirementState.id = undefined;
1454
+ softRequirementState.forcedToolChoice = undefined;
1455
+ softRequirementState.escalations = 0;
1456
+ }
1364
1457
  if (deadlineTimer) {
1365
1458
  clearTimeout(deadlineTimer);
1366
1459
  }
@@ -1384,45 +1477,29 @@ async function emitHarmonyAudit(
1384
1477
  );
1385
1478
  }
1386
1479
 
1387
- /**
1388
- * Stream an assistant response from the LLM.
1389
- * This is where AgentMessage[] gets transformed to Message[] for the LLM.
1390
- */
1391
- async function streamAssistantResponse(
1480
+ interface PreparedProviderCall {
1481
+ model: Model;
1482
+ context: Context;
1483
+ promptToolWireTools: Context["tools"];
1484
+ ownedDialect: Dialect | undefined;
1485
+ }
1486
+
1487
+ async function prepareProviderCall(
1392
1488
  context: AgentContext,
1393
1489
  config: AgentLoopConfig,
1394
1490
  signal: AbortSignal | undefined,
1395
- stream: EventStream<AgentEvent, AgentMessage[]>,
1396
- telemetry: AgentTelemetry | undefined,
1397
- invokeAgentSpan: Span | undefined,
1398
- stepCounter: StepCounter,
1399
- streamFn?: StreamFn,
1400
- harmonyRetryAttempt = 0,
1401
- hostToolChoice?: ToolChoice,
1402
- forcedToolChoice?: ToolChoice,
1403
- ): Promise<AssistantMessage> {
1404
- // Re-resolve the model per provider call (like `getReasoning`): mid-run
1405
- // model switches — context promotion, retry fallback — must apply on the
1406
- // next call instead of the run silently finishing on the stale model
1407
- // captured at run start.
1491
+ ): Promise<PreparedProviderCall> {
1408
1492
  const model = config.getModel?.() ?? config.model;
1409
- // Apply context transform if configured (AgentMessage[] → AgentMessage[])
1410
1493
  let messages = context.messages;
1411
1494
  if (config.transformContext) {
1412
1495
  messages = await config.transformContext(messages, signal);
1413
1496
  }
1414
1497
 
1415
- // Convert to LLM-compatible messages (AgentMessage[] → Message[])
1416
1498
  const llmMessages = await config.convertToLlm(messages);
1417
1499
  const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
1418
-
1419
1500
  const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1420
1501
  const exampleDialect = ownedDialect ?? preferredDialect(model.id);
1421
- // Owned/in-band dialects carry the catalog in the prompt as text and send no
1422
- // native `tools`, so description pruning only applies to native tool calling.
1423
1502
  const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
1424
- // Build LLM context — append-only mode caches system prompt + tools
1425
- // AND keeps an append-only message log so prior-turn bytes are stable.
1426
1503
  let llmContext: Context;
1427
1504
  if (config.appendOnlyContext) {
1428
1505
  config.appendOnlyContext.syncMessages(normalizedMessages);
@@ -1442,9 +1519,6 @@ async function streamAssistantResponse(
1442
1519
  llmContext = await config.transformProviderContext(llmContext, model);
1443
1520
  }
1444
1521
 
1445
- // Owned tool calling: take tool calls away from the provider and run them
1446
- // through the selected in-band prompt dialect. `PI_DIALECT=1` still
1447
- // force-enables GLM; `PI_DIALECT=<dialect>` force-enables that dialect.
1448
1522
  let promptToolWireTools: Context["tools"];
1449
1523
  if (ownedDialect && llmContext.tools && llmContext.tools.length > 0) {
1450
1524
  promptToolWireTools = llmContext.tools;
@@ -1455,6 +1529,29 @@ async function streamAssistantResponse(
1455
1529
  tools: undefined,
1456
1530
  };
1457
1531
  }
1532
+ return { model, context: llmContext, promptToolWireTools, ownedDialect };
1533
+ }
1534
+
1535
+ /**
1536
+ * Stream an assistant response from the LLM.
1537
+ * This is where AgentMessage[] gets transformed to Message[] for the LLM.
1538
+ */
1539
+ async function streamAssistantResponse(
1540
+ context: AgentContext,
1541
+ config: AgentLoopConfig,
1542
+ signal: AbortSignal | undefined,
1543
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1544
+ telemetry: AgentTelemetry | undefined,
1545
+ invokeAgentSpan: Span | undefined,
1546
+ stepCounter: StepCounter,
1547
+ streamFn?: StreamFn,
1548
+ harmonyRetryAttempt = 0,
1549
+ hostToolChoice?: ToolChoice,
1550
+ forcedToolChoice?: ToolChoice,
1551
+ prepared?: PreparedProviderCall,
1552
+ ): Promise<AssistantMessage> {
1553
+ const providerCall = prepared ?? (await prepareProviderCall(context, config, signal));
1554
+ const { model, context: llmContext, promptToolWireTools, ownedDialect } = providerCall;
1458
1555
 
1459
1556
  const streamFunction = streamFn || streamSimple;
1460
1557
 
@@ -1657,6 +1754,17 @@ async function streamAssistantResponse(
1657
1754
  if (config.transformAssistantMessage) {
1658
1755
  await config.transformAssistantMessage(finalMessage, requestSignal);
1659
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
+ }
1660
1768
  if (addedPartial) {
1661
1769
  context.messages[context.messages.length - 1] = finalMessage;
1662
1770
  } else {
@@ -1956,6 +2064,136 @@ function emitAbortedAssistantMessage(
1956
2064
  return abortedMessage;
1957
2065
  }
1958
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
+ }
1959
2197
  /**
1960
2198
  * Execute tool calls from an assistant message.
1961
2199
  */
@@ -1976,8 +2214,6 @@ async function executeToolCalls(
1976
2214
  getToolContext,
1977
2215
  transformToolCallArguments,
1978
2216
  resolveFallbackTool,
1979
- intentTracing,
1980
- beforeToolCall,
1981
2217
  afterToolCall,
1982
2218
  } = config;
1983
2219
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
@@ -2011,22 +2247,25 @@ async function executeToolCalls(
2011
2247
  : AbortSignal.any([steeringAbortController.signal, ircAbortController.signal]);
2012
2248
  const interruptState: { triggered: boolean; source?: SteeringInterruptSource | "irc" } = { triggered: false };
2013
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
+
2014
2257
  const records = toolCalls.map(toolCall => {
2015
- // Tools emitted via OpenAI's custom-tool path (e.g. `apply_patch` on GPT-5)
2016
- // come back under their wire-level name, which may differ from the
2017
- // harness-internal `name`. Match on either, preferring `name` for
2018
- // determinism if both somehow collide.
2019
- const tool =
2020
- tools?.find(t => t.name === toolCall.name) ??
2021
- tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name) ??
2022
- // Not in the advertised set: let the host route side-transport tools
2023
- // (e.g. xd:// device mounts) called by their top-level name.
2024
- resolveFallbackTool?.(toolCall.name);
2025
- 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;
2026
2263
  const interruptibleMode = tool?.interruptible;
2027
2264
  let interruptible = false;
2028
2265
  if (typeof interruptibleMode === "function") {
2029
2266
  try {
2267
+ // Resolved from the prepared (possibly hook-revised) args so an
2268
+ // argument-dependent policy governs the call that actually runs.
2030
2269
  interruptible = interruptibleMode(args);
2031
2270
  } catch {
2032
2271
  // Resolver failures default to preserving the tool's outcome.
@@ -2047,6 +2286,10 @@ async function executeToolCalls(
2047
2286
  skipped: false,
2048
2287
  toolResultMessage: undefined as ToolResultMessage | undefined,
2049
2288
  resultEmitted: false,
2289
+ validationErrorMessage: prepared.validationErrorMessage,
2290
+ blocked: prepared.blocked === true,
2291
+ blockReason: prepared.blockReason,
2292
+ prepareError: prepared.prepareError,
2050
2293
  };
2051
2294
  });
2052
2295
 
@@ -2162,61 +2405,21 @@ async function executeToolCalls(
2162
2405
  if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(record.signal);
2163
2406
 
2164
2407
  const { toolCall, tool } = record;
2165
- let argsForExecution = toolCall.arguments as Record<string, unknown>;
2166
- if (intentTracing) {
2167
- const { intent, strippedArgs } = extractIntent(toolCall.arguments);
2168
- argsForExecution = strippedArgs;
2169
- if (intent) {
2170
- toolCall.intent = intent;
2171
- } else if (typeof tool?.intent === "function") {
2172
- try {
2173
- const derived = tool.intent(strippedArgs as never)?.trim();
2174
- if (derived) {
2175
- toolCall.intent = derived;
2176
- }
2177
- } catch {
2178
- // intent function must never break tool execution
2179
- }
2180
- }
2181
- }
2182
- let effectiveArgs: Record<string, unknown>;
2183
- try {
2184
- if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
2185
- effectiveArgs = validateToolArguments(tool, { ...toolCall, arguments: argsForExecution });
2186
- } catch (validationError) {
2187
- if (tool?.lenientArgValidation) {
2188
- effectiveArgs = { ...argsForExecution };
2189
- delete effectiveArgs.__parseError;
2190
- delete effectiveArgs.__rawJson;
2191
- } else {
2192
- if ("__parseError" in argsForExecution) {
2193
- record.args = {
2194
- __parseError: argsForExecution.__parseError,
2195
- };
2196
- } else {
2197
- record.args = argsForExecution;
2198
- }
2199
- emitToolResult(
2200
- record,
2201
- {
2202
- content: [
2203
- {
2204
- type: "text" as const,
2205
- text: validationError instanceof Error ? validationError.message : String(validationError),
2206
- },
2207
- ],
2208
- details: {
2209
- isError: true,
2210
- error: validationError instanceof Error ? validationError.message : String(validationError),
2211
- },
2212
- },
2213
- true,
2214
- );
2215
- return;
2216
- }
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;
2217
2421
  }
2218
-
2219
- record.args = effectiveArgs;
2422
+ const effectiveArgs = record.args;
2220
2423
  if (record.signal.aborted) {
2221
2424
  record.skipped = true;
2222
2425
  recordSkippedTool(telemetry, {
@@ -2261,24 +2464,9 @@ async function executeToolCalls(
2261
2464
  return;
2262
2465
  }
2263
2466
 
2264
- if (beforeToolCall) {
2265
- const beforeResult = await beforeToolCall(
2266
- {
2267
- assistantMessage,
2268
- toolCall,
2269
- args: effectiveArgs,
2270
- context: currentContext,
2271
- },
2272
- record.signal,
2273
- );
2274
- if (beforeResult?.block) {
2275
- throw new ToolCallBlockedError(beforeResult.reason);
2276
- }
2277
- }
2278
- if (record.signal.aborted) {
2279
- result = createToolSignalAbortedResult(record.signal);
2280
- isError = true;
2281
- return;
2467
+ if (record.prepareError !== undefined) throw record.prepareError;
2468
+ if (record.blocked) {
2469
+ throw new ToolCallBlockedError(record.blockReason);
2282
2470
  }
2283
2471
  const executionArgs = transformToolCallArguments
2284
2472
  ? transformToolCallArguments(effectiveArgs, toolCall.name)
@@ -2413,32 +2601,6 @@ async function executeToolCalls(
2413
2601
  let sharedTasks: Promise<void>[] = [];
2414
2602
  const tasks: Promise<void>[] = [];
2415
2603
 
2416
- for (let index = 0; index < records.length; index++) {
2417
- const record = records[index];
2418
- const concurrencyMode = record.tool?.concurrency;
2419
- let concurrency: "shared" | "exclusive";
2420
- if (typeof concurrencyMode === "function") {
2421
- // Resolved from raw pre-validation args; a throwing resolver must not
2422
- // take down the whole batch, so fall back to the safe (serial) mode.
2423
- try {
2424
- concurrency = concurrencyMode(record.args);
2425
- } catch {
2426
- concurrency = "exclusive";
2427
- }
2428
- } else {
2429
- concurrency = concurrencyMode ?? "shared";
2430
- }
2431
- const start = concurrency === "exclusive" ? Promise.all([lastExclusive, ...sharedTasks]) : lastExclusive;
2432
- const task = start.then(() => runTool(record, index));
2433
- tasks.push(task);
2434
- if (concurrency === "exclusive") {
2435
- lastExclusive = task;
2436
- sharedTasks = [];
2437
- } else {
2438
- sharedTasks.push(task);
2439
- }
2440
- }
2441
-
2442
2604
  // While tool calls are in flight, queued steering or interrupting IRC would
2443
2605
  // otherwise wait out the tools' own window. Poll only non-consuming queues:
2444
2606
  // detection hard-aborts interruptible waits, soft-signals cooperative tools
@@ -2495,6 +2657,33 @@ async function executeToolCalls(
2495
2657
  STEERING_INTERRUPT_POLL_MS,
2496
2658
  )
2497
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
+ }
2498
2687
  try {
2499
2688
  await Promise.allSettled(tasks);
2500
2689
  } finally {