@oh-my-pi/pi-agent-core 18.3.0 → 18.3.2
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 +19 -0
- package/README.md +3 -2
- package/THIRD-PARTY-NOTICES.txt +2 -2
- package/dist/types/agent.d.ts +14 -0
- package/dist/types/compaction/messages.d.ts +9 -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 +2 -0
- package/dist/types/live-steering.d.ts +34 -0
- package/dist/types/output-budget.d.ts +43 -0
- package/dist/types/tool-context.d.ts +31 -0
- package/dist/types/types.d.ts +42 -1
- package/package.json +8 -8
- package/src/agent-loop.ts +185 -19
- package/src/agent.ts +79 -4
- package/src/compaction/anthropic.ts +1 -1
- package/src/compaction/messages.ts +12 -2
- package/src/compaction/pruning.ts +1 -1
- package/src/compaction/transcript-tokens.ts +64 -3
- package/src/index.ts +4 -0
- package/src/live-steering.ts +89 -0
- package/src/output-budget.ts +130 -0
- package/src/tool-context.ts +49 -0
- package/src/types.ts +42 -1
package/src/agent-loop.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type ToolResultProviderMetadata,
|
|
23
23
|
type TSchema,
|
|
24
24
|
toolWireSchema,
|
|
25
|
+
type UserMessage,
|
|
25
26
|
validateToolArguments,
|
|
26
27
|
} from "@oh-my-pi/pi-ai";
|
|
27
28
|
import {
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
getStreamingPartialJson,
|
|
39
40
|
kCursorExecResolved,
|
|
40
41
|
} from "@oh-my-pi/pi-ai/utils/block-symbols";
|
|
42
|
+
import { schemaDefinesProperty } from "@oh-my-pi/pi-ai/utils/schema/json-schema-validator";
|
|
41
43
|
import { stamp } from "@oh-my-pi/pi-ai/utils/schema/stamps";
|
|
42
44
|
import {
|
|
43
45
|
createHarmonyAuditEvent,
|
|
@@ -51,6 +53,7 @@ import {
|
|
|
51
53
|
} from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
52
54
|
import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
53
55
|
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
56
|
+
import { LiveSteeringChannel } from "./live-steering";
|
|
54
57
|
import { agentPauseGate } from "./pause";
|
|
55
58
|
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
|
56
59
|
import { SpeculativeOperationCoordinator } from "./speculative-execution";
|
|
@@ -70,6 +73,7 @@ import {
|
|
|
70
73
|
startExecuteToolSpan,
|
|
71
74
|
startInvokeAgentSpan,
|
|
72
75
|
} from "./telemetry";
|
|
76
|
+
import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
|
|
73
77
|
import type {
|
|
74
78
|
AgentContext,
|
|
75
79
|
AgentEvent,
|
|
@@ -748,6 +752,7 @@ async function emitTurnEnd(
|
|
|
748
752
|
await config.onTurnEnd?.(currentContext.messages, terminalYield ? undefined : signal, {
|
|
749
753
|
message,
|
|
750
754
|
toolResults,
|
|
755
|
+
additionalMessages: [],
|
|
751
756
|
willContinue: false,
|
|
752
757
|
...context,
|
|
753
758
|
});
|
|
@@ -1018,6 +1023,13 @@ function resolveIntentMode(intent: AgentTool["intent"]): "require" | "optional"
|
|
|
1018
1023
|
return "require";
|
|
1019
1024
|
}
|
|
1020
1025
|
|
|
1026
|
+
/**
|
|
1027
|
+
* Longest `i` value accepted as an intent. The injected field is described as
|
|
1028
|
+
* a "concise intent" (INTENT_FIELD_DESCRIPTION); anything past this is a tool
|
|
1029
|
+
* payload the model put in the wrong field, not a label.
|
|
1030
|
+
*/
|
|
1031
|
+
const MAX_INTENT_LENGTH = 200;
|
|
1032
|
+
|
|
1021
1033
|
function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
|
|
1022
1034
|
const { [INTENT_FIELD]: intent, ...strippedArgs } = args;
|
|
1023
1035
|
if (typeof intent !== "string") {
|
|
@@ -1096,6 +1108,26 @@ function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, mess
|
|
|
1096
1108
|
}
|
|
1097
1109
|
}
|
|
1098
1110
|
|
|
1111
|
+
/**
|
|
1112
|
+
* Append passive tool-call context after its results as a developer message.
|
|
1113
|
+
* Returns the injected message for turn-end bookkeeping, or undefined when
|
|
1114
|
+
* there is nothing to inject. Shared by the normal tool-call path and the
|
|
1115
|
+
* resume-tail replay so replayed calls deliver context identically.
|
|
1116
|
+
*/
|
|
1117
|
+
function injectExecutionAdditionalContext(
|
|
1118
|
+
currentContext: AgentContext,
|
|
1119
|
+
newMessages: AgentMessage[],
|
|
1120
|
+
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
1121
|
+
additionalContext: string | undefined,
|
|
1122
|
+
): AgentMessage | undefined {
|
|
1123
|
+
if (additionalContext === undefined) return undefined;
|
|
1124
|
+
const contextMessage = createAdditionalContextMessage(additionalContext);
|
|
1125
|
+
currentContext.messages.push(contextMessage);
|
|
1126
|
+
newMessages.push(contextMessage);
|
|
1127
|
+
emitInputMessages(stream, [contextMessage]);
|
|
1128
|
+
return contextMessage;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1099
1131
|
/**
|
|
1100
1132
|
* Resolve aside entries at the moment the loop is about to inject them. Each entry
|
|
1101
1133
|
* is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
|
|
@@ -1159,6 +1191,10 @@ async function runLoopBody(
|
|
|
1159
1191
|
let preserveSoftRequirementState = false;
|
|
1160
1192
|
|
|
1161
1193
|
let pendingMessages: AgentMessage[] = [];
|
|
1194
|
+
// Steering the provider took from the queue during the last response:
|
|
1195
|
+
// `liveAccepted` reached the model inside it, `liveDeferred` did not.
|
|
1196
|
+
let liveAccepted: AgentMessage[] = [];
|
|
1197
|
+
let liveDeferred: AgentMessage[] = [];
|
|
1162
1198
|
try {
|
|
1163
1199
|
let messagesToEmit = [...initialMessages];
|
|
1164
1200
|
if (isDeadlineExceeded(config.deadline)) {
|
|
@@ -1216,8 +1252,15 @@ async function runLoopBody(
|
|
|
1216
1252
|
currentContext.messages.push(result);
|
|
1217
1253
|
newMessages.push(result);
|
|
1218
1254
|
}
|
|
1255
|
+
const resumeContextMessage = injectExecutionAdditionalContext(
|
|
1256
|
+
currentContext,
|
|
1257
|
+
newMessages,
|
|
1258
|
+
stream,
|
|
1259
|
+
executionResult.additionalContext,
|
|
1260
|
+
);
|
|
1219
1261
|
await emitTurnEnd(stream, currentContext, resumeTail, executionResult.toolResults, config, signal, {
|
|
1220
1262
|
willContinue: !isDeadlineExceeded(config.deadline),
|
|
1263
|
+
...(resumeContextMessage ? { additionalMessages: [resumeContextMessage] } : {}),
|
|
1221
1264
|
});
|
|
1222
1265
|
turnOpen = false;
|
|
1223
1266
|
// A tool hook may mark its completed result as terminal (e.g. subagent
|
|
@@ -1296,6 +1339,7 @@ async function runLoopBody(
|
|
|
1296
1339
|
}
|
|
1297
1340
|
|
|
1298
1341
|
preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
|
|
1342
|
+
preparedProviderCall.liveSteering = openLiveSteering(config, signal, preparedProviderCall);
|
|
1299
1343
|
gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
|
|
1300
1344
|
} catch (error) {
|
|
1301
1345
|
if (!turnOpen) {
|
|
@@ -1422,6 +1466,12 @@ async function runLoopBody(
|
|
|
1422
1466
|
harmonyRetryAttempt++;
|
|
1423
1467
|
continue;
|
|
1424
1468
|
}
|
|
1469
|
+
} finally {
|
|
1470
|
+
const channel = preparedProviderCall.liveSteering;
|
|
1471
|
+
if (channel) {
|
|
1472
|
+
liveAccepted.push(...channel.accepted);
|
|
1473
|
+
liveDeferred.push(...channel.deferred);
|
|
1474
|
+
}
|
|
1425
1475
|
}
|
|
1426
1476
|
if (recovered) {
|
|
1427
1477
|
message = snapshotAssistantMessage(message);
|
|
@@ -1527,6 +1577,7 @@ async function runLoopBody(
|
|
|
1527
1577
|
const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
|
|
1528
1578
|
|
|
1529
1579
|
const toolResults: ToolResultMessage[] = [];
|
|
1580
|
+
const additionalMessages: AgentMessage[] = [];
|
|
1530
1581
|
if (softNonCompliant && softRequiredTool !== undefined) {
|
|
1531
1582
|
SpeculativeOperationCoordinator.discardForMessage(message, "soft tool requirement deferred execution");
|
|
1532
1583
|
if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
|
|
@@ -1569,13 +1620,19 @@ async function runLoopBody(
|
|
|
1569
1620
|
telemetry,
|
|
1570
1621
|
invokeAgentSpan,
|
|
1571
1622
|
);
|
|
1572
|
-
|
|
1573
1623
|
toolResults.push(...executionResult.toolResults);
|
|
1574
1624
|
|
|
1575
1625
|
for (const result of toolResults) {
|
|
1576
1626
|
currentContext.messages.push(result);
|
|
1577
1627
|
newMessages.push(result);
|
|
1578
1628
|
}
|
|
1629
|
+
const injectedContext = injectExecutionAdditionalContext(
|
|
1630
|
+
currentContext,
|
|
1631
|
+
newMessages,
|
|
1632
|
+
stream,
|
|
1633
|
+
executionResult.additionalContext,
|
|
1634
|
+
);
|
|
1635
|
+
if (injectedContext) additionalMessages.push(injectedContext);
|
|
1579
1636
|
} else if (toolCalls.length > 0) {
|
|
1580
1637
|
SpeculativeOperationCoordinator.discardForMessage(
|
|
1581
1638
|
message,
|
|
@@ -1626,6 +1683,7 @@ async function runLoopBody(
|
|
|
1626
1683
|
}
|
|
1627
1684
|
|
|
1628
1685
|
await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
|
|
1686
|
+
additionalMessages,
|
|
1629
1687
|
willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
|
|
1630
1688
|
});
|
|
1631
1689
|
turnOpen = false;
|
|
@@ -1640,16 +1698,36 @@ async function runLoopBody(
|
|
|
1640
1698
|
// instantly aborts — message lands in history, agent never responds. The
|
|
1641
1699
|
// mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
|
|
1642
1700
|
// still owns every message until this dequeue.
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1701
|
+
// Aborted: live-taken steering stays unrecorded, so the agent returns
|
|
1702
|
+
// it to the queue for the continuation run.
|
|
1703
|
+
const live = signal?.aborted ? [] : [...liveAccepted, ...liveDeferred];
|
|
1704
|
+
const liveReachedModel = !signal?.aborted && liveAccepted.length > 0;
|
|
1705
|
+
if (liveReachedModel) {
|
|
1706
|
+
for (const message of liveAccepted) {
|
|
1707
|
+
if (message.role === "user") message.liveSteered = true;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
liveAccepted = [];
|
|
1711
|
+
liveDeferred = [];
|
|
1712
|
+
if (liveReachedModel) {
|
|
1713
|
+
// The server continues from exactly this steering; anything else
|
|
1714
|
+
// queued now would not line up with its continuation, so it waits
|
|
1715
|
+
// for the next boundary (or is steered into that response).
|
|
1716
|
+
pendingMessages = live;
|
|
1648
1717
|
} else {
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1718
|
+
const steering = signal?.aborted
|
|
1719
|
+
? []
|
|
1720
|
+
: [...live, ...((await config.getSteeringMessages?.(signal)) || [])];
|
|
1721
|
+
if (hasMoreToolCalls) {
|
|
1722
|
+
// Mid-work: fold any non-interrupting asides into the next turn alongside steering.
|
|
1723
|
+
const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
|
|
1724
|
+
pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
|
|
1725
|
+
} else {
|
|
1726
|
+
// Stop boundary: only steering (live user input) forces another turn here. Leave
|
|
1727
|
+
// asides for the outer drain below so a passive aside can't trigger an extra model
|
|
1728
|
+
// turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
|
|
1729
|
+
pendingMessages = steering;
|
|
1730
|
+
}
|
|
1653
1731
|
}
|
|
1654
1732
|
}
|
|
1655
1733
|
|
|
@@ -1718,6 +1796,45 @@ interface PreparedProviderCall {
|
|
|
1718
1796
|
context: Context;
|
|
1719
1797
|
promptToolWireTools: Context["tools"];
|
|
1720
1798
|
ownedDialect: Dialect | undefined;
|
|
1799
|
+
/** Steering source offered to the provider for this call. */
|
|
1800
|
+
liveSteering?: LiveSteeringChannel;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Offer queued steering to a provider that can deliver it into the response it
|
|
1805
|
+
* is streaming. Latency decides whether steering lands before the model commits
|
|
1806
|
+
* to its next output, so claims convert only the steering batch — message-level
|
|
1807
|
+
* transforms (steering envelope, redaction) — never the whole transcript.
|
|
1808
|
+
* Provider-context transforms rewrite images, so image-bearing steering waits
|
|
1809
|
+
* for the boundary rather than risk bytes the next request would not replay.
|
|
1810
|
+
*/
|
|
1811
|
+
function openLiveSteering(
|
|
1812
|
+
config: AgentLoopConfig,
|
|
1813
|
+
loopSignal: AbortSignal | undefined,
|
|
1814
|
+
prepared: PreparedProviderCall,
|
|
1815
|
+
): LiveSteeringChannel | undefined {
|
|
1816
|
+
const { getSteeringMessages, waitForSteeringMessages } = config;
|
|
1817
|
+
if (!getSteeringMessages || !waitForSteeringMessages || prepared.ownedDialect) return undefined;
|
|
1818
|
+
const bound = (signal: AbortSignal): AbortSignal => (loopSignal ? AbortSignal.any([signal, loopSignal]) : signal);
|
|
1819
|
+
return new LiveSteeringChannel({
|
|
1820
|
+
wait: signal => waitForSteeringMessages(bound(signal)),
|
|
1821
|
+
take: signal => getSteeringMessages(bound(signal)),
|
|
1822
|
+
toProvider: async (messages, signal) => {
|
|
1823
|
+
const transformed = config.transformContext
|
|
1824
|
+
? await config.transformContext(messages, bound(signal))
|
|
1825
|
+
: messages;
|
|
1826
|
+
const converted = normalizeMessagesForProvider(await config.convertToLlm(transformed), prepared.model);
|
|
1827
|
+
const userMessages: UserMessage[] = [];
|
|
1828
|
+
for (const message of converted) {
|
|
1829
|
+
if (message.role !== "user") return undefined;
|
|
1830
|
+
if (typeof message.content !== "string" && message.content.some(part => part.type === "image")) {
|
|
1831
|
+
return undefined;
|
|
1832
|
+
}
|
|
1833
|
+
userMessages.push(message);
|
|
1834
|
+
}
|
|
1835
|
+
return userMessages.length > 0 ? userMessages : undefined;
|
|
1836
|
+
},
|
|
1837
|
+
});
|
|
1721
1838
|
}
|
|
1722
1839
|
|
|
1723
1840
|
async function prepareProviderCall(
|
|
@@ -1733,7 +1850,8 @@ async function prepareProviderCall(
|
|
|
1733
1850
|
|
|
1734
1851
|
const llmMessages = await config.convertToLlm(messages);
|
|
1735
1852
|
const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
|
|
1736
|
-
const ownedDialect: Dialect | undefined =
|
|
1853
|
+
const ownedDialect: Dialect | undefined =
|
|
1854
|
+
(config.getDialect ? config.getDialect(model) : config.dialect) ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
|
|
1737
1855
|
const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
|
|
1738
1856
|
let llmContext: Context;
|
|
1739
1857
|
if (config.appendOnlyContext) {
|
|
@@ -1902,6 +2020,7 @@ async function streamAssistantResponse(
|
|
|
1902
2020
|
cwd: effectiveCwd,
|
|
1903
2021
|
signal: finalRequestSignal,
|
|
1904
2022
|
onResponse: captureOnResponse,
|
|
2023
|
+
liveSteering: providerCall.liveSteering,
|
|
1905
2024
|
});
|
|
1906
2025
|
if (promptToolWireTools && ownedDialect) {
|
|
1907
2026
|
// Re-materialize in-band tool-call text as native toolCall content blocks
|
|
@@ -2572,6 +2691,12 @@ interface PreparedToolCall {
|
|
|
2572
2691
|
tool: AgentTool<any> | undefined;
|
|
2573
2692
|
/** Validated (possibly hook-revised) execution args; raw args when validation failed. */
|
|
2574
2693
|
args: Record<string, unknown>;
|
|
2694
|
+
/**
|
|
2695
|
+
* Passive context returned by `beforeToolCall`. Committed after the batch
|
|
2696
|
+
* settles only when the call's final result is not an error, so a call the
|
|
2697
|
+
* tool's own approval gate denies (or that otherwise fails) injects nothing.
|
|
2698
|
+
*/
|
|
2699
|
+
additionalContext?: string;
|
|
2575
2700
|
/** Transformed args shared by final reconciliation and eventual dispatch. */
|
|
2576
2701
|
executionArgs?: Record<string, unknown>;
|
|
2577
2702
|
/** Transform failure retained for execution's scheduled error result. */
|
|
@@ -2713,6 +2838,19 @@ async function prepareToolCallDispatch(
|
|
|
2713
2838
|
if (intentTracing) {
|
|
2714
2839
|
const { intent, strippedArgs } = extractIntent(toolCall.arguments);
|
|
2715
2840
|
argsForExecution = strippedArgs;
|
|
2841
|
+
// A payload in `i` would be stripped and the tool run with the leftover
|
|
2842
|
+
// args. Unknown tools fall through to the not-found error; a tool that
|
|
2843
|
+
// owns `i` as a real parameter has nowhere else to put the value.
|
|
2844
|
+
if (
|
|
2845
|
+
intent !== undefined &&
|
|
2846
|
+
intent.length > MAX_INTENT_LENGTH &&
|
|
2847
|
+
tool &&
|
|
2848
|
+
!schemaDefinesProperty(toolWireSchema(tool), INTENT_FIELD)
|
|
2849
|
+
) {
|
|
2850
|
+
entry.args = strippedArgs;
|
|
2851
|
+
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}\`.`;
|
|
2852
|
+
continue;
|
|
2853
|
+
}
|
|
2716
2854
|
if (intent) {
|
|
2717
2855
|
toolCall.intent = intent;
|
|
2718
2856
|
} else if (typeof tool?.intent === "function") {
|
|
@@ -2766,6 +2904,9 @@ async function prepareToolCallDispatch(
|
|
|
2766
2904
|
entry.blockReason = beforeResult.reason;
|
|
2767
2905
|
continue;
|
|
2768
2906
|
}
|
|
2907
|
+
if (isNonBlankContext(beforeResult?.additionalContext)) {
|
|
2908
|
+
entry.additionalContext = beforeResult.additionalContext;
|
|
2909
|
+
}
|
|
2769
2910
|
if (beforeResult?.args !== undefined) {
|
|
2770
2911
|
// Revalidate: a hook revision is untrusted input to the tool schema.
|
|
2771
2912
|
const revised = validate(beforeResult.args);
|
|
@@ -2850,7 +2991,8 @@ async function speculativeFinalCalls(
|
|
|
2850
2991
|
}
|
|
2851
2992
|
|
|
2852
2993
|
/**
|
|
2853
|
-
* Execute tool calls from an assistant message.
|
|
2994
|
+
* Execute tool calls from an assistant message. Returns model-visible context
|
|
2995
|
+
* only after every result has settled, preserving assistant call order.
|
|
2854
2996
|
*/
|
|
2855
2997
|
async function executeToolCalls(
|
|
2856
2998
|
currentContext: AgentContext,
|
|
@@ -2860,7 +3002,7 @@ async function executeToolCalls(
|
|
|
2860
3002
|
config: AgentLoopConfig,
|
|
2861
3003
|
telemetry: AgentTelemetry | undefined,
|
|
2862
3004
|
invokeAgentSpan: Span | undefined,
|
|
2863
|
-
): Promise<{ toolResults: ToolResultMessage[] }> {
|
|
3005
|
+
): Promise<{ toolResults: ToolResultMessage[]; additionalContext?: string }> {
|
|
2864
3006
|
const tools = currentContext.tools;
|
|
2865
3007
|
const {
|
|
2866
3008
|
hasSteeringMessages,
|
|
@@ -2952,6 +3094,8 @@ async function executeToolCalls(
|
|
|
2952
3094
|
blocked: prepared.blocked === true,
|
|
2953
3095
|
blockReason: prepared.blockReason,
|
|
2954
3096
|
prepareError: prepared.prepareError,
|
|
3097
|
+
preparedContext: prepared.additionalContext,
|
|
3098
|
+
reportedContext: [] as string[],
|
|
2955
3099
|
executionArgs: prepared.executionArgs,
|
|
2956
3100
|
transformError: prepared.transformError,
|
|
2957
3101
|
};
|
|
@@ -3184,11 +3328,13 @@ async function executeToolCalls(
|
|
|
3184
3328
|
}
|
|
3185
3329
|
|
|
3186
3330
|
if (!completedToolExecution) {
|
|
3187
|
-
// The cooperative steering signal
|
|
3188
|
-
// ToolCallContext (surfacing as
|
|
3189
|
-
//
|
|
3190
|
-
// the loop
|
|
3191
|
-
|
|
3331
|
+
// The cooperative steering signal and the passive-context sink
|
|
3332
|
+
// ride the loop-owned ToolCallContext (surfacing as
|
|
3333
|
+
// `ctx.toolCall.*`); the host surfaces the sink on the context it
|
|
3334
|
+
// builds, and the loop hands that object to the tool untouched.
|
|
3335
|
+
// Wrapper-dispatched nested calls (for example `write xd://…`)
|
|
3336
|
+
// inherit the context, so their passive hook context joins this
|
|
3337
|
+
// root call at the batch boundary.
|
|
3192
3338
|
const toolContext = getToolContext?.({
|
|
3193
3339
|
batchId,
|
|
3194
3340
|
index,
|
|
@@ -3196,7 +3342,11 @@ async function executeToolCalls(
|
|
|
3196
3342
|
toolCalls: toolCallInfos,
|
|
3197
3343
|
steeringSignal: steeringSoftController.signal,
|
|
3198
3344
|
providerMetadata: toolCall.providerMetadata,
|
|
3345
|
+
addAdditionalContext: context => {
|
|
3346
|
+
if (isNonBlankContext(context)) record.reportedContext.push(context);
|
|
3347
|
+
},
|
|
3199
3348
|
});
|
|
3349
|
+
const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
|
|
3200
3350
|
if (streamSession && toolContext) {
|
|
3201
3351
|
toolContext[SPECULATIVE_STREAM_SESSION] = streamSession;
|
|
3202
3352
|
} else if (streamSession && !streamSession.contextIndependent) {
|
|
@@ -3449,7 +3599,23 @@ async function executeToolCalls(
|
|
|
3449
3599
|
}
|
|
3450
3600
|
await speculationCoordinator?.discardAll("candidate was not dispatched");
|
|
3451
3601
|
|
|
3452
|
-
|
|
3602
|
+
// Skipped calls never ran. Hook-prepared context also requires a non-error
|
|
3603
|
+
// final result; context the tool itself reported during execution stands.
|
|
3604
|
+
// Within a call, tool-reported context (including nested `xd://` dispatch)
|
|
3605
|
+
// precedes the hook's: wrappers release hook context only after the call
|
|
3606
|
+
// succeeds, so this is the one order every dispatch path can produce.
|
|
3607
|
+
const additionalContext = joinAdditionalContext(
|
|
3608
|
+
records
|
|
3609
|
+
.filter(record => !record.skipped)
|
|
3610
|
+
.flatMap(record => [
|
|
3611
|
+
...record.reportedContext,
|
|
3612
|
+
record.toolResultMessage?.isError ? undefined : record.preparedContext,
|
|
3613
|
+
]),
|
|
3614
|
+
);
|
|
3615
|
+
return {
|
|
3616
|
+
toolResults: emittedToolResults,
|
|
3617
|
+
...(additionalContext !== undefined ? { additionalContext } : {}),
|
|
3618
|
+
};
|
|
3453
3619
|
}
|
|
3454
3620
|
|
|
3455
3621
|
/**
|
package/src/agent.ts
CHANGED
|
@@ -40,6 +40,12 @@ import type { AppendOnlyContextManager } from "./append-only-context";
|
|
|
40
40
|
import { isProviderRefusalMessage } from "./replay-policy";
|
|
41
41
|
import { SentToolDefinitions } from "./sent-tool-definitions";
|
|
42
42
|
import { Tokenizer, tokenizerEncodingForModel } from "./tokenizer";
|
|
43
|
+
import {
|
|
44
|
+
createAdditionalContextMessage,
|
|
45
|
+
joinAdditionalContext,
|
|
46
|
+
TOOL_RESULT_ADDITIONAL_CONTEXT,
|
|
47
|
+
type ToolResultWithAdditionalContext,
|
|
48
|
+
} from "./tool-context";
|
|
43
49
|
import type {
|
|
44
50
|
AgentBeforeModelCall,
|
|
45
51
|
AgentContext,
|
|
@@ -66,7 +72,7 @@ import { EventLoopKeepalive } from "./utils/yield";
|
|
|
66
72
|
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
|
67
73
|
return messages.filter((m): m is Message => {
|
|
68
74
|
if (m.role === "assistant") return !isProviderRefusalMessage(m);
|
|
69
|
-
return m.role === "user" || m.role === "toolResult";
|
|
75
|
+
return m.role === "user" || m.role === "developer" || m.role === "toolResult";
|
|
70
76
|
});
|
|
71
77
|
}
|
|
72
78
|
|
|
@@ -272,6 +278,11 @@ export interface AgentOptions {
|
|
|
272
278
|
pruneToolDescriptions?: boolean;
|
|
273
279
|
/** Owned tool-calling dialect. Undefined keeps provider-native tool calling. */
|
|
274
280
|
dialect?: Dialect;
|
|
281
|
+
/**
|
|
282
|
+
* Per-request owned-dialect resolver, consulted with the model being requested.
|
|
283
|
+
* Authoritative when set (like {@link serviceTierResolver}): replaces {@link dialect}.
|
|
284
|
+
*/
|
|
285
|
+
dialectResolver?: (model: Model) => Dialect | undefined;
|
|
275
286
|
/**
|
|
276
287
|
* When owned tool calling is active and the model fabricates a tool result
|
|
277
288
|
* mid-turn: `true` (default) aborts the provider request immediately; `false`
|
|
@@ -362,6 +373,12 @@ interface CursorToolResultEntry {
|
|
|
362
373
|
* `message_end` lands in the same chunk as the tool result.
|
|
363
374
|
*/
|
|
364
375
|
pending?: Promise<void>;
|
|
376
|
+
/**
|
|
377
|
+
* Passive context the executor attached via
|
|
378
|
+
* {@link TOOL_RESULT_ADDITIONAL_CONTEXT}, captured before any transformer
|
|
379
|
+
* can replace the message. Injected after the buffered results.
|
|
380
|
+
*/
|
|
381
|
+
additionalContext?: string;
|
|
365
382
|
}
|
|
366
383
|
|
|
367
384
|
type QueuedMessageQueue = "steering" | "followUp";
|
|
@@ -441,6 +458,7 @@ export class Agent {
|
|
|
441
458
|
#intentTracing: boolean;
|
|
442
459
|
#pruneToolDescriptions: boolean;
|
|
443
460
|
#dialect?: Dialect;
|
|
461
|
+
#dialectResolver?: (model: Model) => Dialect | undefined;
|
|
444
462
|
#abortOnFabricatedToolResult?: boolean;
|
|
445
463
|
#getToolChoice?: () => ToolChoiceDirective | undefined;
|
|
446
464
|
#onToolChoiceUnavailable?: () => void;
|
|
@@ -540,6 +558,7 @@ export class Agent {
|
|
|
540
558
|
this.#intentTracing = opts.intentTracing === true;
|
|
541
559
|
this.#pruneToolDescriptions = opts.pruneToolDescriptions === true;
|
|
542
560
|
this.#dialect = opts.dialect;
|
|
561
|
+
this.#dialectResolver = opts.dialectResolver;
|
|
543
562
|
this.#abortOnFabricatedToolResult = opts.abortOnFabricatedToolResult;
|
|
544
563
|
this.#getToolChoice = opts.getToolChoice;
|
|
545
564
|
this.#onToolChoiceUnavailable = opts.onToolChoiceUnavailable;
|
|
@@ -749,6 +768,33 @@ export class Agent {
|
|
|
749
768
|
this.#hideThinkingSummary = value;
|
|
750
769
|
}
|
|
751
770
|
|
|
771
|
+
/** Strip tool descriptions from provider-bound specs; read per request. */
|
|
772
|
+
get pruneToolDescriptions(): boolean {
|
|
773
|
+
return this.#pruneToolDescriptions;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
set pruneToolDescriptions(value: boolean) {
|
|
777
|
+
this.#pruneToolDescriptions = value;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Inject/strip the intent field on tool calls; applies from the next prompt run. */
|
|
781
|
+
get intentTracing(): boolean {
|
|
782
|
+
return this.#intentTracing;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
set intentTracing(value: boolean) {
|
|
786
|
+
this.#intentTracing = value;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Abort the provider request on a fabricated tool result; applies from the next prompt run. */
|
|
790
|
+
get abortOnFabricatedToolResult(): boolean | undefined {
|
|
791
|
+
return this.#abortOnFabricatedToolResult;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
set abortOnFabricatedToolResult(value: boolean | undefined) {
|
|
795
|
+
this.#abortOnFabricatedToolResult = value;
|
|
796
|
+
}
|
|
797
|
+
|
|
752
798
|
/**
|
|
753
799
|
* Get the current max retry delay in milliseconds.
|
|
754
800
|
*/
|
|
@@ -829,7 +875,9 @@ export class Agent {
|
|
|
829
875
|
): Promise<Context> {
|
|
830
876
|
const model = this.#state.model;
|
|
831
877
|
if (!model) throw new Error("No active model on agent");
|
|
832
|
-
const ownedDialect =
|
|
878
|
+
const ownedDialect =
|
|
879
|
+
(this.#dialectResolver ? this.#dialectResolver(model) : this.#dialect) ??
|
|
880
|
+
resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
|
|
833
881
|
const messages = normalizeMessagesForProvider(llmMessages, model);
|
|
834
882
|
const tools = ownedDialect
|
|
835
883
|
? []
|
|
@@ -1518,7 +1566,10 @@ export class Agent {
|
|
|
1518
1566
|
// that, a transformer resolving after the swap would patch a detached
|
|
1519
1567
|
// object while the persisted result kept the original payload — the
|
|
1520
1568
|
// rewrite silently lost.
|
|
1521
|
-
const entry: CursorToolResultEntry = {
|
|
1569
|
+
const entry: CursorToolResultEntry = {
|
|
1570
|
+
toolResult: message,
|
|
1571
|
+
additionalContext: (message as ToolResultWithAdditionalContext)[TOOL_RESULT_ADDITIONAL_CONTEXT],
|
|
1572
|
+
};
|
|
1522
1573
|
this.#cursorToolResultBuffer.push(entry);
|
|
1523
1574
|
const transform = this.#cursorOnToolResult;
|
|
1524
1575
|
if (transform) {
|
|
@@ -1624,6 +1675,7 @@ export class Agent {
|
|
|
1624
1675
|
intentTracing: this.#intentTracing,
|
|
1625
1676
|
pruneToolDescriptions: this.#pruneToolDescriptions,
|
|
1626
1677
|
dialect: this.#dialect,
|
|
1678
|
+
getDialect: this.#dialectResolver,
|
|
1627
1679
|
abortOnFabricatedToolResult: this.#abortOnFabricatedToolResult,
|
|
1628
1680
|
appendOnlyContext: this.#appendOnlyContext,
|
|
1629
1681
|
beforeToolCall: this.beforeToolCall ? (ctx, signal) => this.beforeToolCall?.(ctx, signal) : undefined,
|
|
@@ -1781,6 +1833,9 @@ export class Agent {
|
|
|
1781
1833
|
.map(entry => entry.pending);
|
|
1782
1834
|
if (pendingTransforms.length > 0) await Promise.all(pendingTransforms);
|
|
1783
1835
|
const bufferedCursorResults = this.#cursorToolResultBuffer.map(({ toolResult }) => toolResult);
|
|
1836
|
+
const bufferedCursorContext = joinAdditionalContext(
|
|
1837
|
+
this.#cursorToolResultBuffer.map(({ additionalContext }) => additionalContext),
|
|
1838
|
+
);
|
|
1784
1839
|
const retainedToolCallIds = new Set(completedToolCallIds);
|
|
1785
1840
|
for (const { toolCallId } of bufferedCursorResults) retainedToolCallIds.add(toolCallId);
|
|
1786
1841
|
const errorMsg: AssistantMessage =
|
|
@@ -1857,9 +1912,13 @@ export class Agent {
|
|
|
1857
1912
|
this.#emit({ type: "message_end", message: toolResult });
|
|
1858
1913
|
toolResults.push(toolResult);
|
|
1859
1914
|
}
|
|
1915
|
+
const agentEndMessages: AgentMessage[] = [errorMsg, ...toolResults];
|
|
1916
|
+
if (bufferedCursorContext !== undefined) {
|
|
1917
|
+
agentEndMessages.push(this.#emitCursorAdditionalContext(bufferedCursorContext));
|
|
1918
|
+
}
|
|
1860
1919
|
this.#emit({ type: "turn_end", message: errorMsg, toolResults });
|
|
1861
1920
|
turnOpen = false;
|
|
1862
|
-
this.#emit({ type: "agent_end", messages:
|
|
1921
|
+
this.#emit({ type: "agent_end", messages: agentEndMessages });
|
|
1863
1922
|
} else {
|
|
1864
1923
|
this.appendMessage(errorMsg);
|
|
1865
1924
|
this.#state.error = errorMessage;
|
|
@@ -1931,8 +1990,24 @@ export class Agent {
|
|
|
1931
1990
|
this.appendMessage(toolResult);
|
|
1932
1991
|
this.#emit({ type: "message_end", message: toolResult });
|
|
1933
1992
|
}
|
|
1993
|
+
const additionalContext = joinAdditionalContext(buffer.map(entry => entry.additionalContext));
|
|
1994
|
+
if (additionalContext !== undefined) this.#emitCursorAdditionalContext(additionalContext);
|
|
1934
1995
|
} finally {
|
|
1935
1996
|
this.#cursorToolResultDrain = undefined;
|
|
1936
1997
|
}
|
|
1937
1998
|
}
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* Append passive context reported by Cursor exec-channel tools after their
|
|
2002
|
+
* results, mirroring the loop's post-batch developer message. Cursor runs
|
|
2003
|
+
* those tools server-side mid-stream, so the context reaches the next
|
|
2004
|
+
* provider request instead of the current one.
|
|
2005
|
+
*/
|
|
2006
|
+
#emitCursorAdditionalContext(text: string): AgentMessage {
|
|
2007
|
+
const message = createAdditionalContextMessage(text);
|
|
2008
|
+
this.#emit({ type: "message_start", message });
|
|
2009
|
+
this.appendMessage(message);
|
|
2010
|
+
this.#emit({ type: "message_end", message });
|
|
2011
|
+
return message;
|
|
2012
|
+
}
|
|
1938
2013
|
}
|
|
@@ -255,7 +255,7 @@ export async function requestAnthropicNativeCompaction(
|
|
|
255
255
|
throw new Error(
|
|
256
256
|
response.stopDetails?.type === "compaction"
|
|
257
257
|
? "Anthropic compaction returned no signed summary"
|
|
258
|
-
:
|
|
258
|
+
: `Anthropic compaction response carried no compaction block (stop reason: ${response.stopDetails?.type ?? response.stopReason})`,
|
|
259
259
|
);
|
|
260
260
|
}
|
|
261
261
|
return {
|
|
@@ -64,6 +64,13 @@ export interface CompactionSummaryMessage {
|
|
|
64
64
|
images?: ImageContent[];
|
|
65
65
|
/** Post-pass dead-end warning attached to this compaction (progress guard). */
|
|
66
66
|
warning?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Thinking-binding rewrite marker when it must differ from `timestamp`: a
|
|
69
|
+
* natively replayed summary predates it before the retained tail so that
|
|
70
|
+
* tail's bound thinking stays valid. `timestamp` remains the commit time,
|
|
71
|
+
* which is what invalidates the tail's pre-compaction usage reports.
|
|
72
|
+
*/
|
|
73
|
+
historyRewriteAt?: number;
|
|
67
74
|
timestamp: number;
|
|
68
75
|
}
|
|
69
76
|
|
|
@@ -135,6 +142,8 @@ export interface CompactionSummaryMessageOptions {
|
|
|
135
142
|
method?: string;
|
|
136
143
|
/** Estimated context tokens after the rewrite, for display alongside `tokensBefore`. */
|
|
137
144
|
tokensAfter?: number;
|
|
145
|
+
/** See {@link CompactionSummaryMessage.historyRewriteAt}. */
|
|
146
|
+
historyRewriteAt?: number;
|
|
138
147
|
}
|
|
139
148
|
|
|
140
149
|
export function createCompactionSummaryMessage(
|
|
@@ -143,7 +152,7 @@ export function createCompactionSummaryMessage(
|
|
|
143
152
|
timestamp: string,
|
|
144
153
|
options: CompactionSummaryMessageOptions = {},
|
|
145
154
|
): CompactionSummaryMessage {
|
|
146
|
-
const { shortSummary, providerPayload, images, blocks, warning, method, tokensAfter } = options;
|
|
155
|
+
const { shortSummary, providerPayload, images, blocks, warning, method, tokensAfter, historyRewriteAt } = options;
|
|
147
156
|
const imageBlocks =
|
|
148
157
|
blocks?.filter((block): block is ImageContent => block.type === "image") ??
|
|
149
158
|
(images && images.length > 0 ? images : undefined);
|
|
@@ -158,6 +167,7 @@ export function createCompactionSummaryMessage(
|
|
|
158
167
|
blocks: blocks && blocks.length > 0 ? blocks : undefined,
|
|
159
168
|
images: imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined,
|
|
160
169
|
warning,
|
|
170
|
+
historyRewriteAt,
|
|
161
171
|
timestamp: new Date(timestamp).getTime(),
|
|
162
172
|
};
|
|
163
173
|
}
|
|
@@ -246,7 +256,7 @@ export function convertMessageToLlm(message: AgentMessage): Message | undefined
|
|
|
246
256
|
...(message.images ?? []),
|
|
247
257
|
],
|
|
248
258
|
attribution: "agent",
|
|
249
|
-
historyRewriteAt: message.timestamp,
|
|
259
|
+
historyRewriteAt: message.historyRewriteAt ?? message.timestamp,
|
|
250
260
|
providerPayload: message.providerPayload,
|
|
251
261
|
timestamp: message.timestamp,
|
|
252
262
|
};
|
|
@@ -120,7 +120,7 @@ function createPrunedNotice(tokens: number): string {
|
|
|
120
120
|
* own rules: useless already drops no-savings candidates, superseded prunes for
|
|
121
121
|
* correctness regardless of size.
|
|
122
122
|
*/
|
|
123
|
-
const MIN_PRUNE_TOKENS = 50;
|
|
123
|
+
export const MIN_PRUNE_TOKENS = 50;
|
|
124
124
|
|
|
125
125
|
function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
|
|
126
126
|
if (entry.type !== "message") return undefined;
|