Micro-framework for building A2A agents with LiteLLM — agent loop, tool registry, A2A gateway.
Project description
lughus
Micro-framework for building A2A agents with LiteLLM. No magic — a small, explicit codebase that replaces the orchestration framework layer.
Why
Agent frameworks (LangChain, CrewAI, ADK, …) add layers of abstraction between your code and what's actually happening: node graphs, runners, session services, callbacks, stores. When something breaks, you debug the framework, not your logic.
lughus provides exactly what repeats from one agent to the next — and nothing more:
agent_loop()— iterates LLM + tools until a text response, with parallel tool executionagent_loop_stream()— same, but yields text chunks as the LLM generates themToolRegistry— a@registry.tool()decorator that registers Python functions (sync or async) as LLM toolsBaseGateway— a generic A2AAgentExecutor(message extraction, artifact handling)LLM— a 25-line wrapper aroundlitellm.acompletion()build_app()/serve()— A2A ASGI app construction + uvicorn wiring in one call
You write your tools, your prompts, and your orchestration logic. The framework handles the plumbing.
Install
pip install lughus
Or for development:
pip install -e path/to/lughus/package
Create a new agent
Use the project scaffold when you want a production-ready starting point instead of wiring the same files by hand:
lughus new agent_test
cd agent_test
python -m pip install -e ".[dev]"
pytest -q
python -m agent_test
This creates a full agent package with settings, tools, workspace orchestration, A2A gateway,
custom task store hook, ASGI entrypoint, .env.example, and offline tests using MockLLM.
You can customize the generated metadata in one command:
lughus new agent_test \
--display-name "agent-test" \
--description "Agent test." \
--skill-id greet \
--skill-name Greet
For prompts during generation:
lughus new agent_test --interactive
Quickstart — minimal agent
A complete agent in 4 files:
1. tools.py — declare tools
import json
from dataclasses import dataclass
from lughus import ToolRegistry
registry = ToolRegistry()
@dataclass
class AgentState:
"""Mutable state shared between tools."""
result: str = ""
@registry.tool(
"greet",
"Greet the user by name.",
{
"type": "object",
"properties": {
"name": {"type": "string", "description": "Name to greet."},
},
"required": ["name"],
"additionalProperties": False,
},
)
def greet(*, name: str, state: AgentState) -> str:
state.result = f"Hello {name}!"
return json.dumps({"status": "success", "greeting": state.result})
Every tool receives state=... as a keyword argument (framework convention) plus whatever arguments the LLM provides.
2. workspace.py — orchestrate
from lughus import agent_loop, ProgressEvent, CompletionEvent
from .tools import AgentState, registry
SYSTEM_PROMPT = "You are an assistant. Use the greet tool to greet the user."
class Workspace:
def __init__(self, objective, llm):
self.objective = objective
self.llm = llm
self.state = AgentState()
async def run(self):
yield ProgressEvent("Processing...")
result = await agent_loop(
self.llm,
system=SYSTEM_PROMPT,
context=self.objective,
registry=registry,
tool_names=["greet"],
state=self.state,
)
yield CompletionEvent(text=result)
3. gateway.py — the A2A bridge
from lughus import BaseGateway
from .workspace import Workspace
class MyGateway(BaseGateway):
async def handle(self, objective, files):
ws = Workspace(objective, self.llm)
async for event in ws.run():
yield event
That's it. BaseGateway handles A2A message extraction, artifact serialization, progress updates, and error handling.
4. __main__.py — launch
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
from lughus import LLM, BaseSettings, serve
from .gateway import MyGateway
settings = BaseSettings()
agent_card = AgentCard(
name="my-agent",
version="1.0.0",
url=settings.public_url or f"http://{settings.host}:{settings.port}",
description="My agent.",
default_input_modes=["text/plain"],
default_output_modes=["text/plain"],
skills=[AgentSkill(id="greet", name="Greet", description="Greets the user.", tags=["demo"])],
capabilities=AgentCapabilities(streaming=True),
)
llm = LLM.from_settings(settings)
gateway = MyGateway(llm=llm, settings=settings)
def main():
serve(agent_card, gateway, settings.host, settings.port)
if __name__ == "__main__":
main()
The server starts on :8080 and exposes:
POST /— A2A JSON-RPC endpointGET /.well-known/agent-card.json— agent identity cardGET /healthandGET /healthz— health check ({"status": "ok"})
Architecture
flowchart TD
%% Styling
classDef default fill:#f9f9f9,stroke:#333,stroke-width:1px;
classDef transport fill:#e1f5fe,stroke:#0288d1,stroke-width:2px;
classDef workspace fill:#fff9c4,stroke:#fbc02d,stroke-width:2px;
classDef llm fill:#e8f5e9,stroke:#388e3c,stroke-width:2px;
classDef external fill:#efebe9,stroke:#5d4037,stroke-width:1px;
subgraph Container["Docker Container"]
subgraph Transport["A2A Transport Layer (serve/uvicorn)"]
Gateway["BaseGateway (AgentExecutor)"]
end
subgraph WS["Your Workspace (Orchestration)"]
direction TB
Objective["Objective<br/>(Text + Files)"]
Registry["ToolRegistry<br/>(@tool)"]
Prompt["System Prompt"]
end
subgraph LLMClient["LLM Provider Wrapper"]
LLMWrapper["LLM Class<br/>(litellm.acompletion)"]
end
end
subgraph Provider["LLM Provider (External)"]
ExtAPI["LiteLLM Routing<br/>(OpenAI, Anthropic, Gemini, ...)"]
end
Gateway -->|"1. handle()"| WS
WS -->|"2. agent_loop()"| LLMClient
LLMWrapper -->|3. API Calls| ExtAPI
class Gateway transport;
class Objective,Registry,Prompt workspace;
class LLMWrapper llm;
class ExtAPI external;
Note : Le Workspace et son état associé sont éphémères et détruits immédiatement après l'envoi de la réponse.
Request flow
sequenceDiagram
autonumber
actor Client as "A2A Client"
participant Gateway as BaseGateway
participant WS as Workspace
participant ALoop as "agent_loop / agent_loop_stream"
participant Registry as ToolRegistry
participant LLM as "LLM Wrapper"
Client->>Gateway: A2A Message (text + optional files)
activate Gateway
Note over Gateway: _extract() parses message & decodes files
Gateway->>WS: handle(objective, files)
activate WS
Note over WS: Instantiates AgentState & processes files
WS->>ALoop: Run loop execution
activate ALoop
loop Until text response or max_iterations
ALoop->>LLM: generate(messages, tools)
LLM-->>ALoop: Tool Calls / Text Response
alt LLM requests tool execution
ALoop->>Registry: Execute tool call(s) in parallel
Registry-->>ALoop: Tool results
else LLM responds with final text
Note over ALoop: Loop ends
end
end
ALoop-->>WS: Return LoopResult (Final text + metadata)
deactivate ALoop
WS-->>Gateway: Yield CompletionEvent(text, artifacts)
deactivate WS
Note over Gateway: Serializes artifacts as base64 FileParts
Gateway->>Client: Send JSON-RPC response
deactivate Gateway
Note over WS: Workspace GC'd (Ephemeral lifecycle)
API reference
BaseSettings
from lughus import BaseSettings
@dataclass(frozen=True)
class Settings(BaseSettings):
# Inherited fields:
# model: str — "" (env AGENT_MODEL, required)
# max_output_tokens: int — 16384 (env MAX_OUTPUT_TOKENS)
# host: str — "0.0.0.0" (env HOST)
# port: int — 8080 (env PORT)
# public_url: str — "" (env PUBLIC_URL)
# log_level: str — "INFO" (env LOG_LEVEL)
# environment: str — "development" (env LUGHUS_ENV)
# enable_test_ui: bool — False (env ENABLE_TEST_UI)
# api_bearer_token: str — "" (env API_BEARER_TOKEN)
# max_file_bytes: int — 25 MB (env MAX_FILE_BYTES)
# max_http_body_bytes — 80 MB (env MAX_HTTP_BODY_BYTES)
# max_objective_chars — 100000 (env MAX_OBJECTIVE_CHARS)
# max_artifacts: int — 10 (env MAX_ARTIFACTS)
# max_artifact_bytes — 50 MB (env MAX_ARTIFACT_BYTES)
# max_total_artifact_bytes — 100 MB (env MAX_TOTAL_ARTIFACT_BYTES)
# max_concurrent_requests — 0/off (env MAX_CONCURRENT_REQUESTS)
# max_queue_backlog — 0 (env MAX_QUEUE_BACKLOG)
# request_queue_timeout — 5.0s (env REQUEST_QUEUE_TIMEOUT)
# tool_queue_timeout — 30.0s (env TOOL_QUEUE_TIMEOUT)
# max_source_chars: int — 12000 (env MAX_SOURCE_CHARS)
# Your agent-specific fields:
my_setting: str = os.getenv("MY_SETTING", "default")
No side effects at import. No .env loading — that's your agent's responsibility.
Note —
max_source_charsis a convention field: the framework declares it inBaseSettingsfor consistency across agents, but does not enforce it automatically. Your workspace is responsible for applying this limit when extracting text from documents (e.g., truncating PDF content before passing it to the LLM).
LLM
from lughus import LLM
llm = LLM(model="openai/gpt-4o", max_output_tokens=16384)
# Direct call (rarely needed — agent_loop does this for you)
response = await llm.generate(
messages=[{"role": "user", "content": "Hello"}],
tools=[...], # optional, OpenAI format
)
Thin wrapper around litellm.acompletion(). Supports all LiteLLM providers: OpenAI, Azure OpenAI, Anthropic, Google, Bedrock, Ollama, and 100+ more. Change the model via the AGENT_MODEL env var — no code change needed.
The model parameter is required — passing an empty string raises a clear ValueError with instructions. This catches misconfiguration at startup rather than on the first LLM call.
Methods:
generate(messages=..., tools=...)→ModelResponse(non-streaming)astream(messages=..., tools=...)→ async iterable of chunks (streaming, withstream_optionsfallback for providers that don't support usage reporting)
Retry on transient errors
LLM automatically retries on transient errors (like RateLimitError (429), ServiceUnavailableError (503), APIConnectionError, and LLM call timeouts) with exponential backoff.
You can configure retry behavior through BaseSettings and pass it directly:
llm = LLM.from_settings(settings)
Set max_retries=0 to disable retries. Set retry_base_delay=0.0 in test environments.
For streaming, Lughus retries both stream creation and chunk consumption. Since text chunks are buffered per iteration and only yielded to the client once the generation is complete, Lughus can safely retry the entire stream generation iteration if a transient connection error occurs midway.
ToolRegistry
from lughus import ToolRegistry
registry = ToolRegistry()
# Sync tool
@registry.tool("my_tool", "Description for the LLM.", {
"type": "object",
"properties": {
"param1": {"type": "string", "description": "..."},
},
"required": ["param1"],
"additionalProperties": False,
})
def my_tool(*, param1: str, state) -> str:
return {"status": "success", "result": "..."}
# Async tool — same decorator, just use async def
@registry.tool("fetch_data", "Fetch data from an external API.", {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL to fetch."},
},
"required": ["url"],
"additionalProperties": False,
})
async def fetch_data(*, url: str, state) -> str:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
data = await resp.text()
return {"status": "success", "data": data[:1000]}
Isolated per instance — each agent creates its own ToolRegistry(). No pollution when multiple agents run in the same process.
Both sync and async tools are supported. The framework detects async functions automatically via inspect.iscoroutinefunction(). Tool names must be unique, and each tool must accept state=... as a keyword argument. Tools may return strings or JSON-serializable values; non-string values are serialized before being sent back to the LLM.
Sync vs async tools
Both def and async def tools are supported transparently.
Sync tools are offloaded to a background runner, so short blocking work does not
freeze the event loop. For network calls, database queries, subprocesses, or any
operation that may hang past TOOL_TIMEOUT, prefer async def tools or set
timeouts in the underlying client: Python cannot forcibly stop a sync thread
that is already blocked in external I/O.
# Sync tool with short blocking I/O — runs outside the event loop
@registry.tool("read_file", "Read a local file.", {...})
def read_file(*, path: str, state) -> str:
with open(path) as f:
return json.dumps({"content": f.read()})
# Async tool — use when you want to await inside
@registry.tool("fetch_url", "Fetch a URL.", {...})
async def fetch_url(*, url: str, state) -> str:
async with aiohttp.ClientSession() as s:
async with s.get(url) as r:
return json.dumps({"body": await r.text()})
Methods:
registry.declarations(["tool1", "tool2"])→ list of OpenAI-format dictsregistry.get_fn("tool1")→ the Python callable
agent_loop()
from lughus import ToolExecutionConfig, agent_loop
result = await agent_loop(
llm, # LLM instance
system="You are an assistant...", # system prompt
context="The user request", # first user message
registry=registry, # tool registry
tool_names=["tool1", "tool2"], # accessible tools
state=my_state, # optional state object (passed to tools)
max_iterations=50, # safety limit (default: 50)
tool_config=ToolExecutionConfig(
max_parallel_tools=8, # per-iteration tool concurrency
max_global_tools=64, # worker-local tool concurrency
max_sync_thread_workers=32, # worker pool for sync tools
tool_timeout=120.0, # per-tool timeout in seconds
max_tool_args_chars=20_000, # raw tool-call arguments limit
max_tool_output_chars=20_000, # tool response size limit
max_message_history_chars=200_000, # accumulated message history limit
),
)
# result is a str — the LLM's final text response
This is the entire orchestration framework. The loop:
- Sends
system+context+ tool declarations to the LLM - If the LLM requests tool(s) → executes them with bounded parallelism, appends results
- If the LLM responds with text → returns the response
- Repeats until text response or
max_iterations
When the LLM requests multiple tool calls in a single response, they run concurrently up to ToolExecutionConfig.max_parallel_tools and also respect the worker-local max_global_tools limit. Tool schemas are validated at registration time, LLM-provided arguments are validated before execution, and tool failures are returned as structured JSON with an error_type. Unknown names in tool_names raise ToolValidationError before the first LLM call so misconfigured agents fail early.
Call agent_loop() as many times as needed in your workspace — once per "phase" (planning, execution, verification, …), with different prompts and tools.
Usage metadata (LoopResult)
agent_loop() returns a LoopResult — a str subclass with attached metrics. It behaves exactly like a regular string (pass it to CompletionEvent(text=...), json.loads(), etc.), but also exposes:
result = await agent_loop(...)
result.iterations # number of LLM round-trips
result.elapsed # wall-clock seconds
result.prompt_tokens # total input tokens across all iterations
result.completion_tokens # total output tokens across all iterations
result.total_tokens # prompt_tokens + completion_tokens
result.cached_tokens # prompt cache hits (OpenAI, Anthropic, etc.)
result.uncached_prompt_tokens # prompt_tokens - cached_tokens
These are also recorded as OpenTelemetry span attributes on the agent_loop span.
Token cost and tool schemas
agent_loop() sends the selected tool declarations on every LLM round-trip, along with the system prompt, user context, and accumulated tool-call history. This can make prompt_tokens look high for agents with many tools, but providers with prompt caching may bill cached input tokens at a lower rate. When comparing approaches, look at uncached_prompt_tokens + completion_tokens, not only total_tokens.
For agents with many verbose tools, ToolExecutionConfig(compact_tool_schemas=True) strips JSON Schema parameter descriptions from tool declarations while keeping names, types, enums, and required fields. This reduces repeated prompt size. A dynamic "tool registry query" pattern can reduce raw prompt even further, but it often adds extra LLM round-trips and may reduce prefix-cache stability. The trade-off is intentional: if your LLM provider does not support prompt caching, or if cache hit rates are low, you should explicitly consider a dynamic registry or tool-shortlisting phase for large tool surfaces.
LoopResult survives copy.copy(), copy.deepcopy(), and pickle round-trips. Note that str(result) returns a plain str without metadata — this is standard Python behavior for str subclasses.
agent_loop_stream()
Streaming variant of agent_loop() — same signature, but yields text chunks as the LLM generates them:
from lughus import agent_loop_stream, LoopResult
async for chunk in agent_loop_stream(
llm,
system="You are an assistant...",
context="The user request",
registry=registry,
tool_names=["tool1", "tool2"],
state=my_state,
):
if isinstance(chunk, LoopResult):
# Last yield — carries usage metadata (.iterations, .elapsed, etc.)
final_text = chunk
else:
# Live text chunk from the LLM
print(chunk, end="", flush=True)
During tool-calling iterations, text chunks (if any) are yielded as progress. On the final text response, chunks stream live. The last yielded value is always a LoopResult.
Streaming in a workspace
from lughus import agent_loop_stream, LoopResult, ProgressEvent, CompletionEvent
class Workspace:
async def run(self):
result = None
async for chunk in agent_loop_stream(self.llm, system=..., ...):
if isinstance(chunk, LoopResult):
result = chunk
else:
yield ProgressEvent(chunk)
yield CompletionEvent(text=result, artifacts=[...])
Each text chunk becomes an A2A progress update — the user sees the response appear as the LLM types.
BaseGateway
from lughus import BaseGateway, ProgressEvent, CompletionEvent, Artifact
class MyGateway(BaseGateway):
# self.llm and self.settings are available (passed to constructor)
async def handle(self, objective, files):
# objective: str — the A2A message text
# files: list[(bytes, mime_type, filename)]
yield ProgressEvent("Step 1...")
# ... your logic ...
yield CompletionEvent(
text="Done!",
artifacts=[
Artifact(data=pdf_bytes, mime_type="application/pdf", name="report.pdf"),
],
)
BaseGateway handles:
- Extraction of the A2A message (
_extract()): text + binary files (base64 decoded) - Progress: each
ProgressEventupdates the A2A task status - Artifacts: each
ArtifactinCompletionEventis base64-encoded and sent as an A2AFilePart - Errors: exceptions are caught and returned as A2A errors
File naming protocol
When an A2A client sends a file without a meaningful name (e.g. a generic upload ID),
it can precede the FilePart with a TextPart containing:
__ORIGINAL_FILENAME__:my_document.xlsx
BaseGateway._extract() intercepts this prefix and uses it as the file name in the
files list. This TextPart is not added to the objective text.
Uploaded filenames are sanitized to their basename before they reach your agent code:
../../secret.txt becomes secret.txt.
This is a client-side convention — your agent does not need to handle it explicitly.
build_app() / serve()
from lughus import build_app, serve
serve(agent_card, gateway, host="0.0.0.0", port=8080, log_level="INFO")
# Or integrate the ASGI app yourself:
app = build_app(agent_card, gateway, task_store=my_task_store)
build_app() wires AgentCard + BaseGateway + DefaultRequestHandler + A2AStarletteApplication and returns a Starlette-compatible ASGI app. serve() is the quickstart wrapper that calls build_app() and starts uvicorn. Both add /health and /healthz endpoints for container orchestrators (Cloud Run, Kubernetes, etc.).
Local Test UI
Enable the minimal browser UI for local agent runs:
export ENABLE_TEST_UI=true
python -m my_agent
Then open http://localhost:8080/ui. The UI sends an objective and optional files to
gateway.handle(), streams progress/tool/completion events live over /ui/stream,
traces tool calls with arguments, duration, status, and output, and exposes returned artifacts as downloads.
[!WARNING] La console de test locale est strictement conçue comme un outil de développement et de débogage rapide. Elle ne doit en aucun cas être exposée en production ni être utilisée comme une interface utilisateur client finale. C'est pourquoi elle est désactivée par défaut et bloquée par validation stricte si
LUGHUS_ENV=production.
Custom Task Store & OpenTelemetry Control
You can customize serve() behavior using optional parameters:
from lughus import BoundedInMemoryTaskStore, serve
serve(
agent_card,
gateway,
host="0.0.0.0",
port=8080,
task_store=BoundedInMemoryTaskStore(), # Or inject a persistent Redis/SQL TaskStore
setup_otel=False, # Skip automatic OTel configuration if already configured
enable_test_ui=False, # Keep the browser test UI disabled in production
)
Note — production storage:
serve()uses a bounded in-process store by default (TASK_STORE_TTL_SECONDS,TASK_STORE_MAX_TASKS). For multiple replicas or durable status lookup, inject a persistentTaskStoresuch as Redis or SQL.
Events
from lughus import ProgressEvent, CompletionEvent, Artifact
# Progress (shown as A2A "working" status)
yield ProgressEvent("Planning...")
# Completion (shown as A2A "completed" status + artifacts)
yield CompletionEvent(
text="3 slides created.",
artifacts=[
Artifact(data=pptx_bytes, mime_type="application/...", name="deck.pptx"),
],
)
Errors
Public framework errors are exported from the top-level package:
from lughus import (
LoopLimitError,
ToolExecutionError,
ToolTimeoutError,
ToolValidationError,
)
Tool-call failures are returned to the LLM as structured JSON, for example:
{"error": "Invalid arguments for tool 'search': 'query' is a required property", "error_type": "ToolValidationError"}
Testing
lughus provides a dedicated lughus.testing module containing mocks to test your agents and workspaces offline without calling real LLM providers.
To use them:
from lughus.testing import MockLLM, MockStreamingLLM
from lughus import ToolRegistry, agent_loop
async def test_my_agent():
# Pass a list of simulated responses (either str for text, or lists of dict for tool calls)
llm = MockLLM([
[{"id": "call_1", "name": "greet", "arguments": {"name": "Test"}}],
"Done.",
])
registry = ToolRegistry()
@registry.tool("greet", "Greet.", {"type": "object", "properties": {}})
def greet(*, name: str, state) -> str:
return f"Hello {name}!"
result = await agent_loop(
llm,
system=".",
context="Say hello",
registry=registry,
tool_names=["greet"],
state=None,
)
assert result == "Done."
assert len(llm.calls) == 2 # Verify two LLM roundtrips occurred
Production Checklist
Before running your agent in a production environment, review the following configuration items:
1. Robustness & Retries
- Transient Errors:
LLMautomatically retries onRateLimitError,ServiceUnavailableError,APIConnectionError, and call timeouts. - Configuration:
LLM_MAX_RETRIES(default3): Max retry attempts.LLM_RETRY_BASE_DELAY(default1.0s): Base delay for exponential backoff with jitter.
2. Task Store & Memory
- The default
BoundedInMemoryTaskStoreevicts tasks by TTL and maximum count. - For horizontally scaled deployments, inject a persistent
TaskStore(e.g., Redis or SQL) via thetask_storeparameter ofserve().
2b. Public Surface
- Set
PUBLIC_URLto the externally reachable agent URL; otherwise scaffolds fall back tohttp://HOST:PORT. - Set
API_BEARER_TOKENto requireAuthorization: Bearer ...on non-health routes. - Use
MAX_HTTP_BODY_BYTES,MAX_OBJECTIVE_CHARS,MAX_ARTIFACTS,MAX_ARTIFACT_BYTES, andMAX_TOTAL_ARTIFACT_BYTESto bound request and response payloads before exposing an agent publicly. - Set
MAX_CONCURRENT_REQUESTSto add per-worker backpressure; at mostMAX_QUEUE_BACKLOGadditional requests wait, and requests that wait longer thanREQUEST_QUEUE_TIMEOUTreceive503. - Set
LUGHUS_ENV=productionto fail fast unlessPUBLIC_URL,API_BEARER_TOKEN, and a custom persistenttask_storeare configured and the test UI is disabled.
3. Timeouts
- LLM Timeout:
LLM_TIMEOUT(default120.0s) limits individual LLM requests. - Agent Timeout:
AGENT_TIMEOUT(default600.0s) limits the entire duration ofBaseGateway.execute(), preventing hung tasks from consuming workers indefinitely.
4. Telemetry
- Configure
OTEL_EXPORTER_OTLP_ENDPOINTto export traces and metrics to your OpenTelemetry collector. - Monitor the
lughus.tool.errorscounter to detect failing tools, andlughus.loop.durationto track performance.
Configuration
Environment variables
| Variable | Default | Description |
|---|---|---|
AGENT_MODEL |
(required) | LiteLLM model string (e.g. openai/gpt-4o, azure/gpt-4o, anthropic/claude-sonnet-4-20250514) |
MAX_OUTPUT_TOKENS |
16384 |
Max output tokens |
HOST |
0.0.0.0 |
Listen interface |
PORT |
8080 |
Listen port |
PUBLIC_URL |
(not set) | External AgentCard URL. If unset, generated agents use http://HOST:PORT. |
LOG_LEVEL |
INFO |
Python log level (DEBUG, INFO, WARNING, ERROR) |
LUGHUS_ENV |
development |
Set to production to enable strict startup validation. |
API_BEARER_TOKEN |
(not set) | Optional bearer token required on non-health HTTP routes. Supports comma-separated list of multiple tokens (e.g. token1,token2) for key rotation. |
CORS_ORIGINS |
(not set) | Optional comma-separated list of allowed CORS origins (e.g. http://example.com,https://test.com) or *. |
LUGHUS_TELEMETRY_CONSOLE |
(not set) | Set to true to enable Console OTel exporter if OTEL_EXPORTER_OTLP_ENDPOINT is unset. |
OTEL_EXPORTER_OTLP_ENDPOINT |
(not set) | OTLP gRPC endpoint (e.g. http://localhost:4317). No exporter (disabled) if unset unless LUGHUS_TELEMETRY_CONSOLE is enabled. |
MAX_HTTP_BODY_BYTES |
83886080 (80 MB) |
Max HTTP body bytes accepted before A2A parsing, including streamed/chunked bodies. |
MAX_CONCURRENT_REQUESTS |
0 |
Max active HTTP requests in one ASGI app instance. Set to 0 to disable. |
MAX_QUEUE_BACKLOG |
0 |
Max extra HTTP requests allowed to wait for a concurrency slot. |
REQUEST_QUEUE_TIMEOUT |
5.0 |
Seconds a request waits for a concurrency slot before receiving 503. Set to 0 to fail immediately. |
MAX_FILE_BYTES |
26214400 (25 MB) |
Max size per uploaded file |
MAX_FILES |
10 |
Max number of uploaded files accepted per request |
MAX_REQUEST_BYTES |
52428800 (50 MB) |
Max total decoded file bytes accepted per request |
MAX_OBJECTIVE_CHARS |
100000 |
Max extracted objective text length. |
MAX_SOURCE_CHARS |
12000 |
Max chars extracted per source document |
MAX_ARTIFACTS |
10 |
Max artifacts accepted in one CompletionEvent. |
MAX_ARTIFACT_BYTES |
52428800 (50 MB) |
Max bytes per returned artifact before base64 encoding. |
MAX_TOTAL_ARTIFACT_BYTES |
104857600 (100 MB) |
Max total bytes across all artifacts in one completion before base64 encoding. |
LLM_TIMEOUT |
120.0 |
LLM call timeout in seconds. Set to 0 to disable. |
LLM_MAX_RETRIES |
3 |
Max retries on transient LLM errors. Set to 0 to disable. |
LLM_RETRY_BASE_DELAY |
1.0 |
Base delay in seconds for exponential backoff between retries. |
LLM_RETRY_MAX_ELAPSED |
0.0 |
Total retry sleep budget in seconds, shared across one agent_loop() invocation. Set to 0 to disable. |
AGENT_TIMEOUT |
600.0 |
Total agent timeout in seconds (entire handle() call). Set to 0 to disable. |
MAX_PARALLEL_TOOLS |
8 |
Max tool calls executed concurrently within one loop iteration. |
MAX_GLOBAL_TOOLS |
64 |
Max tool calls executed concurrently in one event loop / worker process. |
MAX_SYNC_THREAD_WORKERS |
32 |
Max worker threads used for synchronous tools and framework blocking work. |
TOOL_TIMEOUT |
120.0 |
Per-tool timeout in seconds. Set to 0 to disable. |
TOOL_QUEUE_TIMEOUT |
30.0 |
Seconds a tool waits for a worker-local global tool slot before returning a structured timeout error. Set to 0 to fail immediately. |
MAX_TOOL_ARGS_CHARS |
20000 |
Max raw JSON argument characters accepted for one tool call. |
MAX_TOOL_OUTPUT_CHARS |
20000 |
Max characters accepted in one tool response before returning a structured error. |
MAX_MESSAGE_HISTORY_CHARS |
200000 |
Max serialized LLM message history characters before the loop stops. Set to 0 to disable. |
COMPACT_TOOL_SCHEMAS |
false |
Strip JSON Schema parameter descriptions from tool declarations to reduce repeated prompt tokens. |
Provider examples
# OpenAI
export AGENT_MODEL="openai/gpt-4o"
export OPENAI_API_KEY="sk-..."
# Azure OpenAI
export AGENT_MODEL="azure/gpt-4o"
export AZURE_API_KEY="..."
export AZURE_API_BASE="https://my-resource.openai.azure.com"
export AZURE_API_VERSION="2025-04-01-preview"
# Anthropic
export AGENT_MODEL="anthropic/claude-sonnet-4-20250514"
export ANTHROPIC_API_KEY="sk-ant-..."
# Google Gemini
export AGENT_MODEL="gemini/gemini-2.5-flash"
export GEMINI_API_KEY="..."
# Ollama (local)
export AGENT_MODEL="ollama/llama3.1"
No code change needed — LiteLLM routes automatically. See LiteLLM providers for the full list.
Package structure
lughus/
__init__.py # Public API re-exports + __version__
config.py # BaseSettings dataclass
llm.py # LLM — litellm.acompletion() wrapper
loop/ # Modular agent loop sub-package
tools.py # ToolRegistry + ToolDef
gateway.py # BaseGateway(AgentExecutor)
events.py # ProgressEvent, CompletionEvent, Artifact
server.py # build_app() + serve() — A2A + uvicorn
ui_server.py # Test UI routes template handler
telemetry.py # OpenTelemetry setup (traces + metrics)
py.typed # PEP 561 marker
14 source files, ~1000 lines total. You can read the entire framework in 15 minutes.
Creating a new agent
See examples/minimal_agent/ for a complete runnable example with:
build_app()ASGI integration- custom placeholder
TaskStore - production-style
ToolExecutionConfig - offline tests using
MockLLM
See examples/excel_agent/ for a richer workbook automation example with:
- batched Excel tools (
edit,manage_sheets) - request-scoped workbook state, snapshots, and undo
.xlsxA2A artifacts- optional PDF/DOCX/text source extraction
my_agent/
__init__.py
__main__.py # AgentCard + serve() → python -m my_agent
config.py # class Settings(BaseSettings): ...
gateway.py # class MyGateway(BaseGateway): handle()
workspace.py # Your orchestration + agent_loop()
tools.py # registry = ToolRegistry(); @registry.tool(...)
prompt.py # Your system prompts
requirements.txt # lughus + your deps
Checklist
tools.py— Create aToolRegistry(), define yourAgentState, register tools with@registry.tool()prompt.py— Write your system prompts (one per phase if multi-phase)workspace.py— Implementrun()as anAsyncIteratorthat yieldsProgressEvent/CompletionEvent, callingagent_loop()for each phasegateway.py— SubclassBaseGateway, implementhandle()(3 lines: create workspace, yield its events)config.py— SubclassBaseSettingsif you have custom fields__main__.py— Define theAgentCard, instantiateLLM+ gateway, callserve()
Docker
FROM python:3.13-slim
WORKDIR /app
COPY my_agent/ ./my_agent/
RUN pip install --no-cache-dir -r my_agent/requirements.txt
EXPOSE 8080
HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"
CMD ["python", "-m", "my_agent"]
Session independence
Each A2A request is fully isolated:
-
No shared state. Your
handle()method creates a fresh workspace (state, tools, context) for every request. Nothing leaks between requests — no class-level caches, no global dicts, no session stores. -
Ephemeral lifecycle. The workspace lives for the duration of the request. When
handle()returns (or raises), the workspace and all its data are garbage-collected. Files, intermediate results, LLM message history — everything is destroyed. -
Request-local gateway state.
BaseGatewaykeeps only configuration plus an in-process map of running task IDs so local cancellation can stop active coroutines. Your workspace state remains request-local.
Request A ──→ Workspace A (state, tools, messages) ──→ Response A ──→ GC'd
Request B ──→ Workspace B (state, tools, messages) ──→ Response B ──→ GC'd
↑ no shared state ↑
This means:
- Horizontal scaling is straightforward for stateless requests — run N replicas and inject a persistent
TaskStorefor durable task status. - No memory leaks — each request cleans up after itself.
- No concurrency bugs — no shared mutable state between requests.
If you need multi-turn sessions, add a persistent session store with TTL cleanup. A local dict[session_id, state] is fine for development, but it is not enough for multiple replicas.
Observability (OpenTelemetry)
lughus instruments every request with native OpenTelemetry traces and metrics. No logging calls — everything flows through OTel spans and counters.
Traces
Every A2A request produces a trace with the following span hierarchy:
a2a.request ← root span (gateway)
└─ agent_loop ← one per agent_loop() call
├─ llm.generate ← one per LLM round-trip
├─ tool.list_layouts ← one per tool execution
├─ llm.generate
├─ tool.build
└─ llm.generate ← final text response
Span attributes
| Span | Attributes |
|---|---|
a2a.request |
lughus.objective_len, lughus.file_count, lughus.status |
agent_loop |
gen_ai.request.model, lughus.max_iterations, lughus.iterations, lughus.elapsed_s, gen_ai.usage.* |
llm.generate |
gen_ai.request.model, lughus.iteration, gen_ai.usage.prompt_tokens, gen_ai.usage.completion_tokens, gen_ai.usage.cached_tokens |
tool.{name} |
tool.name |
Metrics
| Metric | Type | Description |
|---|---|---|
lughus.loop.tokens |
Counter | LLM tokens consumed (attributes: token.type, gen_ai.request.model) |
lughus.loop.duration |
Histogram | Agent loop wall-clock duration in seconds |
lughus.tool.errors |
Counter | Tool execution errors (attribute: tool.name) |
Exporters
The exporter is chosen automatically at startup:
OTEL_EXPORTER_OTLP_ENDPOINT |
Behavior |
|---|---|
Set (e.g. http://localhost:4317) |
OTLP gRPC export to the collector (Jaeger, Grafana Tempo, etc.) |
| Not set | Console exporter (spans printed to stdout — useful in dev) |
Setup
serve() calls setup_telemetry() automatically. For manual setup (tests, scripts):
from lughus import setup_telemetry
setup_telemetry("my-agent")
Accessing metrics programmatically
agent_loop() returns a LoopResult (a str subclass) with attached metrics:
result = await agent_loop(llm, system=..., context=..., registry=registry,
tool_names=["my_tool"], state=state)
print(f"Took {result.elapsed:.1f}s, {result.total_tokens} tokens")
# Took 6.8s, 3412 tokens
Quick start with Jaeger
# Start Jaeger (OTLP on 4317, UI on 16686)
docker run -d -p 16686:16686 -p 4317:4317 jaegertracing/all-in-one
# Point the agent at it
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
python -m my_agent
# → traces visible at http://localhost:16686
Conventions
- Tools: every function receives
state=...as a keyword argument. The type ofstateis up to you — it's a dataclass defined by the agent. - Tool return values: JSON-stringified (
json.dumps({...})). The LLM reads this JSON to decide what to do next. - Events:
ProgressEventfor intermediate status,CompletionEventfor the end.Artifactobjects inCompletionEventare serialized as base64 A2AFileParts. - No persistence: each request creates an ephemeral workspace, destroyed after response. If you need multi-turn, add a session cache in your gateway.
Philosophy
-
The visible code is the real code. No metaclasses, no magic decorators that rewrite your logic, no implicit graphs.
agent_loop()is aforloop — openloop/and read it. -
The minimum that repeats, and nothing more. The framework does what's identical across agents: call the LLM, execute tools, extract A2A messages, serialize artifacts. Your orchestration logic (which phases, in what order, with which prompts) stays in your code.
-
Switch providers without changing code. LiteLLM routes to 100+ providers. Your agent is written once, deployed anywhere.
-
No persistence by default. A workspace lives for the duration of a request. If you need sessions, add them — it's your code, not a framework plugin.
License
MIT — see LICENSE.
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 lughus-0.1.0.tar.gz.
File metadata
- Download URL: lughus-0.1.0.tar.gz
- Upload date:
- Size: 107.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux","version":"20260607.0.541780","id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bf3020509cae5f3fc3b538dbaa6d261f7090508d8a498d8215981dfb931a19c2
|
|
| MD5 |
17a657842b06ad99a936e2536acef12c
|
|
| BLAKE2b-256 |
ef87376f20f116e5ffbce4c137ce42a4215e6ddcba5698e63c46e08b1eb69a4c
|
File details
Details for the file lughus-0.1.0-py3-none-any.whl.
File metadata
- Download URL: lughus-0.1.0-py3-none-any.whl
- Upload date:
- Size: 65.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux","version":"20260607.0.541780","id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
243ae464873cffb38d27d680c4d2d8e0463c3a66816efd23cb4f0b98b5f919f0
|
|
| MD5 |
45073163e1a8ee035c343002090a7a42
|
|
| BLAKE2b-256 |
3b8cc633f0719a417b4c37cc5d3536228c663d3b059c75148abf6ec063ffa282
|