pi-jev-guard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -0
- package/config/jev-config.example.json +61 -0
- package/extensions/index.ts +692 -0
- package/package.json +39 -0
- package/skills/jev-review/SKILL.md +25 -0
- package/src/adapters/openrouter.ts +289 -0
- package/src/adapters/typesafe.ts +244 -0
- package/src/ask.ts +189 -0
- package/src/automatic/gate.ts +45 -0
- package/src/automatic/guardian.ts +320 -0
- package/src/automatic/overlay.ts +247 -0
- package/src/automatic/recovery.ts +32 -0
- package/src/automatic/serialize.ts +278 -0
- package/src/cache.ts +56 -0
- package/src/commands.ts +408 -0
- package/src/config.ts +385 -0
- package/src/exfil.ts +180 -0
- package/src/factory.ts +140 -0
- package/src/markdown.ts +26 -0
- package/src/metrics.ts +69 -0
- package/src/output-judge.ts +176 -0
- package/src/policy.ts +32 -0
- package/src/reviewer.ts +350 -0
- package/src/tools-policy.ts +128 -0
- package/src/tools.ts +43 -0
package/src/ask.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jev_ask: tool generico per decisioni tipate del modello.
|
|
3
|
+
*
|
|
4
|
+
* A differenza di jev_validate (3 regole fisse di policy), qui il modello
|
|
5
|
+
* definisce le sue domande (noul/choice/score) su un testo e riceve risposte
|
|
6
|
+
* calibrate invece di prosa. Istruzioni di lettura allineate alla nostra
|
|
7
|
+
* calibrazione (soglie 0.20/0.90, confidence floor 0.6).
|
|
8
|
+
*/
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import type { JevConfig } from "./config.ts";
|
|
12
|
+
import type {
|
|
13
|
+
TypedAnswer,
|
|
14
|
+
TypedAskFn,
|
|
15
|
+
TypedAskResult,
|
|
16
|
+
TypedQuestion,
|
|
17
|
+
} from "./reviewer.ts";
|
|
18
|
+
|
|
19
|
+
export const ASK_MAX_STATE_CHARS = 20000;
|
|
20
|
+
export const ASK_MAX_QUESTIONS = 8;
|
|
21
|
+
|
|
22
|
+
const QuestionParam = Type.Object({
|
|
23
|
+
id: Type.String({
|
|
24
|
+
minLength: 1,
|
|
25
|
+
maxLength: 64,
|
|
26
|
+
description: "Short key for this question. The answer comes back under it.",
|
|
27
|
+
}),
|
|
28
|
+
type: Type.Union([Type.Literal("noul"), Type.Literal("choice"), Type.Literal("score")], {
|
|
29
|
+
description: "noul = yes/no probability, choice = pick one option, score = value on a rubric",
|
|
30
|
+
}),
|
|
31
|
+
instructions: Type.String({
|
|
32
|
+
minLength: 1,
|
|
33
|
+
maxLength: 2000,
|
|
34
|
+
description: "The one thing to judge. One specific, well-scoped gut-check per question.",
|
|
35
|
+
}),
|
|
36
|
+
options: Type.Optional(
|
|
37
|
+
Type.Array(
|
|
38
|
+
Type.Object({
|
|
39
|
+
name: Type.String({ minLength: 1, maxLength: 64, description: "Option key" }),
|
|
40
|
+
description: Type.Optional(
|
|
41
|
+
Type.String({ maxLength: 500, description: "When this option applies" }),
|
|
42
|
+
),
|
|
43
|
+
}),
|
|
44
|
+
{ minItems: 1, maxItems: 12, description: "choice only: the options to choose between" },
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
levels: Type.Optional(
|
|
48
|
+
Type.Array(Type.String({ minLength: 1, maxLength: 200 }), {
|
|
49
|
+
minItems: 2,
|
|
50
|
+
maxItems: 8,
|
|
51
|
+
description: "score only: ordered rubric levels, lowest first, at least two",
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const AskParams = Type.Object({
|
|
57
|
+
state: Type.String({
|
|
58
|
+
minLength: 1,
|
|
59
|
+
maxLength: ASK_MAX_STATE_CHARS,
|
|
60
|
+
description: "The text to judge: tool output, a diff, a message, a document excerpt.",
|
|
61
|
+
}),
|
|
62
|
+
questions: Type.Array(QuestionParam, {
|
|
63
|
+
minItems: 1,
|
|
64
|
+
maxItems: ASK_MAX_QUESTIONS,
|
|
65
|
+
description: "One or more questions. All are evaluated in parallel against the same state.",
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export interface AskQuestionParam {
|
|
70
|
+
id: string;
|
|
71
|
+
type: "noul" | "choice" | "score";
|
|
72
|
+
instructions: string;
|
|
73
|
+
options?: { name: string; description?: string }[];
|
|
74
|
+
levels?: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Costruisce la domanda Jev, oppure il motivo per cui la forma non vale. */
|
|
78
|
+
export function toTypedQuestion(question: AskQuestionParam): TypedQuestion | string {
|
|
79
|
+
const { id, instructions } = question;
|
|
80
|
+
if (question.type === "choice") {
|
|
81
|
+
if (!question.options || question.options.length === 0) {
|
|
82
|
+
return `question "${id}": choice needs at least one option`;
|
|
83
|
+
}
|
|
84
|
+
const options: Record<string, string | null> = {};
|
|
85
|
+
for (const option of question.options) {
|
|
86
|
+
options[option.name] = option.description ?? null;
|
|
87
|
+
}
|
|
88
|
+
return { kind: "choice", id, instructions, options };
|
|
89
|
+
}
|
|
90
|
+
if (question.type === "score") {
|
|
91
|
+
if (!question.levels || question.levels.length < 2) {
|
|
92
|
+
return `question "${id}": score needs at least two levels`;
|
|
93
|
+
}
|
|
94
|
+
return { kind: "score", id, instructions, levels: [...question.levels] };
|
|
95
|
+
}
|
|
96
|
+
return { kind: "noul", id, instructions };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function describeAnswer(answer: TypedAnswer): string {
|
|
100
|
+
if (answer.type === "noul") return `yes ${answer.p.toFixed(2)}`;
|
|
101
|
+
if (answer.type === "choice") {
|
|
102
|
+
const ranked = Object.entries(answer.probabilities)
|
|
103
|
+
.sort(([, a], [, b]) => b - a)
|
|
104
|
+
.map(([option, p]) => `${option} ${p.toFixed(2)}`)
|
|
105
|
+
.join(", ");
|
|
106
|
+
return `${answer.choice} (conf ${answer.confidence?.toFixed(2) ?? "n/a"})${ranked ? ` [${ranked}]` : ""}`;
|
|
107
|
+
}
|
|
108
|
+
return `${answer.score.toFixed(2)} (conf ${answer.confidence?.toFixed(2) ?? "n/a"})`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function renderAskResult(
|
|
112
|
+
result: TypedAskResult,
|
|
113
|
+
questions: TypedQuestion[],
|
|
114
|
+
): string {
|
|
115
|
+
const lines = [`model ${result.model ?? "?"}`];
|
|
116
|
+
const byId = new Map(questions.map((q) => [q.id, q]));
|
|
117
|
+
for (const answer of result.answers) {
|
|
118
|
+
const question = byId.get(answer.id);
|
|
119
|
+
const tail = question ? ` <- ${question.instructions}` : "";
|
|
120
|
+
lines.push(`${answer.id}: ${describeAnswer(answer)}${tail}`);
|
|
121
|
+
}
|
|
122
|
+
if (result.usage) {
|
|
123
|
+
lines.push(`tokens ${result.usage.input} in / ${result.usage.output} out`);
|
|
124
|
+
}
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface JevAskDeps {
|
|
129
|
+
ask: () => TypedAskFn;
|
|
130
|
+
config: () => JevConfig;
|
|
131
|
+
keyPresent: () => boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function registerJevAskTool(pi: ExtensionAPI, deps: JevAskDeps) {
|
|
135
|
+
pi.registerTool({
|
|
136
|
+
name: "jev_ask",
|
|
137
|
+
label: "Jev Ask",
|
|
138
|
+
description:
|
|
139
|
+
"Ask TypeSafe Jev typed questions about a piece of text and get calibrated answers " +
|
|
140
|
+
"(probabilities, a chosen option, a rubric score) instead of prose.",
|
|
141
|
+
promptSnippet: "Ask Jev typed questions (yes/no, choice, rubric) about text and get calibrated answers.",
|
|
142
|
+
promptGuidelines: [
|
|
143
|
+
"Use jev_ask when a judgement must be typed and calibrated rather than written: classification, relevance, yes/no checks, rubric scores.",
|
|
144
|
+
"Ask one specific question per entry in jev_ask; split multi-factor judgements into separate questions and combine the answers yourself.",
|
|
145
|
+
"Read noul answers as: p >= 0.90 means yes, p <= 0.20 means no, anything between is uncertain — say so explicitly.",
|
|
146
|
+
"Read choice/score answers as uncertain when confidence is below 0.60.",
|
|
147
|
+
"jev_ask answers are advisory input for your decision, not policy verdicts; jev_validate remains the tool for code flaw checks.",
|
|
148
|
+
],
|
|
149
|
+
parameters: AskParams,
|
|
150
|
+
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
|
|
151
|
+
if (!deps.keyPresent()) {
|
|
152
|
+
return {
|
|
153
|
+
content: [{ type: "text", text: "jev_ask: no Jev API key configured (see /jev status)." }],
|
|
154
|
+
details: { ok: false },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const questions: TypedQuestion[] = [];
|
|
158
|
+
for (const question of params.questions as AskQuestionParam[]) {
|
|
159
|
+
const built = toTypedQuestion(question);
|
|
160
|
+
if (typeof built === "string") {
|
|
161
|
+
return {
|
|
162
|
+
content: [{ type: "text", text: `jev_ask: ${built}` }],
|
|
163
|
+
details: { ok: false },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
questions.push(built);
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const result = await deps.ask()(params.state, questions, signal ?? undefined);
|
|
170
|
+
return {
|
|
171
|
+
content: [{ type: "text", text: renderAskResult(result, questions) }],
|
|
172
|
+
details: {
|
|
173
|
+
ok: true,
|
|
174
|
+
model: result.model,
|
|
175
|
+
backend: (result as { backend?: string }).backend,
|
|
176
|
+
usage: result.usage,
|
|
177
|
+
answers: result.answers,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (signal?.aborted) throw error;
|
|
182
|
+
return {
|
|
183
|
+
content: [{ type: "text", text: `jev_ask: ${error instanceof Error ? error.message : String(error)}` }],
|
|
184
|
+
details: { ok: false },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate logico puro, separato dall'API provider specifica.
|
|
3
|
+
* Il provider-adapter deve: generare in privato, validare, pubblicare solo approvato.
|
|
4
|
+
*/
|
|
5
|
+
import type { ReviewResult } from "../reviewer.ts";
|
|
6
|
+
|
|
7
|
+
export interface GateCandidate {
|
|
8
|
+
/** Testo destinato alla pubblicazione (già serializzato dal provider). */
|
|
9
|
+
text: string;
|
|
10
|
+
/** Metadati opachi del provider, preservati in pubblicazione. */
|
|
11
|
+
meta?: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GateVerdict extends ReviewResult {}
|
|
15
|
+
|
|
16
|
+
export type GateDecision =
|
|
17
|
+
| { action: "publish"; candidate: GateCandidate; verdict: GateVerdict }
|
|
18
|
+
| { action: "regenerate"; candidate: GateCandidate; verdict: GateVerdict; attempt: number }
|
|
19
|
+
| { action: "hold"; verdict: GateVerdict; reason: string };
|
|
20
|
+
|
|
21
|
+
export function decideGate(args: {
|
|
22
|
+
candidate: GateCandidate;
|
|
23
|
+
verdict: GateVerdict;
|
|
24
|
+
attempt: number;
|
|
25
|
+
maxRegenerations: number;
|
|
26
|
+
}): GateDecision {
|
|
27
|
+
const { candidate, verdict, attempt, maxRegenerations } = args;
|
|
28
|
+
|
|
29
|
+
if (verdict.status === "pass") {
|
|
30
|
+
return { action: "publish", candidate, verdict };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (verdict.status === "block" && attempt < maxRegenerations) {
|
|
34
|
+
return { action: "regenerate", candidate, verdict, attempt: attempt + 1 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const reason =
|
|
38
|
+
verdict.status === "block"
|
|
39
|
+
? `blocked after ${attempt + 1} attempt(s)`
|
|
40
|
+
: verdict.status === "review"
|
|
41
|
+
? "uncertain: hold for user"
|
|
42
|
+
: `unavailable (${verdict.errorCode ?? "unknown"}): hold for user`;
|
|
43
|
+
|
|
44
|
+
return { action: "hold", verdict, reason };
|
|
45
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssistantMessage,
|
|
3
|
+
AssistantMessageEventStream,
|
|
4
|
+
Context,
|
|
5
|
+
SimpleStreamOptions,
|
|
6
|
+
Usage,
|
|
7
|
+
} from "@earendil-works/pi-ai";
|
|
8
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
9
|
+
import type { JevConfig } from "../config.ts";
|
|
10
|
+
import type { ReviewFn, ReviewResult } from "../reviewer.ts";
|
|
11
|
+
import { reviewWithCoverage } from "../reviewer.ts";
|
|
12
|
+
import {
|
|
13
|
+
aggregateUsage,
|
|
14
|
+
buildRepairContext,
|
|
15
|
+
buildSafeFailureText,
|
|
16
|
+
byteLengthUtf8,
|
|
17
|
+
emptyUsage,
|
|
18
|
+
extractCandidateText,
|
|
19
|
+
extractRequirements,
|
|
20
|
+
replayApproved,
|
|
21
|
+
} from "./serialize.ts";
|
|
22
|
+
|
|
23
|
+
export type UpstreamStreamFn = (
|
|
24
|
+
context: Context,
|
|
25
|
+
options?: SimpleStreamOptions,
|
|
26
|
+
) => AssistantMessageEventStream;
|
|
27
|
+
|
|
28
|
+
export interface GuardianDeps {
|
|
29
|
+
upstream: UpstreamStreamFn;
|
|
30
|
+
review: ReviewFn;
|
|
31
|
+
config: JevConfig;
|
|
32
|
+
/** Identità pubblica del messaggio ripubblicato. */
|
|
33
|
+
publishedProvider: string;
|
|
34
|
+
publishedModel: string;
|
|
35
|
+
/** Chiamato una volta per esito finale con verdetto Jev (pass e hold). */
|
|
36
|
+
onVerdict?: (report: GateVerdictReport) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Esito finale del gate con verdetto, per `/jev last` e diagnostica. */
|
|
40
|
+
export interface GateVerdictReport {
|
|
41
|
+
verdict: ReviewResult;
|
|
42
|
+
/** Tentativi upstream totali (1 + rigenerazioni). */
|
|
43
|
+
attempts: number;
|
|
44
|
+
/** Come si è chiuso: pubblicato dopo ok, o trattenuto. */
|
|
45
|
+
outcome: "published" | "held";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class GuardianLimitError extends Error {
|
|
49
|
+
code = "BUFFER_LIMIT" as const;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class GuardianUpstreamError extends Error {
|
|
53
|
+
code = "UPSTREAM_ERROR" as const;
|
|
54
|
+
upstreamMessage: AssistantMessage;
|
|
55
|
+
constructor(message: AssistantMessage) {
|
|
56
|
+
super(message.errorMessage || "Upstream error");
|
|
57
|
+
this.upstreamMessage = message;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Consuma privatamente lo stream upstream fino al messaggio finale.
|
|
63
|
+
* Non inoltra alcun evento al chiamante. Applica limite byte e abort.
|
|
64
|
+
*/
|
|
65
|
+
export async function consumePrivate(
|
|
66
|
+
stream: AssistantMessageEventStream,
|
|
67
|
+
args: { maxBufferedBytes: number; signal?: AbortSignal },
|
|
68
|
+
): Promise<AssistantMessage> {
|
|
69
|
+
let bufferedBytes = 0;
|
|
70
|
+
const count = (text: string) => {
|
|
71
|
+
bufferedBytes += byteLengthUtf8(text);
|
|
72
|
+
if (bufferedBytes > args.maxBufferedBytes) {
|
|
73
|
+
throw new GuardianLimitError(
|
|
74
|
+
`Buffered ${bufferedBytes} bytes over limit ${args.maxBufferedBytes}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Consuma eventi per enforcement limite; risultato finale via result().
|
|
80
|
+
// Nota: non conservare riferimenti partial per replay (sono live).
|
|
81
|
+
// Race con abort: for-await da solo resterebbe appeso se upstream non chiude.
|
|
82
|
+
let onAbort: (() => void) | undefined;
|
|
83
|
+
const abortPromise = new Promise<never>((_resolve, reject) => {
|
|
84
|
+
if (!args.signal) return;
|
|
85
|
+
if (args.signal.aborted) {
|
|
86
|
+
reject(args.signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
onAbort = () => {
|
|
90
|
+
reject(args.signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
|
91
|
+
};
|
|
92
|
+
args.signal.addEventListener("abort", onAbort, { once: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const consume = (async () => {
|
|
96
|
+
for await (const event of stream) {
|
|
97
|
+
args.signal?.throwIfAborted();
|
|
98
|
+
if (event.type === "text_delta") count(event.delta);
|
|
99
|
+
else if (event.type === "thinking_delta") count(event.delta);
|
|
100
|
+
else if (event.type === "toolcall_delta") count(event.delta);
|
|
101
|
+
else if (event.type === "error") {
|
|
102
|
+
throw new GuardianUpstreamError(event.error);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await Promise.race([consume, abortPromise]);
|
|
109
|
+
} finally {
|
|
110
|
+
if (args.signal && onAbort) {
|
|
111
|
+
args.signal.removeEventListener("abort", onAbort);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Se abort è scattato, consume resta appeso su upstream non collaborativo:
|
|
115
|
+
// solleva subito senza attendere result().
|
|
116
|
+
args.signal?.throwIfAborted();
|
|
117
|
+
const final = await stream.result();
|
|
118
|
+
args.signal?.throwIfAborted();
|
|
119
|
+
|
|
120
|
+
if (final.stopReason === "error" || final.stopReason === "aborted") {
|
|
121
|
+
throw new GuardianUpstreamError(final);
|
|
122
|
+
}
|
|
123
|
+
if (final.stopReason === "pending") {
|
|
124
|
+
throw new GuardianUpstreamError({
|
|
125
|
+
...final,
|
|
126
|
+
stopReason: "error",
|
|
127
|
+
errorMessage: "Upstream ended without stop reason",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return final;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assertCompleteCandidate(message: AssistantMessage): void {
|
|
134
|
+
// Rifiuta terminali non supportati per pubblicazione.
|
|
135
|
+
if (
|
|
136
|
+
message.stopReason !== "stop" &&
|
|
137
|
+
message.stopReason !== "length" &&
|
|
138
|
+
message.stopReason !== "toolUse" &&
|
|
139
|
+
message.stopReason !== "deferred"
|
|
140
|
+
) {
|
|
141
|
+
throw new GuardianUpstreamError({
|
|
142
|
+
...message,
|
|
143
|
+
stopReason: "error",
|
|
144
|
+
errorMessage: `Unsupported stop reason: ${message.stopReason}`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function toSafeErrorText(error: unknown, aborted: boolean): string {
|
|
150
|
+
if (aborted) return "Richiesta annullata.";
|
|
151
|
+
if (error instanceof GuardianLimitError) {
|
|
152
|
+
return `[JEV — OUTPUT NON PUBBLICATO]\n\nLimite buffer superato (${error.message}).`;
|
|
153
|
+
}
|
|
154
|
+
if (error instanceof GuardianUpstreamError) {
|
|
155
|
+
// Non trapelare contenuto candidato, solo motivo.
|
|
156
|
+
return `[JEV — ERRORE UPSTREAM]\n\n${error.message.slice(0, 500)}`;
|
|
157
|
+
}
|
|
158
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
159
|
+
return "Richiesta annullata.";
|
|
160
|
+
}
|
|
161
|
+
return "[JEV — ERRORE INTERNO]\n\nErrore durante la verifica. Output non pubblicato.";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Esegue il gate e ritorna lo stream pubblico.
|
|
166
|
+
* Nessun evento upstream viene inoltrato prima dell'approvazione.
|
|
167
|
+
*/
|
|
168
|
+
export function runGuardedStream(
|
|
169
|
+
originalContext: Context,
|
|
170
|
+
options: SimpleStreamOptions | undefined,
|
|
171
|
+
deps: GuardianDeps,
|
|
172
|
+
): AssistantMessageEventStream {
|
|
173
|
+
const out = createAssistantMessageEventStream();
|
|
174
|
+
const signal = options?.signal;
|
|
175
|
+
|
|
176
|
+
void (async () => {
|
|
177
|
+
const usages: Usage[] = [];
|
|
178
|
+
let attemptContext = originalContext;
|
|
179
|
+
const maxRegenerations = deps.config.automatic.maxRegenerations;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
for (let attempt = 0; attempt <= maxRegenerations; attempt++) {
|
|
183
|
+
signal?.throwIfAborted();
|
|
184
|
+
|
|
185
|
+
const upstreamStream = deps.upstream(attemptContext, options);
|
|
186
|
+
let candidate: AssistantMessage;
|
|
187
|
+
try {
|
|
188
|
+
candidate = await consumePrivate(upstreamStream, {
|
|
189
|
+
maxBufferedBytes: deps.config.limits.maxBufferedBytes,
|
|
190
|
+
signal,
|
|
191
|
+
});
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error instanceof GuardianUpstreamError) {
|
|
194
|
+
usages.push(error.upstreamMessage.usage ?? emptyUsage());
|
|
195
|
+
}
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
usages.push(candidate.usage ?? emptyUsage());
|
|
199
|
+
|
|
200
|
+
assertCompleteCandidate(candidate);
|
|
201
|
+
signal?.throwIfAborted();
|
|
202
|
+
|
|
203
|
+
// Copertura intera: chunk sul candidato, requisiti dichiarati.
|
|
204
|
+
// Regole gate = difetti concreti: troncamento requisiti tollerato.
|
|
205
|
+
const rawCandidate = extractCandidateText(candidate);
|
|
206
|
+
const rawRequirements = extractRequirements(originalContext);
|
|
207
|
+
|
|
208
|
+
let verdict: ReviewResult;
|
|
209
|
+
try {
|
|
210
|
+
verdict = await reviewWithCoverage(
|
|
211
|
+
deps.review,
|
|
212
|
+
{ requirements: rawRequirements, candidate: rawCandidate },
|
|
213
|
+
{ limits: deps.config.limits, requirementsSensitive: false },
|
|
214
|
+
signal,
|
|
215
|
+
);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (signal?.aborted) throw error;
|
|
218
|
+
verdict = {
|
|
219
|
+
status: "unavailable",
|
|
220
|
+
checks: [],
|
|
221
|
+
elapsedMs: 0,
|
|
222
|
+
errorCode: "JEV_THROWN",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
signal?.throwIfAborted();
|
|
226
|
+
|
|
227
|
+
if (verdict.status === "pass") {
|
|
228
|
+
deps.onVerdict?.({ verdict, attempts: attempt + 1, outcome: "published" });
|
|
229
|
+
const aggregated = aggregateUsage(usages);
|
|
230
|
+
const replayed = replayApproved(candidate, {
|
|
231
|
+
provider: deps.publishedProvider,
|
|
232
|
+
model: deps.publishedModel,
|
|
233
|
+
usage: aggregated,
|
|
234
|
+
});
|
|
235
|
+
// Inoltra SOLO eventi approvati.
|
|
236
|
+
for await (const event of replayed) {
|
|
237
|
+
signal?.throwIfAborted();
|
|
238
|
+
out.push(event as never);
|
|
239
|
+
}
|
|
240
|
+
const final = await replayed.result();
|
|
241
|
+
out.end(final);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (verdict.status === "block" && attempt < maxRegenerations) {
|
|
246
|
+
attemptContext = buildRepairContext({
|
|
247
|
+
originalContext,
|
|
248
|
+
rejectedText: rawCandidate,
|
|
249
|
+
verdict,
|
|
250
|
+
attempt: attempt + 1,
|
|
251
|
+
});
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Hold: pubblica failure sicura, mai il candidato bocciato.
|
|
256
|
+
const attempts = attempt + 1;
|
|
257
|
+
deps.onVerdict?.({ verdict, attempts, outcome: "held" });
|
|
258
|
+
const safeText = buildSafeFailureText({
|
|
259
|
+
verdict,
|
|
260
|
+
attempts,
|
|
261
|
+
notice: verdict.downgradedForTruncation
|
|
262
|
+
? "Input troncato per la verifica: Jev ha visto solo una parte (vedi truncated)."
|
|
263
|
+
: undefined,
|
|
264
|
+
});
|
|
265
|
+
const aggregated = aggregateUsage(usages);
|
|
266
|
+
const safeMessage: AssistantMessage = {
|
|
267
|
+
role: "assistant",
|
|
268
|
+
content: [{ type: "text", text: "" }],
|
|
269
|
+
api: candidate.api,
|
|
270
|
+
provider: deps.publishedProvider as AssistantMessage["provider"],
|
|
271
|
+
model: deps.publishedModel,
|
|
272
|
+
responseModel: candidate.model,
|
|
273
|
+
usage: aggregated,
|
|
274
|
+
stopReason: "stop",
|
|
275
|
+
timestamp: Date.now(),
|
|
276
|
+
};
|
|
277
|
+
out.push({ type: "start", partial: safeMessage });
|
|
278
|
+
out.push({ type: "text_start", contentIndex: 0, partial: safeMessage });
|
|
279
|
+
const block = safeMessage.content[0];
|
|
280
|
+
if (block && block.type === "text") block.text = safeText;
|
|
281
|
+
out.push({ type: "text_delta", contentIndex: 0, delta: safeText, partial: safeMessage });
|
|
282
|
+
out.push({ type: "text_end", contentIndex: 0, content: safeText, partial: safeMessage });
|
|
283
|
+
out.push({ type: "done", reason: "stop", message: safeMessage });
|
|
284
|
+
out.end(safeMessage);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
} catch (error) {
|
|
288
|
+
const aborted = signal?.aborted === true;
|
|
289
|
+
// Su abort, termina come error/aborted senza contenuto candidato.
|
|
290
|
+
const usage = usages.length > 0 ? aggregateUsage(usages) : emptyUsage();
|
|
291
|
+
const errorMessage: AssistantMessage = {
|
|
292
|
+
role: "assistant",
|
|
293
|
+
content: [{ type: "text", text: toSafeErrorText(error, aborted) }],
|
|
294
|
+
api: "openai-completions",
|
|
295
|
+
provider: deps.publishedProvider as AssistantMessage["provider"],
|
|
296
|
+
model: deps.publishedModel,
|
|
297
|
+
usage,
|
|
298
|
+
stopReason: aborted ? "aborted" : "error",
|
|
299
|
+
errorMessage: aborted
|
|
300
|
+
? "aborted"
|
|
301
|
+
: error instanceof Error
|
|
302
|
+
? error.message.slice(0, 300)
|
|
303
|
+
: String(error).slice(0, 300),
|
|
304
|
+
timestamp: Date.now(),
|
|
305
|
+
};
|
|
306
|
+
try {
|
|
307
|
+
out.push({
|
|
308
|
+
type: "error",
|
|
309
|
+
reason: aborted ? "aborted" : "error",
|
|
310
|
+
error: errorMessage,
|
|
311
|
+
});
|
|
312
|
+
} catch {
|
|
313
|
+
// stream già chiuso
|
|
314
|
+
}
|
|
315
|
+
out.end(errorMessage);
|
|
316
|
+
}
|
|
317
|
+
})();
|
|
318
|
+
|
|
319
|
+
return out;
|
|
320
|
+
}
|