Tutorial: a research agent¶
Available · pip install agent-adapter (no extra) · about 15 minutes
You build a small research agent end to end: tools from plain functions, retrieval from three sources at once, a model loop, a human approval before it publishes, and a cost report. Everything runs offline: a scripted function stands in for the model.
| File | Does |
|---|---|
examples/tutorial_research.py |
the whole agent; python examples/tutorial_research.py prints each step |
tests/docs/test_tutorial_examples.py |
runs it and checks the output |
flowchart LR
Q["question"] --> L["run_tool_loop"]
L --> M["model (scripted)"]
M -- "3 tool calls" --> P["run_many: web, papers, news<br/>at once"]
P --> M
M -- "publish_report" --> A{"approval<br/>(DeferredApproval)"}
A -- "pending" --> R["reviewer decides<br/>(store.decide)"]
R --> L2["run_tool_loop again<br/>same ctx, paused messages"]
L2 --> D["answer"] --> C["ledger.summary"]
1. Tools from functions¶
@registry.tool(kind="retrieval", parallel_safe=True, timeout=10, retries=2)
async def search_web(query: str, limit: int = 3) -> list[str]:
"""Search the web for a query."""
return [f"web: {query} #{n}" for n in range(1, limit + 1)]
@registry.tool(kind="retrieval", parallel_safe=True, timeout=10, retries=2)
async def search_papers(query: str) -> list[str]:
"""Search papers for a query."""
return [f"paper: {query} survey"]
def legacy_news_lookup(topic: str, region: str = "us") -> list[str]:
"""Existing code you do not want to edit: news headlines for a topic."""
return [f"news ({region}): {topic} rules change"]
registry.wrap(legacy_news_lookup, name="search_news", kind="retrieval", parallel_safe=True)
@registry.tool(confirm=True, effects="additive")
def publish_report(title: str, body: str) -> str:
"""Publish a report to the team wiki (needs approval)."""
PUBLISHED.append(title)
return f"published {title!r} ({len(body)} chars)"
| Line | Means |
|---|---|
@registry.tool(...) |
the signature and docstring become the schema the model sees |
parallel_safe=True |
calls may share the run; without it a call runs alone |
timeout=10, retries=2 |
each attempt is cut at 10 s; failures retry with backoff |
registry.wrap(legacy_news_lookup, name=...) |
existing code becomes a tool without edits |
confirm=True |
the executor asks the approval port before every call |
2. One executor¶
store, ledger = MemoryApprovalStore(), Ledger(prices=PRICES)
executor = Executor(registry, approval=DeferredApproval(store), ledger=ledger)
ctx = executor.context(run_id="research-1")
The executor runs every call: validation, approval, timeout, retries, ledger. The store
holds approvals until someone decides. The context names the run (research-1): guards,
approvals and the cost report key on it.
3. Parallel retrieval¶
def retrieval_calls(query: str, prefix: str = "") -> list[ToolCall]:
"""One call per source; call ids must be unique within a run."""
return [
ToolCall(name="search_web", arguments={"query": query, "limit": 2}, id=f"{prefix}web"),
ToolCall(name="search_papers", arguments={"query": query}, id=f"{prefix}papers"),
ToolCall(name="search_news", arguments={"topic": query}, id=f"{prefix}news"),
]
async def retrieve(executor: Executor, query: str, ctx: ToolContext) -> list[str]:
"""Run every source at once; a failed source is skipped, not fatal."""
results = await executor.run_many(retrieval_calls(query), ctx, concurrency=3)
return [hit for result in results if result.ok for hit in result.value]
run_many starts the three searches together (up to concurrency) and returns
ToolResults in input order. A failed source is a result with ok=False, not an
exception, so one dead source does not sink the answer.
Output: ['web: EV battery #1', 'web: EV battery #2', 'paper: EV battery survey', 'news (us): EV battery rules change']
4. The model loop¶
async def fake_model(request: LLMRequest) -> dict[str, Any]:
"""Stands in for a real model: search, then publish, then answer."""
last = request.messages[-1]
usage = Usage(input_tokens=2_000, output_tokens=200)
if last.role == "user":
calls = tuple(retrieval_calls(last.content, prefix="loop-"))
return {"tool_calls": calls, "finish_reason": "tool_calls", "usage": usage}
if last.name != "publish_report":
hits = [m.content for m in request.messages if m.role == "tool"]
args = {"title": "EV battery", "body": "\n".join(hits)}
call = ToolCall(name="publish_report", arguments=args, id="publish")
return {"tool_calls": (call,), "finish_reason": "tool_calls", "usage": usage}
return {"text": f"Done: {last.content}", "usage": usage}
MODEL = CallableModel(fake_model, provider="host", model="research-v1")
PRICES = Prices.load(
{
"version": "demo",
"models": {"research-v1": {"provider": "host", "input": 1.0, "output": 4.0}},
}
)
Swap CallableModel(fake_model, ...) for OpenAIModel, AnthropicModel or GeminiModel
from agent_adapter.models; nothing else changes. The price table makes the ledger price
each model call.
question = (Message(role="user", content="EV battery"),)
paused = await run_tool_loop(MODEL, registry, question, executor=executor, ctx=ctx)
print(paused.finish, PUBLISHED) # the model asked to publish: the call waits
The loop calls the model, runs its tool calls (the three searches at once), feeds the
results back, and stops when the model answers or a call waits for approval. Here it asks
to publish, so finish is pending and nothing was published.
5. Approve, edit, resume¶
[waiting] = await store.list(run_id="research-1", status="pending")
print(waiting.request.tool, sorted(waiting.request.arguments)) # what a reviewer sees
edit = {**waiting.request.arguments, "title": "EV battery (draft)"}
await store.decide(waiting.approval_id, Decision(approved=True, edited_args=edit))
done = await run_tool_loop(MODEL, registry, paused.messages, executor=executor, ctx=ctx)
print(done.finish, done.final.text, PUBLISHED) # ran once, with the edited title
| Step | What happens |
|---|---|
store.list(status="pending") |
the reviewer sees the tool and its arguments |
store.decide(id, Decision(approved=True, edited_args=...)) |
approve with a new title; edits are validated before the tool runs |
run_tool_loop(..., paused.messages, ctx=ctx) |
the same run resumes: the waiting call runs first, once, then the model answers |
Resuming from paused.messages a second time does not publish again: the decision was
used once, so the call waits for a new one. Decision(approved=False) turns the call into
an approval-denied error the model sees.
6. Cost report¶
summary = ledger.summary("research-1")
print(f"${summary.total_usd:.4f}", {kind: s.calls for kind, s in summary.by_kind.items()})
print(json.dumps({stage: round(s.cost_usd, 4) for stage, s in summary.by_stage.items()}))
Output: $0.0084 {'tool': 7, 'llm': 3}. Three model calls at $0.0028 each. Seven tool
calls: three searches in step 3, three in the loop, one publish. By stage, the model's
cost sits under tool_loop and each tool under its own name.
Next steps¶
| To | Read |
|---|---|
| cap spend per run | BudgetHook(max_usd) in Hooks |
| serve the agent over HTTP | FastAPI or HTTP server |
| a fixed pipeline instead of a model loop | Flows |
| more on approval ports and stores | Approval |
| prompt caching, response caching | Cache |