pi-openai-toolkit 0.14.8 → 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 +1 -1
- package/README.zh.md +1 -1
- package/docs/internals.md +18 -0
- 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 +2 -1
- 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 -31
- 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 -291
- package/src/context-management/window-budget.ts +83 -83
- package/src/context-management/window-request.ts +125 -125
- 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/pi-context-hook.ts +116 -116
- package/src/provider-headers.ts +23 -23
- package/src/web-search/alpha-client.ts +875 -875
- package/src/web-search/extension.ts +523 -523
- package/src/web-search/payload.ts +295 -295
- package/src/web-search/prompt.ts +59 -59
- package/src/web-search/types.ts +144 -144
package/README.md
CHANGED
|
@@ -199,7 +199,7 @@ Allow a model and reviewer in `autoMode`:
|
|
|
199
199
|
}
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
-
Use `/auto on` in the session. The default `side-effect` gate reviews `bash`, `write`, `edit`, and configured extra tools. Set `gate` to `"all"` when every tool call needs review. A reviewer timeout does not approve a call.
|
|
202
|
+
Use `/auto on` in the session, or start Pi with `--auto`. The TUI shows an activation notice and temporarily changes the working indicator to `Auto mode: reviewing <tool>` while a gated call is being reviewed; the footer keeps the active gate visible. On Pi versions that expose the compatible tool renderer, each tool block in Auto Mode also gets a bottom line: reviewed calls show states such as `allowed by reviewer · low risk · authorization medium` or `denied · <reason>`, while calls outside the configured gate show `not reviewed · outside the configured gate`. If that renderer seam is unavailable, the extension warns once and keeps the footer-only status. The default `side-effect` gate reviews `bash`, `write`, `edit`, and configured extra tools. Set `gate` to `"all"` when every tool call needs review. A reviewer timeout does not approve a call.
|
|
203
203
|
|
|
204
204
|
## Common configuration
|
|
205
205
|
|
package/README.zh.md
CHANGED
|
@@ -199,7 +199,7 @@ standalone 路由属于实验性的 CPA/Codex 网关协议,不是稳定的公
|
|
|
199
199
|
}
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
-
在会话中使用 `/auto on
|
|
202
|
+
在会话中使用 `/auto on`,也可以用 `--auto` 启动 Pi。TUI 会显示自动模式已开启的提示;每次审查受控工具时,工作指示器会临时显示 `Auto mode: reviewing <tool>`,底部状态栏会保留当前审查范围。在支持兼容 tool renderer 的 Pi 版本中,自动模式下每个工具块底部都会显示一行状态:已审查的调用会显示 `allowed by reviewer · low risk · authorization medium` 或 `denied · <reason>`,不在当前审查范围内的调用会显示 `not reviewed · outside the configured gate`。如果当前 Pi 没有这个 renderer seam,扩展会告警一次并继续使用底部状态栏提示。默认的 `side-effect` 审查范围覆盖 `bash`、`write`、`edit` 和额外配置的工具。如果需要审查所有工具调用,将 `gate` 设置为 `"all"`。审查超时不会自动放行调用。
|
|
203
203
|
|
|
204
204
|
## 常用配置
|
|
205
205
|
|
package/docs/internals.md
CHANGED
|
@@ -9,6 +9,7 @@ Developer-facing reference for [pi-openai-toolkit](../README.md). The README kee
|
|
|
9
9
|
- [Remote Context protocol](#remote-context-protocol)
|
|
10
10
|
- [Rollover lifecycle](#rollover-lifecycle)
|
|
11
11
|
- [Remote Compaction v2 wire contract](#remote-compaction-v2-wire-contract)
|
|
12
|
+
- [Auto Mode TUI review renderer](#auto-mode-tui-review-renderer)
|
|
12
13
|
- [Artifacts and debugging](#artifacts-and-debugging)
|
|
13
14
|
- [Provenance](#provenance)
|
|
14
15
|
|
|
@@ -85,6 +86,23 @@ New checkpoints record `inputProvenance: "pi-context-hook-v1"` or `"legacy-raw-c
|
|
|
85
86
|
|
|
86
87
|
---
|
|
87
88
|
|
|
89
|
+
### Auto Mode TUI review renderer
|
|
90
|
+
|
|
91
|
+
Auto Mode owns the review decision, but Pi owns the built-in tool-block component. Pi 0.85.1 exports `ToolExecutionComponent` without a public decorator interface, so `src/auto-mode/tool-review-tui.ts` installs a narrow, idempotent compatibility patch on its `render()` method. The patch only reads the component's existing tool-call ID and appends one bounded, single-line status after the normal block output; it never changes tool execution, event ordering, or provider payloads.
|
|
92
|
+
|
|
93
|
+
The extension updates an ephemeral per-call state through the same `tool_call` lifecycle that performs the review:
|
|
94
|
+
|
|
95
|
+
- `skipped` when the tool is outside the configured Auto Mode gate;
|
|
96
|
+
- `reviewing` while the reviewer model is running;
|
|
97
|
+
- `awaiting-user` when an unavailable review reaches interactive confirmation;
|
|
98
|
+
- `allowed`, `denied`, or `blocked` after the final decision.
|
|
99
|
+
|
|
100
|
+
The state is intentionally not persisted to the session. It is bounded to recent calls and cleared at `session_start`, so reloading a session does not invent historical approval claims. The renderer uses Pi's current theme when available, truncates untrusted rationale text, and preserves the original render output when no state exists.
|
|
101
|
+
|
|
102
|
+
This is a version-coupled adapter, not a stable Pi extension contract. It is feature-detected at registration time and marked with a global symbol so extension reloads do not wrap the class repeatedly. If the exported component shape changes or becomes non-writable, the adapter becomes a no-op, emits one warning in TUI mode, and the existing footer/working-message review indicators remain the fallback.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
88
106
|
### Artifacts and debugging
|
|
89
107
|
|
|
90
108
|
With `debug: true`, the toolkit writes lifecycle and compaction artifacts under `artifactRoot` (default `~/.pi/agent/artifacts/pi-openai-toolkit/compaction`):
|
package/extensions/auto-mode.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from "../src/auto-mode/extension.ts";
|
|
1
|
+
export { default } from "../src/auto-mode/extension.ts";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from "../src/codex-astra/extension.ts";
|
|
1
|
+
export { default } from "../src/codex-astra/extension.ts";
|
package/extensions/compaction.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from "../src/extension-runtime.ts";
|
|
1
|
+
export { default } from "../src/extension-runtime.ts";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from "../src/image-generation/extension.ts";
|
|
1
|
+
export { default } from "../src/image-generation/extension.ts";
|
package/extensions/web-search.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from "../src/web-search/extension.ts";
|
|
1
|
+
export { default } from "../src/web-search/extension.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-openai-toolkit",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.9",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"src/image-generation/service.ts",
|
|
75
75
|
"src/image-generation/types.ts",
|
|
76
76
|
"src/auto-mode/extension.ts",
|
|
77
|
+
"src/auto-mode/tool-review-tui.ts",
|
|
77
78
|
"src/auto-mode/policy.ts",
|
|
78
79
|
"src/auto-mode/prompt.ts",
|
|
79
80
|
"src/auto-mode/transcript.ts",
|
|
@@ -1,165 +1,165 @@
|
|
|
1
|
-
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
-
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { parseModelSpec } from "../runtime";
|
|
4
|
-
import { CLASSIFIER_SYSTEM_PROMPT } from "./prompt";
|
|
5
|
-
import { boundReviewText, type ClassifierRisk, type FastDecisionReason } from "./types";
|
|
6
|
-
|
|
7
|
-
/** One completed classification of the trajectory, tagged to a tool-call position. */
|
|
8
|
-
export type ScoreRecord = {
|
|
9
|
-
risk: ClassifierRisk;
|
|
10
|
-
/** Tool-call counter value when the sample was taken. */
|
|
11
|
-
scoredAtCall: number;
|
|
12
|
-
/** Fingerprint of the user turns the score was based on. */
|
|
13
|
-
authorizationVersion: string;
|
|
14
|
-
sampledAt: number;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Mutable per-session tracker for the non-blocking pre-scorer.
|
|
19
|
-
*
|
|
20
|
-
* The counters reproduce Codex's staleness rule: the agent keeps running while a
|
|
21
|
-
* classification is in flight, so a score is only trustworthy if it is recent
|
|
22
|
-
* enough and nothing the user said has changed underneath it.
|
|
23
|
-
*/
|
|
24
|
-
export type ScoreTracker = {
|
|
25
|
-
latestCallIndex: number;
|
|
26
|
-
latestScoredIndex: number;
|
|
27
|
-
latestFailedIndex: number;
|
|
28
|
-
score?: ScoreRecord;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export function createScoreTracker(): ScoreTracker {
|
|
32
|
-
return { latestCallIndex: 0, latestScoredIndex: 0, latestFailedIndex: 0 };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Reset everything: a new turn or a disengaged mode invalidates prior scoring. */
|
|
36
|
-
export function resetScoreTracker(tracker: ScoreTracker): void {
|
|
37
|
-
tracker.latestCallIndex = 0;
|
|
38
|
-
tracker.latestScoredIndex = 0;
|
|
39
|
-
tracker.latestFailedIndex = 0;
|
|
40
|
-
tracker.score = undefined;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Fingerprint of the user-authored conversation. Any new or edited user turn makes
|
|
45
|
-
* an existing score stale, because the score's authorization premise changed.
|
|
46
|
-
*/
|
|
47
|
-
export function authorizationVersion(userText: string): string {
|
|
48
|
-
const bounded = boundReviewText(userText, 8_000);
|
|
49
|
-
let hash = 2166136261;
|
|
50
|
-
for (let index = 0; index < bounded.length; index += 1) {
|
|
51
|
-
hash ^= bounded.charCodeAt(index);
|
|
52
|
-
hash = Math.imul(hash, 16777619);
|
|
53
|
-
}
|
|
54
|
-
return `${bounded.length}:${(hash >>> 0).toString(36)}`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function recordScoredCall(tracker: ScoreTracker, score: ScoreRecord): void {
|
|
58
|
-
tracker.score = score;
|
|
59
|
-
tracker.latestScoredIndex = Math.max(tracker.latestScoredIndex, score.scoredAtCall);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function recordFailedCall(tracker: ScoreTracker, scoredAtCall: number): void {
|
|
63
|
-
tracker.latestFailedIndex = Math.max(tracker.latestFailedIndex, scoredAtCall);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export type FastDecisionInput = {
|
|
67
|
-
tracker: ScoreTracker;
|
|
68
|
-
/** Tool-call counter value for the call being gated. */
|
|
69
|
-
currentCallIndex: number;
|
|
70
|
-
authorizationVersion: string;
|
|
71
|
-
maxLag: number;
|
|
72
|
-
/** Retries after a denial and widened gates must not take the fast path. */
|
|
73
|
-
requiresSynchronousReview?: boolean;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Decide whether a cached classification can satisfy this gated call without
|
|
78
|
-
* running the blocking reviewer. Anything other than `low_risk` defers to the
|
|
79
|
-
* synchronous review; the classifier never denies an action on its own.
|
|
80
|
-
*/
|
|
81
|
-
export function fastApprovalEligible(input: FastDecisionInput): {
|
|
82
|
-
eligible: boolean;
|
|
83
|
-
reason: FastDecisionReason;
|
|
84
|
-
} {
|
|
85
|
-
const defer = (reason: FastDecisionReason) => ({ eligible: false, reason });
|
|
86
|
-
|
|
87
|
-
if (input.requiresSynchronousReview) return defer("requires_synchronous_review");
|
|
88
|
-
if (input.tracker.latestFailedIndex > input.tracker.latestScoredIndex) return defer("scoring_failure");
|
|
89
|
-
|
|
90
|
-
const score = input.tracker.score;
|
|
91
|
-
if (!score) return defer("missing_score");
|
|
92
|
-
|
|
93
|
-
const lag = input.currentCallIndex - score.scoredAtCall;
|
|
94
|
-
if (lag > input.maxLag) return defer("stale_score");
|
|
95
|
-
if (score.authorizationVersion !== input.authorizationVersion) return defer("authorization_changed");
|
|
96
|
-
if (score.risk === "low") return { eligible: true, reason: "low_risk" };
|
|
97
|
-
return defer("elevated_risk");
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export type ClassificationResult =
|
|
101
|
-
| { kind: ClassifierRisk; model: string }
|
|
102
|
-
| { kind: "failed"; reason: string };
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Run one non-blocking classification. Output is a single token; anything else is a
|
|
106
|
-
* failed sample rather than a verdict, so a confused classifier costs a review and
|
|
107
|
-
* never an approval or a denial.
|
|
108
|
-
*/
|
|
109
|
-
export async function classifyTrajectory(params: {
|
|
110
|
-
registry: Pick<ModelRegistry, "find" | "complete">;
|
|
111
|
-
modelSpec: string;
|
|
112
|
-
prompt: string;
|
|
113
|
-
timeoutMs: number;
|
|
114
|
-
signal?: AbortSignal;
|
|
115
|
-
}): Promise<ClassificationResult> {
|
|
116
|
-
const parsed = parseModelSpec(params.modelSpec);
|
|
117
|
-
if (!parsed) {
|
|
118
|
-
return { kind: "failed", reason: `autoMode.classifier.model "${params.modelSpec}" is not provider/model-id.` };
|
|
119
|
-
}
|
|
120
|
-
const model = params.registry.find(parsed.provider, parsed.modelId) as Model<Api> | undefined;
|
|
121
|
-
if (!model) {
|
|
122
|
-
return { kind: "failed", reason: `Classifier model ${params.modelSpec} is not available.` };
|
|
123
|
-
}
|
|
124
|
-
const label = boundReviewText(`${model.provider}/${model.id}`, 256);
|
|
125
|
-
|
|
126
|
-
const controller = new AbortController();
|
|
127
|
-
const abortFromCaller = () => controller.abort();
|
|
128
|
-
if (params.signal?.aborted) controller.abort();
|
|
129
|
-
else params.signal?.addEventListener("abort", abortFromCaller);
|
|
130
|
-
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
const response = await params.registry.complete(
|
|
134
|
-
model,
|
|
135
|
-
{
|
|
136
|
-
systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
|
|
137
|
-
messages: [
|
|
138
|
-
{
|
|
139
|
-
role: "user",
|
|
140
|
-
content: [{ type: "text", text: params.prompt }],
|
|
141
|
-
timestamp: Date.now(),
|
|
142
|
-
},
|
|
143
|
-
],
|
|
144
|
-
},
|
|
145
|
-
{ signal: controller.signal, cacheRetention: "none" },
|
|
146
|
-
);
|
|
147
|
-
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
148
|
-
return { kind: "failed", reason: response.errorMessage ?? `Classifier stopped: ${response.stopReason}` };
|
|
149
|
-
}
|
|
150
|
-
const text = response.content
|
|
151
|
-
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
152
|
-
.map((block) => block.text)
|
|
153
|
-
.join("")
|
|
154
|
-
.trim()
|
|
155
|
-
.toLowerCase();
|
|
156
|
-
if (/^high\b/.test(text)) return { kind: "high", model: label };
|
|
157
|
-
if (/^low\b/.test(text)) return { kind: "low", model: label };
|
|
158
|
-
return { kind: "failed", reason: `Classifier returned unreadable output: ${boundReviewText(text, 80)}` };
|
|
159
|
-
} catch (error) {
|
|
160
|
-
return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
|
|
161
|
-
} finally {
|
|
162
|
-
clearTimeout(timer);
|
|
163
|
-
params.signal?.removeEventListener("abort", abortFromCaller);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { parseModelSpec } from "../runtime";
|
|
4
|
+
import { CLASSIFIER_SYSTEM_PROMPT } from "./prompt";
|
|
5
|
+
import { boundReviewText, type ClassifierRisk, type FastDecisionReason } from "./types";
|
|
6
|
+
|
|
7
|
+
/** One completed classification of the trajectory, tagged to a tool-call position. */
|
|
8
|
+
export type ScoreRecord = {
|
|
9
|
+
risk: ClassifierRisk;
|
|
10
|
+
/** Tool-call counter value when the sample was taken. */
|
|
11
|
+
scoredAtCall: number;
|
|
12
|
+
/** Fingerprint of the user turns the score was based on. */
|
|
13
|
+
authorizationVersion: string;
|
|
14
|
+
sampledAt: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Mutable per-session tracker for the non-blocking pre-scorer.
|
|
19
|
+
*
|
|
20
|
+
* The counters reproduce Codex's staleness rule: the agent keeps running while a
|
|
21
|
+
* classification is in flight, so a score is only trustworthy if it is recent
|
|
22
|
+
* enough and nothing the user said has changed underneath it.
|
|
23
|
+
*/
|
|
24
|
+
export type ScoreTracker = {
|
|
25
|
+
latestCallIndex: number;
|
|
26
|
+
latestScoredIndex: number;
|
|
27
|
+
latestFailedIndex: number;
|
|
28
|
+
score?: ScoreRecord;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function createScoreTracker(): ScoreTracker {
|
|
32
|
+
return { latestCallIndex: 0, latestScoredIndex: 0, latestFailedIndex: 0 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Reset everything: a new turn or a disengaged mode invalidates prior scoring. */
|
|
36
|
+
export function resetScoreTracker(tracker: ScoreTracker): void {
|
|
37
|
+
tracker.latestCallIndex = 0;
|
|
38
|
+
tracker.latestScoredIndex = 0;
|
|
39
|
+
tracker.latestFailedIndex = 0;
|
|
40
|
+
tracker.score = undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fingerprint of the user-authored conversation. Any new or edited user turn makes
|
|
45
|
+
* an existing score stale, because the score's authorization premise changed.
|
|
46
|
+
*/
|
|
47
|
+
export function authorizationVersion(userText: string): string {
|
|
48
|
+
const bounded = boundReviewText(userText, 8_000);
|
|
49
|
+
let hash = 2166136261;
|
|
50
|
+
for (let index = 0; index < bounded.length; index += 1) {
|
|
51
|
+
hash ^= bounded.charCodeAt(index);
|
|
52
|
+
hash = Math.imul(hash, 16777619);
|
|
53
|
+
}
|
|
54
|
+
return `${bounded.length}:${(hash >>> 0).toString(36)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function recordScoredCall(tracker: ScoreTracker, score: ScoreRecord): void {
|
|
58
|
+
tracker.score = score;
|
|
59
|
+
tracker.latestScoredIndex = Math.max(tracker.latestScoredIndex, score.scoredAtCall);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function recordFailedCall(tracker: ScoreTracker, scoredAtCall: number): void {
|
|
63
|
+
tracker.latestFailedIndex = Math.max(tracker.latestFailedIndex, scoredAtCall);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type FastDecisionInput = {
|
|
67
|
+
tracker: ScoreTracker;
|
|
68
|
+
/** Tool-call counter value for the call being gated. */
|
|
69
|
+
currentCallIndex: number;
|
|
70
|
+
authorizationVersion: string;
|
|
71
|
+
maxLag: number;
|
|
72
|
+
/** Retries after a denial and widened gates must not take the fast path. */
|
|
73
|
+
requiresSynchronousReview?: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Decide whether a cached classification can satisfy this gated call without
|
|
78
|
+
* running the blocking reviewer. Anything other than `low_risk` defers to the
|
|
79
|
+
* synchronous review; the classifier never denies an action on its own.
|
|
80
|
+
*/
|
|
81
|
+
export function fastApprovalEligible(input: FastDecisionInput): {
|
|
82
|
+
eligible: boolean;
|
|
83
|
+
reason: FastDecisionReason;
|
|
84
|
+
} {
|
|
85
|
+
const defer = (reason: FastDecisionReason) => ({ eligible: false, reason });
|
|
86
|
+
|
|
87
|
+
if (input.requiresSynchronousReview) return defer("requires_synchronous_review");
|
|
88
|
+
if (input.tracker.latestFailedIndex > input.tracker.latestScoredIndex) return defer("scoring_failure");
|
|
89
|
+
|
|
90
|
+
const score = input.tracker.score;
|
|
91
|
+
if (!score) return defer("missing_score");
|
|
92
|
+
|
|
93
|
+
const lag = input.currentCallIndex - score.scoredAtCall;
|
|
94
|
+
if (lag > input.maxLag) return defer("stale_score");
|
|
95
|
+
if (score.authorizationVersion !== input.authorizationVersion) return defer("authorization_changed");
|
|
96
|
+
if (score.risk === "low") return { eligible: true, reason: "low_risk" };
|
|
97
|
+
return defer("elevated_risk");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type ClassificationResult =
|
|
101
|
+
| { kind: ClassifierRisk; model: string }
|
|
102
|
+
| { kind: "failed"; reason: string };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Run one non-blocking classification. Output is a single token; anything else is a
|
|
106
|
+
* failed sample rather than a verdict, so a confused classifier costs a review and
|
|
107
|
+
* never an approval or a denial.
|
|
108
|
+
*/
|
|
109
|
+
export async function classifyTrajectory(params: {
|
|
110
|
+
registry: Pick<ModelRegistry, "find" | "complete">;
|
|
111
|
+
modelSpec: string;
|
|
112
|
+
prompt: string;
|
|
113
|
+
timeoutMs: number;
|
|
114
|
+
signal?: AbortSignal;
|
|
115
|
+
}): Promise<ClassificationResult> {
|
|
116
|
+
const parsed = parseModelSpec(params.modelSpec);
|
|
117
|
+
if (!parsed) {
|
|
118
|
+
return { kind: "failed", reason: `autoMode.classifier.model "${params.modelSpec}" is not provider/model-id.` };
|
|
119
|
+
}
|
|
120
|
+
const model = params.registry.find(parsed.provider, parsed.modelId) as Model<Api> | undefined;
|
|
121
|
+
if (!model) {
|
|
122
|
+
return { kind: "failed", reason: `Classifier model ${params.modelSpec} is not available.` };
|
|
123
|
+
}
|
|
124
|
+
const label = boundReviewText(`${model.provider}/${model.id}`, 256);
|
|
125
|
+
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const abortFromCaller = () => controller.abort();
|
|
128
|
+
if (params.signal?.aborted) controller.abort();
|
|
129
|
+
else params.signal?.addEventListener("abort", abortFromCaller);
|
|
130
|
+
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const response = await params.registry.complete(
|
|
134
|
+
model,
|
|
135
|
+
{
|
|
136
|
+
systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
|
|
137
|
+
messages: [
|
|
138
|
+
{
|
|
139
|
+
role: "user",
|
|
140
|
+
content: [{ type: "text", text: params.prompt }],
|
|
141
|
+
timestamp: Date.now(),
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
{ signal: controller.signal, cacheRetention: "none" },
|
|
146
|
+
);
|
|
147
|
+
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
148
|
+
return { kind: "failed", reason: response.errorMessage ?? `Classifier stopped: ${response.stopReason}` };
|
|
149
|
+
}
|
|
150
|
+
const text = response.content
|
|
151
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
152
|
+
.map((block) => block.text)
|
|
153
|
+
.join("")
|
|
154
|
+
.trim()
|
|
155
|
+
.toLowerCase();
|
|
156
|
+
if (/^high\b/.test(text)) return { kind: "high", model: label };
|
|
157
|
+
if (/^low\b/.test(text)) return { kind: "low", model: label };
|
|
158
|
+
return { kind: "failed", reason: `Classifier returned unreadable output: ${boundReviewText(text, 80)}` };
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
|
|
161
|
+
} finally {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
params.signal?.removeEventListener("abort", abortFromCaller);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -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) => {
|