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.
- package/README.md +84 -13
- package/README_zh.md +84 -13
- package/extensions/auto-mode.ts +1 -0
- package/package.json +13 -3
- package/src/auto-mode/classifier.ts +165 -0
- package/src/auto-mode/extension.ts +530 -0
- package/src/auto-mode/policy.ts +146 -0
- package/src/auto-mode/prompt.ts +279 -0
- package/src/auto-mode/reviewer.ts +325 -0
- package/src/auto-mode/transcript.ts +133 -0
- package/src/auto-mode/types.ts +137 -0
- package/src/config.ts +255 -16
- package/src/extension-runtime.ts +12 -4
- package/src/image-generation/eligibility.ts +7 -7
- package/src/image-generation/extension.ts +160 -160
- package/src/native-fallback.ts +19 -6
- package/src/types.ts +107 -10
- package/tsconfig.check.json +5 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { createReadOnlyTools, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { loadToolkitConfig } from "../config";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_AUTO_MODE_CONFIG,
|
|
6
|
+
type AutoModeConfig,
|
|
7
|
+
} from "../types";
|
|
8
|
+
import {
|
|
9
|
+
authorizationVersion,
|
|
10
|
+
classifyTrajectory,
|
|
11
|
+
createScoreTracker,
|
|
12
|
+
fastApprovalEligible,
|
|
13
|
+
recordFailedCall,
|
|
14
|
+
recordScoredCall,
|
|
15
|
+
resetScoreTracker,
|
|
16
|
+
type ScoreTracker,
|
|
17
|
+
} from "./classifier";
|
|
18
|
+
import {
|
|
19
|
+
applyConfiguredGate,
|
|
20
|
+
createRejectionBreaker,
|
|
21
|
+
createRuntimeState,
|
|
22
|
+
describeGate,
|
|
23
|
+
isAutoModeEligible,
|
|
24
|
+
recordDenial,
|
|
25
|
+
recordNonDenial,
|
|
26
|
+
resetRejectionBreaker,
|
|
27
|
+
setGateOverride,
|
|
28
|
+
shouldReviewTool,
|
|
29
|
+
wasDeniedThisTurn,
|
|
30
|
+
type AutoModeRuntimeState,
|
|
31
|
+
type RejectionBreakerState,
|
|
32
|
+
} from "./policy";
|
|
33
|
+
import { requestToolReview, type EvidenceTool, type ReviewerRegistry } from "./reviewer";
|
|
34
|
+
import { buildClassifierPrompt } from "./prompt";
|
|
35
|
+
import { transcriptFromEntries } from "./transcript";
|
|
36
|
+
import {
|
|
37
|
+
AUTO_MODE_COMMAND,
|
|
38
|
+
AUTO_MODE_ENTRY_TYPE,
|
|
39
|
+
AUTO_MODE_FLAG,
|
|
40
|
+
AUTO_MODE_STATUS_KEY,
|
|
41
|
+
boundReviewText,
|
|
42
|
+
type AutoModeDecisionRecord,
|
|
43
|
+
MAX_REVIEW_REASON_CHARS,
|
|
44
|
+
type ReviewOutcome,
|
|
45
|
+
} from "./types";
|
|
46
|
+
|
|
47
|
+
const registeredApis = new WeakSet<object>();
|
|
48
|
+
|
|
49
|
+
/** Read-only tools are stateless per working directory, so build them once each. */
|
|
50
|
+
const evidenceToolCache = new Map<string, EvidenceTool[]>();
|
|
51
|
+
|
|
52
|
+
function textFromToolContent(content: unknown): string {
|
|
53
|
+
if (typeof content === "string") return content;
|
|
54
|
+
if (!Array.isArray(content)) return "";
|
|
55
|
+
return content
|
|
56
|
+
.map((block) => {
|
|
57
|
+
if (!block || typeof block !== "object") return "";
|
|
58
|
+
const record = block as Record<string, unknown>;
|
|
59
|
+
if (record.type === "text" && typeof record.text === "string") return record.text;
|
|
60
|
+
if (record.type === "image") return "[image omitted]";
|
|
61
|
+
return "";
|
|
62
|
+
})
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fingerprint of everything the user actually said. Used to invalidate a cached
|
|
69
|
+
* pre-score: once the authorization premise changes, the old score is meaningless.
|
|
70
|
+
*/
|
|
71
|
+
function userAuthorizationVersion(entries: readonly { type: string; message?: unknown }[]): string {
|
|
72
|
+
const userText = entries
|
|
73
|
+
.filter((entry) => entry.type === "message")
|
|
74
|
+
.map((entry) => entry.message as { role?: string; content?: unknown } | undefined)
|
|
75
|
+
.filter((message): message is { role?: string; content?: unknown } => message?.role === "user")
|
|
76
|
+
.map((message) => textFromToolContent(message.content))
|
|
77
|
+
.join("\n");
|
|
78
|
+
return authorizationVersion(userText);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function evidenceToolsFor(cwd: string): EvidenceTool[] {
|
|
82
|
+
const cached = evidenceToolCache.get(cwd);
|
|
83
|
+
if (cached) return cached;
|
|
84
|
+
const built: EvidenceTool[] = createReadOnlyTools(cwd).map((tool: AgentTool<any>) => ({
|
|
85
|
+
name: tool.name,
|
|
86
|
+
description: tool.description,
|
|
87
|
+
parameters: tool.parameters,
|
|
88
|
+
execute: async (args, signal) => {
|
|
89
|
+
const result = await tool.execute(
|
|
90
|
+
`auto-mode-review-${Date.now().toString(36)}`,
|
|
91
|
+
args as never,
|
|
92
|
+
signal,
|
|
93
|
+
undefined,
|
|
94
|
+
);
|
|
95
|
+
return textFromToolContent(result.content);
|
|
96
|
+
},
|
|
97
|
+
}));
|
|
98
|
+
evidenceToolCache.set(cwd, built);
|
|
99
|
+
return built;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Anti-circumvention clause. A denial has to survive contact with a motivated
|
|
104
|
+
* agent: without an explicit prohibition on workarounds, the model reissues the
|
|
105
|
+
* same operation in a form the reviewer has not seen.
|
|
106
|
+
*/
|
|
107
|
+
const GUARDIAN_REJECTION_INSTRUCTIONS =
|
|
108
|
+
"The agent must not attempt to achieve the same outcome by workaround, indirect execution, " +
|
|
109
|
+
"renaming, splitting the command, or policy circumvention. Proceed only with a materially " +
|
|
110
|
+
"safer alternative, or if the user explicitly approves this action after being informed of " +
|
|
111
|
+
"the risk. Otherwise stop and ask the user.";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A review that never finished is an infrastructure outcome, not a safety verdict.
|
|
115
|
+
* Telling the model "rejected due to risk" here would teach it the wrong lesson.
|
|
116
|
+
*/
|
|
117
|
+
const REVIEW_INCOMPLETE_INSTRUCTIONS =
|
|
118
|
+
"The automatic approval review did not produce a verdict. Do not assume the action is unsafe " +
|
|
119
|
+
"because of that. Ask the user for explicit approval, or choose a materially safer alternative.";
|
|
120
|
+
|
|
121
|
+
type GateDecision =
|
|
122
|
+
| { kind: "pass" }
|
|
123
|
+
| { kind: "block"; reason: string; terminate?: boolean }
|
|
124
|
+
| { kind: "ask-human"; reason: string };
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Auto mode replaces the human approval prompt with a reviewer model that sees the
|
|
128
|
+
* conversation, may investigate with read-only tools, and returns a risk/authorization
|
|
129
|
+
* verdict. Enabling it is always a synchronous state flip: no provider call happens
|
|
130
|
+
* until a gated tool is about to run, so turning the mode on can never stall a turn.
|
|
131
|
+
*/
|
|
132
|
+
export function registerAutoModeExtension(
|
|
133
|
+
pi: ExtensionAPI,
|
|
134
|
+
loadConfig: typeof loadToolkitConfig = loadToolkitConfig,
|
|
135
|
+
requestReview: typeof requestToolReview = requestToolReview,
|
|
136
|
+
): void {
|
|
137
|
+
if (registeredApis.has(pi)) return;
|
|
138
|
+
registeredApis.add(pi);
|
|
139
|
+
|
|
140
|
+
const runtime: AutoModeRuntimeState = createRuntimeState(DEFAULT_AUTO_MODE_CONFIG);
|
|
141
|
+
const breaker: RejectionBreakerState = createRejectionBreaker();
|
|
142
|
+
const tracker: ScoreTracker = createScoreTracker();
|
|
143
|
+
|
|
144
|
+
let callIndex = 0;
|
|
145
|
+
let scoringInFlight = false;
|
|
146
|
+
|
|
147
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
148
|
+
if (!ctx.hasUI) return;
|
|
149
|
+
ctx.ui.setStatus(
|
|
150
|
+
AUTO_MODE_STATUS_KEY,
|
|
151
|
+
runtime.engaged ? `auto-review: ${describeGate(runtime)}` : undefined,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function recordDecision(data: Omit<AutoModeDecisionRecord, "timestamp">): void {
|
|
156
|
+
pi.appendEntry(AUTO_MODE_ENTRY_TYPE, { timestamp: Date.now(), ...data } satisfies AutoModeDecisionRecord);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function engage(ctx: ExtensionContext, config: AutoModeConfig): boolean {
|
|
160
|
+
if (!isAutoModeEligible(ctx.model, config)) {
|
|
161
|
+
if (ctx.hasUI) {
|
|
162
|
+
ctx.ui.notify(
|
|
163
|
+
"Auto mode needs an allowlisted model in autoMode.models and a configured autoMode.reviewerModel.",
|
|
164
|
+
"warning",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
runtime.engaged = true;
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function disengage(ctx: ExtensionContext): void {
|
|
174
|
+
if (!runtime.engaged) return;
|
|
175
|
+
runtime.engaged = false;
|
|
176
|
+
resetScoreTracker(tracker);
|
|
177
|
+
updateStatus(ctx);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function reviewerRegistry(ctx: ExtensionContext): ReviewerRegistry {
|
|
181
|
+
return ctx.modelRegistry;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Reviewer verdicts feed the breaker; a classifier fast allow does not, because
|
|
186
|
+
* the classifier never denies anything and must not be able to mask a streak.
|
|
187
|
+
*/
|
|
188
|
+
function evaluateOutcome(
|
|
189
|
+
outcome: ReviewOutcome,
|
|
190
|
+
pending: { toolName: string; toolCallId: string; reviewerModelSpec: string },
|
|
191
|
+
ctx: ExtensionContext,
|
|
192
|
+
breakerConfig: AutoModeConfig["circuitBreaker"],
|
|
193
|
+
): GateDecision {
|
|
194
|
+
const reviewerModel = outcome.kind === "unavailable" ? pending.reviewerModelSpec : outcome.reviewerModel;
|
|
195
|
+
|
|
196
|
+
if (outcome.kind === "allow") {
|
|
197
|
+
recordNonDenial(breaker);
|
|
198
|
+
recordDecision({
|
|
199
|
+
toolName: pending.toolName,
|
|
200
|
+
toolCallId: pending.toolCallId,
|
|
201
|
+
decision: "allow",
|
|
202
|
+
reason: boundReviewText(outcome.verdict.rationale, MAX_REVIEW_REASON_CHARS),
|
|
203
|
+
reviewerModel,
|
|
204
|
+
source: "reviewer",
|
|
205
|
+
riskLevel: outcome.verdict.riskLevel,
|
|
206
|
+
userAuthorization: outcome.verdict.userAuthorization,
|
|
207
|
+
evidenceRounds: outcome.evidenceRounds,
|
|
208
|
+
});
|
|
209
|
+
return { kind: "pass" };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (outcome.kind === "deny") {
|
|
213
|
+
const reason = boundReviewText(outcome.verdict.rationale, MAX_REVIEW_REASON_CHARS);
|
|
214
|
+
recordDecision({
|
|
215
|
+
toolName: pending.toolName,
|
|
216
|
+
toolCallId: pending.toolCallId,
|
|
217
|
+
decision: "deny",
|
|
218
|
+
reason,
|
|
219
|
+
reviewerModel,
|
|
220
|
+
source: "reviewer",
|
|
221
|
+
riskLevel: outcome.verdict.riskLevel,
|
|
222
|
+
userAuthorization: outcome.verdict.userAuthorization,
|
|
223
|
+
evidenceRounds: outcome.evidenceRounds,
|
|
224
|
+
});
|
|
225
|
+
const breakerAction = recordDenial(breaker, breakerConfig, pending.toolName);
|
|
226
|
+
const risk = `Risk: ${outcome.verdict.riskLevel}. User authorization: ${outcome.verdict.userAuthorization}.`;
|
|
227
|
+
if (breakerAction === "interrupt") {
|
|
228
|
+
recordDecision({
|
|
229
|
+
toolName: pending.toolName,
|
|
230
|
+
toolCallId: pending.toolCallId,
|
|
231
|
+
decision: "turn-interrupted",
|
|
232
|
+
reason: `Repeated reviewer denials in this turn; the turn was stopped.`,
|
|
233
|
+
reviewerModel,
|
|
234
|
+
source: "circuit-breaker",
|
|
235
|
+
});
|
|
236
|
+
return {
|
|
237
|
+
kind: "block",
|
|
238
|
+
terminate: true,
|
|
239
|
+
reason:
|
|
240
|
+
`Approval reviewer denied this action (${risk} Reason: ${reason}). ` +
|
|
241
|
+
"Repeated denials in this turn mean the current approach is not acceptable. " +
|
|
242
|
+
"Stop, explain the blocker to the user, and ask how to proceed.",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
kind: "block",
|
|
247
|
+
reason: `This action was rejected by the approval reviewer. ${risk} Reason: ${reason} ${GUARDIAN_REJECTION_INSTRUCTIONS}`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const reason = boundReviewText(outcome.reason, MAX_REVIEW_REASON_CHARS);
|
|
252
|
+
const detail = `Review status: ${outcome.cause}. ${reason}`;
|
|
253
|
+
|
|
254
|
+
if (ctx.signal?.aborted) {
|
|
255
|
+
recordDecision({
|
|
256
|
+
toolName: pending.toolName,
|
|
257
|
+
toolCallId: pending.toolCallId,
|
|
258
|
+
decision: "unavailable-blocked",
|
|
259
|
+
reason,
|
|
260
|
+
reviewerModel,
|
|
261
|
+
source: "policy",
|
|
262
|
+
});
|
|
263
|
+
return { kind: "block", reason: `Auto-mode review was cancelled, so the action did not run. ${detail}` };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Fail closed without an interactive surface: print and JSON modes cannot ask.
|
|
267
|
+
if (!ctx.hasUI) {
|
|
268
|
+
recordDecision({
|
|
269
|
+
toolName: pending.toolName,
|
|
270
|
+
toolCallId: pending.toolCallId,
|
|
271
|
+
decision: "unavailable-blocked",
|
|
272
|
+
reason,
|
|
273
|
+
reviewerModel,
|
|
274
|
+
source: "policy",
|
|
275
|
+
});
|
|
276
|
+
return {
|
|
277
|
+
kind: "block",
|
|
278
|
+
reason:
|
|
279
|
+
`The action did not run because no interactive surface is available to confirm it. ${detail} ` +
|
|
280
|
+
REVIEW_INCOMPLETE_INSTRUCTIONS,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { kind: "ask-human", reason: detail };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Non-blocking trajectory classification. Fired after a gated tool has run, so
|
|
289
|
+
* the next gated call can be satisfied from a cached score instead of waiting on
|
|
290
|
+
* a full review. A failed sample is recorded as a failure, never as low risk.
|
|
291
|
+
*/
|
|
292
|
+
function scheduleClassification(
|
|
293
|
+
ctx: ExtensionContext,
|
|
294
|
+
config: AutoModeConfig,
|
|
295
|
+
completed: { toolName: string; toolInput: unknown },
|
|
296
|
+
): void {
|
|
297
|
+
if (!config.classifier.enabled || scoringInFlight) return;
|
|
298
|
+
const modelSpec = config.classifier.model ?? config.reviewerModel;
|
|
299
|
+
if (!modelSpec) return;
|
|
300
|
+
|
|
301
|
+
const entries = ctx.sessionManager.buildContextEntries();
|
|
302
|
+
const { text: transcript } = transcriptFromEntries(entries);
|
|
303
|
+
const authVersion = userAuthorizationVersion(entries as never);
|
|
304
|
+
const scoredAtCall = callIndex;
|
|
305
|
+
scoringInFlight = true;
|
|
306
|
+
|
|
307
|
+
void classifyTrajectory({
|
|
308
|
+
registry: reviewerRegistry(ctx),
|
|
309
|
+
modelSpec,
|
|
310
|
+
prompt: buildClassifierPrompt({
|
|
311
|
+
transcript,
|
|
312
|
+
pendingToolName: completed.toolName,
|
|
313
|
+
pendingToolInput: completed.toolInput,
|
|
314
|
+
cwd: ctx.cwd,
|
|
315
|
+
}),
|
|
316
|
+
timeoutMs: config.classifier.timeoutMs,
|
|
317
|
+
signal: ctx.signal,
|
|
318
|
+
})
|
|
319
|
+
.then((result) => {
|
|
320
|
+
if (result.kind === "failed") {
|
|
321
|
+
recordFailedCall(tracker, scoredAtCall);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
recordScoredCall(tracker, {
|
|
325
|
+
risk: result.kind,
|
|
326
|
+
scoredAtCall,
|
|
327
|
+
authorizationVersion: authVersion,
|
|
328
|
+
sampledAt: Date.now(),
|
|
329
|
+
});
|
|
330
|
+
})
|
|
331
|
+
.catch(() => {
|
|
332
|
+
recordFailedCall(tracker, scoredAtCall);
|
|
333
|
+
})
|
|
334
|
+
.finally(() => {
|
|
335
|
+
scoringInFlight = false;
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
pi.registerFlag(AUTO_MODE_FLAG, {
|
|
340
|
+
description: "Start with auto mode: a reviewer model approves gated tool calls instead of you",
|
|
341
|
+
type: "boolean",
|
|
342
|
+
default: false,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
pi.registerCommand(AUTO_MODE_COMMAND, {
|
|
346
|
+
description: "Model-reviewed tool approval: /auto [on|off|status|all|side]",
|
|
347
|
+
handler: async (args, ctx) => {
|
|
348
|
+
const { config } = loadConfig();
|
|
349
|
+
const auto = config.autoMode;
|
|
350
|
+
applyConfiguredGate(runtime, auto);
|
|
351
|
+
const sub = args.trim().toLowerCase();
|
|
352
|
+
|
|
353
|
+
switch (sub) {
|
|
354
|
+
case "on":
|
|
355
|
+
if (engage(ctx, auto)) ctx.ui.notify(`Auto mode on. Reviewing: ${describeGate(runtime)}`, "info");
|
|
356
|
+
break;
|
|
357
|
+
case "off":
|
|
358
|
+
runtime.engaged = false;
|
|
359
|
+
setGateOverride(runtime, auto, undefined);
|
|
360
|
+
resetScoreTracker(tracker);
|
|
361
|
+
ctx.ui.notify("Auto mode off.", "info");
|
|
362
|
+
break;
|
|
363
|
+
case "all":
|
|
364
|
+
setGateOverride(runtime, auto, "all");
|
|
365
|
+
if (engage(ctx, auto)) ctx.ui.notify("Auto mode on. Reviewing all tools.", "info");
|
|
366
|
+
break;
|
|
367
|
+
case "side":
|
|
368
|
+
setGateOverride(runtime, auto, "side-effect");
|
|
369
|
+
ctx.ui.notify(`Reviewing side-effect tools only: ${describeGate(runtime)}`, "info");
|
|
370
|
+
break;
|
|
371
|
+
case "":
|
|
372
|
+
case "status":
|
|
373
|
+
ctx.ui.notify(
|
|
374
|
+
runtime.engaged
|
|
375
|
+
? [
|
|
376
|
+
`Auto mode on. Reviewing: ${describeGate(runtime)}.`,
|
|
377
|
+
`Reviewer: ${auto.reviewerModel ?? "unset"} (transcript ${auto.transcript ? "on" : "off"}, read-only evidence ${auto.evidenceTools ? "on" : "off"}).`,
|
|
378
|
+
`Pre-scorer: ${auto.classifier.enabled ? `${auto.classifier.model ?? auto.reviewerModel ?? "unset"} (max lag ${auto.classifier.maxLag})` : "off"}.`,
|
|
379
|
+
`Circuit breaker: ${auto.circuitBreaker.consecutiveDenials} consecutive / ${auto.circuitBreaker.recentDenials} in ${auto.circuitBreaker.windowSize}.`,
|
|
380
|
+
].join(" ")
|
|
381
|
+
: `Auto mode off. Eligible: ${isAutoModeEligible(ctx.model, auto) ? "yes" : "no"}.`,
|
|
382
|
+
"info",
|
|
383
|
+
);
|
|
384
|
+
break;
|
|
385
|
+
default:
|
|
386
|
+
ctx.ui.notify("Usage: /auto [on|off|status|all|side]", "warning");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
updateStatus(ctx);
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
pi.on("session_start", (_event, ctx) => {
|
|
394
|
+
const { config } = loadConfig();
|
|
395
|
+
setGateOverride(runtime, config.autoMode, undefined);
|
|
396
|
+
applyConfiguredGate(runtime, config.autoMode);
|
|
397
|
+
resetScoreTracker(tracker);
|
|
398
|
+
resetRejectionBreaker(breaker);
|
|
399
|
+
callIndex = 0;
|
|
400
|
+
if (pi.getFlag(AUTO_MODE_FLAG)) engage(ctx, config.autoMode);
|
|
401
|
+
updateStatus(ctx);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
pi.on("model_select", (event, ctx) => {
|
|
405
|
+
const { config } = loadConfig();
|
|
406
|
+
if (runtime.engaged && !isAutoModeEligible(event.model, config.autoMode)) {
|
|
407
|
+
disengage(ctx);
|
|
408
|
+
if (ctx.hasUI) ctx.ui.notify("Auto mode off: this model is not allowlisted.", "warning");
|
|
409
|
+
}
|
|
410
|
+
updateStatus(ctx);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
414
|
+
const { config } = loadConfig();
|
|
415
|
+
applyConfiguredGate(runtime, config.autoMode);
|
|
416
|
+
if (runtime.engaged && !isAutoModeEligible(ctx.model, config.autoMode)) {
|
|
417
|
+
disengage(ctx);
|
|
418
|
+
}
|
|
419
|
+
updateStatus(ctx);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// The breaker judges one turn's denial pattern, so it starts clean each turn.
|
|
423
|
+
pi.on("turn_start", () => {
|
|
424
|
+
resetRejectionBreaker(breaker);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
428
|
+
callIndex += 1;
|
|
429
|
+
if (!runtime.engaged) return undefined;
|
|
430
|
+
|
|
431
|
+
const { config } = loadConfig();
|
|
432
|
+
const auto = config.autoMode;
|
|
433
|
+
applyConfiguredGate(runtime, auto);
|
|
434
|
+
|
|
435
|
+
if (!isAutoModeEligible(ctx.model, auto)) {
|
|
436
|
+
disengage(ctx);
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
if (!shouldReviewTool(event.toolName, runtime)) return undefined;
|
|
440
|
+
|
|
441
|
+
const pending = {
|
|
442
|
+
toolName: event.toolName,
|
|
443
|
+
toolCallId: event.toolCallId,
|
|
444
|
+
reviewerModelSpec: auto.reviewerModel ?? "",
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// A repeat of a tool already denied this turn is the shape of a workaround,
|
|
448
|
+
// so it never rides the pre-scorer fast path.
|
|
449
|
+
const requiresSynchronousReview = wasDeniedThisTurn(breaker, event.toolName);
|
|
450
|
+
if (auto.classifier.enabled && !requiresSynchronousReview) {
|
|
451
|
+
const fastEntries = ctx.sessionManager.buildContextEntries();
|
|
452
|
+
const fast = fastApprovalEligible({
|
|
453
|
+
tracker,
|
|
454
|
+
currentCallIndex: callIndex,
|
|
455
|
+
authorizationVersion: userAuthorizationVersion(fastEntries as never),
|
|
456
|
+
maxLag: auto.classifier.maxLag,
|
|
457
|
+
});
|
|
458
|
+
if (fast.eligible) {
|
|
459
|
+
recordDecision({
|
|
460
|
+
toolName: pending.toolName,
|
|
461
|
+
toolCallId: pending.toolCallId,
|
|
462
|
+
decision: "allow",
|
|
463
|
+
reason: "Non-blocking trajectory pre-score reported low risk for this position.",
|
|
464
|
+
reviewerModel: boundReviewText(auto.classifier.model ?? auto.reviewerModel ?? "", MAX_REVIEW_REASON_CHARS),
|
|
465
|
+
source: "classifier",
|
|
466
|
+
fastDecision: fast.reason,
|
|
467
|
+
});
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const entries = ctx.sessionManager.buildContextEntries();
|
|
473
|
+
const transcript = auto.transcript ? transcriptFromEntries(entries).text : undefined;
|
|
474
|
+
|
|
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
|
+
});
|
|
487
|
+
|
|
488
|
+
const decision = evaluateOutcome(outcome, pending, ctx, auto.circuitBreaker);
|
|
489
|
+
if (decision.kind === "ask-human") {
|
|
490
|
+
const confirmed = await ctx.ui.confirm(
|
|
491
|
+
"Auto-mode review unavailable",
|
|
492
|
+
`${decision.reason}\n\nAllow ${event.toolName} to run?`,
|
|
493
|
+
);
|
|
494
|
+
if (confirmed) recordNonDenial(breaker);
|
|
495
|
+
recordDecision({
|
|
496
|
+
toolName: event.toolName,
|
|
497
|
+
toolCallId: event.toolCallId,
|
|
498
|
+
decision: confirmed ? "unavailable-allowed" : "unavailable-blocked",
|
|
499
|
+
reason: decision.reason,
|
|
500
|
+
reviewerModel: pending.reviewerModelSpec,
|
|
501
|
+
source: "human",
|
|
502
|
+
});
|
|
503
|
+
return confirmed
|
|
504
|
+
? undefined
|
|
505
|
+
: { block: true, reason: "Blocked by user after auto-mode review could not complete." };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
updateStatus(ctx);
|
|
509
|
+
return decision.kind === "block"
|
|
510
|
+
? { block: true, reason: decision.reason, ...(decision.terminate ? { terminate: true } : {}) }
|
|
511
|
+
: undefined;
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
pi.on("tool_result", (event, ctx) => {
|
|
515
|
+
if (!runtime.engaged) return undefined;
|
|
516
|
+
const { config } = loadConfig();
|
|
517
|
+
const auto = config.autoMode;
|
|
518
|
+
if (auto.classifier.enabled && shouldReviewTool(event.toolName, runtime)) {
|
|
519
|
+
scheduleClassification(ctx, auto, {
|
|
520
|
+
toolName: event.toolName,
|
|
521
|
+
toolInput: (event as { input?: unknown }).input,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return undefined;
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export default function autoModeExtension(pi: ExtensionAPI): void {
|
|
529
|
+
registerAutoModeExtension(pi);
|
|
530
|
+
}
|
|
@@ -0,0 +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
|
+
}
|