Protocols¶
Structural typing and Protocol-based design throughout Flux.
Flux is built on Python's typing.Protocol for structural (duck) typing. Instead of requiring you to inherit from abstract base classes, Flux defines protocols -- contracts that any class can satisfy by implementing the right methods and properties. This means you can integrate third-party libraries, existing code, or your own implementations with zero inheritance and minimal boilerplate.
Why Protocols over ABCs¶
The central design principle of Flux is "Protocol over ABC". Here is why:
Traditional Approach (ABC)¶
from abc import ABC, abstractmethod
class Model(ABC):
@abstractmethod
async def complete(self, request: ModelRequest) -> ModelResponse: ...
@abstractmethod
async def stream(self, request: ModelRequest) -> AsyncIterator[StreamChunk]: ...
# Must inherit to satisfy the interface
class MyModel(Model):
async def complete(self, request): ...
async def stream(self, request): ...
yield
Flux Approach (Protocol)¶
from typing import Protocol, runtime_checkable
@runtime_checkable
class Model(Protocol):
async def complete(self, request: ModelRequest) -> ModelResponse: ...
async def stream(self, request: ModelRequest) -> AsyncIterator[StreamChunk]: ...
# No inheritance needed -- just implement the methods
class MyModel:
async def complete(self, request): ...
async def stream(self, request): ...
yield
# isinstance(MyModel(), Model) works at runtime
Benefits¶
Protocol vs ABC comparison
| Aspect | ABC | Protocol |
|---|---|---|
| Inheritance required | Yes | No |
| Can wrap third-party classes | No (without adapter) | Yes |
isinstance checks |
Works | Works (@runtime_checkable) |
| IDE support | Excellent | Excellent (with type checker) |
| Coupling | Tight (must import base) | Loose (structural match) |
Protocol Reference¶
Model Protocol¶
The Model protocol defines how Flux interacts with LLM providers:
from typing import Protocol, runtime_checkable, AsyncIterator
from flux.models.base import ModelRequest, ModelResponse, StreamChunk
@runtime_checkable
class Model(Protocol):
async def complete(self, request: ModelRequest) -> ModelResponse:
"""Get a complete (non-streaming) response from the model."""
...
async def stream(self, request: ModelRequest) -> AsyncIterator[StreamChunk]:
"""Stream a response from the model."""
...
yield # Required for async generator protocol
Key types:
ModelRequest-- Contains messages, system prompt, tools, output schema, and settings.ModelResponse-- Contains content, tool calls, usage info, and finish reason.StreamChunk-- Contains delta text, optional tool call, usage, and done flag.
Implementing a custom model:
from flux.models.base import ModelRequest, ModelResponse, StreamChunk
from flux.context import Usage
class MyCustomModel:
"""Any class with complete() and stream() satisfies Model."""
async def complete(self, request: ModelRequest) -> ModelResponse:
# Call your LLM here
response_text = await my_llm_api(request.messages)
return ModelResponse(
content=response_text,
usage=Usage(prompt_tokens=0, completion_tokens=0),
finish_reason="stop",
)
async def stream(self, request: ModelRequest):
chunks = await my_llm_stream(request.messages)
for chunk in chunks:
yield StreamChunk(
delta_text=chunk.text,
done=chunk.is_last,
)
Tool Protocol¶
The Tool protocol defines how agents interact with tools:
from typing import Protocol, runtime_checkable
from flux.tools.base import ToolResult
from flux.context import ToolContext
@runtime_checkable
class Tool(Protocol):
@property
def name(self) -> str:
"""Unique name of the tool."""
...
@property
def description(self) -> str:
"""Description shown to the model."""
...
@property
def parameters_schema(self) -> dict[str, Any]:
"""JSON Schema for the tool's parameters."""
...
async def execute(self, ctx: ToolContext, args: dict[str, Any]) -> ToolResult:
"""Execute the tool with the given arguments."""
...
Implementing a custom tool:
from flux.tools.base import ToolResult
from flux.context import ToolContext
class WeatherTool:
"""Fetches weather data -- satisfies the Tool protocol."""
@property
def name(self) -> str:
return "get_weather"
@property
def description(self) -> str:
return "Get current weather for a city"
@property
def parameters_schema(self) -> dict:
return {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"}
},
"required": ["city"],
}
async def execute(self, ctx: ToolContext, args: dict) -> ToolResult:
city = args["city"]
weather = await fetch_weather(city)
return ToolResult(output=f"Weather in {city}: {weather}")
Session Protocol¶
The Session protocol defines how conversation history is persisted:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Session(Protocol):
@property
def session_id(self) -> str:
"""Unique session identifier."""
...
async def get_messages(self, limit: int | None = None) -> list[dict[str, Any]]:
"""Retrieve stored messages."""
...
async def add_messages(self, messages: list[dict[str, Any]]) -> None:
"""Store messages."""
...
async def clear(self) -> None:
"""Clear all messages."""
...
Built-in implementations:
InMemorySession-- Ephemeral, lost on restart. Good for testing.SQLiteSession-- Persistent, survives restarts. Good for production.
Custom session:
import redis.asyncio as redis
class RedisSession:
"""Redis-backed session -- satisfies the Session protocol."""
def __init__(self, session_id: str, redis_url: str = "redis://localhost") -> None:
self._session_id = session_id
self._redis = redis.from_url(redis_url)
self._key = f"flux:session:{session_id}"
@property
def session_id(self) -> str:
return self._session_id
async def get_messages(self, limit=None) -> list[dict]:
messages = await self._redis.lrange(self._key, 0, -1)
result = [json.loads(m) for m in messages]
if limit:
return result[-limit:]
return result
async def add_messages(self, messages: list[dict]) -> None:
for msg in messages:
await self._redis.rpush(self._key, json.dumps(msg))
async def clear(self) -> None:
await self._redis.delete(self._key)
Memory Protocol¶
The Memory protocol defines long-term knowledge storage:
from typing import Protocol, runtime_checkable
from flux.memory.base import MemoryEntry
@runtime_checkable
class Memory(Protocol):
async def search(self, query: str, limit: int = 5) -> list[MemoryEntry]:
"""Search memory for relevant entries."""
...
async def store(self, content: str, metadata: dict[str, Any] | None = None) -> None:
"""Store a memory entry."""
...
async def clear(self) -> None:
"""Clear all memories."""
...
Built-in implementations:
ConversationMemory-- Wraps a Session for conversational memory.VectorMemory-- Hash-based embedding search for semantic retrieval.
Middleware Protocol¶
from typing import Protocol, runtime_checkable, Callable, Awaitable
NextFn = Callable[[RequestContext], Awaitable[Response]]
@runtime_checkable
class Middleware(Protocol):
async def process(self, ctx: RequestContext, next: NextFn) -> Response:
"""Process the request, calling next() to continue the chain."""
...
See Middleware for full details.
Tracer Protocol¶
The Tracer protocol defines a backend for distributed tracing:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Tracer(Protocol):
def start_span(self, name: str, attributes: dict[str, Any] | None = None) -> Span:
"""Start a new trace span."""
...
def flush(self) -> None:
"""Flush any buffered trace data."""
...
Built-in implementations:
ConsoleTracer-- Prints spans to stdout.FileTracer-- Writes spans as JSON lines to a file.
Span Protocol¶
The Span protocol represents a single unit of work in a trace:
@runtime_checkable
class Span(Protocol):
@property
def trace_id(self) -> str: ...
@property
def span_id(self) -> str: ...
@property
def name(self) -> str: ...
def set_attribute(self, key: str, value: Any) -> None: ...
def set_error(self, error: SpanError) -> None: ...
def finish(self) -> None: ...
# Context manager support
def __enter__(self) -> Span: ...
def __exit__(self, *args) -> None: ...
async def __aenter__(self) -> Span: ...
async def __aexit__(self, *args) -> None: ...
Spans support both sync and async context managers:
# Sync usage
with tracer.start_span("my_operation") as span:
span.set_attribute("key", "value")
# Do work
span.finish()
# Async usage
async with tracer.start_span("my_operation") as span:
span.set_attribute("key", "value")
# Do async work
span.finish()
Protocol Relationships¶
flowchart TD
subgraph "Flux Protocols"
M[Model Protocol]
T[Tool Protocol]
S[Session Protocol]
ME[Memory Protocol]
MW[Middleware Protocol]
TR[Tracer Protocol]
SP[Span Protocol]
end
subgraph "Flux Core"
AG[Agent]
RU[Runner]
end
AG -->|uses| M
AG -->|uses| T
AG -->|uses| MW
RU -->|creates| S
RU -->|uses| TR
TR -->|creates| SP
AG -.->|optional| ME
subgraph "Built-in Implementations"
M1[OllamaModel]
M2[OpenAIModel]
M3[AnthropicModel]
T1[ShellTool]
T2[FileReadTool]
T3[FileWriteTool]
S1[InMemorySession]
S2[SQLiteSession]
ME1[ConversationMemory]
ME2[VectorMemory]
MW1[LoggingMiddleware]
MW2[CacheMiddleware]
MW3[RateLimitMiddleware]
MW4[RetryMiddleware]
TR1[ConsoleTracer]
TR2[FileTracer]
end
M1 -.->|satisfies| M
M2 -.->|satisfies| M
M3 -.->|satisfies| M
T1 -.->|satisfies| T
T2 -.->|satisfies| T
T3 -.->|satisfies| T
S1 -.->|satisfies| S
S2 -.->|satisfies| S
ME1 -.->|satisfies| ME
ME2 -.->|satisfies| ME
MW1 -.->|satisfies| MW
MW2 -.->|satisfies| MW
MW3 -.->|satisfies| MW
MW4 -.->|satisfies| MW
TR1 -.->|satisfies| TR
TR2 -.->|satisfies| TR
Structural Typing in Practice¶
flowchart LR
A[Your Class] --> B{Has required methods?}
B -- Yes --> C[Satisfies Protocol]
B -- No --> D[Type Error]
C --> E[Works with Flux]
D --> F[Implement missing methods]
style C fill:#c8e6c9
style D fill:#ffcdd2
Duck Typing with isinstance¶
Because all Flux protocols use @runtime_checkable, you can verify protocol conformance at runtime:
from flux.models.base import Model
class MyModel:
async def complete(self, request): ...
async def stream(self, request): ...
yield
print(isinstance(MyModel(), Model)) # True -- no inheritance needed
Integrating Third-Party Libraries¶
Structural typing makes it trivial to wrap existing libraries:
# Wrapping the httpx library as a Session (conceptual example)
import httpx
class HTTPSession:
"""Not a real Session, but demonstrates structural typing."""
def __init__(self, base_url: str) -> None:
self._client = httpx.AsyncClient(base_url=base_url)
self._session_id = "http-session"
@property
def session_id(self) -> str:
return self._session_id
async def get_messages(self, limit=None):
resp = await self._client.get("/messages")
return resp.json()["messages"][-limit:] if limit else resp.json()["messages"]
async def add_messages(self, messages):
for msg in messages:
await self._client.post("/messages", json=msg)
async def clear(self):
await self._client.delete("/messages")
Type Checking¶
For static type checking (mypy, pyright), protocols give you compile-time safety without runtime overhead:
from flux.models.base import Model, ModelRequest, ModelResponse
def process_model(model: Model, request: ModelRequest) -> ModelResponse:
"""Type checkers verify that model has complete() and stream()."""
return await model.complete(request) # Checked at type-check time
Best Practices¶
Implement, don't inherit
The whole point of protocols is that your classes don't need to know about Flux's types at import time. Just implement the methods with the right signatures and you're done.
Use @runtime_checkable for safety
Flux's protocols use @runtime_checkable so you can verify conformance with isinstance() when needed. This is especially useful during debugging and testing.
Prefer composition over inheritance
Rather than subclassing InMemorySession to add caching, compose a new class that wraps it. This keeps your code flexible and testable.
Keep protocol implementations focused
Each class should satisfy exactly one protocol. If you find a class implementing both Tool and Middleware, it's likely doing too much.
Use property accessors where the protocol defines them
The Tool protocol defines name, description, and parameters_schema as @property. Use @property in your implementations too, not plain attributes -- this ensures isinstance checks work correctly with @runtime_checkable.
Document which protocols your class satisfies
Even though inheritance isn't required, docstrings and type annotations help other developers understand the contract:
class MyTool:
"""A custom tool that satisfies the Tool protocol.
Implements: name, description, parameters_schema, execute()
"""
Leverage the no-op implementations. Flux provides _NoopTracer and _NoopSpan internally. For your own optional subsystems, follow the same pattern -- provide a "do nothing" implementation so callers don't need null checks.