@oh-my-pi/pi-agent-core 17.0.4 → 17.0.6
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 +10 -0
- package/dist/types/compaction/index.d.ts +1 -0
- package/dist/types/compaction/message-cache.d.ts +23 -0
- package/dist/types/types.d.ts +7 -5
- package/package.json +7 -7
- package/src/agent-loop.ts +23 -9
- package/src/compaction/compaction.ts +16 -0
- package/src/compaction/index.ts +1 -0
- package/src/compaction/message-cache.ts +92 -0
- package/src/compaction/pruning.ts +3 -0
- package/src/compaction/shake.ts +7 -0
- package/src/types.ts +7 -5
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;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
574
|
-
* queued steering message
|
|
575
|
-
*
|
|
576
|
-
*
|
|
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.
|
|
4
|
+
"version": "17.0.6",
|
|
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.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "17.0.
|
|
40
|
-
"@oh-my-pi/pi-natives": "17.0.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.0.
|
|
42
|
-
"@oh-my-pi/pi-wire": "17.0.
|
|
43
|
-
"@oh-my-pi/snapcompact": "17.0.
|
|
38
|
+
"@oh-my-pi/pi-ai": "17.0.6",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "17.0.6",
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.0.6",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.0.6",
|
|
42
|
+
"@oh-my-pi/pi-wire": "17.0.6",
|
|
43
|
+
"@oh-my-pi/snapcompact": "17.0.6",
|
|
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
|
|
1831
|
-
|
|
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 `
|
|
2214
|
-
//
|
|
2215
|
-
//
|
|
2216
|
-
//
|
|
2217
|
-
//
|
|
2218
|
-
//
|
|
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(
|
|
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") {
|
package/src/compaction/index.ts
CHANGED
|
@@ -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
|
|
package/src/compaction/shake.ts
CHANGED
|
@@ -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
|
-
*
|
|
661
|
-
* queued steering message
|
|
662
|
-
*
|
|
663
|
-
*
|
|
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.
|