@oh-my-pi/pi-agent-core 17.0.3 → 17.0.5

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,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.5] - 2026-07-18
6
+
7
+ ### Added
8
+
9
+ - Added a per-message token estimation cache to optimize performance by reusing token counts for settled message history, with automatic cache invalidation on message mutation.
10
+
11
+ ### Changed
12
+
13
+ - Improved tool execution control by making tool interruptibility resolvable per call, allowing side-effecting operations to complete while passive waits can yield to queued steering.
14
+
5
15
  ## [17.0.2] - 2026-07-17
6
16
 
7
17
  ### Fixed
@@ -5,6 +5,7 @@ export * from "./branch-summarization.js";
5
5
  export * from "./compaction.js";
6
6
  export * from "./entries.js";
7
7
  export * from "./errors.js";
8
+ export * from "./message-cache.js";
8
9
  export * from "./messages.js";
9
10
  export * from "./openai.js";
10
11
  export * from "./pruning.js";
@@ -0,0 +1,23 @@
1
+ import type { AgentMessage } from "../types.js";
2
+ /**
3
+ * Register a cache tied to message identity so owner mutations in this package
4
+ * (prune/shake) can invalidate it across the package boundary. Returns an
5
+ * unregister function. The coding-agent `convertToLlm` memo registers here.
6
+ */
7
+ export declare function registerMessageCacheInvalidator(invalidate: (message: AgentMessage) => void): () => void;
8
+ /**
9
+ * True when this message's estimate is safe to cache by identity. Non-assistants
10
+ * are immutable once appended; assistants are cached only once settled (see the
11
+ * settle-gate invariant above).
12
+ */
13
+ export declare function isEstimateCacheable(message: AgentMessage): boolean;
14
+ /** Read a cached estimate for the given option split, or `undefined` on miss. */
15
+ export declare function readEstimateCache(message: AgentMessage, excludeEncryptedReasoning: boolean): number | undefined;
16
+ /** Store an estimate for the given option split. */
17
+ export declare function writeEstimateCache(message: AgentMessage, excludeEncryptedReasoning: boolean, value: number): void;
18
+ /**
19
+ * Drop every cached derivation of `message` after an in-place rewrite. Owners of
20
+ * mutation (prune, shake, strip-images) call this at the mutation seam so the
21
+ * next convert/estimate pass recomputes from the new content.
22
+ */
23
+ export declare function invalidateMessageCache(message: AgentMessage): void;
@@ -570,14 +570,16 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
570
570
  /** If true, argument validation errors are non-fatal: raw args are passed to execute() instead of returning an error to the LLM. */
571
571
  lenientArgValidation?: boolean;
572
572
  /**
573
- * If true, the agent loop may abort this tool mid-execution to deliver a
574
- * queued steering message (instead of waiting for the tool to finish on its
575
- * own). Set only on tools that purely *wait* and observe their abort signal
576
- * cleanly (e.g. the `job` poll), so the abort surfaces the tool's current
573
+ * Whether the agent loop may abort this tool mid-execution to deliver a
574
+ * queued steering message. A function resolves this per call from the raw,
575
+ * pre-validation arguments.
576
+ *
577
+ * Enable only for calls that purely *wait* and observe their abort signal
578
+ * cleanly (e.g. `job` poll), so the abort surfaces the tool's current
577
579
  * snapshot rather than corrupting a side effect. Honored only when
578
580
  * `interruptMode` is "immediate".
579
581
  */
