Core concepts¶
Six ideas carry the whole framework. Each exists because something concrete breaks without it; this page introduces them by the problem they solve, then walks one run end to end so the rest of the docs can assume the vocabulary.
A quick tour of the concepts:
- Agent vs Runner — an
Agentis immutable configuration; aRunnerexecutes one run. Nothing about a conversation lives on the agent. - Run vs turn — a run is the complete execution of one input; a turn is one iteration inside it: one model call followed by the tool calls it requested. One run may contain many turns.
- Tools — typed functions give the model capabilities beyond generating text. lovia derives their schemas, validates calls, and feeds results back into the run.
- Transcript vs view — the transcript is the append-only record of what happened; the view is what one model call gets to see. Long chats survive because only the view shrinks.
- Session vs checkpoint — a session is conversation memory across runs; a checkpoint is crash recovery within one run.
- Plugins — reusable capabilities contribute tools, instructions, view injectors, hooks, and guardrails through one extension mechanism, while the run loop retains control.
The cast¶
from lovia import Agent, Runner
agent = Agent(name="writer", instructions="Explain user impact before implementation details.", model="<model>")
result = await Runner.run(agent, "Draft a release note.")
Agent is a declarative dataclass: name, instructions, model, tools,
plugins, policies. It holds no conversation state, so one instance serves
any number of concurrent runs, and agent.clone(model="...") derives a
variant without copying anything mutable. (The one sanctioned in-place
mutation is registering dynamic instruction fragments with
@agent.instruction — see Agents.)
Runner is stateless — three static methods (run, run_sync,
stream) that translate arguments into one run. All mutable state for a run
lives inside the loop, created at start and gone at the end.
RunResult is what you get back: output (text, or your validated
output_type), usage, turns, finish_reason, final_agent (who was
active at the end — relevant after handoffs), and entries — the run's
own transcript contribution, not the whole conversation.
Run vs turn¶
A run takes one input through the agent loop to a final result or failure.
A call to Runner.run() or Runner.stream() starts it; if interrupted, a
later call may resume the same run from a checkpoint. The input, accumulated
usage, limits, active agent, and transcript segment all belong to that run. A
handoff changes the active agent but does not start a new run.
A turn is one logical pass through the run loop: obtain one model response,
then execute any tools it requested. Tool execution is still part of that
turn; the next turn begins only when the model is called again with the
results. Transparent provider retries do not create extra turns. A model
response with no tool calls normally ends the run. Consequently, max_turns
limits logical model steps rather than raw HTTP attempts, and RunResult.turns
reports how many such iterations the run used.
One run, turn by turn¶
The problem this design solves: agent loops accrete special cases (approval here, retry there, persistence somewhere else) until nobody can say what happens in what order. lovia's answer is one loop with fixed phases. This is the actual order of events; every guide hangs off some step of it.
Setup, once per run¶
- Resolve the active agent: providers, structured output, workspace
session, plugin
setup()(once per plugin), and the merged tool set — agent tools, plugin tools, workspace tools, and handoff tools. - Build the transcript:
[system prompt] + prior session history + your input. The system prompt concatenates the agent's instructions (plus dynamic fragments and any per-runextra_instructions), workspace instructions, plugin instructions, and — for providers without native JSON-schema support — the structured-output contract. - Run input guardrails once against the built transcript.
Each turn¶
- Check limits:
max_turns, cancellation, budget. TurnStartedfires; queued mailbox messages drain into the transcript as user entries (this is how mid-run steering lands).- The context policy renders this call's view of the transcript; plugin view injectors append their transient entries (todo reminders and the like). These entries are never persisted: repeated injections neither disturb the stable prompt prefix (keeping provider caching effective) nor accumulate until the transcript balloons.
- The provider streams the model's reply: text deltas, reasoning deltas, tool-call deltas. On a context-overflow error with nothing yet streamed, the policy gets one chance to shrink the view and the call is retried.
- The reply's entries append to the transcript; the checkpoint (if any) saves.
- If the model requested tools: each call is preflighted in order (budget, approval, argument validation), then executed — concurrently where tools allow it, serially where they don't. Results append as they complete; each one is checkpointed.
- If the model answered without tool calls: parse the final output. A structured-output parse failure arms one repair turn instead of failing (configurable).
TurnEndedfires. A pending handoff swaps the active agent (new system prompt, same conversation body) and the loop continues.
On completion¶
Output guardrails run, the checkpoint is finalized, and only then is the run's segment appended to the session — in that order, so a crash can never leave a run both persisted and resumable. Every event above is also dispatched to hooks as it happens.
Streams have one more guarantee worth memorizing: iterating a run's event
stream never raises. Every stream closes with exactly one terminal event —
RunCompleted or RunFailed — and await handle.result() is where errors
become exceptions.
Transcript vs view¶
The problem: conversations outgrow context windows, and most frameworks "fix" this by rewriting history — after which nobody can audit what the model actually saw, and resumed runs diverge.
lovia separates the two roles:
- The transcript is the canonical, append-only record: typed
TranscriptEntryvalues (input, assistant text, reasoning, tool call, tool result) that preserve everything providers emit. Sessions and checkpoints persist the transcript. It only ever grows. - The view is what one model call receives. The context policy (default:
Compaction) may offload a huge tool result, clear old ones, or summarize ancient history — in the view only. The transcript is untouched, and arecall_tool_resulttool lets the model retrieve anything the view dropped.
So "the model forgot" and "the record lost it" become different questions with different answers. Details in Context management.
Session vs checkpoint¶
Two persistence stores that are easy to conflate and importantly different:
| Session | Checkpoint | |
|---|---|---|
| Answers | "what has this conversation said so far?" | "how far did this run get?" |
| Keyed by | session_id (yours: user id, thread id, ...) |
run_id (globally unique per checkpointer) |
| Holds | one segment per completed run | the one run that may still resume |
| Written | once, when a run completes | after the model turn and after every tool result |
| Lifetime | the conversation's | the run's (optionally deleted on success) |
Both are append-only: a stored run is never rewritten. The full
conversation at any moment is session.load() plus the in-flight snapshot's
entries. Re-issuing a completed run_id replays the stored result without
calling the model — that's what makes run_id an idempotency key, and it's
why crashed workers can simply retry their whole job. See
Sessions & checkpoints.
Tools: capabilities the model can invoke¶
A tool is a typed Python function exposed to the model. Add tools with
Agent(tools=[...]); lovia turns each signature into JSON Schema, validates
model-supplied arguments before invoking your code, and records both the call
and its result in the transcript.
from lovia import Agent, tool
@tool
async def lookup_order(order_id: str) -> str:
"""Look up an order by id."""
return f"{order_id}: shipped"
agent = Agent(name="support", model="<model>", tools=[lookup_order])
When the model requests one or more tools, those calls and their results remain part of the current turn. The following model call starts the next turn, where the model can use the results to continue or answer. Tools may also come from plugins, workspaces, and handoffs; names must be unique across the merged set. See Tools for schemas, concurrency, retries, approvals, and result handling.
RunContext: the one handle¶
Tools, hooks, guardrails, and dynamic instruction fragments receive the same
live RunContext. It exposes the current dependencies, Agent, transcript,
usage, persistence keys, Workspace, cancellation token, and Mailbox. A Tool
opts in by type-annotating a parameter — the parameter name does not matter:
from dataclasses import dataclass
from lovia import RunContext, tool
@dataclass
class Deps:
db: "Database"
@tool
async def lookup(ctx: RunContext[Deps], user_id: int) -> str:
"""Fetch a user record."""
return await ctx.deps.db.fetch(user_id)
Treat ctx.entries as read-only. Use ctx.deps for application dependencies,
ctx.cancel_token to request cancellation, and ctx.mailbox to steer the next
Turn. The complete field catalog is in the
API reference.
Plugins: the one extension axis¶
The problem: frameworks grow a hook forest — one registry for tools, another for prompt fragments, a middleware stack, lifecycle callbacks — and every reusable capability needs all of them wired separately.
A lovia plugin is one object that contributes any mix of: tools, system
prompt text, per-turn view injectors, hooks, and guardrails. The runner
activates it once per run (await plugin.setup()), tears it down at run end,
and merges its contributions into the fixed loop slots above. Plugins never
drive control flow — the loop keeps the abort, the retry, and the handoff.
Skills, MCP, the todo list, and long-term memory are all plugins built on exactly this seam, which is the proof it suffices. See Plugins.
When things go wrong¶
Every framework exception inherits LoviaError and may carry a .hint with
the next action to try. Configuration problems raise UserError; provider,
context, validation, budget, cancellation, and guardrail failures use specific
subclasses so callers can recover narrowly. The full catalog is in the
API reference, with symptom-driven fixes in
Troubleshooting.
Two rules matter immediately: an ordinary Tool exception becomes a result the
model can react to instead of ending the Run; and streaming failures surface
from await handle.result(), never while iterating events.
Design constraints you can rely on¶
The philosophy ("concise, lightweight, extensible, general-purpose") cashes out as invariants you can build against:
- Agents are configuration. No conversation state on the
Agent; safe to share, cheap to clone. - The transcript is never rewritten. Compaction shapes views; sessions and checkpoints only append; a completed run is immutable.
- Plugins contribute; the loop controls. No plugin can retry, abort, or reroute a run.
- Everything correlates by id, not position. Tool events pair by
call.id; segments and snapshots pair byrun_id. Concurrency reorders nothing that matters. - The core stays small. The default install has only three runtime
dependencies:
httpx,pydantic, andpyyaml. Capabilities that need additional libraries, such as MCP and the web app, ship as opt-in extras and are imported only when used.
See also¶
- Quickstart — the ten-minute path that motivated all this
- Running agents — the full
Runnersurface - Architecture notes — the contributor-level version of this page, with module names and invariants for people changing lovia itself