Thank you for your interest in contributing. This document explains the project structure, conventions, and how to extend the system.
composable-agents follows a strict hexagonal architecture (also known as ports and adapters). The key principle is that the domain layer has zero dependencies on infrastructure or frameworks.
src/
domain/ # Pure business logic. No imports from infrastructure or frameworks.
entities/ # Data models (AgentConfig, Thread, Message)
ports/ # Abstract interfaces (AgentRunner, ThreadRepository, AgentConfigLoader)
exceptions.py # Domain-specific exception hierarchy
application/ # Use cases that orchestrate domain logic. Depends only on domain.
use_cases/ # SendMessage, StreamMessage, HITL decisions, thread management
requests/ # Pydantic request models for the API layer
routes/ # FastAPI route handlers (thin layer: validate input, call use case, return response)
infrastructure/ # Concrete implementations of domain ports
deepagent/ # LangGraph Deep Agent adapter + factory
yaml_config/ # YAML config file loader
memory_thread/ # In-memory thread storage
config.py # Pydantic Settings (environment variables)
dependencies.py # Dependency injection wiring
main.py # FastAPI app creation
domain/imports nothing fromapplication/orinfrastructure/.application/imports fromdomain/only (ports and entities).infrastructure/imports fromdomain/to implement ports.routes/anddependencies.pyimport from bothapplication/andinfrastructure/to wire everything together.
Tools are standard LangChain tools using the @tool decorator. To add a new tool:
# src/infrastructure/deepagent/my_tools.py
from langchain_core.tools import tool
@tool
def search_web(query: str) -> str:
"""Search the web for information on a given query."""
# Your implementation here
return f"Results for: {query}"
@tool
def calculate(expression: str) -> str:
"""Evaluate a mathematical expression."""
try:
result = eval(expression) # Use a safe evaluator in production
return str(result)
except Exception as e:
return f"Error: {e}"Use the module.path:attribute_name format:
name: my-agent
tools:
- "src.infrastructure.deepagent.my_tools:search_web"
- "src.infrastructure.deepagent.my_tools:calculate"uv run python -m src dry-run agents/my-agent.yamlThe dry-run command will attempt to import and resolve the tool references, catching errors before the server starts.
The YAML configuration is validated by the AgentConfig Pydantic model defined in src/domain/entities/agent_config.py.
Key points:
AgentConfigis a frozen PydanticBaseModel(immutable after creation).- Validation includes:
namemust be 1-100 characters.system_promptandsystem_prompt_fileare mutually exclusive (enforced by@model_validator).middlewarevalues must match theMiddlewareTypeenum.backend.typemust match theBackendTypeenum.hitl.rulesvalues are eitherboolorInterruptRuleobjects.subagentsentries requirenameanddescription.
To modify the schema, edit src/domain/entities/agent_config.py and regenerate the JSON schema:
uv run python -m src schema > agent-config-schema.jsonIn src/domain/entities/agent_config.py:
class MiddlewareType(StrEnum):
TODO_LIST = "todo_list"
FILESYSTEM = "filesystem"
SUB_AGENT = "sub_agent"
MY_MIDDLEWARE = "my_middleware" # Add your new typeIn src/infrastructure/deepagent/factory.py, add the mapping:
from my_package import MyMiddleware
MIDDLEWARE_MAP: dict[MiddlewareType, type] = {
MiddlewareType.TODO_LIST: FilesystemMiddleware,
MiddlewareType.FILESYSTEM: FilesystemMiddleware,
MiddlewareType.SUB_AGENT: SubAgentMiddleware,
MiddlewareType.MY_MIDDLEWARE: MyMiddleware, # Register here
}name: my-agent
middleware:
- my_middlewareIn src/domain/entities/agent_config.py:
class BackendType(StrEnum):
STATE = "state"
STORE = "store"
FILESYSTEM = "filesystem"
COMPOSITE = "composite"
MY_BACKEND = "my_backend" # Add your new typeIn src/infrastructure/deepagent/factory.py:
def _resolve_backend(config: AgentConfig):
match config.backend.type:
case BackendType.STATE:
return None
case BackendType.FILESYSTEM:
return FilesystemBackend(root_dir=config.backend.root_dir or "./workspace")
case BackendType.STORE:
return lambda rt: StoreBackend(rt)
case BackendType.MY_BACKEND:
return MyBackend(config.backend.root_dir) # Your implementation
case BackendType.COMPOSITE:
return Nonename: my-agent
backend:
type: my_backend
root_dir: "./data"The project uses pytest with pytest-asyncio for async test support. All tests are pure unit tests with no external dependencies (LLM calls are fully faked).
# Run all tests
uv run pytest tests/ -v
# Run with coverage
uv run pytest tests/ -v --cov=src
# Run a specific test file
uv run pytest tests/unit/test_routes.py -v
# Run a specific test class
uv run pytest tests/unit/test_routes.py::TestChatRoutes -vTest doubles are located in tests/doubles/:
FakeAgentRunner-- implementsAgentRunnerwith deterministic responses.FakeAgentConfigLoader-- implementsAgentConfigLoaderwith in-memory configs.
These are wired into the test suite via tests/unit/test_routes.py fixtures using override_dependencies().
# Lint with ruff
uv run ruff check .
# Auto-fix lint issues
uv run ruff check . --fix
# Type check with mypy
uv run mypy src/- Formatter/Linter: ruff (configured via pyproject.toml or defaults).
- Type hints: Required on all public functions and methods.
- Docstrings: Required on all public classes and methods. French is acceptable for internal domain comments; English is preferred for docstrings.
- Naming: snake_case for functions/variables, PascalCase for classes, UPPER_CASE for constants.
- Pydantic: All data models use Pydantic v2
BaseModel. - Async: All use cases and route handlers are
async.
- Create a feature branch from
main. - Write tests for your changes (the test suite must stay green).
- Run the full validation:
for f in agents/*.yaml; do uv run python -m src validate "$f"; done uv run pytest tests/ -v uv run ruff check . uv run mypy src/
- Open a pull request with a clear description of what was changed and why.