Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

Completion gate (std/agent/completion_gate)

agent_completion_gate(runtime, options) returns an options fragment for agent_loop. It checks host-supplied write and verification facts and can add a bounded LLM judge. Harn owns the decision rules. The host supplies facts such as which writes changed source and whether verification passed.

agent_completion_gate#

agent_completion_gate(runtime: HarnessRuntime, options: CompletionGateOptions = {}) -> dict

Spread the returned fragment into the loop's base options:

import { agent_completion_gate } from "std/agent/completion_gate"

agent_loop(harness, task, system, base_opts + agent_completion_gate(
  harness.runtime, {
    facts: fn(ctx) { return host_completion_facts(ctx.session_id) },
    verify_command: fn() { return host_run_verify() },
    // optional bounded LLM judge, capped at 5 by default
    judge: true,
  }))

Options#

CompletionGateOptions splits into host-fact callbacks and plain-data policy knobs. Every field is optional.

FieldTypeDefaultMeaning
factsfn(ctx) -> CompletionFactsThe primary fact supplier. ctx is {session_id, task, stop_reason, text, messages}.
classify_writefn(path, diff?) -> WriteKindLabels one write when facts returns a writes list without counts.
verify_commandfn() -> CompletionVerifyVerdictRuns the verifier oracle when facts carries no verify verdict.
feedback_decoratorfn(reason, feedback, verdict) -> stringDecorates a delivered veto using its stable ladder reason and structured verdict.
require_source_writebooltrueEnforce the source-write evidence requirement.
requires_writeboolper-task factOverride the "this task needs a source change" fact.
max_vetoesint3Per-session soft-veto budget; 0 disables.
requirement_contractCompletionRequirementContractabsentDeclares requirements assessed from the facts callback's typed evidence. Requires facts.
judgebool / dictoffAttach a bounded LLM judge (true or a judge-config dict).
judge_seamstring"verify_completion_judge"Which capped LLM seam the judge rides ("verify_completion_judge" or "turn_end_condition").
feedback_templatesdictdefaultsOverride feedback by ladder key; repeated failures support {attempts} and {findings}.
escalation_thresholdint3Failed-after-write streak required to recommend escalation.
escalation_targetstringHost routing channel copied onto an escalated verdict and event.

All verifier results are required: one failed result blocks completion, and an empty result list remains unmeasured. The former veto_combine callback has been removed so a host cannot replace this rule with custom verdict arbitration.

requirement_contract uses the same typed contract and evidence roles as the completion judge. The facts callback returns requirement_assessments and requirement_evidence; Harn joins them to the declared roster. Missing, duplicate, unsupported and unmet assessments remain pending, even when a separate verifier passed or the soft-veto budget was spent. The decision receipt includes the required count, pending count and pending names. An assistant_output requirement can cite a typed response observation without inventing a tool call or artifact.

The fact types:

  • CompletionFacts{consecutive_failed_after_write?, source_write_count?, cosmetic_write_count?, writes?, verify?, verification_failures_converging?, requires_write?, requirement_assessments?, requirement_evidence?}. Supply counts directly, or a writes list of CompletionWriteFact for the gate to classify. verify is one CompletionVerifyVerdict or a list of them. When the streak is present, a red verifier uses the streak-aware reasons below; a converging diagnostic set consumes churn credit and suppresses escalation for that decision.
  • CompletionWriteFact{path?, diff?, kind?}. kind is a WriteKind string; "source" and "cosmetic" are load-bearing, anything else is treated as non-source.
  • CompletionVerifyVerdict{ok?, findings?, command?}. findings is optional red-detail text. command optionally names what the oracle ran, so the reading passed to the bounded judge can say which verification passed.

The deterministic ladder#

The gate never keys on a done-sentinel string. It decides purely from write and verifier facts, first match wins:

ReasonResultCondition
no_source_writeveto (soft)task requires a source change, but only cosmetic / zero source writes so far
verification_after_write_redveto (strict)a source write with a red verifier and no streak fact
failed_verificationveto (strict)streak-aware red verifier below the threshold, or diagnostic churn is still converging
repeated_verification_failuresveto (strict) + escalationnon-converging red verifier at or above escalation_threshold
verified_after_write / verifiedallowverifier is green
missing_verificationveto (strict)source written, verifier configured, not yet run — the budget never releases it
no_workspace_writeallowtask does not require a source change
veto_budget_exhaustedallowa soft veto after max_vetoes, converted to an attributable end

Only source writes count as progress toward done. A cosmetic final write (a comment, a .md typo) is not evidence: it can't flip an already-green run back to unverified, and a run that wrote only cosmetics can't claim done. A strict veto (a source write with a red or missing verifier) is never released by the veto budget, so a failing build cannot end as verified.

Each deterministic decision emits a judge_decision event with trigger: "verify_completion", confirm, and the stable reason above. When the budget converts a soft veto into an allow, the event carries reason: "veto_budget_exhausted" plus converted_from with the original class. Streak escalation decisions also carry escalation_recommended and, when configured, escalation_target. Consumers read the same fields from the session's judge_decision events.

feedback_decorator runs only after the ladder and veto budget resolve a veto that will actually be delivered. It receives (reason, feedback, verdict); the verdict includes any escalation fields, so a host can attach language-specific repair cues without parsing the default prose.

Degraded mode#

