@oh-my-pi/pi-agent-core 18.2.11 → 18.3.1
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 +24 -0
- package/README.md +3 -2
- package/THIRD-PARTY-NOTICES.txt +3 -3
- package/dist/types/agent-loop.d.ts +6 -0
- package/dist/types/agent.d.ts +14 -0
- package/dist/types/compaction/anthropic.d.ts +22 -52
- package/dist/types/compaction/compaction.d.ts +2 -0
- package/dist/types/compaction/messages.d.ts +9 -0
- package/dist/types/compaction/transcript-tokens.d.ts +14 -1
- package/dist/types/index.d.ts +3 -0
- package/dist/types/live-steering.d.ts +34 -0
- package/dist/types/output-budget.d.ts +43 -0
- package/dist/types/sent-tool-definitions.d.ts +17 -0
- package/dist/types/tool-context.d.ts +31 -0
- package/dist/types/types.d.ts +63 -9
- package/package.json +12 -9
- package/src/agent-loop.ts +219 -44
- package/src/agent.ts +87 -4
- package/src/compaction/anthropic.ts +60 -103
- package/src/compaction/compaction.ts +58 -48
- package/src/compaction/messages.ts +12 -2
- package/src/compaction/prompts/anthropic-compaction-instructions.md +2 -6
- package/src/compaction/transcript-tokens.ts +31 -1
- package/src/index.ts +6 -0
- package/src/live-steering.ts +89 -0
- package/src/output-budget.ts +130 -0
- package/src/sent-tool-definitions.ts +40 -0
- package/src/tool-context.ts +49 -0
- package/src/types.ts +64 -9
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 "@oh-my-pi/pi-ai";
|
|
26
28
|
import {
|
|
@@ -50,6 +52,7 @@ import {
|
|
|
50
52
|
} from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
51
53
|
import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
52
54
|
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
55
|
+
import { LiveSteeringChannel } from "./live-steering";
|
|
53
56
|
import { agentPauseGate } from "./pause";
|
|
54
57
|
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
|
55
58
|
import { SpeculativeOperationCoordinator } from "./speculative-execution";
|
|
@@ -69,6 +72,7 @@ import {
|
|
|
69
72
|
startExecuteToolSpan,
|
|
70
73
|
startInvokeAgentSpan,
|
|
71
74
|
} from "./telemetry";
|
|
75
|
+
import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
|
|
72
76
|
import type {
|
|
73
77
|
AgentContext,
|
|
74
78
|
AgentEvent,
|
|
@@ -164,6 +168,13 @@ export function createToolScopedAbortReason(
|
|
|
164
168
|
*/
|
|
165
169
|
export const TERMINAL_TOOL_RESULT_ABORT_REASON = Symbol.for("pi-agent-core.terminal-tool-result");
|
|
166
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Abort reason carried by an interruptible tool's signal when queued steering,
|
|
173
|
+
* a peer IRC, or a background completion cut it short. Lets a wait tell the
|
|
174
|
+
* designed wake path apart from an external/user abort of the run.
|
|
175
|
+
*/
|
|
176
|
+
export const TOOL_INTERRUPT_ABORT_REASON = Symbol.for("pi-agent-core.tool-interrupt");
|
|
177
|
+
|
|
167
178
|
const STEERING_INTERRUPT_POLL_MS = 250;
|
|
168
179
|
|
|
169
180
|
class HarmonyLeakInterruption extends Error {
|
|
@@ -740,6 +751,7 @@ async function emitTurnEnd(
|
|
|
740
751
|
await config.onTurnEnd?.(currentContext.messages, terminalYield ? undefined : signal, {
|
|
741
752
|
message,
|
|
742
753
|
toolResults,
|
|
754
|
+
additionalMessages: [],
|
|
743
755
|
willContinue: false,
|
|
744
756
|
...context,
|
|
745
757
|
});
|
|
@@ -1088,6 +1100,26 @@ function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, mess
|
|
|
1088
1100
|
}
|
|
1089
1101
|
}
|
|
1090
1102
|
|
|
1103
|
+
/**
|
|
1104
|
+
* Append passive tool-call context after its results as a developer message.
|
|
1105
|
+
* Returns the injected message for turn-end bookkeeping, or undefined when
|
|
1106
|
+
* there is nothing to inject. Shared by the normal tool-call path and the
|
|
1107
|
+
* resume-tail replay so replayed calls deliver context identically.
|
|
1108
|
+
*/
|
|
1109
|
+
function injectExecutionAdditionalContext(
|
|
1110
|
+
currentContext: AgentContext,
|
|
1111
|
+
newMessages: AgentMessage[],
|
|
1112
|
+
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
1113
|
+
additionalContext: string | undefined,
|
|
1114
|
+
): AgentMessage | undefined {
|
|
1115
|
+
if (additionalContext === undefined) return undefined;
|
|
1116
|
+
const contextMessage = createAdditionalContextMessage(additionalContext);
|
|
1117
|
+
currentContext.messages.push(contextMessage);
|
|
1118
|
+
newMessages.push(contextMessage);
|
|
1119
|
+
emitInputMessages(stream, [contextMessage]);
|
|
1120
|
+
return contextMessage;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1091
1123
|
/**
|
|
1092
1124
|
* Resolve aside entries at the moment the loop is about to inject them. Each entry
|
|
1093
1125
|
* is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
|
|
@@ -1151,6 +1183,10 @@ async function runLoopBody(
|
|
|
1151
1183
|
let preserveSoftRequirementState = false;
|
|
1152
1184
|
|
|
1153
1185
|
let pendingMessages: AgentMessage[] = [];
|
|
1186
|
+
// Steering the provider took from the queue during the last response:
|
|
1187
|
+
// `liveAccepted` reached the model inside it, `liveDeferred` did not.
|
|
1188
|
+
let liveAccepted: AgentMessage[] = [];
|
|
1189
|
+
let liveDeferred: AgentMessage[] = [];
|
|
1154
1190
|
try {
|
|
1155
1191
|
let messagesToEmit = [...initialMessages];
|
|
1156
1192
|
if (isDeadlineExceeded(config.deadline)) {
|
|
@@ -1208,8 +1244,15 @@ async function runLoopBody(
|
|
|
1208
1244
|
currentContext.messages.push(result);
|
|
1209
1245
|
newMessages.push(result);
|
|
1210
1246
|
}
|
|
1247
|
+
const resumeContextMessage = injectExecutionAdditionalContext(
|
|
1248
|
+
currentContext,
|
|
1249
|
+
newMessages,
|
|
1250
|
+
stream,
|
|
1251
|
+
executionResult.additionalContext,
|
|
1252
|
+
);
|
|
1211
1253
|
await emitTurnEnd(stream, currentContext, resumeTail, executionResult.toolResults, config, signal, {
|
|
1212
1254
|
willContinue: !isDeadlineExceeded(config.deadline),
|
|
1255
|
+
...(resumeContextMessage ? { additionalMessages: [resumeContextMessage] } : {}),
|
|
1213
1256
|
});
|
|
1214
1257
|
turnOpen = false;
|
|
1215
1258
|
// A tool hook may mark its completed result as terminal (e.g. subagent
|
|
@@ -1288,6 +1331,7 @@ async function runLoopBody(
|
|
|
1288
1331
|
}
|
|
1289
1332
|
|
|
1290
1333
|
preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
|
|
1334
|
+
preparedProviderCall.liveSteering = openLiveSteering(config, signal, preparedProviderCall);
|
|
1291
1335
|
gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
|
|
1292
1336
|
} catch (error) {
|
|
1293
1337
|
if (!turnOpen) {
|
|
@@ -1414,6 +1458,12 @@ async function runLoopBody(
|
|
|
1414
1458
|
harmonyRetryAttempt++;
|
|
1415
1459
|
continue;
|
|
1416
1460
|
}
|
|
1461
|
+
} finally {
|
|
1462
|
+
const channel = preparedProviderCall.liveSteering;
|
|
1463
|
+
if (channel) {
|
|
1464
|
+
liveAccepted.push(...channel.accepted);
|
|
1465
|
+
liveDeferred.push(...channel.deferred);
|
|
1466
|
+
}
|
|
1417
1467
|
}
|
|
1418
1468
|
if (recovered) {
|
|
1419
1469
|
message = snapshotAssistantMessage(message);
|
|
@@ -1519,6 +1569,7 @@ async function runLoopBody(
|
|
|
1519
1569
|
const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
|
|
1520
1570
|
|
|
1521
1571
|
const toolResults: ToolResultMessage[] = [];
|
|
1572
|
+
const additionalMessages: AgentMessage[] = [];
|
|
1522
1573
|
if (softNonCompliant && softRequiredTool !== undefined) {
|
|
1523
1574
|
SpeculativeOperationCoordinator.discardForMessage(message, "soft tool requirement deferred execution");
|
|
1524
1575
|
if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
|
|
@@ -1561,13 +1612,19 @@ async function runLoopBody(
|
|
|
1561
1612
|
telemetry,
|
|
1562
1613
|
invokeAgentSpan,
|
|
1563
1614
|
);
|
|
1564
|
-
|
|
1565
1615
|
toolResults.push(...executionResult.toolResults);
|
|
1566
1616
|
|
|
1567
1617
|
for (const result of toolResults) {
|
|
1568
1618
|
currentContext.messages.push(result);
|
|
1569
1619
|
newMessages.push(result);
|
|
1570
1620
|
}
|
|
1621
|
+
const injectedContext = injectExecutionAdditionalContext(
|
|
1622
|
+
currentContext,
|
|
1623
|
+
newMessages,
|
|
1624
|
+
stream,
|
|
1625
|
+
executionResult.additionalContext,
|
|
1626
|
+
);
|
|
1627
|
+
if (injectedContext) additionalMessages.push(injectedContext);
|
|
1571
1628
|
} else if (toolCalls.length > 0) {
|
|
1572
1629
|
SpeculativeOperationCoordinator.discardForMessage(
|
|
1573
1630
|
message,
|
|
@@ -1618,6 +1675,7 @@ async function runLoopBody(
|
|
|
1618
1675
|
}
|
|
1619
1676
|
|
|
1620
1677
|
await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
|
|
1678
|
+
additionalMessages,
|
|
1621
1679
|
willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
|
|
1622
1680
|
});
|
|
1623
1681
|
turnOpen = false;
|
|
@@ -1632,16 +1690,36 @@ async function runLoopBody(
|
|
|
1632
1690
|
// instantly aborts — message lands in history, agent never responds. The
|
|
1633
1691
|
// mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
|
|
1634
1692
|
// still owns every message until this dequeue.
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1693
|
+
// Aborted: live-taken steering stays unrecorded, so the agent returns
|
|
1694
|
+
// it to the queue for the continuation run.
|
|
1695
|
+
const live = signal?.aborted ? [] : [...liveAccepted, ...liveDeferred];
|
|
1696
|
+
const liveReachedModel = !signal?.aborted && liveAccepted.length > 0;
|
|
1697
|
+
if (liveReachedModel) {
|
|
1698
|
+
for (const message of liveAccepted) {
|
|
1699
|
+
if (message.role === "user") message.liveSteered = true;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
liveAccepted = [];
|
|
1703
|
+
liveDeferred = [];
|
|
1704
|
+
if (liveReachedModel) {
|
|
1705
|
+
// The server continues from exactly this steering; anything else
|
|
1706
|
+
// queued now would not line up with its continuation, so it waits
|
|
1707
|
+
// for the next boundary (or is steered into that response).
|
|
1708
|
+
pendingMessages = live;
|
|
1640
1709
|
} else {
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1710
|
+
const steering = signal?.aborted
|
|
1711
|
+
? []
|
|
1712
|
+
: [...live, ...((await config.getSteeringMessages?.(signal)) || [])];
|
|
1713
|
+
if (hasMoreToolCalls) {
|
|
1714
|
+
// Mid-work: fold any non-interrupting asides into the next turn alongside steering.
|
|
1715
|
+
const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
|
|
1716
|
+
pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
|
|
1717
|
+
} else {
|
|
1718
|
+
// Stop boundary: only steering (live user input) forces another turn here. Leave
|
|
1719
|
+
// asides for the outer drain below so a passive aside can't trigger an extra model
|
|
1720
|
+
// turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
|
|
1721
|
+
pendingMessages = steering;
|
|
1722
|
+
}
|
|
1645
1723
|
}
|
|
1646
1724
|
}
|
|
1647
1725
|
|
|
@@ -1710,6 +1788,45 @@ interface PreparedProviderCall {
|
|
|
1710
1788
|
context: Context;
|
|
1711
1789
|
promptToolWireTools: Context["tools"];
|
|
1712
1790
|
ownedDialect: Dialect | undefined;
|
|
1791
|
+
/** Steering source offered to the provider for this call. */
|
|
1792
|
+
liveSteering?: LiveSteeringChannel;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* Offer queued steering to a provider that can deliver it into the response it
|
|
1797
|
+
* is streaming. Latency decides whether steering lands before the model commits
|
|
1798
|
+
* to its next output, so claims convert only the steering batch — message-level
|
|
1799
|
+
* transforms (steering envelope, redaction) — never the whole transcript.
|
|
1800
|
+
* Provider-context transforms rewrite images, so image-bearing steering waits
|
|
1801
|
+
* for the boundary rather than risk bytes the next request would not replay.
|
|
1802
|
+
*/
|
|
1803
|
+
function openLiveSteering(
|
|
1804
|
+
config: AgentLoopConfig,
|
|
1805
|
+
loopSignal: AbortSignal | undefined,
|
|
1806
|
+
prepared: PreparedProviderCall,
|
|
1807
|
+
): LiveSteeringChannel | undefined {
|
|
1808
|
+
const { getSteeringMessages, waitForSteeringMessages } = config;
|
|
1809
|
+
if (!getSteeringMessages || !waitForSteeringMessages || prepared.ownedDialect) return undefined;
|
|
1810
|
+
const bound = (signal: AbortSignal): AbortSignal => (loopSignal ? AbortSignal.any([signal, loopSignal]) : signal);
|
|
1811
|
+
return new LiveSteeringChannel({
|
|
1812
|
+
wait: signal => waitForSteeringMessages(bound(signal)),
|
|
1813
|
+
take: signal => getSteeringMessages(bound(signal)),
|
|
1814
|
+
toProvider: async (messages, signal) => {
|
|
1815
|
+
const transformed = config.transformContext
|
|
1816
|
+
? await config.transformContext(messages, bound(signal))
|
|
1817
|
+
: messages;
|
|
1818
|
+
const converted = normalizeMessagesForProvider(await config.convertToLlm(transformed), prepared.model);
|
|
1819
|
+
const userMessages: UserMessage[] = [];
|
|
1820
|
+
for (const message of converted) {
|
|
1821
|
+
if (message.role !== "user") return undefined;
|
|
1822
|
+
if (typeof message.content !== "string" && message.content.some(part => part.type === "image")) {
|
|
1823
|
+
return undefined;
|
|
1824
|
+
}
|
|
1825
|
+
userMessages.push(message);
|
|
1826
|
+
}
|
|
1827
|
+
return userMessages.length > 0 ? userMessages : undefined;
|
|
1828
|
+
},
|
|
1829
|
+
});
|
|
1713
1830
|
}
|
|
1714
1831
|
|
|
1715
1832
|
async function prepareProviderCall(
|
|
@@ -1725,7 +1842,8 @@ async function prepareProviderCall(
|
|
|
1725
1842
|
|
|
1726
1843
|
const llmMessages = await config.convertToLlm(messages);
|
|
1727
1844
|
const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
|
|
1728
|
-
const ownedDialect: Dialect | undefined =
|
|
1845
|
+
const ownedDialect: Dialect | undefined =
|
|
1846
|
+
(config.getDialect ? config.getDialect(model) : config.dialect) ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
|
|
1729
1847
|
const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
|
|
1730
1848
|
let llmContext: Context;
|
|
1731
1849
|
if (config.appendOnlyContext) {
|
|
@@ -1758,6 +1876,12 @@ async function prepareProviderCall(
|
|
|
1758
1876
|
tools: undefined,
|
|
1759
1877
|
};
|
|
1760
1878
|
}
|
|
1879
|
+
// After `transformProviderContext`, so the recorded definitions are exactly what the provider receives.
|
|
1880
|
+
if (config.sentToolDefinitions && llmContext.tools) {
|
|
1881
|
+
config.sentToolDefinitions.record(llmContext.tools);
|
|
1882
|
+
const inactiveTools = config.sentToolDefinitions.inactiveFor(llmContext.messages, llmContext.tools);
|
|
1883
|
+
if (inactiveTools) llmContext = { ...llmContext, inactiveTools };
|
|
1884
|
+
}
|
|
1761
1885
|
return { model, context: llmContext, promptToolWireTools, ownedDialect };
|
|
1762
1886
|
}
|
|
1763
1887
|
|
|
@@ -1814,8 +1938,13 @@ async function streamAssistantResponse(
|
|
|
1814
1938
|
? providerAbortSignals[0]!
|
|
1815
1939
|
: AbortSignal.any(providerAbortSignals);
|
|
1816
1940
|
const requestApiKey = (config.getApiKey ? await config.getApiKey(model) : undefined) ?? config.apiKey;
|
|
1817
|
-
|
|
1818
|
-
const
|
|
1941
|
+
let resolvedCredential: ApiKeyResolution;
|
|
1942
|
+
const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal, resolved => {
|
|
1943
|
+
resolvedCredential = resolved;
|
|
1944
|
+
});
|
|
1945
|
+
const apiKey = isApiKeyResolver(requestApiKey)
|
|
1946
|
+
? seedApiKeyResolver(resolvedCredential ?? resolvedApiKey, requestApiKey)
|
|
1947
|
+
: requestApiKey;
|
|
1819
1948
|
|
|
1820
1949
|
// Re-resolve metadata after credential selection so the per-request value
|
|
1821
1950
|
// reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
|
|
@@ -1883,6 +2012,7 @@ async function streamAssistantResponse(
|
|
|
1883
2012
|
cwd: effectiveCwd,
|
|
1884
2013
|
signal: finalRequestSignal,
|
|
1885
2014
|
onResponse: captureOnResponse,
|
|
2015
|
+
liveSteering: providerCall.liveSteering,
|
|
1886
2016
|
});
|
|
1887
2017
|
if (promptToolWireTools && ownedDialect) {
|
|
1888
2018
|
// Re-materialize in-band tool-call text as native toolCall content blocks
|
|
@@ -2553,6 +2683,12 @@ interface PreparedToolCall {
|
|
|
2553
2683
|
tool: AgentTool<any> | undefined;
|
|
2554
2684
|
/** Validated (possibly hook-revised) execution args; raw args when validation failed. */
|
|
2555
2685
|
args: Record<string, unknown>;
|
|
2686
|
+
/**
|
|
2687
|
+
* Passive context returned by `beforeToolCall`. Committed after the batch
|
|
2688
|
+
* settles only when the call's final result is not an error, so a call the
|
|
2689
|
+
* tool's own approval gate denies (or that otherwise fails) injects nothing.
|
|
2690
|
+
*/
|
|
2691
|
+
additionalContext?: string;
|
|
2556
2692
|
/** Transformed args shared by final reconciliation and eventual dispatch. */
|
|
2557
2693
|
executionArgs?: Record<string, unknown>;
|
|
2558
2694
|
/** Transform failure retained for execution's scheduled error result. */
|
|
@@ -2747,6 +2883,9 @@ async function prepareToolCallDispatch(
|
|
|
2747
2883
|
entry.blockReason = beforeResult.reason;
|
|
2748
2884
|
continue;
|
|
2749
2885
|
}
|
|
2886
|
+
if (isNonBlankContext(beforeResult?.additionalContext)) {
|
|
2887
|
+
entry.additionalContext = beforeResult.additionalContext;
|
|
2888
|
+
}
|
|
2750
2889
|
if (beforeResult?.args !== undefined) {
|
|
2751
2890
|
// Revalidate: a hook revision is untrusted input to the tool schema.
|
|
2752
2891
|
const revised = validate(beforeResult.args);
|
|
@@ -2831,7 +2970,8 @@ async function speculativeFinalCalls(
|
|
|
2831
2970
|
}
|
|
2832
2971
|
|
|
2833
2972
|
/**
|
|
2834
|
-
* Execute tool calls from an assistant message.
|
|
2973
|
+
* Execute tool calls from an assistant message. Returns model-visible context
|
|
2974
|
+
* only after every result has settled, preserving assistant call order.
|
|
2835
2975
|
*/
|
|
2836
2976
|
async function executeToolCalls(
|
|
2837
2977
|
currentContext: AgentContext,
|
|
@@ -2841,7 +2981,7 @@ async function executeToolCalls(
|
|
|
2841
2981
|
config: AgentLoopConfig,
|
|
2842
2982
|
telemetry: AgentTelemetry | undefined,
|
|
2843
2983
|
invokeAgentSpan: Span | undefined,
|
|
2844
|
-
): Promise<{ toolResults: ToolResultMessage[] }> {
|
|
2984
|
+
): Promise<{ toolResults: ToolResultMessage[]; additionalContext?: string }> {
|
|
2845
2985
|
const tools = currentContext.tools;
|
|
2846
2986
|
const {
|
|
2847
2987
|
hasSteeringMessages,
|
|
@@ -2866,7 +3006,10 @@ async function executeToolCalls(
|
|
|
2866
3006
|
const emittedToolResults: ToolResultMessage[] = [];
|
|
2867
3007
|
const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
|
|
2868
3008
|
const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
|
|
2869
|
-
|
|
3009
|
+
// `interruptMode: "wait"` only spares side-effecting work: interruptible
|
|
3010
|
+
// waits are always cut short, since a pure wait has nothing to finish and
|
|
3011
|
+
// would otherwise sit out its full window with a message already queued.
|
|
3012
|
+
const softInterrupts = interruptMode !== "wait";
|
|
2870
3013
|
const steeringAbortController = new AbortController();
|
|
2871
3014
|
const ircAbortController = new AbortController();
|
|
2872
3015
|
// Cooperative channel: aborted when queued steering (or an interrupting
|
|
@@ -2875,7 +3018,7 @@ async function executeToolCalls(
|
|
|
2875
3018
|
// backgrounds itself so the message injects promptly — but it never kills
|
|
2876
3019
|
// anything; ignoring it is always safe.
|
|
2877
3020
|
const steeringSoftController = new AbortController();
|
|
2878
|
-
// Interruptible tools (pure waits:
|
|
3021
|
+
// Interruptible tools (pure waits: wait, vibe) observe steering +
|
|
2879
3022
|
// external + IRC aborts. Every other tool sees ONLY the external signal:
|
|
2880
3023
|
// neither queued steering nor a peer IRC ever hard-kills a partially
|
|
2881
3024
|
// side-effecting foreground tool (e.g. `bash`) — those get the cooperative
|
|
@@ -2930,6 +3073,8 @@ async function executeToolCalls(
|
|
|
2930
3073
|
blocked: prepared.blocked === true,
|
|
2931
3074
|
blockReason: prepared.blockReason,
|
|
2932
3075
|
prepareError: prepared.prepareError,
|
|
3076
|
+
preparedContext: prepared.additionalContext,
|
|
3077
|
+
reportedContext: [] as string[],
|
|
2933
3078
|
executionArgs: prepared.executionArgs,
|
|
2934
3079
|
transformError: prepared.transformError,
|
|
2935
3080
|
};
|
|
@@ -2939,9 +3084,10 @@ async function executeToolCalls(
|
|
|
2939
3084
|
// Asides only fire once: an interrupt already recorded on interruptState
|
|
2940
3085
|
// must not re-abort, and (unlike steering) never re-consumes a queue.
|
|
2941
3086
|
// A completion-triggered record is the exception — it leaves the
|
|
2942
|
-
// cooperative signal down, so keep polling until a peer IRC escalates
|
|
2943
|
-
|
|
2944
|
-
if (
|
|
3087
|
+
// cooperative signal down, so keep polling until a peer IRC escalates
|
|
3088
|
+
// (only when soft interrupts are enabled; otherwise nothing is left).
|
|
3089
|
+
if (signal?.aborted) return;
|
|
3090
|
+
if (interruptState.triggered && (!softInterrupts || steeringSoftController.signal.aborted)) return;
|
|
2945
3091
|
// Peer IRC and background completions (finished jobs, exited supervised
|
|
2946
3092
|
// processes) hard-abort interruptible waits only; foreground tools keep
|
|
2947
3093
|
// running (no partial side effects).
|
|
@@ -2953,7 +3099,7 @@ async function executeToolCalls(
|
|
|
2953
3099
|
if (!interruptState.triggered) {
|
|
2954
3100
|
interruptState.triggered = true;
|
|
2955
3101
|
interruptState.source = source;
|
|
2956
|
-
ircAbortController.abort();
|
|
3102
|
+
ircAbortController.abort(TOOL_INTERRUPT_ABORT_REASON);
|
|
2957
3103
|
}
|
|
2958
3104
|
// Only an urgent aside raises the cooperative signal that makes
|
|
2959
3105
|
// backgroundable foreground work (auto-background bash/eval) detach
|
|
@@ -2963,16 +3109,14 @@ async function executeToolCalls(
|
|
|
2963
3109
|
// also cascades: the freshly detached job's own completion re-triggers
|
|
2964
3110
|
// this check for the next command, so millisecond-long commands chain
|
|
2965
3111
|
// into separate background deliveries (#12869).
|
|
2966
|
-
if (source !== "background") steeringSoftController.abort();
|
|
3112
|
+
if (source !== "background" && softInterrupts) steeringSoftController.abort();
|
|
2967
3113
|
};
|
|
2968
3114
|
|
|
2969
3115
|
const checkSteering = async (): Promise<void> => {
|
|
2970
3116
|
// `signal` (external/user abort) is checked separately from the internal
|
|
2971
3117
|
// abort controllers: once the run is externally aborted it is unwinding
|
|
2972
3118
|
// and the interrupt would be redundant.
|
|
2973
|
-
if (
|
|
2974
|
-
return;
|
|
2975
|
-
}
|
|
3119
|
+
if (signal?.aborted) return;
|
|
2976
3120
|
// Mid-batch steering detection must be non-consuming. If a direct
|
|
2977
3121
|
// integration only provides getSteeringMessages(), the queue drains at the
|
|
2978
3122
|
// injection boundary below; polling it here would strand or drop messages.
|
|
@@ -2990,8 +3134,9 @@ async function executeToolCalls(
|
|
|
2990
3134
|
}
|
|
2991
3135
|
}
|
|
2992
3136
|
if (steeringQueued) {
|
|
2993
|
-
// Queued steering hard-aborts only interruptible waits and
|
|
2994
|
-
//
|
|
3137
|
+
// Queued steering hard-aborts only interruptible waits and (unless
|
|
3138
|
+
// interruptMode is "wait") raises the cooperative soft signal for
|
|
3139
|
+
// everything else: the boundary dequeue
|
|
2995
3140
|
// below injects the message as soon as running tools finish (or
|
|
2996
3141
|
// background themselves), and not-yet-started interruptible waits
|
|
2997
3142
|
// are skipped. Idempotent — a second steer poll after the abort is
|
|
@@ -2999,8 +3144,8 @@ async function executeToolCalls(
|
|
|
2999
3144
|
if (!steeringAbortController.signal.aborted) {
|
|
3000
3145
|
interruptState.triggered = true;
|
|
3001
3146
|
interruptState.source = steeringSource ?? "unknown";
|
|
3002
|
-
steeringAbortController.abort();
|
|
3003
|
-
steeringSoftController.abort();
|
|
3147
|
+
steeringAbortController.abort(TOOL_INTERRUPT_ABORT_REASON);
|
|
3148
|
+
if (softInterrupts) steeringSoftController.abort();
|
|
3004
3149
|
}
|
|
3005
3150
|
return;
|
|
3006
3151
|
}
|
|
@@ -3050,7 +3195,7 @@ async function executeToolCalls(
|
|
|
3050
3195
|
|
|
3051
3196
|
const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
|
|
3052
3197
|
// A pending interrupt preempts not-yet-started *interruptible* waits so
|
|
3053
|
-
// the message injects promptly instead of sitting out a `
|
|
3198
|
+
// the message injects promptly instead of sitting out a `wait`.
|
|
3054
3199
|
// Non-interruptible work is never skipped, whatever the source: the
|
|
3055
3200
|
// expensive part — generating the call — is already paid, the tool
|
|
3056
3201
|
// itself is cheap, and a skip only makes the model re-emit the same
|
|
@@ -3162,11 +3307,13 @@ async function executeToolCalls(
|
|
|
3162
3307
|
}
|
|
3163
3308
|
|
|
3164
3309
|
if (!completedToolExecution) {
|
|
3165
|
-
// The cooperative steering signal
|
|
3166
|
-
// ToolCallContext (surfacing as
|
|
3167
|
-
//
|
|
3168
|
-
// the loop
|
|
3169
|
-
|
|
3310
|
+
// The cooperative steering signal and the passive-context sink
|
|
3311
|
+
// ride the loop-owned ToolCallContext (surfacing as
|
|
3312
|
+
// `ctx.toolCall.*`); the host surfaces the sink on the context it
|
|
3313
|
+
// builds, and the loop hands that object to the tool untouched.
|
|
3314
|
+
// Wrapper-dispatched nested calls (for example `write xd://…`)
|
|
3315
|
+
// inherit the context, so their passive hook context joins this
|
|
3316
|
+
// root call at the batch boundary.
|
|
3170
3317
|
const toolContext = getToolContext?.({
|
|
3171
3318
|
batchId,
|
|
3172
3319
|
index,
|
|
@@ -3174,7 +3321,11 @@ async function executeToolCalls(
|
|
|
3174
3321
|
toolCalls: toolCallInfos,
|
|
3175
3322
|
steeringSignal: steeringSoftController.signal,
|
|
3176
3323
|
providerMetadata: toolCall.providerMetadata,
|
|
3324
|
+
addAdditionalContext: context => {
|
|
3325
|
+
if (isNonBlankContext(context)) record.reportedContext.push(context);
|
|
3326
|
+
},
|
|
3177
3327
|
});
|
|
3328
|
+
const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
|
|
3178
3329
|
if (streamSession && toolContext) {
|
|
3179
3330
|
toolContext[SPECULATIVE_STREAM_SESSION] = streamSession;
|
|
3180
3331
|
} else if (streamSession && !streamSession.contextIndependent) {
|
|
@@ -3312,10 +3463,12 @@ async function executeToolCalls(
|
|
|
3312
3463
|
// detection hard-aborts interruptible waits (running or not yet started),
|
|
3313
3464
|
// and steering/IRC additionally soft-signal cooperative tools
|
|
3314
3465
|
// (auto-background bash), so the boundary dequeue below injects the message
|
|
3315
|
-
// promptly.
|
|
3316
|
-
// (no-op once triggered).
|
|
3466
|
+
// promptly. In "wait" mode only a batch holding an interruptible wait needs
|
|
3467
|
+
// the watch; checkSteering is idempotent (no-op once triggered).
|
|
3317
3468
|
const hasAsidePeek = hasIrcInterrupts !== undefined || hasBackgroundCompletions !== undefined;
|
|
3318
|
-
const watchSteeringWhileRunning =
|
|
3469
|
+
const watchSteeringWhileRunning =
|
|
3470
|
+
(softInterrupts || records.some(record => record.interruptible)) &&
|
|
3471
|
+
(hasSteeringMessages !== undefined || hasAsidePeek);
|
|
3319
3472
|
const eventDrivenSteeringWatch =
|
|
3320
3473
|
watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
|
|
3321
3474
|
const steeringWatchAbortController = new AbortController();
|
|
@@ -3349,11 +3502,17 @@ async function executeToolCalls(
|
|
|
3349
3502
|
() => false,
|
|
3350
3503
|
);
|
|
3351
3504
|
if (!(await Promise.race([steeringChecked, watchAbortedFalse]))) return;
|
|
3352
|
-
// Stop once
|
|
3353
|
-
//
|
|
3354
|
-
// leaves
|
|
3355
|
-
// afterwards must still
|
|
3356
|
-
|
|
3505
|
+
// Stop once nothing is left to escalate: the cooperative signal
|
|
3506
|
+
// is up, or (without soft interrupts) the waits are cut. A
|
|
3507
|
+
// completion-only trigger leaves the soft signal down, so keep
|
|
3508
|
+
// watching: a genuine steer arriving afterwards must still
|
|
3509
|
+
// reach foreground tools.
|
|
3510
|
+
if (
|
|
3511
|
+
steeringWatchSignal.aborted ||
|
|
3512
|
+
steeringSoftController.signal.aborted ||
|
|
3513
|
+
(!softInterrupts && interruptState.triggered)
|
|
3514
|
+
)
|
|
3515
|
+
return;
|
|
3357
3516
|
if (!(await Promise.race([steeringQueued, watchAbortedFalse]))) return;
|
|
3358
3517
|
}
|
|
3359
3518
|
})()
|
|
@@ -3419,7 +3578,23 @@ async function executeToolCalls(
|
|
|
3419
3578
|
}
|
|
3420
3579
|
await speculationCoordinator?.discardAll("candidate was not dispatched");
|
|
3421
3580
|
|
|
3422
|
-
|
|
3581
|
+
// Skipped calls never ran. Hook-prepared context also requires a non-error
|
|
3582
|
+
// final result; context the tool itself reported during execution stands.
|
|
3583
|
+
// Within a call, tool-reported context (including nested `xd://` dispatch)
|
|
3584
|
+
// precedes the hook's: wrappers release hook context only after the call
|
|
3585
|
+
// succeeds, so this is the one order every dispatch path can produce.
|
|
3586
|
+
const additionalContext = joinAdditionalContext(
|
|
3587
|
+
records
|
|
3588
|
+
.filter(record => !record.skipped)
|
|
3589
|
+
.flatMap(record => [
|
|
3590
|
+
...record.reportedContext,
|
|
3591
|
+
record.toolResultMessage?.isError ? undefined : record.preparedContext,
|
|
3592
|
+
]),
|
|
3593
|
+
);
|
|
3594
|
+
return {
|
|
3595
|
+
toolResults: emittedToolResults,
|
|
3596
|
+
...(additionalContext !== undefined ? { additionalContext } : {}),
|
|
3597
|
+
};
|
|
3423
3598
|
}
|
|
3424
3599
|
|
|
3425
3600
|
/**
|