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.
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/docs/internals.md +20 -0
- package/package.json +6 -19
- 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 +335 -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 +115 -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 +298 -291
- package/src/context-management/window-budget.ts +83 -83
- package/src/context-management/window-request.ts +125 -125
- package/src/extension-runtime.ts +1 -0
- 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 +15 -3
- package/src/pi-context-hook.ts +116 -116
- package/src/provider-headers.ts +23 -23
- package/src/serializer.ts +127 -5
- 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/tsconfig.check.json +0 -4
- package/extensions/auto-mode.ts +0 -1
- package/extensions/codex-astra.ts +0 -1
- package/extensions/compaction.ts +0 -1
- package/extensions/image-generation.ts +0 -1
- package/extensions/web-search.ts +0 -1
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
|
|
|
@@ -51,6 +52,7 @@ sequenceDiagram
|
|
|
51
52
|
Toolkit->>Toolkit: verify successful notes write in this window
|
|
52
53
|
Toolkit-->>Model: checkpoint receipt with the successful note path
|
|
53
54
|
Toolkit-->>Pi: window marker appended, previous window trim scheduled
|
|
55
|
+
Note over Model: Context switch is complete; read the receipt once, then resume the active task
|
|
54
56
|
Model->>Backend: next request with the new x-codex-window-id
|
|
55
57
|
Pi->>Toolkit: session_before_compact
|
|
56
58
|
Toolkit-->>Pi: no-summary boundary consuming the scheduled trim
|
|
@@ -61,6 +63,7 @@ State invariants the lifecycle code must keep honest:
|
|
|
61
63
|
- The checkpoint gate verifies a successful `notes` `append_to_file` or `write_file` against the persisted session branch after the latest window boundary, so it survives restarts and forks. A response with `ok: false` or `success: false` is not successful, even when HTTP returns 200.
|
|
62
64
|
- The gate uses the live session/branch identity from `ctx.sessionManager`; cached manager identity must not hide a valid current-window pair, and old or foreign-session evidence must remain rejected.
|
|
63
65
|
- Every rollover carries the most recent successful checkpoint path in its handoff message. The optional `thread_hint` is supplemental; a hint failure must not erase the local receipt.
|
|
66
|
+
- A successful rollover closes the pre-rollover checkpoint phase. The first turn in the new window reads the receipt once and resumes the active user task; it must not immediately create another checkpoint or call `new_context` as part of that handoff. A later checkpoint is only for a later rollover.
|
|
64
67
|
- `new_context` has no force escape hatch: a persisted successful notes checkpoint in the current window is always required before rollover. The model must wait for the notes result to be persisted before retrying.
|
|
65
68
|
- After a marker is accepted for sending, the manager keeps a session/window-anchored pending guard until that exact target marker is persisted or the session changes. A duplicate `new_context` during this persistence gap returns `started: false` and sends no second marker; projected/in-memory messages do not retire the guard.
|
|
66
69
|
- Budget checks are skipped until the current window has produced its own assistant usage. Acting on the previous window's usage anchor would burn the once-per-window reminder on a false alarm.
|
|
@@ -85,6 +88,23 @@ New checkpoints record `inputProvenance: "pi-context-hook-v1"` or `"legacy-raw-c
|
|
|
85
88
|
|
|
86
89
|
---
|
|
87
90
|
|
|
91
|
+
### Auto Mode TUI review renderer
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
The extension updates an ephemeral per-call state through the same `tool_call` lifecycle that performs the review:
|
|
96
|
+
|
|
97
|
+
- `skipped` when the tool is outside the configured Auto Mode gate;
|
|
98
|
+
- `reviewing` while the reviewer model is running;
|
|
99
|
+
- `awaiting-user` when an unavailable review reaches interactive confirmation;
|
|
100
|
+
- `allowed`, `denied`, or `blocked` after the final decision.
|
|
101
|
+
|
|
102
|
+
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.
|
|
103
|
+
|
|
104
|
+
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.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
88
108
|
### Artifacts and debugging
|
|
89
109
|
|
|
90
110
|
With `debug: true`, the toolkit writes lifecycle and compaction artifacts under `artifactRoot` (default `~/.pi/agent/artifacts/pi-openai-toolkit/compaction`):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-openai-toolkit",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.10",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -32,20 +32,7 @@
|
|
|
32
32
|
"codex",
|
|
33
33
|
"responses"
|
|
34
34
|
],
|
|
35
|
-
"pi": {
|
|
36
|
-
"extensions": [
|
|
37
|
-
"./extensions/compaction.ts",
|
|
38
|
-
"./extensions/web-search.ts",
|
|
39
|
-
"./extensions/image-generation.ts",
|
|
40
|
-
"./extensions/auto-mode.ts",
|
|
41
|
-
"./extensions/codex-astra.ts"
|
|
42
|
-
]
|
|
43
|
-
},
|
|
44
35
|
"files": [
|
|
45
|
-
"extensions/compaction.ts",
|
|
46
|
-
"extensions/web-search.ts",
|
|
47
|
-
"extensions/image-generation.ts",
|
|
48
|
-
"extensions/auto-mode.ts",
|
|
49
36
|
"src/compact-client.ts",
|
|
50
37
|
"src/compaction-projection.ts",
|
|
51
38
|
"src/pi-context-hook.ts",
|
|
@@ -74,13 +61,13 @@
|
|
|
74
61
|
"src/image-generation/service.ts",
|
|
75
62
|
"src/image-generation/types.ts",
|
|
76
63
|
"src/auto-mode/extension.ts",
|
|
64
|
+
"src/auto-mode/tool-review-tui.ts",
|
|
77
65
|
"src/auto-mode/policy.ts",
|
|
78
66
|
"src/auto-mode/prompt.ts",
|
|
79
67
|
"src/auto-mode/transcript.ts",
|
|
80
68
|
"src/auto-mode/reviewer.ts",
|
|
81
69
|
"src/auto-mode/classifier.ts",
|
|
82
70
|
"src/auto-mode/types.ts",
|
|
83
|
-
"extensions/codex-astra.ts",
|
|
84
71
|
"src/codex-astra/effort-planner.ts",
|
|
85
72
|
"src/codex-astra/extension.ts",
|
|
86
73
|
"src/model-scope.ts",
|
|
@@ -119,10 +106,10 @@
|
|
|
119
106
|
"@earendil-works/pi-tui": ">=0.85.1"
|
|
120
107
|
},
|
|
121
108
|
"devDependencies": {
|
|
122
|
-
"@earendil-works/pi-agent-core": "0.
|
|
123
|
-
"@earendil-works/pi-ai": "0.
|
|
124
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
125
|
-
"@earendil-works/pi-tui": "0.
|
|
109
|
+
"@earendil-works/pi-agent-core": "0.86.0",
|
|
110
|
+
"@earendil-works/pi-ai": "0.86.0",
|
|
111
|
+
"@earendil-works/pi-coding-agent": "0.86.0",
|
|
112
|
+
"@earendil-works/pi-tui": "0.86.0",
|
|
126
113
|
"@types/node": "24.12.4",
|
|
127
114
|
"bun": "1.2.23",
|
|
128
115
|
"typescript": "5.9.3"
|
|
@@ -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
|
+
}
|