@oh-my-pi/pi-agent-core 15.11.8 → 15.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.12.1] - 2026-06-12
6
+ ### Breaking Changes
7
+
8
+ - Changed `pruneSupersededToolResults` to allow `supersedeKey` to be omitted so useless-result pruning can run without read-style supersede grouping
9
+
10
+ ### Added
11
+
12
+ - Added `pruneUseless` controls to `PruneConfig` and `SupersedePruneConfig` so callers can toggle compaction of `toolResult` entries marked `useless`
13
+ - Added the ability to disable useless-result pruning by setting `pruneUseless` to false
14
+ - Tools can flag a result contextually useless (`AgentToolResult.useless`; overridable via `AfterToolCallResult.useless`): the agent loop copies the flag onto the persisted `ToolResultMessage` (errors always win), and compaction consumes it — the cache-aware supersede pass and the threshold prune blank flagged results to the exact `USELESS_NOTICE` placeholder (bypassing the protect window, skipping results smaller than the notice), shake collects them inside the protect-recent window, and `serializeConversation` drops the whole tool call/result pair from summarizer input
15
+
16
+ ### Changed
17
+
18
+ - Changed `pruneSupersededToolResults` to allow omitted `supersedeKey` when `pruneUseless` is enabled, so useless-result pruning can run without read-style supersede grouping
19
+
5
20
  ## [15.11.4] - 2026-06-12
6
21
  ### Added
7
22
 
@@ -17,6 +17,8 @@ export interface PruneConfig {
17
17
  * unchanged.
18
18
  */
19
19
  supersedeKey?: SupersedeKeyFn;
20
+ /** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */
21
+ pruneUseless?: boolean;
20
22
  }
21
23
  export declare const DEFAULT_PRUNE_CONFIG: PruneConfig;
22
24
  export interface PruneResult {
@@ -25,6 +27,8 @@ export interface PruneResult {
25
27
  }
26
28
  /** Exact placeholder written over a superseded tool result. */
27
29
  export declare const SUPERSEDED_NOTICE = "[Superseded by a newer read of this file]";
30
+ /** Exact placeholder written over an elided useless tool result. */
31
+ export declare const USELESS_NOTICE = "[Uneventful result elided]";
28
32
  /**
29
33
  * Maps a tool call to a supersede key. Results sharing a key form a group in
30
34
  * which every result except the newest is a supersede candidate. A key `K`
@@ -35,7 +39,9 @@ export declare const SUPERSEDED_NOTICE = "[Superseded by a newer read of this fi
35
39
  export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) => string | undefined;
36
40
  export interface SupersedePruneConfig {
37
41
  /** Supersede key function; results sharing a key supersede older ones. */
38
- supersedeKey: SupersedeKeyFn;
42
+ supersedeKey?: SupersedeKeyFn;
43
+ /** Also prune results flagged useless by their tool. Default false. */
44
+ pruneUseless?: boolean;
39
45
  /** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
40
46
  suffixTokenLimit?: number;
41
47
  /** Prune all candidates when the last message is at least this old (prompt cache is cold anyway). Default 30 min. */
@@ -47,7 +53,8 @@ export interface SupersedePruneConfig {
47
53
  }
48
54
  /**
49
55
  * Prune superseded tool results (e.g. stale `read` outputs replaced by a newer
50
- * read of the same file). Cheap, incremental, and prompt-cache-aware: a
56
+ * read of the same file) and, when `pruneUseless` is set, results their tool
57
+ * flagged contextually useless. Cheap, incremental, and prompt-cache-aware: a
51
58
  * candidate is pruned now only when the suffix after it is small (tail case —
52
59
  * the read→edit→read loop) or when the context has been idle long enough that
53
60
  * the provider cache is cold anyway (then ALL candidates flush).
@@ -54,7 +54,9 @@ export type ShakeRegion = ToolResultShakeRegion | BlockShakeRegion;
54
54
  * Walks the protect-recent window (most recent `protectTokens` of context is
55
55
  * kept intact), collects whole tool-result messages (honoring `protectedTools`
56
56
  * and skipping already-pruned results) and large fenced/XML blocks inside
57
- * user/developer/assistant/custom messages. Returns regions in document order.
57
+ * user/developer/assistant/custom messages. Tool results flagged contextually
58
+ * useless by their tool bypass the protect window — there is nothing recent
59
+ * worth keeping in them. Returns regions in document order.
58
60
  *
59
61
  * `toolCall` blocks are never touched (tool-call/result pairing is preserved)
60
62
  * and regions never span a message boundary. When the combined estimated
@@ -277,6 +277,8 @@ export interface AfterToolCallResult {
277
277
  details?: unknown;
278
278
  /** If provided, replaces the error flag carried with the tool result. */
279
279
  isError?: boolean;
280
+ /** If provided, replaces the contextually-useless flag carried with the tool result. */
281
+ useless?: boolean;
280
282
  }
