pi-openai-toolkit 0.14.8 → 0.14.10

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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/docs/internals.md +20 -0
  4. package/package.json +6 -19
  5. package/src/auto-mode/classifier.ts +165 -165
  6. package/src/auto-mode/extension.ts +129 -20
  7. package/src/auto-mode/policy.ts +146 -146
  8. package/src/auto-mode/prompt.ts +279 -279
  9. package/src/auto-mode/reviewer.ts +335 -325
  10. package/src/auto-mode/tool-review-tui.ts +211 -0
  11. package/src/auto-mode/transcript.ts +133 -133
  12. package/src/auto-mode/types.ts +137 -137
  13. package/src/codex-astra/effort-planner.ts +175 -175
  14. package/src/codex-astra/extension.ts +122 -122
  15. package/src/compaction-projection.ts +31 -31
  16. package/src/context-management/messages.ts +115 -112
  17. package/src/context-management/namespace-tools.ts +426 -426
  18. package/src/context-management/tool-contract.ts +23 -23
  19. package/src/context-management/tools.ts +298 -291
  20. package/src/context-management/window-budget.ts +83 -83
  21. package/src/context-management/window-request.ts +125 -125
  22. package/src/extension-runtime.ts +1 -0
  23. package/src/image-generation/artifacts.ts +237 -237
  24. package/src/image-generation/client.ts +248 -248
  25. package/src/image-generation/eligibility.ts +18 -18
  26. package/src/image-generation/references.ts +194 -194
  27. package/src/image-generation/render.ts +252 -252
  28. package/src/model-scope.ts +17 -17
  29. package/src/payload-rewrite.ts +15 -3
  30. package/src/pi-context-hook.ts +116 -116
  31. package/src/provider-headers.ts +23 -23
  32. package/src/serializer.ts +127 -5
  33. package/src/web-search/alpha-client.ts +875 -875
  34. package/src/web-search/extension.ts +523 -523
  35. package/src/web-search/payload.ts +295 -295
  36. package/src/web-search/prompt.ts +59 -59
  37. package/src/web-search/types.ts +144 -144
  38. package/tsconfig.check.json +0 -4
  39. package/extensions/auto-mode.ts +0 -1
  40. package/extensions/codex-astra.ts +0 -1
  41. package/extensions/compaction.ts +0 -1
  42. package/extensions/image-generation.ts +0 -1
  43. package/extensions/web-search.ts +0 -1
@@ -43,6 +43,11 @@ import {
43
43
  MAX_REVIEW_REASON_CHARS,
44
44
  type ReviewOutcome,
45
45
  } from "./types";
46
+ import {
47
+ installToolReviewRenderer,
48
+ type ToolReviewDisplayState,
49
+ type ToolReviewRendererBridge,
50
+ } from "./tool-review-tui";
46
51
 
47
52
  const registeredApis = new WeakSet<object>();
48
53
 
