Toolnexus.Agents.Loop (toolnexus v0.18.1)

Copy Markdown View Source

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

completion()

@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

guardrail()

@type guardrail() :: (map() -> String.t() | nil)

outcome()

@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.

verdict()

@type verdict() :: %{ok: boolean(), reason: String.t()}

Functions

all_todos_done(map)

@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.

guarded_hooks(guardrails, hooks)

@spec guarded_hooks([guardrail()] | nil, map() | nil) :: map() | nil

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(spec)

@spec harness(keyword() | map()) :: keyword() | map()

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.

new(agent, options, toolkit)

@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).

run(loop, prompt, opts \\ [])

@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.

run_gated(ask, prompt, completion, state)

@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.