580
- interruptible?: boolean;
582
+ interruptible?: boolean | ((args: Partial<Static<TParameters>>) => boolean);
581
583
  /**
582
584
  * Controls how the INTENT_FIELD (`i`) is handled for this tool.
583
585
  * - `"require"` (default): `i` is injected and required in the parameter schema.
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": "17.0.3",
4
+ "version": "17.0.5",
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": "17.0.3",
39
- "@oh-my-pi/pi-catalog": "17.0.3",
40
- "@oh-my-pi/pi-natives": "17.0.3",
41
- "@oh-my-pi/pi-utils": "17.0.3",
42
- "@oh-my-pi/pi-wire": "17.0.3",
43
- "@oh-my-pi/snapcompact": "17.0.3",
38
+ "@oh-my-pi/pi-ai": "17.0.5",
39
+ "@oh-my-pi/pi-catalog": "17.0.5",
40
+ "@oh-my-pi/pi-natives": "17.0.5",
41
+ "@oh-my-pi/pi-utils": "17.0.5",
42
+ "@oh-my-pi/pi-wire": "17.0.5",
43
+ "@oh-my-pi/snapcompact": "17.0.5",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -1824,11 +1824,25 @@ async function executeToolCalls(
1824
1824
  const tool =
1825
1825
  tools?.find(t => t.name === toolCall.name) ??
1826
1826
  tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
1827
+ const args = toolCall.arguments as Record<string, unknown>;
1828
+ const interruptibleMode = tool?.interruptible;
1829
+ let interruptible = false;
1830
+ if (typeof interruptibleMode === "function") {
1831
+ try {
1832
+ interruptible = interruptibleMode(args);
1833
+ } catch {
1834
+ // Resolver failures default to preserving the tool's outcome.
1835
+ interruptible = false;
1836
+ }
1837
+ } else {
1838
+ interruptible = interruptibleMode === true;
1839
+ }
1827
1840
  return {
1828
1841
  toolCall,
1829
1842
  tool,
1830
- args: toolCall.arguments as Record<string, unknown>,
1831
- signal: tool?.interruptible ? interruptibleSignal : nonInterruptibleSignal,
1843
+ args,
1844
+ interruptible,
1845
+ signal: interruptible ? interruptibleSignal : nonInterruptibleSignal,
1832
1846
  started: false,
1833
1847
  result: undefined as AgentToolResult<any> | undefined,
1834
1848
  isError: false,
@@ -2210,16 +2224,16 @@ async function executeToolCalls(
2210
2224
  }
2211
2225
  }
2212
2226
 
2213
- // While an interruptible tool is in flight (e.g. a `job`/`irc` wait
2214
- // blocking on external work), queued steering or interrupting IRC would
2215
- // otherwise wait out the tool's own window. Poll only non-consuming queues
2216
- // and abort the shared tool signal so the boundary dequeue below injects
2217
- // the message promptly. Gated on immediate-interrupt mode + an
2218
- // interruptible tool; checkSteering is idempotent (no-op once triggered).
2227
+ // While an interruptible tool call is in flight (e.g. a `hub` wait blocking
2228
+ // on external work), queued steering or interrupting IRC would otherwise
2229
+ // wait out the tool's own window. Poll only non-consuming queues and abort
2230
+ // the shared tool signal so the boundary dequeue below injects the message
2231
+ // promptly. Gated on immediate-interrupt mode + an interruptible call;
2232
+ // checkSteering is idempotent (no-op once triggered).
2219
2233
  const watchSteeringWhileRunning =
2220
2234
  shouldInterruptImmediately &&
2221
2235
  (hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined) &&
2222
- records.some(r => r.tool?.interruptible === true);
2236
+ records.some(record => record.interruptible);
2223
2237
  const steeringWatchTimer = watchSteeringWhileRunning
2224
2238
  ? setInterval(() => void checkSteering(), STEERING_INTERRUPT_POLL_MS)
2225
2239
  : undefined;
@@ -43,6 +43,7 @@ import {
43
43
  V2_RETAINED_MESSAGE_TOKEN_BUDGET,
44
44
  } from "./compaction-v2-streaming";
45
45
  import type { CompactionEntry, SessionEntry } from "./entries";
46
+ import { isEstimateCacheable, readEstimateCache, writeEstimateCache } from "./message-cache";
46
47
  import { type ConvertToLlm, createBranchSummaryMessage, createCustomMessage, defaultConvertToLlm } from "./messages";
47
48
  import {
48
49
  buildOpenAiNativeHistory,
@@ -364,6 +365,21 @@ const IMAGE_TOKEN_ESTIMATE = 1200;
364
365
  * content) excludes them to avoid false triggers on thinking-heavy turns.
365
366
  */
