@linxiraos/pi-agent-core 1.1.19 → 1.1.22
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 +253 -10
- package/README.md +4 -3
- package/THIRD-PARTY-NOTICES.txt +3 -40
- package/dist/types/agent-loop.d.ts +14 -1
- package/dist/types/agent.d.ts +36 -0
- package/dist/types/compaction/anthropic.d.ts +22 -52
- package/dist/types/compaction/branch-summarization.d.ts +1 -1
- package/dist/types/compaction/compaction.d.ts +4 -2
- package/dist/types/compaction/messages.d.ts +9 -0
- package/dist/types/compaction/openai.d.ts +11 -0
- package/dist/types/compaction/pruning.d.ts +9 -0
- package/dist/types/compaction/transcript-tokens.d.ts +25 -2
- package/dist/types/index.d.ts +3 -0
- package/dist/types/live-steering.d.ts +34 -0
- package/dist/types/output-budget.d.ts +54 -0
- package/dist/types/sent-tool-definitions.d.ts +17 -0
- package/dist/types/speculative-execution.d.ts +2 -1
- package/dist/types/telemetry.d.ts +103 -60
- package/dist/types/tool-context.d.ts +32 -0
- package/dist/types/types.d.ts +105 -7
- package/package.json +10 -10
- package/src/agent-loop.ts +386 -90
- package/src/agent.ts +175 -26
- package/src/compaction/anthropic.ts +62 -105
- package/src/compaction/branch-summarization.ts +1 -1
- package/src/compaction/compaction-v2-streaming.ts +13 -4
- package/src/compaction/compaction.ts +63 -51
- package/src/compaction/messages.ts +12 -2
- package/src/compaction/openai.ts +37 -9
- package/src/compaction/prompts/anthropic-compaction-instructions.md +2 -6
- package/src/compaction/pruning.ts +1 -1
- package/src/compaction/transcript-tokens.ts +64 -3
- package/src/index.ts +6 -0
- package/src/live-steering.ts +89 -0
- package/src/output-budget.ts +142 -0
- package/src/sent-tool-definitions.ts +40 -0
- package/src/speculative-execution.ts +12 -0
- package/src/telemetry.ts +240 -117
- package/src/tokenizer.ts +45 -6
- package/src/tool-context.ts +55 -0
- package/src/types.ts +109 -7
package/src/agent-loop.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import {
|
|
6
6
|
type AssistantMessage,
|
|
7
7
|
type AssistantMessageEvent,
|
|
8
|
+
type ApiKeyResolution,
|
|
8
9
|
type ComputerAction,
|
|
9
10
|
type ComputerSafetyCheck,
|
|
10
11
|
type Context,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
type ToolResultProviderMetadata,
|
|
22
23
|
type TSchema,
|
|
23
24
|
toolWireSchema,
|
|
25
|
+
type UserMessage,
|
|
24
26
|
validateToolArguments,
|
|
25
27
|
} from "@linxiraos/pi-ai";
|
|
26
28
|
import {
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
getStreamingPartialJson,
|
|
38
40
|
kCursorExecResolved,
|
|
39
41
|
} from "@linxiraos/pi-ai/utils/block-symbols";
|
|
42
|
+
import { schemaDefinesProperty } from "@linxiraos/pi-ai/utils/schema/json-schema-validator";
|
|
40
43
|
import { stamp } from "@linxiraos/pi-ai/utils/schema/stamps";
|
|
41
44
|
import {
|
|
42
45
|
createHarmonyAuditEvent,
|
|
@@ -48,8 +51,9 @@ import {
|
|
|
48
51
|
recoverHarmonyToolCall,
|
|
49
52
|
signalListLabel,
|
|
50
53
|
} from "@linxiraos/pi-ai/utils/harmony-leak";
|
|
51
|
-
import { logger, sanitizeText, structuredCloneJSON } from "@linxiraos/pi-utils";
|
|
54
|
+
import { cloneJsonTree, logger, sanitizeText, structuredCloneJSON } from "@linxiraos/pi-utils";
|
|
52
55
|
import { INTENT_FIELD } from "@linxiraos/pi-wire";
|
|
56
|
+
import { LiveSteeringChannel } from "./live-steering";
|
|
53
57
|
import { agentPauseGate } from "./pause";
|
|
54
58
|
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
|
55
59
|
import { SpeculativeOperationCoordinator } from "./speculative-execution";
|
|
@@ -60,7 +64,7 @@ import {
|
|
|
60
64
|
finishExecuteToolSpan,
|
|
61
65
|
finishInvokeAgentSpan,
|
|
62
66
|
fireOnRunEnd,
|
|
63
|
-
|
|
67
|
+
OmpGenAIAttr,
|
|
64
68
|
recordSkippedTool,
|
|
65
69
|
resolveTelemetry,
|
|
66
70
|
runInActiveSpan,
|
|
@@ -69,6 +73,7 @@ import {
|
|
|
69
73
|
startExecuteToolSpan,
|
|
70
74
|
startInvokeAgentSpan,
|
|
71
75
|
} from "./telemetry";
|
|
76
|
+
import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
|
|
72
77
|
import type {
|
|
73
78
|
AgentContext,
|
|
74
79
|
AgentEvent,
|
|
@@ -164,6 +169,13 @@ export function createToolScopedAbortReason(
|
|
|
164
169
|
*/
|
|
165
170
|
export const TERMINAL_TOOL_RESULT_ABORT_REASON = Symbol.for("pi-agent-core.terminal-tool-result");
|
|
166
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Abort reason carried by an interruptible tool's signal when queued steering,
|
|
174
|
+
* a peer IRC, or a background completion cut it short. Lets a wait tell the
|
|
175
|
+
* designed wake path apart from an external/user abort of the run.
|
|
176
|
+
*/
|
|
177
|
+
export const TOOL_INTERRUPT_ABORT_REASON = Symbol.for("pi-agent-core.tool-interrupt");
|
|
178
|
+
|
|
167
179
|
const STEERING_INTERRUPT_POLL_MS = 250;
|
|
168
180
|
|
|
169
181
|
class HarmonyLeakInterruption extends Error {
|
|
@@ -365,13 +377,17 @@ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantC
|
|
|
365
377
|
case "redactedThinking":
|
|
366
378
|
return { ...block };
|
|
367
379
|
case "anthropicServerTool":
|
|
368
|
-
return { ...block, block:
|
|
380
|
+
return { ...block, block: cloneJsonTree(block.block) };
|
|
369
381
|
case "fallback":
|
|
370
382
|
return { ...block, from: { ...block.from }, to: { ...block.to } };
|
|
371
383
|
case "toolCall": {
|
|
372
384
|
const snap = {
|
|
373
385
|
...block,
|
|
374
|
-
arguments
|
|
386
|
+
// Providers mutate streaming arguments in place (owned-stream, GLM)
|
|
387
|
+
// as well as replacing them, so containers are always copied; the
|
|
388
|
+
// strings inside are immutable and shared, keeping the per-delta
|
|
389
|
+
// cost independent of the argument payload size.
|
|
390
|
+
arguments: cloneJsonTree(block.arguments),
|
|
375
391
|
providerMetadata: snapshotToolCallProviderMetadata(block.providerMetadata),
|
|
376
392
|
};
|
|
377
393
|
// Object spread copies enumerable symbols in Bun, but the Cursor
|
|
@@ -599,18 +615,18 @@ export function agentLoop(
|
|
|
599
615
|
const stream = createAgentStream();
|
|
600
616
|
|
|
601
617
|
(async () => {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
(prompt
|
|
609
|
-
|
|
618
|
+
try {
|
|
619
|
+
const newMessages: AgentMessage[] = [...prompts];
|
|
620
|
+
const currentContext: AgentContext = {
|
|
621
|
+
...context,
|
|
622
|
+
messages: [...context.messages, ...prompts],
|
|
623
|
+
};
|
|
624
|
+
for (const prompt of prompts) {
|
|
625
|
+
(prompt as CommittableAsideMessage)[ASIDE_MESSAGE_COMMIT]?.();
|
|
626
|
+
}
|
|
610
627
|
|
|
611
|
-
|
|
628
|
+
stream.push({ type: "agent_start" });
|
|
612
629
|
|
|
613
|
-
try {
|
|
614
630
|
await runLoop(currentContext, newMessages, config, signal, stream, streamFn, prompts);
|
|
615
631
|
} catch (err) {
|
|
616
632
|
stream.fail(err);
|
|
@@ -668,12 +684,12 @@ export function agentLoopContinue(
|
|
|
668
684
|
const stream = createAgentStream();
|
|
669
685
|
|
|
670
686
|
(async () => {
|
|
671
|
-
|
|
672
|
-
|
|
687
|
+
try {
|
|
688
|
+
const newMessages: AgentMessage[] = [];
|
|
689
|
+
const currentContext: AgentContext = { ...context, messages: [...context.messages] };
|
|
673
690
|
|
|
674
|
-
|
|
691
|
+
stream.push({ type: "agent_start" });
|
|
675
692
|
|
|
676
|
-
try {
|
|
677
693
|
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
|
678
694
|
} catch (err) {
|
|
679
695
|
stream.fail(err);
|
|
@@ -740,6 +756,7 @@ async function emitTurnEnd(
|
|
|
740
756
|
await config.onTurnEnd?.(currentContext.messages, terminalYield ? undefined : signal, {
|
|
741
757
|
message,
|
|
742
758
|
toolResults,
|
|
759
|
+
additionalMessages: [],
|
|
743
760
|
willContinue: false,
|
|
744
761
|
...context,
|
|
745
762
|
});
|
|
@@ -1010,6 +1027,13 @@ function resolveIntentMode(intent: AgentTool["intent"]): "require" | "optional"
|
|
|
1010
1027
|
return "require";
|
|
1011
1028
|
}
|
|
1012
1029
|
|
|
1030
|
+
/**
|
|
1031
|
+
* Longest `i` value accepted as an intent. The injected field is described as
|
|
1032
|
+
* a "concise intent" (INTENT_FIELD_DESCRIPTION); anything past this is a tool
|
|
1033
|
+
* payload the model put in the wrong field, not a label.
|
|
1034
|
+
*/
|
|
1035
|
+
const MAX_INTENT_LENGTH = 200;
|
|
1036
|
+
|
|
1013
1037
|
function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
|
|
1014
1038
|
const { [INTENT_FIELD]: intent, ...strippedArgs } = args;
|
|
1015
1039
|
if (typeof intent !== "string") {
|
|
@@ -1088,6 +1112,26 @@ function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, mess
|
|
|
1088
1112
|
}
|
|
1089
1113
|
}
|
|
1090
1114
|
|
|
1115
|
+
/**
|
|
1116
|
+
* Append passive tool-call context after its results as a developer message.
|
|
1117
|
+
* Returns the injected message for turn-end bookkeeping, or undefined when
|
|
1118
|
+
* there is nothing to inject. Shared by the normal tool-call path and the
|
|
1119
|
+
* resume-tail replay so replayed calls deliver context identically.
|
|
1120
|
+
*/
|
|
1121
|
+
function injectExecutionAdditionalContext(
|
|
1122
|
+
currentContext: AgentContext,
|
|
1123
|
+
newMessages: AgentMessage[],
|
|
1124
|
+
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
1125
|
+
additionalContext: string | undefined,
|
|
1126
|
+
): AgentMessage | undefined {
|
|
1127
|
+
if (additionalContext === undefined) return undefined;
|
|
1128
|
+
const contextMessage = createAdditionalContextMessage(additionalContext);
|
|
1129
|
+
currentContext.messages.push(contextMessage);
|
|
1130
|
+
newMessages.push(contextMessage);
|
|
1131
|
+
emitInputMessages(stream, [contextMessage]);
|
|
1132
|
+
return contextMessage;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1091
1135
|
/**
|
|
1092
1136
|
* Resolve aside entries at the moment the loop is about to inject them. Each entry
|
|
1093
1137
|
* is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
|
|
@@ -1112,7 +1156,11 @@ function resolveAsides(entries: AsideMessage[] | undefined): AgentMessage[] {
|
|
|
1112
1156
|
|
|
1113
1157
|
function discardAsides(messages: readonly AgentMessage[], error: Error): void {
|
|
1114
1158
|
for (const message of messages) {
|
|
1115
|
-
|
|
1159
|
+
try {
|
|
1160
|
+
(message as CommittableAsideMessage)[ASIDE_MESSAGE_DISCARD]?.(error);
|
|
1161
|
+
} catch (discardError) {
|
|
1162
|
+
logger.error("Aside discard hook threw", { error: discardError });
|
|
1163
|
+
}
|
|
1116
1164
|
}
|
|
1117
1165
|
}
|
|
1118
1166
|
|
|
@@ -1147,6 +1195,10 @@ async function runLoopBody(
|
|
|
1147
1195
|
let preserveSoftRequirementState = false;
|
|
1148
1196
|
|
|
1149
1197
|
let pendingMessages: AgentMessage[] = [];
|
|
1198
|
+
// Steering the provider took from the queue during the last response:
|
|
1199
|
+
// `liveAccepted` reached the model inside it, `liveDeferred` did not.
|
|
1200
|
+
let liveAccepted: AgentMessage[] = [];
|
|
1201
|
+
let liveDeferred: AgentMessage[] = [];
|
|
1150
1202
|
try {
|
|
1151
1203
|
let messagesToEmit = [...initialMessages];
|
|
1152
1204
|
if (isDeadlineExceeded(config.deadline)) {
|
|
@@ -1199,13 +1251,21 @@ async function runLoopBody(
|
|
|
1199
1251
|
config,
|
|
1200
1252
|
telemetry,
|
|
1201
1253
|
invokeAgentSpan,
|
|
1254
|
+
[],
|
|
1202
1255
|
);
|
|
1203
1256
|
for (const result of executionResult.toolResults) {
|
|
1204
1257
|
currentContext.messages.push(result);
|
|
1205
1258
|
newMessages.push(result);
|
|
1206
1259
|
}
|
|
1260
|
+
const resumeContextMessage = injectExecutionAdditionalContext(
|
|
1261
|
+
currentContext,
|
|
1262
|
+
newMessages,
|
|
1263
|
+
stream,
|
|
1264
|
+
executionResult.additionalContext,
|
|
1265
|
+
);
|
|
1207
1266
|
await emitTurnEnd(stream, currentContext, resumeTail, executionResult.toolResults, config, signal, {
|
|
1208
1267
|
willContinue: !isDeadlineExceeded(config.deadline),
|
|
1268
|
+
...(resumeContextMessage ? { additionalMessages: [resumeContextMessage] } : {}),
|
|
1209
1269
|
});
|
|
1210
1270
|
turnOpen = false;
|
|
1211
1271
|
// A tool hook may mark its completed result as terminal (e.g. subagent
|
|
@@ -1284,6 +1344,7 @@ async function runLoopBody(
|
|
|
1284
1344
|
}
|
|
1285
1345
|
|
|
1286
1346
|
preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
|
|
1347
|
+
preparedProviderCall.liveSteering = openLiveSteering(config, signal, preparedProviderCall);
|
|
1287
1348
|
gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
|
|
1288
1349
|
} catch (error) {
|
|
1289
1350
|
if (!turnOpen) {
|
|
@@ -1410,6 +1471,12 @@ async function runLoopBody(
|
|
|
1410
1471
|
harmonyRetryAttempt++;
|
|
1411
1472
|
continue;
|
|
1412
1473
|
}
|
|
1474
|
+
} finally {
|
|
1475
|
+
const channel = preparedProviderCall.liveSteering;
|
|
1476
|
+
if (channel) {
|
|
1477
|
+
liveAccepted.push(...channel.accepted);
|
|
1478
|
+
liveDeferred.push(...channel.deferred);
|
|
1479
|
+
}
|
|
1413
1480
|
}
|
|
1414
1481
|
if (recovered) {
|
|
1415
1482
|
message = snapshotAssistantMessage(message);
|
|
@@ -1515,6 +1582,7 @@ async function runLoopBody(
|
|
|
1515
1582
|
const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
|
|
1516
1583
|
|
|
1517
1584
|
const toolResults: ToolResultMessage[] = [];
|
|
1585
|
+
const additionalMessages: AgentMessage[] = [];
|
|
1518
1586
|
if (softNonCompliant && softRequiredTool !== undefined) {
|
|
1519
1587
|
SpeculativeOperationCoordinator.discardForMessage(message, "soft tool requirement deferred execution");
|
|
1520
1588
|
if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
|
|
@@ -1556,14 +1624,21 @@ async function runLoopBody(
|
|
|
1556
1624
|
config,
|
|
1557
1625
|
telemetry,
|
|
1558
1626
|
invokeAgentSpan,
|
|
1627
|
+
[...liveAccepted, ...liveDeferred],
|
|
1559
1628
|
);
|
|
1560
|
-
|
|
1561
1629
|
toolResults.push(...executionResult.toolResults);
|
|
1562
1630
|
|
|
1563
1631
|
for (const result of toolResults) {
|
|
1564
1632
|
currentContext.messages.push(result);
|
|
1565
1633
|
newMessages.push(result);
|
|
1566
1634
|
}
|
|
1635
|
+
const injectedContext = injectExecutionAdditionalContext(
|
|
1636
|
+
currentContext,
|
|
1637
|
+
newMessages,
|
|
1638
|
+
stream,
|
|
1639
|
+
executionResult.additionalContext,
|
|
1640
|
+
);
|
|
1641
|
+
if (injectedContext) additionalMessages.push(injectedContext);
|
|
1567
1642
|
} else if (toolCalls.length > 0) {
|
|
1568
1643
|
SpeculativeOperationCoordinator.discardForMessage(
|
|
1569
1644
|
message,
|
|
@@ -1614,6 +1689,7 @@ async function runLoopBody(
|
|
|
1614
1689
|
}
|
|
1615
1690
|
|
|
1616
1691
|
await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
|
|
1692
|
+
additionalMessages,
|
|
1617
1693
|
willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
|
|
1618
1694
|
});
|
|
1619
1695
|
turnOpen = false;
|
|
@@ -1628,16 +1704,36 @@ async function runLoopBody(
|
|
|
1628
1704
|
// instantly aborts — message lands in history, agent never responds. The
|
|
1629
1705
|
// mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
|
|
1630
1706
|
// still owns every message until this dequeue.
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1707
|
+
// Aborted: live-taken steering stays unrecorded, so the agent returns
|
|
1708
|
+
// it to the queue for the continuation run.
|
|
1709
|
+
const live = signal?.aborted ? [] : [...liveAccepted, ...liveDeferred];
|
|
1710
|
+
const liveReachedModel = !signal?.aborted && liveAccepted.length > 0;
|
|
1711
|
+
if (liveReachedModel) {
|
|
1712
|
+
for (const message of liveAccepted) {
|
|
1713
|
+
if (message.role === "user") message.liveSteered = true;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
liveAccepted = [];
|
|
1717
|
+
liveDeferred = [];
|
|
1718
|
+
if (liveReachedModel) {
|
|
1719
|
+
// The server continues from exactly this steering; anything else
|
|
1720
|
+
// queued now would not line up with its continuation, so it waits
|
|
1721
|
+
// for the next boundary (or is steered into that response).
|
|
1722
|
+
pendingMessages = live;
|
|
1636
1723
|
} else {
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1724
|
+
const steering = signal?.aborted
|
|
1725
|
+
? []
|
|
1726
|
+
: [...live, ...((await config.getSteeringMessages?.(signal)) || [])];
|
|
1727
|
+
if (hasMoreToolCalls) {
|
|
1728
|
+
// Mid-work: fold any non-interrupting asides into the next turn alongside steering.
|
|
1729
|
+
const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
|
|
1730
|
+
pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
|
|
1731
|
+
} else {
|
|
1732
|
+
// Stop boundary: only steering (live user input) forces another turn here. Leave
|
|
1733
|
+
// asides for the outer drain below so a passive aside can't trigger an extra model
|
|
1734
|
+
// turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
|
|
1735
|
+
pendingMessages = steering;
|
|
1736
|
+
}
|
|
1641
1737
|
}
|
|
1642
1738
|
}
|
|
1643
1739
|
|
|
@@ -1706,6 +1802,70 @@ interface PreparedProviderCall {
|
|
|
1706
1802
|
context: Context;
|
|
1707
1803
|
promptToolWireTools: Context["tools"];
|
|
1708
1804
|
ownedDialect: Dialect | undefined;
|
|
1805
|
+
/** Steering source offered to the provider for this call. */
|
|
1806
|
+
liveSteering?: LiveSteeringChannel;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* Classify the first `count` steering messages for a tool-batch interrupt:
|
|
1811
|
+
* any user-authored message wins, then agent-attributed user messages, else
|
|
1812
|
+
* system steering (advisor cards, hidden directives). Shared by the agent's
|
|
1813
|
+
* queue peek and the loop's live-taken steering.
|
|
1814
|
+
*/
|
|
1815
|
+
export function steeringQueueState(messages: readonly AgentMessage[], count = messages.length): SteeringQueueState {
|
|
1816
|
+
if (count === 0) return { queued: false };
|
|
1817
|
+
let hasAgentSteering = false;
|
|
1818
|
+
for (let i = 0; i < count; i++) {
|
|
1819
|
+
const message = messages[i];
|
|
1820
|
+
const role = "role" in message ? message.role : undefined;
|
|
1821
|
+
const attribution = "attribution" in message ? message.attribution : undefined;
|
|
1822
|
+
if (attribution === "user") return { queued: true, source: "user" };
|
|
1823
|
+
if (role !== "user") continue;
|
|
1824
|
+
if (attribution !== "agent") return { queued: true, source: "user" };
|
|
1825
|
+
hasAgentSteering = true;
|
|
1826
|
+
}
|
|
1827
|
+
return { queued: true, source: hasAgentSteering ? "agent" : "system" };
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* Offer queued steering to a provider that can deliver it into the response it
|
|
1832
|
+
* is streaming. Latency decides whether steering lands before the model commits
|
|
1833
|
+
* to its next output, so claims convert only the steering batch — message-level
|
|
1834
|
+
* transforms (steering envelope, redaction) — never the whole transcript.
|
|
1835
|
+
* Provider-context transforms rewrite images, so image-bearing steering waits
|
|
1836
|
+
* for the boundary rather than risk bytes the next request would not replay.
|
|
1837
|
+
*/
|
|
1838
|
+
function openLiveSteering(
|
|
1839
|
+
config: AgentLoopConfig,
|
|
1840
|
+
loopSignal: AbortSignal | undefined,
|
|
1841
|
+
prepared: PreparedProviderCall,
|
|
1842
|
+
): LiveSteeringChannel | undefined {
|
|
1843
|
+
const { getSteeringMessages, waitForSteeringMessages } = config;
|
|
1844
|
+
if (!getSteeringMessages || !waitForSteeringMessages || prepared.ownedDialect) return undefined;
|
|
1845
|
+
const bound = (signal: AbortSignal): AbortSignal => (loopSignal ? AbortSignal.any([signal, loopSignal]) : signal);
|
|
1846
|
+
return new LiveSteeringChannel({
|
|
1847
|
+
wait: signal => waitForSteeringMessages(bound(signal)),
|
|
1848
|
+
take: async signal => {
|
|
1849
|
+
const messages = await getSteeringMessages(bound(signal));
|
|
1850
|
+
if (messages.length > 0) config.onLiveSteeringTaken?.(messages);
|
|
1851
|
+
return messages;
|
|
1852
|
+
},
|
|
1853
|
+
toProvider: async (messages, signal) => {
|
|
1854
|
+
const transformed = config.transformContext
|
|
1855
|
+
? await config.transformContext(messages, bound(signal))
|
|
1856
|
+
: messages;
|
|
1857
|
+
const converted = normalizeMessagesForProvider(await config.convertToLlm(transformed), prepared.model);
|
|
1858
|
+
const userMessages: UserMessage[] = [];
|
|
1859
|
+
for (const message of converted) {
|
|
1860
|
+
if (message.role !== "user") return undefined;
|
|
1861
|
+
if (typeof message.content !== "string" && message.content.some(part => part.type === "image")) {
|
|
1862
|
+
return undefined;
|
|
1863
|
+
}
|
|
1864
|
+
userMessages.push(message);
|
|
1865
|
+
}
|
|
1866
|
+
return userMessages.length > 0 ? userMessages : undefined;
|
|
1867
|
+
},
|
|
1868
|
+
});
|
|
1709
1869
|
}
|
|
1710
1870
|
|
|
1711
1871
|
async function prepareProviderCall(
|
|
@@ -1721,7 +1881,8 @@ async function prepareProviderCall(
|
|
|
1721
1881
|
|
|
1722
1882
|
const llmMessages = await config.convertToLlm(messages);
|
|
1723
1883
|
const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
|
|
1724
|
-
const ownedDialect: Dialect | undefined =
|
|
1884
|
+
const ownedDialect: Dialect | undefined =
|
|
1885
|
+
(config.getDialect ? config.getDialect(model) : config.dialect) ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
|
|
1725
1886
|
const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
|
|
1726
1887
|
let llmContext: Context;
|
|
1727
1888
|
if (config.appendOnlyContext) {
|
|
@@ -1754,6 +1915,12 @@ async function prepareProviderCall(
|
|
|
1754
1915
|
tools: undefined,
|
|
1755
1916
|
};
|
|
1756
1917
|
}
|
|
1918
|
+
// After `transformProviderContext`, so the recorded definitions are exactly what the provider receives.
|
|
1919
|
+
if (config.sentToolDefinitions && llmContext.tools) {
|
|
1920
|
+
config.sentToolDefinitions.record(llmContext.tools);
|
|
1921
|
+
const inactiveTools = config.sentToolDefinitions.inactiveFor(llmContext.messages, llmContext.tools);
|
|
1922
|
+
if (inactiveTools) llmContext = { ...llmContext, inactiveTools };
|
|
1923
|
+
}
|
|
1757
1924
|
return { model, context: llmContext, promptToolWireTools, ownedDialect };
|
|
1758
1925
|
}
|
|
1759
1926
|
|
|
@@ -1810,8 +1977,13 @@ async function streamAssistantResponse(
|
|
|
1810
1977
|
? providerAbortSignals[0]!
|
|
1811
1978
|
: AbortSignal.any(providerAbortSignals);
|
|
1812
1979
|
const requestApiKey = (config.getApiKey ? await config.getApiKey(model) : undefined) ?? config.apiKey;
|
|
1813
|
-
|
|
1814
|
-
const
|
|
1980
|
+
let resolvedCredential: ApiKeyResolution;
|
|
1981
|
+
const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal, resolved => {
|
|
1982
|
+
resolvedCredential = resolved;
|
|
1983
|
+
});
|
|
1984
|
+
const apiKey = isApiKeyResolver(requestApiKey)
|
|
1985
|
+
? seedApiKeyResolver(resolvedCredential ?? resolvedApiKey, requestApiKey)
|
|
1986
|
+
: requestApiKey;
|
|
1815
1987
|
|
|
1816
1988
|
// Re-resolve metadata after credential selection so the per-request value
|
|
1817
1989
|
// reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
|
|
@@ -1879,6 +2051,7 @@ async function streamAssistantResponse(
|
|
|
1879
2051
|
cwd: effectiveCwd,
|
|
1880
2052
|
signal: finalRequestSignal,
|
|
1881
2053
|
onResponse: captureOnResponse,
|
|
2054
|
+
liveSteering: providerCall.liveSteering,
|
|
1882
2055
|
});
|
|
1883
2056
|
if (promptToolWireTools && ownedDialect) {
|
|
1884
2057
|
// Re-materialize in-band tool-call text as native toolCall content blocks
|
|
@@ -1925,6 +2098,8 @@ async function streamAssistantResponse(
|
|
|
1925
2098
|
signal: requestSignal,
|
|
1926
2099
|
})
|
|
1927
2100
|
: undefined;
|
|
2101
|
+
const speculationPlansFromStream =
|
|
2102
|
+
!config.transformAssistantMessage || config.transformAssistantMessagePreservesToolCalls === true;
|
|
1928
2103
|
|
|
1929
2104
|
let providerStreamSettled = false;
|
|
1930
2105
|
let speculationSettled = false;
|
|
@@ -2154,15 +2329,11 @@ async function streamAssistantResponse(
|
|
|
2154
2329
|
case "toolcall_delta":
|
|
2155
2330
|
case "toolcall_end":
|
|
2156
2331
|
if (partialMessage) {
|
|
2157
|
-
if (
|
|
2158
|
-
event.type === "toolcall_start" &&
|
|
2159
|
-
speculationCoordinator &&
|
|
2160
|
-
!config.transformAssistantMessage
|
|
2161
|
-
) {
|
|
2332
|
+
if (event.type === "toolcall_start" && speculationCoordinator && speculationPlansFromStream) {
|
|
2162
2333
|
// Stream sessions plan from pre-transform arguments, exactly like
|
|
2163
2334
|
// direct candidates (see admitFinalized below): with a transformer
|
|
2164
|
-
//
|
|
2165
|
-
// work started from the original would be phantom I/O.
|
|
2335
|
+
// that may rewrite calls, the authoritative call may differ, so any
|
|
2336
|
+
// speculative work started from the original would be phantom I/O.
|
|
2166
2337
|
speculationCoordinator.register(event.contentIndex);
|
|
2167
2338
|
const toolCall = event.partial.content[event.contentIndex];
|
|
2168
2339
|
if (toolCall?.type === "toolCall") {
|
|
@@ -2259,7 +2430,7 @@ async function streamAssistantResponse(
|
|
|
2259
2430
|
event.type === "toolcall_end" &&
|
|
2260
2431
|
speculationCoordinator &&
|
|
2261
2432
|
speculationConfig &&
|
|
2262
|
-
|
|
2433
|
+
speculationPlansFromStream
|
|
2263
2434
|
) {
|
|
2264
2435
|
speculationCoordinator.admitFinalized(context, event.toolCall, config, requestSignal);
|
|
2265
2436
|
}
|
|
@@ -2331,8 +2502,11 @@ async function streamAssistantResponse(
|
|
|
2331
2502
|
}
|
|
2332
2503
|
if (addedPartial) {
|
|
2333
2504
|
context.messages[context.messages.length - 1] = trailing;
|
|
2334
|
-
|
|
2505
|
+
} else {
|
|
2506
|
+
context.messages.push(trailing);
|
|
2507
|
+
stream.push({ type: "message_start", message: snapshotAssistantMessage(trailing) });
|
|
2335
2508
|
}
|
|
2509
|
+
stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
|
|
2336
2510
|
await finishChat(trailing);
|
|
2337
2511
|
speculationSettled = true;
|
|
2338
2512
|
providerStreamSettled = true;
|
|
@@ -2546,6 +2720,12 @@ interface PreparedToolCall {
|
|
|
2546
2720
|
tool: AgentTool<any> | undefined;
|
|
2547
2721
|
/** Validated (possibly hook-revised) execution args; raw args when validation failed. */
|
|
2548
2722
|
args: Record<string, unknown>;
|
|
2723
|
+
/**
|
|
2724
|
+
* Passive context returned by `beforeToolCall`. Committed after the batch
|
|
2725
|
+
* settles only when the call's final result is not an error, so a call the
|
|
2726
|
+
* tool's own approval gate denies (or that otherwise fails) injects nothing.
|
|
2727
|
+
*/
|
|
2728
|
+
additionalContext?: string;
|
|
2549
2729
|
/** Transformed args shared by final reconciliation and eventual dispatch. */
|
|
2550
2730
|
executionArgs?: Record<string, unknown>;
|
|
2551
2731
|
/** Transform failure retained for execution's scheduled error result. */
|
|
@@ -2681,12 +2861,32 @@ async function prepareToolCallDispatch(
|
|
|
2681
2861
|
if (toolCall.type !== "toolCall") continue;
|
|
2682
2862
|
if ((toolCall as CursorExecResolvedCarrier)[kCursorExecResolved] === true) continue;
|
|
2683
2863
|
const tool = resolveToolForCall(context.tools, toolCall, resolveFallbackTool);
|
|
2864
|
+
// A host fallback accepts aliases (`xd://recall`, a mis-separated MCP
|
|
2865
|
+
// name) that providers reject when replayed as a function-call name.
|
|
2866
|
+
// Record the call under the resolved tool's canonical name so history,
|
|
2867
|
+
// persistence, and replay agree; custom-wire calls keep their wire name.
|
|
2868
|
+
if (tool && toolCall.name !== tool.name && toolCall.name !== tool.customWireName) {
|
|
2869
|
+
toolCall.name = tool.name;
|
|
2870
|
+
}
|
|
2684
2871
|
const entry: PreparedToolCall = { tool, args: toolCall.arguments as Record<string, unknown> };
|
|
2685
2872
|
prepared.set(toolCall.id, entry);
|
|
2686
2873
|
let argsForExecution = toolCall.arguments as Record<string, unknown>;
|
|
2687
2874
|
if (intentTracing) {
|
|
2688
2875
|
const { intent, strippedArgs } = extractIntent(toolCall.arguments);
|
|
2689
2876
|
argsForExecution = strippedArgs;
|
|
2877
|
+
// A payload in `i` would be stripped and the tool run with the leftover
|
|
2878
|
+
// args. Unknown tools fall through to the not-found error; a tool that
|
|
2879
|
+
// owns `i` as a real parameter has nowhere else to put the value.
|
|
2880
|
+
if (
|
|
2881
|
+
intent !== undefined &&
|
|
2882
|
+
intent.length > MAX_INTENT_LENGTH &&
|
|
2883
|
+
tool &&
|
|
2884
|
+
!schemaDefinesProperty(toolWireSchema(tool), INTENT_FIELD)
|
|
2885
|
+
) {
|
|
2886
|
+
entry.args = strippedArgs;
|
|
2887
|
+
entry.validationErrorMessage = `\`${INTENT_FIELD}\` is a short intent label (at most ${MAX_INTENT_LENGTH} chars); the value you sent is ${intent.length} chars. The tool was not run. Put that content in the tool's own parameters and retry with a brief \`${INTENT_FIELD}\`.`;
|
|
2888
|
+
continue;
|
|
2889
|
+
}
|
|
2690
2890
|
if (intent) {
|
|
2691
2891
|
toolCall.intent = intent;
|
|
2692
2892
|
} else if (typeof tool?.intent === "function") {
|
|
@@ -2740,6 +2940,9 @@ async function prepareToolCallDispatch(
|
|
|
2740
2940
|
entry.blockReason = beforeResult.reason;
|
|
2741
2941
|
continue;
|
|
2742
2942
|
}
|
|
2943
|
+
if (isNonBlankContext(beforeResult?.additionalContext)) {
|
|
2944
|
+
entry.additionalContext = beforeResult.additionalContext;
|
|
2945
|
+
}
|
|
2743
2946
|
if (beforeResult?.args !== undefined) {
|
|
2744
2947
|
// Revalidate: a hook revision is untrusted input to the tool schema.
|
|
2745
2948
|
const revised = validate(beforeResult.args);
|
|
@@ -2824,7 +3027,13 @@ async function speculativeFinalCalls(
|
|
|
2824
3027
|
}
|
|
2825
3028
|
|
|
2826
3029
|
/**
|
|
2827
|
-
* Execute tool calls from an assistant message.
|
|
3030
|
+
* Execute tool calls from an assistant message. Returns model-visible context
|
|
3031
|
+
* only after every result has settled, preserving assistant call order.
|
|
3032
|
+
*
|
|
3033
|
+
* `tool_execution_end` fires as each call settles so live UI updates promptly;
|
|
3034
|
+
* result `message_start`/`message_end` events (which append to agent state and
|
|
3035
|
+
* the persisted session) are held until every earlier call has a result, so
|
|
3036
|
+
* history always pairs results in call order regardless of completion order.
|
|
2828
3037
|
*/
|
|
2829
3038
|
async function executeToolCalls(
|
|
2830
3039
|
currentContext: AgentContext,
|
|
@@ -2834,11 +3043,15 @@ async function executeToolCalls(
|
|
|
2834
3043
|
config: AgentLoopConfig,
|
|
2835
3044
|
telemetry: AgentTelemetry | undefined,
|
|
2836
3045
|
invokeAgentSpan: Span | undefined,
|
|
2837
|
-
|
|
3046
|
+
// Steering the provider took off the queue during the response that emitted
|
|
3047
|
+
// this batch; it injects at this batch's boundary like queued steering.
|
|
3048
|
+
liveSteering: readonly AgentMessage[],
|
|
3049
|
+
): Promise<{ toolResults: ToolResultMessage[]; additionalContext?: string }> {
|
|
2838
3050
|
const tools = currentContext.tools;
|
|
2839
3051
|
const {
|
|
2840
3052
|
hasSteeringMessages,
|
|
2841
3053
|
hasIrcInterrupts,
|
|
3054
|
+
hasBackgroundCompletions,
|
|
2842
3055
|
interruptMode = "immediate",
|
|
2843
3056
|
getToolContext,
|
|
2844
3057
|
|
|
@@ -2858,7 +3071,10 @@ async function executeToolCalls(
|
|
|
2858
3071
|
const emittedToolResults: ToolResultMessage[] = [];
|
|
2859
3072
|
const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
|
|
2860
3073
|
const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
|
|
2861
|
-
|
|
3074
|
+
// `interruptMode: "wait"` only spares side-effecting work: interruptible
|
|
3075
|
+
// waits are always cut short, since a pure wait has nothing to finish and
|
|
3076
|
+
// would otherwise sit out its full window with a message already queued.
|
|
3077
|
+
const softInterrupts = interruptMode !== "wait";
|
|
2862
3078
|
const steeringAbortController = new AbortController();
|
|
2863
3079
|
const ircAbortController = new AbortController();
|
|
2864
3080
|
// Cooperative channel: aborted when queued steering (or an interrupting
|
|
@@ -2867,7 +3083,7 @@ async function executeToolCalls(
|
|
|
2867
3083
|
// backgrounds itself so the message injects promptly — but it never kills
|
|
2868
3084
|
// anything; ignoring it is always safe.
|
|
2869
3085
|
const steeringSoftController = new AbortController();
|
|
2870
|
-
// Interruptible tools (pure waits:
|
|
3086
|
+
// Interruptible tools (pure waits: wait, vibe) observe steering +
|
|
2871
3087
|
// external + IRC aborts. Every other tool sees ONLY the external signal:
|
|
2872
3088
|
// neither queued steering nor a peer IRC ever hard-kills a partially
|
|
2873
3089
|
// side-effecting foreground tool (e.g. `bash`) — those get the cooperative
|
|
@@ -2876,7 +3092,7 @@ async function executeToolCalls(
|
|
|
2876
3092
|
const interruptibleSignal: AbortSignal = signal
|
|
2877
3093
|
? AbortSignal.any([signal, steeringAbortController.signal, ircAbortController.signal])
|
|
2878
3094
|
: AbortSignal.any([steeringAbortController.signal, ircAbortController.signal]);
|
|
2879
|
-
const interruptState: { triggered: boolean; source?:
|
|
3095
|
+
const interruptState: { triggered: boolean; source?: AsideInterruptSource } = { triggered: false };
|
|
2880
3096
|
|
|
2881
3097
|
// Streamed messages were prepared (validation + `beforeToolCall`) before
|
|
2882
3098
|
// `message_end`, so hook revisions are already part of the message; anything
|
|
@@ -2922,39 +3138,59 @@ async function executeToolCalls(
|
|
|
2922
3138
|
blocked: prepared.blocked === true,
|
|
2923
3139
|
blockReason: prepared.blockReason,
|
|
2924
3140
|
prepareError: prepared.prepareError,
|
|
3141
|
+
preparedContext: prepared.additionalContext,
|
|
3142
|
+
reportedContext: [] as string[],
|
|
2925
3143
|
executionArgs: prepared.executionArgs,
|
|
2926
3144
|
transformError: prepared.transformError,
|
|
2927
3145
|
};
|
|
2928
3146
|
});
|
|
2929
3147
|
|
|
2930
|
-
const
|
|
2931
|
-
//
|
|
3148
|
+
const checkAsideInterrupts = async (): Promise<void> => {
|
|
3149
|
+
// Asides only fire once: an interrupt already recorded on interruptState
|
|
2932
3150
|
// must not re-abort, and (unlike steering) never re-consumes a queue.
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
3151
|
+
// A completion-triggered record is the exception — it leaves the
|
|
3152
|
+
// cooperative signal down, so keep polling until a peer IRC escalates
|
|
3153
|
+
// (only when soft interrupts are enabled; otherwise nothing is left).
|
|
3154
|
+
if (signal?.aborted) return;
|
|
3155
|
+
if (interruptState.triggered && (!softInterrupts || steeringSoftController.signal.aborted)) return;
|
|
3156
|
+
// Peer IRC and background completions (finished jobs, exited supervised
|
|
3157
|
+
// processes) hard-abort interruptible waits only; foreground tools keep
|
|
3158
|
+
// running (no partial side effects).
|
|
3159
|
+
let source: AsideInterruptSource | undefined;
|
|
3160
|
+
if (hasIrcInterrupts && (await hasIrcInterrupts())) source = "irc";
|
|
3161
|
+
else if (!interruptState.triggered && hasBackgroundCompletions && (await hasBackgroundCompletions()))
|
|
3162
|
+
source = "background";
|
|
3163
|
+
if (!source) return;
|
|
3164
|
+
if (!interruptState.triggered) {
|
|
2938
3165
|
interruptState.triggered = true;
|
|
2939
|
-
interruptState.source =
|
|
2940
|
-
ircAbortController.abort();
|
|
2941
|
-
steeringSoftController.abort();
|
|
3166
|
+
interruptState.source = source;
|
|
3167
|
+
ircAbortController.abort(TOOL_INTERRUPT_ABORT_REASON);
|
|
2942
3168
|
}
|
|
3169
|
+
// Only an urgent aside raises the cooperative signal that makes
|
|
3170
|
+
// backgroundable foreground work (auto-background bash/eval) detach
|
|
3171
|
+
// itself. A peer waiting on an IRC is blocked on this batch; a finished
|
|
3172
|
+
// background job is not — its notice is an aside that injects at the
|
|
3173
|
+
// batch boundary either way. Detaching ordinary foreground work for it
|
|
3174
|
+
// also cascades: the freshly detached job's own completion re-triggers
|
|
3175
|
+
// this check for the next command, so millisecond-long commands chain
|
|
3176
|
+
// into separate background deliveries (#12869).
|
|
3177
|
+
if (source !== "background" && softInterrupts) steeringSoftController.abort();
|
|
2943
3178
|
};
|
|
2944
3179
|
|
|
2945
3180
|
const checkSteering = async (): Promise<void> => {
|
|
2946
3181
|
// `signal` (external/user abort) is checked separately from the internal
|
|
2947
3182
|
// abort controllers: once the run is externally aborted it is unwinding
|
|
2948
3183
|
// and the interrupt would be redundant.
|
|
2949
|
-
if (
|
|
2950
|
-
return;
|
|
2951
|
-
}
|
|
3184
|
+
if (signal?.aborted) return;
|
|
2952
3185
|
// Mid-batch steering detection must be non-consuming. If a direct
|
|
2953
3186
|
// integration only provides getSteeringMessages(), the queue drains at the
|
|
2954
3187
|
// injection boundary below; polling it here would strand or drop messages.
|
|
2955
3188
|
let steeringQueued = false;
|
|
2956
3189
|
let steeringSource: SteeringInterruptSource | undefined;
|
|
2957
|
-
if (
|
|
3190
|
+
if (liveSteering.length > 0) {
|
|
3191
|
+
steeringQueued = true;
|
|
3192
|
+
steeringSource = steeringQueueState(liveSteering).source;
|
|
3193
|
+
} else if (hasSteeringMessages) {
|
|
2958
3194
|
const queuedState = await hasSteeringMessages();
|
|
2959
3195
|
if (typeof queuedState === "boolean") {
|
|
2960
3196
|
steeringQueued = queuedState;
|
|
@@ -2966,8 +3202,9 @@ async function executeToolCalls(
|
|
|
2966
3202
|
}
|
|
2967
3203
|
}
|
|
2968
3204
|
if (steeringQueued) {
|
|
2969
|
-
// Queued steering hard-aborts only interruptible waits and
|
|
2970
|
-
//
|
|
3205
|
+
// Queued steering hard-aborts only interruptible waits and (unless
|
|
3206
|
+
// interruptMode is "wait") raises the cooperative soft signal for
|
|
3207
|
+
// everything else: the boundary dequeue
|
|
2971
3208
|
// below injects the message as soon as running tools finish (or
|
|
2972
3209
|
// background themselves), and not-yet-started interruptible waits
|
|
2973
3210
|
// are skipped. Idempotent — a second steer poll after the abort is
|
|
@@ -2975,12 +3212,24 @@ async function executeToolCalls(
|
|
|
2975
3212
|
if (!steeringAbortController.signal.aborted) {
|
|
2976
3213
|
interruptState.triggered = true;
|
|
2977
3214
|
interruptState.source = steeringSource ?? "unknown";
|
|
2978
|
-
steeringAbortController.abort();
|
|
2979
|
-
steeringSoftController.abort();
|
|
3215
|
+
steeringAbortController.abort(TOOL_INTERRUPT_ABORT_REASON);
|
|
3216
|
+
if (softInterrupts) steeringSoftController.abort();
|
|
2980
3217
|
}
|
|
2981
3218
|
return;
|
|
2982
3219
|
}
|
|
2983
|
-
await
|
|
3220
|
+
await checkAsideInterrupts();
|
|
3221
|
+
};
|
|
3222
|
+
|
|
3223
|
+
// Index of the first record whose result message has not been emitted yet.
|
|
3224
|
+
let nextResultIndex = 0;
|
|
3225
|
+
const flushResultMessages = (): void => {
|
|
3226
|
+
for (; nextResultIndex < records.length; nextResultIndex++) {
|
|
3227
|
+
const message = records[nextResultIndex].toolResultMessage;
|
|
3228
|
+
if (!message) return;
|
|
3229
|
+
emittedToolResults.push(message);
|
|
3230
|
+
stream.push({ type: "message_start", message });
|
|
3231
|
+
stream.push({ type: "message_end", message });
|
|
3232
|
+
}
|
|
2984
3233
|
};
|
|
2985
3234
|
|
|
2986
3235
|
const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
|
|
@@ -3018,15 +3267,12 @@ async function executeToolCalls(
|
|
|
3018
3267
|
record.isError = isError;
|
|
3019
3268
|
record.toolResultMessage = toolResultMessage;
|
|
3020
3269
|
record.resultEmitted = true;
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
stream.push({ type: "message_start", message: toolResultMessage });
|
|
3024
|
-
stream.push({ type: "message_end", message: toolResultMessage });
|
|
3270
|
+
flushResultMessages();
|
|
3025
3271
|
};
|
|
3026
3272
|
|
|
3027
3273
|
const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
|
|
3028
3274
|
// A pending interrupt preempts not-yet-started *interruptible* waits so
|
|
3029
|
-
// the message injects promptly instead of sitting out a `
|
|
3275
|
+
// the message injects promptly instead of sitting out a `wait`.
|
|
3030
3276
|
// Non-interruptible work is never skipped, whatever the source: the
|
|
3031
3277
|
// expensive part — generating the call — is already paid, the tool
|
|
3032
3278
|
// itself is cheap, and a skip only makes the model re-emit the same
|
|
@@ -3093,7 +3339,7 @@ async function executeToolCalls(
|
|
|
3093
3339
|
parent: invokeAgentSpan,
|
|
3094
3340
|
});
|
|
3095
3341
|
if (toolSpan && toolCall.intent) {
|
|
3096
|
-
toolSpan.setAttribute(
|
|
3342
|
+
toolSpan.setAttribute(OmpGenAIAttr.ToolCallIntent, toolCall.intent);
|
|
3097
3343
|
}
|
|
3098
3344
|
|
|
3099
3345
|
let result: AgentToolResult<any> = { content: [], details: {} };
|
|
@@ -3138,11 +3384,13 @@ async function executeToolCalls(
|
|
|
3138
3384
|
}
|
|
3139
3385
|
|
|
3140
3386
|
if (!completedToolExecution) {
|
|
3141
|
-
// The cooperative steering signal
|
|
3142
|
-
// ToolCallContext (surfacing as
|
|
3143
|
-
//
|
|
3144
|
-
// the loop
|
|
3145
|
-
|
|
3387
|
+
// The cooperative steering signal and the passive-context sink
|
|
3388
|
+
// ride the loop-owned ToolCallContext (surfacing as
|
|
3389
|
+
// `ctx.toolCall.*`); the host surfaces the sink on the context it
|
|
3390
|
+
// builds, and the loop hands that object to the tool untouched.
|
|
3391
|
+
// Wrapper-dispatched nested calls (for example `write xd://…`)
|
|
3392
|
+
// inherit the context, so their passive hook context joins this
|
|
3393
|
+
// root call at the batch boundary.
|
|
3146
3394
|
const toolContext = getToolContext?.({
|
|
3147
3395
|
batchId,
|
|
3148
3396
|
index,
|
|
@@ -3150,7 +3398,11 @@ async function executeToolCalls(
|
|
|
3150
3398
|
toolCalls: toolCallInfos,
|
|
3151
3399
|
steeringSignal: steeringSoftController.signal,
|
|
3152
3400
|
providerMetadata: toolCall.providerMetadata,
|
|
3401
|
+
addAdditionalContext: context => {
|
|
3402
|
+
if (isNonBlankContext(context)) record.reportedContext.push(context);
|
|
3403
|
+
},
|
|
3153
3404
|
});
|
|
3405
|
+
const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
|
|
3154
3406
|
if (streamSession && toolContext) {
|
|
3155
3407
|
toolContext[SPECULATIVE_STREAM_SESSION] = streamSession;
|
|
3156
3408
|
} else if (streamSession && !streamSession.contextIndependent) {
|
|
@@ -3270,7 +3522,13 @@ async function executeToolCalls(
|
|
|
3270
3522
|
toolName: toolCall.name,
|
|
3271
3523
|
});
|
|
3272
3524
|
|
|
3273
|
-
|
|
3525
|
+
// Best-effort steering probe: its own failure is surfaced by the
|
|
3526
|
+
// dedicated watch path (which guards the identical call), so a rejecting
|
|
3527
|
+
// host `hasSteeringMessages`/`hasIrcInterrupts` callback must not reject
|
|
3528
|
+
// this task. An unguarded rejection here fires after the tool already
|
|
3529
|
+
// ran and poisons the `start.then(runTool)` ordering chain, skipping
|
|
3530
|
+
// every later chained record with a phantom "pending steering" result.
|
|
3531
|
+
await checkSteering().catch(() => undefined);
|
|
3274
3532
|
};
|
|
3275
3533
|
|
|
3276
3534
|
let lastExclusive: Promise<void> = Promise.resolve();
|
|
@@ -3279,12 +3537,18 @@ async function executeToolCalls(
|
|
|
3279
3537
|
|
|
3280
3538
|
// While tool calls are in flight, queued steering or interrupting IRC would
|
|
3281
3539
|
// otherwise wait out the tools' own window. Poll only non-consuming queues:
|
|
3282
|
-
// detection hard-aborts interruptible waits (running or not yet started)
|
|
3283
|
-
// and soft-
|
|
3284
|
-
// dequeue below injects the message
|
|
3285
|
-
// mode
|
|
3540
|
+
// detection hard-aborts interruptible waits (running or not yet started),
|
|
3541
|
+
// and steering/IRC additionally soft-signal cooperative tools
|
|
3542
|
+
// (auto-background bash), so the boundary dequeue below injects the message
|
|
3543
|
+
// promptly. In "wait" mode only a batch holding an interruptible wait needs
|
|
3544
|
+
// the watch; checkSteering is idempotent (no-op once triggered).
|
|
3545
|
+
const hasAsidePeek = hasIrcInterrupts !== undefined || hasBackgroundCompletions !== undefined;
|
|
3286
3546
|
const watchSteeringWhileRunning =
|
|
3287
|
-
|
|
3547
|
+
(softInterrupts || records.some(record => record.interruptible)) &&
|
|
3548
|
+
(hasSteeringMessages !== undefined || hasAsidePeek);
|
|
3549
|
+
// Live-taken steering is already pending: interrupt before any call starts
|
|
3550
|
+
// so not-yet-started interruptible waits are skipped outright.
|
|
3551
|
+
if (liveSteering.length > 0) await checkSteering();
|
|
3288
3552
|
const eventDrivenSteeringWatch =
|
|
3289
3553
|
watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
|
|
3290
3554
|
const steeringWatchAbortController = new AbortController();
|
|
@@ -3318,18 +3582,28 @@ async function executeToolCalls(
|
|
|
3318
3582
|
() => false,
|
|
3319
3583
|
);
|
|
3320
3584
|
if (!(await Promise.race([steeringChecked, watchAbortedFalse]))) return;
|
|
3321
|
-
|
|
3585
|
+
// Stop once nothing is left to escalate: the cooperative signal
|
|
3586
|
+
// is up, or (without soft interrupts) the waits are cut. A
|
|
3587
|
+
// completion-only trigger leaves the soft signal down, so keep
|
|
3588
|
+
// watching: a genuine steer arriving afterwards must still
|
|
3589
|
+
// reach foreground tools.
|
|
3590
|
+
if (
|
|
3591
|
+
steeringWatchSignal.aborted ||
|
|
3592
|
+
steeringSoftController.signal.aborted ||
|
|
3593
|
+
(!softInterrupts && interruptState.triggered)
|
|
3594
|
+
)
|
|
3595
|
+
return;
|
|
3322
3596
|
if (!(await Promise.race([steeringQueued, watchAbortedFalse]))) return;
|
|
3323
3597
|
}
|
|
3324
3598
|
})()
|
|
3325
3599
|
: undefined;
|
|
3326
|
-
// IRC interrupt records
|
|
3327
|
-
// callback. Keep
|
|
3328
|
-
// system steering uses the event-driven path above and does not poll.
|
|
3600
|
+
// IRC interrupt records and background completions live in session-owned
|
|
3601
|
+
// queues with no wake callback. Keep the timer fallback when either peek is
|
|
3602
|
+
// present; system steering uses the event-driven path above and does not poll.
|
|
3329
3603
|
const steeringWatchTimer =
|
|
3330
|
-
watchSteeringWhileRunning && (!eventDrivenSteeringWatch ||
|
|
3604
|
+
watchSteeringWhileRunning && (!eventDrivenSteeringWatch || hasAsidePeek)
|
|
3331
3605
|
? setInterval(
|
|
3332
|
-
() => void (eventDrivenSteeringWatch ?
|
|
3606
|
+
() => void (eventDrivenSteeringWatch ? checkAsideInterrupts() : checkSteering()),
|
|
3333
3607
|
STEERING_INTERRUPT_POLL_MS,
|
|
3334
3608
|
)
|
|
3335
3609
|
: undefined;
|
|
@@ -3384,7 +3658,23 @@ async function executeToolCalls(
|
|
|
3384
3658
|
}
|
|
3385
3659
|
await speculationCoordinator?.discardAll("candidate was not dispatched");
|
|
3386
3660
|
|
|
3387
|
-
|
|
3661
|
+
// Skipped calls never ran. Hook-prepared context also requires a non-error
|
|
3662
|
+
// final result; context the tool itself reported during execution stands.
|
|
3663
|
+
// Within a call, tool-reported context (including nested `xd://` dispatch)
|
|
3664
|
+
// precedes the hook's: wrappers release hook context only after the call
|
|
3665
|
+
// succeeds, so this is the one order every dispatch path can produce.
|
|
3666
|
+
const additionalContext = joinAdditionalContext(
|
|
3667
|
+
records
|
|
3668
|
+
.filter(record => !record.skipped)
|
|
3669
|
+
.flatMap(record => [
|
|
3670
|
+
...record.reportedContext,
|
|
3671
|
+
record.toolResultMessage?.isError ? undefined : record.preparedContext,
|
|
3672
|
+
]),
|
|
3673
|
+
);
|
|
3674
|
+
return {
|
|
3675
|
+
toolResults: emittedToolResults,
|
|
3676
|
+
...(additionalContext !== undefined ? { additionalContext } : {}),
|
|
3677
|
+
};
|
|
3388
3678
|
}
|
|
3389
3679
|
|
|
3390
3680
|
/**
|
|
@@ -3538,8 +3828,11 @@ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unk
|
|
|
3538
3828
|
};
|
|
3539
3829
|
}
|
|
3540
3830
|
|
|
3831
|
+
/** Origin of a mid-batch interrupt: queued steering, a peer IRC, or a background completion notice. */
|
|
3832
|
+
type AsideInterruptSource = SteeringInterruptSource | "irc" | "background";
|
|
3833
|
+
|
|
3541
3834
|
function createSkippedToolResult(
|
|
3542
|
-
source:
|
|
3835
|
+
source: AsideInterruptSource | undefined,
|
|
3543
3836
|
executionStarted: boolean,
|
|
3544
3837
|
): AgentToolResult<SyntheticToolResultDetails | InterruptedToolResultDetails> {
|
|
3545
3838
|
let reason = "pending steering message";
|
|
@@ -3556,6 +3849,9 @@ function createSkippedToolResult(
|
|
|
3556
3849
|
} else if (source === "irc") {
|
|
3557
3850
|
reason = "pending peer interrupt";
|
|
3558
3851
|
blocker = "interrupt";
|
|
3852
|
+
} else if (source === "background") {
|
|
3853
|
+
reason = "a queued background completion (job or supervised process)";
|
|
3854
|
+
blocker = "completion notice";
|
|
3559
3855
|
}
|
|
3560
3856
|
return {
|
|
3561
3857
|
content: [
|