pi-condense 2.2.1 → 2.4.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 +8 -0
- package/PRUNING.md +3 -0
- package/README.md +113 -191
- package/index.ts +6 -0
- package/package.json +2 -2
- package/src/chain-compressor.test.ts +33 -1
- package/src/chain-compressor.ts +9 -2
- package/src/commands.ts +95 -1
- package/src/config.test.ts +101 -0
- package/src/config.ts +31 -6
- package/src/pruner.test.ts +117 -0
- package/src/pruner.ts +6 -0
- package/src/query-tool.ts +2 -1
- package/src/recovery-grace.test.ts +35 -0
- package/src/recovery-grace.ts +33 -0
- package/src/summarizer-wiring.test.ts +144 -2
- package/src/summarizer.ts +71 -10
- package/src/types.ts +67 -0
|
@@ -2,11 +2,11 @@ import { describe, it, expect, mock } from "bun:test";
|
|
|
2
2
|
|
|
3
3
|
// Stub pi-ai's `stream` so runSummarization can be exercised without a network
|
|
4
4
|
// call. `streamImpl` is swapped per test to simulate primary/fallback outcomes.
|
|
5
|
-
let streamImpl: (model: any) => any = () => {
|
|
5
|
+
let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
|
|
6
6
|
throw new Error("streamImpl not set");
|
|
7
7
|
};
|
|
8
8
|
mock.module("@earendil-works/pi-ai", () => ({
|
|
9
|
-
stream: (
|
|
9
|
+
stream: (...args: any[]) => streamImpl(...args),
|
|
10
10
|
}));
|
|
11
11
|
|
|
12
12
|
const { summarizeBatch } = await import("./summarizer.js");
|
|
@@ -45,6 +45,61 @@ function errStream(message: string) {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Hangs until `opts.signal` (the combined caller+timeout signal runOnce
|
|
49
|
+
// passes to stream()) aborts. With no signal it never settles.
|
|
50
|
+
function hangingStream(opts: any) {
|
|
51
|
+
const signal: AbortSignal | undefined = opts?.signal;
|
|
52
|
+
const untilAbort = () =>
|
|
53
|
+
new Promise<never>((_, reject) => {
|
|
54
|
+
if (!signal) return; // no signal => never settles
|
|
55
|
+
if (signal.aborted) return reject(new Error("aborted"));
|
|
56
|
+
signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
async *[Symbol.asyncIterator]() {
|
|
60
|
+
await untilAbort();
|
|
61
|
+
},
|
|
62
|
+
async result() {
|
|
63
|
+
return untilAbort();
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Emits `events` thinking_delta events spaced `gapMs` apart, then completes
|
|
69
|
+
// successfully — UNLESS `opts.signal` aborts mid-drip, in which case the
|
|
70
|
+
// current sleep rejects, exactly like a real provider stream cancelling on
|
|
71
|
+
// abort. This is what gives the idle-reset test teeth: if runOnce's in-loop
|
|
72
|
+
// bumpIdle() is ever removed, the idle timer fires at the configured window
|
|
73
|
+
// and the combined signal aborts, so this stream rejects instead of
|
|
74
|
+
// completing — the test then fails instead of passing vacuously.
|
|
75
|
+
function drippingStream(opts: any, text: string, events: number, gapMs: number) {
|
|
76
|
+
const signal: AbortSignal | undefined = opts?.signal;
|
|
77
|
+
const sleepOrAbort = (ms: number) =>
|
|
78
|
+
new Promise<void>((resolve, reject) => {
|
|
79
|
+
if (signal?.aborted) return reject(new Error("aborted"));
|
|
80
|
+
const timer = setTimeout(resolve, ms);
|
|
81
|
+
signal?.addEventListener(
|
|
82
|
+
"abort",
|
|
83
|
+
() => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
reject(new Error("aborted"));
|
|
86
|
+
},
|
|
87
|
+
{ once: true }
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
async *[Symbol.asyncIterator]() {
|
|
92
|
+
for (let i = 0; i < events; i++) {
|
|
93
|
+
await sleepOrAbort(gapMs);
|
|
94
|
+
yield { type: "thinking_delta" };
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
async result() {
|
|
98
|
+
return { stopReason: "stop", content: [{ type: "text", text }], usage: USAGE };
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
48
103
|
interface Note {
|
|
49
104
|
msg: string;
|
|
50
105
|
level: string;
|
|
@@ -163,3 +218,90 @@ describe("runSummarization wiring — abort", () => {
|
|
|
163
218
|
).rejects.toThrow();
|
|
164
219
|
});
|
|
165
220
|
});
|
|
221
|
+
|
|
222
|
+
describe("runSummarization wiring — timeouts", () => {
|
|
223
|
+
it("idle timeout (default model): transient warning, returns null", async () => {
|
|
224
|
+
streamImpl = (_m, _i, opts) => hangingStream(opts);
|
|
225
|
+
const notes: Note[] = [];
|
|
226
|
+
const ctx = makeCtx(notes);
|
|
227
|
+
const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 20, summarizerMaxTimeoutMs: 0 };
|
|
228
|
+
const r = await summarizeBatch(makeBatch(), cfg, ctx, {});
|
|
229
|
+
expect(r).toBeNull();
|
|
230
|
+
const warnings = notes.filter((n) => n.level === "warning");
|
|
231
|
+
expect(warnings).toHaveLength(1);
|
|
232
|
+
expect(warnings[0].msg).toMatch(/stalled/);
|
|
233
|
+
expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("ceiling timeout (idle disabled): transient warning mentioning ceiling", async () => {
|
|
237
|
+
streamImpl = (_m, _i, opts) => hangingStream(opts);
|
|
238
|
+
const notes: Note[] = [];
|
|
239
|
+
const ctx = makeCtx(notes);
|
|
240
|
+
const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 20 };
|
|
241
|
+
const r = await summarizeBatch(makeBatch(), cfg, ctx, {});
|
|
242
|
+
expect(r).toBeNull();
|
|
243
|
+
const warnings = notes.filter((n) => n.level === "warning");
|
|
244
|
+
expect(warnings).toHaveLength(1);
|
|
245
|
+
expect(warnings[0].msg).toMatch(/ceiling/);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("option B: primary idle-times-out, session model rescues", async () => {
|
|
249
|
+
streamImpl = (model, _i, opts) => (model.id === PRIMARY.id ? hangingStream(opts) : okStream("- rescued"));
|
|
250
|
+
const notes: Note[] = [];
|
|
251
|
+
const ctx = makeCtx(notes);
|
|
252
|
+
const controller = new FallbackController();
|
|
253
|
+
const cfg = { ...distinctConfig, summarizerIdleTimeoutMs: 20 };
|
|
254
|
+
const r = await summarizeBatch(makeBatch(), cfg, ctx, { controller });
|
|
255
|
+
expect(r?.summaryText).toBe("- rescued");
|
|
256
|
+
expect(controller.inFallback).toBe(true);
|
|
257
|
+
expect(notes.filter((n) => n.level === "warning")).toHaveLength(1); // generic "enter" fallback warning
|
|
258
|
+
expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("both time out: null, both-down notice at warning severity", async () => {
|
|
262
|
+
streamImpl = (_m, _i, opts) => hangingStream(opts);
|
|
263
|
+
const notes: Note[] = [];
|
|
264
|
+
const ctx = makeCtx(notes);
|
|
265
|
+
const controller = new FallbackController();
|
|
266
|
+
const cfg = { ...distinctConfig, summarizerIdleTimeoutMs: 20 };
|
|
267
|
+
const r = await summarizeBatch(makeBatch(), cfg, ctx, { controller });
|
|
268
|
+
expect(r).toBeNull();
|
|
269
|
+
expect(notes.filter((n) => n.level === "warning")).toHaveLength(1);
|
|
270
|
+
expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("pre-aborted signal is not a timeout (throws, no warning)", async () => {
|
|
274
|
+
streamImpl = (_m, _i, opts) => hangingStream(opts);
|
|
275
|
+
const notes: Note[] = [];
|
|
276
|
+
const ctx = makeCtx(notes);
|
|
277
|
+
const ac = new AbortController();
|
|
278
|
+
ac.abort();
|
|
279
|
+
const cfg = { ...distinctConfig, summarizerIdleTimeoutMs: 20 };
|
|
280
|
+
await expect(summarizeBatch(makeBatch(), cfg, ctx, { signal: ac.signal })).rejects.toThrow();
|
|
281
|
+
expect(notes.filter((n) => n.level === "warning")).toHaveLength(0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("both timeouts disabled: okStream succeeds unchanged", async () => {
|
|
285
|
+
streamImpl = () => okStream("- ok");
|
|
286
|
+
const notes: Note[] = [];
|
|
287
|
+
const ctx = makeCtx(notes);
|
|
288
|
+
const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 0 };
|
|
289
|
+
const r = await summarizeBatch(makeBatch(), cfg, ctx, {});
|
|
290
|
+
expect(r?.summaryText).toBe("- ok");
|
|
291
|
+
expect(notes).toHaveLength(0);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("runSummarization wiring — idle reset keeps a flowing stream alive", () => {
|
|
296
|
+
it("does not time out while events keep arriving within the idle window", async () => {
|
|
297
|
+
// 6 events, 10ms apart = 60ms total > 25ms idle window; only survives if
|
|
298
|
+
// the idle timer resets on every event (bumpIdle() inside the loop).
|
|
299
|
+
streamImpl = (_m, _i, opts) => drippingStream(opts, "- flowing summary", 6, 10);
|
|
300
|
+
const notes: Note[] = [];
|
|
301
|
+
const ctx = makeCtx(notes);
|
|
302
|
+
const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 25, summarizerMaxTimeoutMs: 0 };
|
|
303
|
+
const r = await summarizeBatch(makeBatch(), cfg, ctx, {});
|
|
304
|
+
expect(r?.summaryText).toBe("- flowing summary");
|
|
305
|
+
expect(notes.filter((n) => n.level === "warning")).toHaveLength(0);
|
|
306
|
+
});
|
|
307
|
+
});
|
package/src/summarizer.ts
CHANGED
|
@@ -90,7 +90,7 @@ type RunOutcome =
|
|
|
90
90
|
| { kind: "ok"; result: SummarizeResult }
|
|
91
91
|
| { kind: "auth"; message: string }
|
|
92
92
|
| { kind: "unusable" }
|
|
93
|
-
| { kind: "transient"; message: string };
|
|
93
|
+
| { kind: "transient"; message: string; timedOut?: boolean };
|
|
94
94
|
|
|
95
95
|
/** Human label for a model in notify text: prefer name, fall back to provider/id. */
|
|
96
96
|
function modelLabel(model: any): string {
|
|
@@ -98,6 +98,14 @@ function modelLabel(model: any): string {
|
|
|
98
98
|
return model.name || `${model.provider}/${model.id}`;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** Combines any present abort signals into one; undefined if none are given. */
|
|
102
|
+
function combineSignals(...signals: (AbortSignal | undefined)[]): AbortSignal | undefined {
|
|
103
|
+
const present = signals.filter((s): s is AbortSignal => !!s);
|
|
104
|
+
if (present.length === 0) return undefined;
|
|
105
|
+
if (present.length === 1) return present[0];
|
|
106
|
+
return AbortSignal.any(present); // Node 20+; host runtime is node 24.5.0
|
|
107
|
+
}
|
|
108
|
+
|
|
101
109
|
/**
|
|
102
110
|
* One summarization attempt against a specific model. Returns a classified
|
|
103
111
|
* outcome instead of throwing (except aborts, which propagate so flushPending
|
|
@@ -113,6 +121,29 @@ async function runOnce(
|
|
|
113
121
|
ctx: ExtensionContext,
|
|
114
122
|
options: SummarizeBatchOptions
|
|
115
123
|
): Promise<RunOutcome> {
|
|
124
|
+
const idleMs = config.summarizerIdleTimeoutMs;
|
|
125
|
+
const maxMs = config.summarizerMaxTimeoutMs;
|
|
126
|
+
const timeoutController = new AbortController();
|
|
127
|
+
let timedOut = false;
|
|
128
|
+
let timeoutKind: "idle" | "ceiling" | null = null;
|
|
129
|
+
let idleTimerId: ReturnType<typeof setTimeout> | null = null;
|
|
130
|
+
let ceilingTimerId: ReturnType<typeof setTimeout> | null = null;
|
|
131
|
+
|
|
132
|
+
const bumpIdle = () => {
|
|
133
|
+
if (idleTimerId !== null) clearTimeout(idleTimerId);
|
|
134
|
+
if (idleMs > 0) {
|
|
135
|
+
idleTimerId = setTimeout(() => {
|
|
136
|
+
timedOut = true;
|
|
137
|
+
timeoutKind = "idle";
|
|
138
|
+
timeoutController.abort();
|
|
139
|
+
}, idleMs);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const timeoutMessage = () =>
|
|
143
|
+
timeoutKind === "ceiling"
|
|
144
|
+
? `summarizer ${modelLabel(model)} exceeded ${Math.round(maxMs / 1000)}s ceiling`
|
|
145
|
+
: `summarizer ${modelLabel(model)} stalled (no output for ${Math.round(idleMs / 1000)}s)`;
|
|
146
|
+
|
|
116
147
|
try {
|
|
117
148
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
118
149
|
if (!auth.ok) {
|
|
@@ -120,8 +151,8 @@ async function runOnce(
|
|
|
120
151
|
return { kind: "auth", message: authMessage };
|
|
121
152
|
}
|
|
122
153
|
|
|
123
|
-
// Pass the
|
|
124
|
-
// when the user presses Esc
|
|
154
|
+
// Pass the combined signal so the underlying fetch is cancelled immediately
|
|
155
|
+
// either when the user presses Esc, or when an idle/ceiling timeout fires.
|
|
125
156
|
const responseStream = stream(
|
|
126
157
|
model,
|
|
127
158
|
{
|
|
@@ -133,9 +164,25 @@ async function runOnce(
|
|
|
133
164
|
},
|
|
134
165
|
],
|
|
135
166
|
},
|
|
136
|
-
{
|
|
167
|
+
{
|
|
168
|
+
apiKey: auth.apiKey,
|
|
169
|
+
headers: auth.headers,
|
|
170
|
+
signal: combineSignals(options.signal, timeoutController.signal),
|
|
171
|
+
...summarizerThinkingOptions(config),
|
|
172
|
+
}
|
|
137
173
|
);
|
|
138
174
|
|
|
175
|
+
// Ceiling arms once at call start; idle arms/resets on every stream event
|
|
176
|
+
// (including before the first one, so it also bounds time-to-first-token).
|
|
177
|
+
if (maxMs > 0) {
|
|
178
|
+
ceilingTimerId = setTimeout(() => {
|
|
179
|
+
timedOut = true;
|
|
180
|
+
timeoutKind ??= "ceiling";
|
|
181
|
+
timeoutController.abort();
|
|
182
|
+
}, maxMs);
|
|
183
|
+
}
|
|
184
|
+
bumpIdle();
|
|
185
|
+
|
|
139
186
|
let lastReportedChars = -1;
|
|
140
187
|
options.onTextProgress?.(0);
|
|
141
188
|
const reportTextProgress = (message: AssistantMessage) => {
|
|
@@ -147,6 +194,10 @@ async function runOnce(
|
|
|
147
194
|
};
|
|
148
195
|
|
|
149
196
|
for await (const event of responseStream) {
|
|
197
|
+
// Reset idle on ANY event (text_* and thinking_*), not just text — a
|
|
198
|
+
// reasoning-heavy model stays alive via thinking_delta and is never
|
|
199
|
+
// false-aborted for being quiet on text while it reasons.
|
|
200
|
+
bumpIdle();
|
|
150
201
|
// Belt-and-suspenders: break early when signal fires mid-stream.
|
|
151
202
|
if (options.signal?.aborted) break;
|
|
152
203
|
if (event.type === "text_start" || event.type === "text_delta" || event.type === "text_end") {
|
|
@@ -167,6 +218,7 @@ async function runOnce(
|
|
|
167
218
|
throw new Error("summarize: stream stopped with reason aborted");
|
|
168
219
|
}
|
|
169
220
|
if (response.stopReason === "error") {
|
|
221
|
+
if (timedOut) return { kind: "transient", message: timeoutMessage(), timedOut: true };
|
|
170
222
|
return { kind: "transient", message: response.errorMessage ?? "Summarizer stopped with reason: error" };
|
|
171
223
|
}
|
|
172
224
|
|
|
@@ -182,7 +234,11 @@ async function runOnce(
|
|
|
182
234
|
// Propagate abort errors upward so flushPending can check signal.aborted
|
|
183
235
|
// and return { ok: false, reason: "aborted" } without showing a UI error.
|
|
184
236
|
if (options.signal?.aborted) throw err;
|
|
237
|
+
if (timedOut) return { kind: "transient", message: timeoutMessage(), timedOut: true };
|
|
185
238
|
return { kind: "transient", message: err.message };
|
|
239
|
+
} finally {
|
|
240
|
+
if (idleTimerId !== null) clearTimeout(idleTimerId);
|
|
241
|
+
if (ceilingTimerId !== null) clearTimeout(ceilingTimerId);
|
|
186
242
|
}
|
|
187
243
|
}
|
|
188
244
|
|
|
@@ -211,8 +267,13 @@ async function runSummarization(
|
|
|
211
267
|
const controller = options.controller;
|
|
212
268
|
const sessionModel = ctx.model;
|
|
213
269
|
|
|
214
|
-
const
|
|
215
|
-
ctx.ui.notify(
|
|
270
|
+
const notifyFailure = (o: { message: string; timedOut?: boolean }) =>
|
|
271
|
+
ctx.ui.notify(
|
|
272
|
+
o.timedOut
|
|
273
|
+
? `pi-condense: ${o.message}; summarizer call abandoned`
|
|
274
|
+
: `pruner: summarization failed: ${o.message}`,
|
|
275
|
+
o.timedOut ? "warning" : "error",
|
|
276
|
+
);
|
|
216
277
|
|
|
217
278
|
// No controller or no distinct fallback: single attempt, legacy behavior.
|
|
218
279
|
if (!controller || !FallbackController.hasDistinctFallback(primary, sessionModel)) {
|
|
@@ -222,7 +283,7 @@ async function runSummarization(
|
|
|
222
283
|
return r.result;
|
|
223
284
|
case "auth":
|
|
224
285
|
case "transient":
|
|
225
|
-
|
|
286
|
+
notifyFailure(r);
|
|
226
287
|
return null;
|
|
227
288
|
case "unusable":
|
|
228
289
|
return null;
|
|
@@ -250,14 +311,14 @@ async function runSummarization(
|
|
|
250
311
|
else emit(controller.onFallbackSuccess());
|
|
251
312
|
return r.result;
|
|
252
313
|
case "auth":
|
|
253
|
-
|
|
314
|
+
notifyFailure(r); // auth never trips the controller
|
|
254
315
|
return null;
|
|
255
316
|
case "unusable":
|
|
256
317
|
return null; // probe unusable => stay (no state change)
|
|
257
318
|
case "transient": {
|
|
258
319
|
if (decision.target === "fallback") {
|
|
259
320
|
controller.onFallbackOnlyFail();
|
|
260
|
-
|
|
321
|
+
notifyFailure(r);
|
|
261
322
|
return null;
|
|
262
323
|
}
|
|
263
324
|
// target was primary (initial detection or probe): retry once on the session model.
|
|
@@ -267,7 +328,7 @@ async function runSummarization(
|
|
|
267
328
|
return r2.result; // suppress the legacy error notify — fallback rescued the call
|
|
268
329
|
}
|
|
269
330
|
controller.onBothDown();
|
|
270
|
-
|
|
331
|
+
notifyFailure(r2.kind === "transient" || r2.kind === "auth" ? r2 : r);
|
|
271
332
|
return null;
|
|
272
333
|
}
|
|
273
334
|
}
|
package/src/types.ts
CHANGED
|
@@ -78,6 +78,10 @@ export const CUSTOM_TYPE_DEDUP_ALIAS = "context-prune-dedup-alias";
|
|
|
78
78
|
*/
|
|
79
79
|
export const CUSTOM_TYPE_CHAIN = "context-prune-chain";
|
|
80
80
|
|
|
81
|
+
/** The registered name of the recovery tool (src/query-tool.ts). Shared so the
|
|
82
|
+
* grace checks in pruner.ts / chain-compressor.ts cannot drift from registration. */
|
|
83
|
+
export const QUERY_TOOL_NAME = "context_tree_query";
|
|
84
|
+
|
|
81
85
|
/** pi.events channel for cross-extension cost contributions (an aggregator like pi-subagents folds these into one total). */
|
|
82
86
|
export const EXTERNAL_COST_CHANNEL = "cost:external";
|
|
83
87
|
|
|
@@ -185,6 +189,43 @@ export const MIN_BATCH_CHARS_PRESETS: { value: string; label: string }[] = [
|
|
|
185
189
|
{ value: "5000", label: "5000" },
|
|
186
190
|
];
|
|
187
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Cycling presets for the `recoveryGraceTurns` setting in the SettingsList.
|
|
194
|
+
* Stored as strings; converted to number when applied. "0" disables the grace
|
|
195
|
+
* (recovery output stubs immediately, pre-feature behavior).
|
|
196
|
+
*/
|
|
197
|
+
export const RECOVERY_GRACE_PRESETS: { value: string; label: string }[] = [
|
|
198
|
+
{ value: "0", label: "0 (disabled)" },
|
|
199
|
+
{ value: "1", label: "1" },
|
|
200
|
+
{ value: "3", label: "3 (default)" },
|
|
201
|
+
{ value: "5", label: "5" },
|
|
202
|
+
{ value: "8", label: "8" },
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Cycling presets for `summarizerIdleTimeoutMs` (stored as strings; the
|
|
207
|
+
* settings UI cycles string values). "0" is the disabling sentinel.
|
|
208
|
+
*/
|
|
209
|
+
export const SUMMARIZER_IDLE_TIMEOUT_PRESETS: { value: string; label: string }[] = [
|
|
210
|
+
{ value: "0", label: "0 (disabled)" },
|
|
211
|
+
{ value: "10000", label: "10s" },
|
|
212
|
+
{ value: "20000", label: "20s (default)" },
|
|
213
|
+
{ value: "45000", label: "45s" },
|
|
214
|
+
{ value: "90000", label: "90s" },
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Cycling presets for `summarizerMaxTimeoutMs` (stored as strings). "0" is
|
|
219
|
+
* the disabling sentinel - no total-duration ceiling.
|
|
220
|
+
*/
|
|
221
|
+
export const SUMMARIZER_MAX_TIMEOUT_PRESETS: { value: string; label: string }[] = [
|
|
222
|
+
{ value: "0", label: "0 (disabled)" },
|
|
223
|
+
{ value: "120000", label: "120s" },
|
|
224
|
+
{ value: "180000", label: "180s (default)" },
|
|
225
|
+
{ value: "300000", label: "300s" },
|
|
226
|
+
{ value: "600000", label: "600s" },
|
|
227
|
+
];
|
|
228
|
+
|
|
188
229
|
/**
|
|
189
230
|
* Cycling presets for the `autoBudgetThreshold` setting (stored as strings;
|
|
190
231
|
* the settings UI cycles string values). "0" is the disabled sentinel → null.
|
|
@@ -256,6 +297,29 @@ export interface ContextPruneConfig {
|
|
|
256
297
|
* Default: 1000.
|
|
257
298
|
*/
|
|
258
299
|
minBatchChars: number;
|
|
300
|
+
/**
|
|
301
|
+
* User-turn-groups a `context_tree_query` (recovery) output stays verbatim in
|
|
302
|
+
* context after recovery, before it reverts to the normal stub. Bounds the
|
|
303
|
+
* retrieve->re-stub->re-query loop without permanent retention. 0 disables
|
|
304
|
+
* (recovery output stubs immediately). Enforced at render time in pruner.ts
|
|
305
|
+
* (Phase 1) and chain-compressor.ts (eligibility), not at capture.
|
|
306
|
+
*/
|
|
307
|
+
recoveryGraceTurns: number;
|
|
308
|
+
/**
|
|
309
|
+
* Idle (inactivity) timeout for a single summarizer stream call, in ms.
|
|
310
|
+
* Reset on every received stream event; armed before the first event so it
|
|
311
|
+
* also bounds time-to-first-token. If no event arrives within this window
|
|
312
|
+
* the call is aborted and classified transient (feeds the outage-fallback
|
|
313
|
+
* retry). 0 disables the idle timer. Default 20000.
|
|
314
|
+
*/
|
|
315
|
+
summarizerIdleTimeoutMs: number;
|
|
316
|
+
/**
|
|
317
|
+
* Total-duration ceiling for a single summarizer stream call, in ms. Armed
|
|
318
|
+
* once at call start, never reset - a hard upper bound catching a stream
|
|
319
|
+
* that keeps dribbling events but never completes. Same transient/warning
|
|
320
|
+
* handling as the idle timeout. 0 disables the ceiling. Default 180000.
|
|
321
|
+
*/
|
|
322
|
+
summarizerMaxTimeoutMs: number;
|
|
259
323
|
/**
|
|
260
324
|
* Tool names whose outputs must NEVER be pruned or summarized. Tool calls
|
|
261
325
|
* with matching `toolName` are filtered out of the pruning capture path so
|
|
@@ -454,6 +518,9 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
|
|
|
454
518
|
batchingMode: "turn",
|
|
455
519
|
quietOversizedSkips: false,
|
|
456
520
|
minBatchChars: 1000,
|
|
521
|
+
recoveryGraceTurns: 3,
|
|
522
|
+
summarizerIdleTimeoutMs: 20000,
|
|
523
|
+
summarizerMaxTimeoutMs: 180000,
|
|
457
524
|
protectedTools: [],
|
|
458
525
|
protectedPaths: ["**/skills/**/*.md"],
|
|
459
526
|
chainCompression: {
|