import "std/agent/loop_call_budget"
import "std/agent/loop_support"
import "std/agent/loop_turn_options"
import "std/agent/loop_turn_scope"
import {
AgentDeclaredArtifactAudit,
agent_declared_artifacts_audit,
agent_declared_artifacts_convict,
} from "std/agent/required_artifacts"
/**
* Why a run that reached its own end did not do the work, or `nil` when there
* is no evidence that it failed to.
*
* ONE owner for the abandonment decision, and it is deliberately independent of
* the completion judge. The judge is optional, costs a model call, and on a
* cheap model accepts a blocked run as done (harn#7910). Most runs configure no
* judge at all, so a terminal that can only say "gave up" when a judge is
* present cannot say it for most runs.
*
* The rule is POSITIVE EVIDENCE ONLY. Absence never convicts:
*
* - A run that called no tools at all is not abandoned. Answering a question is
* a legitimate completed run, and treating a quiet run as a failed one would
* be a worse defect than the one this fixes.
* - A run with even one successful tool call is not abandoned by THE TOOL TALLY.
* Whether what it did was CORRECT is a different question, and the judge's or
* the declared-artifact contract's to answer.
*
* What does convict:
*
* - `required_tool_missing`: the caller declared required tools and one never
* succeeded. Already computed; previously it could only warn.
* - `no_tool_call_succeeded`: the run attempted tool calls and not one of them
* succeeded. This is the issue's read-only workspace and its denied approval,
* in one rule, because the per-call tally is decided by `__tool_result_ok`,
* the same structured reader that feeds the session name lists, which counts
* a handler throw and a `permission_denied` refusal alike and never
* substring-matches prose.
* - `declared_artifact_missing`: the caller declared what the run must produce
* and a declared path does not exist. This is the issue's third cause, the
* run whose every tool call succeeded and which nonetheless did the wrong
* thing. A tool tally cannot see it and must not try; the caller's own
* declaration is the only evidence available without a model call. Ordered
* last so that a run which produced nothing because nothing worked is
* reported by the more proximate cause.
*
* The call counts, not the session name lists, decide the second rule.
* `successful_tools_seen` and `rejected_tools_seen` deduplicate by tool NAME,
* so their lengths answer "which tools worked" and not "how many calls were
* made". Both readings agree on whether the count is zero, but only the tally
* can be reported as evidence without mislabeling it.
*
* @effects: []
* @errors: []
*/
pub fn __agent_loop_abandonment_cause(
opts: dict,
successful_tools_seen: list,
attempted_tool_calls: int,
succeeded_tool_calls: int,
artifacts: AgentDeclaredArtifactAudit,
) -> string? {
if len(agent_required_tools_missing_from_session(opts, successful_tools_seen)) > 0 {
return "required_tool_missing"
}
if attempted_tool_calls > 0 && succeeded_tool_calls == 0 {
return "no_tool_call_succeeded"
}
if agent_declared_artifacts_convict(artifacts) {
return "declared_artifact_missing"
}
return nil
}
/**
* The natural stop reasons that no judge or reserve verification decided.
*
* Named rather than inlined so the omission from the classifier's nine is
* visible at a glance and a reviewer can argue with the list itself.
*/
const __AGENT_LOOP_UNJUDGED_NATURAL_REASONS: list<string> = [
"",
"completed",
"natural",
"required_tools_satisfied",
"sentinel",
]
/**
* Whether this terminal would otherwise be sealed as a natural completion.
*
* Scoped narrowly on purpose. A run that already carries a suspension, a
* terminal error, or any non-completion status has an owner for its outcome
* and must not have this one layered on top; the abandonment rule exists only
* for the ordinary path, where the terminal says `natural` no matter what
* happened. The status set mirrors the classifier's `done` arm, which
* normalizes an empty status to `done`.
*
* The stop-reason set is deliberately FIVE of the classifier's nine natural
* reasons, and the four left out are the point rather than an oversight.
* `post_edit_reverify`, `repeated_verified_pass` and `stalled_turn_end_judge`
* are reached only when a completion judge or a reserve verification already
* ruled on this run, and `done` is the judge-accepted spelling. Something with
* more evidence than a tool tally has already decided those, and a judge that
* accepts a blocked run is harn#7910's defect to fix, not this rule's to
* override. Adding one of them here would silently take that decision away
* from its owner.
*
* @effects: []
* @errors: []
*/
pub fn __agent_loop_terminal_seals_natural(
final_status: string,
stop_reason: any,
terminal_error: any,
suspend_result: any,
) -> bool {
if terminal_error != nil || suspend_result != nil {
return false
}
if final_status != "" && final_status != "done" {
return false
}
const reason = to_string(stop_reason ?? "")
return contains(__AGENT_LOOP_UNJUDGED_NATURAL_REASONS, reason)
}
/*
* The turn engine and terminal policy have different control-flow contracts.
* Keep reserve verification and the one-shot terminal callback here; callers
* receive an explicit `action` instead of smuggling `continue`/`break` across
* a module boundary.
*/
pub fn __agent_loop_terminal_boundary(harness: Harness, session: dict, state: dict) -> dict {
let opts = state.opts
let final_status = state.final_status
let stop_reason = state.stop_reason
let terminal_write_unverified = state.terminal_write_unverified
let stall_state = state.stall_state
let call_budget = state.call_budget
let verify_attempts = state.verify_attempts
let verify_completion_judge_invocations = state.verify_completion_judge_invocations
let verify_completion_judge_vetoes = state.verify_completion_judge_vetoes
let feedback_history = state?.feedback_history ?? {}
let current_max = state.current_max
let budget_exhausted_emitted = state.budget_exhausted_emitted
let budget_decisions = state.budget_decisions
let terminal_error = state.terminal_error
if final_status == "" && state.iteration >= current_max && stop_reason == nil {
final_status = "budget_exhausted"
stop_reason = stop_reason ?? "max_iterations"
}
if terminal_error == nil && state.last_turn_parse_dropped
&& final_status
== "budget_exhausted"
&& stop_reason
== "max_iterations" {
final_status = "error"
stop_reason = "parse_dropped"
terminal_error = {
terminal_class: "parse_dropped",
category: "harness",
reason: "parse_dropped",
message:
"The final model turn contained tool-shaped output that the text-tool parser dropped.",
}
}
if __agent_loop_should_spend_reserve(
state.reserve_cfg,
opts,
final_status,
terminal_write_unverified,
state.made_source_write,
verify_attempts < state.max_verify_attempts,
) {
const reserve_kind = if terminal_write_unverified {
"reserved_terminal_verify"
} else {
"zero_write_terminal_verify"
}
// Open the window before spending it. `verify_passed`/`verify_failed`/
// `grant` all report a reserve cycle that already finished, so a host
// watching only those learns about the verification after the user has
// already waited through it. Same event, same phase vocabulary: the
// lifecycle keeps one owner rather than growing a sibling kind.
agent_emit_event(
harness.agent,
session.session_id,
"reserved_terminal_verify",
{reserveKind: reserve_kind, phase: "verify_started", iteration: state.iteration},
)
const terminal_verdict = agent_evaluate_completion(
harness,
session,
opts
+ {
_turn_end_judge_due: false,
_turn_end_judge_invocations: state.turn_end_judge_invocations,
_verify_completion_judge_invocations: verify_completion_judge_invocations,
_feedback_history: feedback_history,
},
reserve_kind,
"",
state.iteration,
)
feedback_history = terminal_verdict?.feedback_history ?? feedback_history
verify_completion_judge_invocations += __completion_judge_hit(terminal_verdict)
if terminal_verdict.action == "stop_unverified" {
return state
+ {
action: "break",
final_status: "completion_unverified",
stop_reason: __completion_stop_reason(terminal_verdict),
terminal_write_unverified: terminal_write_unverified,
stall_state: stall_state,
verify_completion_judge_invocations: verify_completion_judge_invocations,
feedback_history: feedback_history,
}
}
if terminal_verdict.action == "accept" {
terminal_write_unverified = false
stall_state = stall_state + {reverify_owed: false}
final_status = "done"
stop_reason = reserve_kind
agent_emit_event(
harness.agent,
session.session_id,
"reserved_terminal_verify",
{reserveKind: reserve_kind, phase: "verify_passed", iteration: state.iteration},
)
return state
+ {
action: "break",
final_status: final_status,
stop_reason: stop_reason,
terminal_write_unverified: terminal_write_unverified,
stall_state: stall_state,
verify_completion_judge_invocations: verify_completion_judge_invocations,
feedback_history: feedback_history,
}
}
verify_attempts = verify_attempts + 1
verify_completion_judge_vetoes += __completion_judge_hit(terminal_verdict)
agent_emit_event(
harness.agent,
session.session_id,
"reserved_terminal_verify",
{
reserveKind: reserve_kind,
phase: "verify_failed",
iteration: state.iteration,
terminal_status: final_status,
},
)
if __agent_loop_grant_allowed(call_budget, "verification")
&& verify_attempts < state.max_verify_attempts {
call_budget = __agent_loop_record_grant(call_budget, "verification")
agent_emit_event(
harness.agent,
session.session_id,
"reserved_terminal_verify",
{
reserveKind: reserve_kind,
phase: "grant",
reserve_remaining: __agent_loop_grants_remaining(call_budget, "verification"),
iteration: state.iteration,
prior_status: final_status,
},
)
current_max = current_max + 1
final_status = ""
stop_reason = nil
budget_exhausted_emitted = false
return state
+ {
action: "continue",
final_status: final_status,
stop_reason: stop_reason,
call_budget: call_budget,
verify_attempts: verify_attempts,
verify_completion_judge_invocations: verify_completion_judge_invocations,
verify_completion_judge_vetoes: verify_completion_judge_vetoes,
current_max: current_max,
budget_exhausted_emitted: budget_exhausted_emitted,
feedback_history: feedback_history,
}
}
stop_reason = reserve_kind + "_failed"
}
if final_status != "" && state.suspend_result == nil && terminal_error == nil
&& __agent_loop_grant_allowed(call_budget, "terminal_report")
&& __agent_loop_terminal_callback_continue_allowed(final_status, stop_reason) {
const terminal_outcome = agent_compute_terminal_callback(
harness.agent,
session,
opts,
{
iteration: state.iteration,
final_status: final_status,
stop_reason: stop_reason ?? "",
max_iterations: current_max,
iteration_budget: state.budget,
session_successful_tools: state.successful_tools_seen,
session_rejected_tools: state.rejected_tools_seen,
},
)
if terminal_outcome.kind == "continue" {
call_budget = __agent_loop_record_grant(call_budget, "terminal_report")
opts = __apply_post_turn_options(opts, terminal_outcome)
const old_limit = current_max
const extra = __agent_loop_terminal_callback_extend_by(state.budget)
current_max = max(current_max, state.iteration + extra)
budget_decisions = __agent_loop_record_terminal_callback_continue(
budget_decisions,
state.iteration,
old_limit,
current_max,
)
agent_emit_event(
harness.agent,
session.session_id,
"loop_control_decision",
{
iteration: state.iteration,
action: "extend",
old_limit: old_limit,
new_limit: current_max,
reason: "terminal_callback_continue",
status: "",
},
)
final_status = ""
stop_reason = nil
budget_exhausted_emitted = false
return state
+ {
action: "continue",
opts: opts,
final_status: final_status,
stop_reason: stop_reason,
current_max: current_max,
budget_decisions: budget_decisions,
budget_exhausted_emitted: budget_exhausted_emitted,
}
}
}
// The run is about to seal its outcome. This is the last point at which the
// loop still holds the evidence, and the only owner of the abandoned/finished
// distinction on the ordinary path.
let abandonment = nil
// Present on every run that declared a contract, convicted or not. A verdict
// reported only on conviction cannot be told apart from a contract that was
// never checked, and that is the exact shape in which a silently-skipped gate
// reads as a satisfied one.
let declared_artifacts = nil
if __agent_loop_terminal_seals_natural(
final_status,
stop_reason,
terminal_error,
state.suspend_result,
) {
const attempted = state?.tool_calls_attempted ?? 0
const succeeded = state?.tool_calls_succeeded ?? 0
// The filesystem read happens once, here, and only for a run that is about
// to seal a natural terminal. A caller who declared nothing pays nothing:
// the audit's declared list is empty and it touches no path.
const artifacts = agent_declared_artifacts_audit(harness.fs, opts)
if len(artifacts.declared) > 0 {
declared_artifacts = artifacts
}
const cause = __agent_loop_abandonment_cause(
opts,
state?.successful_tools_seen ?? [],
attempted,
succeeded,
artifacts,
)
if cause != nil {
// COMPOSE, never overwrite. The typed kind has to change or a host
// cannot tell the two runs apart, and `completion_unverified` is the
// status the classifier already maps to that kind. But the reason the
// run gave for stopping is the only record of what it thought it was
// doing, so it is kept verbatim and the abandonment evidence is carried
// beside it rather than written over it.
abandonment = {
cause: cause,
natural_stop_reason: to_string(stop_reason ?? ""),
attempted_tool_calls: attempted,
succeeded_tool_calls: succeeded,
last_rejection_category: state?.last_tool_rejection_category,
}
final_status = "completion_unverified"
// The loop's existing decision channel, on its typed payload. It names
// the decision; the counts that justified it ride on the result record,
// which is the surface a host reads. `old_limit`/`new_limit` are equal
// because this decision changes the outcome, never the budget.
agent_emit_event(
harness.agent,
session.session_id,
"loop_control_decision",
{
iteration: state.iteration,
action: "abandon",
old_limit: current_max,
new_limit: current_max,
reason: cause,
status: final_status,
},
)
}
}
return state
+ {
action: "break",
opts: opts,
final_status: final_status,
stop_reason: stop_reason,
abandonment: abandonment,
declared_artifacts: declared_artifacts,
terminal_write_unverified: terminal_write_unverified,
call_budget: call_budget,
verify_attempts: verify_attempts,
verify_completion_judge_invocations: verify_completion_judge_invocations,
verify_completion_judge_vetoes: verify_completion_judge_vetoes,
current_max: current_max,
budget_exhausted_emitted: budget_exhausted_emitted,
budget_decisions: budget_decisions,
terminal_error: terminal_error,
feedback_history: feedback_history,
}
}