366
367
  export function estimateTokens(message: AgentMessage, options?: { excludeEncryptedReasoning?: boolean }): number {
368
+ // Settled historical messages are counted once and reused until an owner
369
+ // (prune/shake/strip-images) invalidates them; streaming assistants bypass
370
+ // the cache entirely (see message-cache.ts settle-gate invariant).
371
+ const cacheable = isEstimateCacheable(message);
372
+ const excludeEncryptedReasoning = options?.excludeEncryptedReasoning === true;
373
+ if (cacheable) {
374
+ const cached = readEstimateCache(message, excludeEncryptedReasoning);
375
+ if (cached !== undefined) return cached;
376
+ }
377
+ const result = computeMessageTokens(message, options);
378
+ if (cacheable) writeEstimateCache(message, excludeEncryptedReasoning, result);
379
+ return result;
380
+ }
381
+
382
+ function computeMessageTokens(message: AgentMessage, options?: { excludeEncryptedReasoning?: boolean }): number {
367
383
  const fragments: string[] = [];
368
384
  let extra = 0;
369
385
  if ((message as { role?: string }).role === "bashExecution") {
@@ -6,6 +6,7 @@ export * from "./branch-summarization";
6
6
  export * from "./compaction";
7
7
  export * from "./entries";
8
8
  export * from "./errors";
9
+ export * from "./message-cache";
9
10
  export * from "./messages";
10
11
  export * from "./openai";
11
12
  export * from "./pruning";
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Per-message memoization for the two hot history walks: token estimation
3
+ * ({@link estimateTokens}) and LLM conversion (the coding-agent's `convertToLlm`).
4
+ *
5
+ * Long sessions re-walk a settled `AgentMessage[]` every turn, re-tokenizing and
6
+ * re-converting historical objects that only the newest suffix can change. These
7
+ * caches key on message *identity* so a settled message is counted/converted once
8
+ * and reused until an owner rewrites it.
9
+ *
10
+ * Correctness rests on two invariants:
11
+ *
12
+ * 1. **Settle gate.** A streaming assistant is mutated under one identity while
13
+ * its `usage`/`stopReason` are provisional (the seed carries zeroed usage and
14
+ * a placeholder `stopReason`). Caching it would freeze a mid-stream count, so
15
+ * estimation only caches assistants that are settled — real `usage`
16
+ * (`totalTokens > 0`) with a terminal `stopReason` that is not `"aborted"` /
17
+ * `"error"`. Unsettled assistants never read or insert. Non-assistant roles
18
+ * are immutable once appended and cache by identity.
19
+ * 2. **Owner invalidation.** `pruneToolOutputs` / `pruneSupersededToolResults`,
20
+ * `applyShakeRegion`, and `stripImagesFromMessage` rewrite message content in
21
+ * place under a stable identity. Each MUST call {@link invalidateMessageCache}
22
+ * on the mutated message before the next convert/estimate pass so both caches
23
+ * drop the stale entry. The convert cache lives in another package, so it
24
+ * subscribes via {@link registerMessageCacheInvalidator}.
25
+ */
26
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
27
+ import type { AgentMessage } from "../types";
28
+
29
+ /** External cache invalidators (e.g. the coding-agent `convertToLlm` memo). */
30
+ const externalInvalidators = new Set<(message: AgentMessage) => void>();
31
+
32
+ /**
33
+ * Register a cache tied to message identity so owner mutations in this package
34
+ * (prune/shake) can invalidate it across the package boundary. Returns an
35
+ * unregister function. The coding-agent `convertToLlm` memo registers here.
36
+ */
37
+ export function registerMessageCacheInvalidator(invalidate: (message: AgentMessage) => void): () => void {
38
+ externalInvalidators.add(invalidate);
39
+ return () => {
40
+ externalInvalidators.delete(invalidate);
41
+ };
42
+ }
43
+
44
+ // Dual option-split estimate caches: the compaction floor passes
45
+ // `excludeEncryptedReasoning` (dropping opaque provider reasoning), so a message
46
+ // has two distinct estimates that must not collide in one map.
47
+ //
48
+ // These are WeakMaps, not symbol-tagged properties, deliberately: callers spread
49
+ // messages to derive throwaway variants for counting — `estimateBranchSummaryTokens`
50
+ // does `estimateTokens({ ...message, content: truncated })`. A symbol-keyed cache
51
+ // value rides along an object spread, so the truncated clone would inherit (and
52
+ // return) the full-content estimate. Keying strictly on identity keeps the cache
53
+ // off spread copies, which get their own fresh count.
54
+ const estimateCacheDefault = new WeakMap<AgentMessage, number>();
55
+ const estimateCacheFloored = new WeakMap<AgentMessage, number>();
56
+
57
+ /**
58
+ * True when this message's estimate is safe to cache by identity. Non-assistants
59
+ * are immutable once appended; assistants are cached only once settled (see the
60
+ * settle-gate invariant above).
61
+ */
62
+ export function isEstimateCacheable(message: AgentMessage): boolean {
63
+ if (message.role !== "assistant") return true;
64
+ const assistant = message as AssistantMessage;
65
+ return (
66
+ assistant.stopReason !== "aborted" &&
67
+ assistant.stopReason !== "error" &&
68
+ assistant.usage != null &&
69
+ assistant.usage.totalTokens > 0
70
+ );
71
+ }
72
+
73
+ /** Read a cached estimate for the given option split, or `undefined` on miss. */
74
+ export function readEstimateCache(message: AgentMessage, excludeEncryptedReasoning: boolean): number | undefined {
75
+ return (excludeEncryptedReasoning ? estimateCacheFloored : estimateCacheDefault).get(message);
76
+ }
77
+
78
+ /** Store an estimate for the given option split. */
79
+ export function writeEstimateCache(message: AgentMessage, excludeEncryptedReasoning: boolean, value: number): void {
80
+ (excludeEncryptedReasoning ? estimateCacheFloored : estimateCacheDefault).set(message, value);
81
+ }
82
+
83
+ /**
84
+ * Drop every cached derivation of `message` after an in-place rewrite. Owners of
85
+ * mutation (prune, shake, strip-images) call this at the mutation seam so the
86
+ * next convert/estimate pass recomputes from the new content.
87
+ */
88
+ export function invalidateMessageCache(message: AgentMessage): void {
89
+ estimateCacheDefault.delete(message);
90
+ estimateCacheFloored.delete(message);
91
+ for (const invalidate of externalInvalidators) invalidate(message);
92
+ }
@@ -6,6 +6,7 @@ import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
6
6
  import type { AgentMessage, AgentToolCall } from "../types";
7
7
  import { estimateTokens } from "./compaction";
8
8
  import type { SessionEntry, SessionMessageEntry } from "./entries";
9
+ import { invalidateMessageCache } from "./message-cache";
9
10
  import {
10
11
  collectToolCallsById,
11
12
  isProtectedToolResult,
@@ -295,6 +296,7 @@ export function pruneSupersededToolResults(entries: SessionEntry[], config: Supe
295
296
  for (const candidate of toPrune) {
296
297
  candidate.message.content = [{ type: "text", text: candidate.notice }];
297
298
  candidate.message.prunedAt = prunedAt;
299
+ invalidateMessageCache(candidate.message as AgentMessage);
298
300
  tokensSaved += estimatePrunedSavings(candidate.tokens, candidate.notice);
299
301
  }
300
302
  return { prunedCount: toPrune.length, tokensSaved };
@@ -398,6 +400,7 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
398
400
  : createPrunedNotice(candidate.tokens);
399
401
  message.content = [{ type: "text", text: notice }];
400
402
  message.prunedAt = prunedAt;
403
+ invalidateMessageCache(message as AgentMessage);
401
404
  prunedCount++;
402
405
  }
403
406
 
@@ -15,6 +15,7 @@ import { countTokens } from "../tokenizer";
15
15
  import type { AgentMessage } from "../types";
16
16
  import { estimateTokens } from "./compaction";
17
17
  import type { CustomMessageEntry, SessionEntry, SessionMessageEntry } from "./entries";
18
+ import { invalidateMessageCache } from "./message-cache";
18
19
  import {
19
20
  collectToolCallsById,
20
21
  isProtectedToolResult,
@@ -406,12 +407,18 @@ export function applyShakeRegion(region: ShakeRegion, replacement: string): void
406
407
  const message = region.entry.message as ToolResultMessage;
407
408
  message.content = [{ type: "text", text: replacement }];
408
409
  message.prunedAt = Date.now();
410
+ invalidateMessageCache(message as AgentMessage);
409
411
  return;
410
412
  }
411
413
  const slot = getBlockTextSlot(region.entry, region.blockIndex);
412
414
  if (!slot) return;
413
415
  const text = slot.read();
414
416
  slot.write(text.slice(0, region.start) + replacement + text.slice(region.end));
417
+ // Message entries keep a stable `entry.message` identity across context
418
+ // rebuilds, so an in-place block rewrite must drop its cached estimate/convert.
419
+ // Custom-message entries are re-materialized into a fresh AgentMessage on every
420
+ // buildSessionContext, so they carry no stable cached identity to invalidate.
421
+ if (region.entry.type === "message") invalidateMessageCache(region.entry.message);
415
422
  }
416
423
 
417
424
  /**
package/src/types.ts CHANGED
@@ -657,14 +657,16 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
657
657
  /** If true, argument validation errors are non-fatal: raw args are passed to execute() instead of returning an error to the LLM. */
658
658
  lenientArgValidation?: boolean;
659
659
  /**
660
- * If true, the agent loop may abort this tool mid-execution to deliver a
661
- * queued steering message (instead of waiting for the tool to finish on its
662
- * own). Set only on tools that purely *wait* and observe their abort signal
663
- * cleanly (e.g. the `job` poll), so the abort surfaces the tool's current
660
+ * Whether the agent loop may abort this tool mid-execution to deliver a
661
+ * queued steering message. A function resolves this per call from the raw,
662
+ * pre-validation arguments.
663
+ *
664
+ * Enable only for calls that purely *wait* and observe their abort signal
665
+ * cleanly (e.g. `job` poll), so the abort surfaces the tool's current
664
666
  * snapshot rather than corrupting a side effect. Honored only when
665
667
  * `interruptMode` is "immediate".
666
668
  */
667
- interruptible?: boolean;
669
+ interruptible?: boolean | ((args: Partial<Static<TParameters>>) => boolean);
668
670
  /**
669
671
  * Controls how the INTENT_FIELD (`i`) is handled for this tool.
670
672
  * - `"require"` (default): `i` is injected and required in the parameter schema.