@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/CHANGELOG.md +16 -0
- package/dist/types/agent.d.ts +21 -1
- package/dist/types/types.d.ts +63 -7
- package/package.json +7 -7
- package/src/agent-loop.ts +393 -204
- package/src/agent.ts +82 -1
- package/src/types.ts +73 -7
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
|
|
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[]
|
|
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
|
|
975
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1058
|
+
let preparedProviderCall: PreparedProviderCall;
|
|
1059
|
+
let gateResult: AgentPreModelCallResult;
|
|
1060
|
+
try {
|
|
1061
|
+
if (config.syncContextBeforeModelCall) {
|
|
1062
|
+
await config.syncContextBeforeModelCall(currentContext);
|
|
1063
|
+
}
|
|
1026
1064
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
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
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
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 (
|
|
2265
|
-
|
|
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 {
|