pi-condense 2.13.0 → 2.14.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 +19 -0
- package/PRUNING.md +5 -3
- package/README.md +3 -1
- package/index.ts +3 -2
- package/package.json +8 -5
- package/src/batch-capture.test.ts +20 -0
- package/src/batch-capture.ts +4 -2
- package/src/chain-detector.test.ts +16 -0
- package/src/chain-detector.ts +5 -5
- package/src/commands.test.ts +26 -3
- package/src/commands.ts +1 -1
- package/src/config.test.ts +8 -1
- package/src/context-metrics.test.ts +11 -1
- package/src/context-metrics.ts +3 -2
- package/src/protected.test.ts +41 -0
- package/src/protected.ts +10 -1
- package/src/pruner.test.ts +21 -0
- package/src/pruner.ts +1 -1
- package/src/reload-rearm.integration.test.ts +42 -0
- package/src/summarizer-wiring.test.ts +237 -2
- package/src/summarizer.ts +19 -13
- package/src/types.ts +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
|
|
|
7
7
|
Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
|
|
8
8
|
publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
|
|
9
9
|
|
|
10
|
+
## [2.14.0] - 2026-11-08
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The compiled-against pi packages (`@earendil-works/pi-ai`, `pi-coding-agent`, `pi-tui`) move from `^1.3.0` to `^1.5.0`; the runtime floor stays pi >= 1.3.0 (`streamSimple`).
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Protected tool names and path globs also cover a result's recorded nested calls, including reads inside codemode scripts. Incomplete nested-call records keep the whole result inline verbatim even above the spill threshold and with path matching disabled; protected scripted reads are not superseded by later reads of the same path. (#27)
|
|
19
|
+
- Paid synthetic behavioral evals measure downstream context quality after summarization and pruning, with frozen committed baselines for both summarizers and an eval-needed decision gate for every repository change. Offline harness tests remain separate from paid runs. (#22)
|
|
20
|
+
- A private-session replay harness (`npm run eval:replay`) rebuilds real tool-call batches from session files into a corpus outside every checkout, summarizes each with production and a `--candidate` module, scores both against strings the assistant later reused, and fails on regressions; `matrix` compares model x thinking cells on retention, latency and cost. Synthetic eval reports now count evidence retained in the summary separately from evidence recovered on demand. (#23)
|
|
21
|
+
|
|
22
|
+
## [2.13.1] - 2026-10-22
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- The summarizer idle default is 45s instead of 20s, allowing queued providers longer before session-model takeover; the silent-primary wait is 45s on initial detection and each failed 10-minute probe. Explicit settings keep their values. (#21)
|
|
27
|
+
- Immediate session-model rescue notices include the primary failure reason, including idle/ceiling timeout or provider error text; deferred fallback notices remain unchanged. (#21)
|
|
28
|
+
|
|
10
29
|
## [2.13.0] - 2026-10-20
|
|
11
30
|
|
|
12
31
|
### Changed
|
package/PRUNING.md
CHANGED
|
@@ -633,15 +633,17 @@ Implementation: `src/pruner.ts` `pruneMessages(messages, indexer)` returns `{ me
|
|
|
633
633
|
|
|
634
634
|
### Protected tools & paths
|
|
635
635
|
|
|
636
|
-
A tool call is protected if
|
|
636
|
+
A tool call is protected if its `toolName` is in `protectedTools`, its `args.path` (string) matches any glob in `protectedPaths`, or its result's recorded nested calls (`nestedCalls.calls`) include a name or path matching the same rules. This covers reads made inside codemode scripts and any other tool carrying `nestedCalls`, without a tool-name restriction. An incomplete nested-call record (`nestedCalls.complete === false`) protects the whole result unconditionally, even with both configuration lists empty. The recorder marks a record incomplete when arguments exceed 8 KiB per call or 32 KiB total, calls exceed 256, or a call is still running when the script ends; even a script that reads nothing protected can therefore stay verbatim. Nested call status does not affect matching.
|
|
637
|
+
|
|
638
|
+
Protected calls are filtered out **at capture time** - they never enter the `pendingBatches` queue, summarizer, new index entries, or oversized spill, so their raw `ToolResultMessage` stays inline verbatim even above `spillThreshold`. Inside newly compressed chains their outputs relocate verbatim as `<protected-output>` without blocking compression. Only direct reads can be superseded when a later read of the same path covers their delivered lines and the cadence gate allows it (below); a protected scripted read stays verbatim and is not stubbed by a later read of the same path.
|
|
637
639
|
|
|
638
640
|
**`protectedTools: string[]`** (default `[]`) - allowlist of tool names. Covers tools whose output is a small handle that must be reused byte-for-byte (e.g. a session-id) or planning tools like `todowrite` / `todoread`.
|
|
639
641
|
|
|
640
|
-
**`protectedPaths: string[]`** (default `["**/skills/**/*.md", "**/gauntlet-overrides.md"]`) - glob list matched against `args.path`. Designed for skill files that carry multi-step workflow gates; summarizing them is categorically lossy. Non-string or missing `path` arguments never match. Set `[]` to disable. Edit with `/pruner protected-paths`.
|
|
642
|
+
**`protectedPaths: string[]`** (default `["**/skills/**/*.md", "**/gauntlet-overrides.md"]`) - glob list matched against direct and recorded nested `args.path`. Designed for skill files that carry multi-step workflow gates; summarizing them is categorically lossy. Non-string or missing `path` arguments never match. Set `[]` to disable path matching only; nested tool-name matches and incomplete-record protection remain active. Edit with `/pruner protected-paths`.
|
|
641
643
|
|
|
642
644
|
Glob contract: full-path match against the raw `args.path` string with `\` normalized to `/`. `*` and `?` match within a segment (no `/`); `**` crosses segments; `**/` also matches zero directories (so `**/SKILL.md` matches a bare relative `SKILL.md`). Case-sensitive. All other characters are regex-escaped literals.
|
|
643
645
|
|
|
644
|
-
**Render-time re-check:** stub replacement runs in-flight on every turn (`pruneMessages`). If a tool call's persisted `args` now satisfy `isProtected` (e.g. a pattern was added mid-session), the stub is skipped and the raw result is left verbatim
|
|
646
|
+
**Render-time re-check:** stub replacement runs in-flight on every turn (`pruneMessages`). If a tool call's persisted name and `args`, or its live result's `nestedCalls`, now satisfy `isProtected` (e.g. a pattern was added mid-session or a scripted read was summarized before nested-call protection), the stub is skipped and the raw result is left verbatim. The stored index entry stays but is not applied; this repairs already-summarized records in existing sessions with no schema change. Declared limitation: records inside already-compressed chains (`context-prune-chain` entries) are NOT repaired - their `protectedToolCallIds` set is fixed at compression time (forward-only). Dedup-alias edge: an alias resolving to an unprotected original stays stubbed unless its own live result carries protecting `nestedCalls`, and its own `args.path` is not re-checked.
|
|
645
647
|
|
|
646
648
|
**Supersession (phase 1b, `src/supersede.ts`):** protected calls are never indexed, so content-hash dedup never sees them. A paired occurrence participates only when `isProtected(toolName, args)` is true and `typeof args.path === "string"`. Occurrences group by normalized `args.path` (backslash -> slash). An earlier occurrence is a supersession candidate only when **any later occurrence** of that path covers its delivered line range, not just when the last occurrence covers it. Different paths never interact; disjoint chunks stay verbatim.
|
|
647
649
|
|
package/README.md
CHANGED
|
@@ -172,7 +172,7 @@ Settings live under `contextPrune` in `<agent-dir>/settings.json` (`$PI_CODING_A
|
|
|
172
172
|
| `autoBudgetThreshold` | `null` | Fraction (e.g. `0.8`) of the context window that force-flushes everything regardless of `pruneOn`; the trigger point is capped at 300k tokens |
|
|
173
173
|
| `frontierGapThresholdTokens` | `null` | Opt-in absolute-token flush trigger: fires at `turn_end` once the un-pruned tail past the prune frontier reaches N tokens, regardless of window size; recommended starting value `80000` |
|
|
174
174
|
| `maxImagesPerRequest` | `null` | Once a request carries more than N images, the oldest become a text note (in cache-friendly steps of half of N) so a long session stays under a provider's per-request image limit; applies even with pruning off. `null` = built-in limit for the model's API (Anthropic Messages: 100; others: no cap); a number overrides it for every API |
|
|
175
|
-
| `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md", "**/gauntlet-overrides.md"]` | Tool names / path globs that
|
|
175
|
+
| `protectedTools` / `protectedPaths` | `[]` / `["**/skills/**/*.md", "**/gauntlet-overrides.md"]` | Tool names / path globs that bypass summarization, including reads made inside codemode scripts through the result's recorded nested calls (`nestedCalls`). A protected scripted read stays verbatim and is not stubbed by a later read of the same path; only direct reads can supersede earlier reads when their delivered lines cover them and the cache-rewrite cadence allows it. An incomplete nested-call record (over 8 KiB of arguments per call, 32 KiB total, 256 calls, or a call still running when the script ends) keeps the whole result inline verbatim regardless of `protectedPaths`, even above `spillThreshold`. `protectedPaths: []` disables path matching only |
|
|
176
176
|
| `spillThreshold` | `65536` | Chars above which a single oversized result spills straight to a sidecar file |
|
|
177
177
|
|
|
178
178
|
The default also protects reads of [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet)'s per-repo `gauntlet-overrides.md` so the repo's harness contract stays available for gate decisions after pruning.
|
|
@@ -191,6 +191,8 @@ No committed roadmap beyond what's already tracked in [CHANGELOG.md](CHANGELOG.m
|
|
|
191
191
|
|
|
192
192
|
See [CONTRIBUTING.md](CONTRIBUTING.md) - issues follow a Context / Problem / Idea / Acceptance Criteria template; PRs run the [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) workflow (one-liners exempt from ceremony, never from keeping docs truthful).
|
|
193
193
|
|
|
194
|
+
For paid synthetic context-quality checks and retained behavioral baselines, see the [behavioral eval guide](eval/README.md).
|
|
195
|
+
|
|
194
196
|
## Support
|
|
195
197
|
|
|
196
198
|
If this saves you tokens, [buy me a coffee](https://buymeacoffee.com/jjurasszek).
|
package/index.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Usage: pi -e .
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
16
17
|
import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
17
18
|
import { loadConfig } from "./src/config.js";
|
|
18
19
|
import { capImages, imageLimitFor } from "./src/image-cap.js";
|
|
@@ -62,7 +63,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
62
63
|
value: { ...DEFAULT_CONFIG },
|
|
63
64
|
};
|
|
64
65
|
|
|
65
|
-
const protectionPredicate = (name: string, args: unknown) => isProtected(name, args, currentConfig.value);
|
|
66
|
+
const protectionPredicate = (name: string, args: unknown, nested?: NestedToolCalls) => isProtected(name, args, currentConfig.value, nested);
|
|
66
67
|
|
|
67
68
|
// Shared indexer — rebuilt from session on every session_start / session_tree
|
|
68
69
|
const indexer = new ToolCallIndexer();
|
|
@@ -926,7 +927,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
926
927
|
// CapturedBatch is pruned, which is exactly what we want.
|
|
927
928
|
const filtered = {
|
|
928
929
|
...capturedBatch,
|
|
929
|
-
toolCalls: capturedBatch.toolCalls.filter((tc) => !
|
|
930
|
+
toolCalls: capturedBatch.toolCalls.filter((tc) => !protectionPredicate(tc.toolName, tc.args, tc.nestedCalls)),
|
|
930
931
|
};
|
|
931
932
|
|
|
932
933
|
// Eager spill: offload oversized single results to sidecar files before they
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,10 @@
|
|
|
38
38
|
],
|
|
39
39
|
"scripts": {
|
|
40
40
|
"check:agents-core": "node scripts/check-agents-core.mjs",
|
|
41
|
-
"test": "bun test src/"
|
|
41
|
+
"test": "bun test src/",
|
|
42
|
+
"eval": "bun eval/run.ts",
|
|
43
|
+
"eval:replay": "bun eval/replay/run.ts",
|
|
44
|
+
"test:eval": "bun test eval/"
|
|
42
45
|
},
|
|
43
46
|
"pi": {
|
|
44
47
|
"extensions": [
|
|
@@ -57,9 +60,9 @@
|
|
|
57
60
|
"@sinclair/typebox": "*"
|
|
58
61
|
},
|
|
59
62
|
"devDependencies": {
|
|
60
|
-
"@earendil-works/pi-ai": "^1.
|
|
61
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
62
|
-
"@earendil-works/pi-tui": "^1.
|
|
63
|
+
"@earendil-works/pi-ai": "^1.5.0",
|
|
64
|
+
"@earendil-works/pi-coding-agent": "^1.5.0",
|
|
65
|
+
"@earendil-works/pi-tui": "^1.5.0",
|
|
63
66
|
"@sinclair/typebox": "^0.34.52"
|
|
64
67
|
}
|
|
65
68
|
}
|
|
@@ -11,6 +11,26 @@ import {
|
|
|
11
11
|
} from "./batch-capture.js";
|
|
12
12
|
import type { CapturedBatch, CapturedToolCall } from "./types.js";
|
|
13
13
|
import { ToolCallIndexer } from "./indexer.js";
|
|
14
|
+
import { isProtected } from "./protected.js";
|
|
15
|
+
import { DEFAULT_CONFIG } from "./types.js";
|
|
16
|
+
|
|
17
|
+
test("nested protection uses each turn's first paired result and preserves capture metadata", () => {
|
|
18
|
+
const nestedCalls = { complete: true, calls: [{ id: "nested", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" as const }] };
|
|
19
|
+
const assistant = { role: "assistant", content: [{ type: "toolCall", id: "same", name: "codemode", arguments: { code: "read()" } }] };
|
|
20
|
+
const result = (timestamp: number, nested?: typeof nestedCalls) => ({ role: "toolResult", toolCallId: "same", toolName: "codemode", timestamp, content: [{ type: "text", text: "body" }], ...(nested ? { nestedCalls: nested } : {}) });
|
|
21
|
+
const protectedResult = result(1, nestedCalls);
|
|
22
|
+
const plainResult = result(2);
|
|
23
|
+
const captured = captureBatch(assistant, [protectedResult, plainResult], 0, 0);
|
|
24
|
+
expect(captured.toolCalls[0].nestedCalls).toBe(nestedCalls);
|
|
25
|
+
expect(captureBatch(assistant, [plainResult], 1, 0).toolCalls[0]).not.toHaveProperty("nestedCalls");
|
|
26
|
+
expect(serializeBatchForSummarizer(captured)).not.toContain("nestedCalls");
|
|
27
|
+
const branch = [assistant, protectedResult, plainResult, assistant, plainResult].map(message => ({ type: "message", message }));
|
|
28
|
+
const batches = captureUnindexedBatchesFromSession(branch, { isSummarized: () => false },
|
|
29
|
+
(name, args, nested) => isProtected(name, args, DEFAULT_CONFIG, nested));
|
|
30
|
+
expect(batches).toHaveLength(1);
|
|
31
|
+
expect(batches[0].turnIndex).toBe(1);
|
|
32
|
+
expect(batches[0].toolCalls[0].resultTimestamp).toBe(2);
|
|
33
|
+
});
|
|
14
34
|
|
|
15
35
|
function toolCall(overrides: Partial<CapturedToolCall> = {}): CapturedToolCall {
|
|
16
36
|
return {
|
package/src/batch-capture.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
2
3
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import type { CapturedBatch, CapturedToolCall, BatchingMode } from "./types.js";
|
|
4
5
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
@@ -111,6 +112,7 @@ export function captureBatch(
|
|
|
111
112
|
resultText,
|
|
112
113
|
isError,
|
|
113
114
|
...(resultTimestamp !== undefined ? { resultTimestamp } : {}),
|
|
115
|
+
...(match?.nestedCalls !== undefined ? { nestedCalls: match.nestedCalls } : {}),
|
|
114
116
|
} satisfies CapturedToolCall;
|
|
115
117
|
});
|
|
116
118
|
|
|
@@ -128,7 +130,7 @@ export function captureBatch(
|
|
|
128
130
|
export function captureUnindexedBatchesFromSession(
|
|
129
131
|
branch: any[],
|
|
130
132
|
indexer: { isSummarized(id: string): boolean },
|
|
131
|
-
exclude: (toolName: string, args: unknown) => boolean = () => false
|
|
133
|
+
exclude: (toolName: string, args: unknown, nested?: NestedToolCalls) => boolean = () => false
|
|
132
134
|
): CapturedBatch[] {
|
|
133
135
|
// Keep the SessionEntry wrapper alongside each projected message so the
|
|
134
136
|
// entry's own timestamp remains available as the preferred source below
|
|
@@ -191,7 +193,7 @@ export function captureUnindexedBatchesFromSession(
|
|
|
191
193
|
const result = turnResults.get(id);
|
|
192
194
|
if (!result) return false;
|
|
193
195
|
if (indexer.isSummarized(occKey(id, resultTimestampOf(result.timestamp)))) return false;
|
|
194
|
-
if (exclude(tc.name, tc.input ?? tc.arguments)) return false;
|
|
196
|
+
if (exclude(tc.name, tc.input ?? tc.arguments, result.nestedCalls)) return false;
|
|
195
197
|
return true;
|
|
196
198
|
});
|
|
197
199
|
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { detectChains, isChainAnchorCustom, withClosingMessage } from "./chain-detector.js";
|
|
3
|
+
import { isProtected } from "./protected.js";
|
|
4
|
+
import { DEFAULT_CONFIG } from "./types.js";
|
|
5
|
+
|
|
6
|
+
test("nested protected reads join a closed chain's protected ids", () => {
|
|
7
|
+
const messages = [
|
|
8
|
+
userMsg(1),
|
|
9
|
+
{ role: "assistant", timestamp: 2, content: [{ type: "toolCall", id: "script", name: "codemode", arguments: {} }] },
|
|
10
|
+
{ role: "toolResult", timestamp: 3, toolCallId: "script", toolName: "codemode", nestedCalls: {
|
|
11
|
+
complete: true, calls: [{ id: "read", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" }],
|
|
12
|
+
} },
|
|
13
|
+
{ role: "assistant", timestamp: 4, content: [{ type: "text", text: "done" }] },
|
|
14
|
+
];
|
|
15
|
+
const [chain] = detectChains(messages, (name, args, nested) => isProtected(name, args, DEFAULT_CONFIG, nested));
|
|
16
|
+
expect(chain.protectedToolCallIds).toEqual(["script"]);
|
|
17
|
+
expect(chain.finalAssistantTimestamp).toBe(4);
|
|
18
|
+
});
|
|
3
19
|
|
|
4
20
|
const custom = (customType: string, timestamp: number) => ({ role: "custom", customType, timestamp });
|
|
5
21
|
|
package/src/chain-detector.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
1
2
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
2
3
|
import type { ChainRange } from "./types.js";
|
|
3
4
|
|
|
@@ -45,12 +46,12 @@ type State = "idle" | "inChain";
|
|
|
45
46
|
* NOTE: Message identity uses `timestamp` (for user / final text-only assistant) and
|
|
46
47
|
* `toolCallId` sets (for middle tool-using turns). AgentMessage has no `.id` field.
|
|
47
48
|
*
|
|
48
|
-
* @param isProtected Predicate over (toolName, args); matching calls are never pruned
|
|
49
|
+
* @param isProtected Predicate over (toolName, args, nestedCalls); matching calls are never pruned
|
|
49
50
|
* and their outputs are relocated verbatim into compressed chains.
|
|
50
51
|
*/
|
|
51
52
|
export function detectChains(
|
|
52
53
|
messages: any[],
|
|
53
|
-
isProtected: (toolName: string, args: unknown) => boolean = () => false,
|
|
54
|
+
isProtected: (toolName: string, args: unknown, nested?: NestedToolCalls) => boolean = () => false,
|
|
54
55
|
): ChainRange[] {
|
|
55
56
|
const ranges: ChainRange[] = [];
|
|
56
57
|
let state: State = "idle";
|
|
@@ -108,9 +109,8 @@ export function detectChains(
|
|
|
108
109
|
if (msg.toolCallId) {
|
|
109
110
|
middleIds.add(msg.toolCallId);
|
|
110
111
|
middleKeys.add(occKey(msg.toolCallId, resultTimestampOf(msg.timestamp)));
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
if (isProtected(msg.toolName, undefined)) protectedIds.add(msg.toolCallId);
|
|
112
|
+
// Outer args live on the assistant block; nested calls live on the result.
|
|
113
|
+
if (isProtected(msg.toolName, undefined, msg.nestedCalls)) protectedIds.add(msg.toolCallId);
|
|
114
114
|
}
|
|
115
115
|
continue;
|
|
116
116
|
}
|
package/src/commands.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
-
import { mkdtempSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
@@ -9,7 +9,7 @@ import { join } from "node:path";
|
|
|
9
9
|
process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-condense-commands-test-"));
|
|
10
10
|
|
|
11
11
|
import { pruneStatusText, setPruneStatusWidget, registerCommands } from "./commands.js";
|
|
12
|
-
import { settingsPath } from "./config.js";
|
|
12
|
+
import { settingsPath, loadConfig } from "./config.js";
|
|
13
13
|
import type { ContextPruneConfig, ContextMetricsSnapshot, SummarizerStats } from "./types.js";
|
|
14
14
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
15
15
|
import { Container, SettingsList } from "@earendil-works/pi-tui";
|
|
@@ -97,13 +97,14 @@ function setupPrunerCommand(overrides: {
|
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
describe("/pruner settings
|
|
100
|
+
describe("/pruner settings descriptions", () => {
|
|
101
101
|
initTheme("dark");
|
|
102
102
|
|
|
103
103
|
it.each([
|
|
104
104
|
["purgeErrorsEnabled", "Argument purge", "Shrink long strings in summarized calls when their results are stubbed in the same render, and in errored calls after the error cooldown, keeping argument shape."],
|
|
105
105
|
["purgeErrorsCooldown", "Error purge cooldown (turns)", "Wait this many turns after a tool error before purging its argument body."],
|
|
106
106
|
["purgeErrorsMinArgChars", "Argument purge min arg chars", "Only purge summarized or errored argument bodies at least this many chars."],
|
|
107
|
+
["protectedPaths", "Protected paths", "reads made inside codemode scripts"],
|
|
107
108
|
])("describes %s through the settings overlay", async (id, label, description) => {
|
|
108
109
|
const harness = setupPrunerCommand({ save: async () => {} });
|
|
109
110
|
await harness.run("settings");
|
|
@@ -114,6 +115,11 @@ describe("/pruner settings argument purge", () => {
|
|
|
114
115
|
expect(rendered).toContain(label);
|
|
115
116
|
expect(rendered).toContain(description);
|
|
116
117
|
if (id === "purgeErrorsCooldown") expect(rendered).not.toContain("same render");
|
|
118
|
+
if (id === "protectedPaths") {
|
|
119
|
+
expect(rendered).toContain("nestedCalls");
|
|
120
|
+
expect(rendered).toContain("incomplete nested-call record is kept verbatim regardless of this setting");
|
|
121
|
+
expect(rendered).toContain("Set to 'none' to disable path matching only");
|
|
122
|
+
}
|
|
117
123
|
});
|
|
118
124
|
|
|
119
125
|
it("persists the shared disabled toggle and minimum using only existing purgeErrors fields", async () => {
|
|
@@ -148,6 +154,23 @@ describe("/pruner now (empty capture)", () => {
|
|
|
148
154
|
});
|
|
149
155
|
|
|
150
156
|
describe("/pruner status context block", () => {
|
|
157
|
+
it("reports the resolved 45s idle default when the setting is absent", async () => {
|
|
158
|
+
const scratchDir = mkdtempSync(join(tmpdir(), "pi-condense-idle-status-"));
|
|
159
|
+
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
160
|
+
try {
|
|
161
|
+
process.env.PI_CODING_AGENT_DIR = scratchDir;
|
|
162
|
+
writeFileSync(settingsPath(), JSON.stringify({ contextPrune: { enabled: true } }));
|
|
163
|
+
const harness = setupPrunerCommand();
|
|
164
|
+
harness.currentConfig.value = await loadConfig();
|
|
165
|
+
await harness.run("status");
|
|
166
|
+
expect(harness.notifications[0]?.message).toContain("idle to: 45s");
|
|
167
|
+
} finally {
|
|
168
|
+
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
169
|
+
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
|
170
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
151
174
|
const metrics: ContextMetricsSnapshot = {
|
|
152
175
|
openCycleThinkingTokens: 12000,
|
|
153
176
|
largestChainSharePct: 62,
|
package/src/commands.ts
CHANGED
|
@@ -256,7 +256,7 @@ function protectedToolsDescription(config: ContextPruneConfig): string {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
function protectedPathsDescription(config: ContextPruneConfig): string {
|
|
259
|
-
return `Glob patterns
|
|
259
|
+
return `Glob patterns match a tool call's \`args.path\` and reads recorded in a result's \`nestedCalls\`, including reads made inside codemode scripts. Matching outputs bypass summarization. An incomplete nested-call record is kept verbatim regardless of this setting. Set to 'none' to disable path matching only. Currently: ${protectedToolsDisplay(config.protectedPaths)}. Edit via \`/pruner protected-paths\` (interactive) or \`/pruner protected-paths <comma-separated globs>\`. Default protects skill files and per-repo gauntlet overrides: **/skills/**/*.md, **/gauntlet-overrides.md`;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
const HELP_TEXT = `pruner — automatically summarizes tool-call outputs to keep context lean.
|
package/src/config.test.ts
CHANGED
|
@@ -99,8 +99,15 @@ describe("loadConfig summarizer timeout normalization", () => {
|
|
|
99
99
|
it("defaults both timeouts when absent", async () => {
|
|
100
100
|
await writeContextPrune({});
|
|
101
101
|
const config = await loadConfig();
|
|
102
|
-
expect(config.summarizerIdleTimeoutMs).toBe(
|
|
102
|
+
expect(config.summarizerIdleTimeoutMs).toBe(45000);
|
|
103
103
|
expect(config.summarizerMaxTimeoutMs).toBe(DEFAULT_CONFIG.summarizerMaxTimeoutMs);
|
|
104
|
+
expect(config.summarizerMaxTimeoutMs).toBe(180000);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("preserves an explicit legacy 20s idle timeout", async () => {
|
|
108
|
+
await writeContextPrune({ summarizerIdleTimeoutMs: 20000 });
|
|
109
|
+
const config = await loadConfig();
|
|
110
|
+
expect(config.summarizerIdleTimeoutMs).toBe(20000);
|
|
104
111
|
});
|
|
105
112
|
|
|
106
113
|
it("preserves explicit 0 (disabled) for both", async () => {
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { computeContextMetrics } from "./context-metrics.js";
|
|
3
|
-
import type
|
|
3
|
+
import { DEFAULT_CONFIG, type PruneFrontier } from "./types.js";
|
|
4
|
+
import { isProtected } from "./protected.js";
|
|
5
|
+
|
|
6
|
+
test("frontier gap excludes a result with a protected nested read", () => {
|
|
7
|
+
const messages = [userMsg(1), assistantWithTools(2, ["script"]), {
|
|
8
|
+
...toolResult(3, "script", "codemode"),
|
|
9
|
+
nestedCalls: { complete: true, calls: [{ id: "nested", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" }] },
|
|
10
|
+
}];
|
|
11
|
+
expect(computeContextMetrics(messages, null, () => false,
|
|
12
|
+
(name, args, nested) => isProtected(name, args, DEFAULT_CONFIG, nested)).frontierGapTokens).toBe(0);
|
|
13
|
+
});
|
|
4
14
|
|
|
5
15
|
// ── Minimal message factories (mirrors src/chain-detector.test.ts style) ───
|
|
6
16
|
|
package/src/context-metrics.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
1
2
|
import { detectChains, isChainAnchorCustom } from "./chain-detector.js";
|
|
2
3
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
3
4
|
import type { ContextMetricsSnapshot, PruneFrontier } from "./types.js";
|
|
@@ -49,7 +50,7 @@ export function computeContextMetrics(
|
|
|
49
50
|
branch: any[],
|
|
50
51
|
frontier: PruneFrontier | null,
|
|
51
52
|
isSummarized: (occurrenceKey: string) => boolean,
|
|
52
|
-
isProtected: (toolName: string, args: unknown) => boolean,
|
|
53
|
+
isProtected: (toolName: string, args: unknown, nested?: NestedToolCalls) => boolean,
|
|
53
54
|
): ContextMetricsSnapshot {
|
|
54
55
|
if (branch.length === 0) {
|
|
55
56
|
return { openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 };
|
|
@@ -156,7 +157,7 @@ export function computeContextMetrics(
|
|
|
156
157
|
const key = occKey(m.toolCallId, resultTimestampOf(m.timestamp));
|
|
157
158
|
if (isSummarized(key)) continue;
|
|
158
159
|
const args = findArgsForToolCallId(branch, i, m.toolCallId);
|
|
159
|
-
if (isProtected(m.toolName, args)) continue;
|
|
160
|
+
if (isProtected(m.toolName, args, m.nestedCalls)) continue;
|
|
160
161
|
frontierGapTokens += tokensOf(m);
|
|
161
162
|
}
|
|
162
163
|
|
package/src/protected.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
2
3
|
import { globToRegExp, isProtected, normalizePath } from "./protected.js";
|
|
3
4
|
import { DEFAULT_CONFIG } from "./types.js";
|
|
4
5
|
|
|
@@ -67,6 +68,46 @@ describe("isProtected", () => {
|
|
|
67
68
|
expect(isProtected("read", { path: "/h/src/app.ts" }, cfg)).toBe(false);
|
|
68
69
|
});
|
|
69
70
|
|
|
71
|
+
test.each(["ok", "error"] as const)("protects nested skill reads with status %s", (status) => {
|
|
72
|
+
expect(isProtected("codemode", { code: "console.log(await tools.read({ path: 'skills/x/SKILL.md' }))" }, cfg, {
|
|
73
|
+
complete: true,
|
|
74
|
+
calls: [{ id: "nested-read", name: "read", arguments: { path: "skills/x/SKILL.md" }, status }],
|
|
75
|
+
})).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test.each([undefined, {}, { path: "source.ts" }])("protects nested tool names with arguments %j and path matching disabled", (args) => {
|
|
79
|
+
for (const outerName of ["codemode", "another-script-tool"]) {
|
|
80
|
+
expect(isProtected(outerName, {}, { protectedTools: ["todowrite"], protectedPaths: [] }, {
|
|
81
|
+
complete: true, calls: [{ id: "nested", name: "todowrite", arguments: args, status: "ok" }],
|
|
82
|
+
})).toBe(true);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test.each([
|
|
87
|
+
{ complete: false, calls: [] },
|
|
88
|
+
{ complete: false, calls: [{ id: "write", name: "write", arguments: { path: "source.ts" }, status: "ok" }] },
|
|
89
|
+
{ complete: false, calls: [{ id: "read", name: "read", argumentsBytes: 8193, status: "ok" }] },
|
|
90
|
+
{ complete: false, calls: [{ id: "read", name: "read", arguments: { path: "source.ts" }, status: "unfinished" }] },
|
|
91
|
+
] satisfies NestedToolCalls[])("protects incomplete records %j even with empty configuration", (nested) => {
|
|
92
|
+
for (const config of [cfg, { protectedTools: [], protectedPaths: [] }]) {
|
|
93
|
+
expect(isProtected("codemode", {}, config, nested)).toBe(true);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test.each([
|
|
98
|
+
undefined,
|
|
99
|
+
{ complete: true, calls: [] },
|
|
100
|
+
{ complete: true, calls: [{ id: "read", name: "read", arguments: { path: "source.ts" }, status: "ok" }] },
|
|
101
|
+
{ complete: true, calls: [{ id: "read", name: "read", arguments: { path: 42 }, status: "ok" }] },
|
|
102
|
+
"malformed",
|
|
103
|
+
{ complete: false, calls: {} },
|
|
104
|
+
])("keeps unmatched or malformed nested records %j prunable without changing direct protection", (nested) => {
|
|
105
|
+
const record = nested as NestedToolCalls | undefined;
|
|
106
|
+
expect(isProtected("codemode", { code: "tools.read({path: 'skills/x/SKILL.md'})" }, cfg, record)).toBe(false);
|
|
107
|
+
expect(isProtected("todowrite", {}, cfg, record)).toBe(true);
|
|
108
|
+
expect(isProtected("read", { path: "skills/x/SKILL.md" }, cfg, record)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
70
111
|
test("backslashes normalized to forward slashes", () => {
|
|
71
112
|
expect(isProtected("read", { path: "h\\skills\\x\\SKILL.md" }, cfg)).toBe(true);
|
|
72
113
|
});
|
package/src/protected.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
1
3
|
/** Structural pick of ContextPruneConfig — keeps this module dependency-free. */
|
|
2
4
|
export interface ProtectionConfig {
|
|
3
5
|
protectedTools: readonly string[];
|
|
@@ -46,7 +48,7 @@ export function normalizePath(path: string): string {
|
|
|
46
48
|
return path.replace(/\\/g, "/");
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
function matches(toolName: string, args: unknown, config: ProtectionConfig): boolean {
|
|
50
52
|
if (config.protectedTools.includes(toolName)) return true;
|
|
51
53
|
if (config.protectedPaths.length === 0) return false;
|
|
52
54
|
const path = (args as Record<string, unknown> | null | undefined)?.path;
|
|
@@ -54,3 +56,10 @@ export function isProtected(toolName: string, args: unknown, config: ProtectionC
|
|
|
54
56
|
const normalized = normalizePath(path);
|
|
55
57
|
return config.protectedPaths.some((p) => globToRegExp(p).test(normalized));
|
|
56
58
|
}
|
|
59
|
+
|
|
60
|
+
export function isProtected(toolName: string, args: unknown, config: ProtectionConfig, nested?: NestedToolCalls): boolean {
|
|
61
|
+
if (matches(toolName, args, config)) return true;
|
|
62
|
+
if (!nested || typeof nested !== "object" || !Array.isArray(nested.calls)) return false;
|
|
63
|
+
if (nested.complete === false) return true;
|
|
64
|
+
return nested.calls.some((call) => matches(call?.name, call?.arguments, config));
|
|
65
|
+
}
|
package/src/pruner.test.ts
CHANGED
|
@@ -599,6 +599,27 @@ describe("render-time protection re-check", () => {
|
|
|
599
599
|
expect(messages[1].content[0].text).toBe("FULL SKILL BODY");
|
|
600
600
|
});
|
|
601
601
|
|
|
602
|
+
it("repairs an indexed script result from nested metadata with byte-identical repeat renders", () => {
|
|
603
|
+
const record = { toolCallId: "script", toolName: "codemode", args: { code: "read()" }, resultText: "FULL SKILL BODY", isError: false, turnIndex: 0, timestamp: 10 };
|
|
604
|
+
const oldIndexer = makeMockIndexer({
|
|
605
|
+
summarized: new Set(["script"]), shortRefs: new Map([["script", "t1"]]), records: new Map([["script", record]]),
|
|
606
|
+
});
|
|
607
|
+
const raw = [
|
|
608
|
+
{ ...skillAsst, content: [{ type: "toolCall", id: "script", name: "codemode", arguments: record.args }] },
|
|
609
|
+
{ ...skillMsg, toolCallId: "script", toolName: "codemode", nestedCalls: {
|
|
610
|
+
complete: true, calls: [{ id: "nested", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" }],
|
|
611
|
+
} },
|
|
612
|
+
];
|
|
613
|
+
const before = JSON.stringify(raw);
|
|
614
|
+
const render = () => pruneMessages(raw, oldIndexer as any, undefined, undefined,
|
|
615
|
+
{ protectedTools: [], protectedPaths: ["**/skills/**/*.md"] });
|
|
616
|
+
const first = render();
|
|
617
|
+
expect(first.pruned).toBe(false);
|
|
618
|
+
expect(JSON.stringify(first.messages)).toBe(before);
|
|
619
|
+
expect(JSON.stringify(render().messages)).toBe(JSON.stringify(first.messages));
|
|
620
|
+
expect(oldIndexer.getRecord("script")).toBe(record);
|
|
621
|
+
});
|
|
622
|
+
|
|
602
623
|
it("still stubs when no protection config is passed", () => {
|
|
603
624
|
const { messages, pruned } = pruneMessages([skillAsst, skillMsg], indexer as any);
|
|
604
625
|
expect(pruned).toBe(true);
|
package/src/pruner.ts
CHANGED
|
@@ -116,7 +116,7 @@ export function pruneMessages(
|
|
|
116
116
|
// session JSONL, so skipping the stub restores it verbatim.
|
|
117
117
|
// Dedup aliases resolve to the original record, so an alias whose own
|
|
118
118
|
// path is protected but whose original isn't stays stubbed (edge case).
|
|
119
|
-
if (protection && record && isProtected(record.toolName, record.args, protection)) {
|
|
119
|
+
if (protection && record && isProtected(record.toolName, record.args, protection, msg.nestedCalls)) {
|
|
120
120
|
return msg;
|
|
121
121
|
}
|
|
122
122
|
if (inGrace.has(key)) {
|
|
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { supersededStub } from "./supersede.js";
|
|
6
|
+
import { DEFAULT_CONFIG } from "./types.js";
|
|
6
7
|
|
|
7
8
|
// This must run before any module that transitively reads PI_CODING_AGENT_DIR
|
|
8
9
|
// (src/config.ts's getAgentDir()) is imported/executed.
|
|
@@ -323,6 +324,47 @@ async function boot(options?: Parameters<typeof bootExtension>[0]) {
|
|
|
323
324
|
return harness;
|
|
324
325
|
}
|
|
325
326
|
|
|
327
|
+
describe("nested protection capture", () => {
|
|
328
|
+
it.each([
|
|
329
|
+
{ mode: "live", incomplete: false },
|
|
330
|
+
{ mode: "rescan", incomplete: false },
|
|
331
|
+
{ mode: "live", incomplete: true },
|
|
332
|
+
{ mode: "rescan", incomplete: true },
|
|
333
|
+
])("%j excludes protected script output from summarizer and index", async ({ mode, incomplete }) => {
|
|
334
|
+
const branch: any[] = [];
|
|
335
|
+
const { handlers, ctx, appended } = await boot({ branch, ...(incomplete ? { protectedPaths: [] } : {}) });
|
|
336
|
+
await handlers.get("session_start")!({}, ctx);
|
|
337
|
+
const message = { role: "assistant", content: [
|
|
338
|
+
{ type: "toolCall", id: "script", name: "codemode", arguments: { code: "read()" } },
|
|
339
|
+
{ type: "toolCall", id: "control", name: "read", arguments: { path: "source.ts" } },
|
|
340
|
+
] };
|
|
341
|
+
const toolResults = [
|
|
342
|
+
{ role: "toolResult", toolCallId: "script", toolName: "codemode", timestamp: 10, content: [{ type: "text", text: "PROTECTED-SCRIPT-BODY".repeat(incomplete ? Math.ceil(DEFAULT_CONFIG.spillThreshold / "PROTECTED-SCRIPT-BODY".length) + 1 : 200) }], nestedCalls: {
|
|
343
|
+
complete: !incomplete, calls: incomplete ? [] : [{ id: "nested", name: "read", arguments: { path: "skills/x/SKILL.md" }, status: "ok" }],
|
|
344
|
+
} },
|
|
345
|
+
{ role: "toolResult", toolCallId: "control", toolName: "read", timestamp: 11, content: [{ type: "text", text: "UNPROTECTED-CONTROL".repeat(200) }] },
|
|
346
|
+
];
|
|
347
|
+
branch.push(...[message, ...toolResults].map(message => ({ type: "message", message })));
|
|
348
|
+
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
349
|
+
const inputs: string[] = [];
|
|
350
|
+
const previousStream = streamImpl;
|
|
351
|
+
streamImpl = (_model, input) => { inputs.push(JSON.stringify(input)); return okStream(); };
|
|
352
|
+
try {
|
|
353
|
+
await handlers.get("turn_end")!({ message, toolResults, turnIndex: 0 }, ctx);
|
|
354
|
+
if (mode === "live") ctx.sessionManager.getBranch = () => { throw new Error("force pending live batch"); };
|
|
355
|
+
await handlers.get("message_end")!({ message: { role: "assistant", content: [{ type: "text", text: "done" }] } }, ctx);
|
|
356
|
+
expect(inputs).toHaveLength(1);
|
|
357
|
+
expect(inputs[0]).toContain("UNPROTECTED-CONTROL");
|
|
358
|
+
expect(inputs[0]).not.toContain("PROTECTED-SCRIPT-BODY");
|
|
359
|
+
const indexed = appended.filter(entry => entry.type === "context-prune-index")
|
|
360
|
+
.flatMap(entry => (entry.data as any).toolCalls.map((call: any) => call.toolCallId));
|
|
361
|
+
expect(indexed).toEqual(["control"]);
|
|
362
|
+
} finally {
|
|
363
|
+
streamImpl = previousStream;
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
326
368
|
describe("reload rearm (issue #6)", () => {
|
|
327
369
|
it("hides the startup widget when the prune status line is disabled", async () => {
|
|
328
370
|
const { handlers, ctx, widgets } = await boot({ showPruneStatusLine: false });
|
|
@@ -4,7 +4,7 @@ let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
|
|
|
4
4
|
};
|
|
5
5
|
const { summarizeBatch, summarizeRange } = await import("./summarizer.js");
|
|
6
6
|
const { FallbackController } = await import("./summarizer-fallback.js");
|
|
7
|
-
const { DEFAULT_CONFIG } = await import("./types.js");
|
|
7
|
+
const { DEFAULT_CONFIG, SUMMARIZER_IDLE_TIMEOUT_PRESETS } = await import("./types.js");
|
|
8
8
|
|
|
9
9
|
const PRIMARY = { id: "primary-model", provider: "provider-a", name: "Primary", api: "openai-responses" };
|
|
10
10
|
const SESSION = { id: "session-model", provider: "provider-b", name: "Session", api: "openai-responses" };
|
|
@@ -161,6 +161,7 @@ describe("virtual models through the registry", () => {
|
|
|
161
161
|
expect(result?.summaryText).toBe("- routed summary");
|
|
162
162
|
expect(controller.inFallback).toBe(true);
|
|
163
163
|
expect(seen[1]).toBe(VIRTUAL);
|
|
164
|
+
expect(notes[0]).toEqual({ msg: "pi-condense: summarizer model Primary failing (down), using session model Auto until it recovers", level: "warning" });
|
|
164
165
|
expect(notes.filter(n => n.level === "warning")).toHaveLength(1);
|
|
165
166
|
expect(notes.filter(n => n.level === "error")).toHaveLength(0);
|
|
166
167
|
});
|
|
@@ -300,6 +301,7 @@ describe("runSummarization wiring — both-down + deferred warning", () => {
|
|
|
300
301
|
const r2 = await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller });
|
|
301
302
|
expect(r2?.summaryText).toBe("- rescued");
|
|
302
303
|
expect(notes.filter((n) => n.level === "warning")).toHaveLength(1);
|
|
304
|
+
expect(notes[0].msg).toBe("pi-condense: summarizer model Primary failing, using session model Session until it recovers");
|
|
303
305
|
});
|
|
304
306
|
});
|
|
305
307
|
|
|
@@ -319,6 +321,7 @@ describe("runSummarization wiring — abort", () => {
|
|
|
319
321
|
|
|
320
322
|
describe("runSummarization wiring — timeouts", () => {
|
|
321
323
|
for (const [kind, idleMs, maxMs, message] of [
|
|
324
|
+
// Millisecond-scale overrides exercise timer behavior, not the default.
|
|
322
325
|
["idle", 20, 0, /stalled/],
|
|
323
326
|
["ceiling", 0, 20, /ceiling/],
|
|
324
327
|
] as const) {
|
|
@@ -379,7 +382,7 @@ describe("runSummarization wiring — timeouts", () => {
|
|
|
379
382
|
const r = await summarizeBatch(makeBatch(), cfg, ctx, { controller });
|
|
380
383
|
expect(r?.summaryText).toBe("- rescued");
|
|
381
384
|
expect(controller.inFallback).toBe(true);
|
|
382
|
-
expect(notes.filter((n) => n.level === "warning")).toHaveLength(1);
|
|
385
|
+
expect(notes.filter((n) => n.level === "warning")).toHaveLength(1);
|
|
383
386
|
expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
|
|
384
387
|
});
|
|
385
388
|
|
|
@@ -430,3 +433,235 @@ describe("runSummarization wiring — idle reset keeps a flowing stream alive",
|
|
|
430
433
|
expect(notes.filter((n) => n.level === "warning")).toHaveLength(0);
|
|
431
434
|
});
|
|
432
435
|
});
|
|
436
|
+
|
|
437
|
+
async function withTimerClock(
|
|
438
|
+
run: (clock: { advance(ms: number): Promise<void>; now(): number }) => Promise<void>,
|
|
439
|
+
) {
|
|
440
|
+
const realSetTimeout = globalThis.setTimeout;
|
|
441
|
+
const realClearTimeout = globalThis.clearTimeout;
|
|
442
|
+
const timers = new Map<number, { at: number; fire(): void }>();
|
|
443
|
+
let now = 0;
|
|
444
|
+
let nextId = 0;
|
|
445
|
+
const drain = () => new Promise<void>((resolve) => realSetTimeout(resolve, 0));
|
|
446
|
+
globalThis.setTimeout = ((callback: (...args: any[]) => void, delay = 0, ...args: any[]) => {
|
|
447
|
+
const id = ++nextId;
|
|
448
|
+
timers.set(id, { at: now + Number(delay), fire: () => callback(...args) });
|
|
449
|
+
return id;
|
|
450
|
+
}) as unknown as typeof globalThis.setTimeout;
|
|
451
|
+
globalThis.clearTimeout = ((id: any) => { timers.delete(Number(id)); }) as unknown as typeof globalThis.clearTimeout;
|
|
452
|
+
try {
|
|
453
|
+
await run({
|
|
454
|
+
now: () => now,
|
|
455
|
+
async advance(ms) {
|
|
456
|
+
await drain();
|
|
457
|
+
const end = now + ms;
|
|
458
|
+
while (true) {
|
|
459
|
+
const next = [...timers.entries()]
|
|
460
|
+
.filter(([, timer]) => timer.at <= end)
|
|
461
|
+
.sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
|
|
462
|
+
if (!next) break;
|
|
463
|
+
now = next[1].at;
|
|
464
|
+
timers.delete(next[0]);
|
|
465
|
+
next[1].fire();
|
|
466
|
+
await drain();
|
|
467
|
+
}
|
|
468
|
+
now = end;
|
|
469
|
+
await drain();
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
} finally {
|
|
473
|
+
globalThis.setTimeout = realSetTimeout;
|
|
474
|
+
globalThis.clearTimeout = realClearTimeout;
|
|
475
|
+
timers.clear();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const genericEnter = "pi-condense: summarizer model Primary failing, using session model Session until it recovers";
|
|
480
|
+
|
|
481
|
+
describe("immediate fallback reasons", () => {
|
|
482
|
+
it("marks only the 45s preset as default", () => {
|
|
483
|
+
expect(DEFAULT_CONFIG.summarizerIdleTimeoutMs).toBe(45000);
|
|
484
|
+
expect(SUMMARIZER_IDLE_TIMEOUT_PRESETS.filter(p => p.label.includes("(default)")))
|
|
485
|
+
.toEqual([{ value: "45000", label: "45s (default)" }]);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
for (const raw of [
|
|
489
|
+
{ summarizerModel: "provider-a/primary-model" },
|
|
490
|
+
{ summarizerModel: "provider-a/primary-model", summarizerIdleTimeoutMs: 20000 },
|
|
491
|
+
]) {
|
|
492
|
+
it(`delayed first event with ${raw.summarizerIdleTimeoutMs ?? "resolved default"}`, async () => {
|
|
493
|
+
await withTimerClock(async clock => {
|
|
494
|
+
const config = { ...DEFAULT_CONFIG, ...raw };
|
|
495
|
+
const notes: Note[] = [];
|
|
496
|
+
const attempts: any[] = [];
|
|
497
|
+
let primarySignal: AbortSignal;
|
|
498
|
+
streamImpl = (model, _input, opts) => {
|
|
499
|
+
attempts.push(model);
|
|
500
|
+
if (model === PRIMARY) {
|
|
501
|
+
primarySignal = opts.signal;
|
|
502
|
+
return drippingStream(opts, "- primary summary", 1, 25000);
|
|
503
|
+
}
|
|
504
|
+
return okStream("- rescued");
|
|
505
|
+
};
|
|
506
|
+
const controller = new FallbackController(clock.now);
|
|
507
|
+
let settled = false;
|
|
508
|
+
const pending = summarizeBatch(makeBatch(), config, makeCtx(notes), { controller });
|
|
509
|
+
void pending.then(() => { settled = true; });
|
|
510
|
+
await clock.advance(19999);
|
|
511
|
+
expect(settled).toBe(false);
|
|
512
|
+
await clock.advance(1);
|
|
513
|
+
if (raw.summarizerIdleTimeoutMs === 20000) {
|
|
514
|
+
expect(primarySignal!.aborted).toBe(true);
|
|
515
|
+
expect((await pending)?.summaryText).toBe("- rescued");
|
|
516
|
+
expect(attempts).toEqual([PRIMARY, SESSION]);
|
|
517
|
+
expect(notes).toEqual([{ msg: genericEnter.replace("failing,", "failing (stalled (no output for 20s)),"), level: "warning" }]);
|
|
518
|
+
} else {
|
|
519
|
+
expect(config.summarizerIdleTimeoutMs).toBe(45000);
|
|
520
|
+
expect(settled).toBe(false);
|
|
521
|
+
expect(primarySignal!.aborted).toBe(false);
|
|
522
|
+
await clock.advance(5000);
|
|
523
|
+
expect((await pending)?.summaryText).toBe("- primary summary");
|
|
524
|
+
expect(attempts).toEqual([PRIMARY]);
|
|
525
|
+
expect(controller.inFallback).toBe(false);
|
|
526
|
+
expect(notes).toEqual([]);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
for (const kind of ["idle", "ceiling"] as const) {
|
|
533
|
+
it(`${kind} rescue carries the prefix-free reason`, async () => {
|
|
534
|
+
await withTimerClock(async clock => {
|
|
535
|
+
const notes: Note[] = [];
|
|
536
|
+
streamImpl = (model, _input, opts) => {
|
|
537
|
+
if (model === SESSION) return okStream("- rescued");
|
|
538
|
+
if (kind === "ceiling") return hangingStream(opts);
|
|
539
|
+
const drip = drippingStream(opts, "- partial", 1, 1000);
|
|
540
|
+
const hang = hangingStream(opts);
|
|
541
|
+
return {
|
|
542
|
+
async *[Symbol.asyncIterator]() { yield* drip; yield* hang; },
|
|
543
|
+
result: () => hang.result(),
|
|
544
|
+
};
|
|
545
|
+
};
|
|
546
|
+
let settled = false;
|
|
547
|
+
const pending = summarizeBatch(makeBatch(), {
|
|
548
|
+
...distinctConfig, summarizerIdleTimeoutMs: kind === "idle" ? 45000 : 0,
|
|
549
|
+
summarizerMaxTimeoutMs: 180000,
|
|
550
|
+
}, makeCtx(notes), { controller: new FallbackController(clock.now) });
|
|
551
|
+
void pending.then(() => { settled = true; });
|
|
552
|
+
await clock.advance(kind === "idle" ? 45000 : 179999);
|
|
553
|
+
expect(settled).toBe(false);
|
|
554
|
+
await clock.advance(kind === "idle" ? 1000 : 1);
|
|
555
|
+
expect((await pending)?.summaryText).toBe("- rescued");
|
|
556
|
+
const reason = kind === "idle" ? "stalled (no output for 45s)" : "exceeded 180s ceiling";
|
|
557
|
+
expect(notes).toEqual([{ msg: genericEnter.replace("failing,", `failing (${reason}),`), level: "warning" }]);
|
|
558
|
+
if (kind === "idle") expect(notes[0].msg).toMatch(/summarizer model .* failing \(stalled \(no output for \d+s\)\), using session model/);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
for (const path of ["response", "throw"] as const) {
|
|
564
|
+
for (const [name, message] of [
|
|
565
|
+
["non-empty", "boom"],
|
|
566
|
+
["multiline", " long provider message\n" + "detail ".repeat(60) + "\n "],
|
|
567
|
+
["empty", ""],
|
|
568
|
+
]) {
|
|
569
|
+
it(`${path} preserves ${name} message verbatim`, async () => {
|
|
570
|
+
streamImpl = model => {
|
|
571
|
+
if (model === SESSION) return okStream("- rescued");
|
|
572
|
+
if (path === "throw") throw new Error(message);
|
|
573
|
+
return errStream(message);
|
|
574
|
+
};
|
|
575
|
+
const notes: Note[] = [];
|
|
576
|
+
const result = await summarizeBatch(makeBatch(), distinctConfig, makeCtx(notes), { controller: new FallbackController() });
|
|
577
|
+
expect(result?.summaryText).toBe("- rescued");
|
|
578
|
+
expect(notes).toEqual([{ msg: message ? genericEnter.replace("failing,", `failing (${message}),`) : genericEnter, level: "warning" }]);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
describe("terminal timeout labels", () => {
|
|
585
|
+
for (const kind of ["idle", "ceiling"] as const) {
|
|
586
|
+
for (const mode of ["legacy", "fallback-only", "both-down", "unusable-retry"] as const) {
|
|
587
|
+
it(`${kind}: ${mode} labels the selected outcome`, async () => {
|
|
588
|
+
await withTimerClock(async clock => {
|
|
589
|
+
const notes: Note[] = [];
|
|
590
|
+
streamImpl = (model, _input, opts) => mode === "unusable-retry" && model === SESSION
|
|
591
|
+
? okStream(" ") : hangingStream(opts);
|
|
592
|
+
const controller = new FallbackController(clock.now);
|
|
593
|
+
if (mode === "fallback-only") controller.onBothDown();
|
|
594
|
+
const pending = summarizeBatch(makeBatch(), {
|
|
595
|
+
...distinctConfig,
|
|
596
|
+
summarizerModel: mode === "legacy" ? "default" : distinctConfig.summarizerModel,
|
|
597
|
+
summarizerIdleTimeoutMs: kind === "idle" ? 20 : 0,
|
|
598
|
+
summarizerMaxTimeoutMs: kind === "ceiling" ? 20 : 0,
|
|
599
|
+
}, makeCtx(notes), mode === "legacy" ? {} : { controller });
|
|
600
|
+
await clock.advance(40);
|
|
601
|
+
expect(await pending).toBeNull();
|
|
602
|
+
const label = mode === "unusable-retry" ? "Primary" : "Session";
|
|
603
|
+
const reason = kind === "idle" ? "stalled (no output for 0s)" : "exceeded 0s ceiling";
|
|
604
|
+
expect(notes).toEqual([{ msg: `pi-condense: summarizer ${label} ${reason}; summarizer call abandoned`, level: "warning" }]);
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
it("session-only auth failure after transient primary keeps its error text", async () => {
|
|
611
|
+
streamImpl = () => errStream("down");
|
|
612
|
+
const notes: Note[] = [];
|
|
613
|
+
const ctx = makeCtx(notes);
|
|
614
|
+
ctx.modelRegistry.getApiKeyAndHeaders = async (model: any) => model === PRIMARY
|
|
615
|
+
? { ok: true, apiKey: "k", headers: {} } : { ok: false, error: "session key missing" };
|
|
616
|
+
expect(await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller: new FallbackController() })).toBeNull();
|
|
617
|
+
expect(notes).toEqual([{ msg: "pruner: summarization failed: session key missing", level: "error" }]);
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
describe("deferred entry and probe notices", () => {
|
|
622
|
+
for (const initial of ["both-down", "rescued"] as const) {
|
|
623
|
+
it(`${initial}: failed probe rescue then recovery`, async () => {
|
|
624
|
+
let now = 0;
|
|
625
|
+
const controller = new FallbackController(() => now);
|
|
626
|
+
const notes: Note[] = [];
|
|
627
|
+
const ctx = makeCtx(notes);
|
|
628
|
+
streamImpl = model => model === PRIMARY || initial === "both-down" ? errStream("down") : okStream("- ok");
|
|
629
|
+
await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller });
|
|
630
|
+
notes.length = 0;
|
|
631
|
+
now += 600001;
|
|
632
|
+
streamImpl = model => model === PRIMARY ? errStream("probe failed") : okStream("- rescued");
|
|
633
|
+
expect((await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller }))?.summaryText).toBe("- rescued");
|
|
634
|
+
expect(notes).toEqual(initial === "both-down" ? [{ msg: genericEnter, level: "warning" }] : []);
|
|
635
|
+
notes.length = 0;
|
|
636
|
+
now += 600001;
|
|
637
|
+
streamImpl = () => okStream("- recovered");
|
|
638
|
+
await summarizeBatch(makeBatch(), distinctConfig, ctx, { controller });
|
|
639
|
+
expect(notes).toEqual([{ msg: "pi-condense: summarizer model Primary recovered", level: "info" }]);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
it("blank primary never retries or enters fallback", async () => {
|
|
644
|
+
const attempts: any[] = [];
|
|
645
|
+
streamImpl = model => { attempts.push(model); return okStream(" "); };
|
|
646
|
+
const controller = new FallbackController();
|
|
647
|
+
const notes: Note[] = [];
|
|
648
|
+
expect(await summarizeBatch(makeBatch(), distinctConfig, makeCtx(notes), { controller })).toBeNull();
|
|
649
|
+
expect(attempts).toEqual([PRIMARY]);
|
|
650
|
+
expect(controller.inFallback).toBe(false);
|
|
651
|
+
expect(notes).toEqual([]);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("abort during pending stream rejects without notices", async () => {
|
|
655
|
+
await withTimerClock(async clock => {
|
|
656
|
+
streamImpl = (_model, _input, opts) => hangingStream(opts);
|
|
657
|
+
const notes: Note[] = [];
|
|
658
|
+
const ac = new AbortController();
|
|
659
|
+
const pending = summarizeBatch(makeBatch(), distinctConfig, makeCtx(notes), { controller: new FallbackController(clock.now), signal: ac.signal });
|
|
660
|
+
const rejected = pending.then(() => null, error => error);
|
|
661
|
+
await clock.advance(1);
|
|
662
|
+
ac.abort();
|
|
663
|
+
expect(await rejected).toBeInstanceOf(Error);
|
|
664
|
+
expect(notes).toEqual([]);
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
});
|
package/src/summarizer.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
import { serializeBatchForSummarizer } from "./batch-capture.js";
|
|
12
12
|
import { FallbackController, type FallbackTransition } from "./summarizer-fallback.js";
|
|
13
13
|
|
|
14
|
-
const SYSTEM_PROMPT = `You are summarizing a batch of tool calls made by an AI coding assistant.
|
|
14
|
+
export const SYSTEM_PROMPT = `You are summarizing a batch of tool calls made by an AI coding assistant.
|
|
15
15
|
For each tool call provide:
|
|
16
16
|
- Tool name and a one-sentence description of what it did
|
|
17
17
|
- Key outcome, plus any file paths, identifiers, signatures, or error strings copied verbatim - never reword these
|
|
@@ -153,8 +153,8 @@ async function runOnce(
|
|
|
153
153
|
};
|
|
154
154
|
const timeoutMessage = () =>
|
|
155
155
|
timeoutKind === "ceiling"
|
|
156
|
-
? `
|
|
157
|
-
: `
|
|
156
|
+
? `exceeded ${Math.round(maxMs / 1000)}s ceiling`
|
|
157
|
+
: `stalled (no output for ${Math.round(idleMs / 1000)}s)`;
|
|
158
158
|
|
|
159
159
|
try {
|
|
160
160
|
// Preserve auth classification for physical models without pre-checking
|
|
@@ -273,7 +273,7 @@ async function runOnce(
|
|
|
273
273
|
* session model, and the controller stays sticky in fallback until a
|
|
274
274
|
* per-cooldown probe of the primary succeeds.
|
|
275
275
|
*/
|
|
276
|
-
async function runSummarization(
|
|
276
|
+
export async function runSummarization(
|
|
277
277
|
userMessage: string,
|
|
278
278
|
config: ContextPruneConfig,
|
|
279
279
|
ctx: ExtensionContext,
|
|
@@ -286,10 +286,10 @@ async function runSummarization(
|
|
|
286
286
|
const controller = options.controller;
|
|
287
287
|
const sessionModel = ctx.model;
|
|
288
288
|
|
|
289
|
-
const notifyFailure = (o: { message: string; timedOut?: boolean }) =>
|
|
289
|
+
const notifyFailure = (model: any, o: { message: string; timedOut?: boolean }) =>
|
|
290
290
|
ctx.ui.notify(
|
|
291
291
|
o.timedOut
|
|
292
|
-
? `pi-condense: ${o.message}; summarizer call abandoned`
|
|
292
|
+
? `pi-condense: summarizer ${modelLabel(model)} ${o.message}; summarizer call abandoned`
|
|
293
293
|
: `pruner: summarization failed: ${o.message}`,
|
|
294
294
|
o.timedOut ? "warning" : "error",
|
|
295
295
|
);
|
|
@@ -302,17 +302,18 @@ async function runSummarization(
|
|
|
302
302
|
return r.result;
|
|
303
303
|
case "auth":
|
|
304
304
|
case "transient":
|
|
305
|
-
notifyFailure(r);
|
|
305
|
+
notifyFailure(primary, r);
|
|
306
306
|
return null;
|
|
307
307
|
case "unusable":
|
|
308
308
|
return null;
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
const emit = (t: FallbackTransition) => {
|
|
312
|
+
const emit = (t: FallbackTransition, reason?: string) => {
|
|
313
313
|
if (t === "enter") {
|
|
314
|
+
const failureReason = typeof reason === "string" && reason.length > 0 ? ` (${reason})` : "";
|
|
314
315
|
ctx.ui.notify(
|
|
315
|
-
`pi-condense: summarizer model ${modelLabel(primary)} failing, using session model ${modelLabel(sessionModel)} until it recovers`,
|
|
316
|
+
`pi-condense: summarizer model ${modelLabel(primary)} failing${failureReason}, using session model ${modelLabel(sessionModel)} until it recovers`,
|
|
316
317
|
"warning"
|
|
317
318
|
);
|
|
318
319
|
} else if (t === "recover") {
|
|
@@ -330,24 +331,29 @@ async function runSummarization(
|
|
|
330
331
|
else emit(controller.onFallbackSuccess());
|
|
331
332
|
return r.result;
|
|
332
333
|
case "auth":
|
|
333
|
-
notifyFailure(r); // auth never trips the controller
|
|
334
|
+
notifyFailure(model, r); // auth never trips the controller
|
|
334
335
|
return null;
|
|
335
336
|
case "unusable":
|
|
336
337
|
return null; // probe unusable => stay (no state change)
|
|
337
338
|
case "transient": {
|
|
338
339
|
if (decision.target === "fallback") {
|
|
339
340
|
controller.onFallbackOnlyFail();
|
|
340
|
-
notifyFailure(r);
|
|
341
|
+
notifyFailure(model, r);
|
|
341
342
|
return null;
|
|
342
343
|
}
|
|
343
344
|
// target was primary (initial detection or probe): retry once on the session model.
|
|
344
345
|
const r2 = await runOnce(sessionModel, userMessage, config, ctx, options);
|
|
345
346
|
if (r2.kind === "ok") {
|
|
346
|
-
|
|
347
|
+
const wasInFallback = controller.inFallback;
|
|
348
|
+
emit(controller.onPrimaryFailFallbackOk(decision.wasProbe), wasInFallback ? undefined : r.message);
|
|
347
349
|
return r2.result; // suppress the legacy error notify — fallback rescued the call
|
|
348
350
|
}
|
|
349
351
|
controller.onBothDown();
|
|
350
|
-
|
|
352
|
+
if (r2.kind === "transient" || r2.kind === "auth") {
|
|
353
|
+
notifyFailure(sessionModel, r2);
|
|
354
|
+
} else {
|
|
355
|
+
notifyFailure(model, r);
|
|
356
|
+
}
|
|
351
357
|
return null;
|
|
352
358
|
}
|
|
353
359
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { NestedToolCalls } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Shared types for the context-prune extension.
|
|
3
5
|
*
|
|
@@ -219,8 +221,8 @@ export const RECOVERY_GRACE_PRESETS: { value: string; label: string }[] = [
|
|
|
219
221
|
export const SUMMARIZER_IDLE_TIMEOUT_PRESETS: { value: string; label: string }[] = [
|
|
220
222
|
{ value: "0", label: "0 (disabled)" },
|
|
221
223
|
{ value: "10000", label: "10s" },
|
|
222
|
-
{ value: "20000", label: "20s
|
|
223
|
-
{ value: "45000", label: "45s" },
|
|
224
|
+
{ value: "20000", label: "20s" },
|
|
225
|
+
{ value: "45000", label: "45s (default)" },
|
|
224
226
|
{ value: "90000", label: "90s" },
|
|
225
227
|
];
|
|
226
228
|
|
|
@@ -321,7 +323,7 @@ export interface ContextPruneConfig {
|
|
|
321
323
|
* Reset on every received stream event; armed before the first event so it
|
|
322
324
|
* also bounds time-to-first-token. If no event arrives within this window
|
|
323
325
|
* the call is aborted and classified transient (feeds the outage-fallback
|
|
324
|
-
* retry). 0 disables the idle timer. Default
|
|
326
|
+
* retry). 0 disables the idle timer. Default 45000.
|
|
325
327
|
*/
|
|
326
328
|
summarizerIdleTimeoutMs: number;
|
|
327
329
|
/**
|
|
@@ -569,7 +571,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
|
|
|
569
571
|
quietOversizedSkips: false,
|
|
570
572
|
minBatchChars: 2000,
|
|
571
573
|
recoveryGraceTurns: 3,
|
|
572
|
-
summarizerIdleTimeoutMs:
|
|
574
|
+
summarizerIdleTimeoutMs: 45000,
|
|
573
575
|
summarizerMaxTimeoutMs: 180000,
|
|
574
576
|
protectedTools: [],
|
|
575
577
|
protectedPaths: ["**/skills/**/*.md", "**/gauntlet-overrides.md"],
|
|
@@ -597,6 +599,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
|
|
|
597
599
|
|
|
598
600
|
/** A single tool call + its result as captured from turn_end */
|
|
599
601
|
export interface CapturedToolCall {
|
|
602
|
+
nestedCalls?: NestedToolCalls;
|
|
600
603
|
toolCallId: string;
|
|
601
604
|
toolName: string;
|
|
602
605
|
args: Record<string, unknown>;
|