281
283
  /** Context passed to `beforeToolCall`. */
282
284
  export interface BeforeToolCallContext {
@@ -348,6 +350,8 @@ export interface AgentToolResult<T = any, _TInput = unknown> {
348
350
  content: (TextContent | ImageContent)[];
349
351
  details?: T;
350
352
  isError?: boolean;
353
+ /** Marks the result as contextually useless: safe for compaction to elide once consumed (e.g. zero matches, wait timeout). Ignored when isError is set. */
354
+ useless?: boolean;
351
355
  }
352
356
  export type AgentToolUpdateCallback<T = any, TInput = unknown> = (partialResult: AgentToolResult<T, TInput>) => void;
353
357
  /** Options passed to renderResult */
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": "15.11.8",
4
+ "version": "15.12.1",
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,11 +35,11 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "15.11.8",
39
- "@oh-my-pi/pi-catalog": "15.11.8",
40
- "@oh-my-pi/pi-natives": "15.11.8",
41
- "@oh-my-pi/pi-utils": "15.11.8",
42
- "@oh-my-pi/snapcompact": "15.11.8",
38
+ "@oh-my-pi/pi-ai": "15.12.1",
39
+ "@oh-my-pi/pi-catalog": "15.12.1",
40
+ "@oh-my-pi/pi-natives": "15.12.1",
41
+ "@oh-my-pi/pi-utils": "15.12.1",
42
+ "@oh-my-pi/snapcompact": "15.12.1",
43
43
  "@opentelemetry/api": "^1.9.1"
44
44
  },
45
45
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -174,6 +174,9 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; mal
174
174
  // aggregator that catches per-entry errors and synthesizes a combined
175
175
  // result). Preserve the flag so agent-loop can surface it on the wire.
176
176
  const explicitError = Boolean(rawObj && "isError" in rawObj && rawObj.isError);
177
+ // Tools may flag the result contextually useless (zero matches, elapsed
178
+ // wait) so compaction can elide it once consumed. Errors are never useless.
179
+ const useless = Boolean(rawObj && "useless" in rawObj && rawObj.useless);
177
180
 
178
181
  if (!Array.isArray(rawContent)) {
179
182
  return {
@@ -218,7 +221,12 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; mal
218
221
  content.push({ type: "text", text: EMPTY_ERROR_TOOL_RESULT_TEXT });
219
222
  }
220
223
  return {
221
- result: { content, details, ...(isError ? { isError: true } : {}) },
224
+ result: {
225
+ content,
226
+ details,
227
+ ...(isError ? { isError: true } : {}),
228
+ ...(useless && !isError ? { useless: true } : {}),
229
+ },
222
230
  malformed: invalidBlocks > 0,
223
231
  };
224
232
  }
@@ -1355,6 +1363,7 @@ async function executeToolCalls(
1355
1363
  content: result.content,
1356
1364
  details: result.details,
1357
1365
  isError,
1366
+ ...(result.useless && !isError ? { useless: true } : {}),
1358
1367
  timestamp: Date.now(),
1359
1368
  };
1360
1369
  record.result = result;
@@ -1534,6 +1543,7 @@ async function executeToolCalls(
1534
1543
  content: after.content ?? result.content,
1535
1544
  details: after.details ?? result.details,
1536
1545
  isError: after.isError ?? result.isError,
1546
+ useless: after.useless ?? result.useless,
1537
1547
  });
1538
1548
  result = coerced.result;
1539
1549
  isError = coerced.malformed || (after.isError ?? isError);
@@ -28,12 +28,15 @@ export interface PruneConfig {
28
28
  * unchanged.
29
29
  */
30
30
  supersedeKey?: SupersedeKeyFn;
31
+ /** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */
32
+ pruneUseless?: boolean;
31
33
  }
32
34
 
33
35
  export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
34
36
  protectTokens: 40_000,
35
37
  minimumSavings: 20_000,
36
38
  protectedTools: ["skill", isSkillReadToolResult],
39
+ pruneUseless: true,
37
40
  };
38
41
 
