pi-openai-toolkit 0.8.0 → 0.10.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.
@@ -0,0 +1,133 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ boundReviewText,
4
+ MAX_TRANSCRIPT_CHARS,
5
+ MAX_TRANSCRIPT_ENTRY_CHARS,
6
+ MAX_TRANSCRIPT_RECENT_ENTRIES,
7
+ MAX_TRANSCRIPT_TOOL_CHARS,
8
+ TRUNCATION_MARKER,
9
+ } from "./types";
10
+
11
+ /**
12
+ * A transcript line already reduced to text, with the role kept so the reviewer can
13
+ * tell whose words these are. `[user]` is the only role that carries authorization.
14
+ */
15
+ export type TranscriptLine = {
16
+ role: "user" | "assistant" | "tool";
17
+ text: string;
18
+ };
19
+
20
+ function textOfContent(content: unknown): string {
21
+ if (typeof content === "string") return content;
22
+ if (!Array.isArray(content)) return "";
23
+ return content
24
+ .map((block) => {
25
+ if (!block || typeof block !== "object") return "";
26
+ const record = block as Record<string, unknown>;
27
+ if (record.type === "text" && typeof record.text === "string") return record.text;
28
+ if (record.type === "image") return "[image omitted]";
29
+ if (record.type === "toolCall" && typeof record.name === "string") {
30
+ const args = record.arguments;
31
+ return `${record.name} ${typeof args === "object" && args ? JSON.stringify(args) : ""}`.trim();
32
+ }
33
+ return "";
34
+ })
35
+ .filter(Boolean)
36
+ .join("\n")
37
+ .trim();
38
+ }
39
+
40
+ /**
41
+ * Flatten session entries into reviewer lines. Non-message entries are skipped,
42
+ * except a compaction summary, which is kept as a `[user]`-adjacent context marker
43
+ * because it is the only surviving view of the older conversation.
44
+ */
45
+ export function sessionEntriesToLines(entries: readonly SessionEntry[]): TranscriptLine[] {
46
+ const lines: TranscriptLine[] = [];
47
+ for (const entry of entries) {
48
+ if (entry.type === "compaction" && typeof entry.summary === "string") {
49
+ const summary = boundReviewText(entry.summary, MAX_TRANSCRIPT_ENTRY_CHARS);
50
+ if (summary) lines.push({ role: "assistant", text: `[earlier conversation summary] ${summary}` });
51
+ continue;
52
+ }
53
+ if (entry.type !== "message") continue;
54
+ const message = entry.message as { role?: unknown; content?: unknown };
55
+ const text = textOfContent(message.content);
56
+ if (!text) continue;
57
+ if (message.role === "user") lines.push({ role: "user", text });
58
+ else if (message.role === "assistant") lines.push({ role: "assistant", text });
59
+ else if (message.role === "toolResult") lines.push({ role: "tool", text });
60
+ }
61
+ return lines;
62
+ }
63
+
64
+ function renderLine(index: number, line: TranscriptLine): string {
65
+ const body = boundReviewText(line.text, MAX_TRANSCRIPT_ENTRY_CHARS);
66
+ return `[${index}] [${line.role}]: ${body}`;
67
+ }
68
+
69
+ /**
70
+ * Build a bounded transcript the way Codex's guardian does: user turns are the
71
+ * anchors and are protected, tool and assistant evidence gets its own smaller
72
+ * budget so verbose command output cannot crowd out the human conversation that
73
+ * actually establishes authorization.
74
+ *
75
+ * Selection order is newest-first, then rendered in chronological order.
76
+ */
77
+ export function buildReviewTranscript(
78
+ lines: readonly TranscriptLine[],
79
+ budgets: {
80
+ maxTotalChars?: number;
81
+ maxToolChars?: number;
82
+ maxRecentEntries?: number;
83
+ } = {},
84
+ ): { text: string; omitted: boolean } {
85
+ const maxTotalChars = budgets.maxTotalChars ?? MAX_TRANSCRIPT_CHARS;
86
+ const maxToolChars = budgets.maxToolChars ?? MAX_TRANSCRIPT_TOOL_CHARS;
87
+ const maxRecentEntries = budgets.maxRecentEntries ?? MAX_TRANSCRIPT_RECENT_ENTRIES;
88
+
89
+ const recent = lines.slice(-Math.max(1, maxRecentEntries));
90
+ const omitted = recent.length < lines.length;
91
+
92
+ const userLines: number[] = [];
93
+ const otherLines: number[] = [];
94
+ recent.forEach((line, index) => {
95
+ if (line.role === "user") userLines.push(index);
96
+ else otherLines.push(index);
97
+ });
98
+
99
+ const selected = new Set<number>();
100
+ let userChars = 0;
101
+ // The first user turn carries the task; the latest carries the immediate ask.
102
+ for (const index of [userLines[0]!, userLines[userLines.length - 1]!, ...userLines.slice(1, -1).reverse()]) {
103
+ if (index === undefined || selected.has(index)) continue;
104
+ const cost = recent[index]!.text.length;
105
+ if (userChars + cost > maxTotalChars) break;
106
+ selected.add(index);
107
+ userChars += cost;
108
+ }
109
+
110
+ let toolChars = 0;
111
+ for (const index of [...otherLines].reverse()) {
112
+ const cost = recent[index]!.text.length;
113
+ if (toolChars + cost > maxToolChars) continue;
114
+ if (userChars + toolChars + cost > maxTotalChars) continue;
115
+ selected.add(index);
116
+ toolChars += cost;
117
+ }
118
+
119
+ const ordered = [...selected].sort((a, b) => a - b);
120
+ const rendered = ordered.map((index) => renderLine(index + 1, recent[index]!));
121
+ if (omitted) rendered.unshift(`${TRUNCATION_MARKER} earlier conversation entries were omitted.`);
122
+ return { text: rendered.join("\n"), omitted };
123
+ }
124
+
125
+ /**
126
+ * Build the reviewer transcript straight from session entries.
127
+ */
128
+ export function transcriptFromEntries(
129
+ entries: readonly SessionEntry[],
130
+ budgets?: Parameters<typeof buildReviewTranscript>[1],
131
+ ): { text: string; omitted: boolean } {
132
+ return buildReviewTranscript(sessionEntriesToLines(entries), budgets);
133
+ }
@@ -0,0 +1,137 @@
1
+ import type { AutoModeConfig } from "../types";
2
+
3
+ export const AUTO_MODE_ENTRY_TYPE = "pi-openai-toolkit.auto-mode-decision.v1";
4
+ export const AUTO_MODE_STATUS_KEY = "pi-openai-toolkit:auto-mode";
5
+ export const AUTO_MODE_FLAG = "auto";
6
+ export const AUTO_MODE_COMMAND = "auto";
7
+
8
+ /**
9
+ * Built-in tools that change state outside the conversation. Everything else is
10
+ * read-only by default and only reviewed when the user opts in through
11
+ * `autoMode.extraTools` or `gate: "all"`.
12
+ */
13
+ export const SIDE_EFFECT_TOOLS = ["bash", "write", "edit"] as const;
14
+
15
+ export const MAX_REVIEW_INPUT_CHARS = 8_000;
16
+ export const MAX_REVIEW_INTENT_CHARS = 2_000;
17
+ export const MAX_REVIEW_REASON_CHARS = 600;
18
+ export const MAX_REVIEW_MODEL_KEY_CHARS = 256;
19
+
20
+ /** Budgets for the compact transcript handed to the reviewer. */
21
+ export const MAX_TRANSCRIPT_CHARS = 24_000;
22
+ export const MAX_TRANSCRIPT_TOOL_CHARS = 12_000;
23
+ export const MAX_TRANSCRIPT_ENTRY_CHARS = 2_000;
24
+ export const MAX_TRANSCRIPT_RECENT_ENTRIES = 40;
25
+ export const TRUNCATION_MARKER = "<truncated />";
26
+
27
+ /** Risk of the planned action itself, as scored by the reviewer. */
28
+ export type RiskLevel = "low" | "medium" | "high" | "critical";
29
+
30
+ /** How far the observed conversation actually authorizes that action. */
31
+ export type UserAuthorization = "unknown" | "low" | "medium" | "high";
32
+
33
+ /**
34
+ * Why a review could not be completed. Kept separate from `deny` on purpose: an
35
+ * infrastructure failure must never be reported to the agent as a safety verdict.
36
+ */
37
+ export type ReviewFailureCause =
38
+ | "not-configured"
39
+ | "timeout"
40
+ | "cancelled"
41
+ | "provider-error"
42
+ | "invalid-output";
43
+
44
+ /**
45
+ * The reviewer's full verdict. Only `outcome` is required from the model; the
46
+ * remaining fields are back-filled by `parseReviewVerdict` so that a terse
47
+ * `{"outcome":"allow"}` answer is still a usable, recordable decision.
48
+ */
49
+ export type GuardianVerdict = {
50
+ outcome: "allow" | "deny";
51
+ riskLevel: RiskLevel;
52
+ userAuthorization: UserAuthorization;
53
+ rationale: string;
54
+ };
55
+
56
+ /**
57
+ * Result of asking the reviewer model about one pending tool call.
58
+ * `unavailable` means the review could not be completed at all; it is never
59
+ * treated as approval.
60
+ */
61
+ export type ReviewOutcome =
62
+ | {
63
+ kind: "allow";
64
+ verdict: GuardianVerdict;
65
+ reviewerModel: string;
66
+ evidenceRounds: number;
67
+ }
68
+ | {
69
+ kind: "deny";
70
+ verdict: GuardianVerdict;
71
+ reviewerModel: string;
72
+ evidenceRounds: number;
73
+ }
74
+ | { kind: "unavailable"; reason: string; cause: ReviewFailureCause };
75
+
76
+ /** Non-blocking pre-score produced by the trajectory classifier. */
77
+ export type ClassifierRisk = "low" | "high";
78
+
79
+ /**
80
+ * Why the classifier's cached score could or could not satisfy a gated call.
81
+ * Mirrors the deferral reasons the blocking reviewer exists to cover: anything
82
+ * other than `low_risk` falls through to the synchronous review.
83
+ */
84
+ export type FastDecisionReason =
85
+ | "low_risk"
86
+ | "elevated_risk"
87
+ | "stale_score"
88
+ | "scoring_failure"
89
+ | "missing_score"
90
+ | "authorization_changed"
91
+ | "requires_synchronous_review";
92
+
93
+ export type AutoModeDecisionSource =
94
+ | "reviewer"
95
+ | "classifier"
96
+ | "human"
97
+ | "policy"
98
+ | "circuit-breaker";
99
+
100
+ export type AutoModeDecisionRecord = {
101
+ timestamp: number;
102
+ toolName: string;
103
+ toolCallId: string;
104
+ decision:
105
+ | "allow"
106
+ | "deny"
107
+ | "unavailable-allowed"
108
+ | "unavailable-blocked"
109
+ | "turn-interrupted";
110
+ reason: string;
111
+ reviewerModel?: string;
112
+ source: AutoModeDecisionSource;
113
+ /** Present on reviewer verdicts; omitted for classifier fast paths. */
114
+ riskLevel?: RiskLevel;
115
+ userAuthorization?: UserAuthorization;
116
+ evidenceRounds?: number;
117
+ /** Only present when `source` is `classifier`. */
118
+ fastDecision?: FastDecisionReason;
119
+ };
120
+
121
+ export type AutoModeModelRef = {
122
+ provider?: string;
123
+ id?: string;
124
+ };
125
+
126
+ export type AutoModeGateSource = AutoModeConfig["gate"];
127
+
128
+ /**
129
+ * Truncate to a bounded single-line-safe diagnostic. Reviewer reasons and tool
130
+ * arguments both originate from untrusted text, so every persisted copy is capped.
131
+ */
132
+ export function boundReviewText(value: unknown, maxChars: number): string {
133
+ const text = typeof value === "string" ? value : String(value ?? "");
134
+ const collapsed = text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim();
135
+ if (collapsed.length <= maxChars) return collapsed;
136
+ return `${collapsed.slice(0, Math.max(0, maxChars - 3))}...`;
137
+ }
package/src/config.ts CHANGED
@@ -6,14 +6,32 @@ import {
6
6
  DEFAULT_COMPACTION_CONFIG,
7
7
  DEFAULT_TOOLKIT_CONFIG,
8
8
  DEFAULT_AUTO_COMPACTION_CONFIG,
9
+ DEFAULT_AUTO_MODE_CONFIG,
9
10
  DEFAULT_IMAGE_GENERATION_CONFIG,
11
+ DEFAULT_NATIVE_FALLBACK_CONFIG,
10
12
  DEFAULT_WEB_SEARCH_CONFIG,
11
13
  RESPONSES_COMPACT_CAPABLE_APIS,
14
+ BREAKER_LIMIT_MAX,
15
+ BREAKER_LIMIT_MIN,
16
+ BREAKER_WINDOW_MAX,
17
+ BREAKER_WINDOW_MIN,
18
+ CLASSIFIER_MAX_LAG_MAX,
19
+ CLASSIFIER_MAX_LAG_MIN,
20
+ DEFAULT_CLASSIFIER_TIMEOUT_MS,
21
+ EVIDENCE_ROUNDS_MAX,
22
+ EVIDENCE_ROUNDS_MIN,
23
+ REVIEWER_TIMEOUT_MAX_MS,
24
+ REVIEWER_TIMEOUT_MIN_MS,
12
25
  THINKING_LEVELS,
13
26
  TOOLKIT_ID,
27
+ type AutoModeCircuitBreakerConfig,
28
+ type AutoModeClassifierConfig,
29
+ type AutoModeConfig,
30
+ type AutoModeGate,
14
31
  type CompactionConfig,
15
32
  type ImageGenerationConfig,
16
33
  type LoadedToolkitConfig,
34
+ type NativeFallbackConfig,
17
35
  type ToolkitConfig,
18
36
  type WebSearchConfig,
19
37
  } from "./types";
