Dispatching agents¶
An agent is a class. It carries its own command line, its own prompt, the model it wants, its policies and its timeout, and a node body awaits it:
from athanore import ACPAgent, NonRetryable, Workflow
from pydantic import BaseModel
wf = Workflow("review")
class Verdict(BaseModel):
ship: bool
why: str
class Reviewer(ACPAgent):
command = ["npx", "-y", "pi-acp@0.0.33"]
system_prompt = "You review one branch of one repository. ..."
model = "anthropic/claude-sonnet-4"
output_model = Verdict
@wf.node(start=True, timeout=1800)
async def review(merge, rework):
result = await Reviewer(cwd="/srv/checkout").run(
"Review the branch named in the work log."
)
if not result.ok:
raise NonRetryable(result.error)
verdict: Verdict = result.output
return merge if verdict.ship else rework
@wf.node()
async def merge():
...
@wf.node()
async def rework():
...
Everything a subclass can set, on Agent and on ACPAgent, and the
fields of the result a run returns, are in
Agents.
The engine never sees the agent¶
A node body awaits an agent the way it would await anything else. The engine treats the body as an opaque coroutine; it does not know agents exist. That keeps the failure story simple. An agent that goes wrong is an exception or a result your body inspects, and the engine's retry policy applies to the attempt, not to the agent.
Prompts are inlined text¶
system_prompt is a Python string on the class. There is no template
language and no prompt directory. What the agent receives is that
string, then your run() argument as the assignment, then a task block
the façade adds: which task this is, which node of which workflow, which
run, and how to reach its own task.
Sections with nothing to say are left out, so an agent with no
system_prompt opens on its assignment.
Structured submissions¶
output_model is the contract. Declare a pydantic model and the agent is
told to submit an object of that shape. The API validates what arrives
and refuses anything else with the errors and the schema, which the agent
can read and fix inside the same turn. Only valid payloads are stored,
the latest valid one wins, and the history is kept.
result.output is therefore an instance of your model, or your body
never got there. Without an output_model, whatever JSON was submitted
is stored as it arrived.
A submission is a value. An agent cannot transition the task, choose a node, or reopen a run. It answers; your body routes.
How an agent reaches its task¶
An agent needs four things: to read its task, append to the work log, submit a result, and, if you turned it on, ask you a question. Those are one set of HTTP endpoints, reached through whichever of three adapters the agent can use:
- MCP, when the agent advertises an HTTP MCP client. The façade hands
it an in-process MCP server, and
submit_result's input schema is youroutput_model's schema, so the model sees it as a tool definition and a rejection comes back as a tool result. - Native, when the harness has its own tool registry and an adapter for it is installed. The adapter reads the task URL and token from the environment the façade exports.
- HTTP, for everything else. The prompt carries the request lines, with the token in a header.
- None, when the agent has no business with its task at all. Set
tooling="none"on the class.
tooling="auto" picks MCP when it is advertised and HTTP otherwise. In
the first two the token never appears in prompt text, and so never
reaches the model provider.
Some agents have no use for any of that. A chat turn, a summariser, a
classifier — an agent whose whole job is to talk — gets the system
prompt and the assignment and nothing else on tooling="none": no tool
listing, no request lines, no server on the session, no task token in
its environment. What it says comes back as result.text, and
result.output is None. A class that sets tooling="none" and an
output_model is refused with AgentError the moment it is opened,
since it asked for a value from an agent it gave no way to deliver one.
auto never picks this tier; you declare it. An agent handed a tool
listing tends to use it — reading its task, appending to the log,
submitting a result nobody asked for — and the way to stop that is not a
sterner system prompt but giving it nothing to call.
Results and exceptions¶
run() returns a result rather than raising for outcomes your body might
reasonably route around. A refusal, a cancellation or a truncated turn
come back as a failed result with error set, so a body can decide;
result.ok is the short form of asking. The common shape is the one
above: convert it to NonRetryable if you know a retry is pointless, or
return a different edge if you would rather route around it.
Timeouts, transport failures and a missing or invalid submission raise
AgentError. Both paths record a statistics entry.
Continuing a session¶
A run is one conversation, and the agent forgets it when its process
stops. The next run remembers it if you hand it the last result's
session_id:
first = await Reviewer(cwd=checkout).run("Review the branch.")
again = await Reviewer(cwd=checkout, session_id=first.session_id).run(
"Now look at the tests you skipped."
)
assert again.session_id == first.session_id
Use the same cwd; a session belongs to the directory it was opened in.
The agent re-opens the session by whichever of the two methods it
supports — a resume, or a load that replays the history first — and both
pi and Claude Code persist sessions, so either works with the adapters
in examples/.
The earlier turns are not written into the new attempt's transcript
again. It opens with a continuing session line and then carries only
what this run said and did, and its statistics are this run's turn, not
the whole conversation's. The earlier attempt already has its own.
An agent that cannot re-open the session — it does not support it, the
id is unknown, the cwd moved — raises AgentError rather than
starting a fresh conversation. A reply from an agent that quietly forgot
everything would look exactly like success, and that is the one outcome
this argument exists to rule out.
Holding a session open¶
A run is one process for one prompt. That is the right shape for a
pipeline stage, and the wrong one for a body that talks to the same
agent turn after turn: every message would spawn the adapter, open the
session and stop it again, a few seconds and a cold process per reply.
open() holds the session for a block instead:
@wf.node(pool="talk")
async def talk(*, checkout: str) -> str:
async with ChatAgent(cwd=checkout).open() as agent:
while True:
said = await human_input("Say something, or `stop`.")
if said == "stop":
return said
reply = await agent.prompt(said)
The block is the conversation. The process lives for it and is stopped
at its end on every path — a return, an exception, a cancellation — and
there is no close() to forget. Each prompt() takes an assignment and
returns a result exactly as run() does, and records a [stats] line
of its own, so the work log reads as one line under each reply. run()
is this with one prompt.
A timeout or a transport failure ends the session: the child is stopped
at once and every later prompt() raises AgentError ("the session is
closed"), so a body that wants to carry on opens a new block rather
than being handed a new process behind its back. A refusal does not end
it; the process is fine, and the next prompt is answered.
If the conversation is long, write agent.session_id to the log. An
attempt that crashes re-executes from its first line, and the id is what
lets it hand the conversation back as session_id= instead of starting
a fresh one. The pool counts agents answering, not agents alive, so a
chat parked on a person holds no slot while it waits.
Permissions and elicitations¶
When an agent asks to run a tool, permission_policy decides what
happens. The default, ask, opens a request and shows it to you with the
agent's own options, and the agent blocks until you answer.
auto_allow and auto_deny choose by kind rather than by position, and
permission_timeout with permission_timeout_action bounds the wait.
Form-mode elicitations become a request with the agent's schema, and the answer is validated against it before it goes back. URL-mode elicitations are declined.
These waits do not release the worker slot: the agent process is alive
and holding resources. Only a node's own human_input does that.
ask_policy="http" is off by default and lets an agent ask you a
question directly. Leave it off for anything unattended. A chatty model
can stall a pipeline that had nobody watching it.
Statistics¶
One entry is recorded per run — per prompt, on a held session — on every
exit path, as a [stats] line in the work log and an event on the
stream: the node and attempt, the
outcome, the model the provider reported, input and output tokens, tool
calls, cost, duration, session id, and, when they happened, repair
turns and denied permissions.
Fields that cannot be determined are left out. Nothing is estimated and nothing is zero-filled. An absent token count means the provider did not report one, which is a different fact from zero.
On a continued run the token counts and the cost are the run's own turn
as the agent reports them, never the whole session's: a provider reads a
whole session file, and attributing all of it to the tenth turn would be
an estimate. The session id is the same on every run of one
conversation, which is how you read its cost across the [stats] lines.
Testing without a model¶
The package ships doubles so that a workflow's tests never call a model. Point the node at one the way you would inject anything else:
from athanore import Workflow
from athanore.testing import MockAgent
wf = Workflow("review")
REVIEWER = None # the agent class a test replaces
def reviewer():
return REVIEWER() if REVIEWER else Reviewer()
@wf.node(start=True)
async def review(merge, rework):
result = await reviewer().run("Review the branch.")
return merge if result.output["ship"] else rework
from athanore.testing import MockAgent
import myproject.flows as flows
def use_a_double():
flows.REVIEWER = lambda: MockAgent(output={"ship": True, "why": "clean"})
MockAgent runs no subprocess. output= is the shortest double;
submit= makes the real round trip to the submission endpoint with the
real task token, so a rejection is a real rejection; log= and
stream= write to the work log and the transcript; fail= raises the
AgentError a body has to route around. Each takes a value or a
zero-argument callable, so an agent run twice in one body can be
scripted by closing over a counter. StatsMockAgent records a
statistics entry the way the real façade does.
For the tests that are about the wire, scenario() scripts
fake_acp.py, a real subprocess speaking the protocol over stdio, and
returns the command an ACPAgent runs, down to text chunks, tool
calls, permission requests, elicitations and usage. Setting
agent_command (the ATHANORE_AGENT_COMMAND environment variable)
replaces the command on every agent at spawn, which is how a whole
workflow runs end to end with no model in the loop.
Vendor adapters¶
Nothing in the package depends on pi, on Claude Code or on Docker. An adapter is a subclass in your own project:
from athanore import ACPAgent
class ClaudeAgent(ACPAgent):
command = ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.5.0"]
model = "claude-sonnet-4"
class SandboxedAgent(ACPAgent):
"""The same agent, in a container that is the guardrail."""
command = ["./scripts/agent.sh", "pi"]
permission_policy = "auto_allow"
Pin the adapter version rather than floating it. An agent command is code you are choosing to run.
Next¶
- Asking a human: the request object these policies open.
- Agents: every attribute and every field, generated from the code.