@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 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
@@ -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
- reserveTokens: number;
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, whichever is larger.
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.
@@ -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.2.13",
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.2.13",
39
- "@oh-my-pi/pi-catalog": "16.2.13",
40
- "@oh-my-pi/pi-natives": "16.2.13",
41
- "@oh-my-pi/pi-utils": "16.2.13",
42
- "@oh-my-pi/pi-wire": "16.2.13",
43
- "@oh-my-pi/snapcompact": "16.2.13",
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 properties =
548
- propertiesValue && typeof propertiesValue === "object" && !Array.isArray(propertiesValue)
549
- ? (propertiesValue as Record<string, unknown>)
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
- getSteeringMessages,
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 toolSignal = signal
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
- args: toolCall.arguments as Record<string, unknown>,
1680
- started: false,
1681
- result: undefined as AgentToolResult<any> | undefined,
1682
- isError: false,
1683
- skipped: false,
1684
- toolResultMessage: undefined as ToolResultMessage | undefined,
1685
- resultEmitted: false,
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
- // steeringAbortController: once the run is externally aborted it is
1691
- // unwinding and the interrupt would be redundant.
1692
- if (!shouldInterruptImmediately || interruptState.triggered || signal?.aborted) {
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
- // Prefer the non-consuming peek (`hasSteeringMessages`) when available.
1696
- // Fall back to calling `getSteeringMessages` directly when only it is
1697
- // provided (e.g. in tests or minimal integrations without a separate
1698
- // peek function). In that case the message is consumed here rather than
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
- hasMessages = await hasSteeringMessages();
1703
- } else if (getSteeringMessages) {
1704
- const msgs = await getSteeringMessages();
1705
- hasMessages = (msgs?.length ?? 0) > 0;
1706
- } else {
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
- if (hasMessages) {
1710
- if (interruptState.triggered || signal?.aborted) return;
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
- steeringAbortController.abort();
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 (toolSignal.aborted) {
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(toolSignal), true);
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 (toolSignal.aborted) {
1862
- result = createToolSignalAbortedResult(toolSignal);
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
- toolSignal,
1917
+ record.signal,
1876
1918
  );
1877
1919
  if (beforeResult?.block) {
1878
1920
  throw new ToolCallBlockedError(beforeResult.reason);
1879
1921
  }
1880
1922
  }
1881
- if (toolSignal.aborted) {
1882
- result = createToolSignalAbortedResult(toolSignal);
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
- toolSignal,
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 && (!toolSignal.aborted || completedToolExecution)) {
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
- toolSignal,
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 abortedDuringExecution = toolSignal.aborted && isError;
1967
- if (interrupted && isError) {
1968
- // Steering/abort fired AND this tool failed — it was cut off before producing a
1969
- // usable result, so report it as skipped.
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 genuine error) before
1974
- // the interrupt landed. Keep its real result: a completed tool already ran its side
1975
- // effects, so the model must see what actually happened rather than a false "skipped".
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
- (interrupted && isError) || abortedDuringExecution
1984
- ? "aborted"
1985
- : caughtError instanceof ToolCallBlockedError
1986
- ? "blocked"
1987
- : isError
1988
- ? "error"
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` poll blocking on
2034
- // background work), a queued steer would otherwise wait out the tool's own
2035
- // window. Poll the steering queue and let checkSteering() abort the shared
2036
- // tool signal so the wait returns early; the boundary dequeue below then
2037
- // injects it. Gated on immediate-interrupt mode + an interruptible tool;
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 || getSteeringMessages !== 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: [{ type: "text", text: "Skipped due to queued user message." }],
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
- // Skip tool results - context is in assistant's tool call
172
- if (entry.message.role === "toolResult") return undefined;
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 = estimateTokens(message);
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
- reserveTokens: number;
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, whichever is larger.
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 contextWindow - effectiveReserveTokens(contextWindow, settings);
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
- settings.reserveTokens,
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, settings.reserveTokens, apiKey, signal, summaryOptions),
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
- settings.reserveTokens,
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, settings.reserveTokens, apiKey, signal, {
1468
+ : await generateShortSummary(recentMessages, summary, model, reserveTokens, apiKey, signal, {
1421
1469
  extraContext: options?.extraContext,
1422
1470
  remoteEndpoint: summaryOptions.remoteEndpoint,
1423
1471
  initiatorOverride: summaryOptions.initiatorOverride,
@@ -199,13 +199,12 @@ export function upsertFileOperations(
199
199
  const TOOL_RESULT_MAX_CHARS = 2000;
200
200
 
201
201
  /**
202
- * Truncate text to a maximum character length for summarization.
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 truncateForSummary(text: string, maxChars: number): string {
206
- if (text.length <= maxChars) return text;
207
- const truncatedChars = text.length - maxChars;
208
- return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`;
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: truncateForSummary(text, TOOL_RESULT_MAX_CHARS) }],
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 = truncateForSummary(content, TOOL_RESULT_MAX_CHARS);
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
  *