pi-openai-toolkit 0.14.5 → 0.14.7
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/docs/internals.md +42 -21
- package/package.json +1 -1
- package/src/auto-mode/classifier.ts +165 -165
- 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/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/context-management/codex-provider.ts +20 -20
- package/src/context-management/history-notes.ts +32 -0
- package/src/context-management/messages.ts +112 -106
- package/src/context-management/namespace-tools.ts +426 -426
- package/src/context-management/tool-contract.ts +23 -23
- package/src/context-management/tools.ts +279 -311
- package/src/context-management/types.ts +7 -0
- package/src/context-management/window-budget.ts +83 -83
- package/src/context-management/window-manager.ts +60 -51
- package/src/context-management/window-request.ts +125 -125
- package/src/debug.ts +1 -1
- package/src/extension-runtime.ts +34 -9
- 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/provider-headers.ts +23 -23
- package/src/responses-headers.ts +76 -11
- package/src/runtime.ts +37 -1
- package/src/web-search/extension.ts +80 -80
- package/src/web-search/payload.ts +96 -96
- package/src/web-search/prompt.ts +20 -20
- package/src/web-search/types.ts +45 -45
package/docs/internals.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Toolkit Internals
|
|
2
2
|
|
|
3
|
-
Developer-facing reference for [pi-openai-toolkit](../README.md).
|
|
3
|
+
Developer-facing reference for [pi-openai-toolkit](../README.md). The README keeps this protocol and implementation detail out of the installation path. If you are changing wire behavior, fixtures, or the compaction strategy code, start here.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
### Table of Contents
|
|
6
8
|
|
|
7
9
|
- [Remote Context protocol](#remote-context-protocol)
|
|
8
10
|
- [Rollover lifecycle](#rollover-lifecycle)
|
|
@@ -10,28 +12,34 @@ Developer-facing reference for [pi-openai-toolkit](../README.md). This is the pr
|
|
|
10
12
|
- [Artifacts and debugging](#artifacts-and-debugging)
|
|
11
13
|
- [Provenance](#provenance)
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
### Remote Context protocol
|
|
14
18
|
|
|
15
19
|
Internal alpha endpoints, adapted from `@howaboua/pi-codex-conversion`:
|
|
16
20
|
|
|
17
21
|
```text
|
|
18
22
|
POST {base}/alpha/history/v2/list_windows | list_items | read_item | search_contents
|
|
19
23
|
POST {base}/alpha/notes/v2/list_files_by_prefix | read_file | search_contents | append_to_file | write_file
|
|
20
|
-
|
|
24
|
+
POST {base}/alpha/notes/v2/thread_hint
|
|
21
25
|
```
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
The native route uses `{base} = <backend-api>/codex` with `Authorization: Bearer <oauth token>`, `ChatGPT-Account-ID`, and Codex CLI headers. The gateway route uses the configured `/v1` base with the API key configured for that provider, `originator: codex_cli_rs`, `X-Codex-Affinity-Scope`, and `X-Codex-Model` carrying the bare model ID. Inherited OAuth headers, cookies, and account IDs are stripped before the request leaves the process. Both routes set `Session-Id` and `X-Client-Request-Id` to the bounded Pi session ID, plus `x-openai-tool-output-truncation-policy` and `x-openai-encrypted-tool-arguments` for endpoints with encrypted parameters. Encrypted results come back as `encrypted_output` and are re-encoded into later requests. These are alpha contracts, so the upstream backend may change them at any time.
|
|
24
28
|
|
|
25
29
|
Search text and note bodies are sent under the encrypted-argument policy. Live model requests carry `x-codex-window-id` and `x-codex-turn-metadata` request headers derived from the active window identity.
|
|
26
30
|
|
|
27
|
-
Coverage is never inferred from model names. A native session
|
|
31
|
+
Coverage is never inferred from model names. A native session uses provider `openai-codex` with API `openai-codex-responses` for any model ID. A gateway session uses API `openai-responses` and must carry an exact `provider/model` key listed in `compaction.gatewayContextModels`; the list ships empty and there is no built-in gateway SKU. See [src/context-management/codex-provider.ts](../src/context-management/codex-provider.ts). The toolkit registers no provider or model with Pi. It resolves credentials and base URLs through Pi's `ModelRegistry`.
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
### Rollover lifecycle
|
|
30
36
|
|
|
31
37
|
The `new_context` happy path, end to end:
|
|
32
38
|
|
|
33
39
|
```mermaid
|
|
34
40
|
sequenceDiagram
|
|
41
|
+
accTitle: Remote Context rollover
|
|
42
|
+
accDescr: A Pi model checkpoints notes, requests a local new_context boundary, and sends the next request with the new window identity.
|
|
35
43
|
participant Model
|
|
36
44
|
participant Toolkit
|
|
37
45
|
participant Pi
|
|
@@ -41,6 +49,7 @@ sequenceDiagram
|
|
|
41
49
|
Backend-->>Toolkit: encrypted_output
|
|
42
50
|
Model->>Toolkit: new_context
|
|
43
51
|
Toolkit->>Toolkit: verify successful notes write in this window
|
|
52
|
+
Toolkit-->>Model: checkpoint receipt with the successful note path
|
|
44
53
|
Toolkit-->>Pi: window marker appended, previous window trim scheduled
|
|
45
54
|
Model->>Backend: next request with the new x-codex-window-id
|
|
46
55
|
Pi->>Toolkit: session_before_compact
|
|
@@ -49,25 +58,37 @@ sequenceDiagram
|
|
|
49
58
|
|
|
50
59
|
State invariants the lifecycle code must keep honest:
|
|
51
60
|
|
|
52
|
-
- The checkpoint gate verifies a
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
61
|
+
- 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
|
+
- 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.
|
|
63
|
+
- `new_context` has no force escape hatch: a successful notes checkpoint is always required before rollover.
|
|
64
|
+
- 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.
|
|
65
|
+
- The scheduled trim is consumed exactly once, synchronously, by the first compaction attempt whose boundary window ID matches. Every other compaction path, including a threshold without a scheduled rollover, manual `/compact`, and overflow, is cancelled.
|
|
66
|
+
- A window boundary is persisted as a `codex-context-window` custom message. `session_start` replays boundaries from the branch and rebuilds identity after forks.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
### Remote Compaction v2 wire contract
|
|
71
|
+
|
|
72
|
+
v2 is what an uncovered session gets. Any model that Remote Context declines, such as a gateway model absent from `gatewayContextModels`, can still compact through v2 when its API appears in `responsesApis`. The two strategies never compete for one session.
|
|
73
|
+
|
|
74
|
+
A `compaction_trigger` item appended to the live streaming request yields one output item of `type: "compaction"` with non-empty `encrypted_content`, stored in `CompactionEntry.details.compactedWindow`. On later requests the opaque checkpoint is replayed ahead of live turns, with no text summary. Replay fails closed: if the summary anchor cannot be located, the request is aborted with a notification and a content-free failure artifact. The sentinel-only payload is never sent.
|
|
75
|
+
|
|
76
|
+
A v2 response with a missing or empty checkpoint is never stored, and the `nativeFallback` tier is skipped. Pi's own threshold drives the next attempt, which may use `remoteCompactModel` when configured. `remoteCompactModel` must resolve to the same effective base URL as the active model.
|
|
56
77
|
|
|
57
|
-
|
|
78
|
+
---
|
|
58
79
|
|
|
59
|
-
|
|
80
|
+
### Artifacts and debugging
|
|
60
81
|
|
|
61
|
-
|
|
82
|
+
With `debug: true`, the toolkit writes lifecycle and compaction artifacts under `artifactRoot` (default `~/.pi/agent/artifacts/pi-openai-toolkit/compaction`):
|
|
62
83
|
|
|
63
|
-
|
|
84
|
+
- Each `session_start` writes a lifecycle artifact whose `activation` field records `active` or the exact inactive reason, such as `unsupported-model`, `unsupported-api`, `missing-api-key`, `auth-resolution-failed`, or `tool-name-conflict`.
|
|
85
|
+
- `logProviderPayloads` additionally writes provider request metadata and payloads, while `logCompactResponses` writes compact status and structural summaries with complete response and body fields critically redacted. Keep both off unless inspecting a specific request.
|
|
86
|
+
- Artifacts always redact Authorization credentials, API keys and tokens, Codex account IDs, and opaque `encrypted_content` or `encrypted_output`, even when `redactSensitiveData` is `false`.
|
|
64
87
|
|
|
65
|
-
|
|
66
|
-
- `logProviderPayloads` additionally writes raw provider request payloads, `logCompactResponses` the compact SSE bodies; keep both off unless inspecting a specific request;
|
|
67
|
-
- artifacts always redact Authorization credentials, API keys/tokens, Codex account ids, and opaque `encrypted_content` / `encrypted_output`, even with `redactSensitiveData: false`.
|
|
88
|
+
---
|
|
68
89
|
|
|
69
|
-
|
|
90
|
+
### Provenance
|
|
70
91
|
|
|
71
|
-
The Remote Context protocol was reverse-engineered from `@howaboua/pi-codex-conversion@3.0.29` (source commit `7021ae48e8efe36a3becc5830d529696ff798e5e`). The Astra compatibility layer is adapted from Oh My Pi 18.1.8 (MIT). Web Search behavior was adapted from [
|
|
92
|
+
The Remote Context protocol was reverse-engineered from `@howaboua/pi-codex-conversion@3.0.29` (source commit `7021ae48e8efe36a3becc5830d529696ff798e5e`). The Astra compatibility layer is adapted from Oh My Pi 18.1.8 (MIT). Web Search behavior was adapted from [pi-openai-web-search](https://github.com/code-yeongyu/pi-openai-web-search) (commit `3964338`). Attribution details are in [NOTICE](../NOTICE).
|
|
72
93
|
|
|
73
|
-
Protocol fixtures live in [src/context-management/](../src/context-management/) and must be updated together with any upstream alpha endpoint change
|
|
94
|
+
Protocol fixtures live in [src/context-management/](../src/context-management/) and must be updated together with any upstream alpha endpoint change. New wire behavior requires fixture-based tests.
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|