A lightweight and elegant Agent framework
Project description
lovia
A Python agent framework that stays out of your way.
pip install lovia
# Set once in your environment (or .env):
# OPENAI_BASE_URL=https://api.deepseek.com
# OPENAI_API_KEY=sk-your-key
import asyncio
from lovia import Agent, Runner, tool
@tool
async def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
async def main() -> None:
agent = Agent(
name="calc",
instructions="Answer briefly. Use tools when needed.",
model="deepseek-v4-pro",
tools=[add],
)
result = await Runner.run(agent, "What is 2 + 3?")
print(result.output) # 5
asyncio.run(main())
Why lovia?
The LLM agent space is crowded. lovia makes a specific set of trade-offs:
- 🪶 Minimal concepts — Agent, Runner, tool. The whole mental model fits on one page.
- 🔌 Provider-neutral — OpenAI, Anthropic, any OpenAI-compatible endpoint. Swap with one line.
- 🧩 Extend without subclassing — Protocols and dataclasses throughout. Plug in your own session store, memory backend, or provider without touching framework internals.
- ✂️ Thin by default — Only
httpxandpydanticare required. Web UI, MCP, search, and orchestration stay optional. - 🛡️ Production primitives — Guardrails, approval gates, lifecycle hooks, sandboxed file/shell tools — available when you need them, invisible when you don't.
Agent
Agent is a plain dataclass — no inheritance required:
from lovia import Agent
agent = Agent(
name="writer",
instructions="Write concise, concrete answers.",
model="deepseek-v4-pro",
)
Dynamic system-prompt fragments can be injected at run time:
@agent.system_prompt
async def add_context(ctx) -> str:
return f"User tier: {ctx.context['tier']}"
Need a one-off variant? Clone without mutating the original:
strict = agent.clone(instructions="Always cite sources.", output_type=Report)
Runner
from lovia import Runner
result = await Runner.run(agent, "Draft a release note.")
print(result.output)
Streaming delivers typed events as they arrive:
from lovia import events
handle = Runner.stream(agent, "Tell me a short story.")
async for ev in handle:
if isinstance(ev, events.TextDelta):
print(ev.delta, end="", flush=True)
result = await handle.result()
Sync wrapper for scripts:
result = Runner.run_sync(agent, "Summarize this.")
Tools
Any typed Python function becomes a tool. lovia generates JSON Schema from
type hints, docstrings, and Annotated/Field metadata automatically:
from typing import Annotated
from pydantic import Field
from lovia import tool
@tool
async def fetch_weather(city: str) -> str:
"""Get current weather for a city."""
...
@tool(strict=True)
async def search_docs(
query: Annotated[str, Field(description="Search terms")],
limit: Annotated[int, Field(ge=1, le=10)] = 5,
) -> list[str]:
"""Search internal documentation."""
...
Tool approval
Flag sensitive tools to require explicit sign-off before they run:
@tool(needs_approval=True)
async def delete_record(record_id: str) -> str:
"""Permanently delete a record."""
...
Programmatic approval (e.g. for automated pipelines):
agent = Agent(
...,
approval_handler=lambda call, ctx: call.name != "delete_record",
)
In streaming mode the runner emits ApprovalRequired; your UI resolves it:
async for ev in handle:
if isinstance(ev, events.ApprovalRequired):
ev.approve() # or ev.deny("reason")
Structured output
Pass a Pydantic model to get validated, typed output:
from pydantic import BaseModel
class Summary(BaseModel):
title: str
bullets: list[str]
agent = Agent(
name="summarizer",
model="deepseek-v4-pro",
output_type=Summary,
)
result = await Runner.run(agent, "Summarize lovia in three bullets.")
print(result.output.title)
Override the type per call without changing the agent:
result = await Runner.run(agent, "Give me a JSON summary.", output_type=Summary)
Multi-agent: handoff and composition
Handoff
The triage agent routes requests to specialist agents seamlessly:
from lovia.handoff import Handoff, drop_stale_tool_calls
billing = Agent(name="billing", instructions="Handle billing questions.", model="deepseek-v4-pro")
support = Agent(name="support", instructions="Handle technical issues.", model="deepseek-v4-pro")
triage = Agent(
name="triage",
instructions="Route to the right specialist.",
model="deepseek-v4-pro",
handoffs=[
Handoff(target=billing, input_filter=drop_stale_tool_calls),
Handoff(target=support, input_filter=drop_stale_tool_calls),
],
)
result = await Runner.run(triage, "I was charged twice.")
Agent as tool
Wrap an agent so a parent can delegate sub-tasks to it:
summarizer = Agent(name="summarizer", instructions="Summarize text.", model="deepseek-v4-pro")
orchestrator = Agent(
name="orchestrator",
model="deepseek-v4-pro",
tools=[summarizer.as_tool(description="Summarize a passage of text.")],
)
The sub-agent runs in an isolated loop; its final output is returned as the tool result.
Human in the loop
Approval gates
Set needs_approval=True on any tool. The runner pauses until the call is
approved or denied — by your streaming consumer, a web handler, or the agent's
approval_handler.
Asking the human a question
ask_human lets the model explicitly request input from an operator:
from lovia.tools.human import HumanChannel, ask_human
channel = HumanChannel()
agent = Agent(
name="assistant",
model="deepseek-v4-pro",
tools=[ask_human(channel)],
)
# From your UI or event loop — resolve pending questions:
for q in channel.pending:
channel.answer(q.id, "Please proceed with option A.")
Hooks
AgentHooks fires on lifecycle events — logging, metrics, debugging:
from lovia.hooks import AgentHooks
from lovia import events
hooks = AgentHooks()
@hooks.on(events.ToolCallStarted)
async def log_tool(ev):
print(f"→ {ev.call.name}({ev.call.arguments})")
@hooks.on((events.RunCompleted, events.ErrorOccurred))
def at_end(ev):
print("done:", type(ev).__name__)
agent = Agent(..., hooks=hooks)
Handlers may be sync or async; both work.
Guardrails
Async callables that veto a run before it starts or after it finishes:
from lovia.exceptions import GuardrailTripped
async def no_pii(messages, ctx):
for m in messages:
if "@" in str(m.content):
raise GuardrailTripped("PII detected — email address in input.")
async def must_cite(output, ctx):
if "source:" not in output.lower():
return "Response must include a source citation." # truthy string = violation
agent = Agent(
name="researcher",
model="deepseek-v4-pro",
input_guardrails=[no_pii],
output_guardrails=[must_cite],
)
Returning None or False means the check passed.
Sessions and memory
Persist transcript state across multiple calls:
from lovia.stores import SQLiteSession
session = SQLiteSession("chat.db")
await Runner.run(agent, "My project is called Atlas.", session=session, session_id="u1")
await Runner.run(agent, "What is my project called?", session=session, session_id="u1")
For long-running conversations, a context policy compresses old messages before the model's context window fills up:
from lovia import SummarizingContextPolicy
policy = SummarizingContextPolicy(keep_recent_messages=10)
result = await Runner.run(agent, "Continue.", context_policy=policy)
Skills
File-backed prompt fragments loaded on demand — ideal for large domain knowledge that shouldn't always occupy the context window:
from lovia.skills import SkillCatalog
catalog = SkillCatalog("skills/", mode="lazy") # or mode="eager"
agent = Agent(
name="support",
model="deepseek-v4-pro",
skills=catalog,
)
Each skill is a directory with a SKILL.md (YAML frontmatter + body).
In lazy mode the model calls load_skill(name) when needed; in eager mode
all skill bodies are inlined at startup.
Built-in tools
Practical tools live under lovia.tools — nothing is imported automatically,
pick what you need:
from lovia.tools.http import http_fetch
from lovia.tools.search import duckduckgo_search_tool
from lovia.tools.todo import TodoList, todo_tools
from lovia.tools.human import HumanChannel, ask_human
from lovia.tools.time import now
todos = TodoList()
agent = Agent(
name="assistant",
model="deepseek-v4-pro",
tools=[
http_fetch,
duckduckgo_search_tool(),
*todo_tools(todos),
now,
],
)
Focused examples are in examples/tools/.
Sandbox and coding agent
Attach a sandbox to a coding agent — no need to wire each tool manually:
from lovia import Agent
from lovia.sandbox import Sandbox
agent = Agent(
name="coder",
instructions="Make small, targeted edits.",
model="deepseek-v4-pro",
sandbox=Sandbox.local(".", mode="coding"),
)
| Mode | Tools exposed |
|---|---|
"readonly" |
read_file, list_dir, glob |
"coding" |
read_file, write_file, edit_file, list_dir, glob + shell (approval required) |
"trusted" |
all of the above, shell without approval |
Local sandbox paths are root-relative. Absolute paths, .. escapes, and
symlink escapes are rejected. The local shell still runs as the host user —
this is a convenience boundary, not a hard security sandbox.
Or use the tool factories directly:
from lovia.tools import coding_tools
agent = Agent(
name="coder",
model="deepseek-v4-pro",
tools=coding_tools(root=".", mode="coding"),
)
Web UI
A minimal FastAPI app with streaming, sessions, markdown rendering, and approval:
pip install "lovia[web]"
python examples/16_web_serve.py
from lovia.web import serve
serve(agent, host="127.0.0.1", port=8000, db_path="lovia.db")
Features: SSE streaming · persistent sessions · tool approval via HTTP · safe markdown rendering · Jinja2-rendered no-build UI.
Examples
| File | What it shows |
|---|---|
examples/01_hello.py |
minimal agent |
examples/02_tools.py |
custom @tool |
examples/03_streaming.py |
streaming with Rich |
examples/04_structured_output.py |
Pydantic output |
examples/05_handoff.py |
agent handoff |
examples/08_skills.py |
skill catalog |
examples/11_approval.py |
tool approval |
examples/16_web_serve.py |
web UI |
examples/22_sandbox.py |
direct sandbox session |
examples/23_sandbox_agent.py |
coding agent |
examples/24_prefect.py |
Prefect workflow |
examples/tools/ |
focused tool demos |
examples/workflows/ |
workflow patterns |
Development
pip install -e ".[dev]"
ruff check . # lint
ruff format . # format
mypy lovia # type-check
pytest -q # run tests
Install extras
| Need | Install |
|---|---|
| Core | pip install lovia |
| DuckDuckGo search | pip install "lovia[tools]" |
| MCP integration | pip install "lovia[mcp]" |
| Web UI | pip install "lovia[web]" |
| Prefect workflows | pip install "lovia[prefect]" |
| Run all examples | pip install "lovia[examples,web]" |
| Dev / CI | pip install -e ".[dev]" |
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lovia-0.5.8.tar.gz.
File metadata
- Download URL: lovia-0.5.8.tar.gz
- Upload date:
- Size: 392.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e1ddf0ee03f56aa85d9250f728e4c0e3d461c1092518bf66277636d13e660552
|
|
| MD5 |
501c9880234cbfb8da4cfd868d8983ba
|
|
| BLAKE2b-256 |
c8b943b35fd50aa1d43a81d84fc041f1ef1ba8368a11efb228f9ca9c0ed805dc
|
File details
Details for the file lovia-0.5.8-py3-none-any.whl.
File metadata
- Download URL: lovia-0.5.8-py3-none-any.whl
- Upload date:
- Size: 139.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c3eb8f9381f3369daa6793372b72a1ec7acf8d49d1b59fffd276f6c8862938c6
|
|
| MD5 |
222827b428abca00a93852c070380b64
|
|
| BLAKE2b-256 |
e2bb76805a91ada2c0fc75656c363c1614123ad4af231cb8f56369d5d4a2a188
|