blob: bbd2fd2fbd750a05bcf3f8cbc9cc39d5f4de8f1c [file]
/**
* Goal evaluator — CC-style external judge. Runs after each turn to decide
* whether the goal is met, impossible, making progress, or waiting on an
* external event. The judge runs on the SESSION's own model (see wiring): the
* evaluation reads that session's recent messages, so routing it to the same
* provider keeps the judgment consistent and avoids leaking session text to an
* unrelated default connection.
*
* The working model never judges its own completion (unlike Codex): keeping the
* judge external prevents the agent from rationalizing itself into a premature
* "done", which is Codex's documented failure mode.
*/
import { truncateGoalText, type GoalTextLimit } from './goal-state.js';
import type { NormalizedUsage } from './model-protocol.js';
import { generateToolFreeModelCall } from './tool-free-model-call.js';
export interface GoalEvaluation {
/** Condition is satisfied — stop, success. */
met: boolean;
/** Fundamentally unachievable — stop, give up. */
impossible: boolean;
/** The last turn advanced toward the goal (resets the block cap). */
progress: boolean;
/** The agent is blocked waiting on an external event (CI, deploy, review). */
waiting: boolean;
/**
* The evaluator failed (timeout/error) and produced no real judgment. The
* caller should treat `progress` as UNKNOWN — neither advancing nor resetting
* the stall counter — so a transient evaluator outage cannot silently defeat
* stall detection. Fail-open on continuation still applies.
*/
evaluatorFailed: boolean;
/** One-sentence rationale, fed back to the agent as steering. */
reason: string;
}
export interface GoalEvaluatorDeps {
/**
* Single-shot LLM call for goal evaluation, run on the session's own model
* (the wiring resolves the session's connection from `sessionId`). The
* evaluator must not run tools or read files — it judges from text only.
*/
evaluate: (prompt: string, sessionId: string, signal: AbortSignal) => Promise<string>;
/** Hard timeout for the evaluator call (ms). Defaults to 30_000 (CC's limit). */
timeoutMs?: number;
/** Injectable timer for tests. Defaults to global setTimeout/clearTimeout. */
setTimeout?: (fn: () => void, ms: number) => unknown;
clearTimeout?: (handle: unknown) => void;
}
export interface GoalEvaluatorResource extends GoalEvaluatorDeps {
/** Wait until every accepted physical evaluation and its cleanup have settled. */
close(): Promise<void>;
}
export interface GoalEvaluationModelInput {
readonly model: unknown;
readonly prompt: string;
readonly providerOptions?: unknown;
readonly abortSignal?: AbortSignal;
}
export interface GoalEvaluationModelResult {
readonly text: string;
readonly usage?: NormalizedUsage;
readonly finishReason?: string;
}
/** Runs the bounded, tool-free model call and returns its accounting facts. */
export async function generateGoalEvaluationModelCall(
input: GoalEvaluationModelInput,
): Promise<GoalEvaluationModelResult> {
return generateToolFreeModelCall({
model: input.model,
prompt: input.prompt,
...(input.abortSignal === undefined ? {} : { abortSignal: input.abortSignal }),
...(input.providerOptions === undefined ? {} : { providerOptions: input.providerOptions }),
maxOutputTokens: 1_024,
});
}
const DEFAULT_EVALUATOR_TIMEOUT_MS = 30_000;
const EVALUATOR_REASON_TEXT_LIMIT: GoalTextLimit = Object.freeze({
codeUnits: 200,
utf8Bytes: 600,
});
const EVALUATOR_SYSTEM = `You are a goal evaluation judge for an autonomous coding agent. Given a GOAL CONDITION and recent CONVERSATION CONTEXT, judge the agent's progress.
Respond ONLY with valid JSON in this exact shape:
{"met": boolean, "impossible": boolean, "progress": boolean, "waiting": boolean, "reason": "one sentence"}
Field rules:
- met: true ONLY if there is clear, concrete evidence the condition is fully satisfied. Match verification scope to the requirement scope — do not accept a narrower substitute.
- impossible: true ONLY for a truly unachievable goal (violates constraints/physics), not merely a hard one.
- progress: true if the last turn moved measurably closer to the goal (fixed a failure, advanced a step). false if the turn spun, repeated itself, or did nothing useful.
- waiting: true if the agent is correctly blocked on an external event it cannot speed up (CI run, deploy, remote queue, human review).
- reason: concise (under 120 chars), specific, actionable steering for the next turn.
Be conservative on "met" and "impossible". When uncertain, met=false impossible=false progress=false waiting=false.`;
export function buildGoalEvaluationPrompt(condition: string, context: string): string {
return [
EVALUATOR_SYSTEM,
'',
'--- GOAL CONDITION ---',
condition,
'',
'--- RECENT CONVERSATION CONTEXT ---',
context,
'',
'--- YOUR JUDGMENT (JSON only) ---',
].join('\n');
}
export function parseGoalEvaluation(raw: string): GoalEvaluation {
// Unparseable output is "no real judgment" — treat it as a NEUTRAL evaluator
// failure (like a timeout), NOT as real "no progress", so a garbled cheap-model
// response cannot skew stall detection into a false 'stalled' termination.
const fallback: GoalEvaluation = {
met: false,
impossible: false,
progress: false,
waiting: false,
evaluatorFailed: true,
reason: 'Evaluator produced unparseable output',
};
// Prefer the object that mentions "met"; fall back to the first object.
const jsonMatch = raw.match(/\{[^{}]*"met"[^{}]*\}/s) ?? raw.match(/\{[\s\S]*?\}/);
if (!jsonMatch) return fallback;
try {
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
return {
met: Boolean(parsed.met),
impossible: Boolean(parsed.impossible),
progress: Boolean(parsed.progress),
waiting: Boolean(parsed.waiting),
evaluatorFailed: false,
reason:
typeof parsed.reason === 'string' && parsed.reason.trim()
? truncateGoalText(parsed.reason, EVALUATOR_REASON_TEXT_LIMIT)
: 'No reason provided',
};
} catch {
return { ...fallback, reason: 'Evaluator JSON parse failed' };
}
}
/**
* Race the evaluator against a hard timeout. On timeout or error, fail OPEN
* for continuation (goal keeps working) but flag `evaluatorFailed` so the
* caller does not treat the outage as either progress or a stall.
*/
export async function evaluateGoal(
deps: GoalEvaluatorDeps,
condition: string,
context: string,
sessionId: string,
abortSignal?: AbortSignal,
): Promise<GoalEvaluation> {
const prompt = buildGoalEvaluationPrompt(condition, context);
const timeoutMs = deps.timeoutMs ?? DEFAULT_EVALUATOR_TIMEOUT_MS;
const setT = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
const clearT = deps.clearTimeout ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
const controller = new AbortController();
const timedOut = Symbol('timed_out');
const cancelled = Symbol('cancelled');
let timer: unknown;
let resolveCancellation!: (result: typeof timedOut | typeof cancelled) => void;
const cancellation = new Promise<typeof timedOut | typeof cancelled>((resolve) => {
resolveCancellation = resolve;
});
const cancel = () => {
resolveCancellation(cancelled);
controller.abort(abortSignal?.reason);
};
if (abortSignal?.aborted) cancel();
else abortSignal?.addEventListener('abort', cancel, { once: true });
timer = setT(() => {
resolveCancellation(timedOut);
controller.abort(new Error('Goal evaluator timed out'));
}, timeoutMs);
try {
if (abortSignal?.aborted) return cancelledEvaluation();
const result = await Promise.race([
deps.evaluate(prompt, sessionId, controller.signal),
cancellation,
]);
if (result === timedOut) {
return {
met: false,
impossible: false,
progress: false,
waiting: false,
evaluatorFailed: true,
reason: 'Evaluator timed out (continuing)',
};
}
if (result === cancelled) return cancelledEvaluation();
return parseGoalEvaluation(result);
} catch {
return {
met: false,
impossible: false,
progress: false,
waiting: false,
evaluatorFailed: true,
reason: 'Evaluator call failed (continuing)',
};
} finally {
abortSignal?.removeEventListener('abort', cancel);
clearT(timer);
}
}
function cancelledEvaluation(): GoalEvaluation {
return {
met: false,
impossible: false,
progress: false,
waiting: false,
evaluatorFailed: true,
reason: 'Evaluator cancelled (continuing)',
};
}
export { DEFAULT_EVALUATOR_TIMEOUT_MS };