@oh-my-pi/pi-agent-core 16.2.13 → 16.3.0
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 +15 -0
- package/dist/types/agent.d.ts +4 -0
- package/dist/types/compaction/compaction.d.ts +26 -2
- package/dist/types/compaction/utils.d.ts +4 -0
- package/dist/types/types.d.ts +8 -0
- package/package.json +7 -7
- package/src/agent-loop.ts +118 -68
- package/src/agent.ts +5 -0
- package/src/compaction/branch-summarization.ts +21 -3
- package/src/compaction/compaction.ts +58 -10
- package/src/compaction/utils.ts +7 -8
- package/src/types.ts +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.0] - 2026-07-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added support for Anthropic fallback content blocks in agent-loop assistant messages, ensuring they are preserved across session persistence and event fanout.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed an issue where legacy steering messages were prematurely consumed and dropped during in-flight tool execution polls.
|
|
14
|
+
- Fixed an issue where skipped tool results in queued messages were incorrectly treated as completed, preventing necessary retries.
|
|
15
|
+
- Improved branch summaries to preserve informative tool results from abandoned branches while filtering out redundant output.
|
|
16
|
+
- Fixed interruptible tool waits to properly abort on host-provided IRC interrupts in addition to user steering.
|
|
17
|
+
- Fixed schema validation errors for closed union tools by correctly injecting intent tracing into each variant.
|
|
18
|
+
- Fixed token compaction reserve-budget logic to honor explicit reserveTokens values equal to the built-in default, and clamped the fallback reserve to at least one token for very small context windows.
|
|
19
|
+
|
|
5
20
|
## [16.2.4] - 2026-06-28
|
|
6
21
|
|
|
7
22
|
### Changed
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -222,6 +222,10 @@ export declare class Agent {
|
|
|
222
222
|
* UI emission, and tool dispatch. Reassign at any time to swap the implementation.
|
|
223
223
|
*/
|
|
224
224
|
transformAssistantMessage?: AgentLoopConfig["transformAssistantMessage"];
|
|
225
|
+
/**
|
|
226
|
+
* Hook that peeks whether interrupting IRC asides are queued for the next boundary.
|
|
227
|
+
*/
|
|
228
|
+
hasIrcInterrupts?: AgentLoopConfig["hasIrcInterrupts"];
|
|
225
229
|
constructor(opts?: AgentOptions);
|
|
226
230
|
/**
|
|
227
231
|
* Get the current session ID used for provider caching.
|
|
@@ -34,7 +34,15 @@ export interface CompactionSettings {
|
|
|
34
34
|
thresholdPercent?: number;
|
|
35
35
|
thresholdTokens?: number;
|
|
36
36
|
midTurnEnabled?: boolean;
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Tokens reserved below the context window for the next prompt + response.
|
|
39
|
+
*
|
|
40
|
+
* Leave unset to use {@link DEFAULT_RESERVE_TOKENS}; the unset state is the
|
|
41
|
+
* provenance signal that lets small-window recovery replace the default with
|
|
42
|
+
* a proportional reserve (see {@link resolveBudgetReserveTokens}). An
|
|
43
|
+
* explicit value — even one equal to the default — is always honored.
|
|
44
|
+
*/
|
|
45
|
+
reserveTokens?: number;
|
|
38
46
|
keepRecentTokens: number;
|
|
39
47
|
autoContinue?: boolean;
|
|
40
48
|
remoteEnabled?: boolean;
|
|
@@ -42,6 +50,8 @@ export interface CompactionSettings {
|
|
|
42
50
|
remoteStreamingV2Enabled?: boolean;
|
|
43
51
|
v2RetainedMessageBudget?: number;
|
|
44
52
|
}
|
|
53
|
+
/** Reserve applied when {@link CompactionSettings.reserveTokens} is unset. */
|
|
54
|
+
export declare const DEFAULT_RESERVE_TOKENS = 16384;
|
|
45
55
|
export declare const DEFAULT_COMPACTION_SETTINGS: CompactionSettings;
|
|
46
56
|
/**
|
|
47
57
|
* Calculate total context tokens from usage.
|
|
@@ -54,9 +64,23 @@ export declare function calculatePromptTokens(usage: Usage): number;
|
|
|
54
64
|
*/
|
|
55
65
|
export declare function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined;
|
|
56
66
|
/**
|
|
57
|
-
* Effective reserve: at least 15% of context window or the configured floor
|
|
67
|
+
* Effective reserve: at least 15% of context window or the configured floor
|
|
68
|
+
* (defaulting to {@link DEFAULT_RESERVE_TOKENS} when unset), whichever is larger.
|
|
58
69
|
*/
|
|
59
70
|
export declare function effectiveReserveTokens(contextWindow: number, settings: CompactionSettings): number;
|
|
71
|
+
/**
|
|
72
|
+
* Reserve used when deciding whether a prompt still fits inside the model window.
|
|
73
|
+
*
|
|
74
|
+
* The default absolute reserve predates small bundled windows and can leave no
|
|
75
|
+
* practical budget there; recover a DEFAULTED reserve that is impossible for
|
|
76
|
+
* the window with the 15% proportional reserve (clamped to >= 1 so the derived
|
|
77
|
+
* threshold stays strictly below the window even for tiny test windows).
|
|
78
|
+
* Explicit valid reserves — including one that happens to equal the default —
|
|
79
|
+
* still win, because they intentionally shrink the usable prompt budget;
|
|
80
|
+
* provenance is carried by `settings.reserveTokens` being unset, never by
|
|
81
|
+
* comparing values against the default.
|
|
82
|
+
*/
|
|
83
|
+
export declare function resolveBudgetReserveTokens(contextWindow: number, settings: CompactionSettings): number;
|
|
60
84
|
/**
|
|
61
85
|
* Check if compaction should trigger based on context usage.
|
|
62
86
|
*/
|
|
@@ -45,6 +45,10 @@ export declare function computeFileLists(fileOps: FileOperations): {
|
|
|
45
45
|
};
|
|
46
46
|
export declare function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
|
|
47
47
|
export declare function upsertFileOperations(summary: string, readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
|
|
48
|
+
/**
|
|
49
|
+
* Truncate tool results to the same representation used in summarization prompts.
|
|
50
|
+
*/
|
|
51
|
+
export declare function truncateToolResultForSummary(text: string): string;
|
|
48
52
|
/**
|
|
49
53
|
* Serialize LLM messages to text for summarization.
|
|
50
54
|
* This prevents the model from treating it as a conversation to continue.
|
package/dist/types/types.d.ts
CHANGED
|
@@ -158,6 +158,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
158
158
|
* messages are still delivered at the next injection boundary.
|
|
159
159
|
*/
|
|
160
160
|
hasSteeringMessages?: () => boolean | Promise<boolean>;
|
|
161
|
+
/**
|
|
162
|
+
* Peeks whether IRC messages should interrupt an interruptible waiting tool.
|
|
163
|
+
*
|
|
164
|
+
* Uses the same delivery rules as steering: the poll is non-consuming, only
|
|
165
|
+
* runs for interruptible tools, and is ignored when interruptMode is "wait".
|
|
166
|
+
* The host owns message injection at the next boundary.
|
|
167
|
+
*/
|
|
168
|
+
hasIrcInterrupts?: () => boolean | Promise<boolean>;
|
|
161
169
|
/**
|
|
162
170
|
* Returns follow-up messages to process after the agent would otherwise stop.
|
|
163
171
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-agent-core",
|
|
4
|
-
"version": "16.
|
|
4
|
+
"version": "16.3.0",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@oh-my-pi/pi-ai": "16.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "16.
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.
|
|
42
|
-
"@oh-my-pi/pi-wire": "16.
|
|
43
|
-
"@oh-my-pi/snapcompact": "16.
|
|
38
|
+
"@oh-my-pi/pi-ai": "16.3.0",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "16.3.0",
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.3.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.3.0",
|
|
42
|
+
"@oh-my-pi/pi-wire": "16.3.0",
|
|
43
|
+
"@oh-my-pi/snapcompact": "16.3.0",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -156,6 +156,8 @@ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantC
|
|
|
156
156
|
return { ...block };
|
|
157
157
|
case "redactedThinking":
|
|
158
158
|
return { ...block };
|
|
159
|
+
case "fallback":
|
|
160
|
+
return { ...block, from: { ...block.from }, to: { ...block.to } };
|
|
159
161
|
case "toolCall":
|
|
160
162
|
return { ...block, arguments: structuredCloneJSON(block.arguments) };
|
|
161
163
|
}
|
|
@@ -535,6 +537,7 @@ export function normalizeMessagesForProvider(
|
|
|
535
537
|
}
|
|
536
538
|
|
|
537
539
|
const INTENT_FIELD_DESCRIPTION = "concise intent";
|
|
540
|
+
const INTENT_SCHEMA_UNION_KEYS = ["anyOf", "oneOf"] as const;
|
|
538
541
|
|
|
539
542
|
function injectIntentIntoSchema(
|
|
540
543
|
schema: unknown,
|
|
@@ -544,10 +547,30 @@ function injectIntentIntoSchema(
|
|
|
544
547
|
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
|
|
545
548
|
const schemaRecord = schema as Record<string, unknown>;
|
|
546
549
|
const propertiesValue = schemaRecord.properties;
|
|
547
|
-
const
|
|
548
|
-
propertiesValue && typeof propertiesValue === "object" && !Array.isArray(propertiesValue)
|
|
549
|
-
|
|
550
|
-
|
|
550
|
+
const hasOwnProperties =
|
|
551
|
+
propertiesValue !== null && typeof propertiesValue === "object" && !Array.isArray(propertiesValue);
|
|
552
|
+
|
|
553
|
+
// Pure union root (anyOf/oneOf with no own properties): push `i` into each
|
|
554
|
+
// alternative branch so each closed shape keeps `additionalProperties: false`
|
|
555
|
+
// honest with intent tracing. Adding a sibling root `properties: { i }` /
|
|
556
|
+
// `required: [i]` would force every input to satisfy both root *and* a
|
|
557
|
+
// branch, leaving no satisfiable shape because each branch's
|
|
558
|
+
// `additionalProperties: false` rejects every other field — and OpenAI
|
|
559
|
+
// strict sanitization later promotes that sibling to a closed root
|
|
560
|
+
// `type: "object"` that rejects every non-`i` key outright. allOf is not
|
|
561
|
+
// alternation (its members are sub-constraints), so we don't recurse into it.
|
|
562
|
+
if (!hasOwnProperties) {
|
|
563
|
+
for (const key of INTENT_SCHEMA_UNION_KEYS) {
|
|
564
|
+
const variants = schemaRecord[key];
|
|
565
|
+
if (!Array.isArray(variants)) continue;
|
|
566
|
+
return {
|
|
567
|
+
...schemaRecord,
|
|
568
|
+
[key]: variants.map(variant => injectIntentIntoSchema(variant, mode, describeIntent)),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const properties = hasOwnProperties ? (propertiesValue as Record<string, unknown>) : {};
|
|
551
574
|
const requiredValue = schemaRecord.required;
|
|
552
575
|
const required = Array.isArray(requiredValue)
|
|
553
576
|
? requiredValue.filter((item): item is string => typeof item === "string")
|
|
@@ -1647,7 +1670,7 @@ async function executeToolCalls(
|
|
|
1647
1670
|
const tools = currentContext.tools;
|
|
1648
1671
|
const {
|
|
1649
1672
|
hasSteeringMessages,
|
|
1650
|
-
|
|
1673
|
+
hasIrcInterrupts,
|
|
1651
1674
|
interruptMode = "immediate",
|
|
1652
1675
|
getToolContext,
|
|
1653
1676
|
transformToolCallArguments,
|
|
@@ -1662,54 +1685,73 @@ async function executeToolCalls(
|
|
|
1662
1685
|
const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
|
|
1663
1686
|
const shouldInterruptImmediately = interruptMode !== "wait";
|
|
1664
1687
|
const steeringAbortController = new AbortController();
|
|
1665
|
-
const
|
|
1688
|
+
const ircAbortController = new AbortController();
|
|
1689
|
+
// Interruptible tools observe steering + external + IRC aborts; every other
|
|
1690
|
+
// tool only sees steering + external, so an IRC-only interrupt never kills a
|
|
1691
|
+
// partially side-effecting foreground tool (e.g. `bash`) running alongside a
|
|
1692
|
+
// pure wait (e.g. `job` poll).
|
|
1693
|
+
const nonInterruptibleSignal: AbortSignal = signal
|
|
1666
1694
|
? AbortSignal.any([signal, steeringAbortController.signal])
|
|
1667
1695
|
: steeringAbortController.signal;
|
|
1696
|
+
const interruptibleSignal: AbortSignal = signal
|
|
1697
|
+
? AbortSignal.any([signal, steeringAbortController.signal, ircAbortController.signal])
|
|
1698
|
+
: AbortSignal.any([steeringAbortController.signal, ircAbortController.signal]);
|
|
1668
1699
|
const interruptState = { triggered: false };
|
|
1669
1700
|
|
|
1670
|
-
const records = toolCalls.map(toolCall =>
|
|
1671
|
-
toolCall,
|
|
1701
|
+
const records = toolCalls.map(toolCall => {
|
|
1672
1702
|
// Tools emitted via OpenAI's custom-tool path (e.g. `apply_patch` on GPT-5)
|
|
1673
1703
|
// come back under their wire-level name, which may differ from the
|
|
1674
1704
|
// harness-internal `name`. Match on either, preferring `name` for
|
|
1675
1705
|
// determinism if both somehow collide.
|
|
1676
|
-
tool
|
|
1706
|
+
const tool =
|
|
1677
1707
|
tools?.find(t => t.name === toolCall.name) ??
|
|
1678
|
-
tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name)
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1708
|
+
tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
|
|
1709
|
+
return {
|
|
1710
|
+
toolCall,
|
|
1711
|
+
tool,
|
|
1712
|
+
args: toolCall.arguments as Record<string, unknown>,
|
|
1713
|
+
signal: tool?.interruptible ? interruptibleSignal : nonInterruptibleSignal,
|
|
1714
|
+
started: false,
|
|
1715
|
+
result: undefined as AgentToolResult<any> | undefined,
|
|
1716
|
+
isError: false,
|
|
1717
|
+
skipped: false,
|
|
1718
|
+
toolResultMessage: undefined as ToolResultMessage | undefined,
|
|
1719
|
+
resultEmitted: false,
|
|
1720
|
+
};
|
|
1721
|
+
});
|
|
1687
1722
|
|
|
1688
1723
|
const checkSteering = async (): Promise<void> => {
|
|
1689
1724
|
// `signal` (external/user abort) is checked separately from the internal
|
|
1690
|
-
//
|
|
1691
|
-
//
|
|
1692
|
-
if (!shouldInterruptImmediately ||
|
|
1725
|
+
// abort controllers: once the run is externally aborted it is unwinding
|
|
1726
|
+
// and the interrupt would be redundant.
|
|
1727
|
+
if (!shouldInterruptImmediately || signal?.aborted) {
|
|
1693
1728
|
return;
|
|
1694
1729
|
}
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
1698
|
-
|
|
1699
|
-
// at the outer injection boundary, but the interrupt still fires.
|
|
1700
|
-
let hasMessages: boolean;
|
|
1730
|
+
// Mid-batch steering detection must be non-consuming. If a direct
|
|
1731
|
+
// integration only provides getSteeringMessages(), the queue drains at the
|
|
1732
|
+
// injection boundary below; polling it here would strand or drop messages.
|
|
1733
|
+
let steeringQueued = false;
|
|
1701
1734
|
if (hasSteeringMessages) {
|
|
1702
|
-
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1735
|
+
steeringQueued = await hasSteeringMessages();
|
|
1736
|
+
}
|
|
1737
|
+
if (steeringQueued) {
|
|
1738
|
+
// User steering upgrades an in-flight IRC interrupt: it aborts the
|
|
1739
|
+
// shared signal so foreground tools stop as they do for a user Esc.
|
|
1740
|
+
// Idempotent — a second steer poll after the abort is a no-op.
|
|
1741
|
+
if (!steeringAbortController.signal.aborted) {
|
|
1742
|
+
interruptState.triggered = true;
|
|
1743
|
+
steeringAbortController.abort();
|
|
1744
|
+
}
|
|
1707
1745
|
return;
|
|
1708
1746
|
}
|
|
1709
|
-
|
|
1710
|
-
|
|
1747
|
+
// IRC only fires once: a peer interrupt already recorded on interruptState
|
|
1748
|
+
// must not re-abort, and (unlike steering above) never re-consume a queue.
|
|
1749
|
+
if (interruptState.triggered) return;
|
|
1750
|
+
if (hasIrcInterrupts && (await hasIrcInterrupts())) {
|
|
1751
|
+
// Peer IRC only aborts interruptible waits: a foreground bash / write
|
|
1752
|
+
// mid-execution keeps running so we never leave partial side effects.
|
|
1711
1753
|
interruptState.triggered = true;
|
|
1712
|
-
|
|
1754
|
+
ircAbortController.abort();
|
|
1713
1755
|
}
|
|
1714
1756
|
};
|
|
1715
1757
|
|
|
@@ -1820,14 +1862,14 @@ async function executeToolCalls(
|
|
|
1820
1862
|
}
|
|
1821
1863
|
|
|
1822
1864
|
record.args = effectiveArgs;
|
|
1823
|
-
if (
|
|
1865
|
+
if (record.signal.aborted) {
|
|
1824
1866
|
record.skipped = true;
|
|
1825
1867
|
recordSkippedTool(telemetry, {
|
|
1826
1868
|
toolCallId: toolCall.id,
|
|
1827
1869
|
toolName: toolCall.name,
|
|
1828
1870
|
status: "aborted",
|
|
1829
1871
|
});
|
|
1830
|
-
emitToolResult(record, createToolSignalAbortedResult(
|
|
1872
|
+
emitToolResult(record, createToolSignalAbortedResult(record.signal), true);
|
|
1831
1873
|
return;
|
|
1832
1874
|
}
|
|
1833
1875
|
record.started = true;
|
|
@@ -1858,8 +1900,8 @@ async function executeToolCalls(
|
|
|
1858
1900
|
await runInActiveSpan(toolSpan, async () => {
|
|
1859
1901
|
try {
|
|
1860
1902
|
if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
|
|
1861
|
-
if (
|
|
1862
|
-
result = createToolSignalAbortedResult(
|
|
1903
|
+
if (record.signal.aborted) {
|
|
1904
|
+
result = createToolSignalAbortedResult(record.signal);
|
|
1863
1905
|
isError = true;
|
|
1864
1906
|
return;
|
|
1865
1907
|
}
|
|
@@ -1872,14 +1914,14 @@ async function executeToolCalls(
|
|
|
1872
1914
|
args: effectiveArgs,
|
|
1873
1915
|
context: currentContext,
|
|
1874
1916
|
},
|
|
1875
|
-
|
|
1917
|
+
record.signal,
|
|
1876
1918
|
);
|
|
1877
1919
|
if (beforeResult?.block) {
|
|
1878
1920
|
throw new ToolCallBlockedError(beforeResult.reason);
|
|
1879
1921
|
}
|
|
1880
1922
|
}
|
|
1881
|
-
if (
|
|
1882
|
-
result = createToolSignalAbortedResult(
|
|
1923
|
+
if (record.signal.aborted) {
|
|
1924
|
+
result = createToolSignalAbortedResult(record.signal);
|
|
1883
1925
|
isError = true;
|
|
1884
1926
|
return;
|
|
1885
1927
|
}
|
|
@@ -1899,7 +1941,7 @@ async function executeToolCalls(
|
|
|
1899
1941
|
const rawResult = await tool.execute(
|
|
1900
1942
|
toolCall.id,
|
|
1901
1943
|
executionArgs,
|
|
1902
|
-
|
|
1944
|
+
record.signal,
|
|
1903
1945
|
partialResult => {
|
|
1904
1946
|
stream.push({
|
|
1905
1947
|
type: "tool_execution_update",
|
|
@@ -1924,7 +1966,7 @@ async function executeToolCalls(
|
|
|
1924
1966
|
isError = true;
|
|
1925
1967
|
}
|
|
1926
1968
|
|
|
1927
|
-
if (afterToolCall && (!
|
|
1969
|
+
if (afterToolCall && (!record.signal.aborted || completedToolExecution)) {
|
|
1928
1970
|
try {
|
|
1929
1971
|
const after = await afterToolCall(
|
|
1930
1972
|
{
|
|
@@ -1935,7 +1977,7 @@ async function executeToolCalls(
|
|
|
1935
1977
|
isError,
|
|
1936
1978
|
context: currentContext,
|
|
1937
1979
|
},
|
|
1938
|
-
|
|
1980
|
+
record.signal,
|
|
1939
1981
|
);
|
|
1940
1982
|
if (after) {
|
|
1941
1983
|
// Re-normalize the post-hook result: `afterToolCall` is untyped user/extension
|
|
@@ -1963,30 +2005,33 @@ async function executeToolCalls(
|
|
|
1963
2005
|
});
|
|
1964
2006
|
|
|
1965
2007
|
const interrupted = interruptState.triggered;
|
|
1966
|
-
const
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
//
|
|
2008
|
+
const perToolAborted = record.signal.aborted;
|
|
2009
|
+
const abortedDuringExecution = perToolAborted && isError;
|
|
2010
|
+
if (interrupted && perToolAborted && isError) {
|
|
2011
|
+
// This tool's own signal fired AND it failed — it was cut off before producing
|
|
2012
|
+
// a usable result, so report it as skipped.
|
|
1970
2013
|
record.skipped = true;
|
|
1971
2014
|
emitToolResult(record, createSkippedToolResult(), true);
|
|
1972
2015
|
} else {
|
|
1973
|
-
// No interrupt, or the tool finished (successfully or with a
|
|
1974
|
-
// the interrupt landed. Keep its real result: a completed
|
|
1975
|
-
// effects, so the model must see what actually
|
|
2016
|
+
// No interrupt on this signal, or the tool finished (successfully or with a
|
|
2017
|
+
// genuine error) before the interrupt landed. Keep its real result: a completed
|
|
2018
|
+
// tool already ran its side effects, so the model must see what actually
|
|
2019
|
+
// happened rather than a false "skipped". A peer-IRC interrupt on the batch
|
|
2020
|
+
// leaves non-interruptible tools' signals untouched — their genuine errors
|
|
2021
|
+
// survive here instead of being clobbered into "skipped".
|
|
1976
2022
|
emitToolResult(record, result, isError);
|
|
1977
2023
|
}
|
|
1978
2024
|
|
|
1979
2025
|
const firstTextBlock = result.content?.[0];
|
|
1980
2026
|
const errorMessageForSpan =
|
|
1981
2027
|
caughtError === undefined && isError && firstTextBlock?.type === "text" ? firstTextBlock.text : undefined;
|
|
1982
|
-
const status =
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
: "ok";
|
|
2028
|
+
const status = abortedDuringExecution
|
|
2029
|
+
? "aborted"
|
|
2030
|
+
: caughtError instanceof ToolCallBlockedError
|
|
2031
|
+
? "blocked"
|
|
2032
|
+
: isError
|
|
2033
|
+
? "error"
|
|
2034
|
+
: "ok";
|
|
1990
2035
|
finishExecuteToolSpan(telemetry, toolSpan, {
|
|
1991
2036
|
result,
|
|
1992
2037
|
isError,
|
|
@@ -2030,15 +2075,15 @@ async function executeToolCalls(
|
|
|
2030
2075
|
}
|
|
2031
2076
|
}
|
|
2032
2077
|
|
|
2033
|
-
// While an interruptible tool is in flight (e.g. a `job`
|
|
2034
|
-
//
|
|
2035
|
-
//
|
|
2036
|
-
//
|
|
2037
|
-
//
|
|
2038
|
-
// checkSteering is idempotent (no-op once triggered).
|
|
2078
|
+
// While an interruptible tool is in flight (e.g. a `job`/`irc` wait
|
|
2079
|
+
// blocking on external work), queued steering or interrupting IRC would
|
|
2080
|
+
// otherwise wait out the tool's own window. Poll only non-consuming queues
|
|
2081
|
+
// and abort the shared tool signal so the boundary dequeue below injects
|
|
2082
|
+
// the message promptly. Gated on immediate-interrupt mode + an
|
|
2083
|
+
// interruptible tool; checkSteering is idempotent (no-op once triggered).
|
|
2039
2084
|
const watchSteeringWhileRunning =
|
|
2040
2085
|
shouldInterruptImmediately &&
|
|
2041
|
-
(hasSteeringMessages !== undefined ||
|
|
2086
|
+
(hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined) &&
|
|
2042
2087
|
records.some(r => r.tool?.interruptible === true);
|
|
2043
2088
|
const steeringWatchTimer = watchSteeringWhileRunning
|
|
2044
2089
|
? setInterval(() => void checkSteering(), STEERING_INTERRUPT_POLL_MS)
|
|
@@ -2131,7 +2176,12 @@ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unk
|
|
|
2131
2176
|
|
|
2132
2177
|
function createSkippedToolResult(): AgentToolResult<any> {
|
|
2133
2178
|
return {
|
|
2134
|
-
content: [
|
|
2179
|
+
content: [
|
|
2180
|
+
{
|
|
2181
|
+
type: "text",
|
|
2182
|
+
text: "Skipped due to queued user message. Do not count this skipped result as completed work or verification. After the queued message is handled on the next step, retry the skipped tool if it is still needed.",
|
|
2183
|
+
},
|
|
2184
|
+
],
|
|
2135
2185
|
details: {},
|
|
2136
2186
|
};
|
|
2137
2187
|
}
|
package/src/agent.ts
CHANGED
|
@@ -414,6 +414,10 @@ export class Agent {
|
|
|
414
414
|
* UI emission, and tool dispatch. Reassign at any time to swap the implementation.
|
|
415
415
|
*/
|
|
416
416
|
transformAssistantMessage?: AgentLoopConfig["transformAssistantMessage"];
|
|
417
|
+
/**
|
|
418
|
+
* Hook that peeks whether interrupting IRC asides are queued for the next boundary.
|
|
419
|
+
*/
|
|
420
|
+
hasIrcInterrupts?: AgentLoopConfig["hasIrcInterrupts"];
|
|
417
421
|
|
|
418
422
|
constructor(opts: AgentOptions = {}) {
|
|
419
423
|
this.#state = { ...this.#state, ...opts.initialState };
|
|
@@ -1182,6 +1186,7 @@ export class Agent {
|
|
|
1182
1186
|
return this.#dequeueSteeringMessages();
|
|
1183
1187
|
},
|
|
1184
1188
|
hasSteeringMessages: () => this.#steeringQueue.length > 0,
|
|
1189
|
+
hasIrcInterrupts: this.hasIrcInterrupts,
|
|
1185
1190
|
getFollowUpMessages: async () => this.#dequeueFollowUpMessages(),
|
|
1186
1191
|
getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
|
|
1187
1192
|
onBeforeYield: () => this.#onBeforeYield?.(),
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
SUMMARIZATION_SYSTEM_PROMPT,
|
|
30
30
|
serializeConversation,
|
|
31
31
|
stripReadSelector,
|
|
32
|
+
truncateToolResultForSummary,
|
|
32
33
|
upsertFileOperations,
|
|
33
34
|
} from "./utils";
|
|
34
35
|
|
|
@@ -168,8 +169,12 @@ export function collectEntriesForBranchSummary(
|
|
|
168
169
|
function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
|
169
170
|
switch (entry.type) {
|
|
170
171
|
case "message":
|
|
171
|
-
//
|
|
172
|
-
|
|
172
|
+
// Useless non-error tool results are dropped by serializeConversation()
|
|
173
|
+
// downstream. Skip them here so a large useless payload can't eat the
|
|
174
|
+
// branch-summary token budget and starve older useful entries.
|
|
175
|
+
if (entry.message.role === "toolResult" && entry.message.useless === true && entry.message.isError !== true) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
173
178
|
return entry.message;
|
|
174
179
|
|
|
175
180
|
case "custom_message":
|
|
@@ -202,6 +207,19 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
|
|
202
207
|
}
|
|
203
208
|
}
|
|
204
209
|
|
|
210
|
+
function estimateBranchSummaryTokens(message: AgentMessage): number {
|
|
211
|
+
if (message.role !== "toolResult") return estimateTokens(message);
|
|
212
|
+
const text = message.content
|
|
213
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
214
|
+
.map(c => c.text)
|
|
215
|
+
.join("");
|
|
216
|
+
if (!text) return 0;
|
|
217
|
+
return estimateTokens({
|
|
218
|
+
...message,
|
|
219
|
+
content: [{ type: "text", text: truncateToolResultForSummary(text) }],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
205
223
|
/**
|
|
206
224
|
* Prepare entries for summarization with token budget.
|
|
207
225
|
*
|
|
@@ -247,7 +265,7 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe
|
|
|
247
265
|
// Extract file ops from assistant messages (tool calls)
|
|
248
266
|
extractFileOpsFromMessage(message, fileOps);
|
|
249
267
|
|
|
250
|
-
const tokens =
|
|
268
|
+
const tokens = estimateBranchSummaryTokens(message);
|
|
251
269
|
|
|
252
270
|
// Check budget before adding
|
|
253
271
|
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
|
|
@@ -161,7 +161,15 @@ export interface CompactionSettings {
|
|
|
161
161
|
thresholdPercent?: number;
|
|
162
162
|
thresholdTokens?: number;
|
|
163
163
|
midTurnEnabled?: boolean;
|
|
164
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Tokens reserved below the context window for the next prompt + response.
|
|
166
|
+
*
|
|
167
|
+
* Leave unset to use {@link DEFAULT_RESERVE_TOKENS}; the unset state is the
|
|
168
|
+
* provenance signal that lets small-window recovery replace the default with
|
|
169
|
+
* a proportional reserve (see {@link resolveBudgetReserveTokens}). An
|
|
170
|
+
* explicit value — even one equal to the default — is always honored.
|
|
171
|
+
*/
|
|
172
|
+
reserveTokens?: number;
|
|
165
173
|
keepRecentTokens: number;
|
|
166
174
|
autoContinue?: boolean;
|
|
167
175
|
remoteEnabled?: boolean;
|
|
@@ -170,13 +178,18 @@ export interface CompactionSettings {
|
|
|
170
178
|
v2RetainedMessageBudget?: number;
|
|
171
179
|
}
|
|
172
180
|
|
|
181
|
+
/** Reserve applied when {@link CompactionSettings.reserveTokens} is unset. */
|
|
182
|
+
export const DEFAULT_RESERVE_TOKENS = 16384;
|
|
183
|
+
|
|
184
|
+
// reserveTokens is deliberately absent: an unset reserve is what marks it as
|
|
185
|
+
// defaulted, which resolveBudgetReserveTokens needs to distinguish "user never
|
|
186
|
+
// chose a reserve" from "user explicitly configured the default value".
|
|
173
187
|
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
|
174
188
|
enabled: true,
|
|
175
189
|
strategy: "context-full",
|
|
176
190
|
thresholdPercent: -1,
|
|
177
191
|
thresholdTokens: -1,
|
|
178
192
|
midTurnEnabled: true,
|
|
179
|
-
reserveTokens: 16384,
|
|
180
193
|
keepRecentTokens: 20000,
|
|
181
194
|
autoContinue: true,
|
|
182
195
|
remoteEnabled: true,
|
|
@@ -233,10 +246,34 @@ export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefine
|
|
|
233
246
|
}
|
|
234
247
|
|
|
235
248
|
/**
|
|
236
|
-
* Effective reserve: at least 15% of context window or the configured floor
|
|
249
|
+
* Effective reserve: at least 15% of context window or the configured floor
|
|
250
|
+
* (defaulting to {@link DEFAULT_RESERVE_TOKENS} when unset), whichever is larger.
|
|
237
251
|
*/
|
|
238
252
|
export function effectiveReserveTokens(contextWindow: number, settings: CompactionSettings): number {
|
|
239
|
-
return Math.max(Math.floor(contextWindow * 0.15), settings.reserveTokens);
|
|
253
|
+
return Math.max(Math.floor(contextWindow * 0.15), settings.reserveTokens ?? DEFAULT_RESERVE_TOKENS);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Reserve used when deciding whether a prompt still fits inside the model window.
|
|
258
|
+
*
|
|
259
|
+
* The default absolute reserve predates small bundled windows and can leave no
|
|
260
|
+
* practical budget there; recover a DEFAULTED reserve that is impossible for
|
|
261
|
+
* the window with the 15% proportional reserve (clamped to >= 1 so the derived
|
|
262
|
+
* threshold stays strictly below the window even for tiny test windows).
|
|
263
|
+
* Explicit valid reserves — including one that happens to equal the default —
|
|
264
|
+
* still win, because they intentionally shrink the usable prompt budget;
|
|
265
|
+
* provenance is carried by `settings.reserveTokens` being unset, never by
|
|
266
|
+
* comparing values against the default.
|
|
267
|
+
*/
|
|
268
|
+
export function resolveBudgetReserveTokens(contextWindow: number, settings: CompactionSettings): number {
|
|
269
|
+
const reserveTokens = effectiveReserveTokens(contextWindow, settings);
|
|
270
|
+
const proportionalReserveTokens = Math.max(1, Math.floor(contextWindow * 0.15));
|
|
271
|
+
const reserveWasDefaulted = settings.reserveTokens === undefined;
|
|
272
|
+
const defaultReserveIsEffectivelyImpossible =
|
|
273
|
+
reserveWasDefaulted && reserveTokens >= contextWindow - proportionalReserveTokens;
|
|
274
|
+
const reserveExceedsWindow = reserveTokens >= contextWindow;
|
|
275
|
+
|
|
276
|
+
return defaultReserveIsEffectivelyImpossible || reserveExceedsWindow ? proportionalReserveTokens : reserveTokens;
|
|
240
277
|
}
|
|
241
278
|
|
|
242
279
|
/**
|
|
@@ -275,10 +312,19 @@ export function resolveThresholdTokens(contextWindow: number, settings: Compacti
|
|
|
275
312
|
return Math.min(contextWindow - 1, Math.max(1, thresholdTokens));
|
|
276
313
|
}
|
|
277
314
|
|
|
278
|
-
// Percentage-based threshold
|
|
315
|
+
// Percentage-based threshold. The default absolute reserve can exceed bundled
|
|
316
|
+
// small-context windows, or nearly consume a 16k-class window; in those
|
|
317
|
+
// known-impossible default configurations, fall back to the proportional
|
|
318
|
+
// reserve so threshold/recovery-band checks stay usable. Explicit valid
|
|
319
|
+
// configured reserves still define the usable prompt budget. Cap at
|
|
320
|
+
// contextWindow - 1 (matching the fixed-token clamp above) so the threshold
|
|
321
|
+
// never reaches the whole window even when the reserve resolves to 0.
|
|
279
322
|
const thresholdPercent = settings.thresholdPercent;
|
|
280
323
|
if (typeof thresholdPercent !== "number" || !Number.isFinite(thresholdPercent) || thresholdPercent <= 0) {
|
|
281
|
-
return
|
|
324
|
+
return Math.max(
|
|
325
|
+
0,
|
|
326
|
+
Math.min(contextWindow - 1, contextWindow - resolveBudgetReserveTokens(contextWindow, settings)),
|
|
327
|
+
);
|
|
282
328
|
}
|
|
283
329
|
const clampedThresholdPercent = Math.min(99, Math.max(1, thresholdPercent));
|
|
284
330
|
return Math.floor(contextWindow * (clampedThresholdPercent / 100));
|
|
@@ -1223,6 +1269,8 @@ export async function compact(
|
|
|
1223
1269
|
settings,
|
|
1224
1270
|
} = preparation;
|
|
1225
1271
|
|
|
1272
|
+
const reserveTokens = settings.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
|
|
1273
|
+
|
|
1226
1274
|
const summaryOptions: SummaryOptions = {
|
|
1227
1275
|
promptOverride: options?.promptOverride,
|
|
1228
1276
|
extraContext: options?.extraContext,
|
|
@@ -1383,7 +1431,7 @@ export async function compact(
|
|
|
1383
1431
|
? generateSummary(
|
|
1384
1432
|
messagesToSummarize,
|
|
1385
1433
|
model,
|
|
1386
|
-
|
|
1434
|
+
reserveTokens,
|
|
1387
1435
|
apiKey,
|
|
1388
1436
|
signal,
|
|
1389
1437
|
customInstructions,
|
|
@@ -1391,7 +1439,7 @@ export async function compact(
|
|
|
1391
1439
|
summaryOptions,
|
|
1392
1440
|
)
|
|
1393
1441
|
: Promise.resolve("No prior history."),
|
|
1394
|
-
generateTurnPrefixSummary(turnPrefixMessages, model,
|
|
1442
|
+
generateTurnPrefixSummary(turnPrefixMessages, model, reserveTokens, apiKey, signal, summaryOptions),
|
|
1395
1443
|
]);
|
|
1396
1444
|
// Merge into single summary
|
|
1397
1445
|
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
|
|
@@ -1400,7 +1448,7 @@ export async function compact(
|
|
|
1400
1448
|
summary = await generateSummary(
|
|
1401
1449
|
messagesToSummarize,
|
|
1402
1450
|
model,
|
|
1403
|
-
|
|
1451
|
+
reserveTokens,
|
|
1404
1452
|
apiKey,
|
|
1405
1453
|
signal,
|
|
1406
1454
|
customInstructions,
|
|
@@ -1417,7 +1465,7 @@ export async function compact(
|
|
|
1417
1465
|
|
|
1418
1466
|
const shortSummary = usedRemoteCompaction
|
|
1419
1467
|
? "Remote compaction"
|
|
1420
|
-
: await generateShortSummary(recentMessages, summary, model,
|
|
1468
|
+
: await generateShortSummary(recentMessages, summary, model, reserveTokens, apiKey, signal, {
|
|
1421
1469
|
extraContext: options?.extraContext,
|
|
1422
1470
|
remoteEndpoint: summaryOptions.remoteEndpoint,
|
|
1423
1471
|
initiatorOverride: summaryOptions.initiatorOverride,
|
package/src/compaction/utils.ts
CHANGED
|
@@ -199,13 +199,12 @@ export function upsertFileOperations(
|
|
|
199
199
|
const TOOL_RESULT_MAX_CHARS = 2000;
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
|
-
* Truncate
|
|
203
|
-
* Keeps the beginning and appends a truncation marker.
|
|
202
|
+
* Truncate tool results to the same representation used in summarization prompts.
|
|
204
203
|
*/
|
|
205
|
-
function
|
|
206
|
-
if (text.length <=
|
|
207
|
-
const truncatedChars = text.length -
|
|
208
|
-
return `${text.slice(0,
|
|
204
|
+
export function truncateToolResultForSummary(text: string): string {
|
|
205
|
+
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
|
|
206
|
+
const truncatedChars = text.length - TOOL_RESULT_MAX_CHARS;
|
|
207
|
+
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n\n[... ${truncatedChars} more characters truncated]`;
|
|
209
208
|
}
|
|
210
209
|
|
|
211
210
|
/**
|
|
@@ -241,7 +240,7 @@ export function serializeConversation(messages: Message[], dialect?: Dialect): s
|
|
|
241
240
|
if (!text) continue;
|
|
242
241
|
processed.push({
|
|
243
242
|
...msg,
|
|
244
|
-
content: [{ type: "text", text:
|
|
243
|
+
content: [{ type: "text", text: truncateToolResultForSummary(text) }],
|
|
245
244
|
});
|
|
246
245
|
continue;
|
|
247
246
|
}
|
|
@@ -293,7 +292,7 @@ export function serializeConversation(messages: Message[], dialect?: Dialect): s
|
|
|
293
292
|
.map(c => c.text)
|
|
294
293
|
.join("");
|
|
295
294
|
if (content) {
|
|
296
|
-
const text =
|
|
295
|
+
const text = truncateToolResultForSummary(content);
|
|
297
296
|
parts.push(`[Tool Result]: ${text}`);
|
|
298
297
|
}
|
|
299
298
|
}
|
package/src/types.ts
CHANGED
|
@@ -199,6 +199,15 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
199
199
|
*/
|
|
200
200
|
hasSteeringMessages?: () => boolean | Promise<boolean>;
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Peeks whether IRC messages should interrupt an interruptible waiting tool.
|
|
204
|
+
*
|
|
205
|
+
* Uses the same delivery rules as steering: the poll is non-consuming, only
|
|
206
|
+
* runs for interruptible tools, and is ignored when interruptMode is "wait".
|
|
207
|
+
* The host owns message injection at the next boundary.
|
|
208
|
+
*/
|
|
209
|
+
hasIrcInterrupts?: () => boolean | Promise<boolean>;
|
|
210
|
+
|
|
202
211
|
/**
|
|
203
212
|
* Returns follow-up messages to process after the agent would otherwise stop.
|
|
204
213
|
*
|