@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 +15 -0
- package/dist/types/compaction/pruning.d.ts +9 -2
- package/dist/types/compaction/shake.d.ts +3 -1
- package/dist/types/types.d.ts +4 -0
- package/package.json +6 -6
- package/src/agent-loop.ts +11 -1
- package/src/compaction/pruning.ts +80 -14
- package/src/compaction/shake.ts +7 -3
- package/src/compaction/utils.ts +13 -0
- package/src/types.ts +4 -0
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
|
|
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)
|
|
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.
|
|
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
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "15.
|
|
40
|
-
"@oh-my-pi/pi-natives": "15.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.
|
|
42
|
-
"@oh-my-pi/snapcompact": "15.
|
|
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: {
|
|
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
|
|
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)
|
|
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 =
|
|
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:
|
|
222
|
+
candidate.message.content = [{ type: "text", text: candidate.notice }];
|
|
178
223
|
candidate.message.prunedAt = prunedAt;
|
|
179
|
-
tokensSaved += estimatePrunedSavings(candidate.tokens,
|
|
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
|
|
213
|
-
// (a stale copy of re-read content
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
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
|
}
|
package/src/compaction/shake.ts
CHANGED
|
@@ -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.
|
|
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))
|
package/src/compaction/utils.ts
CHANGED
|
@@ -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
|