Loop — a live execution of an Agent, and the completion gate that stops it
claiming done too early. A layer over the shipped §8 client: nothing here
changes existing behaviour.
The placement law this encodes:
agent spec (the harness) answers "MAY it?" — capability, ceilings. Per problem.
run opts answers "with WHAT?" — model for this call. Per call.
Loop answers "DID it?" — status, turns. Observed.
none of them answers "is it RIGHT?" — a tool, skill or agent.So a loop takes no options: it is read, not configured.
A loop is a plain struct rather than a process. It threads history and turn
counts through run/3's return value, which keeps it usable from anywhere —
including inside the runtime's own turn process, where a second GenServer would
just be a supervision problem.
Summary
Types
The gate that stops an agent claiming done before its work verifies
What a run reports. status reuses the SHIPPED vocabulary.
Functions
The built-in completion verifier. Reads the SHIPPED todowrite builtin's result
metadata and requires every item to be checked.
Compile guardrails into one :before_tool with FIRST-DENY-WINS, composed ahead
of any hook already set. No guardrails ⇒ hooks is returned untouched, so absent
is byte-identical.
harness/1 is a NAME, not a type.
Open a LIVE EXECUTION of an agent, over client OPTIONS (not a built client).
Run one request. Returns {outcome, loop} — the loop carries forward the turn
count and the transcript, so a caller may run again on the same conversation.
Wrap a client run with the completion gate. SHARED by the standalone loop and the §7D runtime turn, so a delegated child gets exactly the same guarantee as a directly-driven one.
Types
@type completion() :: %{ verify: (Toolnexus.Client.RunResult.t() -> verdict()), max_attempts: pos_integer() }
The gate that stops an agent claiming done before its work verifies:
:verify— judges the run; receives the tool calls ACCUMULATED across attempts:max_attempts— REQUIRED; an unbounded verify loop is a denial-of-service on the caller's own bill
@type outcome() :: %{ text: String.t(), status: String.t(), stopped_by: String.t() | nil, attempts: non_neg_integer(), turns: non_neg_integer(), result: Toolnexus.Client.RunResult.t() | nil }
What a run reports. status reuses the SHIPPED vocabulary.
Functions
@spec all_todos_done(Toolnexus.Client.RunResult.t()) :: verdict()
The built-in completion verifier. Reads the SHIPPED todowrite builtin's result
metadata and requires every item to be checked.
Structural, not domain: it counts unchecked boxes and never learns what a todo means, so the loop stays domain-blind. No plan declared ⇒ nothing to verify ⇒ pass, so the gate never punishes an agent that does not use the builtin.
Compile guardrails into one :before_tool with FIRST-DENY-WINS, composed ahead
of any hook already set. No guardrails ⇒ hooks is returned untouched, so absent
is byte-identical.
harness/1 is a NAME, not a type.
An agent spec already IS the harness — tools, soul, team, budget, model, policy,
ceilings — so this is the word landing in the API without a second concept to
learn. agent("x", harness(does: "...")) and agent("x", does: "...") are
indistinguishable.
@spec new(map(), keyword() | map(), term()) :: %Toolnexus.Agents.Loop{ agent: term(), history: term(), options: term(), status: term(), toolkit: term(), turns: term() }
Open a LIVE EXECUTION of an agent, over client OPTIONS (not a built client).
@spec run( %Toolnexus.Agents.Loop{ agent: term(), history: term(), options: term(), status: term(), toolkit: term(), turns: term() }, String.t(), keyword() ) :: {outcome(), %Toolnexus.Agents.Loop{ agent: term(), history: term(), options: term(), status: term(), toolkit: term(), turns: term() }}
Run one request. Returns {outcome, loop} — the loop carries forward the turn
count and the transcript, so a caller may run again on the same conversation.
@spec run_gated( (String.t(), term() -> {Toolnexus.Client.RunResult.t(), term()}), String.t(), completion() | nil, term() ) :: {Toolnexus.Client.RunResult.t(), term()}
Wrap a client run with the completion gate. SHARED by the standalone loop and the §7D runtime turn, so a delegated child gets exactly the same guarantee as a directly-driven one.
ask takes (prompt, state) and returns {result, state}, which is what lets
the same function serve a struct-threading loop and a process-held turn.
Rule 2 in force: a run that is pending (suspended on a human) or otherwise
non-done already carries its own reason, so the gate never re-judges it. That
keeps pending and incomplete distinct — the caller can always tell whether it
owes an Answer or a fix.