pi-openai-toolkit 0.14.5 → 0.14.6
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 +306 -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
|
@@ -1,325 +1,325 @@
|
|
|
1
|
-
import type { Api, AssistantMessage, Context, Message, Model, Tool } from "@earendil-works/pi-ai";
|
|
2
|
-
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { parseModelSpec } from "../runtime";
|
|
4
|
-
import { backfillVerdictFields, buildReviewPrompt, reviewerSystemPrompt } from "./prompt";
|
|
5
|
-
import {
|
|
6
|
-
boundReviewText,
|
|
7
|
-
type GuardianVerdict,
|
|
8
|
-
MAX_REVIEW_MODEL_KEY_CHARS,
|
|
9
|
-
type ReviewFailureCause,
|
|
10
|
-
type ReviewOutcome,
|
|
11
|
-
type RiskLevel,
|
|
12
|
-
type UserAuthorization,
|
|
13
|
-
} from "./types";
|
|
14
|
-
|
|
15
|
-
/** The reviewer needs model lookup plus whatever read-only tools we choose to give it. */
|
|
16
|
-
export type ReviewerRegistry = Pick<ModelRegistry, "find" | "complete">;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* One read-only investigation tool the reviewer may call. `parameters` is passed to
|
|
20
|
-
* the provider verbatim; `execute` is ours, so the reviewer can never reach a tool
|
|
21
|
-
* that is not in this list.
|
|
22
|
-
*/
|
|
23
|
-
export type EvidenceTool = {
|
|
24
|
-
name: string;
|
|
25
|
-
description: string;
|
|
26
|
-
parameters: Tool["parameters"];
|
|
27
|
-
execute: (
|
|
28
|
-
args: Record<string, unknown>,
|
|
29
|
-
signal: AbortSignal | undefined,
|
|
30
|
-
) => Promise<string>;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const RISK_LEVELS: readonly string[] = ["low", "medium", "high", "critical"];
|
|
34
|
-
const AUTHORIZATIONS: readonly string[] = ["unknown", "low", "medium", "high"];
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Parse a reviewer answer. Only the outcome is required; risk, authorization, and
|
|
38
|
-
* rationale are back-filled. Both the Codex-style field names and our earlier
|
|
39
|
-
* `decision`/`reason` spelling are accepted, so an older reviewer prompt cached by
|
|
40
|
-
* a provider still yields a usable verdict.
|
|
41
|
-
*
|
|
42
|
-
* A verdict we cannot read is reported as a failure, never guessed into approval.
|
|
43
|
-
*/
|
|
44
|
-
export function parseReviewVerdict(text: string): GuardianVerdict | undefined {
|
|
45
|
-
for (const candidate of candidateJsonObjects(text)) {
|
|
46
|
-
let parsed: unknown;
|
|
47
|
-
try {
|
|
48
|
-
parsed = JSON.parse(candidate);
|
|
49
|
-
} catch {
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
53
|
-
const record = parsed as Record<string, unknown>;
|
|
54
|
-
const rawOutcome = record.outcome ?? record.decision;
|
|
55
|
-
if (rawOutcome !== "allow" && rawOutcome !== "deny") continue;
|
|
56
|
-
const risk = String(record.risk_level ?? record.riskLevel ?? "").toLowerCase();
|
|
57
|
-
const authorization = String(
|
|
58
|
-
record.user_authorization ?? record.userAuthorization ?? "",
|
|
59
|
-
).toLowerCase();
|
|
60
|
-
const rationale =
|
|
61
|
-
typeof record.rationale === "string"
|
|
62
|
-
? record.rationale
|
|
63
|
-
: typeof record.reason === "string"
|
|
64
|
-
? record.reason
|
|
65
|
-
: undefined;
|
|
66
|
-
return backfillVerdictFields({
|
|
67
|
-
outcome: rawOutcome,
|
|
68
|
-
riskLevel: (RISK_LEVELS.includes(risk) ? risk : undefined) as RiskLevel | undefined,
|
|
69
|
-
userAuthorization: (AUTHORIZATIONS.includes(authorization) ? authorization : undefined) as
|
|
70
|
-
| UserAuthorization
|
|
71
|
-
| undefined,
|
|
72
|
-
rationale,
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
return undefined;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function candidateJsonObjects(text: string): string[] {
|
|
79
|
-
const candidates: string[] = [];
|
|
80
|
-
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
81
|
-
if (fenced?.[1]) candidates.push(fenced[1].trim());
|
|
82
|
-
candidates.push(text.trim());
|
|
83
|
-
const brace = text.match(/\{[\s\S]*\}/);
|
|
84
|
-
if (brace?.[0]) candidates.push(brace[0]);
|
|
85
|
-
return candidates;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function firstTextBlock(message: AssistantMessage): string {
|
|
89
|
-
return message.content
|
|
90
|
-
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
91
|
-
.map((block) => block.text)
|
|
92
|
-
.join("")
|
|
93
|
-
.trim();
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function toolCallBlocks(message: AssistantMessage) {
|
|
97
|
-
return message.content.filter(
|
|
98
|
-
(block): block is { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> } =>
|
|
99
|
-
block.type === "toolCall",
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
type CompletionAttempt = {
|
|
104
|
-
response?: AssistantMessage;
|
|
105
|
-
errorMessage?: string;
|
|
106
|
-
timedOut: boolean;
|
|
107
|
-
cancelled: boolean;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
async function completeBefore(
|
|
111
|
-
registry: ReviewerRegistry,
|
|
112
|
-
model: Model<Api>,
|
|
113
|
-
context: Context,
|
|
114
|
-
deadlineAt: number,
|
|
115
|
-
callerSignal?: AbortSignal,
|
|
116
|
-
): Promise<CompletionAttempt> {
|
|
117
|
-
const remaining = deadlineAt - Date.now();
|
|
118
|
-
if (remaining <= 0) {
|
|
119
|
-
return { timedOut: true, cancelled: Boolean(callerSignal?.aborted) };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const controller = new AbortController();
|
|
123
|
-
let timedOut = false;
|
|
124
|
-
let cancelled = false;
|
|
125
|
-
const abortFromCaller = () => {
|
|
126
|
-
cancelled = true;
|
|
127
|
-
controller.abort();
|
|
128
|
-
};
|
|
129
|
-
if (callerSignal?.aborted) {
|
|
130
|
-
cancelled = true;
|
|
131
|
-
controller.abort();
|
|
132
|
-
} else {
|
|
133
|
-
callerSignal?.addEventListener("abort", abortFromCaller);
|
|
134
|
-
}
|
|
135
|
-
const timer = setTimeout(() => {
|
|
136
|
-
timedOut = true;
|
|
137
|
-
controller.abort();
|
|
138
|
-
}, remaining);
|
|
139
|
-
|
|
140
|
-
try {
|
|
141
|
-
const response = await registry.complete(model, context, {
|
|
142
|
-
signal: controller.signal,
|
|
143
|
-
cacheRetention: "none",
|
|
144
|
-
});
|
|
145
|
-
return { response, timedOut, cancelled };
|
|
146
|
-
} catch (error) {
|
|
147
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
148
|
-
return { errorMessage: message, timedOut, cancelled };
|
|
149
|
-
} finally {
|
|
150
|
-
clearTimeout(timer);
|
|
151
|
-
callerSignal?.removeEventListener("abort", abortFromCaller);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function failure(cause: ReviewFailureCause, reason: string): ReviewOutcome {
|
|
156
|
-
return { kind: "unavailable", reason: boundReviewText(reason, 240), cause };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function outcomeFromVerdict(
|
|
160
|
-
verdict: GuardianVerdict,
|
|
161
|
-
reviewerModel: string,
|
|
162
|
-
evidenceRounds: number,
|
|
163
|
-
): ReviewOutcome {
|
|
164
|
-
return verdict.outcome === "allow"
|
|
165
|
-
? { kind: "allow", verdict, reviewerModel, evidenceRounds }
|
|
166
|
-
: { kind: "deny", verdict, reviewerModel, evidenceRounds };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Ask the reviewer model about one pending tool call.
|
|
171
|
-
*
|
|
172
|
-
* When `evidenceTools` are supplied the reviewer runs as a short bounded agent
|
|
173
|
-
* loop: it may call read-only tools to establish facts, we execute those calls
|
|
174
|
-
* ourselves, and we feed the results back. The whole exchange shares one deadline,
|
|
175
|
-
* and the final round is forced to answer without tools, so a reviewer that keeps
|
|
176
|
-
* investigating still terminates as a verdict or as an explicit failure.
|
|
177
|
-
*/
|
|
178
|
-
export async function requestToolReview(params: {
|
|
179
|
-
registry: ReviewerRegistry;
|
|
180
|
-
reviewerModelSpec: string;
|
|
181
|
-
toolName: string;
|
|
182
|
-
toolInput: unknown;
|
|
183
|
-
transcript?: string;
|
|
184
|
-
cwd?: string;
|
|
185
|
-
timeoutMs: number;
|
|
186
|
-
signal?: AbortSignal;
|
|
187
|
-
evidenceTools?: EvidenceTool[];
|
|
188
|
-
maxEvidenceRounds?: number;
|
|
189
|
-
}): Promise<ReviewOutcome> {
|
|
190
|
-
const parsed = parseModelSpec(params.reviewerModelSpec);
|
|
191
|
-
if (!parsed) {
|
|
192
|
-
return failure(
|
|
193
|
-
"not-configured",
|
|
194
|
-
`autoMode.reviewerModel "${params.reviewerModelSpec}" is not provider/model-id.`,
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const model = params.registry.find(parsed.provider, parsed.modelId);
|
|
199
|
-
if (!model) {
|
|
200
|
-
return failure("not-configured", `Reviewer model ${params.reviewerModelSpec} is not available.`);
|
|
201
|
-
}
|
|
202
|
-
const reviewerModel = boundReviewText(`${model.provider}/${model.id}`, MAX_REVIEW_MODEL_KEY_CHARS);
|
|
203
|
-
|
|
204
|
-
const tools = params.evidenceTools ?? [];
|
|
205
|
-
const maxRounds = Math.max(0, params.maxEvidenceRounds ?? 0);
|
|
206
|
-
const useTools = tools.length > 0 && maxRounds > 0;
|
|
207
|
-
const toolByName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
208
|
-
const deadlineAt = Date.now() + params.timeoutMs;
|
|
209
|
-
const messages: Message[] = [
|
|
210
|
-
{
|
|
211
|
-
role: "user",
|
|
212
|
-
content: [
|
|
213
|
-
{
|
|
214
|
-
type: "text",
|
|
215
|
-
text: buildReviewPrompt({
|
|
216
|
-
toolName: params.toolName,
|
|
217
|
-
toolInput: params.toolInput,
|
|
218
|
-
transcript: params.transcript,
|
|
219
|
-
cwd: params.cwd,
|
|
220
|
-
}),
|
|
221
|
-
},
|
|
222
|
-
],
|
|
223
|
-
timestamp: Date.now(),
|
|
224
|
-
},
|
|
225
|
-
];
|
|
226
|
-
|
|
227
|
-
let evidenceRounds = 0;
|
|
228
|
-
for (let round = 0; ; round += 1) {
|
|
229
|
-
const forceAnswer = !useTools || round >= maxRounds;
|
|
230
|
-
const context: Context = {
|
|
231
|
-
systemPrompt: reviewerSystemPrompt(useTools),
|
|
232
|
-
messages,
|
|
233
|
-
...(forceAnswer ? {} : { tools: tools.map(toProviderTool) }),
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
const attempt = await completeBefore(params.registry, model, context, deadlineAt, params.signal);
|
|
237
|
-
if (attempt.cancelled) {
|
|
238
|
-
return failure("cancelled", "Auto-mode review was cancelled before it finished.");
|
|
239
|
-
}
|
|
240
|
-
if (attempt.timedOut) {
|
|
241
|
-
return failure("timeout", `Auto-mode review timed out after ${params.timeoutMs}ms.`);
|
|
242
|
-
}
|
|
243
|
-
if (attempt.errorMessage) {
|
|
244
|
-
return failure("provider-error", attempt.errorMessage);
|
|
245
|
-
}
|
|
246
|
-
const response = attempt.response;
|
|
247
|
-
if (!response) {
|
|
248
|
-
return failure("provider-error", "Reviewer returned no message.");
|
|
249
|
-
}
|
|
250
|
-
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
251
|
-
return failure(
|
|
252
|
-
"provider-error",
|
|
253
|
-
response.errorMessage ?? `Reviewer stopped with "${response.stopReason}".`,
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const calls = toolCallBlocks(response);
|
|
258
|
-
if (forceAnswer || calls.length === 0) {
|
|
259
|
-
const verdict = parseReviewVerdict(firstTextBlock(response));
|
|
260
|
-
if (!verdict) {
|
|
261
|
-
return failure("invalid-output", "Reviewer did not return a readable allow/deny verdict.");
|
|
262
|
-
}
|
|
263
|
-
return outcomeFromVerdict(verdict, reviewerModel, evidenceRounds);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
messages.push(response);
|
|
267
|
-
evidenceRounds += 1;
|
|
268
|
-
for (const call of calls) {
|
|
269
|
-
const tool = toolByName.get(call.name);
|
|
270
|
-
if (!tool) {
|
|
271
|
-
messages.push(refusedToolResult(call, `Tool "${call.name}" is not available to the reviewer.`));
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
if (params.signal?.aborted) {
|
|
275
|
-
return failure("cancelled", "Auto-mode review was cancelled during investigation.");
|
|
276
|
-
}
|
|
277
|
-
try {
|
|
278
|
-
const text = await tool.execute(call.arguments, params.signal);
|
|
279
|
-
messages.push({
|
|
280
|
-
role: "toolResult",
|
|
281
|
-
toolCallId: call.id,
|
|
282
|
-
toolName: call.name,
|
|
283
|
-
content: [{ type: "text", text: boundReviewText(text, 4_000) }],
|
|
284
|
-
isError: false,
|
|
285
|
-
timestamp: Date.now(),
|
|
286
|
-
});
|
|
287
|
-
} catch (error) {
|
|
288
|
-
messages.push({
|
|
289
|
-
role: "toolResult",
|
|
290
|
-
toolCallId: call.id,
|
|
291
|
-
toolName: call.name,
|
|
292
|
-
content: [
|
|
293
|
-
{
|
|
294
|
-
type: "text",
|
|
295
|
-
text: boundReviewText(
|
|
296
|
-
error instanceof Error ? error.message : String(error),
|
|
297
|
-
1_000,
|
|
298
|
-
),
|
|
299
|
-
},
|
|
300
|
-
],
|
|
301
|
-
isError: true,
|
|
302
|
-
timestamp: Date.now(),
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function toProviderTool(tool: EvidenceTool): Tool {
|
|
310
|
-
return { name: tool.name, description: tool.description, parameters: tool.parameters };
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function refusedToolResult(
|
|
314
|
-
call: { id: string; name: string },
|
|
315
|
-
text: string,
|
|
316
|
-
): Message {
|
|
317
|
-
return {
|
|
318
|
-
role: "toolResult",
|
|
319
|
-
toolCallId: call.id,
|
|
320
|
-
toolName: call.name,
|
|
321
|
-
content: [{ type: "text", text }],
|
|
322
|
-
isError: true,
|
|
323
|
-
timestamp: Date.now(),
|
|
324
|
-
};
|
|
325
|
-
}
|
|
1
|
+
import type { Api, AssistantMessage, Context, Message, Model, Tool } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { parseModelSpec } from "../runtime";
|
|
4
|
+
import { backfillVerdictFields, buildReviewPrompt, reviewerSystemPrompt } from "./prompt";
|
|
5
|
+
import {
|
|
6
|
+
boundReviewText,
|
|
7
|
+
type GuardianVerdict,
|
|
8
|
+
MAX_REVIEW_MODEL_KEY_CHARS,
|
|
9
|
+
type ReviewFailureCause,
|
|
10
|
+
type ReviewOutcome,
|
|
11
|
+
type RiskLevel,
|
|
12
|
+
type UserAuthorization,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
/** The reviewer needs model lookup plus whatever read-only tools we choose to give it. */
|
|
16
|
+
export type ReviewerRegistry = Pick<ModelRegistry, "find" | "complete">;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One read-only investigation tool the reviewer may call. `parameters` is passed to
|
|
20
|
+
* the provider verbatim; `execute` is ours, so the reviewer can never reach a tool
|
|
21
|
+
* that is not in this list.
|
|
22
|
+
*/
|
|
23
|
+
export type EvidenceTool = {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
parameters: Tool["parameters"];
|
|
27
|
+
execute: (
|
|
28
|
+
args: Record<string, unknown>,
|
|
29
|
+
signal: AbortSignal | undefined,
|
|
30
|
+
) => Promise<string>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const RISK_LEVELS: readonly string[] = ["low", "medium", "high", "critical"];
|
|
34
|
+
const AUTHORIZATIONS: readonly string[] = ["unknown", "low", "medium", "high"];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse a reviewer answer. Only the outcome is required; risk, authorization, and
|
|
38
|
+
* rationale are back-filled. Both the Codex-style field names and our earlier
|
|
39
|
+
* `decision`/`reason` spelling are accepted, so an older reviewer prompt cached by
|
|
40
|
+
* a provider still yields a usable verdict.
|
|
41
|
+
*
|
|
42
|
+
* A verdict we cannot read is reported as a failure, never guessed into approval.
|
|
43
|
+
*/
|
|
44
|
+
export function parseReviewVerdict(text: string): GuardianVerdict | undefined {
|
|
45
|
+
for (const candidate of candidateJsonObjects(text)) {
|
|
46
|
+
let parsed: unknown;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(candidate);
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
53
|
+
const record = parsed as Record<string, unknown>;
|
|
54
|
+
const rawOutcome = record.outcome ?? record.decision;
|
|
55
|
+
if (rawOutcome !== "allow" && rawOutcome !== "deny") continue;
|
|
56
|
+
const risk = String(record.risk_level ?? record.riskLevel ?? "").toLowerCase();
|
|
57
|
+
const authorization = String(
|
|
58
|
+
record.user_authorization ?? record.userAuthorization ?? "",
|
|
59
|
+
).toLowerCase();
|
|
60
|
+
const rationale =
|
|
61
|
+
typeof record.rationale === "string"
|
|
62
|
+
? record.rationale
|
|
63
|
+
: typeof record.reason === "string"
|
|
64
|
+
? record.reason
|
|
65
|
+
: undefined;
|
|
66
|
+
return backfillVerdictFields({
|
|
67
|
+
outcome: rawOutcome,
|
|
68
|
+
riskLevel: (RISK_LEVELS.includes(risk) ? risk : undefined) as RiskLevel | undefined,
|
|
69
|
+
userAuthorization: (AUTHORIZATIONS.includes(authorization) ? authorization : undefined) as
|
|
70
|
+
| UserAuthorization
|
|
71
|
+
| undefined,
|
|
72
|
+
rationale,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function candidateJsonObjects(text: string): string[] {
|
|
79
|
+
const candidates: string[] = [];
|
|
80
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
81
|
+
if (fenced?.[1]) candidates.push(fenced[1].trim());
|
|
82
|
+
candidates.push(text.trim());
|
|
83
|
+
const brace = text.match(/\{[\s\S]*\}/);
|
|
84
|
+
if (brace?.[0]) candidates.push(brace[0]);
|
|
85
|
+
return candidates;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function firstTextBlock(message: AssistantMessage): string {
|
|
89
|
+
return message.content
|
|
90
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
91
|
+
.map((block) => block.text)
|
|
92
|
+
.join("")
|
|
93
|
+
.trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function toolCallBlocks(message: AssistantMessage) {
|
|
97
|
+
return message.content.filter(
|
|
98
|
+
(block): block is { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> } =>
|
|
99
|
+
block.type === "toolCall",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type CompletionAttempt = {
|
|
104
|
+
response?: AssistantMessage;
|
|
105
|
+
errorMessage?: string;
|
|
106
|
+
timedOut: boolean;
|
|
107
|
+
cancelled: boolean;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
async function completeBefore(
|
|
111
|
+
registry: ReviewerRegistry,
|
|
112
|
+
model: Model<Api>,
|
|
113
|
+
context: Context,
|
|
114
|
+
deadlineAt: number,
|
|
115
|
+
callerSignal?: AbortSignal,
|
|
116
|
+
): Promise<CompletionAttempt> {
|
|
117
|
+
const remaining = deadlineAt - Date.now();
|
|
118
|
+
if (remaining <= 0) {
|
|
119
|
+
return { timedOut: true, cancelled: Boolean(callerSignal?.aborted) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const controller = new AbortController();
|
|
123
|
+
let timedOut = false;
|
|
124
|
+
let cancelled = false;
|
|
125
|
+
const abortFromCaller = () => {
|
|
126
|
+
cancelled = true;
|
|
127
|
+
controller.abort();
|
|
128
|
+
};
|
|
129
|
+
if (callerSignal?.aborted) {
|
|
130
|
+
cancelled = true;
|
|
131
|
+
controller.abort();
|
|
132
|
+
} else {
|
|
133
|
+
callerSignal?.addEventListener("abort", abortFromCaller);
|
|
134
|
+
}
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
timedOut = true;
|
|
137
|
+
controller.abort();
|
|
138
|
+
}, remaining);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const response = await registry.complete(model, context, {
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
cacheRetention: "none",
|
|
144
|
+
});
|
|
145
|
+
return { response, timedOut, cancelled };
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
148
|
+
return { errorMessage: message, timedOut, cancelled };
|
|
149
|
+
} finally {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
callerSignal?.removeEventListener("abort", abortFromCaller);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function failure(cause: ReviewFailureCause, reason: string): ReviewOutcome {
|
|
156
|
+
return { kind: "unavailable", reason: boundReviewText(reason, 240), cause };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function outcomeFromVerdict(
|
|
160
|
+
verdict: GuardianVerdict,
|
|
161
|
+
reviewerModel: string,
|
|
162
|
+
evidenceRounds: number,
|
|
163
|
+
): ReviewOutcome {
|
|
164
|
+
return verdict.outcome === "allow"
|
|
165
|
+
? { kind: "allow", verdict, reviewerModel, evidenceRounds }
|
|
166
|
+
: { kind: "deny", verdict, reviewerModel, evidenceRounds };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Ask the reviewer model about one pending tool call.
|
|
171
|
+
*
|
|
172
|
+
* When `evidenceTools` are supplied the reviewer runs as a short bounded agent
|
|
173
|
+
* loop: it may call read-only tools to establish facts, we execute those calls
|
|
174
|
+
* ourselves, and we feed the results back. The whole exchange shares one deadline,
|
|
175
|
+
* and the final round is forced to answer without tools, so a reviewer that keeps
|
|
176
|
+
* investigating still terminates as a verdict or as an explicit failure.
|
|
177
|
+
*/
|
|
178
|
+
export async function requestToolReview(params: {
|
|
179
|
+
registry: ReviewerRegistry;
|
|
180
|
+
reviewerModelSpec: string;
|
|
181
|
+
toolName: string;
|
|
182
|
+
toolInput: unknown;
|
|
183
|
+
transcript?: string;
|
|
184
|
+
cwd?: string;
|
|
185
|
+
timeoutMs: number;
|
|
186
|
+
signal?: AbortSignal;
|
|
187
|
+
evidenceTools?: EvidenceTool[];
|
|
188
|
+
maxEvidenceRounds?: number;
|
|
189
|
+
}): Promise<ReviewOutcome> {
|
|
190
|
+
const parsed = parseModelSpec(params.reviewerModelSpec);
|
|
191
|
+
if (!parsed) {
|
|
192
|
+
return failure(
|
|
193
|
+
"not-configured",
|
|
194
|
+
`autoMode.reviewerModel "${params.reviewerModelSpec}" is not provider/model-id.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const model = params.registry.find(parsed.provider, parsed.modelId);
|
|
199
|
+
if (!model) {
|
|
200
|
+
return failure("not-configured", `Reviewer model ${params.reviewerModelSpec} is not available.`);
|
|
201
|
+
}
|
|
202
|
+
const reviewerModel = boundReviewText(`${model.provider}/${model.id}`, MAX_REVIEW_MODEL_KEY_CHARS);
|
|
203
|
+
|
|
204
|
+
const tools = params.evidenceTools ?? [];
|
|
205
|
+
const maxRounds = Math.max(0, params.maxEvidenceRounds ?? 0);
|
|
206
|
+
const useTools = tools.length > 0 && maxRounds > 0;
|
|
207
|
+
const toolByName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
208
|
+
const deadlineAt = Date.now() + params.timeoutMs;
|
|
209
|
+
const messages: Message[] = [
|
|
210
|
+
{
|
|
211
|
+
role: "user",
|
|
212
|
+
content: [
|
|
213
|
+
{
|
|
214
|
+
type: "text",
|
|
215
|
+
text: buildReviewPrompt({
|
|
216
|
+
toolName: params.toolName,
|
|
217
|
+
toolInput: params.toolInput,
|
|
218
|
+
transcript: params.transcript,
|
|
219
|
+
cwd: params.cwd,
|
|
220
|
+
}),
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
timestamp: Date.now(),
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
let evidenceRounds = 0;
|
|
228
|
+
for (let round = 0; ; round += 1) {
|
|
229
|
+
const forceAnswer = !useTools || round >= maxRounds;
|
|
230
|
+
const context: Context = {
|
|
231
|
+
systemPrompt: reviewerSystemPrompt(useTools),
|
|
232
|
+
messages,
|
|
233
|
+
...(forceAnswer ? {} : { tools: tools.map(toProviderTool) }),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const attempt = await completeBefore(params.registry, model, context, deadlineAt, params.signal);
|
|
237
|
+
if (attempt.cancelled) {
|
|
238
|
+
return failure("cancelled", "Auto-mode review was cancelled before it finished.");
|
|
239
|
+
}
|
|
240
|
+
if (attempt.timedOut) {
|
|
241
|
+
return failure("timeout", `Auto-mode review timed out after ${params.timeoutMs}ms.`);
|
|
242
|
+
}
|
|
243
|
+
if (attempt.errorMessage) {
|
|
244
|
+
return failure("provider-error", attempt.errorMessage);
|
|
245
|
+
}
|
|
246
|
+
const response = attempt.response;
|
|
247
|
+
if (!response) {
|
|
248
|
+
return failure("provider-error", "Reviewer returned no message.");
|
|
249
|
+
}
|
|
250
|
+
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
|
251
|
+
return failure(
|
|
252
|
+
"provider-error",
|
|
253
|
+
response.errorMessage ?? `Reviewer stopped with "${response.stopReason}".`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const calls = toolCallBlocks(response);
|
|
258
|
+
if (forceAnswer || calls.length === 0) {
|
|
259
|
+
const verdict = parseReviewVerdict(firstTextBlock(response));
|
|
260
|
+
if (!verdict) {
|
|
261
|
+
return failure("invalid-output", "Reviewer did not return a readable allow/deny verdict.");
|
|
262
|
+
}
|
|
263
|
+
return outcomeFromVerdict(verdict, reviewerModel, evidenceRounds);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
messages.push(response);
|
|
267
|
+
evidenceRounds += 1;
|
|
268
|
+
for (const call of calls) {
|
|
269
|
+
const tool = toolByName.get(call.name);
|
|
270
|
+
if (!tool) {
|
|
271
|
+
messages.push(refusedToolResult(call, `Tool "${call.name}" is not available to the reviewer.`));
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (params.signal?.aborted) {
|
|
275
|
+
return failure("cancelled", "Auto-mode review was cancelled during investigation.");
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const text = await tool.execute(call.arguments, params.signal);
|
|
279
|
+
messages.push({
|
|
280
|
+
role: "toolResult",
|
|
281
|
+
toolCallId: call.id,
|
|
282
|
+
toolName: call.name,
|
|
283
|
+
content: [{ type: "text", text: boundReviewText(text, 4_000) }],
|
|
284
|
+
isError: false,
|
|
285
|
+
timestamp: Date.now(),
|
|
286
|
+
});
|
|
287
|
+
} catch (error) {
|
|
288
|
+
messages.push({
|
|
289
|
+
role: "toolResult",
|
|
290
|
+
toolCallId: call.id,
|
|
291
|
+
toolName: call.name,
|
|
292
|
+
content: [
|
|
293
|
+
{
|
|
294
|
+
type: "text",
|
|
295
|
+
text: boundReviewText(
|
|
296
|
+
error instanceof Error ? error.message : String(error),
|
|
297
|
+
1_000,
|
|
298
|
+
),
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
isError: true,
|
|
302
|
+
timestamp: Date.now(),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function toProviderTool(tool: EvidenceTool): Tool {
|
|
310
|
+
return { name: tool.name, description: tool.description, parameters: tool.parameters };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function refusedToolResult(
|
|
314
|
+
call: { id: string; name: string },
|
|
315
|
+
text: string,
|
|
316
|
+
): Message {
|
|
317
|
+
return {
|
|
318
|
+
role: "toolResult",
|
|
319
|
+
toolCallId: call.id,
|
|
320
|
+
toolName: call.name,
|
|
321
|
+
content: [{ type: "text", text }],
|
|
322
|
+
isError: true,
|
|
323
|
+
timestamp: Date.now(),
|
|
324
|
+
};
|
|
325
|
+
}
|