39
42
  export interface PruneResult {
@@ -44,6 +47,9 @@ export interface PruneResult {
44
47
  /** Exact placeholder written over a superseded tool result. */
45
48
  export const SUPERSEDED_NOTICE = "[Superseded by a newer read of this file]";
46
49
 
50
+ /** Exact placeholder written over an elided useless tool result. */
51
+ export const USELESS_NOTICE = "[Uneventful result elided]";
52
+
47
53
  /**
48
54
  * Maps a tool call to a supersede key. Results sharing a key form a group in
49
55
  * which every result except the newest is a supersede candidate. A key `K`
@@ -55,7 +61,9 @@ export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) =
55
61
 
56
62
  export interface SupersedePruneConfig {
57
63
  /** Supersede key function; results sharing a key supersede older ones. */
58
- supersedeKey: SupersedeKeyFn;
64
+ supersedeKey?: SupersedeKeyFn;
65
+ /** Also prune results flagged useless by their tool. Default false. */
66
+ pruneUseless?: boolean;
59
67
  /** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
60
68
  suffixTokenLimit?: number;
61
69
  /** Prune all candidates when the last message is at least this old (prompt cache is cold anyway). Default 30 min. */
@@ -91,6 +99,8 @@ interface SupersedeCandidate {
91
99
  /** Index of the entry within the `entries` array. */
92
100
  index: number;
93
101
  tokens: number;
102
+ /** Placeholder text written over the blanked result. */
103
+ notice: string;
94
104
  }
95
105
 
96
106
  /**
@@ -125,21 +135,56 @@ function collectSupersededResults(
125
135
  message,
126
136
  index: i,
127
137
  tokens: estimateTokens(message as AgentMessage),
138
+ notice: SUPERSEDED_NOTICE,
128
139
  });
129
140
  }
130
141
  return candidates.reverse();
131
142
  }
132
143
 
144
+ /**
145
+ * Collect tool results their tool flagged contextually useless (zero matches,
146
+ * elapsed wait): unpruned, non-error, unprotected, not in `exclude`, and large
147
+ * enough that blanking to {@link USELESS_NOTICE} actually saves tokens.
148
+ * Returned in message order.
149
+ */
150
+ function collectUselessResults(
151
+ entries: readonly SessionEntry[],
152
+ toolCallsById: ReadonlyMap<string, AgentToolCall>,
153
+ protectedTools: readonly ProtectedToolMatcher[],
154
+ exclude: ReadonlySet<ToolResultMessage>,
155
+ ): SupersedeCandidate[] {
156
+ const candidates: SupersedeCandidate[] = [];
157
+ for (let i = 0; i < entries.length; i++) {
158
+ const entry = entries[i];
159
+ const message = getToolResultMessage(entry);
160
+ if (message?.useless !== true || message.prunedAt !== undefined || message.isError === true) continue;
161
+ if (exclude.has(message)) continue;
162
+ if (isProtectedToolResult(message, toolCallsById.get(message.toolCallId), protectedTools)) continue;
163
+ const tokens = estimateTokens(message as AgentMessage);
164
+ if (estimatePrunedSavings(tokens, USELESS_NOTICE) <= 0) continue;
165
+ candidates.push({ entry: entry as SessionMessageEntry, message, index: i, tokens, notice: USELESS_NOTICE });
166
+ }
167
+ return candidates;
168
+ }
169
+
133
170
  /**
134
171
  * Prune superseded tool results (e.g. stale `read` outputs replaced by a newer
135
- * read of the same file). Cheap, incremental, and prompt-cache-aware: a
172
+ * read of the same file) and, when `pruneUseless` is set, results their tool
173
+ * flagged contextually useless. Cheap, incremental, and prompt-cache-aware: a
136
174
  * candidate is pruned now only when the suffix after it is small (tail case —
137
175
  * the read→edit→read loop) or when the context has been idle long enough that
138
176
  * the provider cache is cold anyway (then ALL candidates flush).
139
177
  */
140
178
  export function pruneSupersededToolResults(entries: SessionEntry[], config: SupersedePruneConfig): PruneResult {
141
179
  const toolCallsById = collectToolCallsById(entries);
142
- const candidates = collectSupersededResults(entries, toolCallsById, config.supersedeKey, config.protectedTools);
180
+ const candidates = config.supersedeKey
181
+ ? collectSupersededResults(entries, toolCallsById, config.supersedeKey, config.protectedTools)
182
+ : [];
183
+ if (config.pruneUseless) {
184
+ const exclude = new Set(candidates.map(candidate => candidate.message));
185
+ candidates.push(...collectUselessResults(entries, toolCallsById, config.protectedTools, exclude));
186
+ candidates.sort((a, b) => a.index - b.index);
187
+ }
143
188
  if (candidates.length === 0) return { prunedCount: 0, tokensSaved: 0 };
144
189
 
145
190
  const now = config.now ?? Date.now();
@@ -174,9 +219,9 @@ export function pruneSupersededToolResults(entries: SessionEntry[], config: Supe
174
219
  const prunedAt = Date.now();
175
220
  let tokensSaved = 0;
176
221
  for (const candidate of toPrune) {
177
- candidate.message.content = [{ type: "text", text: SUPERSEDED_NOTICE }];
222
+ candidate.message.content = [{ type: "text", text: candidate.notice }];
178
223
  candidate.message.prunedAt = prunedAt;
179
- tokensSaved += estimatePrunedSavings(candidate.tokens, SUPERSEDED_NOTICE);
224
+ tokensSaved += estimatePrunedSavings(candidate.tokens, candidate.notice);
180
225
  }
181
226
  return { prunedCount: toPrune.length, tokensSaved };
182
227
  }
@@ -186,7 +231,7 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
186
231
  let tokensSaved = 0;
187
232
  let prunedCount = 0;
188
233
 
189
- const candidates: Array<{ entry: SessionMessageEntry; tokens: number; superseded: boolean }> = [];
234
+ const candidates: Array<{ entry: SessionMessageEntry; tokens: number; superseded: boolean; useless: boolean }> = [];
190
235
  const toolCallsById = collectToolCallsById(entries);
191
236
  const supersededMessages = config.supersedeKey
192
237
  ? new Set(
@@ -195,6 +240,17 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
195
240
  ),
196
241
  )
197
242
  : undefined;
243
+ const uselessMessages =
244
+ config.pruneUseless !== false
245
+ ? new Set(
246
+ collectUselessResults(
247
+ entries,
248
+ toolCallsById,
249
+ config.protectedTools,
250
+ supersededMessages ?? new Set(),
251
+ ).map(candidate => candidate.message),
252
+ )
253
+ : undefined;
198
254
 
199
255
  for (let i = entries.length - 1; i >= 0; i--) {
200
256
  const entry = entries[i];
@@ -209,22 +265,29 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
209
265
  continue;
210
266
  }
211
267
 
212
- // Superseded results are pruned first: they bypass the protect window
213
- // (a stale copy of re-read content is dead weight at any age).
268
+ // Superseded and useless results are pruned first: they bypass the
269
+ // protect window (a stale copy of re-read content or a result the
270
+ // tool itself flagged as carrying no information — is dead weight at
271
+ // any age).
214
272
  const superseded = supersededMessages?.has(message) ?? false;
215
- if (!superseded && (accumulatedTokens < config.protectTokens || isProtected)) {
273
+ const useless = uselessMessages?.has(message) ?? false;
274
+ if (!superseded && !useless && (accumulatedTokens < config.protectTokens || isProtected)) {
216
275
  accumulatedTokens += tokens;
217
276
  continue;
218
277
  }
219
278
 
220
- candidates.push({ entry: entry as SessionMessageEntry, tokens, superseded });
279
+ candidates.push({ entry: entry as SessionMessageEntry, tokens, superseded, useless });
221
280
  accumulatedTokens += tokens;
222
281
  }
223
282
 
224
283
  for (const candidate of candidates) {
225
284
  tokensSaved += estimatePrunedSavings(
226
285
  candidate.tokens,
227
- candidate.superseded ? SUPERSEDED_NOTICE : createPrunedNotice(candidate.tokens),
286
+ candidate.superseded
287
+ ? SUPERSEDED_NOTICE
288
+ : candidate.useless
289
+ ? USELESS_NOTICE
290
+ : createPrunedNotice(candidate.tokens),
228
291
  );
229
292
  }
230
293
 
@@ -235,9 +298,12 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
235
298
  const prunedAt = Date.now();
236
299
  for (const candidate of candidates) {
237
300
  const message = candidate.entry.message as ToolResultMessage;
238
- message.content = [
239
- { type: "text", text: candidate.superseded ? SUPERSEDED_NOTICE : createPrunedNotice(candidate.tokens) },
240
- ];
301
+ const notice = candidate.superseded
302
+ ? SUPERSEDED_NOTICE
303
+ : candidate.useless
304
+ ? USELESS_NOTICE
305
+ : createPrunedNotice(candidate.tokens);
306
+ message.content = [{ type: "text", text: notice }];
241
307
  message.prunedAt = prunedAt;
242
308
  prunedCount++;
243
309
  }
@@ -267,7 +267,9 @@ function scanContentBlocks(
267
267
  * Walks the protect-recent window (most recent `protectTokens` of context is
268
268
  * kept intact), collects whole tool-result messages (honoring `protectedTools`
269
269
  * and skipping already-pruned results) and large fenced/XML blocks inside
270
- * user/developer/assistant/custom messages. Returns regions in document order.
270
+ * user/developer/assistant/custom messages. Tool results flagged contextually
271
+ * useless by their tool bypass the protect window — there is nothing recent
272
+ * worth keeping in them. Returns regions in document order.
271
273
  *
272
274
  * `toolCall` blocks are never touched (tool-call/result pairing is preserved)
273
275
  * and regions never span a message boundary. When the combined estimated
@@ -289,10 +291,12 @@ export function collectShakeRegions(entries: SessionEntry[], config: ShakeConfig
289
291
 
290
292
  const regions: ShakeRegion[] = [];
291
293
  for (let i = 0; i < n; i++) {
292
- if (accumulatedAfter[i] < config.protectTokens) continue;
293
294
  const entry = entries[i];
294
-
295
295
  const toolResult = getToolResultMessage(entry);
296
+ // Useless-flagged results carry no information once consumed; they are
297
+ // eligible even inside the protect-recent window.
298
+ const uselessResult = toolResult !== undefined && toolResult.useless === true && toolResult.isError !== true;
299
+ if (!uselessResult && accumulatedAfter[i] < config.protectTokens) continue;
296
300
  if (toolResult) {
297
301
  if (toolResult.prunedAt !== undefined) continue;
298
302
  if (isProtectedToolResult(toolResult, toolCallsById.get(toolResult.toolCallId), config.protectedTools))
@@ -191,6 +191,17 @@ function truncateForSummary(text: string, maxChars: number): string {
191
191
  export function serializeConversation(messages: Message[]): string {
192
192
  const parts: string[] = [];
193
193
 
194
+ // Tool results flagged contextually useless (and their paired calls) are
195
+ // dropped from the serialized text: the source region is discarded after
196
+ // summarization anyway, so excluding them costs nothing and keeps garbage
197
+ // out of the summary input.
198
+ const uselessCallIds = new Set<string>();
199
+ for (const msg of messages) {
200
+ if (msg.role === "toolResult" && msg.useless === true && msg.isError !== true) {
201
+ uselessCallIds.add(msg.toolCallId);
202
+ }
203
+ }
204
+
194
205
  for (const msg of messages) {
195
206
  if (msg.role === "user") {
196
207
  const content =
@@ -212,6 +223,7 @@ export function serializeConversation(messages: Message[]): string {
212
223
  } else if (block.type === "thinking") {
213
224
  thinkingParts.push(block.thinking);
214
225
  } else if (block.type === "toolCall") {
226
+ if (uselessCallIds.has(block.id)) continue;
215
227
  const args = block.arguments as Record<string, unknown>;
216
228
  const argsStr = Object.entries(args)
217
229
  .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
@@ -230,6 +242,7 @@ export function serializeConversation(messages: Message[]): string {
230
242
  parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
231
243
  }
232
244
  } else if (msg.role === "toolResult") {
245
+ if (uselessCallIds.has(msg.toolCallId)) continue;
233
246
  const content = msg.content
234
247
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
235
248
  .map(c => c.text)
package/src/types.ts CHANGED
@@ -326,6 +326,8 @@ export interface AfterToolCallResult {
326
326
  details?: unknown;
327
327
  /** If provided, replaces the error flag carried with the tool result. */
328
328
  isError?: boolean;
329
+ /** If provided, replaces the contextually-useless flag carried with the tool result. */
330
+ useless?: boolean;
329
331
  }
330
332
 
331
333
  /** Context passed to `beforeToolCall`. */
@@ -408,6 +410,8 @@ export interface AgentToolResult<T = any, _TInput = unknown> {
408
410
  // Marks a non-throwing failure (e.g. an aggregator catching per-entry errors).
409
411
  // agent-loop honors this and surfaces it as a tool error on the wire.
410
412
  isError?: boolean;
413
+ /** Marks the result as contextually useless: safe for compaction to elide once consumed (e.g. zero matches, wait timeout). Ignored when isError is set. */
414
+ useless?: boolean;
411
415
  }
412
416
 
413
417
  // Callback for streaming tool execution updates