@@ -133,10 +138,13 @@ export function registerAutoModeExtension(
133
138
  pi: ExtensionAPI,
134
139
  loadConfig: typeof loadToolkitConfig = loadToolkitConfig,
135
140
  requestReview: typeof requestToolReview = requestToolReview,
141
+ createToolReviewRenderer: () => ToolReviewRendererBridge = installToolReviewRenderer,
136
142
  ): void {
137
143
  if (registeredApis.has(pi)) return;
138
144
  registeredApis.add(pi);
139
145
 
146
+ const toolReviewRenderer = createToolReviewRenderer();
147
+ let toolReviewRendererWarningShown = false;
140
148
  const runtime: AutoModeRuntimeState = createRuntimeState(DEFAULT_AUTO_MODE_CONFIG);
141
149
  const breaker: RejectionBreakerState = createRejectionBreaker();
142
150
  const tracker: ScoreTracker = createScoreTracker();
@@ -144,14 +152,34 @@ export function registerAutoModeExtension(
144
152
  let callIndex = 0;
145
153
  let scoringInFlight = false;
146
154
 
147
- function updateStatus(ctx: ExtensionContext): void {
155
+ function updateStatus(ctx: ExtensionContext, reviewingTool?: string): void {
148
156
  if (!ctx.hasUI) return;
157
+ if (ctx.mode === "tui") {
158
+ toolReviewRenderer.setTheme(ctx.ui.theme);
159
+ if (reviewingTool) ctx.ui.setWorkingMessage(`Auto mode: reviewing ${reviewingTool}`);
160
+ }
161
+ const rendererSuffix = toolReviewRenderer.supported ? "" : " (footer only)";
149
162
  ctx.ui.setStatus(
150
163
  AUTO_MODE_STATUS_KEY,
151
- runtime.engaged ? `auto-review: ${describeGate(runtime)}` : undefined,
164
+ runtime.engaged
165
+ ? reviewingTool
166
+ ? `auto-review: reviewing ${reviewingTool}${rendererSuffix}`
167
+ : `auto-review: ${describeGate(runtime)}${rendererSuffix}`
168
+ : undefined,
152
169
  );
153
170
  }
154
171
 
172
+ function setReviewDisplay(ctx: ExtensionContext, toolCallId: string, state: ToolReviewDisplayState): void {
173
+ if (ctx.mode === "tui") toolReviewRenderer.setTheme(ctx.ui.theme);
174
+ toolReviewRenderer.setState(toolCallId, state);
175
+ }
176
+
177
+ function clearReviewActivity(ctx: ExtensionContext): void {
178
+ if (!ctx.hasUI) return;
179
+ if (ctx.mode === "tui") ctx.ui.setWorkingMessage(undefined);
180
+ updateStatus(ctx);
181
+ }
182
+
155
183
  function recordDecision(data: Omit<AutoModeDecisionRecord, "timestamp">): void {
156
184
  pi.appendEntry(AUTO_MODE_ENTRY_TYPE, { timestamp: Date.now(), ...data } satisfies AutoModeDecisionRecord);
157
185
  }
@@ -390,14 +418,30 @@ export function registerAutoModeExtension(
390
418
  },
391
419
  });
392
420
 
393
- pi.on("session_start", (_event, ctx) => {
421
+ pi.on("session_start", (event, ctx) => {
394
422
  const { config } = loadConfig();
423
+ toolReviewRenderer.clear();
424
+ if (
425
+ !toolReviewRenderer.supported &&
426
+ !toolReviewRendererWarningShown &&
427
+ ctx.hasUI &&
428
+ ctx.mode === "tui"
429
+ ) {
430
+ ctx.ui.notify(
431
+ "Per-tool auto-review display is unavailable in this Pi version; using the footer status instead.",
432
+ "warning",
433
+ );
434
+ toolReviewRendererWarningShown = true;
435
+ }
395
436
  setGateOverride(runtime, config.autoMode, undefined);
396
437
  applyConfiguredGate(runtime, config.autoMode);
397
438
  resetScoreTracker(tracker);
398
439
  resetRejectionBreaker(breaker);
399
440
  callIndex = 0;
400
- if (pi.getFlag(AUTO_MODE_FLAG)) engage(ctx, config.autoMode);
441
+ const startedFromFlag = pi.getFlag(AUTO_MODE_FLAG) === true && engage(ctx, config.autoMode);
442
+ if (startedFromFlag && event.reason === "startup" && ctx.mode === "tui") {
443
+ ctx.ui.notify(`Auto mode on. Reviewing: ${describeGate(runtime)}`, "info");
444
+ }
401
445
  updateStatus(ctx);
402
446
  });
403
447
 
@@ -436,7 +480,15 @@ export function registerAutoModeExtension(
436
480
  disengage(ctx);
437
481
  return undefined;
438
482
  }
439
- if (!shouldReviewTool(event.toolName, runtime)) return undefined;
483
+ if (!shouldReviewTool(event.toolName, runtime)) {
484
+ setReviewDisplay(ctx, event.toolCallId, {
485
+ phase: "skipped",
486
+ toolName: event.toolName,
487
+ detail: "outside the configured gate",
488
+ });
489
+ updateStatus(ctx);
490
+ return undefined;
491
+ }
440
492
 
441
493
  const pending = {
442
494
  toolName: event.toolName,
@@ -456,6 +508,13 @@ export function registerAutoModeExtension(
456
508
  maxLag: auto.classifier.maxLag,
457
509
  });
458
510
  if (fast.eligible) {
511
+ setReviewDisplay(ctx, pending.toolCallId, {
512
+ phase: "allowed",
513
+ toolName: pending.toolName,
514
+ source: "classifier",
515
+ detail: "low-risk trajectory pre-score",
516
+ });
517
+ updateStatus(ctx);
459
518
  recordDecision({
460
519
  toolName: pending.toolName,
461
520
  toolCallId: pending.toolCallId,
@@ -472,26 +531,48 @@ export function registerAutoModeExtension(
472
531
  const entries = ctx.sessionManager.buildContextEntries();
473
532
  const transcript = auto.transcript ? transcriptFromEntries(entries).text : undefined;
474
533
 
475
- const outcome = await requestReview({
476
- registry: reviewerRegistry(ctx),
477
- reviewerModelSpec: pending.reviewerModelSpec,
478
- toolName: event.toolName,
479
- toolInput: event.input,
480
- transcript,
481
- cwd: ctx.cwd,
482
- timeoutMs: auto.timeoutMs,
483
- signal: ctx.signal,
484
- evidenceTools: auto.evidenceTools ? evidenceToolsFor(ctx.cwd) : undefined,
485
- maxEvidenceRounds: auto.maxEvidenceRounds,
486
- });
534
+ setReviewDisplay(ctx, pending.toolCallId, { phase: "reviewing", toolName: pending.toolName });
535
+ updateStatus(ctx, event.toolName);
536
+ let outcome: ReviewOutcome;
537
+ try {
538
+ outcome = await requestReview({
539
+ registry: reviewerRegistry(ctx),
540
+ reviewerModelSpec: pending.reviewerModelSpec,
541
+ toolName: event.toolName,
542
+ toolInput: event.input,
543
+ transcript,
544
+ cwd: ctx.cwd,
545
+ timeoutMs: auto.timeoutMs,
546
+ signal: ctx.signal,
547
+ evidenceTools: auto.evidenceTools ? evidenceToolsFor(ctx.cwd) : undefined,
548
+ maxEvidenceRounds: auto.maxEvidenceRounds,
549
+ });
550
+ } finally {
551
+ clearReviewActivity(ctx);
552
+ }
487
553
 
488
554
  const decision = evaluateOutcome(outcome, pending, ctx, auto.circuitBreaker);
489
555
  if (decision.kind === "ask-human") {
556
+ setReviewDisplay(ctx, pending.toolCallId, { phase: "awaiting-user", toolName: pending.toolName });
557
+ updateStatus(ctx);
490
558
  const confirmed = await ctx.ui.confirm(
491
559
  "Auto-mode review unavailable",
492
560
  `${decision.reason}\n\nAllow ${event.toolName} to run?`,
493
561
  );
494
562
  if (confirmed) recordNonDenial(breaker);
563
+ setReviewDisplay(
564
+ ctx,
565
+ pending.toolCallId,
566
+ confirmed
567
+ ? {
568
+ phase: "allowed",
569
+ toolName: pending.toolName,
570
+ source: "human",
571
+ detail: `review unavailable · ${outcome.kind === "unavailable" ? outcome.cause : "manual confirmation"}`,
572
+ }
573
+ : { phase: "blocked", toolName: pending.toolName, detail: "user declined" },
574
+ );
575
+ updateStatus(ctx);
495
576
  recordDecision({
496
577
  toolName: event.toolName,
497
578
  toolCallId: event.toolCallId,
@@ -505,10 +586,38 @@ export function registerAutoModeExtension(
505
586
  : { block: true, reason: "Blocked by user after auto-mode review could not complete." };
506
587
  }
507
588
 
589
+ if (decision.kind === "block") {
590
+ setReviewDisplay(
591
+ ctx,
592
+ pending.toolCallId,
593
+ outcome.kind === "deny"
594
+ ? {
595
+ phase: "denied",
596
+ toolName: pending.toolName,
597
+ detail: `${outcome.verdict.riskLevel} risk · ${outcome.verdict.rationale}`,
598
+ }
599
+ : {
600
+ phase: "blocked",
601
+ toolName: pending.toolName,
602
+ detail:
603
+ outcome.kind === "unavailable" ? `${outcome.cause} · ${outcome.reason}` : decision.reason,
604
+ },
605
+ );
606
+ updateStatus(ctx);
607
+ return { block: true, reason: decision.reason, ...(decision.terminate ? { terminate: true } : {}) };
608
+ }
609
+
610
+ setReviewDisplay(ctx, pending.toolCallId, {
611
+ phase: "allowed",
612
+ toolName: pending.toolName,
613
+ source: "reviewer",
614
+ detail:
615
+ outcome.kind === "allow"
616
+ ? `${outcome.verdict.riskLevel} risk · authorization ${outcome.verdict.userAuthorization}`
617
+ : "review completed",
618
+ });
508
619
  updateStatus(ctx);
509
- return decision.kind === "block"
510
- ? { block: true, reason: decision.reason, ...(decision.terminate ? { terminate: true } : {}) }
511
- : undefined;
620
+ return undefined;
512
621
  });
513
622
 
514
623
  pi.on("tool_result", (event, ctx) => {
@@ -1,146 +1,146 @@
1
- import { isExactModelAllowed } from "../model-scope";
2
- import type { AutoModeCircuitBreakerConfig, AutoModeConfig } from "../types";
3
- import {
4
- SIDE_EFFECT_TOOLS,
5
- type AutoModeGateSource,
6
- type AutoModeModelRef,
7
- } from "./types";
8
-
9
- export type AutoModeRuntimeState = {
10
- engaged: boolean;
11
- /** Gate currently in force: either the configured value or a session override. */
12
- gate: AutoModeGateSource;
13
- /** Session override from `/auto all` or `/auto side`; cleared on session start. */
14
- gateOverride?: AutoModeGateSource;
15
- extraTools: string[];
16
- };
17
-
18
- /**
19
- * Per-turn denial accounting, modelled on Codex's rejection circuit breaker. Without
20
- * it a denied action just becomes an infinite negotiation between the agent and the
21
- * reviewer, which is both expensive and the exact behaviour the anti-circumvention
22
- * clause is trying to stop.
23
- */
24
- export type RejectionBreakerState = {
25
- consecutiveDenials: number;
26
- recentDenials: boolean[];
27
- deniedTools: Set<string>;
28
- interrupted: boolean;
29
- };
30
-
31
- export function createRejectionBreaker(): RejectionBreakerState {
32
- return { consecutiveDenials: 0, recentDenials: [], deniedTools: new Set(), interrupted: false };
33
- }
34
-
35
- /** Clear at turn start: the breaker judges one turn's pattern, not the whole session. */
36
- export function resetRejectionBreaker(state: RejectionBreakerState): void {
37
- state.consecutiveDenials = 0;
38
- state.recentDenials = [];
39
- state.deniedTools.clear();
40
- state.interrupted = false;
41
- }
42
-
43
- function normalizeToolNames(names: readonly string[]): string[] {
44
- return [...new Set(names.map((name) => name.trim()).filter(Boolean))];
45
- }
46
-
47
- /**
48
- * Auto mode needs an allowlisted active model plus a configured reviewer. The
49
- * reviewer model itself is resolved later, so an unknown spec degrades through the
50
- * normal review-unavailable path instead of silently disabling the gate.
51
- */
52
- export function isAutoModeEligible(
53
- model: AutoModeModelRef | undefined,
54
- config: AutoModeConfig,
55
- ): boolean {
56
- if (!config.enabled) return false;
57
- if (!config.reviewerModel) return false;
58
- return isExactModelAllowed(model, config.models);
59
- }
60
-
61
- export function shouldReviewTool(toolName: string, state: AutoModeRuntimeState): boolean {
62
- if (state.gate === "all") return true;
63
- if ((SIDE_EFFECT_TOOLS as readonly string[]).includes(toolName)) return true;
64
- return normalizeToolNames(state.extraTools).includes(toolName);
65
- }
66
-
67
- export function describeGate(state: Pick<AutoModeRuntimeState, "gate" | "extraTools">): string {
68
- if (state.gate === "all") return "all tools";
69
- return [...new Set([...SIDE_EFFECT_TOOLS, ...normalizeToolNames(state.extraTools)])].join(", ");
70
- }
71
-
72
- export function createRuntimeState(config: AutoModeConfig): AutoModeRuntimeState {
73
- return {
74
- engaged: false,
75
- gate: config.gate,
76
- extraTools: normalizeToolNames(config.extraTools),
77
- };
78
- }
79
-
80
- /**
81
- * Refresh the configured gate and tool list without touching engagement: editing
82
- * config changes what gets reviewed, it never silently turns auto mode on or off.
83
- * An explicit `/auto` scope override wins until the next session start.
84
- */
85
- export function applyConfiguredGate(
86
- state: AutoModeRuntimeState,
87
- config: AutoModeConfig,
88
- ): AutoModeRuntimeState {
89
- state.gate = state.gateOverride ?? config.gate;
90
- state.extraTools = normalizeToolNames(config.extraTools);
91
- return state;
92
- }
93
-
94
- export function setGateOverride(
95
- state: AutoModeRuntimeState,
96
- config: AutoModeConfig,
97
- gate: AutoModeGateSource | undefined,
98
- ): AutoModeRuntimeState {
99
- state.gateOverride = gate;
100
- state.gate = gate ?? config.gate;
101
- return state;
102
- }
103
-
104
- /**
105
- * Record one genuine reviewer denial and report whether the turn should stop.
106
- *
107
- * Two independent triggers, matching Codex: a short run of consecutive denials
108
- * means the agent cannot get past the reviewer, and a high denial rate inside a
109
- * sliding window means it keeps trying different unsafe things.
110
- */
111
- export function recordDenial(
112
- state: RejectionBreakerState,
113
- config: AutoModeCircuitBreakerConfig,
114
- toolName: string,
115
- ): "continue" | "interrupt" {
116
- state.consecutiveDenials += 1;
117
- state.deniedTools.add(toolName);
118
- state.recentDenials.push(true);
119
- if (state.recentDenials.length > Math.max(1, config.windowSize)) {
120
- state.recentDenials.splice(0, state.recentDenials.length - config.windowSize);
121
- }
122
-
123
- const recentDenials = state.recentDenials.filter(Boolean).length;
124
- const consecutiveTripped = config.consecutiveDenials > 0 && state.consecutiveDenials >= config.consecutiveDenials;
125
- const rateTripped = config.recentDenials > 0 && recentDenials >= config.recentDenials;
126
- if (consecutiveTripped || rateTripped) {
127
- state.interrupted = true;
128
- return "interrupt";
129
- }
130
- return "continue";
131
- }
132
-
133
- /** Any allowed action resets the consecutive run, so one approval breaks a streak. */
134
- export function recordNonDenial(state: RejectionBreakerState): void {
135
- state.consecutiveDenials = 0;
136
- state.recentDenials.push(false);
137
- }
138
-
139
- /**
140
- * A repeat request for a tool that was already denied in this turn is the shape of
141
- * a workaround attempt, so it must be judged by the blocking reviewer rather than
142
- * accepted on the strength of an earlier low-risk classification.
143
- */
144
- export function wasDeniedThisTurn(state: RejectionBreakerState, toolName: string): boolean {
145
- return state.deniedTools.has(toolName);
146
- }
1
+ import { isExactModelAllowed } from "../model-scope";
2
+ import type { AutoModeCircuitBreakerConfig, AutoModeConfig } from "../types";
3
+ import {
4
+ SIDE_EFFECT_TOOLS,
5
+ type AutoModeGateSource,
6
+ type AutoModeModelRef,
7
+ } from "./types";
8
+
9
+ export type AutoModeRuntimeState = {
10
+ engaged: boolean;
11
+ /** Gate currently in force: either the configured value or a session override. */
12
+ gate: AutoModeGateSource;
13
+ /** Session override from `/auto all` or `/auto side`; cleared on session start. */
14
+ gateOverride?: AutoModeGateSource;
15
+ extraTools: string[];
16
+ };
17
+
18
+ /**
19
+ * Per-turn denial accounting, modelled on Codex's rejection circuit breaker. Without
20
+ * it a denied action just becomes an infinite negotiation between the agent and the
21
+ * reviewer, which is both expensive and the exact behaviour the anti-circumvention
22
+ * clause is trying to stop.
23
+ */
24
+ export type RejectionBreakerState = {
25
+ consecutiveDenials: number;
26
+ recentDenials: boolean[];
27
+ deniedTools: Set<string>;
28
+ interrupted: boolean;
29
+ };
30
+
31
+ export function createRejectionBreaker(): RejectionBreakerState {
32
+ return { consecutiveDenials: 0, recentDenials: [], deniedTools: new Set(), interrupted: false };
33
+ }
34
+
35
+ /** Clear at turn start: the breaker judges one turn's pattern, not the whole session. */
36
+ export function resetRejectionBreaker(state: RejectionBreakerState): void {
37
+ state.consecutiveDenials = 0;
38
+ state.recentDenials = [];
39
+ state.deniedTools.clear();
40
+ state.interrupted = false;
41
+ }
42
+
43
+ function normalizeToolNames(names: readonly string[]): string[] {
44
+ return [...new Set(names.map((name) => name.trim()).filter(Boolean))];
45
+ }
46
+
47
+ /**
48
+ * Auto mode needs an allowlisted active model plus a configured reviewer. The
49
+ * reviewer model itself is resolved later, so an unknown spec degrades through the
50
+ * normal review-unavailable path instead of silently disabling the gate.
51
+ */
52
+ export function isAutoModeEligible(
53
+ model: AutoModeModelRef | undefined,
54
+ config: AutoModeConfig,
55
+ ): boolean {
56
+ if (!config.enabled) return false;
57
+ if (!config.reviewerModel) return false;
58
+ return isExactModelAllowed(model, config.models);
59
+ }
60
+
61
+ export function shouldReviewTool(toolName: string, state: AutoModeRuntimeState): boolean {
62
+ if (state.gate === "all") return true;
63
+ if ((SIDE_EFFECT_TOOLS as readonly string[]).includes(toolName)) return true;
64
+ return normalizeToolNames(state.extraTools).includes(toolName);
65
+ }
66
+
67
+ export function describeGate(state: Pick<AutoModeRuntimeState, "gate" | "extraTools">): string {
68
+ if (state.gate === "all") return "all tools";
69
+ return [...new Set([...SIDE_EFFECT_TOOLS, ...normalizeToolNames(state.extraTools)])].join(", ");
70
+ }
71
+
72
+ export function createRuntimeState(config: AutoModeConfig): AutoModeRuntimeState {
73
+ return {
74
+ engaged: false,
75
+ gate: config.gate,
76
+ extraTools: normalizeToolNames(config.extraTools),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Refresh the configured gate and tool list without touching engagement: editing
82
+ * config changes what gets reviewed, it never silently turns auto mode on or off.
83
+ * An explicit `/auto` scope override wins until the next session start.
84
+ */
85
+ export function applyConfiguredGate(
86
+ state: AutoModeRuntimeState,
87
+ config: AutoModeConfig,
88
+ ): AutoModeRuntimeState {
89
+ state.gate = state.gateOverride ?? config.gate;
90
+ state.extraTools = normalizeToolNames(config.extraTools);
91
+ return state;
92
+ }
93
+
94
+ export function setGateOverride(
95
+ state: AutoModeRuntimeState,
96
+ config: AutoModeConfig,
97
+ gate: AutoModeGateSource | undefined,
98
+ ): AutoModeRuntimeState {
99
+ state.gateOverride = gate;
100
+ state.gate = gate ?? config.gate;
101
+ return state;
102
+ }
103
+
104
+ /**
105
+ * Record one genuine reviewer denial and report whether the turn should stop.
106
+ *
107
+ * Two independent triggers, matching Codex: a short run of consecutive denials
108
+ * means the agent cannot get past the reviewer, and a high denial rate inside a
109
+ * sliding window means it keeps trying different unsafe things.
110
+ */
111
+ export function recordDenial(
112
+ state: RejectionBreakerState,
113
+ config: AutoModeCircuitBreakerConfig,
114
+ toolName: string,
115
+ ): "continue" | "interrupt" {
116
+ state.consecutiveDenials += 1;
117
+ state.deniedTools.add(toolName);
118
+ state.recentDenials.push(true);
119
+ if (state.recentDenials.length > Math.max(1, config.windowSize)) {
120
+ state.recentDenials.splice(0, state.recentDenials.length - config.windowSize);
121
+ }
122
+
123
+ const recentDenials = state.recentDenials.filter(Boolean).length;
124
+ const consecutiveTripped = config.consecutiveDenials > 0 && state.consecutiveDenials >= config.consecutiveDenials;
125
+ const rateTripped = config.recentDenials > 0 && recentDenials >= config.recentDenials;
126
+ if (consecutiveTripped || rateTripped) {
127
+ state.interrupted = true;
128
+ return "interrupt";
129
+ }
130
+ return "continue";
131
+ }
132
+
133
+ /** Any allowed action resets the consecutive run, so one approval breaks a streak. */
134
+ export function recordNonDenial(state: RejectionBreakerState): void {
135
+ state.consecutiveDenials = 0;
136
+ state.recentDenials.push(false);
137
+ }
138
+
139
+ /**
140
+ * A repeat request for a tool that was already denied in this turn is the shape of
141
+ * a workaround attempt, so it must be judged by the blocking reviewer rather than
142
+ * accepted on the strength of an earlier low-risk classification.
143
+ */
144
+ export function wasDeniedThisTurn(state: RejectionBreakerState, toolName: string): boolean {
145
+ return state.deniedTools.has(toolName);
146
+ }