Agents¶
An Agent is a Python class. Its class attributes are its entire configuration. There is no
separate config object to build.
from runa import Agent
class SupportAgent(Agent):
name = "support_agent"
instructions = "You help customers troubleshoot their orders."
model = "claude-sonnet-5"
Attributes¶
| Attribute | Default | Meaning |
|---|---|---|
name |
required | The agent's identity. Traces, handoffs, and runa chat all key off it. |
instructions |
None |
A system prompt string, or a (context) -> str callable. |
model |
"gpt-5.4-nano" |
Which model to call. See Model Providers. |
model_settings |
ModelSettings() |
Temperature, max tokens, and other sampling options. |
tools |
[] |
@tool-decorated functions. See Tools. |
subagents |
[] |
Other agents to hand off or delegate to. See Subagents. |
guardrails |
[] |
Input/output checks. See Guardrails. |
mcp |
[] |
MCP servers whose tools this agent can call. See MCP Servers. |
memory |
None |
Long-term memory across conversations. See Memory. |
knowledge |
None |
Retrieval from application documents. See Knowledge. |
output_type |
None |
A dataclass, Pydantic model or TypedDict the final output is parsed into. |
max_turns |
10 |
How many model calls one run may make before it errors. |
max_tokens |
None |
Total tokens one run may spend. See Bounding a run. |
timeout |
None |
Wall-clock seconds one run may take. See Bounding a run. |
hooks |
None |
A Hooks instance scoped to this agent. See Tracing and Hooks. |
compact |
False |
Keep long-running history from growing without bound. See below. |
name is the only required attribute. Everything else has a sane default, in the spirit of
convention over configuration.
That table is also the complete list. Since class attributes are the whole configuration surface, the one mistake the style invites is a misspelled name, so Runa refuses one rather than ignoring it:
class SupportAgent(Agent):
name = "support_agent"
modell = "claude-sonnet-5" # UserError: ... Did you mean 'model'?
Without that, the agent would quietly run on the default model and surface days later as "it
answers oddly". The check only looks at data: methods, properties, _-prefixed attributes and
anything a mixin brings along are yours to use freely.
A constructor override (SupportAgent(model="claude-opus-5")) is covered the ordinary Python
way instead: every setting in the table is a keyword-only parameter of Agent.__init__, so
SupportAgent(tolls=[...]) is a TypeError naming what it meant, your editor completes the
settings as you type them, and a type checker catches max_turns="ten" before the code runs.
Passing a setting always wins over declaring it, and passing None means None -- that is how
SupportAgent(memory=None) turns off a declared memory = "auto" for one instance.
class SupportAgent(Agent):
name = "support_agent"
_escalation_threshold = 3 # not config, not checked
def escalate(self, severity: int) -> bool:
return severity >= self._escalation_threshold
Bounding a Run¶
Three independent ceilings, each optional, each ending the run as Run(status="error") rather
than raising:
class SupportAgent(Agent):
max_turns = 10 # model calls
max_tokens = 50_000 # total tokens this run may spend
timeout = 30.0 # wall-clock seconds
max_turns bounds how often a run calls the model. max_tokens bounds what those calls cost,
which a turn limit alone cannot: ten calls over a long conversation can cost far more than ten
over a short one. timeout bounds elapsed time, which neither of the others can, and is the only
one that helps when a tool or a provider hangs.
max_tokens here is the run's whole budget, and is a different knob from
ModelSettings(max_tokens=...), which caps the length of a single response:
class SupportAgent(Agent):
max_tokens = 50_000 # the run may spend this many, in total
model_settings = ModelSettings(max_tokens=512) # any one reply is at most this long
Cancelling a run propagates CancelledError rather than becoming an error result: a caller that
cancels (a dropped HTTP connection, a worker shutting down) wants the run to stop, not to be
handed a verdict.
One Agent, One Conversation at a Time¶
An Agent instance holds the conversation it is running, in self.history. Two overlapping runs
on one instance would read the same history and race to write it back, so Runa refuses instead of
losing one conversation into the other:
Give each run its own session, or its own Agent:
Sequential runs on one instance are the normal conversational loop and are unaffected. Under a
web server, build the agent inside the request handler; runa serve already
does. See Deployment.
Instructions as a Function¶
instructions can be a plain string, or a function of one argument, the context passed to
run/run_sync. It is resolved fresh on every call:
from dataclasses import dataclass
@dataclass
class Context:
user_name: str
def instructions(context: Context) -> str:
return f"You are a friendly assistant helping {context.user_name}."
class Assistant(Agent):
name = "assistant"
instructions = instructions
Assistant().run_sync("Hi", context=Context(user_name="Ada"))
context is never sent to the model. It is plumbed through to instructions, tools, and
guardrails as is, so it is the place to put per-run data (a user id, a tenant, a request-scoped
client) without reaching for global state.
Running an Agent¶
run_sync/run/run_streamed all append the turn to agent.history, so the next call on the
same instance continues the conversation. Pass session= instead to persist history to
runa.db. See Sessions and Chat.
run/run_sync return a Run:
run.output: the final output (anoutput_typeinstance, when set), orNoneunless completedrun.status:"completed","paused"(a tool call awaits approval) or"error"run.interruptions: the calls awaiting approval, whenstatus == "paused"run.error: the error message, whenstatus == "error"run.usage: this call's token usagerun.trace: the full span tree for this call. See Tracing and Hooks.run.input_guardrail_results(andoutput_,tool_input_,tool_output_): every guardrail that ran. See Guardrails.
A tripped guardrail or a runtime error (MaxTurnsExceeded, a model error) is caught and reported
as status="error" instead of being raised, and so is an exception out of code you supplied and
the run called -- a Hooks method, a custom Session, a compact function -- which arrives as
UnexpectedError's message on run.error, with its traceback in the log. agent.history is left
unchanged, since the turn never completed.
Only two things raise out of a run: asyncio.CancelledError, since a caller that cancels a run
wants it stopped rather than reported as failed, and an OperatorError (a mistyped
RUNA_DATABASE_URL, say), which is the operator's input and not the run's failure.
run_streamed instead yields StreamEvents as the model responds. It is the same run as run:
guardrails, hooks, tracing, memory, approvals and session= all apply. Once the stream ends,
stream.run holds the same Run that run would have returned, errors included, and
agent.history is updated:
stream = agent.run_streamed("My order hasn't arrived.")
async for event in stream:
...
print(stream.run.output)
Structured Output¶
Set output_type and run.output is an instance of it. The model is asked for the matching
JSON schema (on Claude, OpenAI and every chat-completions provider), and an answer that still
doesn't fit comes back as status="error":
@dataclass
class Ticket:
category: str
urgent: bool
class TriageAgent(Agent):
name = "triage_agent"
output_type = Ticket
run = TriageAgent().run_sync("My card was charged twice!")
run.output.urgent # True
Usage¶
agent.last_usage # tokens used by the most recent call
agent.usage # accumulated across every call on this instance
Both are populated whether or not the run succeeded, used a session, or passed hooks.
Keeping History From Growing¶
A long-running chat can push history/session past a model's context window. Set compact
to trim it automatically:
compact = True uses Runa's default compactor: once history passes a token threshold, it keeps
only the most recent exchange. It is a rolling window, not a summary. Pass your own
Compactor (any (items, usage_tokens) -> items | None callable) for a different strategy, such
as an LLM-written summary. compact = False, the default, turns this off.
Visualizing an Agent¶
agent.graph renders the agent, its tools, and its subagents as a Graphviz diagram: inline in
Jupyter, or .render(path) to save it, or .source for the raw DOT text. A delegate is drawn
with a dotted edge, a handoff with a dashed one. Rendering an actual image needs the system dot
binary.
Example¶
More in examples/01_agent/.