@oh-my-pi/pi-agent-core 18.3.0 → 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 +12 -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/transcript-tokens.d.ts +14 -1
- 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 +164 -19
- package/src/agent.ts +79 -4
- package/src/compaction/messages.ts +12 -2
- package/src/compaction/transcript-tokens.ts +31 -1
- 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 {
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
} from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
52
53
|
import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
53
54
|
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
55
|
+
import { LiveSteeringChannel } from "./live-steering";
|
|
54
56
|
import { agentPauseGate } from "./pause";
|
|
55
57
|
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
|
56
58
|
import { SpeculativeOperationCoordinator } from "./speculative-execution";
|
|
@@ -70,6 +72,7 @@ import {
|
|
|
70
72
|
startExecuteToolSpan,
|
|
71
73
|
startInvokeAgentSpan,
|
|
72
74
|
} from "./telemetry";
|
|
75
|
+
import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
|
|
73
76
|
import type {
|
|
74
77
|
AgentContext,
|
|
75
78
|
AgentEvent,
|
|
@@ -748,6 +751,7 @@ async function emitTurnEnd(
|
|
|
748
751
|
await config.onTurnEnd?.(currentContext.messages, terminalYield ? undefined : signal, {
|
|
749
752
|
message,
|
|
750
753
|
toolResults,
|
|
754
|
+
additionalMessages: [],
|
|
751
755
|
willContinue: false,
|
|
752
756
|
...context,
|
|
753
757
|
});
|
|
@@ -1096,6 +1100,26 @@ function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, mess
|
|
|
1096
1100
|
}
|
|
1097
1101
|
}
|
|
1098
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
|
+
|
|
1099
1123
|
/**
|
|
1100
1124
|
* Resolve aside entries at the moment the loop is about to inject them. Each entry
|
|
1101
1125
|
* is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
|
|
@@ -1159,6 +1183,10 @@ async function runLoopBody(
|
|
|
1159
1183
|
let preserveSoftRequirementState = false;
|
|
1160
1184
|
|
|
1161
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[] = [];
|
|
1162
1190
|
try {
|
|
1163
1191
|
let messagesToEmit = [...initialMessages];
|
|
1164
1192
|
if (isDeadlineExceeded(config.deadline)) {
|
|
@@ -1216,8 +1244,15 @@ async function runLoopBody(
|
|
|
1216
1244
|
currentContext.messages.push(result);
|
|
1217
1245
|
newMessages.push(result);
|
|
1218
1246
|
}
|
|
1247
|
+
const resumeContextMessage = injectExecutionAdditionalContext(
|
|
1248
|
+
currentContext,
|
|
1249
|
+
newMessages,
|
|
1250
|
+
stream,
|
|
1251
|
+
executionResult.additionalContext,
|
|
1252
|
+
);
|
|
1219
1253
|
await emitTurnEnd(stream, currentContext, resumeTail, executionResult.toolResults, config, signal, {
|
|
1220
1254
|
willContinue: !isDeadlineExceeded(config.deadline),
|
|
1255
|
+
...(resumeContextMessage ? { additionalMessages: [resumeContextMessage] } : {}),
|
|
1221
1256
|
});
|
|
1222
1257
|
turnOpen = false;
|
|
1223
1258
|
// A tool hook may mark its completed result as terminal (e.g. subagent
|
|
@@ -1296,6 +1331,7 @@ async function runLoopBody(
|
|
|
1296
1331
|
}
|
|
1297
1332
|
|
|
1298
1333
|
preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
|
|
1334
|
+
preparedProviderCall.liveSteering = openLiveSteering(config, signal, preparedProviderCall);
|
|
1299
1335
|
gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
|
|
1300
1336
|
} catch (error) {
|
|
1301
1337
|
if (!turnOpen) {
|
|
@@ -1422,6 +1458,12 @@ async function runLoopBody(
|
|
|
1422
1458
|
harmonyRetryAttempt++;
|
|
1423
1459
|
continue;
|
|
1424
1460
|
}
|
|
1461
|
+
} finally {
|
|
1462
|
+
const channel = preparedProviderCall.liveSteering;
|
|
1463
|
+
if (channel) {
|
|
1464
|
+
liveAccepted.push(...channel.accepted);
|
|
1465
|
+
liveDeferred.push(...channel.deferred);
|
|
1466
|
+
}
|
|
1425
1467
|
}
|
|
1426
1468
|
if (recovered) {
|
|
1427
1469
|
message = snapshotAssistantMessage(message);
|
|
@@ -1527,6 +1569,7 @@ async function runLoopBody(
|
|
|
1527
1569
|
const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
|
|
1528
1570
|
|
|
1529
1571
|
const toolResults: ToolResultMessage[] = [];
|
|
1572
|
+
const additionalMessages: AgentMessage[] = [];
|
|
1530
1573
|
if (softNonCompliant && softRequiredTool !== undefined) {
|
|
1531
1574
|
SpeculativeOperationCoordinator.discardForMessage(message, "soft tool requirement deferred execution");
|
|
1532
1575
|
if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
|
|
@@ -1569,13 +1612,19 @@ async function runLoopBody(
|
|
|
1569
1612
|
telemetry,
|
|
1570
1613
|
invokeAgentSpan,
|
|
1571
1614
|
);
|
|
1572
|
-
|
|
1573
1615
|
toolResults.push(...executionResult.toolResults);
|
|
1574
1616
|
|
|
1575
1617
|
for (const result of toolResults) {
|
|
1576
1618
|
currentContext.messages.push(result);
|
|
1577
1619
|
newMessages.push(result);
|
|
1578
1620
|
}
|
|
1621
|
+
const injectedContext = injectExecutionAdditionalContext(
|
|
1622
|
+
currentContext,
|
|
1623
|
+
newMessages,
|
|
1624
|
+
stream,
|
|
1625
|
+
executionResult.additionalContext,
|
|
1626
|
+
);
|
|
1627
|
+
if (injectedContext) additionalMessages.push(injectedContext);
|
|
1579
1628
|
} else if (toolCalls.length > 0) {
|
|
1580
1629
|
SpeculativeOperationCoordinator.discardForMessage(
|
|
1581
1630
|
message,
|
|
@@ -1626,6 +1675,7 @@ async function runLoopBody(
|
|
|
1626
1675
|
}
|
|
1627
1676
|
|
|
1628
1677
|
await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
|
|
1678
|
+
additionalMessages,
|
|
1629
1679
|
willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
|
|
1630
1680
|
});
|
|
1631
1681
|
turnOpen = false;
|
|
@@ -1640,16 +1690,36 @@ async function runLoopBody(
|
|
|
1640
1690
|
// instantly aborts — message lands in history, agent never responds. The
|
|
1641
1691
|
// mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
|
|
1642
1692
|
// still owns every message until this dequeue.
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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;
|
|
1648
1709
|
} else {
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
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
|
+
}
|
|
1653
1723
|
}
|
|
1654
1724
|
}
|
|
1655
1725
|
|
|
@@ -1718,6 +1788,45 @@ interface PreparedProviderCall {
|
|
|
1718
1788
|
context: Context;
|
|
1719
1789
|
promptToolWireTools: Context["tools"];
|
|
1720
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
|
+
});
|
|
1721
1830
|
}
|
|
1722
1831
|
|
|
1723
1832
|
async function prepareProviderCall(
|
|
@@ -1733,7 +1842,8 @@ async function prepareProviderCall(
|
|
|
1733
1842
|
|
|
1734
1843
|
const llmMessages = await config.convertToLlm(messages);
|
|
1735
1844
|
const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
|
|
1736
|
-
const ownedDialect: Dialect | undefined =
|
|
1845
|
+
const ownedDialect: Dialect | undefined =
|
|
1846
|
+
(config.getDialect ? config.getDialect(model) : config.dialect) ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
|
|
1737
1847
|
const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
|
|
1738
1848
|
let llmContext: Context;
|
|
1739
1849
|
if (config.appendOnlyContext) {
|
|
@@ -1902,6 +2012,7 @@ async function streamAssistantResponse(
|
|
|
1902
2012
|
cwd: effectiveCwd,
|
|
1903
2013
|
signal: finalRequestSignal,
|
|
1904
2014
|
onResponse: captureOnResponse,
|
|
2015
|
+
liveSteering: providerCall.liveSteering,
|
|
1905
2016
|
});
|
|
1906
2017
|
if (promptToolWireTools && ownedDialect) {
|
|
1907
2018
|
// Re-materialize in-band tool-call text as native toolCall content blocks
|
|
@@ -2572,6 +2683,12 @@ interface PreparedToolCall {
|
|
|
2572
2683
|
tool: AgentTool<any> | undefined;
|
|
2573
2684
|
/** Validated (possibly hook-revised) execution args; raw args when validation failed. */
|
|
2574
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;
|
|
2575
2692
|
/** Transformed args shared by final reconciliation and eventual dispatch. */
|
|
2576
2693
|
executionArgs?: Record<string, unknown>;
|
|
2577
2694
|
/** Transform failure retained for execution's scheduled error result. */
|
|
@@ -2766,6 +2883,9 @@ async function prepareToolCallDispatch(
|
|
|
2766
2883
|
entry.blockReason = beforeResult.reason;
|
|
2767
2884
|
continue;
|
|
2768
2885
|
}
|
|
2886
|
+
if (isNonBlankContext(beforeResult?.additionalContext)) {
|
|
2887
|
+
entry.additionalContext = beforeResult.additionalContext;
|
|
2888
|
+
}
|
|
2769
2889
|
if (beforeResult?.args !== undefined) {
|
|
2770
2890
|
// Revalidate: a hook revision is untrusted input to the tool schema.
|
|
2771
2891
|
const revised = validate(beforeResult.args);
|
|
@@ -2850,7 +2970,8 @@ async function speculativeFinalCalls(
|
|
|
2850
2970
|
}
|
|
2851
2971
|
|
|
2852
2972
|
/**
|
|
2853
|
-
* 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.
|
|
2854
2975
|
*/
|
|
2855
2976
|
async function executeToolCalls(
|
|
2856
2977
|
currentContext: AgentContext,
|
|
@@ -2860,7 +2981,7 @@ async function executeToolCalls(
|
|
|
2860
2981
|
config: AgentLoopConfig,
|
|
2861
2982
|
telemetry: AgentTelemetry | undefined,
|
|
2862
2983
|
invokeAgentSpan: Span | undefined,
|
|
2863
|
-
): Promise<{ toolResults: ToolResultMessage[] }> {
|
|
2984
|
+
): Promise<{ toolResults: ToolResultMessage[]; additionalContext?: string }> {
|
|
2864
2985
|
const tools = currentContext.tools;
|
|
2865
2986
|
const {
|
|
2866
2987
|
hasSteeringMessages,
|
|
@@ -2952,6 +3073,8 @@ async function executeToolCalls(
|
|
|
2952
3073
|
blocked: prepared.blocked === true,
|
|
2953
3074
|
blockReason: prepared.blockReason,
|
|
2954
3075
|
prepareError: prepared.prepareError,
|
|
3076
|
+
preparedContext: prepared.additionalContext,
|
|
3077
|
+
reportedContext: [] as string[],
|
|
2955
3078
|
executionArgs: prepared.executionArgs,
|
|
2956
3079
|
transformError: prepared.transformError,
|
|
2957
3080
|
};
|
|
@@ -3184,11 +3307,13 @@ async function executeToolCalls(
|
|
|
3184
3307
|
}
|
|
3185
3308
|
|
|
3186
3309
|
if (!completedToolExecution) {
|
|
3187
|
-
// The cooperative steering signal
|
|
3188
|
-
// ToolCallContext (surfacing as
|
|
3189
|
-
//
|
|
3190
|
-
// the loop
|
|
3191
|
-
|
|
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.
|
|
3192
3317
|
const toolContext = getToolContext?.({
|
|
3193
3318
|
batchId,
|
|
3194
3319
|
index,
|
|
@@ -3196,7 +3321,11 @@ async function executeToolCalls(
|
|
|
3196
3321
|
toolCalls: toolCallInfos,
|
|
3197
3322
|
steeringSignal: steeringSoftController.signal,
|
|
3198
3323
|
providerMetadata: toolCall.providerMetadata,
|
|
3324
|
+
addAdditionalContext: context => {
|
|
3325
|
+
if (isNonBlankContext(context)) record.reportedContext.push(context);
|
|
3326
|
+
},
|
|
3199
3327
|
});
|
|
3328
|
+
const streamSession = speculationCoordinator?.takeStreamSession(toolCall.id);
|
|
3200
3329
|
if (streamSession && toolContext) {
|
|
3201
3330
|
toolContext[SPECULATIVE_STREAM_SESSION] = streamSession;
|
|
3202
3331
|
} else if (streamSession && !streamSession.contextIndependent) {
|
|
@@ -3449,7 +3578,23 @@ async function executeToolCalls(
|
|
|
3449
3578
|
}
|
|
3450
3579
|
await speculationCoordinator?.discardAll("candidate was not dispatched");
|
|
3451
3580
|
|
|
3452
|
-
|
|
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
|
+
};
|
|
3453
3598
|
}
|
|
3454
3599
|
|
|
3455
3600
|
/**
|
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
|
}
|
|
@@ -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
|
};
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* - `hasContextTokenUsage(usage)`: the report must carry usable context numbers.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
21
|
+
import type { AssistantMessage, Message } from "@oh-my-pi/pi-ai";
|
|
22
22
|
import type { MessageCountOptions, Tokenizer } from "../tokenizer";
|
|
23
23
|
import type { AgentMessage } from "../types";
|
|
24
24
|
import { calculateContextTokens, hasContextTokenUsage } from "./compaction";
|
|
@@ -67,6 +67,36 @@ export function findTranscriptUsageAnchor(
|
|
|
67
67
|
return undefined;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Newest assistant turn in a provider request's `messages` whose usage still
|
|
72
|
+
* describes the prefix it sits on, or `undefined` when none does.
|
|
73
|
+
*
|
|
74
|
+
* Request contexts carry no compaction index, so staleness is read from the
|
|
75
|
+
* rewrite markers themselves: a compaction/branch summary or pruned tool result
|
|
76
|
+
* (`prunedAt`) replaced text that every report made at or before the rewrite
|
|
77
|
+
* already counted. A summary's rewrite time is its `timestamp` (commit time);
|
|
78
|
+
* its `historyRewriteAt` may be predated before a natively replayed retained
|
|
79
|
+
* tail so that tail's bound thinking survives, but the tail's usage still
|
|
80
|
+
* counted the summarized prefix.
|
|
81
|
+
*/
|
|
82
|
+
export function findRequestUsageAnchor(messages: readonly Message[]): TranscriptUsageAnchor | undefined {
|
|
83
|
+
let rewriteAt = Number.NEGATIVE_INFINITY;
|
|
84
|
+
let anchorIndex = -1;
|
|
85
|
+
let anchor: AssistantMessage | undefined;
|
|
86
|
+
for (let index = 0; index < messages.length; index++) {
|
|
87
|
+
const message = messages[index];
|
|
88
|
+
if (message.role === "user" && message.historyRewriteAt !== undefined) {
|
|
89
|
+
rewriteAt = Math.max(rewriteAt, message.historyRewriteAt, message.timestamp);
|
|
90
|
+
} else if (message.role === "toolResult" && message.prunedAt !== undefined) {
|
|
91
|
+
rewriteAt = Math.max(rewriteAt, message.prunedAt);
|
|
92
|
+
} else if (isTranscriptUsageAnchor(message) && message.timestamp > rewriteAt) {
|
|
93
|
+
anchorIndex = index;
|
|
94
|
+
anchor = message;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return anchor && { index: anchorIndex, message: anchor, tokens: calculateContextTokens(anchor.usage) };
|
|
98
|
+
}
|
|
99
|
+
|
|
70
100
|
/** Options for {@link estimateTranscriptTokens}. */
|
|
71
101
|
export interface TranscriptTokenOptions {
|
|
72
102
|
/**
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,8 @@ export * from "./agent-loop";
|
|
|
6
6
|
export * from "./append-only-context";
|
|
7
7
|
// Compaction
|
|
8
8
|
export * from "./compaction";
|
|
9
|
+
// Output cap sized to the remaining context window
|
|
10
|
+
export * from "./output-budget";
|
|
9
11
|
// Process-global pause gate
|
|
10
12
|
export * from "./pause";
|
|
11
13
|
// Proxy utilities
|
|
@@ -22,6 +24,8 @@ export * from "./speculative-execution";
|
|
|
22
24
|
export * from "./telemetry";
|
|
23
25
|
// Thinking selectors
|
|
24
26
|
export * from "./thinking";
|
|
27
|
+
// Tool-context augmentation
|
|
28
|
+
export * from "./tool-context";
|
|
25
29
|
// Tokenizer choice
|
|
26
30
|
export * from "./tokenizer";
|
|
27
31
|
// Types
|