pi-openai-toolkit 0.14.7 → 0.14.9
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/README.md +37 -8
- package/README.zh.md +37 -8
- package/docs/internals.md +27 -1
- package/extensions/auto-mode.ts +1 -1
- package/extensions/codex-astra.ts +1 -1
- package/extensions/compaction.ts +1 -1
- package/extensions/image-generation.ts +1 -1
- package/extensions/web-search.ts +1 -1
- package/package.json +6 -2
- package/src/auto-mode/classifier.ts +165 -165
- package/src/auto-mode/extension.ts +129 -20
- package/src/auto-mode/policy.ts +146 -146
- package/src/auto-mode/prompt.ts +279 -279
- package/src/auto-mode/reviewer.ts +325 -325
- package/src/auto-mode/tool-review-tui.ts +211 -0
- package/src/auto-mode/transcript.ts +133 -133
- package/src/auto-mode/types.ts +137 -137
- package/src/codex-astra/effort-planner.ts +175 -175
- package/src/codex-astra/extension.ts +122 -122
- package/src/compaction-projection.ts +31 -0
- package/src/config.ts +100 -1
- package/src/context-management/messages.ts +112 -112
- package/src/context-management/namespace-tools.ts +426 -426
- package/src/context-management/tool-contract.ts +23 -23
- package/src/context-management/tools.ts +291 -279
- package/src/context-management/window-budget.ts +83 -83
- package/src/context-management/window-manager.ts +110 -28
- package/src/context-management/window-request.ts +125 -125
- package/src/extension-runtime.ts +263 -34
- package/src/image-generation/artifacts.ts +237 -237
- package/src/image-generation/client.ts +248 -248
- package/src/image-generation/eligibility.ts +18 -18
- package/src/image-generation/references.ts +194 -194
- package/src/image-generation/render.ts +252 -252
- package/src/model-scope.ts +17 -17
- package/src/payload-rewrite.ts +55 -10
- package/src/pi-context-hook.ts +116 -0
- package/src/provider-headers.ts +23 -23
- package/src/request-context-cache.ts +16 -1
- package/src/runtime.ts +52 -0
- package/src/types.ts +46 -0
- package/src/web-search/alpha-client.ts +875 -0
- package/src/web-search/extension.ts +523 -80
- package/src/web-search/payload.ts +295 -96
- package/src/web-search/prompt.ts +59 -20
- package/src/web-search/types.ts +144 -45
- package/tsconfig.check.json +2 -0
|
@@ -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
|
|
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", (
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
|
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) => {
|
package/src/auto-mode/policy.ts
CHANGED
|
@@ -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
|
+
}
|