// std/agent/judge_internals — private helpers shared by `std/agent/judge`
// (verify_completion + done_judge) and `std/agent/step_judge`. NOT
// intended as a public surface: every export is prefixed `__judge_` and
// the file is excluded from the docs sweep. Keep shared judge plumbing here:
// LLM-option overrides, structured-output schema field names, and the
// "raw_verdict -> {vetoed, feedback}" classifier.
/**
* Keys on `judge_cfg` that override `opts.llm_options` when present.
* Listed once here so adding (or renaming) an LLM tuning knob is a
* single-line change instead of a 2x sweep.
*/
const __JUDGE_LLM_OVERRIDE_KEYS = ["temperature", "max_tokens", "top_p", "tool_format", "reasoning_effort"]
/**
* Apply per-judge LLM overrides on top of an already-built `llm_opts`
* dict. Mirrors the `for key in [...]` block that lived inline at both
* call sites before v0.8.43.
*
* @effects: []
* @errors: []
* @api_stability: internal
*/
pub fn __judge_apply_llm_overrides(llm_opts, judge_cfg) {
var out = llm_opts
for key in __JUDGE_LLM_OVERRIDE_KEYS {
if judge_cfg[key] != nil {
out = out + {[key]: judge_cfg[key]}
}
}
return out
}
/**
* JSON structural characters can never be part of a legitimate verdict
* token, so a captured verdict containing one was mangled upstream.
*/
const __JUDGE_VERDICT_JSON_JUNK = ["\"", ",", "{", "}", ":", "\\"]
/**
* Normalize a captured judge verdict to its leading token. Structured
* judges occasionally emit sloppy JSON (double commas, run-on key/value
* pairs) that the structured-call repair layer salvages by capturing
* trailing JSON junk into the verdict string — observed live in
* `judge_decision` events as `continue",,` and `continue", "reasoning":`.
* Cut at the first JSON structural character and trim; verdicts without
* JSON junk (including multi-word prose verdicts) pass through unchanged.
*
* @effects: []
* @errors: []
* @api_stability: internal
* @example: __judge_verdict_token("continue\",,")
*/
pub fn __judge_verdict_token(raw_verdict) {
let normalized = lowercase(trim(to_string(raw_verdict ?? "")))
var cut = len(normalized)
for junk in __JUDGE_VERDICT_JSON_JUNK {
let idx = normalized.index_of(junk)
if idx >= 0 && idx < cut {
cut = idx
}
}
if cut == len(normalized) {
return normalized
}
return trim(normalized[0:cut])
}
/**
* Punctuation that cheap models hang off the leading verdict word
* (`"done."`, `"done!"`, `"pass:"`, `"(done)"`, quotes/asterisks from
* markdown emphasis). Stripped from both ends of the leading token before
* the allow-list compare so a decorated `done` still classifies as DONE.
*/
const __JUDGE_TOKEN_PUNCT = ".,;:!?\"'`*_-)("
/**
* Reduce a normalized verdict to its leading whitespace-delimited word with
* surrounding punctuation stripped. Cheap models decorate enum values
* (`"done."`, `"yes, complete"`, `"done — all tests pass"`); the verdict's
* *intent* is its first word, so classification keys on that word instead of
* requiring whole-string equality. `"not done yet"` reduces to `"not"`
* (still a veto), while `"done."` reduces to `"done"` (a pass).
*
* @effects: []
* @errors: []
* @api_stability: internal
* @example: __judge_leading_word("done.")
*/
pub fn __judge_leading_word(normalized) {
var first = ""
for piece in normalized.split(" ") {
if first == "" && trim(piece) != "" {
first = trim(piece)
}
}
// Strip leading punctuation.
var start = 0
while start < len(first) && __JUDGE_TOKEN_PUNCT.contains(first.char_at(start)) {
start = start + 1
}
// Strip trailing punctuation.
var stop = len(first)
while stop > start && __JUDGE_TOKEN_PUNCT.contains(first.char_at(stop - 1)) {
stop = stop - 1
}
return first[start:stop]
}
/**
* Classify a raw verdict string against an allow-list of pass tokens and
* compose the `{vetoed, feedback?}` outcome. `feedback_candidates` is
* tried in order: the first non-nil, non-empty entry wins. Falls back
* to `feedback_default` when nothing else is set.
*
* Used by `agent_step_judge` (pass tokens like "pass"/"yes"/"approve") and
* by `agent_verify_or_continue` (pass tokens like "done"/"complete").
* The raw verdict is normalized through `__judge_verdict_token` first, then
* reduced to its leading word so a decorated enum value (`"done."`,
* `"done — all tests pass"`) still classifies. The stored/emitted `verdict`
* field carries the clean leading word.
*
* @effects: []
* @errors: []
* @api_stability: internal
* @example: __judge_classify_verdict("pass", ["pass", "yes"], [critique], default)
*/
pub fn __judge_classify_verdict(raw_verdict, pass_tokens, feedback_candidates, feedback_default) {
let normalized = __judge_leading_word(__judge_verdict_token(raw_verdict))
if contains(pass_tokens, normalized) {
return {vetoed: false, verdict: normalized}
}
var feedback = ""
for candidate in feedback_candidates {
if feedback == "" && candidate != nil && to_string(candidate) != "" {
feedback = to_string(candidate)
}
}
if feedback == "" {
feedback = feedback_default
}
return {vetoed: true, feedback: feedback, verdict: normalized}
}
// -------------------------------------------------------------------------------------------------
// Completion-gate policy (pure). These implement the deterministic veto
// arithmetic that `std/agent/judge::completion_gate` rides on `verify_completion`.
// They are PURE (no store / no LLM / no host calls) so the parity fixtures can
// pin burin-code's decisions on synthetic verdict sets. All host facts
// (source-vs-cosmetic write classification, verifier verdict) enter as data —
// the split from burin-code's harn#3817 principle "Harn owns orchestration
// policy; hosts supply facts". NEVER key any decision on a done-sentinel string
// (ledger ⛔#3): the gate reads only write/verify facts.
// -------------------------------------------------------------------------------------------------
/**
* Reduce a list of host write facts to `{source_write_count, cosmetic_write_count,
* other_write_count}`. Each write is classified by `classify_write(path, diff?)`
* when the callback is supplied, else by the write's own `kind` field, else
* conservatively as `"source"` (an unclassified write counts as source progress,
* so the evidence gate never manufactures a false veto — ledger ⛔#5's spirit).
* Only the literal kind `"cosmetic"` is excluded from source progress; any other
* non-`"source"` kind (e.g. `"test"`, `"doc"`) is counted as `other`.
*
* @effects: []
* @errors: []
* @api_stability: internal
*/
pub fn __completion_gate_classify_writes(writes, classify_write) {
var source = 0
var cosmetic = 0
var other = 0
for write in writes ?? [] {
let path = to_string(write?.path ?? "")
let kind = if classify_write != nil && path != "" {
to_string(classify_write(path, write?.diff) ?? "source")
} else {
to_string(write?.kind ?? "source")
}
if kind == "cosmetic" {
cosmetic = cosmetic + 1
} else if kind == "source" {
source = source + 1
} else {
other = other + 1
}
}
return {source_write_count: source, cosmetic_write_count: cosmetic, other_write_count: other}
}
/**
* Combine one or more host verifier verdicts (`{ok, findings?}`) into a single
* verdict. A caller-supplied `veto_combine(verdicts)` overrides the default; the
* default is explicit AND-of-oracles arithmetic — the combined verdict is green
* only when EVERY verdict is green, and red findings are concatenated. Returns
* nil when there is no verdict to combine (verifier state unknown).
*
* @effects: []
* @errors: []
* @api_stability: internal
*/
pub fn __completion_gate_combine_verify(verify, veto_combine) {
if verify == nil {
return nil
}
let verdicts = if type_of(verify) == "list" {
verify
} else {
[verify]
}
if len(verdicts) == 0 {
return nil
}
if veto_combine != nil {
return veto_combine(verdicts)
}
var all_ok = true
var findings = []
for verdict in verdicts {
if !(verdict?.ok ?? false) {
all_ok = false
let finding = to_string(verdict?.findings ?? "")
if finding != "" {
findings = findings.push(finding)
}
}
}
return {ok: all_ok, findings: join(findings, "; ")}
}
/**
* The default completion-gate veto arithmetic: an ordered precedence ladder over
* derived facts, ported from burin-code `completion_gate_uncapped_status`. First
* matching rule wins.
*
* `derived` fields: `source_known` (whether write facts were supplied),
* `source_write_count`, `cosmetic_write_count`, `requires_write` (task needs a
* source change), `verify` (`{ok, findings}` or nil = unknown), `oracle_expected`
* (a verifier is configured).
*
* Returns `{ok, reason, strict?, feedback?}`. `strict: true` marks a veto that the
* per-session veto budget may NEVER convert to an allow — every post-write-not-green
* class (a red verifier, or a source write whose expected verifier has not produced a
* green verdict yet). This matches burin's carve-out: its budget-exhaustion refuses to
* release the whole `saw_write && !verification_after_last_write` set, because a source
* write always needs a fresh green verifier before completion.
*
* @effects: []
* @errors: []
* @api_stability: internal
*/
pub fn __completion_gate_ladder(derived) {
let source_known = derived?.source_known ?? false
let source_writes = to_int(derived?.source_write_count ?? 0) ?? 0
let cosmetic_writes = to_int(derived?.cosmetic_write_count ?? 0) ?? 0
let saw_source = source_known && source_writes > 0
let requires_write = derived?.requires_write ?? true
let verify = derived?.verify
let verify_known = verify != nil
let verify_ok = verify_known && (verify?.ok ?? false)
let findings = to_string(verify?.findings ?? "")
let oracle_expected = derived?.oracle_expected ?? false
// 1. Evidence requirement (ledger ⛔#5): a task that must change source may not
// finish with zero SOURCE writes. Cosmetic/test-scaffold writes are not progress.
if source_known && requires_write && !saw_source {
let message = if cosmetic_writes > 0 {
"This task expects a source-code change, but only cosmetic / non-source writes have been made. Make the required change to a source file before finishing."
} else {
"This task expects a source-code change, but no source file has been written yet. Make the required change before finishing."
}
return {ok: false, reason: "no_source_write", strict: false, feedback: message}
}
// 2. Post-write verification: a source write (or an unclassified write set) with
// a RED verifier blocks, and this class is strict — the budget can never let it
// through; only a fresh green verifier ends it.
if verify_known && !verify_ok && (saw_source || !source_known) {
let detail = if findings != "" {
" Findings: " + findings + "."
} else {
""
}
return {
ok: false,
reason: "verification_after_write_red",
strict: true,
feedback: "Verification is still failing after your change." + detail
+ " Fix the specific failure and re-run the verifier before finishing.",
}
}
// 3. Green verifier -> completion is honorable.
if verify_ok {
return {
ok: true,
reason: if saw_source {
"verified_after_write"
} else {
"verified"
},
}
}
// 4. Source written, a verifier is configured, but it has not run yet. Strict:
// burin never budget-releases a post-write state without a fresh green verifier
// (`saw_write && !verification_after_last_write`), so this class — like the red
// one above — is never converted to an allow. Only a green verifier ends it.
if saw_source && oracle_expected && !verify_known {
return {
ok: false,
reason: "missing_verification",
strict: true,
feedback: "You changed source but have not run the verifier yet. Run the narrowest verification for your change before finishing.",
}
}
// 5. Nothing left to gate.
if source_known && !saw_source && !requires_write {
return {ok: true, reason: "no_workspace_write"}
}
if saw_source {
return {ok: true, reason: "wrote_source_no_oracle"}
}
// Facts too thin for a deterministic verdict (unknown writes, no verifier
// signal): the deterministic gate ABSTAINS and lets any configured LLM judge
// decide. Never a silent fabricated pass — the caller surfaces the degraded mode.
return {ok: true, reason: "gate_abstain"}
}
/**
* Apply the per-session veto budget to a ladder verdict. Pure: the caller reads
* `vetoes_used` from the store and persists the charge. After `max_vetoes`
* non-strict vetoes the gate converts a would-be veto into an attributable allow
* (`veto_budget_exhausted`), so a run that a weak model can never satisfy still
* ends with a named reason instead of riding vetoes to the wall-clock timeout.
* A `strict` veto (post-write-red) is NEVER converted. `max_vetoes <= 0` disables
* the budget (unbounded vetoes).
*
* Returns `{verdict, charge}` — `charge: true` means the caller should increment
* the session veto counter.
*
* @effects: []
* @errors: []
* @api_stability: internal
*/
pub fn __completion_gate_apply_budget(verdict, vetoes_used, max_vetoes) {
if verdict?.ok ?? false {
return {verdict: verdict, charge: false}
}
let strict = verdict?.strict ?? false
if !strict && max_vetoes > 0 && vetoes_used >= max_vetoes {
return {
verdict: {ok: true, reason: "veto_budget_exhausted", converted_from: verdict?.reason ?? ""},
charge: false,
}
}
return {verdict: verdict, charge: true}
}