Core concepts¶
Six groups of concepts recur throughout a Run. This page defines their boundaries and follows them in execution order through the complete flow.
Concepts at a glance¶
| Concept | Question it answers | Key distinction |
|---|---|---|
| Agent and Runner | What is configured, and what executes it? | Agent stores configuration; Runner starts execution |
| Run and turn | How does one request progress? | A Run contains one or more turns |
| Tool | How does the model call external capabilities? | Schema, metadata, and async invoke, usually built with @tool |
| Transcript and view | What is recorded, and what does the model see? | The conversation body is canonical; a view is one model input |
| Session and Checkpoint | How is state persisted? | Session stores conversation; Checkpoint recovers a Run |
| Plugin | How is a capability bundle reused? | Plugins contribute; the run loop still controls execution |
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¶
Agent loops tend to accumulate special cases—approval here, retry there, persistence somewhere else—until execution order becomes unclear. lovia keeps them in one loop with fixed phases. From the user side, a Run has three parts: prepare input, loop between the model and Tools, then validate and persist the result. The steps below show the actual order.
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 in-memory transcript, persisted Run entries, and one model call's view:
- The full transcript is “the active Agent's rendered system head + Session history + this Run's entries.” A Handoff replaces the system head with the target Agent's version; it does not rewrite the conversation body.
- This Run's entries contain input, assistant text, reasoning, Tool calls,
and Tool results. They append only and form the body persisted in
RunResult.entries, Session Segments, and Checkpoint Snapshots. Oversized Tool output may still be truncated before an entry is stored. - The view is what one model call receives. The default
Compactionpolicy may offload a huge Tool result, clear old ones, or summarize ancient history in the view only.recall_tool_resultcan retrieve content removed from the view.
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 finalized Segment per Run; Runner auto-appends on success | one Run's in-flight or completed Snapshot for resume or replay |
| 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) |
During normal execution, both stores append new Run records; compaction never
rewrites a stored Run. 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 is what makes
run_id an idempotency key. See Sessions & checkpoints.
Explicit Session maintenance may trim Tool results or rewind recent segments. Checkpoint entries append, while its small status head is updated as the Run advances. Neither operation is context compaction.
Tools: capabilities the model can invoke¶
A Tool contains Schema, metadata, and an async invoke, and is usually
built by @tool from a callable's signature. Add it with
Agent(tools=[...]); lovia 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¶
A reusable capability often needs Tools, instructions, per-turn reminders,
hooks, and cleanup together. 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. A Plugin does
not own control flow: it can affect a Run through Tools, Guardrails, and Hooks,
while the loop still executes aborts, retries, and handoffs.
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. - Compaction never rewrites the conversation body. Handoff replaces only the system head; persistence maintenance is separate and explicit.
- Plugins contribute; the loop controls. A Guardrail or Hook may request an abort, but only through extension points executed by the loop.
- 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