Toolnexus.Agents.Loop (toolnexus v0.17.0)

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.