Project Structure¶
Understanding the Flux Agents project layout and architecture.
Package Layout¶
Flux Agents is organized into focused modules, each handling a specific concern:
flux/
├── __init__.py # Public API — all re-exports for convenient imports
├── agent.py # Agent and AgentSettings dataclasses
├── runner.py # Runner execution engine (run, run_sync, run_streamed)
├── context.py # RunContext, ToolContext, Usage tracking
├── config.py # FluxConfig global configuration
├── exceptions.py # Exception hierarchy
├── cli.py # CLI entry point (flux / flux-agents commands)
│
├── models/ # LLM provider implementations
│ ├── base.py # Model protocol, Message, ModelRequest, ModelResponse, ModelSettings
│ ├── registry.py # ModelRegistry — resolve model names to providers
│ ├── ollama.py # OllamaModel — local models via Ollama
│ ├── openai_provider.py # OpenAIModel — OpenAI, OpenRouter, DeepSeek, Groq
│ └── anthropic.py # AnthropicModel — Claude models
│
├── tools/ # Tool system
│ ├── base.py # Tool protocol, ToolResult
│ ├── decorator.py # @tool decorator, FunctionTool
│ ├── registry.py # ToolRegistry for managing tool collections
│ ├── schema.py # JSON Schema generation from function signatures
│ └── builtins.py # ShellTool, FileReadTool, FileWriteTool
│
├── handoffs/ # Agent-to-agent routing
│ ├── handoff.py # Handoff and HandoffData dataclasses
│ └── router.py # Handoff lookup utilities
│
├── guardrails/ # Input and output validation
│ ├── base.py # InputGuardrail, OutputGuardrail base classes
│ └── builtins.py # LengthGuardrail, ProfanityGuardrail, PIIGuardrail
│
├── sessions/ # Conversation persistence
│ ├── base.py # Session protocol
│ ├── in_memory.py # InMemorySession — transient storage
│ └── sqlite.py # SQLiteSession — persistent storage
│
├── memory/ # Long-term memory
│ ├── base.py # Memory protocol, MemoryEntry
│ ├── conversation.py # ConversationMemory — wraps a Session for search
│ └── vector.py # VectorMemory — hash-based similarity search
│
├── streaming/ # Real-time output
│ └── events.py # TextDeltaEvent, ToolCallEvent, MessageCompleteEvent, etc.
│
├── middleware/ # Composable request/response pipeline
│ ├── base.py # Middleware protocol, RequestContext, Response
│ ├── logging.py # LoggingMiddleware
│ ├── rate_limit.py # RateLimitMiddleware (token bucket)
│ ├── cache.py # CacheMiddleware (hash-based, TTL)
│ └── retry.py # RetryMiddleware (exponential backoff)
│
├── events/ # Decoupled event bus
│ └── bus.py # EventBus, Event, type constants (AGENT_START, LLM_START, etc.)
│
├── tracing/ # Observability
│ ├── base.py # Span, Tracer protocols
│ ├── console.py # ConsoleTracer — prints spans to stderr
│ └── file.py # FileTracer — writes JSON lines to file
│
└── utils/ # Shared utilities
├── schema.py # function_to_schema — JSON Schema from Python signatures
├── tokens.py # count_tokens, truncate_to_tokens (tiktoken-based)
└── pretty.py # Pretty-printing for results, errors, and stream deltas
Module Relationships¶
flowchart TB
subgraph Core
Runner
Agent
Config[FluxConfig]
Context[RunContext / ToolContext]
end
subgraph Providers
Models[models/]
Ollama[OllamaModel]
OpenAI[OpenAIModel]
Anthropic[AnthropicModel]
Registry[ModelRegistry]
end
subgraph Features
Tools[tools/]
Handoffs[handoffs/]
Guardrails[guardrails/]
Sessions[sessions/]
Memory[memory/]
Streaming[streaming/]
end
subgraph Infrastructure
Middleware[middleware/]
EventBus[events/]
Tracing[tracing/]
Utils[utils/]
end
Runner --> Agent
Runner --> Config
Runner --> Context
Runner --> Models
Runner --> Tools
Runner --> Handoffs
Runner --> Guardrails
Runner --> Sessions
Runner --> Streaming
Runner --> EventBus
Agent --> Tools
Agent --> Handoffs
Agent --> Guardrails
Agent --> Models
Models --> Registry
Models --> Ollama
Models --> OpenAI
Models --> Anthropic
Tools --> Context
Tools --> Utils
Tracing -.-> Runner
Middleware -.-> Runner
Memory --> Sessions
Key Design Principles¶
1. Immutable Core Types¶
The Agent dataclass is frozen=True. Once created, it cannot be modified. Use agent.clone(**kwargs) to create modified copies. This ensures thread safety and predictable behavior:
# Agents are immutable
agent = Agent(name="bot", instructions="Be helpful.")
# Create modified copies -- never mutate
concise_bot = agent.clone(name="concise_bot", instructions="Be brief.")
2. Async-First¶
The entire framework is built on async/await. Every model provider, tool, guardrail, session, and memory store uses async interfaces. Synchronous wrappers (Runner.run_sync()) are provided for convenience, but the async path is the primary design:
# Async (primary)
result = await Runner.run(agent, input)
# Sync (convenience wrapper)
result = Runner.run_sync(agent, input)
3. Protocol-Based Extensibility¶
Flux uses Python's Protocol class (structural subtyping) instead of abstract base classes. This means you can implement any interface without inheriting from a base class -- you just need to match the method signatures:
# You don't need to subclass Model -- just implement the protocol
class MyCustomModel:
async def complete(self, request: ModelRequest) -> ModelResponse:
...
async def stream(self, request: ModelRequest) -> AsyncIterator[StreamChunk]:
...
yield
The same applies to Tool, Session, Memory, Middleware, Tracer, and Span.
4. Zero-Required-Dependencies¶
The core package has no third-party dependencies. Model providers are optional extras (aiohttp for Ollama, openai for OpenAI, anthropic for Anthropic). This keeps the dependency footprint minimal and lets you choose only what you need.
5. Composable Middleware¶
The middleware system follows the classic onion/wrapper pattern. Each middleware receives a request context and a next function, allowing it to inspect, modify, or short-circuit the request:
class MyMiddleware:
async def process(self, ctx: RequestContext, next: NextFn) -> Response:
# Before: inspect or modify the request
print(f"Request from {ctx.agent_name}")
# Continue the chain
response = await next(ctx)
# After: inspect or modify the response
print(f"Response: {response.content[:100]}")
return response
6. Event-Driven Observability¶
The event bus emits typed events at every stage of execution (agent start/end, LLM call, tool execution, handoff, run start/end). Subscribe to specific event types or all events for logging, metrics, or debugging:
from flux import get_event_bus
from flux.events import AGENT_START, TOOL_START
bus = get_event_bus()
bus.on(AGENT_START, lambda e: print(f"Agent started: {e.data['agent']}"))
bus.on(TOOL_START, lambda e: print(f"Tool called: {e.data['tool']}"))
How to Extend the Framework¶
Adding a Custom Model Provider¶
Implement the Model protocol by providing complete() and stream() methods:
from flux.models.base import Model, ModelRequest, ModelResponse, StreamChunk
class MyModelProvider:
"""Custom model provider."""
def __init__(self, api_key: str, model: str = "my-model"):
self.api_key = api_key
self.model = model
async def complete(self, request: ModelRequest) -> ModelResponse:
# Call your LLM API here
content = await self._call_api(request)
return ModelResponse(content=content)
async def stream(self, request: ModelRequest) -> AsyncIterator[StreamChunk]:
# Stream tokens from your LLM API
async for token in self._stream_api(request):
yield StreamChunk(delta_text=token)
yield StreamChunk(done=True)
Adding a Custom Tool¶
Implement the Tool protocol or use the @tool decorator:
# Using the @tool decorator (recommended)
from flux import tool
@tool(name="search_db", description="Search the database")
def search_database(query: str, limit: int = 10) -> str:
"""Search the product database.
Args:
query: The search query.
limit: Maximum results to return.
"""
# Your database logic here
return f"Found results for: {query}"
# Implementing the Tool protocol directly
from flux.tools.base import Tool, ToolResult
from flux.context import ToolContext
class DatabaseSearchTool:
"""Manual tool implementation."""
@property
def name(self) -> str:
return "search_db"
@property
def description(self) -> str:
return "Search the product database."
@property
def parameters_schema(self) -> dict:
return {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"limit": {"type": "integer", "description": "Max results"},
},
"required": ["query"],
}
async def execute(self, ctx: ToolContext, args: dict) -> ToolResult:
query = args["query"]
limit = args.get("limit", 10)
return ToolResult(output=f"Results for {query} (limit={limit})")
Adding a Custom Guardrail¶
Subclass InputGuardrail or OutputGuardrail:
from flux.guardrails.base import InputGuardrail, OutputGuardrail, GuardrailResult
class profanity_guardrail(InputGuardrail):
"""Custom input guardrail."""
@property
def name(self) -> str:
return "custom_profanity"
async def check(self, user_input: str, context=None) -> GuardrailResult:
# Your validation logic
if "badword" in user_input.lower():
return GuardrailResult(
passed=False,
message="Inappropriate content detected",
)
return GuardrailResult(passed=True)
class fact_check_guardrail(OutputGuardrail):
"""Custom output guardrail."""
@property
def name(self) -> str:
return "fact_check"
async def check(self, output: str, context=None) -> GuardrailResult:
# Your output validation logic
return GuardrailResult(passed=True)
Adding a Custom Middleware¶
Implement the Middleware protocol:
from flux.middleware.base import Middleware, RequestContext, Response, NextFn
class TokenLimitMiddleware:
"""Truncate messages that exceed a token limit."""
def __init__(self, max_tokens: int = 4000):
self._max_tokens = max_tokens
async def process(self, ctx: RequestContext, next: NextFn) -> Response:
# Modify request before passing to next middleware
if len(ctx.messages) > self._max_tokens:
ctx.messages = ctx.messages[-self._max_tokens:]
return await next(ctx)
Adding a Custom Session¶
Implement the Session protocol:
import json
from flux.sessions.base import Session
class RedisSession:
"""Session backed by Redis."""
def __init__(self, redis_url: str, session_id: str):
self._url = redis_url
self._session_id = session_id
@property
def session_id(self) -> str:
return self._session_id
async def get_messages(self, limit: int | None = None) -> list[dict]:
# Fetch from Redis
...
async def add_messages(self, messages: list[dict]) -> None:
# Store in Redis
...
async def clear(self) -> None:
# Clear Redis key
...
Adding a Custom Tracer¶
Implement the Tracer protocol:
from flux.tracing.base import Tracer, Span, SpanData, SpanError
import uuid
class DatadogTracer:
"""Send traces to Datadog."""
def __init__(self, api_key: str):
self._api_key = api_key
def start_span(
self, name: str, attributes: dict | None = None
) -> Span:
# Create and return a Datadog span
...
def flush(self) -> None:
# Flush pending traces to Datadog
...
Exception Hierarchy¶
All framework exceptions inherit from FluxError, making it easy to catch any Flux-specific error:
FluxError
├── MaxTurnsExceeded # Agent exceeded max_turns
├── ModelBehaviorError # Model returned empty or invalid response
├── UserError # Developer misuse of the framework
├── ToolError # Tool execution failure
├── ToolTimeoutError # Tool execution timeout
├── GuardrailTripwireError # Base for guardrail violations
│ ├── InputGuardrailTripwireTriggered
│ └── OutputGuardrailTripwireTriggered
├── HandoffError # Agent handoff failure
├── ProviderError # LLM provider returned an error
└── ConfigurationError # Invalid configuration
Catch specific exceptions for fine-grained error handling, or catch FluxError as a fallback:
from flux.exceptions import (
FluxError,
InputGuardrailTripwireTriggered,
ProviderError,
ToolError,
)
try:
result = await Runner.run(agent, user_input)
except InputGuardrailTripwireTriggered:
return "Input rejected by guardrail"
except ToolError as e:
logger.error(f"Tool '{e.tool_name}' failed: {e.tool_error}")
except ProviderError as e:
logger.error(f"Provider '{e.provider}' error: {e}")
except FluxError as e:
logger.error(f"Flux error: {e}")
Next Steps¶
- Installation -- Set up Flux Agents and optional providers
- Quickstart -- Build your first agent in 5 minutes
- Your First Agent -- Step-by-step tutorial with tools, streaming, sessions, and guardrails