Agents and tools
The decorator, the loop, and what the runtime does between them.
Tools are plain async functions. The runtime reads their signature, offers them to the model, and calls them when the model asks.
from agentino import tool
@tool
async def search_leads(status: str, limit: int = 20) -> str:
"""Find leads by pipeline status.
Args:
status: One of new, contacted, qualified, lost.
limit: How many to return.
"""
rows = await db.leads(status=status, limit=limit)
return "\n".join(f"{r.id} {r.name} {r.value}" for r in rows)
Return a string
Tools return text, because text is what the model consumes. Returning a dict or a dataclass means something has to serialise it anyway, and the way it gets serialised changes how well the model reads it — better to control that yourself.
For anything the user should see as a component rather than the model as prose, return a fenced block. Runspace renders those; see MCP UI blocks.
Failure
Raise. The runtime catches it, hands the model an error result, and lets it decide what to do — usually apologise or try a different call. A tool that raises does not end the turn.
What you should not do is return a string that describes a failure in a way
the model might read as success. "No results" is fine; "error: 0 rows"
invites the model to report an error to the user that never happened.
Long output
Tool results are truncated before they reach the model, because a 40,000-row
dump costs the same as a summary and reads worse. Aggregate in the tool. If
the answer genuinely needs many rows, return a datatable block — the user
sees all of it, the model sees a manageable summary.
The built-in tools, and one to be careful with
BUILTIN_TOOLS is the ten a coding-style agent gets by default: read_file,
write_file, edit_file, list_files, search_files, grep, shell,
web_search, web_fetch and stage_verdict. The rest of
agentino.tools.std — document generation, weather, agent memory — is opt-in.
shell runs the command it is given. That is what it is for, and it is
the right tool for a coding agent working in a checkout you control. It is the
wrong tool for an agent that reads input you do not control.
It carries a small blocklist for shapes that are almost always accidents —
rm -rf /, mkfs, dd to a device, a fork bomb. That is a tripwire, not a
sandbox: rm -rf /*, find / -delete and curl … | sh all pass through, and
any blocklist over a shell language always will.
The boundary is whether an agent has the tool:
agents:
support: # reads messages from strangers
tools: [search_knowledge, list_files] # no shell
coder: # works in a checkout you own
tools: [read_file, write_file, grep, shell]
If an agent genuinely needs it under conditions, a PreToolUse hook can
inspect the command before it runs, and a gate can require a prior step.
Neither is the same as not giving it the tool.
Context
A tool can read who it is running for:
from agentino.core.context import get_context
@tool
async def my_open_jobs() -> str:
"""Jobs assigned to the person asking."""
ctx = get_context()
return await db.jobs(assignee=ctx.sender_id, tenant=ctx.tenant_id)
The context is set by the framework at the entry point of each turn, so a tool never has to take a tenant argument the model could get wrong.