Engines¶
An agent's engine implements its contract. The closed set is pending, code, tool,
decide, llm, loop and human. This page covers pending, code, tool, llm and human;
see decide and loop and tools for the other two.
Every engine except pending can emit error. Engine parameters live under a key named after the
engine (code:, tool:, llm:, …).
pending — a contract without an implementation¶
- id: classify_ticket
description: Decide which team owns the ticket
engine: pending
inputs: { ticket: Ticket }
outputs: { team: "enum[billing, technical, general]" }
routes: { billing: billing_queue, technical: kb_search, general: general_queue }
tuvl validatechecks it fully as a contract (data flow, types, routes). It is a V010 warning.- In dev and in tests it returns a typed stub: schema-valid placeholder outputs and its first
normal signal. Tests override it with
mocks. tuvl shiprefuses a project withpendingagents, and the production runner won't load one.
A pending agent is a typed placeholder for a step the spec names but nobody has built yet; tuvl spec verify reports it as unfinished and tests through it are stubbed, not passed.
code — a Python function¶
- id: issue_refund
description: Calculate the refund amount and record it
engine: code
effect: write # read | write | destructive; default write
inputs: { order: Order, reason: str }
outputs: { refund: Refund }
code: { run: issue_refund, with: { currency: EUR } }
routes: { default: reply, error: END.failed }
# agents/issue_refund.py — one @agent per file
from tuvl import agent, Ctx
from agents._generated.schemas import IssueRefundIn, IssueRefundOut
@agent("issue_refund")
async def issue_refund(inp: IssueRefundIn, ctx: Ctx) -> IssueRefundOut:
"""Calculate the refund amount and record it."""
amount = inp.order.total if inp.reason != "partial" else inp.order.total / 2
refund = await ctx.db.create("Refund", order_id=inp.order.id, amount=amount)
return IssueRefundOut(refund=refund)
| Signature | async def f(inp: <Id>In, ctx: Ctx) -> <Id>Out or tuple[<Id>Out, str]. tuvl codegen writes the decorator, signature and docstring; you write the body. |
| Signals | Returning Out emits default; (Out, "signal") emits that signal (it must be routed); an exception emits error (code_exception). |
ctx.db |
Unit of work restricted to spec.models; read-only when effect: read (a write raises). Writes commit with the step's checkpoint. |
ctx |
ctx.log (structlog, bound to the run and agent), ctx.run_id, ctx.principal, ctx.idempotency_key, ctx.http (shared HTTP client), ctx.artifact(ref), ctx.params (rendered code.with), ctx.agent_id |
effect |
Declared, because it can't be derived from Python. It drives policy (require_human_before) and MCP annotations. |
Built-ins. run: tuvl.data_search and run: tuvl.data_ingest are vector search and ingest with
typed contracts, configured by code.with (collection, query/document, top_k, filter,
metadata, scope: global | run). Search writes [{content, metadata, score}] to its single list
output; ingest writes the new document id to its optional single output. The tuvl. prefix is
reserved.
tool — database, HTTP and MCP operations¶
One engine, three use variants. The effect is derived; a declared effect may only strengthen
it (V015), except that an HTTP POST/PUT/PATCH may declare effect: read (search APIs often
read via POST).
use: db¶
tool: { use: db, model: Order, op: read, where: { id: "{{ order_id }}" } }
tool: { use: db, model: Refund, op: create, values: { order_id: "{{ order.id }}", amount: "{{ amount }}" } }
tool: { use: db, model: Ticket, op: list, where: { status: open }, order_by: [-created_at], limit: 20 }
op: create, read, update, delete, list, upsert. The model must be in spec.models
(V018). The result lands in the agent's single output — a row, or list[Model] for list; delete
has none. Untyped where values are coerced to the column type.
| op | Effect | Reserved signals |
|---|---|---|
read |
read | not_found |
list |
read | — |
create, update, upsert |
write | conflict |
delete |
destructive | not_found |
use: http¶
tool:
use: http
method: POST
url: https://payments.example.com/refunds
headers: { Authorization: "Bearer ${PAYMENTS_KEY}" }
body: { order: "{{ order.id }}", amount: "{{ amount }}" }
expect: { status: [200, 201] }
map: { refund_ref: response.body.id } # response.status | headers | body paths → outputs
idempotency: auto # auto | none — sends Idempotency-Key
GET/HEAD are read; other methods are write (override to destructive allowed). Without
map, a single output receives the body. An unexpected status emits http_<status> when that
signal is routed; otherwise error (rate_limit for 429 and transient for 5xx are retryable,
http_error otherwise). ${VAR} reads the environment.
use: mcp¶
tool: { use: mcp, server: artifact://zendesk@3, name: add_comment, args: { ticket: "{{ ticket.id }}", body: "{{ text }}" } }
The effect comes from the server's readOnlyHint/destructiveHint, else write. The tool's
structured content (or parsed text) fills a single output, or spreads an object across several.
The tool's input schema is pinned in tuvl.lock.
Side effects and crashes¶
Runs are at-least-once per agent (runtime). tool: db writes on the primary
datasource commit with the step's checkpoint (exactly-once under transaction: per_agent); HTTP
calls get a stable Idempotency-Key; MCP calls are at-least-once, so give write/destructive tools a
compensate agent (V015).
llm — one structured model call¶
- id: reply
description: Write the customer confirmation
engine: llm
inputs: { order: Order, refund: Refund }
outputs: { text: str }
budget: { timeout: 20s, max_tokens: 1500, retry: { attempts: 2, errors: [parse_error, timeout] } }
llm:
model: default # AgentModel name (type llm) or a LiteLLM model id
system: artifact://support-voice@3 # text or a prompt artifact
prompt: Write a short confirmation of the refund.
skills: [artifact://refund-policy@2]
guardrails: { output: [artifact://refund-reply-quality@2] }
routes: { default: END, guardrail_violation: END.failed, error: END.failed,
parse_error: END.failed, timeout: END.failed, budget_exceeded: END.failed }
- Every input is serialised into the prompt inside a
<data name="…" type="…">fence, and the system prompt tells the model that fenced text is data, never instructions. A value can't close its fence early (</data>inside it is escaped), so retrieved documents, user text or web content can't pass themselves off as part of the prompt.loopagents fence every tool result the same way (<data tool="…">). Text you interpolate intollm.promptwith{{ … }}is not fenced: pass untrusted values as inputs. - The model answers with structured output whose schema is the agent's
outputs. - With more than one normal route, an
outcome: enum[<those signals>]field is added and its value becomes the signal; a free-form model string can never become a signal. budget.max_tokensandbudget.timeoutare required (V011).- Guardrails:
input/outputlists ofguardrailorjudgeartifacts. A failing output judge emitsguardrail_violation; an unavailable or uncertain judge fails closed (error). - Secure model fields can't be inputs unless
policy.allow_secure_to_llm: true(V020).
human — a person fills a form¶
- id: approval
description: A manager approves or rejects a refund
engine: human
inputs: { order: Order, message: str }
outputs: { approved: bool, note: "str?" } # the form
human:
title: "Refund for order {{ order.id }}"
instruction: Approve this refund?
show: [order, message] # inputs shown to the reviewer
form: # optional; defaults from outputs
approved: { label: "Approve?", widget: toggle }
note: { label: Note, widget: textarea }
required_group: support_manager
assignee: "{{ order.owner_id }}"
expires: 48h
route_on: approved # bool → "true"/"false"; enum → its values
routes: { "true": issue_refund, "false": END.rejected, expired: END.failed, error: END.failed }
- The run checkpoints, opens an approval request and becomes
waiting_human. A synchronous trigger answers202with the run descriptor and the request. - Answer with
POST /api/approvals/{id}{ values }(orPOST /api/runs/{run_id}/human/{agent_id}). Values are validated againstoutputs; the run is re-queued and continues on any worker. - Who may answer: with
required_group, a holder ofapprovals:decidein that group who is not the triggering principal; otherwise the triggering principal oriam:admin.iam:adminbypasses the group, never the no-self-approval rule. expirescloses the request and takes theexpiredroute; a late answer gets409.formfields must equaloutputs;route_onmust be a bool or enum output (V016).
In tests, an unmocked human agent stops the run: assert it with expect.wait: human.