Agent: Building Your Own Bot
QueryBot and DefinitionBot are opinionated assemblies built on a primitives layer
that's public in its own right. Reach for the primitives directly when you need a bot
whose tools, prompt, or output shape don't fit either convenience bot — the primitives
are designed from the start to support this, not bolted on as an escape hatch.
Installation
agent-core installs pydantic-ai without a provider SDK — enough to build and test bots
against TestModel/FunctionModel, as in the example below, or against a provider SDK
you install separately (pip install anthropic, pip install openai, etc.).
The Bot subclassing contract
Every bot subclasses Bot and implements _build_agent(), which must:
- Build a
pydantic_ai.toolsets.FunctionToolset. - Register every tool in
self._toolsonto it, viaaitaem.agent.base._register_tool()(handles both plain callables and pre-builtpydantic_ai.Toolinstances). - Assign the toolset to
self._toolset—Bot.__init__raisesTypeErrorimmediately if this isn't set, naming the offending subclass. - Return a configured
pydantic_ai.Agent.
from datetime import datetime, timezone
from pydantic_ai import Agent
from pydantic_ai.toolsets import FunctionToolset
from pydantic_ai.models.test import TestModel
from aitaem.agent import Bot, BotResponse, Status
from aitaem.agent.base import _register_tool
from aitaem.agent.trace import assemble_trace
def word_count(text: str) -> int:
"""Count words in text."""
return len(text.split())
class EchoBot(Bot):
def _build_agent(self) -> Agent:
toolset = FunctionToolset()
toolset.add_function(word_count)
for tool in self._tools:
_register_tool(toolset, tool)
self._toolset = toolset # required — see contract above
return Agent(model=self._model, toolsets=[toolset], instructions="Answer briefly.")
async def ask(self, message: str, *, extra_tools=None) -> BotResponse:
run_start = datetime.now(timezone.utc)
result = await self._agent.run(message)
return BotResponse(
status=Status.ok,
narrative=str(result.output),
trace=assemble_trace(result, run_start),
)
bot = EchoBot(model=TestModel())
response = await bot.ask("How many words are in this sentence?")
print(response.narrative)
print([tc.name for tc in response.trace.tool_calls])
Swap TestModel() for a real model string (e.g. "anthropic:claude-haiku-4-5-20251001")
once you've installed the matching provider SDK.
BotResponse, Status, RunTrace, ResultStore
Every bot method returns a BotResponse[PayloadT] — a frozen Pydantic model with
status: Status (ok / empty / refused / error), a narrative: str, a
trace: RunTrace, and an optional typed payload. aitaem.agent.trace.assemble_trace()
builds a RunTrace (tool calls, token usage, duration) from a completed
pydantic_ai.AgentRunResult — the example above uses it directly; QueryBot and
DefinitionBot call it internally.
Any tool that produces a result too large for the LLM-facing summary (a metric table, a
draft spec) should write it to self._store (a ResultStore) and return a compact
summary plus the result_id. Callers dereference the full result via
bot.get_result(result_id), which returns a TabularEntry (Arrow table + optional Ibis
ref) or TextEntry (string + content type) — narrow with isinstance() or use
ResultStore.get_tabular()/get_text() for a typed getter that raises
WrongEntryKindError on a kind mismatch.
Tool composition
Three surfaces add tools to a bot without subclassing further:
# Construction-time — folded into the bot's default toolset
bot = QueryBot(model=..., spec_cache=..., connection_manager=..., tools=[my_tool])
# Runtime, persistent — takes effect on the next chat()/ask() call
bot.add_tool(another_tool)
# Per-call, ephemeral — additive to construction-time tools, not a replacement
response = await bot.ask("...", extra_tools=[one_off_tool])
Tool-name collisions (construction-time duplicates, or a runtime/per-call tool sharing a
name with a default tool) raise pydantic_ai.exceptions.UserError at the point of
registration — no silent auto-namespacing.
History serialization
bundle = bot.dump_history() # JSON-serializable dict
# ... persist bundle, e.g. json.dumps(bundle) to disk/DB ...
restored = QueryBot.load_history(bundle, model=..., spec_cache=..., connection_manager=...)
dump_history() preserves Arrow artifacts but not live Ibis refs — get_result() on a
restored bot works, but get_ibis() returns None for restored entries. Tools added at
runtime via add_tool() are not restored automatically; load_history() warns if
the bundle references runtime-added tools that are missing after reload. Pass them again
via tools=[...] or call add_tool() post-reload to silence the warning.
Context-window management
For long-running sessions where history may exceed the model's context limit, pass a
history processor via capabilities when constructing the Agent in _build_agent():
from pydantic_ai.capabilities import ProcessHistory, ReinjectSystemPrompt
def _build_agent(self) -> Agent:
return Agent(
model=self._model,
capabilities=[
ReinjectSystemPrompt(replace_existing=True),
ProcessHistory(trim_old_messages),
],
)
The processor receives the full message list before each model request (including
mid-tool-call-loop steps) and returns a modified list. Only trim complete tool-call pairs
(ToolCallPart + its matching ToolReturnPart) as a unit — dropping a ToolReturnPart
without its ToolCallPart violates provider API constraints
(pydantic-ai issue #2050). No
built-in trimmer ships with aitaem; this is a reference pattern to implement against,
not a shipped utility.
Next steps
- Evaluating Your Agent — write
pydantic_evalsevaluations against a bot you've built - Stability & Limitations — what's semver-stable if you're building on the primitives layer
- Agent API Reference — full primitives class and type documentation