Architecture¶
The whole library fits in your head because it is a small set of uniform interfaces plus factories that build them and adapters that let them stand in for one another. Learn the interface once and every provider, modality, agent, and workflow behaves the same way:
| Interface | One per… | Concretes |
|---|---|---|
BaseModelClient |
text provider | Ollama, Anthropic, OpenAI-compat, HuggingFace, llama.cpp |
Base{Image,Audio,Speech,Transcription,Embedding}Client |
other modality | HF, OpenAI, Gemini, Ollama (per modality) |
Runner |
agent or workflow | Agent, SkillAgent, OrchestratorAgent, Chain, Router, Parallel, … |
StreamChunk |
streaming source | every chat() / run() / image-gen stream |
MemoryStore |
memory backend | SemanticMemoryStore, DocumentStore |
Channel |
chat transport (async) | CLIChannel, WebChannel |
There is no Message class, no Runnable protocol, no graph DSL. Conversation state is a plain
list[dict] in OpenAI format. Everything below is a consequence of keeping these interfaces small
and composable — see Design principles.
The big picture¶
A request flows top-down: the ergonomic top-level API resolves a model and hands off to either a
plain client (one turn) or a Runner that drives the model over multiple turns. Tool execution
lives in an internal engine between the Runner and the client, never in the client itself.
flowchart TB
E["<b>Top-level API</b> — aimu.chat · client · agent · image_client · embed · transcribe · …"]
AG["<b>Runner layer</b> (aimu.agents) — Agent · SkillAgent · OrchestratorAgent<br/>Chain · Router · Parallel · EvaluatorOptimizer · PlanExecuteEvaluator"]
TL["<b>_ToolLoop / _AsyncToolLoop</b> — internal engine: dispatch · approve · inject deps · loop"]
F["<b>ModelClient</b> (factory)"]
B["<b>BaseModelClient</b> — chat() = one model turn (parse + store, no execution)"]
P["<b>Providers</b> — Ollama · Anthropic · OpenAI-compat · HuggingFace · llama.cpp"]
E --> F --> B --> P
E --> AG
AG -->|composes per run| TL
TL -->|chat(tools=…), dispatch, repeat| B
The three most load-bearing ideas — the single-turn client, the tool-loop engine, and the Runner
interface — each get a section below.
A tool-using turn: three layers¶
Tool calling is split into three layers with one responsibility each. This is the part most worth understanding, because it is where the "the client is a pure adapter" principle pays off.
- Model client — a pure provider adapter.
chat()is a single model turn: it advertises thetools=it was handed, issues one request, and when the model asks for a tool it callsself._record_tool_calls(...)to parse and store the call on the assistant message. It does not execute tools and keeps no persistent tool registry, approval policy, or deps. - Tool-loop engine — the internal middle layer (
_ToolLoop/_AsyncToolLoop, inaimu.agents._tool_loop/aimu.aio._tool_loop). It owns the iterative tool-calling logic: dispatch the pending calls (one by-name lookup), enforce the approval policy, injectToolContext(deps), coerce arguments, run them (concurrently whenconcurrent_tool_calls), append therole:"tool"results, and call the client again — repeating until a turn makes no tool calls (bounded bymax_rounds), then the optionalfinal_answer_promptwrap-up. It is not public API; the ladder stayschat()→Agent→ workflows. Agent— autonomy and composition. It holds the tool configuration (tools,deps,tool_approval,concurrent_tool_calls) plus per-runrun(tools=/deps=/tool_approval=)overrides, composes a_ToolLoopper run (_make_tool_loop), and adds identity, theRunnerinterface,restore(), and theschema=short-circuit.
sequenceDiagram
participant A as Agent
participant E as _ToolLoop (engine)
participant C as BaseModelClient
participant M as Model
A->>E: run(task)
E->>C: chat(task, tools=…)
C->>M: one request
M-->>C: assistant turn (may carry tool_calls)
C-->>E: stores tool_calls (does NOT execute)
loop while the last turn requested tools
E->>E: dispatch tools (approve · inject deps · coerce args)
E->>C: chat() — no new user turn
C->>M: continue
end
E-->>A: final answer
The consequence: a bare client.chat("q", tools=[...]) parses and stores the tool call but never
runs it. To execute tools you need an Agent (or agent.as_model_client()). See
Tool integration for the @tool / MCP routes and dispatch details.
The base contract: BaseModelClient¶
Every text provider speaks the same shape:
class BaseModelClient(ABC):
model: Model
messages: list[dict] # OpenAI format; the source of truth
system_message: str | None
tools: list[Callable] # per-call transient (default []); pass via chat(..., tools=...)
last_thinking: str
last_usage: dict | None
last_structured: object | None
def chat(user_message=None, *, tools=None, stream=False, images=None, audio=None,
schema=None, include=None, ...) -> str | Iterator[StreamChunk] | <schema instance>
def generate(prompt, *, images=None, audio=None, schema=None, ...) -> ...
def reset(system_message="__keep__") -> None
chat() and generate() are concrete on the base — they apply the include= stream filter and
the schema= structured-output path, then delegate to abstract _chat() / _generate() which each
provider implements. A cross-cutting feature (streaming filters, structured output, vision/audio
input normalization) lands in one place and works everywhere. The I/O-free helpers — the
system_message lifecycle, reset(), tool-spec collection, tool-call recording
(_record_tool_calls), and structured-request resolution (_structured_request) — live in the
shared _ChatStateMixin (_internal/chat_state.py), inherited by
both the sync and async bases.
Providers that need a different wire format (Anthropic blocks, Ollama image fields, HF PIL images)
adapt at request time and never mutate self.messages. See
System message lifecycle and
Thinking and context for how history and reasoning are managed.
The factory: ModelClient, ModelSpec, Model¶
ModelClient(OllamaModel.QWEN_3_8B) # provider from the enum type
ModelClient("anthropic:claude-sonnet-4-6") # or from a "provider:model_id" string
ModelClient is the single public entry point. It resolves the Model enum (or string) against a
provider registry, instantiates the matching concrete client, and delegates every method to it,
keeping your code provider-agnostic. Provider classes (OllamaClient, AnthropicClient, …) remain
importable but are not the recommended surface.
Each provider lists its models as a Model enum whose members carry a ModelSpec — a frozen
dataclass of the id string plus capability flags (tools, thinking, vision, audio,
structured_output, optional generation_kwargs). Equality/hash use the id only, so a spec can
hold a dict and still be an enum value. Members expose .value (the id), .spec, mirrored
.supports_* flags, and derived TOOL_MODELS / THINKING_MODELS / VISION_MODELS classproperties.
This same spec-plus-enum-plus-factory pattern recurs for every modality below.
Modality surfaces: one shape, six stacks¶
Text is one of six parallel modality surfaces. Each is an independent stack with the same shape,
built on the shared _base/ spec/enum types and the _internal/factory.py machinery:
| Modality | Base ABC | Factory | Top-level entry points |
|---|---|---|---|
| Text (chat) | BaseModelClient |
ModelClient |
aimu.client() / aimu.chat() |
| Image | BaseImageClient |
ImageClient |
aimu.image_client() / aimu.generate_image() |
| Audio (music/sound) | BaseAudioClient |
AudioClient |
aimu.audio_client() / aimu.generate_audio() |
| Speech (TTS) | BaseSpeechClient |
SpeechClient |
aimu.speech_client() / aimu.generate_speech() |
| Transcription (STT) | BaseTranscriptionClient |
TranscriptionClient |
aimu.transcription_client() / aimu.transcribe() |
| Embedding | BaseEmbeddingClient |
EmbeddingClient |
aimu.embedding_client() / aimu.embed() |
flowchart LR
T["Text<br/>ModelClient"] --> S
IM["Image<br/>ImageClient"] --> S
AU["Audio<br/>AudioClient"] --> S
SP["Speech<br/>SpeechClient"] --> S
TR["Transcription<br/>TranscriptionClient"] --> S
EM["Embedding<br/>EmbeddingClient"] --> S
S["<b>Shared machinery</b><br/>_internal/factory · _base/*Spec + *Model enum · StreamChunk"]
The non-text base ABCs do not subclass BaseModelClient — the contracts are disjoint. chat(),
messages, system_message, tools, and the chat StreamChunk phases are meaningless for
stateless text-to-image or text-to-vector. Forcing the modalities into one class would bleed
irrelevant fields into every user's code. What they share is shape, not implementation: the same
factory-plus-concretes pattern, the same provider:id string format, and the same per-provider
extras ([hf] covers HF across modalities; [google] is image-only since text-side Gemini uses the
OpenAI-compat endpoint).
Modalities meet in practice through tools: the built-in generate_image / generate_audio /
transcribe_audio tools wrap a modality client so any text agent can reach it, and
SemanticMemoryStore takes an EmbeddingClient. The agent loop never knows whether a tool hits a
cloud API or a local GPU.
Agents and workflows: Runner¶
Runner(ABC) is the common interface for anything you can run():
class Runner(ABC):
def run(task, *, generate_kwargs=None, stream=False, images=None) -> str | Iterator[StreamChunk]: ...
@property
def messages(self) -> dict[str, list[dict]]: ...
def as_tool(self, *, name=None, description=None) -> Callable: ... # concrete
There is a single Runner ABC. The autonomous-vs-code-controlled split survives as a
categorisation of concrete classes, not a type hierarchy:
- Autonomous (the LLM directs flow):
Agentloops until the model stops calling tools;SkillAgentadds filesystem-discovered skills;OrchestratorAgentwraps worker runners as tools (itsassemble()factory + the prebuiltResearchReportAgent/CodeReviewAgent/ContentCreationAgent). - Code-controlled (Python directs flow):
Chain(steps in order),Router(classify then dispatch),Parallel(fan out, optional aggregate),EvaluatorOptimizer(generate→critique→ revise),PlanExecuteEvaluator(plan→execute-with-tools→score→replan).
All concretes are exported from aimu.agents. This is the Building Effective Agents taxonomy made
concrete — see Agents vs workflows.
Composition seams¶
Because everything speaks a uniform interface, the pieces substitute for one another through a handful of adapters. This is where "composability through uniform interfaces" cashes out:
| Adapter | Turns… | …into a | So you can |
|---|---|---|---|
Runner.as_tool() |
any agent/workflow | @tool callable |
let an Agent call a Chain; give an orchestrator workers |
agent.as_model_client() |
an Agent |
BaseModelClient |
drop an agent into Benchmark, a Chain step, or any client slot |
FallbackClient |
an ordered client list | one BaseModelClient |
fail over across providers with no special wiring |
MCPClient.as_tools() |
a remote MCP server's tools | local callables | mix cross-process tools with @tool functions |
RemoteAgent (A2A) |
a remote agent over HTTP | a local Runner |
compose a remote agent as a tool / worker / step |
RemoteAgent is the agent-level analog of MCPClient: MCP shares tools over the wire, A2A shares
whole agents. Both are optional adapters at the boundary, not core abstractions — see
A2A vs MCP.
Resilience and structured output¶
Two features compose over BaseModelClient rather than adding class trees:
FallbackClientwraps an ordered list of clients and fails over on error, preserving conversation state across the switch; when all fail it raisesFallbackExhaustedError(with.errors). Because it is aBaseModelClient, it drops into agents, workflows, andBenchmarkunchanged. Pair it with per-clienttimeout/max_retries(forwarded verbatim to the SDK) for in-SDK retry plus cross-provider failover.- Structured output (
schema=onchat()/generate(), andAgent.run(schema=...)) returns a validated dataclass / Pydantic instance. The base branches on the model'ssupports_structured_outputflag: native provider enforcement (response_format, Ollamaformat=, or an Anthropic forced tool) when available, otherwise a JSON-Schema instruction + parse. The result is also stored onclient.last_structured. It streams too (a terminalDONEchunk carries the object).
Two surfaces, one shape (sync and async)¶
Everything above is the sync surface. AIMU ships an opt-in async surface under aimu.aio
that mirrors it one-for-one — same class names, same Runner decision tree, same StreamChunk.
aimu.{chat, client, Agent, Chain, …} # sync (default)
aimu.aio.{chat, client, Agent, Chain, …} # async (opt-in)
The two surfaces share the state mechanics and pure logic rather than duplicating them:
_ChatStateMixin, the _base/* spec/enum types, the _internal/* helpers (streaming, JSON,
structured, usage, image/audio I/O), the _AgentLoopMixin, _FallbackStateMixin, the tool-loop's
async-free construction and helpers (_BaseToolLoop, subclassed by both _ToolLoop and
_AsyncToolLoop), the in-process wrapper mechanics (_AsyncInProcessClient, subclassed by
AsyncHuggingFaceClient / AsyncLlamaCppClient), and even the loop's stop condition
last_turn_called_tools. What is duplicated by necessity is only the await points: the
_chat/_generate bodies, the _ToolLoop vs _AsyncToolLoop loop drivers and dispatch, the
concurrency primitive (asyncio.TaskGroup vs ThreadPoolExecutor), and the streaming return type
(AsyncIterator[StreamChunk] vs Iterator[StreamChunk] — a deliberate asymmetry).
The async surface also carries a few async-only primitives for always-on assistants: the
Channel transport (CLIChannel, WebChannel), the Scheduler for proactive triggers, and
RunHandle for cooperative run cancellation. See Async design.
Streaming: one chunk type everywhere¶
StreamChunk(phase, content, agent=None, iteration=0) is the single chunk type yielded by every
streaming path — client.chat(), Agent.run(), every workflow run(), image/audio generation,
and streaming tools. Earlier versions had separate AgentChunk / ChainChunk; both collapsed into
StreamChunk. The agent and iteration fields are populated by agents/workflows and default to
None / 0 for plain chats. See StreamChunk model and the
stream phases reference.
What lives where¶
| Package | Role |
|---|---|
aimu |
Top-level chat(), client(), agent(), per-modality *_client() / generate_*() / embed() / transcribe(), re-exports |
aimu.aio |
Async mirror of the sync surface + async-only channels, scheduler, run_handle |
aimu.models |
Every modality: Base*Client ABCs (_base/), factories, ModelSpec/Model enums, _internal/ helpers, provider clients, FallbackClient |
aimu.agents |
Runner ABC; Agent/SkillAgent/OrchestratorAgent; workflows; the _tool_loop engine; a2a interop; prebuilt/ orchestrators |
aimu.tools |
@tool decorator, MCPClient, ToolContext (deps), approval hook, builtin.* groups |
aimu.skills |
AgentSkill, SkillManager (discovery), runtime authoring, skills MCP server |
aimu.memory |
MemoryStore ABC, SemanticMemoryStore, DocumentStore, MCP servers |
aimu.rag |
ingest / retrieve / format_context / split_text / rerank — plain functions over MemoryStore |
aimu.sessions |
Multi-user SessionStore (InMemory / TinyDB) keyed by channel:sender, Session, SessionLocks |
aimu.history |
ConversationManager (single-conversation TinyDB persistence) |
aimu.prompts |
Versioned PromptCatalog, PromptTuner + concrete tuners, Scorer |
aimu.evals |
Benchmark / BenchmarkResults, DeepEval adapters |
A few subsystems are newer than the client/agent core and worth calling out:
aimu.sessionsgeneralizes the single-conversationConversationManagerto many users/chats in one process, keyed bychannel:sender. It reuses thereset()+restore()per-turn seam so agents never share a livemessageslist.aimu.ragis deliberately functions, not aRetriever/Splitterclass tree — the retriever interface already exists asMemoryStore.search(). RAG is a workflow you compose (ingest→retrieve→format_context→chat), not a new abstraction.aimu.skillsdiscoversSKILL.mdfiles and can author new skills (and runnable scripts) at run time;SkillAgentsurfaces them through its tool list mid-run.
Each package has its own optional dependency; aimu[all] installs everything, and piecewise installs
degrade gracefully via HAS_* flags.
See also¶
- Design principles: what AIMU deliberately doesn't do, and why.
- Agents vs workflows: the taxonomy and when to pick which.
- Tool integration: the
@tooland MCP routes, dispatch, and failure modes. - StreamChunk model: why one chunk type.
- System message lifecycle · Thinking and context.
- A2A vs MCP · Async design.
- Reference: provider matrix · model matrix · stream phases.