Agent (mode: autonomous) — Internals of the Bounded Tool-Loop¶
kind: Agent with mode: autonomous is tuvl's bounded ReAct step: an LLM that chooses among author-declared tools, observes results, and re-decides — inside a loop the workflow contract caps on every axis. This document explains how the mode works internally, end to end. For the authoring reference (the YAML contract, field by field), see tuvl-agentic-manual.md §4.4 and §4.13.
Both agent modes share one runtime: src/tuvl/core/engine/agent_core.py (AgentStepRunner) — one runner class, two execution strategies, selected by the required step-level mode: discriminator. Message assembly, call-parameter resolution (resolve_call_params), retry, guardrail gating, and the outcome routing contract (OutcomeContract) are shared; this document covers the autonomous strategy.
Table of Contents¶
- Mental Model
- The Turn Lifecycle
- How Tools Work
- Steering and Skills
- Exits and Routing
- Guardrail Gates
- Hook Events
- Live Progress Streaming
- Supervision Hooks
- Validation
- Observability
- Reading the Code
1. Mental Model¶
An autonomous-mode Agent step is autonomous only inside a closed contract. The model decides which tool to call, with what arguments, and when to stop — nothing else. Everything around that decision loop is declared in YAML and enforced by the engine:
| Axis | Bound | Enforced by |
|---|---|---|
| Actions | agent.tools[] — a closed set of refs to other steps in the same workflow (REQUIRED in this mode) |
build_tool_specs + the dispatch callback |
| Exits | agent.outcome.enum — a closed set, plus five reserved abnormal exits |
OutcomeContract.finalize_autonomous + routes: validation |
| Iterations | agent.max_iterations (default 8) |
the loop itself |
| Tokens | agent.token_budget (optional, cumulative) |
checked at the top of every turn |
| Wall clock per tool | TUVL_AGENT_TOOL_TIMEOUT_S (default 300s) |
asyncio.wait_for around each dispatch |
| Content | agent.guardrails — input / output / tools gates (§6) |
run_guardrails inside the shared core |
| Context writes | only outcome.write; tools need writes_context: true to write |
_dispatch_tool merge policy |
Compare mode: completion (a single retried call, no tools): the difference is the loop, and the loop is why every bound above exists. The step never mutates shared context mid-run except through explicitly opted-in tools; its one guaranteed write is the final outcome.write key (default <step_id>_result).
Note: the persistent instruction field is steering — inline text or an artifact:// reference. system / prompt are completion-mode fields and are rejected by tuvl validate on an autonomous step; mode: itself is required, with no default.
2. The Turn Lifecycle¶
The loop lives in src/tuvl/core/engine/agent_core.py (AgentStepRunner._run_autonomous). Each pass through the for iteration in range(1, max_iterations + 1) loop is one turn:
┌─────────────────────────────────────────────┐
│ control checkpoint (pause / abort / steer) │ ← cooperative,
├─────────────────────────────────────────────┤ turn boundary only
│ token budget check (tokens_used >= budget?) │──── budget_exceeded ──▶ exit
├─────────────────────────────────────────────┤
│ LLM call (transport-retried per agent.retry)│──── LLM exception ───▶ error exit
├─────────────────────────────────────────────┤
┌──────▶│ tool_calls in the response? │
│ └──────────────┬───────────────┬──────────────┘
│ yes │ │ no
│ ▼ ▼
│ ┌────────────────────────┐ ┌────────────────────────────┐
│ │ dispatch each tool │ │ output guardrail gate │
│ │ SEQUENTIALLY, in order │ │ finalize_autonomous: │
│ │ (tools gate on each │ │ parse {"outcome","result"} │
│ │ observation; abort │ │ write outcome.write │
│ │ checked between calls;│ │ return outcome signal │
│ │ timeout fatal) │ └────────────────────────────┘
│ └───────────┬────────────┘
│ │
└── next iteration ◀┘ loop exhausted ──▶ max_iterations exit
Details that matter:
- Message assembly (
_assemble_autonomous_messages): the system prompt is built from the steering block (inline text or a resolvedsteeringartifact, always injected), the skills block (each entry inline or a resolvedskillartifact, injected as "apply when relevant"), a fixed autonomous-agent instruction, and — whenoutcome.enumis declared — a strict final-answer format:{"outcome": <enum value>, "result": <payload>}, no markdown fences. The first user message is the public context (keys not starting with_) serialized as a JSON block, or"Begin."when the context is empty;context_injectionkeys are appended as a delimited untrusted-content message. - Input gate: before anything is sent to the model, the assembled user text passes the
inputguardrail gate (§6). - Transport retry (
_acompletion_with_transport_retry): each LLM turn is retried on transport failures peragent.retry— onlytimeout/errorinretry.ontrigger a retried call (with exponential backoff); parse-level issues are handled by the loop itself. Exhausted attempts raise the last exception, which becomes theerrorexit. - Token accounting:
usage.total_tokensfrom each response is accumulated intotokens_used. The budget check runs at the top of every turn, so it also bounds the final no-tool answer turn, not only tool-calling turns. - Tool calls are sequential, not concurrent. All tools in a turn share the request's single SQLAlchemy
AsyncSession(through a shallow context copy); concurrent statements on oneAsyncSessionraiseIllegalStateChangeErrorand can poison the request-final commit. Sequential dispatch also keeps eachrole: "tool"message aligned with itstool_call_id, and lets an abort directive land between calls so a multi-tool turn cannot outlive it. - Tool failure is not loop failure. A tool that raises is captured as
{"error": ...}with signalerrorand fed back to the model, which may retry or route around it. Two fatal cases:ToolTimeoutError(the cancelled coroutine may have interrupted a statement on the shared session, so the run ends on theerrorexit rather than continuing on a possibly-poisoned connection) and aGuardrailViolationErrorfrom thetoolsgate (routed asguardrail_violation). - Tool results are truncated to 8000 characters (
_MAX_TOOL_RESULT_CHARS), then passed through thetoolsguardrail gate, before being appended as the tool message. - Termination: a response with no tool calls means the agent is done — the content passes the
outputguardrail gate, thenOutcomeContract.finalize_autonomousstrips code fences, parses the JSON, writes theresultpayload (or the whole parsed object, or the raw text) tooutcome.write(default<stepId>_result), and returns the outcome signal. Exhausting the loop returnsmax_iterations.
3. How Tools Work¶
A tool is another declared step in the same workflow. There is no separate tool registry: agent.tools[].ref names a step id, and the engine reuses the existing step runners to execute it.
3.1 Schema derivation¶
src/tuvl/core/engine/agent_tools.py (build_tool_specs) converts each tool entry into an OpenAI/Anthropic function-calling spec, forwarded by LiteLLM as tools=:
- name — the
refitself. - description — sourced from the referenced step's top-level
description:when set, otherwise the tool entry'sdescription:, otherwise the generic fallback"Invoke the '<ref>' component.". Validation errors when neither is set (§10). - parameters — the tool entry's
parameters(a JSON Schema object) when declared. When omitted,_derive_parametersbuilds a best-effort schema offline from what the component already declares:MCP→ the keys of itsmcp.argumentsmap (as strings);APICall→ the{{ var }}placeholders found in itshttpblock;ModelOp→ a free-formpayloadobject. Anything else falls back to a permissive empty object. Declare explicitparametersfor a precise contract; derivation is the convenience path.
3.2 Dispatch¶
_run_autonomous_agent_step in src/tuvl/core/engine/runner.py resolves every ref against the workflow's step index up front (an unknown ref raises before the loop starts), then wires a dispatch callback into the runner. When the model calls a tool, _dispatch_tool:
- Makes a shallow copy of the live context and merges the LLM-generated arguments into it, so the component's
{{ }}templates resolve from them. The copy shares the request's_session/_dbhandles — ModelOp IAM scope checks, masking, and OTel spans all apply exactly as on the spine. - Dispatches by the component's
kindthrough the matching runner:APICall,MCP,ModelOp,Router, orFunctional(the default). Any other kind returns an error result. - Ignores the tool step's own
routes:— the returned signal goes back to the agent (and onto thetool_callspan/progress event), it never routes the workflow. - Computes the delta: public context keys the tool changed. The delta is what the model sees as the tool result (falling back to the component's declared output key when there is no delta). Only when the tool entry set
writes_context: true(defaultfalse) is the delta merged back into the real workflow context.
So the context policy is: the agent reads the full public context once (turn 1), tool results flow back to the model by default, and shared context changes only through writes_context: true tools and the final outcome.write write.
3.3 Timeout¶
Every dispatch runs under asyncio.wait_for with TUVL_AGENT_TOOL_TIMEOUT_S (default 300s, settings.tuvl_agent_tool_timeout_s). Without it a hung tool would make the run unkillable, because control directives land only between calls. A timeout is fatal for the run (§2).
4. Steering and Skills¶
Two instruction channels, one injection point (_assemble_autonomous_messages):
| Channel | YAML key | Injection | Framing in the system prompt |
|---|---|---|---|
| Steering | agent.steering |
always | "persistent operating context — always follow it" |
| Skills | agent.skills[] |
always present, but framed as conditional | "apply the following skills/instructions when relevant" |
Steering is the contract; skills are capabilities the model applies when the task calls for them.
Both fields take inline text or artifact:// references — resolved through the artifact registry (resolve_prose) with site type-compatibility enforced: steering accepts steering and prompt artifacts; each skills[] entry accepts skill artifacts. The old per-agent directory scheme (agent.steering_files, agents/<workflow>__<stepId>/{steering,skills}/, the .agents/skills/ library) is gone — artifacts are the single mechanism, shared and versioned project-wide (see tuvl-agentic-manual.md §2.11). An unresolvable ref fails startup in production and produces a precise runtime error in dev; in dev mode, edits to a file-source .md artifact apply on the next run without a restart (mtime refresh).
Supervisor steer messages (§9) arrive through a different channel — appended system messages at the turn boundary — and are explicitly templated as guidance that "does not change your tools, your outcome contract, or the task itself."
5. Exits and Routing¶
Every run ends on exactly one signal, drawn from a closed set:
| Signal | Source | Meaning |
|---|---|---|
one of outcome.enum |
finalize_autonomous |
normal completion; the model chose this exit |
default |
finalize_autonomous |
normal completion when no outcome.enum is declared |
max_iterations |
loop exhaustion | the cap was reached without a final answer |
budget_exceeded |
top-of-turn check | cumulative total_tokens reached token_budget |
error |
several paths | LLM call failed, a tool timed out, or the model returned an outcome not in the enum |
aborted |
control channel | a supervisor rule/judge or the operator API aborted the run, or a pause outlived TUVL_AGENT_PAUSE_MAX_S |
guardrail_violation |
any guardrail gate | a declared check failed on input, a tool observation, or the final output (§6) |
max_iterations, budget_exceeded, error, aborted, and guardrail_violation are the reserved exits (RESERVED_EXITS in src/tuvl/core/engine/agent_core.py); they are emitted by the engine, so do not reuse them as outcome.enum values. Every abnormal exit also writes _last_error and _last_error_type into context so the fallback step can report what happened.
Routing follows the engine's normal _advance rules, which for this step mean:
- Every
outcome.enumvalue must be mapped inroutes:—tuvl validateerrors otherwise, and an unmapped non-default signal raisesRuntimeErrorat runtime. - An unmapped
errororabortedstops the workflow cleanly with a warning (an abort is an out-of-band stop, not an authoring bug). Map them anyway when you want a routed fallback. - An unmapped
max_iterations,budget_exceeded, orguardrail_violationraises at runtime; validation warns when they are missing. - A model answer whose
outcomeis not in the enum routes througherror, never through an invented signal — the exit set stays closed even against a misbehaving model.
routes:
resolved: format_reply
escalate: notify_manager
max_iterations: fallback_summary
budget_exceeded: fallback_summary
error: alert_ops
aborted: alert_ops
guardrail_violation: alert_ops
6. Guardrail Gates¶
Guardrails (type: guardrail artifacts — see tuvl-agentic-manual.md §4.15) attach to the step as agent.guardrails: {input: […], output: […], tools: […]}. The gates run inside the shared agent core (AgentStepRunner._gate → run_guardrails), not in the engine's step dispatch — so every execution surface (engine, streaming, Spectrum, test runner) is correct by construction:
input— on the assembled user text, before anything is sent to the model.tools— on every serialized tool observation (after the 8000-char truncation), before it reaches the model. Autonomous-mode only.output— on the model's final no-tool content, beforefinalize_autonomousmerges anything into context.
A failing check raises GuardrailViolationError inside the core and exits the run on the reserved guardrail_violation signal, with _last_error naming the artifact and check. pii_mask is the exception — a transform that masks declared secure fields and never fails; the (possibly transformed) content continues through the gate. llm_judge is the only check that costs an LLM call and is fail-open on judge outage unless on_judge_error: violation.
7. Hook Events¶
Observe-only hooks (type: hook artifacts — see tuvl-agentic-manual.md §4.16) fire around the loop when attached per step (hooks: […]) or workflow-wide (spec.hooks:):
before_step/after_step/on_errorfire in the engine's single per-kind dispatch (WorkflowEngine._run_kind), like every other kind.before_tool/after_toolfire inside_invoke_tool, around each tool dispatch, with{tool}/{tool, signal}detail.
Hooks never mutate context and never affect flow control; a failing hook is logged and swallowed. action: metric increments the tuvl.hook.events counter; action: notify runs a declared APICall step on a private payload copy (WorkflowEngine._notify_hook).
8. Live Progress Streaming¶
On the streaming execution path, _stream_autonomous_agent in src/tuvl/core/engine/runner.py runs the step as a background task and bridges its progress events through an asyncio.Queue. The runner emits three event types via its progress sink:
| Event | Payload |
|---|---|
iteration |
iteration, tool_calls (count requested this turn), tokens_used |
tool_call |
iteration, tool (name), signal |
outcome |
iteration, signal |
Each one is wrapped in a StepEvent with signal="running" and the payload under snapshot.agent_progress, then serialized to the wire by src/tuvl/core/engine/streaming.py (SSE, gRPC, and the CLI stream watcher share the same serializers). The snapshot carries loop metadata only — no context values, no tool arguments, no results — so it adds no PII surface beyond the masked final frame.
Two hardening properties: a broken sink never breaks the loop (emit failures are swallowed), and a client disconnect (GeneratorExit on the generator) cancels the background agent task instead of leaving it running against a tearing-down session and burning tokens.
On non-streaming paths no sink is wired and the loop runs identically without emitting. Everything else about step execution — including hooks — funnels through the one per-kind dispatch (WorkflowEngine._run_kind) shared by engine run/streaming, Spectrum, and the test runner; there are no per-surface dispatch forks to keep in sync.
9. Supervision Hooks¶
Every autonomous-mode Agent run registers a RunHandle in the orchestrator registry (mirrored to Redis when available, so other workers can see and address it), and — when the workflow declares spec.supervisor — spawns a Supervisor watcher task for the run's lifetime. With no supervisor and no operator, the control channel is never touched and costs nothing.
Control is cooperative, applied through an AgentControl checkpoint at the top of every turn — never mid-LLM-call, never mid-tool:
- pause — the loop parks in
wait_if_paused. A paused run pins its request-scoped DB session/transaction, so the dwell is capped byTUVL_AGENT_PAUSE_MAX_S(default 300s) and escalates to an abort at the deadline. - steer — queued messages are drained and appended as templated system messages before the next LLM call. Steer text is free-form operator/judge input; the template frames it as guidance, not a new operating contract.
- abort — the run returns on the
abortedexit. The abort directive is also checked between tool calls within a turn, so a multi-tool turn cannot outlive it.
The supervisor itself — deterministic rules, the LLM judge, cadence, fail-open behavior, and the /api/agents/* operator API — is documented in docs/supervisor.md.
10. Validation¶
tuvl validate (src/tuvl/cli/commands/validate.py) checks the step's contract statically:
| Check | Severity |
|---|---|
mode missing or not completion / autonomous |
error |
agent.model missing |
error |
agent.model names an llms/<name>.yaml that does not exist |
warning |
removed contracts present (agent.output, agent.steering_files, outcome.output_key, outcome.signal_from) |
error (pointed message naming the replacement) |
completion-only fields (system / prompt) on an autonomous step |
error |
agent.steering missing |
warning |
no tools declared (mode: autonomous requires them) |
error |
tool entry missing ref, ref not a declared step, or ref is the agent itself |
error |
| tool has no description (neither on the referenced step nor on the tool entry) | error |
an outcome.enum value unmapped in routes: |
error |
steering / skills / guardrails artifact ref unknown, wrong type for the site, or pinned to a missing version |
error |
| a floating (unpinned) artifact ref | warning (tuvl ship --strict fails on it) |
guardrails gate not input / output / tools, or a non-artifact entry |
error |
tools guardrail gate on a completion step |
error |
guardrails attached but guardrail_violation unmapped in routes: |
warning (raises at runtime if hit) |
max_iterations / budget_exceeded unmapped in routes: |
warning (raises at runtime if hit) |
aborted unmapped in routes: |
warning (an abort stops the workflow without a routed exit) |
11. Observability¶
The loop is instrumented at both granularities; docs/observability.md is the full reference. The step's own span is node.Agent — autonomous agents appear under the same span name as completion agents, distinguished by their child spans.
- Spans —
agent.iteration(attributestuvl.agent.step_id,tuvl.agent.iteration,tuvl.agent.tokens_used,tuvl.agent.tool_calls) wraps each turn including its tool dispatches;agent.tool_call(tuvl.agent.tool,tuvl.agent.tool_signal) nests inside it, and the dispatched step's own spans (LiteLLMgen_ai.*, HTTP, DB) nest inside that. - Counters — meter
tuvl.agent:tuvl.agent.iterations,tuvl.agent.tool_calls,tuvl.agent.aborts,tuvl.agent.budget_exceeded(plus the supervisor counters) — unchanged names. Hooks addtuvl.hook.events. Seedocs/observability.md§6 for attributes and export behavior. - Structured logs —
agent.turn,agent.tool_call, and the exit events (agent.max_iterations,agent.budget_exceeded,agent.llm_error,agent.tool_timeout,agent.aborted,agent.invalid_outcome,agent.guardrail_violation), all carryingstep_idand iteration fields, correlated to spans viatrace_id/span_id.
12. Reading the Code¶
| Module | What lives there |
|---|---|
src/tuvl/core/engine/agent_core.py |
AgentStepRunner — both strategies: message assembly, budget check, control checkpoint, transport retry, tool invocation, guardrail gates, OutcomeContract, RESERVED_EXITS |
src/tuvl/core/engine/agent_tools.py |
build_tool_specs / _derive_parameters — declared tools → provider function-calling schemas |
src/tuvl/core/engine/guardrails.py |
run_guardrails, the closed check set, GuardrailViolationError |
src/tuvl/core/engine/hooks.py |
emit_hooks, the closed event/action sets, tuvl.hook.events |
src/tuvl/core/engine/runner.py |
_run_kind (the single per-kind dispatch), _run_agent_step (mode gate), _run_autonomous_agent_step (setup, run registration, supervisor spawn), _dispatch_tool (kind dispatch, context-delta policy), _stream_autonomous_agent (progress bridging), _notify_hook, _advance (signal routing) |
src/tuvl/core/artifacts/ |
the artifact registry, loaders, and artifact:// resolution used by steering / skills / guardrails |
src/tuvl/core/engine/streaming.py |
StepEvent wire serialization shared by SSE, gRPC, and the CLI |
src/tuvl/core/engine/orchestrator/ |
RunHandle / registry / AgentControl, Redis mirror, Supervisor, agent metrics |
src/tuvl/cli/commands/validate.py |
the static checks in §10 |
docs/tuvl-agentic-manual.md §4.4, §4.13–4.16 |
the authoring contract this document sits beneath |