With neither facts nor verify_command, the deterministic gate has nothing to assert, so it abstains: it allows, and marks _completion_gate.facts_available = false with a verdict reason of facts_unavailable. It never fabricates a pass. Any configured LLM judge still runs, so this is judge-only mode, not no-op mode.

The optional bounded judge#

Set judge to add an LLM check after the deterministic ladder. judge: true uses defaults. A dict may set the provider, model, system prompt, timeouts, and invocation cap. The judge uses verify_completion_judge by default; set judge_seam: "turn_end_condition" to use that completion trigger instead. The default cap is 5 calls per session. Past the cap, the loop ends with status completion_unverified. Set max_invocations: 0 to disable the cap. Two helpers expose the catalog review and the resolved cap:

agent_completion_review(llm, opts) -> dict
agent_verify_completion_judge_cap(judge_cfg, review?) -> int | nil
agent_turn_end_judge_cap(judge_cfg) -> int | nil

agent_turn_end_judge_cap is exported by std/agent/turn_end, which owns the turn-end condition end to end: whether one is configured, whether its bounded judge is due at this boundary, and how its caps and invocation counts are resolved. This module holds the product question "may this turn end?" and nothing else; make check-turn-end-boundary fails if grading vocabulary appears in it, or if an eval or bench module reaches into its internals instead of reading the public result.turn_end_condition block off a finished session.

agent_completion_review reads the session model's catalog row and returns {scrutiny: "standard"} when the row omits completion_review. The verification-judge cap prefers an explicit max_invocations, then catalog max_judge_calls, then 5. Both cap helpers return nil when the cap is disabled.

What the judge is shown, and what it may refuse on#

The ladder's verifier reading is passed to the judge rather than discarded. The evidence snapshot carries a typed verification: {oracle_expected, command, observed, observed_at_evidence_index}, where observed is passed, failed, or not_run, and the judge's prompt renders it as an explicit deterministic-verification block. not_run covers both "no oracle was configured" and "the gate could not read facts", so a facts_unavailable allow can never read as a passing verification.

The judge's verdict carries a gap_class alongside verdict and detail: missing_artifact, unmet_manner_clause, failed_verification, unresolved_authorization, or other. It is optional on the wire — an absent or unrecognized value reads as other.

A continue naming failed_verification is converted to done when, and only when, all four hold: the deterministic stage actually ran (the directive receipt's invoked.deterministic), the threaded observed is passed, the gate's reason is not facts_unavailable, and the named class is failed_verification. The converted directive carries converted_from: "failed_verification_contradicted_by_gate". Count conversions by converted_from; a receipt's trigger names whichever adjudicator answered, not the boundary that asked.

Every other gap_class vetoes exactly as before. The judge keeps sole authority over artifact clauses, manner and negative clauses, and authorization, and loses it only over the one question a deterministic oracle has already answered.

When the judge is not called at all#

Neither judge seam is called when the runtime already holds the answer. All seven of these must hold:

ClauseRequired reading
the gate ranthe directive receipt's invoked.deterministic
the gate allowed on a proven writereason is exactly verified_after_write
the reading agrees with the reasonthreaded observed is passed
the turn is the sealed final answerstop_reason is sentinel
there is an answer to hand backthe final response is non-empty
nothing is deferred past the turnpending_tool_batch_effect_count is zero
no acceptance row is outstandingthe ledger's pending_count is zero

No provider call is made and no judge_started event is emitted. The directive seals as accept with source: "gate" and outcome: "skipped_verified_after_write", and invoked.turn_end_condition stays false, so a reader can tell a judge that never ran from one that ran and agreed.

verified is deliberately not verified_after_write. A green verifier with no source write behind it is an ordinary prose completion and still calls the judge, as do an unverified path, a red or unrun verifier, a non-sentinel boundary, and a run with no deterministic gate.

The acceptance-ledger clause is there because the skip does not only skip the judge. The pending-requirements check that turns a done into a continue lives inside the structured invocation, so a judge that is never asked takes the ledger with it. A declared row can only be established as met by an assessment, so with no assessment in hand every declared row is pending and the judge is asked. What the clause never does is let the skip fire because a ledger came back met.

The judge cap reads the same ledger. A cap reached over a passing verifier converts to stop_verified, which seals done; that conversion now also requires the ledger to be clear, because a cap is a budget rather than an assessment and the judge may have spent its whole budget refusing the row. With no rows outstanding the conversion is unchanged and still reports judge_cap_reached_over_verified_pass.

A done sealed without a judge now records an explicit empty ledger rather than nil. nil reads the same for "no rows were declared" and "rows were declared and nobody looked", and only the second is a false completion.

One predicate answers for both slots, read at the single point where the plan decides whether to announce a judge. The catalog-declared completion_review light-scrutiny skip is subordinate to it: that rule never reads the verification at all, so on a typed green terminal it was deciding, from a model's catalog row, a question the oracle had already answered. It now decides only what the verification did not, and its receipt still carries source: "catalog" so a reader can tell the two skips apart. The ledger clause applies to that skip too, for the same reason: a skipped judge is a skipped pending-requirements check, whichever rule did the skipping.

Projection receipts name their selected actions and resolved evidence roles in selected_actions, so a host whose verifier declares no completion_evidence_role — and whose passing verification is therefore counted and then dropped from the bounded packet — is visible without arithmetic on the counts.

See also#