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.