Agents¶
An Agent runs a think/act loop against a model: ask for a response, dispatch any tool calls
it requests, repeat until the model answers with no tool calls or the step budget is spent.
from deepharness import Agent, Budget, Message, tool
from deepharness import OpenAI
@tool
def get_weather(city: str) -> str:
"""Look up the current weather for a city."""
return f"It is 22°C and sunny in {city}."
agent = Agent(
OpenAI(model="gpt-4o-mini", api_key="sk-..."),
system="You are a concise assistant.",
tools=[get_weather],
)
result = await agent.arun("Weather in Oslo?")
print(result.output)
tools= accepts any number of functions — decorated with @tool or plain — an
agent isn't limited to one. See the tools guide for how a function becomes a tool.
run() vs arun()¶
Same split as providers: arun() is the async path — tool calls requested in the same turn
run concurrently, sync tools included: those go to a thread so one blocking call cannot
stall the rest. run() is a real synchronous path rather than a wrapper, so it raises
if a registered tool turns out to be async def — there's no event loop here to await it:
result = agent.run("Weather in Oslo?") # sync, sync tools only
result = await agent.arun("Weather in Oslo?") # async, concurrent tool calls
State¶
arun/run return an AgentState — a dataclass, not a dict:
| Field | What it holds |
|---|---|
messages |
The transcript, as wire-form dicts. |
output |
The answer: text, or an output= instance when one is set. |
usage |
TokenUsage for this run. |
stop_reason |
Why the loop ended: "answer", "step_budget", "paused", "token_budget", "truncated". |
paused |
Any PendingHumanInput waiting on a human; empty otherwise. |
answered |
True only when stop_reason == "answer" — check this before trusting output. |
For input, pass whatever is convenient — a prompt string, a list of messages, or an
AgentState when you are resuming one:
await agent.arun("Weather in Oslo?")
await agent.arun([Message.human("Weather in Oslo?")])
await agent.arun(previous_state)
A dict of known fields still works, but a key the agent does not own raises
ConfigurationError rather than being dropped silently — an agent owns its own state, so keep
a graph's fields on the graph's state.
Streaming¶
astream() yields the model's prose as it arrives, and tools still run:
Every turn is streamed, not just the answering one — an agent cannot know in advance whether a turn will answer or call a tool, so the provider hands back the assembled turn either way and a tool turn simply yields no text.
When you need the result too, use astream_events(). It yields TextDelta as text arrives and
one final Finished carrying the AgentState, because an async generator cannot return a
value — and in between, what the loop is doing:
from deepharness import Finished, TextDelta
from deepharness.agent import StepStarted, ToolFinished, ToolStarted
async for event in agent.astream_events("What is 17 * 23?"):
match event:
case StepStarted(step):
print(f"\n— step {step}")
case TextDelta(text):
print(text, end="", flush=True)
case ToolStarted(name, arguments, _):
print(f"{name}({arguments}) …")
case ToolFinished(name, result, failed, _):
print(f"{name} {'failed' if failed else 'ok'}: {result}")
case Finished(state):
print(f"\nstopped because: {state.stop_reason}")
Most of a long run's wall clock goes on tool calls the model never narrates, so text alone
makes an agent look stalled. StepStarted.step is 1-based and capped by Budget.steps;
ToolFinished.result is the text the model will read — already truncated by the
context policy, so what you display is what the model saw. A tool that
raised sets failed, and the run still continues, since the error goes back to the model as
that call's result. A tool that asked a human reports no ToolFinished: it has no result yet,
and the run is about to pause on it.
stream()/stream_events() are the synchronous counterparts, and there tools run one at a
time, so each tool's pair of events surrounds its own call rather than the whole turn's.
astream()/stream() yield text alone and never see the rest.
Token usage and budgets¶
Every provider normalizes the vendor's token counts into a TokenUsage(prompt_tokens,
completion_tokens, total_tokens). Agent accumulates it across every model call on
agent.total_usage, and the final state.usage reflects that running total:
agent = Agent(llm, name="assistant", budget=Budget(tokens=50_000))
state = await agent.arun("...")
print(
state.usage
) # TokenUsage(prompt_tokens=..., completion_tokens=..., total_tokens=...)
print(agent.total_usage) # same object — persists across multiple arun()/run() calls
Budget bounds a run two ways, and they fail differently. Budget(tokens=...) raises
TokenBudgetExceeded the moment cumulative usage crosses it — checked right after each model
response, so a run already over budget won't dispatch further tool calls or make another model
call. Budget(steps=...) (default 10) caps think/act turns instead, and spending them returns
normally with stop_reason == "step_budget" and an empty output.
Budget(steps=1) is the single-shot case: one model call, no turn to react to a tool result.
Handy for a classify-or-extract step, but an agent with tools will stop at "step_budget"
rather than answering whenever it calls one.
Context management¶
A think/act loop grows its own transcript: every turn appends the model's request and whatever
its tools returned, and every later turn pays for all of it again. ContextPolicy bounds that
from both ends.
from deepharness import Agent
from deepharness.agent import ContextPolicy
agent = Agent(
llm,
tools=[read_file],
context=ContextPolicy(max_tokens=100_000, tool_result_chars=8_000),
)
tool_result_chars (default 8000) bounds each tool result as it is recorded, eliding the
middle and saying how much went missing — the middle rather than the tail because a long
result usually says what it is at the top and how it ended at the bottom. This is on by
default: one oversized result is the usual way a long run dies, since it is re-sent with every
turn after it.
max_tokens bounds the whole transcript and is off by default. Set it somewhere under the
model's real window and the loop sends a pruned view — oldest turns dropped, with a note in
their place — while state.messages keeps every message, so nothing is lost to you that only
had to be kept from the provider. Pruning never drops the leading system prompt, never orphans
a tool result from the turn that requested it, and always keeps the last keep_last messages
(default 4). A transcript whose tail alone exceeds the budget is therefore sent over it: a
bound is not worth breaking a turn's tool-call pairing for.
The token figure behind max_tokens is an estimate (estimate_tokens, roughly four
characters per token) rather than a real tokenizer, which would mean a per-vendor dependency.
Treat it as a soft bound that keeps a long run away from a hard provider error, not as a way
to predict a bill. Subclass ContextPolicy and override prune() to choose differently — the
loop asks for a view of the transcript and does not care how it was chosen.
Structured output¶
Pass output= a dataclass and state.output becomes a validated instance of it instead of
prose:
from dataclasses import dataclass
@dataclass
class Weather:
city: str
celsius: int
agent = Agent(llm, output=Weather, tools=[get_weather])
state = await agent.arun("Weather in Oslo?")
print(state.output.celsius) # 22
It works by offering the model one extra tool, final_answer, whose parameters are the
model's schema — so it behaves the same on every provider, with no vendor-specific JSON mode
involved. Two consequences worth knowing:
- If the model replies with prose instead of calling
final_answer, that is not treated as an answer: the agent asks it to call the tool and keeps going, bounded by the step budget. A model that never complies ends atstop_reason == "step_budget". - If the arguments don't fit, an
OutputValidationErrorgoes back to the model as that call's result — the same courtesy a failing tool gets — so it can try again with valid fields. Every bad field is reported at once, so one round-trip fixes them all. - Fields are checked, not coerced from anything:
list[str],Literal,Enum,X | Noneand nested dataclasses all validate, anintis accepted where afloatis declared, andtrueis rejected for anintfield even though Python calls a bool an int.
Human in the loop¶
Two different things a human can be needed for, and they resolve differently.
Approval — the call has not run yet. Mark the tool and the agent pauses before running it:
@tool(requires_approval=True)
def wire_transfer(amount_usd: int, to: str) -> str:
"""Send money."""
return f"sent ${amount_usd:,} to {to}"
state = await agent.arun("Pay the Acme invoice")
print(state.stop_reason) # "paused"
print(state.paused[0].question) # Run wire_transfer with {'amount_usd': 50000, ...}?
state = await agent.arun(state.approve()) # runs it now, with the model's arguments
# or
state = await agent.arun(state.reject()) # records "Denied by the user." instead
approve()/reject() return the state, so a resume is one line. With no call_id they resolve
every pending call; pass one to rule on a single call. Resuming a paused run without deciding
raises ConfigurationError rather than silently continuing.
To decide during the run instead of pausing it, give the Toolbox an approve= callback. It is
called with each gated call's name and arguments before the call runs, and may be async. True
runs the call; False raises ToolDenied, which an Agent hands to the model as that call's
result. How the callback decides is up to you:
from deepharness.agent import Toolbox
def approve(name: str, arguments: dict) -> bool:
return arguments["amount_usd"] <= 1_000
agent = Agent(model, tools=Toolbox([wire_transfer], approve=approve))
The gate belongs to the toolbox, so it also holds when a Graph node or your own code calls
tools.call(...) directly; catch ToolDenied there to decide what a refusal means. Without
approve= the run pauses as above.
The gate lives on the tool, not in the prompt, so a model cannot route around it by declining to ask. And if a turn requests a gated call alongside ordinary ones, nothing in that turn runs until the ruling — a half-applied turn the human is about to refuse would be worse than waiting. Those unrun calls are still recorded as not run, because a requested call that no result answers is a transcript vendors reject when the run resumes.
For anything finer than per-tool — allowing git log but not git push, both of which are
run_command — see Permissions.
A question — the tool wants to ask you something. Raise HumanInputRequired and the human's
answer becomes that call's result:
from deepharness.errors import HumanInputRequired
@tool
def confirm(question: str) -> str:
"""Ask the operator something."""
raise HumanInputRequired(question)
state = await agent.arun("...")
pending = state.paused[0]
state.messages.append(
Message.tool("yes, proceed", name=pending.name, call_id=pending.call_id)
)
state = await agent.arun(state)
The difference matters: an approval defers execution, a question substitutes a result. Use
pending.needs_approval to tell them apart.
Session persistence¶
state.to_dict() turns a whole AgentState into JSON-able data and AgentState.from_dict()
reads it back, so a run resumes across processes. Where you keep it — a file, a database row,
a queue message — is up to you:
import json
from pathlib import Path
from deepharness import AgentState
Path("session.json").write_text(json.dumps(state.to_dict()))
# later, in another process
state = AgentState.from_dict(json.loads(Path("session.json").read_text()))
if state.stop_reason == "paused":
state = await agent.arun(state.approve()) # the gated tool runs now
The whole state is kept, not just the transcript — usage, stop reason, and any call
paused on an approval, which is what a run waiting on a human needs to
resume. Structured output is stored as plain data and comes back as a dict rather than your
dataclass; reconstructing the type would mean trusting an import path out of stored data.
Messages¶
Message is a dict subclass with role-named constructors, so it's a drop-in replacement for
{"role": ..., "content": ...} everywhere a message is expected:
from deepharness import Message
Message.system("You are a concise assistant.") # {"role": "system", "content": "..."}
Message.human("What's the weather in Oslo?") # {"role": "user", "content": "..."}
Message.ai("It's 22°C and sunny.") # {"role": "assistant", "content": "..."}
Message.tool(
"22°C, sunny", name="get_weather"
) # {"role": "tool", "name": "...", "content": "..."}
You only need the tool_calls=/call_id= forms yourself if you're building messages by hand
instead of going through Agent — it manages that round-trip for you.
Agents as nodes¶
See Graphs → Agents as nodes for wiring multiple agents into a multi-agent workflow.