@@ -21,13 +39,12 @@ import {
21
39
  export const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", TOOLKIT_ID);
22
40
  export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
23
41
 
24
- const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch", "imageGeneration"]);
42
+ const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch", "imageGeneration", "autoMode"]);
25
43
  const COMPACTION_FIELDS = new Set([
26
44
  "enabled",
27
45
  "allowCompactionContinuityBreak",
28
46
  "remoteCompactModel",
29
- "model",
30
- "thinkingLevel",
47
+ "nativeFallback",
31
48
  "autoCompaction",
32
49
  "responsesApis",
33
50
  "notifyOnLoad",
@@ -37,9 +54,25 @@ const COMPACTION_FIELDS = new Set([
37
54
  "redactSensitiveData",
38
55
  "artifactRoot",
39
56
  ]);
57
+ const NATIVE_FALLBACK_FIELDS = new Set(["enabled", "model", "thinkingLevel"]);
40
58
  const AUTO_COMPACTION_FIELDS = new Set(["enabled", "continuation", "unsupportedFallback", "reserveTokens"]);
41
59
  const WEB_SEARCH_FIELDS = new Set(["enabled", "models"]);
42
- const IMAGE_GENERATION_FIELDS = new Set(["enabled", "models"]);
60
+ const IMAGE_GENERATION_FIELDS = new Set(["enabled"]);
61
+ const AUTO_MODE_FIELDS = new Set([
62
+ "enabled",
63
+ "models",
64
+ "reviewerModel",
65
+ "gate",
66
+ "extraTools",
67
+ "timeoutMs",
68
+ "transcript",
69
+ "evidenceTools",
70
+ "maxEvidenceRounds",
71
+ "classifier",
72
+ "circuitBreaker",
73
+ ]);
74
+ const AUTO_MODE_CLASSIFIER_FIELDS = new Set(["enabled", "model", "timeoutMs", "maxLag"]);
75
+ const AUTO_MODE_BREAKER_FIELDS = new Set(["consecutiveDenials", "recentDenials", "windowSize"]);
43
76
 
44
77
  function isRecord(value: unknown): value is Record<string, unknown> {
45
78
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -148,6 +181,21 @@ function toNonNegativeInteger(value: unknown, fieldPath: string, warnings: strin
148
181
  return undefined;
149
182
  }
150
183
 
184
+ function toBoundedInteger(
185
+ value: unknown,
186
+ fieldPath: string,
187
+ warnings: string[],
188
+ min: number,
189
+ max: number,
190
+ ): number | undefined {
191
+ if (value === undefined) return undefined;
192
+ if (typeof value === "number" && Number.isInteger(value) && Number.isFinite(value) && value >= min && value <= max) {
193
+ return value;
194
+ }
195
+ warnings.push(`Ignoring ${fieldPath}: expected an integer between ${min} and ${max}.`);
196
+ return undefined;
197
+ }
198
+
151
199
  function toSupportedApis(
152
200
  value: unknown,
153
201
  fieldPath: string,
@@ -175,7 +223,7 @@ function toSupportedApis(
175
223
  return accepted;
176
224
  }
177
225
 
178
- function toModelAllowlist(value: unknown, fieldPath: string, warnings: string[]): string[] | undefined {
226
+ function toStringList(value: unknown, fieldPath: string, warnings: string[]): string[] | undefined {
179
227
  if (value === undefined) return undefined;
180
228
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
181
229
  warnings.push(`Ignoring ${fieldPath}: expected a string array.`);
@@ -185,10 +233,34 @@ function toModelAllowlist(value: unknown, fieldPath: string, warnings: string[])
185
233
  return [...new Set(value.map((entry) => entry.trim()).filter(Boolean))];
186
234
  }
187
235
 
236
+ function toAutoModeGate(value: unknown, fieldPath: string, warnings: string[]): AutoModeGate | undefined {
237
+ if (value === undefined) return undefined;
238
+ if (value === "side-effect" || value === "all") return value;
239
+ warnings.push(`Ignoring ${fieldPath}: expected one of side-effect, all.`);
240
+ return undefined;
241
+ }
242
+
243
+ function toReviewerTimeoutMs(value: unknown, fieldPath: string, warnings: string[]): number | undefined {
244
+ if (value === undefined) return undefined;
245
+ if (
246
+ typeof value === "number" &&
247
+ Number.isInteger(value) &&
248
+ value >= REVIEWER_TIMEOUT_MIN_MS &&
249
+ value <= REVIEWER_TIMEOUT_MAX_MS
250
+ ) {
251
+ return value;
252
+ }
253
+ warnings.push(
254
+ `Ignoring ${fieldPath}: expected an integer between ${REVIEWER_TIMEOUT_MIN_MS} and ${REVIEWER_TIMEOUT_MAX_MS} ms.`,
255
+ );
256
+ return undefined;
257
+ }
258
+
188
259
  function cloneDefaults(): ToolkitConfig {
189
260
  return {
190
261
  compaction: {
191
262
  ...DEFAULT_COMPACTION_CONFIG,
263
+ nativeFallback: { ...DEFAULT_NATIVE_FALLBACK_CONFIG },
192
264
  autoCompaction: { ...DEFAULT_AUTO_COMPACTION_CONFIG },
193
265
  responsesApis: [...DEFAULT_COMPACTION_CONFIG.responsesApis],
194
266
  },
@@ -196,13 +268,36 @@ function cloneDefaults(): ToolkitConfig {
196
268
  ...DEFAULT_WEB_SEARCH_CONFIG,
197
269
  models: [...DEFAULT_WEB_SEARCH_CONFIG.models],
198
270
  },
199
- imageGeneration: {
200
- ...DEFAULT_IMAGE_GENERATION_CONFIG,
201
- models: [...DEFAULT_IMAGE_GENERATION_CONFIG.models],
271
+ imageGeneration: { ...DEFAULT_IMAGE_GENERATION_CONFIG },
272
+ autoMode: {
273
+ ...DEFAULT_AUTO_MODE_CONFIG,
274
+ models: [...DEFAULT_AUTO_MODE_CONFIG.models],
275
+ extraTools: [...DEFAULT_AUTO_MODE_CONFIG.extraTools],
276
+ classifier: { ...DEFAULT_AUTO_MODE_CONFIG.classifier },
277
+ circuitBreaker: { ...DEFAULT_AUTO_MODE_CONFIG.circuitBreaker },
202
278
  },
203
279
  };
204
280
  }
205
281
 
282
+ function applyNativeFallbackConfig(
283
+ raw: Record<string, unknown>,
284
+ resolved: NativeFallbackConfig,
285
+ warnings: string[],
286
+ ): void {
287
+ warnUnknownFields(raw, NATIVE_FALLBACK_FIELDS, "compaction.nativeFallback", warnings);
288
+ resolved.enabled =
289
+ toBoolean(raw.enabled, "compaction.nativeFallback.enabled", warnings) ?? resolved.enabled;
290
+
291
+ const modelSpec = toModelSpec(raw.model, "compaction.nativeFallback.model", warnings);
292
+ if (modelSpec !== undefined) {
293
+ resolved.model = modelSpec === null ? undefined : modelSpec;
294
+ }
295
+
296
+ resolved.thinkingLevel =
297
+ toThinkingLevel(raw.thinkingLevel, "compaction.nativeFallback.thinkingLevel", warnings) ??
298
+ resolved.thinkingLevel;
299
+ }
300
+
206
301
  function applyAutoCompactionConfig(
207
302
  raw: Record<string, unknown>,
208
303
  resolved: CompactionConfig["autoCompaction"],
@@ -265,14 +360,14 @@ function applyCompactionConfig(
265
360
  resolved.remoteCompactModel = remoteCompactModelSpec === null ? undefined : remoteCompactModelSpec;
266
361
  }
267
362
 
268
- const modelSpec = toModelSpec(raw.model, "compaction.model", warnings);
269
- if (modelSpec !== undefined) {
270
- resolved.model = modelSpec === null ? undefined : modelSpec;
363
+ if (raw.nativeFallback !== undefined) {
364
+ if (isRecord(raw.nativeFallback)) {
365
+ applyNativeFallbackConfig(raw.nativeFallback, resolved.nativeFallback, warnings);
366
+ } else {
367
+ warnings.push("Ignoring compaction.nativeFallback: expected a JSON object.");
368
+ }
271
369
  }
272
370
 
273
- resolved.thinkingLevel =
274
- toThinkingLevel(raw.thinkingLevel, "compaction.thinkingLevel", warnings) ?? resolved.thinkingLevel;
275
-
276
371
  if (raw.autoCompaction !== undefined) {
277
372
  if (isRecord(raw.autoCompaction)) {
278
373
  applyAutoCompactionConfig(raw.autoCompaction, resolved.autoCompaction, warnings);
@@ -306,7 +401,7 @@ function applyWebSearchConfig(
306
401
  warnUnknownFields(raw, WEB_SEARCH_FIELDS, "webSearch", warnings);
307
402
  resolved.enabled = toBoolean(raw.enabled, "webSearch.enabled", warnings) ?? resolved.enabled;
308
403
 
309
- const models = toModelAllowlist(raw.models, "webSearch.models", warnings);
404
+ const models = toStringList(raw.models, "webSearch.models", warnings);
310
405
  if (models !== undefined) {
311
406
  resolved.models = models;
312
407
  }
@@ -320,11 +415,147 @@ function applyImageGenerationConfig(
320
415
  warnUnknownFields(raw, IMAGE_GENERATION_FIELDS, "imageGeneration", warnings);
321
416
  resolved.enabled =
322
417
  toBoolean(raw.enabled, "imageGeneration.enabled", warnings) ?? resolved.enabled;
418
+ }
419
+
420
+ function applyAutoModeConfig(
421
+ raw: Record<string, unknown>,
422
+ resolved: AutoModeConfig,
423
+ warnings: string[],
424
+ ): void {
425
+ warnUnknownFields(raw, AUTO_MODE_FIELDS, "autoMode", warnings);
426
+ resolved.enabled = toBoolean(raw.enabled, "autoMode.enabled", warnings) ?? resolved.enabled;
323
427
 
324
- const models = toModelAllowlist(raw.models, "imageGeneration.models", warnings);
428
+ const models = toStringList(raw.models, "autoMode.models", warnings);
325
429
  if (models !== undefined) {
326
430
  resolved.models = models;
327
431
  }
432
+
433
+ const reviewerModel = toModelSpec(raw.reviewerModel, "autoMode.reviewerModel", warnings);
434
+ if (reviewerModel !== undefined) {
435
+ resolved.reviewerModel = reviewerModel === null ? undefined : reviewerModel;
436
+ }
437
+
438
+ resolved.gate = toAutoModeGate(raw.gate, "autoMode.gate", warnings) ?? resolved.gate;
439
+
440
+ const extraTools = toStringList(raw.extraTools, "autoMode.extraTools", warnings);
441
+ if (extraTools !== undefined) {
442
+ resolved.extraTools = extraTools;
443
+ }
444
+
445
+ const timeoutMs = toReviewerTimeoutMs(raw.timeoutMs, "autoMode.timeoutMs", warnings);
446
+ if (timeoutMs !== undefined) {
447
+ resolved.timeoutMs = timeoutMs;
448
+ }
449
+
450
+ resolved.transcript = toBoolean(raw.transcript, "autoMode.transcript", warnings) ?? resolved.transcript;
451
+ resolved.evidenceTools =
452
+ toBoolean(raw.evidenceTools, "autoMode.evidenceTools", warnings) ?? resolved.evidenceTools;
453
+
454
+ const maxEvidenceRounds = toBoundedInteger(
455
+ raw.maxEvidenceRounds,
456
+ "autoMode.maxEvidenceRounds",
457
+ warnings,
458
+ EVIDENCE_ROUNDS_MIN,
459
+ EVIDENCE_ROUNDS_MAX,
460
+ );
461
+ if (maxEvidenceRounds !== undefined) {
462
+ resolved.maxEvidenceRounds = maxEvidenceRounds;
463
+ }
464
+
465
+ if (raw.classifier !== undefined) {
466
+ if (isRecord(raw.classifier)) {
467
+ applyAutoModeClassifierConfig(raw.classifier, resolved.classifier, warnings);
468
+ } else {
469
+ warnings.push("Ignoring autoMode.classifier: expected a JSON object.");
470
+ }
471
+ }
472
+
473
+ if (raw.circuitBreaker !== undefined) {
474
+ if (isRecord(raw.circuitBreaker)) {
475
+ applyAutoModeBreakerConfig(raw.circuitBreaker, resolved.circuitBreaker, warnings);
476
+ } else {
477
+ warnings.push("Ignoring autoMode.circuitBreaker: expected a JSON object.");
478
+ }
479
+ }
480
+ }
481
+
482
+ function applyAutoModeClassifierConfig(
483
+ raw: Record<string, unknown>,
484
+ resolved: AutoModeClassifierConfig,
485
+ warnings: string[],
486
+ ): void {
487
+ warnUnknownFields(raw, AUTO_MODE_CLASSIFIER_FIELDS, "autoMode.classifier", warnings);
488
+ resolved.enabled =
489
+ toBoolean(raw.enabled, "autoMode.classifier.enabled", warnings) ?? resolved.enabled;
490
+
491
+ const model = toModelSpec(raw.model, "autoMode.classifier.model", warnings);
492
+ if (model !== undefined) {
493
+ resolved.model = model === null ? undefined : model;
494
+ }
495
+
496
+ const timeoutMs =
497
+ toBoundedInteger(
498
+ raw.timeoutMs,
499
+ "autoMode.classifier.timeoutMs",
500
+ warnings,
501
+ REVIEWER_TIMEOUT_MIN_MS,
502
+ REVIEWER_TIMEOUT_MAX_MS,
503
+ ) ?? DEFAULT_CLASSIFIER_TIMEOUT_MS;
504
+ if (raw.timeoutMs !== undefined) {
505
+ resolved.timeoutMs = timeoutMs;
506
+ }
507
+
508
+ const maxLag = toBoundedInteger(
509
+ raw.maxLag,
510
+ "autoMode.classifier.maxLag",
511
+ warnings,
512
+ CLASSIFIER_MAX_LAG_MIN,
513
+ CLASSIFIER_MAX_LAG_MAX,
514
+ );
515
+ if (maxLag !== undefined) {
516
+ resolved.maxLag = maxLag;
517
+ }
518
+ }
519
+
520
+ function applyAutoModeBreakerConfig(
521
+ raw: Record<string, unknown>,
522
+ resolved: AutoModeCircuitBreakerConfig,
523
+ warnings: string[],
524
+ ): void {
525
+ warnUnknownFields(raw, AUTO_MODE_BREAKER_FIELDS, "autoMode.circuitBreaker", warnings);
526
+
527
+ const consecutiveDenials = toBoundedInteger(
528
+ raw.consecutiveDenials,
529
+ "autoMode.circuitBreaker.consecutiveDenials",
530
+ warnings,
531
+ BREAKER_LIMIT_MIN,
532
+ BREAKER_LIMIT_MAX,
533
+ );
534
+ if (consecutiveDenials !== undefined) {
535
+ resolved.consecutiveDenials = consecutiveDenials;
536
+ }
537
+
538
+ const recentDenials = toBoundedInteger(
539
+ raw.recentDenials,
540
+ "autoMode.circuitBreaker.recentDenials",
541
+ warnings,
542
+ BREAKER_LIMIT_MIN,
543
+ BREAKER_LIMIT_MAX,
544
+ );
545
+ if (recentDenials !== undefined) {
546
+ resolved.recentDenials = recentDenials;
547
+ }
548
+
549
+ const windowSize = toBoundedInteger(
550
+ raw.windowSize,
551
+ "autoMode.circuitBreaker.windowSize",
552
+ warnings,
553
+ BREAKER_WINDOW_MIN,
554
+ BREAKER_WINDOW_MAX,
555
+ );
556
+ if (windowSize !== undefined) {
557
+ resolved.windowSize = windowSize;
558
+ }
328
559
  }
329
560
 
330
561
  /**
@@ -365,6 +596,14 @@ export function loadToolkitConfig(configPath: string = CONFIG_PATH): LoadedToolk
365
596
  warnings.push("Ignoring imageGeneration: expected a JSON object.");
366
597
  }
367
598
  }
599
+
600
+ if (raw.autoMode !== undefined) {
601
+ if (isRecord(raw.autoMode)) {
602
+ applyAutoModeConfig(raw.autoMode, resolved.autoMode, warnings);
603
+ } else {
604
+ warnings.push("Ignoring autoMode: expected a JSON object.");
605
+ }
606
+ }
368
607
  }
369
608
 
370
609
  resolved.compaction.artifactRoot = resolveConfiguredPath(
@@ -316,6 +316,7 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
316
316
  }
317
317
 
318
318
  // Branch 1: Responses-family APIs use remote_compaction_v2 on the normal Responses stream.
319
+ let remoteAttempted = false;
319
320
  const resolution = await resolveRemoteCompactionExecution(
320
321
  ctx,
321
322
  {
@@ -325,6 +326,7 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
325
326
  config.remoteCompactModel,
326
327
  );
327
328
  if (resolution.ok) {
329
+ remoteAttempted = true;
328
330
  const responsesOutcome = await runResponsesNativeCompact(event, ctx, config, resolution.execution);
329
331
  if (responsesOutcome.outcome === "success") {
330
332
  return { compaction: responsesOutcome.compaction };
@@ -357,8 +359,10 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
357
359
  }
358
360
  }
359
361
 
360
- // Branch 2: run pi's native compaction method with the configured model.
361
- const fallback = await runNativeFallbackCompaction({ ctx, event, config });
362
+ // Branch 2: run pi's native compaction method. A failed remote request is compacted by the
363
+ // remote producer itself; a model that cannot use remote v2 at all uses nativeFallback.model.
364
+ const fallbackModelSpec = remoteAttempted ? config.remoteCompactModel : config.nativeFallback.model;
365
+ const fallback = await runNativeFallbackCompaction({ ctx, event, config, modelSpec: fallbackModelSpec });
362
366
  if (fallback.ok) {
363
367
  if (ctx.hasUI) {
364
368
  ctx.ui.notify(
@@ -394,8 +398,12 @@ async function handleSessionBeforeCompact(event: SessionBeforeCompactEvent, ctx:
394
398
  ctx,
395
399
  );
396
400
 
397
- // Intentional pi-default paths: no configured model, or it matches the current one.
398
- if (fallback.reason !== "no-model-configured" && fallback.reason !== "same-as-current-model") {
401
+ // Intentional pi-default paths: feature disabled, nothing configured, or it matches the current one.
402
+ const intentionalSkip =
403
+ fallback.reason === "disabled" ||
404
+ fallback.reason === "no-model-configured" ||
405
+ fallback.reason === "same-as-current-model";
406
+ if (!intentionalSkip) {
399
407
  notifyWarning(
400
408
  ctx,
401
409
  `compaction model "${fallback.modelSpec}" unusable (${fallback.reason}${fallback.errorMessage ? `: ${fallback.errorMessage}` : ""}); using pi's default compaction`,