diff --git a/Dockerfile b/Dockerfile index fe52321..bbb40b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,9 +18,8 @@ WORKDIR /app # Copy virtual environment from builder COPY --from=builder /app/.venv /app/.venv -# Copy application source and agent configs +# Copy application source COPY src/ /app/src/ -COPY agents/ /app/agents/ # Set Python path and venv ENV PYTHONPATH=/app:$PYTHONPATH diff --git a/README.md b/README.md index 1b06684..bc59c92 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Configure Deep Agent LangGraph agents in YAML and expose them via FastAPI. -**composable-agents** is a Python framework that lets you declare AI agents as simple YAML files and instantly expose them as a full-featured HTTP API. It is built on [deepagents](https://pypi.org/project/deepagents/) (LangGraph-based Deep Agent) with a strict hexagonal architecture, making every component testable and replaceable. +**composable-agents** is a Python framework that lets you declare AI agents as YAML configurations and instantly expose them as a full-featured HTTP API. It is built on [deepagents](https://pypi.org/project/deepagents/) (LangGraph-based Deep Agent) with a strict hexagonal architecture, making every component testable and replaceable. -The server supports **multi-agent mode**: multiple agents are defined as separate YAML files in an `agents/` directory, each thread is bound to a specific agent at creation time, and agents are lazily instantiated on first use. +The server supports **multi-agent mode**: agents are created and managed via a REST API (backed by MinIO for YAML blob storage and PostgreSQL for metadata), each thread is bound to a specific agent at creation time, and agents are lazily instantiated on first use. --- @@ -15,6 +15,7 @@ The server supports **multi-agent mode**: multiple agents are defined as separat - Python 3.11+ - [UV](https://docs.astral.sh/uv/) package manager - PostgreSQL 15+ (required for thread and agent config persistence) +- MinIO (required for YAML config blob storage) - An API key for at least one LLM provider (Anthropic, OpenAI, or Google) ### Installation @@ -43,22 +44,14 @@ POSTGRES_PASSWORD=raganything POSTGRES_DATABASE=raganything ``` -### Configure your agents +### Create your first agent -Each agent is a standalone YAML file inside the `agents/` directory. A minimal agent only needs a name. Create `agents/my-agent.yaml`: +Agents are created via the REST API by uploading a YAML configuration. A minimal agent only needs a name: ```yaml name: my-agent ``` -Or use one of the provided examples in the `agents/` directory (see [Examples](#examples)). - -### Validate the configuration - -```bash -uv run python -m src validate agents/my-agent.yaml -``` - ### Launch the server ```bash @@ -68,8 +61,7 @@ uv run python -m src serve The API starts on `http://localhost:8000`. On startup, the server: 1. **Runs Alembic migrations** automatically to bring the database schema up to date. -2. **Initializes persistence** (PostgreSQL engine, MinIO store, agent seeding). -3. Reads the `AGENTS_DIR` environment variable (default: `./agents`) to discover available agents. +2. **Initializes persistence** (PostgreSQL engine, MinIO store, agent registry). Agents are not loaded into memory until a thread references them for the first time. @@ -79,7 +71,12 @@ Agents are not loaded into memory until a thread references them for the first t # Health check curl http://localhost:8000/health -# Create a thread bound to an agent (agent_name must match a YAML filename in agents/) +# Create an agent by uploading a YAML file +curl -X POST http://localhost:8000/api/v1/agents \ + -F "agent_name=my-agent" \ + -F "file=@my-agent.yaml" + +# Create a thread bound to the agent curl -X POST http://localhost:8000/api/v1/threads \ -H "Content-Type: application/json" \ -d '{"agent_name": "my-agent"}' @@ -94,12 +91,12 @@ curl -X POST http://localhost:8000/api/v1/chat/ \ ## Multi-Agent Architecture -composable-agents now supports running **multiple agents simultaneously**. Each agent is defined by a separate YAML file in the `agents/` directory. +composable-agents supports running **multiple agents simultaneously**. Each agent is defined by a YAML configuration stored in MinIO, with metadata tracked in PostgreSQL. ### How it works -1. **Discovery** -- On startup, the server scans `AGENTS_DIR` (default: `./agents`) for `.yaml` files. The filename (without extension) becomes the agent name. -2. **Thread creation** -- When creating a thread via `POST /api/v1/threads`, you specify an `agent_name`. If no matching YAML file exists, the API returns `404`. +1. **Agent creation** -- Agents are created via `POST /api/v1/agents` by uploading a YAML file. The configuration is stored in MinIO, and metadata is saved to PostgreSQL. +2. **Thread creation** -- When creating a thread via `POST /api/v1/threads`, you specify an `agent_name`. If no matching agent exists in the registry, the API returns `404`. 3. **Lazy loading** -- The agent (LangGraph graph + runner) is created only when a thread first sends a message to it. Subsequent requests reuse the cached runner. 4. **Per-thread binding** -- Each thread is permanently bound to its agent. Different threads can use different agents. @@ -108,12 +105,22 @@ composable-agents now supports running **multiple agents simultaneously**. Each | Component | Location | Role | |---|---|---| | `AgentRegistry` (port) | `src/domain/ports/agent_registry.py` | Abstract interface for retrieving agent runners by name. | -| `DeepAgentRegistry` (adapter) | `src/infrastructure/deepagent/registry.py` | Scans `agents/` directory, creates and caches runners on demand. | -| `AgentNotFoundError` | `src/domain/exceptions.py` | Raised when a requested agent name has no corresponding YAML file. | +| `PersistentAgentRegistry` (adapter) | `src/infrastructure/persistent_registry/adapter.py` | MinIO + PostgreSQL backed registry, creates and caches runners on demand. | +| `AgentNotFoundError` | `src/domain/exceptions.py` | Raised when a requested agent name has no corresponding config. | ### Example: two agents, two threads ```bash +# Create the research-assistant agent +curl -X POST http://localhost:8000/api/v1/agents \ + -F "agent_name=research-assistant" \ + -F "file=@research-assistant.yaml" + +# Create the code-reviewer agent +curl -X POST http://localhost:8000/api/v1/agents \ + -F "agent_name=code-reviewer" \ + -F "file=@code-reviewer.yaml" + # Create a thread using the research assistant agent curl -X POST http://localhost:8000/api/v1/threads \ -H "Content-Type: application/json" \ @@ -146,8 +153,8 @@ Every agent is defined by a single YAML file validated against the `AgentConfig` |---|---|---|---| | `name` | `string` (required) | -- | Unique agent name (1-100 characters). | | `model` | `string` | `"claude-sonnet-4-5-20250929"` | LLM model identifier. See [Supported Models](#supported-models). | -| `system_prompt` | `string` | `null` | Inline system prompt. Mutually exclusive with `system_prompt_file`. | -| `system_prompt_file` | `string` | `null` | Path to a text file containing the system prompt (resolved relative to the YAML file). Mutually exclusive with `system_prompt`. | +| `system_prompt` | `string` | `null` | Inline system prompt. Use this for all agents created via the REST API. | +| `system_prompt_file` | `string` | `null` | Path to a text file containing the system prompt (only works with filesystem-based loading; **rejected** by the persistent MinIO-backed registry -- inline the prompt in `system_prompt` instead). Mutually exclusive with `system_prompt`. | | `tools` | `list[string]` | `[]` | Python tool references in `module.path:attribute` format. | | `middleware` | `list[MiddlewareType]` | `[]` | Middleware to attach. See [Middlewares](#middlewares). | | `backend` | `BackendConfig` | `{"type": "state"}` | Persistence backend. See [Backends](#backends). | @@ -269,6 +276,11 @@ All endpoints are prefixed appropriately. The server runs on `http://localhost:8 | Method | Path | Description | Success Status | |---|---|---|---| | `GET` | `/health` | Health check | `200` | +| `POST` | `/api/v1/agents` | Create a new agent (upload YAML via multipart form) | `201` | +| `GET` | `/api/v1/agents` | List all agent config metadata | `200` | +| `GET` | `/api/v1/agents/{agent_name}` | Get a specific agent configuration | `200` | +| `PUT` | `/api/v1/agents/{agent_name}` | Update an existing agent (upload YAML via multipart form) | `200` | +| `DELETE` | `/api/v1/agents/{agent_name}` | Delete an agent configuration | `204` | | `POST` | `/api/v1/threads` | Create a new conversation thread (bound to an agent) | `201` | | `GET` | `/api/v1/threads` | List all threads | `200` | | `GET` | `/api/v1/threads/{thread_id}` | Get a specific thread | `200` | @@ -277,19 +289,19 @@ All endpoints are prefixed appropriately. The server runs on `http://localhost:8 | `POST` | `/api/v1/chat/{thread_id}` | Send a message and get the full response | `200` | | `POST` | `/api/v1/chat/{thread_id}/stream` | Send a message and stream the response (SSE) | `200` | | `POST` | `/api/v1/threads/{thread_id}/hitl` | Submit a human-in-the-loop decision | `200` | -| `GET` | `/api/v1/agents` | List all agent configs from `agents/` directory | `200` | -| `GET` | `/api/v1/agents/{agent_name}` | Get a specific agent configuration | `200` | | `WS` | `/api/v1/ws/{thread_id}` | WebSocket endpoint for streaming chat | -- | ### Error Responses | Status | Condition | |---|---| -| `400` | General configuration error | -| `404` | Thread not found, agent not found, or config file not found | +| `400` | General configuration error, invalid agent name, file too large | +| `404` | Thread not found, agent not found, or config not found | +| `409` | Agent config already exists (on create) | | `422` | Validation error (bad request body, invalid config schema) | | `502` | Agent execution error (LLM failure) | | `500` | Unexpected domain error | +| `503` | Storage error (MinIO or PostgreSQL unavailable) | --- @@ -307,7 +319,37 @@ Response: {"status": "ok"} ``` -### 2. List Available Agents +### 2. Create an Agent + +Upload a YAML configuration file to create a new agent: + +```bash +curl -X POST http://localhost:8000/api/v1/agents \ + -F "agent_name=example-agent" \ + -F "file=@example-agent.yaml" +``` + +Response (`201`): + +```json +{ + "name": "example-agent", + "model": "openai:anthropic/claude-haiku-4.5:nitro", + "system_prompt": "You are a helpful assistant.", + "system_prompt_file": null, + "tools": [], + "middleware": [], + "backend": {"type": "state", "root_dir": null}, + "hitl": {"rules": {}}, + "memory": [], + "skills": [], + "subagents": [], + "mcp_servers": [], + "debug": false +} +``` + +### 3. List Available Agents ```bash curl http://localhost:8000/api/v1/agents @@ -319,28 +361,18 @@ Response (`200`): [ { "name": "code-reviewer", - "model": "claude-sonnet-4-5-20250929", - "system_prompt": "You are an expert code reviewer...", - "tools": [], - "middleware": ["filesystem", "sub_agent"], - "backend": {"type": "state", "root_dir": null}, - "hitl": {"rules": {"write_file": true, "execute": {"allowed_decisions": ["approve", "reject"]}}}, - "subagents": [...] + "created_at": "2025-01-15T10:00:00.000000", + "updated_at": "2025-01-15T10:00:00.000000" }, { "name": "example-agent", - "model": "openai:anthropic/claude-haiku-4.5:nitro", - "system_prompt": "You are a helpful assistant.", - "tools": [], - "middleware": [], - "backend": {"type": "state", "root_dir": null}, - "hitl": {"rules": {}}, - "subagents": [] + "created_at": "2025-01-15T10:05:00.000000", + "updated_at": "2025-01-15T10:05:00.000000" } ] ``` -### 3. Get a Specific Agent Configuration +### 4. Get a Specific Agent Configuration ```bash curl http://localhost:8000/api/v1/agents/example-agent @@ -375,12 +407,31 @@ curl http://localhost:8000/api/v1/agents/nonexistent Response (`404`): ```json -{"detail": "Fichier de configuration introuvable: agents/nonexistent.yaml"} +{"detail": "Agent introuvable: nonexistent"} +``` + +### 5. Update an Agent + +Upload a new YAML file to replace an existing agent's configuration: + +```bash +curl -X PUT http://localhost:8000/api/v1/agents/example-agent \ + -F "file=@example-agent-v2.yaml" ``` -### 4. Create a Thread +Response (`200`): returns the updated `AgentConfig`. -The `agent_name` must match an existing YAML filename (without the `.yaml` extension) in the `agents/` directory. +### 6. Delete an Agent + +```bash +curl -X DELETE http://localhost:8000/api/v1/agents/example-agent +``` + +Response: `204 No Content` + +### 7. Create a Thread + +The `agent_name` must match an existing agent in the persistent registry (created via `POST /api/v1/agents`). ```bash curl -X POST http://localhost:8000/api/v1/threads \ @@ -400,7 +451,7 @@ Response (`201`): } ``` -If the agent name does not match any YAML file: +If the agent name does not match any registered agent: ```bash curl -X POST http://localhost:8000/api/v1/threads \ @@ -414,7 +465,7 @@ Response (`404`): {"detail": "Agent introuvable: nonexistent-agent"} ``` -### 5. Send a Message +### 8. Send a Message ```bash curl -X POST http://localhost:8000/api/v1/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ @@ -434,7 +485,7 @@ Response (`200`): } ``` -### 6. Stream a Message (SSE) +### 9. Stream a Message (SSE) ```bash curl -N -X POST http://localhost:8000/api/v1/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890/stream \ @@ -452,7 +503,7 @@ data: align data: ... ``` -### 7. List All Threads +### 10. List All Threads ```bash curl http://localhost:8000/api/v1/threads @@ -479,13 +530,13 @@ Response (`200`): ] ``` -### 8. Get a Specific Thread +### 11. Get a Specific Thread ```bash curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890 ``` -### 9. List Messages in a Thread +### 12. List Messages in a Thread ```bash curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/messages @@ -512,7 +563,7 @@ Response (`200`): ] ``` -### 10. HITL -- Approve a Pending Tool Call +### 13. HITL -- Approve a Pending Tool Call When the agent is configured with HITL rules and a tool call is interrupted, submit a decision: @@ -537,7 +588,7 @@ Response (`200`): } ``` -### 11. HITL -- Reject a Pending Tool Call +### 14. HITL -- Reject a Pending Tool Call ```bash curl -X POST http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/hitl \ @@ -549,7 +600,7 @@ curl -X POST http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234 }' ``` -### 12. HITL -- Edit and Approve a Pending Tool Call +### 15. HITL -- Edit and Approve a Pending Tool Call ```bash curl -X POST http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/hitl \ @@ -561,7 +612,7 @@ curl -X POST http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234 }' ``` -### 13. Delete a Thread +### 16. Delete a Thread ```bash curl -X DELETE http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890 @@ -614,8 +665,9 @@ composable-agents follows a strict **hexagonal architecture** (ports and adapter | (adapters) | +--------------------+ | - DeepAgentRunner | - | - DeepAgentRegistry| + | - PersistentRegistry| | - YamlConfigLoader | + | - MinioConfigStore | | - PostgresThreads | | - Alembic (migrate)| +--------------------+ @@ -625,12 +677,6 @@ composable-agents follows a strict **hexagonal architecture** (ports and adapter ``` composable-agents/ - agents/ # YAML agent configuration files - example-agent.yaml # Basic example agent - minimal.yaml # Minimal agent (name only) - mcp-agent.yaml # Agent with MCP server tools - research-assistant.yaml # Research assistant with tools - code-reviewer.yaml # Code reviewer with HITL + subagents src/ main.py # FastAPI app creation and lifespan (runs migrations) config.py # Pydantic Settings (env vars, database_url property) @@ -648,7 +694,7 @@ composable-agents/ health.py # GET /health threads.py # CRUD /api/v1/threads chat.py # POST /api/v1/chat/{id} and /stream - agents.py # GET /api/v1/agents + agents.py # CRUD /api/v1/agents (create, list, get, update, delete) websocket.py # WS /api/v1/ws/{id} use_cases/ send_message.py # Invoke agent synchronously @@ -658,8 +704,6 @@ composable-agents/ delete_agent_config.py # Delete agent config get_agent_config.py # Get agent config from MinIO list_agent_configs.py # List agent configs from Postgres - load_agent_config.py # Load and validate a YAML config - seed_agents.py # Seed built-in agents from agents/ dir thread_management.py # Create / get / list / delete threads domain/ entities/ @@ -670,7 +714,7 @@ composable-agents/ thread.py # Thread (id, agent_name, messages, timestamps) tracing_config.py # TracingConfig, TracingProviderType ports/ - agent_config_loader.py # Abstract: load config from file + agent_config_loader.py # Abstract: load config from file or string agent_config_repository.py # Abstract: CRUD for agent config metadata agent_config_store.py # Abstract: object storage for YAML blobs agent_registry.py # Abstract: get_runner(name), list_agents(), close() @@ -689,7 +733,6 @@ composable-agents/ deepagent/ adapter.py # DeepAgentRunner (LangGraph adapter) factory.py # create_agent_from_config (resolves tools, middleware, backend) - registry.py # DeepAgentRegistry (lazy loading + caching from agents/ dir) example_tools.py # Example tools: current_time, word_count mcp/ adapter.py # LangchainMcpToolLoader @@ -721,7 +764,6 @@ composable-agents/ test_factory.py test_factory_mcp_integration.py test_langfuse_adapter.py - test_load_agent_config_use_case.py test_mcp_adapter.py test_mcp_lifecycle.py test_mcp_server_config.py @@ -732,10 +774,8 @@ composable-agents/ test_phoenix_adapter.py test_postgres_repository.py test_postgres_thread_repository.py - test_registry.py test_routes.py test_runner_tracing.py - test_seed_agents.py test_send_message.py test_thread.py test_thread_management.py @@ -754,9 +794,11 @@ composable-agents/ ## Examples +Below are example YAML configurations you can upload via `POST /api/v1/agents`. Save each to a file and use `curl -F "agent_name=..." -F "file=@your-file.yaml"`. + ### Minimal Agent -`agents/minimal.yaml` -- the simplest possible agent. Uses all defaults (Claude Sonnet, no tools, state backend). +The simplest possible agent. Uses all defaults (Claude Sonnet, no tools, state backend). ```yaml name: minimal-agent @@ -764,7 +806,7 @@ name: minimal-agent ### Example Agent (OpenAI-compatible endpoint) -`agents/example-agent.yaml` -- a basic agent using an OpenAI-compatible model via OpenRouter. +A basic agent using an OpenAI-compatible model via OpenRouter. ```yaml name: example-agent @@ -774,7 +816,7 @@ system_prompt: "You are a helpful assistant." ### MCP Agent -`agents/mcp-agent.yaml` -- an agent connected to an MCP filesystem server. +An agent connected to an MCP filesystem server. ```yaml name: mcp-agent @@ -789,7 +831,7 @@ mcp_servers: ### Research Assistant with Tools -`agents/research-assistant.yaml` -- an agent with custom tools and filesystem persistence. +An agent with custom tools and filesystem persistence. ```yaml name: research-assistant @@ -810,7 +852,7 @@ debug: false ### Code Reviewer with HITL and Subagents -`agents/code-reviewer.yaml` -- a multi-agent system with human-in-the-loop approval. +A multi-agent system with human-in-the-loop approval. ```yaml name: code-reviewer @@ -900,7 +942,6 @@ Configured via `.env` file or environment variables. See `.env.example`. | Variable | Default | Description | |---|---|---| -| `AGENTS_DIR` | `./agents` | Directory containing agent YAML configuration files. | | `ANTHROPIC_API_KEY` | -- | API key for Anthropic models. | | `OPENAI_API_KEY` | -- | API key for OpenAI models. | | `GOOGLE_API_KEY` | -- | API key for Google models. | @@ -979,12 +1020,6 @@ uv run ruff check . uv run mypy src/ ``` -### Validate all agent YAML files - -```bash -for f in agents/*.yaml; do uv run python -m src validate "$f"; done -``` - --- ## Optional Dependencies diff --git a/src/application/use_cases/load_agent_config.py b/src/application/use_cases/load_agent_config.py deleted file mode 100644 index a13c3d5..0000000 --- a/src/application/use_cases/load_agent_config.py +++ /dev/null @@ -1,14 +0,0 @@ -from pathlib import Path - -from src.domain.entities.agent_config import AgentConfig -from src.domain.ports.agent_config_loader import AgentConfigLoader - - -class LoadAgentConfigUseCase: - """Charge la configuration d'un agent depuis un fichier.""" - - def __init__(self, loader: AgentConfigLoader): - self._loader = loader - - def execute(self, config_path: str | Path) -> AgentConfig: - return self._loader.load(config_path) diff --git a/src/application/use_cases/seed_agents.py b/src/application/use_cases/seed_agents.py deleted file mode 100644 index 007a37a..0000000 --- a/src/application/use_cases/seed_agents.py +++ /dev/null @@ -1,69 +0,0 @@ -import logging -from datetime import UTC, datetime -from pathlib import Path - -import yaml - -from src.domain.entities.agent_config_metadata import AgentConfigMetadata -from src.domain.ports.agent_config_loader import AgentConfigLoader -from src.domain.ports.agent_config_repository import AgentConfigRepository -from src.domain.ports.agent_config_store import AgentConfigStore - -logger = logging.getLogger("composable-agents") - - -class SeedAgentsUseCase: - """Seed built-in agent configurations from a local directory into persistent storage.""" - - def __init__( - self, - config_loader: AgentConfigLoader, - config_store: AgentConfigStore, - config_repository: AgentConfigRepository, - ) -> None: - self._config_loader = config_loader - self._config_store = config_store - self._config_repository = config_repository - - async def execute(self, agents_dir: Path) -> None: - """For each YAML file in agents_dir, upload to MinIO and save metadata if not already present. - - If a YAML references system_prompt_file, the prompt is read from disk and inlined - before uploading so that the stored YAML is self-contained. - - Args: - agents_dir: Path to the directory containing seed agent YAML files. - """ - if not agents_dir.exists(): - logger.warning("Agents directory does not exist: %s", agents_dir) - return - - for yaml_file in sorted(agents_dir.glob("*.yaml")): - agent_name = yaml_file.stem - - if await self._config_repository.exists(agent_name): - logger.debug("Agent '%s' already seeded, skipping", agent_name) - continue - - config = self._config_loader.load(yaml_file) - - raw = yaml.safe_load(yaml_file.read_text(encoding="utf-8")) - if raw.get("system_prompt_file"): - raw.pop("system_prompt_file") - raw["system_prompt"] = config.system_prompt - yaml_content = yaml.dump(raw, default_flow_style=False, allow_unicode=True) - - await self._config_store.put(agent_name, yaml_content) - - now = datetime.now(UTC) - metadata = AgentConfigMetadata( - name=agent_name, - model=config.model, - minio_path=f"{agent_name}.yaml", - is_builtin=True, - created_at=now, - updated_at=now, - ) - await self._config_repository.save(metadata) - - logger.info("Seeded built-in agent '%s'", agent_name) diff --git a/src/config.py b/src/config.py index b0588fd..a0732c0 100644 --- a/src/config.py +++ b/src/config.py @@ -21,7 +21,6 @@ class TracingSettings(BaseSettings): class Settings(BaseSettings): - agents_dir: str = "./agents" openai_api_key: str | None = None host: str = "0.0.0.0" port: int = 8000 diff --git a/src/dependencies.py b/src/dependencies.py index f618b58..f53373d 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -1,7 +1,6 @@ import logging -from pathlib import Path -from miniopy_async import Minio +from miniopy_async.api import Minio from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlalchemy.pool import AsyncAdaptedQueuePool @@ -9,8 +8,6 @@ from src.application.use_cases.delete_agent_config import DeleteAgentConfigUseCase from src.application.use_cases.get_agent_config import GetAgentConfigUseCase from src.application.use_cases.list_agent_configs import ListAgentConfigsUseCase -from src.application.use_cases.load_agent_config import LoadAgentConfigUseCase -from src.application.use_cases.seed_agents import SeedAgentsUseCase from src.application.use_cases.send_message import SendMessageUseCase from src.application.use_cases.stream_message import StreamMessageUseCase from src.application.use_cases.thread_management import ( @@ -23,7 +20,6 @@ from src.config import Settings from src.domain.exceptions import StorageError from src.domain.ports.thread_repository import ThreadRepository -from src.infrastructure.deepagent.registry import DeepAgentRegistry from src.infrastructure.mcp.adapter import LangchainMcpToolLoader from src.infrastructure.minio_store.adapter import MinioAgentConfigStore from src.infrastructure.persistent_registry.adapter import PersistentAgentRegistry @@ -82,15 +78,7 @@ def _create_tracing_provider(settings: Settings): mcp_tool_loader = LangchainMcpToolLoader() tracing_provider = _create_tracing_provider(settings) -# Filesystem-based registry (kept for backward compatibility) -agent_registry = DeepAgentRegistry( - agents_dir=Path(settings.agents_dir), - config_loader=agent_config_loader, - mcp_tool_loader=mcp_tool_loader, - tracing_provider=tracing_provider, -) - -agents_dir = settings.agents_dir +agent_registry: PersistentAgentRegistry | None = None # ============= PERSISTENCE (initialized at startup) ============= @@ -159,23 +147,6 @@ async def close_persistence() -> None: logger.info("SQLAlchemy engine disposed") -async def seed_builtin_agents() -> None: - """Seed built-in agents from the configured agents directory.""" - if _minio_store is None or _pg_repository is None: - logger.warning("Persistence not initialized, skipping seed") - return - - seed_use_case = SeedAgentsUseCase( - config_loader=agent_config_loader, - config_store=_minio_store, - config_repository=_pg_repository, - ) - await seed_use_case.execute(agents_dir=Path(settings.agents_dir)) - logger.info("Built-in agents seeded from %s", settings.agents_dir) - - -logger.info("Dependencies initialized (agents_dir=%s)", settings.agents_dir) - # ============= USE CASE PROVIDERS ============= @@ -186,19 +157,26 @@ def _require_thread_repository() -> ThreadRepository: return thread_repository +def _require_agent_registry() -> PersistentAgentRegistry: + """Return agent registry or raise StorageError if not initialized.""" + if agent_registry is None: + raise StorageError("Agent registry not initialized. Check persistence connectivity.") + return agent_registry + + def get_send_message_use_case() -> SendMessageUseCase: """Provide a SendMessageUseCase instance.""" - return SendMessageUseCase(agent_registry, _require_thread_repository()) + return SendMessageUseCase(_require_agent_registry(), _require_thread_repository()) def get_stream_message_use_case() -> StreamMessageUseCase: """Provide a StreamMessageUseCase instance.""" - return StreamMessageUseCase(agent_registry, _require_thread_repository()) + return StreamMessageUseCase(_require_agent_registry(), _require_thread_repository()) def get_create_thread_use_case() -> CreateThreadUseCase: """Provide a CreateThreadUseCase instance.""" - return CreateThreadUseCase(_require_thread_repository(), agent_registry) + return CreateThreadUseCase(_require_thread_repository(), _require_agent_registry()) def get_get_thread_use_case() -> GetThreadUseCase: @@ -216,16 +194,6 @@ def get_delete_thread_use_case() -> DeleteThreadUseCase: return DeleteThreadUseCase(_require_thread_repository()) -def get_load_agent_config_use_case() -> LoadAgentConfigUseCase: - """Provide a LoadAgentConfigUseCase instance.""" - return LoadAgentConfigUseCase(agent_config_loader) - - -def get_agents_dir() -> str: - """Provide the configured agents directory path.""" - return agents_dir - - def _require_persistence() -> tuple[MinioAgentConfigStore, PostgresAgentConfigRepository]: """Return persistence adapters or raise StorageError if not initialized.""" if _minio_store is None or _pg_repository is None: @@ -250,7 +218,7 @@ def get_update_agent_config_use_case() -> UpdateAgentConfigUseCase: config_loader=agent_config_loader, config_store=store, config_repository=repo, - agent_registry=agent_registry, + agent_registry=_require_agent_registry(), ) @@ -260,7 +228,7 @@ def get_delete_agent_config_use_case() -> DeleteAgentConfigUseCase: return DeleteAgentConfigUseCase( config_store=store, config_repository=repo, - agent_registry=agent_registry, + agent_registry=_require_agent_registry(), ) diff --git a/src/infrastructure/deepagent/registry.py b/src/infrastructure/deepagent/registry.py deleted file mode 100644 index 5af5358..0000000 --- a/src/infrastructure/deepagent/registry.py +++ /dev/null @@ -1,68 +0,0 @@ -import asyncio -import logging -from pathlib import Path - -from src.domain.exceptions import AgentNotFoundError -from src.domain.ports.agent_config_loader import AgentConfigLoader -from src.domain.ports.agent_registry import AgentRegistry -from src.domain.ports.agent_runner import AgentRunner -from src.domain.ports.mcp_tool_loader import McpToolLoader -from src.domain.ports.tracing_provider import TracingProvider -from src.infrastructure.deepagent.adapter import DeepAgentRunner -from src.infrastructure.deepagent.factory import create_agent_from_config - -logger = logging.getLogger("composable-agents") - - -class DeepAgentRegistry(AgentRegistry): - """Registre qui cree et cache les agents a la demande depuis un dossier YAML.""" - - def __init__( - self, - agents_dir: Path, - config_loader: AgentConfigLoader, - mcp_tool_loader: McpToolLoader, - tracing_provider: TracingProvider | None = None, - ) -> None: - self._agents_dir = agents_dir - self._config_loader = config_loader - self._mcp_tool_loader = mcp_tool_loader - self._tracing_provider = tracing_provider - self._runners: dict[str, AgentRunner] = {} - self._lock = asyncio.Lock() - - async def get_runner(self, agent_name: str) -> AgentRunner: - if agent_name in self._runners: - logger.debug("Agent '%s' loaded from cache", agent_name) - return self._runners[agent_name] - - async with self._lock: - if agent_name in self._runners: - return self._runners[agent_name] - - config_path = self._agents_dir / f"{agent_name}.yaml" - if not config_path.exists(): - logger.error("Agent not found: %s", agent_name) - raise AgentNotFoundError(f"Agent not found: {agent_name}") - - logger.info("Building agent '%s' from %s", agent_name, config_path) - config = self._config_loader.load(config_path) - graph = await create_agent_from_config(config, self._mcp_tool_loader) - runner = DeepAgentRunner(graph, tracing_provider=self._tracing_provider) - self._runners[agent_name] = runner - logger.info("Agent '%s' ready and cached", agent_name) - return runner - - async def list_agents(self) -> list[str]: - if not self._agents_dir.exists(): - return [] - return sorted(f.stem for f in self._agents_dir.glob("*.yaml")) - - async def invalidate(self, agent_name: str) -> None: - async with self._lock: - self._runners.pop(agent_name, None) - logger.info("Invalidated cached agent '%s'", agent_name) - - async def close(self) -> None: - logger.info("Closing registry, clearing %d cached agents", len(self._runners)) - self._runners.clear() diff --git a/src/main.py b/src/main.py index 90c9c32..c148eaa 100644 --- a/src/main.py +++ b/src/main.py @@ -15,7 +15,6 @@ close_persistence, init_persistence, mcp_tool_loader, - seed_builtin_agents, tracing_provider, ) from src.domain.exceptions import ( @@ -57,16 +56,11 @@ def _run_alembic_upgrade() -> None: async def lifespan(_app: FastAPI): """Application lifespan: run migrations, init persistence on startup, cleanup on shutdown.""" logger.info("Application startup initiated") - try: - logger.info("Running database migrations...") - await asyncio.to_thread(_run_alembic_upgrade) - logger.info("Database migrations completed") - await init_persistence() - await seed_builtin_agents() - logger.info("Persistence initialized and agents seeded") - except Exception: - logger.exception("Failed to initialize persistence, falling back to filesystem registry") - logger.info("Application startup complete") + logger.info("Running database migrations...") + await asyncio.to_thread(_run_alembic_upgrade) + logger.info("Database migrations completed") + await init_persistence() + logger.info("Persistence initialized") yield logger.info("Application shutdown initiated") try: diff --git a/tests/unit/test_load_agent_config_use_case.py b/tests/unit/test_load_agent_config_use_case.py deleted file mode 100644 index f9fe6ce..0000000 --- a/tests/unit/test_load_agent_config_use_case.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Tests for LoadAgentConfigUseCase. - -Uses the real YamlAgentConfigLoader with tmp_path (internal component). -""" - -import pytest - -from src.application.use_cases.load_agent_config import LoadAgentConfigUseCase -from src.domain.exceptions import ConfigNotFoundError -from src.infrastructure.yaml_config.adapter import YamlAgentConfigLoader - - -class TestLoadAgentConfigUseCase: - @pytest.fixture - def loader(self): - return YamlAgentConfigLoader() - - def test_loads_existing_config(self, loader, tmp_path): - yaml_file = tmp_path / "test.yaml" - yaml_file.write_text("name: test-agent") - use_case = LoadAgentConfigUseCase(loader) - - result = use_case.execute(str(yaml_file)) - - assert result.name == "test-agent" - - def test_raises_on_missing_config(self, loader): - use_case = LoadAgentConfigUseCase(loader) - - with pytest.raises(ConfigNotFoundError): - use_case.execute("/nonexistent/path.yaml") diff --git a/tests/unit/test_mcp_lifecycle.py b/tests/unit/test_mcp_lifecycle.py index b8244d5..6342ed1 100644 --- a/tests/unit/test_mcp_lifecycle.py +++ b/tests/unit/test_mcp_lifecycle.py @@ -17,10 +17,6 @@ def test_mcp_tool_loader_is_langchain_instance(self): """The module-level mcp_tool_loader is a LangchainMcpToolLoader.""" assert isinstance(dependencies.mcp_tool_loader, LangchainMcpToolLoader) - def test_agent_registry_received_mcp_tool_loader(self): - """The agent_registry was constructed with the mcp_tool_loader.""" - assert dependencies.agent_registry._mcp_tool_loader is dependencies.mcp_tool_loader - class TestLifespanMcpCleanup: """Tests for lifespan MCP cleanup.""" @@ -33,7 +29,6 @@ async def test_lifespan_calls_mcp_tool_loader_close(self, mock_mcp_tool_loader): patch("src.main.mcp_tool_loader", mock_mcp_tool_loader), patch("src.main.close_persistence", AsyncMock()), patch("src.main.init_persistence", AsyncMock()), - patch("src.main.seed_builtin_agents", AsyncMock()), patch("src.main.tracing_provider", AsyncMock()), ): async with lifespan(None): @@ -52,7 +47,6 @@ async def test_lifespan_handles_cleanup_gracefully(self): with ( patch("src.main.close_persistence", mock_close_persistence), patch("src.main.init_persistence", AsyncMock()), - patch("src.main.seed_builtin_agents", AsyncMock()), patch("src.main.mcp_tool_loader", mock_mcp), patch("src.main.tracing_provider", mock_tracing), ): diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py deleted file mode 100644 index 0740846..0000000 --- a/tests/unit/test_registry.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Tests for DeepAgentRegistry. - -Uses real YamlAgentConfigLoader with tmp_path (internal). -Uses AsyncMock for mcp_tool_loader (external). -Patches create_agent_from_config and DeepAgentRunner (external LLM boundary). -""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from src.domain.exceptions import AgentNotFoundError -from src.infrastructure.deepagent.registry import DeepAgentRegistry -from src.infrastructure.yaml_config.adapter import YamlAgentConfigLoader - - -class TestDeepAgentRegistry: - @pytest.fixture - def agents_dir(self, tmp_path): - """Create a temporary agents directory with YAML files.""" - d = tmp_path / "agents" - d.mkdir() - (d / "chatbot.yaml").write_text("name: chatbot") - (d / "coder.yaml").write_text("name: coder") - return d - - @pytest.fixture - def config_loader(self): - """Real YamlAgentConfigLoader.""" - return YamlAgentConfigLoader() - - @pytest.fixture - def registry(self, agents_dir, config_loader, mock_mcp_tool_loader): - return DeepAgentRegistry( - agents_dir=agents_dir, - config_loader=config_loader, - mcp_tool_loader=mock_mcp_tool_loader, - ) - - # -- get_runner -------------------------------------------------------- - - @patch("src.infrastructure.deepagent.registry.create_agent_from_config", new_callable=AsyncMock) - @patch("src.infrastructure.deepagent.registry.DeepAgentRunner") - async def test_get_runner_creates_and_returns_runner(self, mock_runner_cls, mock_create, registry): - """get_runner should load config, create the graph, wrap it in a runner.""" - mock_graph = MagicMock() - mock_create.return_value = mock_graph - mock_runner_instance = MagicMock() - mock_runner_cls.return_value = mock_runner_instance - - runner = await registry.get_runner("chatbot") - - assert runner is mock_runner_instance - mock_create.assert_awaited_once() - mock_runner_cls.assert_called_once_with(mock_graph, tracing_provider=None) - - @patch("src.infrastructure.deepagent.registry.create_agent_from_config", new_callable=AsyncMock) - @patch("src.infrastructure.deepagent.registry.DeepAgentRunner") - async def test_get_runner_caches_on_second_call(self, mock_runner_cls, mock_create, registry): - """get_runner should return the cached runner without recreating.""" - mock_create.return_value = MagicMock() - mock_runner_cls.return_value = MagicMock() - - first = await registry.get_runner("chatbot") - second = await registry.get_runner("chatbot") - - assert first is second - assert mock_create.await_count == 1, "Factory should only be called once" - - async def test_get_runner_raises_on_unknown_agent(self, registry): - """get_runner should raise AgentNotFoundError for missing YAML.""" - with pytest.raises(AgentNotFoundError, match="Agent not found: unknown"): - await registry.get_runner("unknown") - - @patch("src.infrastructure.deepagent.registry.create_agent_from_config", new_callable=AsyncMock) - @patch("src.infrastructure.deepagent.registry.DeepAgentRunner") - async def test_get_runner_passes_tracing_provider( - self, mock_runner_cls, mock_create, agents_dir, config_loader, mock_mcp_tool_loader - ): - """get_runner should forward the tracing_provider to DeepAgentRunner.""" - tracing = MagicMock() - registry = DeepAgentRegistry( - agents_dir=agents_dir, - config_loader=config_loader, - mcp_tool_loader=mock_mcp_tool_loader, - tracing_provider=tracing, - ) - mock_create.return_value = MagicMock() - mock_runner_cls.return_value = MagicMock() - - await registry.get_runner("chatbot") - - mock_runner_cls.assert_called_once_with(mock_create.return_value, tracing_provider=tracing) - - @patch("src.infrastructure.deepagent.registry.create_agent_from_config", new_callable=AsyncMock) - @patch("src.infrastructure.deepagent.registry.DeepAgentRunner") - async def test_get_runner_passes_mcp_tool_loader_to_factory( - self, mock_runner_cls, mock_create, registry, mock_mcp_tool_loader - ): - """create_agent_from_config should receive the mcp_tool_loader.""" - mock_create.return_value = MagicMock() - mock_runner_cls.return_value = MagicMock() - - await registry.get_runner("chatbot") - - _, kwargs = mock_create.call_args - assert kwargs.get("mcp_tool_loader") is mock_mcp_tool_loader or ( - mock_create.call_args.args[1] is mock_mcp_tool_loader - ) - - # -- list_agents ------------------------------------------------------- - - async def test_list_agents_returns_sorted_yaml_stems(self, registry): - """list_agents should return sorted stem names of .yaml files.""" - result = await registry.list_agents() - assert result == ["chatbot", "coder"] - - async def test_list_agents_excludes_non_yaml_files(self, agents_dir, config_loader, mock_mcp_tool_loader): - """list_agents should ignore files that are not .yaml.""" - (agents_dir / "notes.txt").write_text("not an agent") - (agents_dir / "readme.md").write_text("docs") - registry = DeepAgentRegistry( - agents_dir=agents_dir, - config_loader=config_loader, - mcp_tool_loader=mock_mcp_tool_loader, - ) - result = await registry.list_agents() - assert result == ["chatbot", "coder"] - - async def test_list_agents_returns_empty_when_dir_missing(self, tmp_path, config_loader, mock_mcp_tool_loader): - """list_agents should return [] if agents_dir does not exist.""" - registry = DeepAgentRegistry( - agents_dir=tmp_path / "nonexistent", - config_loader=config_loader, - mcp_tool_loader=mock_mcp_tool_loader, - ) - result = await registry.list_agents() - assert result == [] - - async def test_list_agents_returns_empty_when_no_yaml_files(self, tmp_path, config_loader, mock_mcp_tool_loader): - """list_agents should return [] if the directory has no .yaml files.""" - empty_dir = tmp_path / "empty_agents" - empty_dir.mkdir() - registry = DeepAgentRegistry( - agents_dir=empty_dir, - config_loader=config_loader, - mcp_tool_loader=mock_mcp_tool_loader, - ) - result = await registry.list_agents() - assert result == [] - - # -- invalidate -------------------------------------------------------- - - @patch("src.infrastructure.deepagent.registry.create_agent_from_config", new_callable=AsyncMock) - @patch("src.infrastructure.deepagent.registry.DeepAgentRunner") - async def test_invalidate_removes_cached_runner(self, mock_runner_cls, mock_create, registry): - """After invalidate, get_runner should re-build the agent from scratch.""" - mock_create.return_value = MagicMock() - runner_a = MagicMock() - runner_b = MagicMock() - mock_runner_cls.side_effect = [runner_a, runner_b] - - first = await registry.get_runner("chatbot") - assert first is runner_a - - await registry.invalidate("chatbot") - assert "chatbot" not in registry._runners, "Cache entry should be removed" - - second = await registry.get_runner("chatbot") - assert second is runner_b - assert first is not second - assert mock_create.await_count == 2 - - # -- close ------------------------------------------------------------- - - @patch("src.infrastructure.deepagent.registry.create_agent_from_config", new_callable=AsyncMock) - @patch("src.infrastructure.deepagent.registry.DeepAgentRunner") - async def test_close_clears_cached_runners(self, mock_runner_cls, mock_create, registry): - """close should clear the internal runner cache.""" - mock_create.return_value = MagicMock() - mock_runner_cls.return_value = MagicMock() - - await registry.get_runner("chatbot") - assert registry._runners, "Runner should be cached before close" - - await registry.close() - assert registry._runners == {}, "Cache should be empty after close" - - @patch("src.infrastructure.deepagent.registry.create_agent_from_config", new_callable=AsyncMock) - @patch("src.infrastructure.deepagent.registry.DeepAgentRunner") - async def test_close_then_get_runner_recreates(self, mock_runner_cls, mock_create, registry): - """After close, get_runner should create the runner again from scratch.""" - mock_create.return_value = MagicMock() - runner_a = MagicMock() - runner_b = MagicMock() - mock_runner_cls.side_effect = [runner_a, runner_b] - - first = await registry.get_runner("chatbot") - await registry.close() - second = await registry.get_runner("chatbot") - - assert first is runner_a - assert second is runner_b - assert first is not second - assert mock_create.await_count == 2 diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index c24fa73..f655291 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -2,7 +2,7 @@ Uses real InMemoryThreadRepository and YamlAgentConfigLoader (internal). Uses AsyncMock for AgentRunner (external LLM boundary). -Uses a real DeepAgentRegistry with patched factory for agent creation. +Uses a real PersistentAgentRegistry with patched factory for agent creation. """ from datetime import UTC, datetime @@ -14,7 +14,7 @@ from src.domain.entities.agent_config_metadata import AgentConfigMetadata from src.domain.entities.message import Message, MessageRole, MessageStatus from src.domain.exceptions import AgentError -from src.infrastructure.deepagent.registry import DeepAgentRegistry +from src.infrastructure.persistent_registry.adapter import PersistentAgentRegistry from src.infrastructure.yaml_config.adapter import YamlAgentConfigLoader from src.main import app from tests.fixtures.in_memory_thread_repository import InMemoryThreadRepository @@ -72,10 +72,11 @@ def real_loader(): @pytest.fixture -def real_registry(agents_dir, real_loader, mock_mcp_tool_loader): - return DeepAgentRegistry( - agents_dir=agents_dir, +def real_registry(mock_config_store, mock_config_repository, real_loader, mock_mcp_tool_loader): + return PersistentAgentRegistry( config_loader=real_loader, + config_store=mock_config_store, + config_repository=mock_config_repository, mcp_tool_loader=mock_mcp_tool_loader, ) @@ -124,23 +125,22 @@ def mock_config_repository(agents_dir): @pytest.fixture(autouse=True) def _wire_dependencies( - real_threads, real_registry, real_loader, agents_dir, mock_runner, mock_config_store, mock_config_repository + real_threads, real_registry, real_loader, mock_runner, mock_config_store, mock_config_repository ): """Wire real internal components + mocked runner into the app dependencies.""" with ( patch( - "src.infrastructure.deepagent.registry.create_agent_from_config", + "src.infrastructure.persistent_registry.adapter.create_agent_from_config", new_callable=AsyncMock, return_value=MagicMock(), ), patch( - "src.infrastructure.deepagent.registry.DeepAgentRunner", + "src.infrastructure.persistent_registry.adapter.DeepAgentRunner", return_value=mock_runner, ), patch("src.dependencies.thread_repository", real_threads), patch("src.dependencies.agent_registry", real_registry), patch("src.dependencies.agent_config_loader", real_loader), - patch("src.dependencies.agents_dir", str(agents_dir)), patch("src.dependencies._minio_store", mock_config_store), patch("src.dependencies._pg_repository", mock_config_repository), ): diff --git a/tests/unit/test_seed_agents.py b/tests/unit/test_seed_agents.py deleted file mode 100644 index f00fd98..0000000 --- a/tests/unit/test_seed_agents.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Tests for SeedAgentsUseCase. - -Mocks AgentConfigStore and AgentConfigRepository (external boundaries). -Uses real YamlAgentConfigLoader (internal). -""" - -from unittest.mock import AsyncMock - -import pytest - -from src.application.use_cases.seed_agents import SeedAgentsUseCase -from src.domain.ports.agent_config_repository import AgentConfigRepository -from src.domain.ports.agent_config_store import AgentConfigStore -from src.infrastructure.yaml_config.adapter import YamlAgentConfigLoader - - -class TestSeedAgentsUseCase: - """Tests for SeedAgentsUseCase.""" - - @pytest.fixture - def loader(self): - """Real YamlAgentConfigLoader (internal).""" - return YamlAgentConfigLoader() - - @pytest.fixture - def mock_store(self): - return AsyncMock(spec=AgentConfigStore) - - @pytest.fixture - def mock_repository(self): - return AsyncMock(spec=AgentConfigRepository) - - @pytest.fixture - def use_case(self, loader, mock_store, mock_repository): - return SeedAgentsUseCase( - config_loader=loader, - config_store=mock_store, - config_repository=mock_repository, - ) - - @pytest.fixture - def agents_dir(self, tmp_path): - """Create a temporary agents directory with seed YAML files.""" - d = tmp_path / "agents" - d.mkdir() - (d / "chatbot.yaml").write_text( - 'name: chatbot\nmodel: claude-sonnet-4-5-20250929\nsystem_prompt: "You are a chatbot."\n' - ) - (d / "coder.yaml").write_text( - 'name: coder\nmodel: claude-sonnet-4-5-20250929\nsystem_prompt: "You are a coding assistant."\n' - ) - return d - - async def test_seed_uploads_new_agents(self, use_case, mock_store, mock_repository, agents_dir): - """For each YAML file not in PG, should upload to MinIO and save metadata.""" - mock_repository.exists.return_value = False - - await use_case.execute(agents_dir=agents_dir) - - # Both agents should be uploaded since neither exists - assert mock_store.put.await_count == 2 - assert mock_repository.save.await_count == 2 - - async def test_seed_skips_existing_agents(self, use_case, mock_store, mock_repository, agents_dir): - """If agent already exists in PG, should not upload to MinIO.""" - # chatbot exists, coder does not - mock_repository.exists.side_effect = lambda name: name == "chatbot" - - await use_case.execute(agents_dir=agents_dir) - - # Only coder should be uploaded - assert mock_store.put.await_count == 1 - assert mock_repository.save.await_count == 1 - put_call_name = mock_store.put.call_args[0][0] - assert put_call_name == "coder" - - async def test_seed_inlines_system_prompt_file(self, use_case, mock_store, mock_repository, tmp_path): - """If YAML references system_prompt_file, should read it and inline the prompt before uploading.""" - d = tmp_path / "agents_with_prompt" - d.mkdir() - prompt_file = d / "prompt.md" - prompt_file.write_text("You are a specialized agent with inlined prompt.") - (d / "prompted-agent.yaml").write_text('name: prompted-agent\nsystem_prompt_file: "./prompt.md"\n') - mock_repository.exists.return_value = False - - await use_case.execute(agents_dir=d) - - mock_store.put.assert_awaited_once() - # The uploaded YAML content should have the prompt inlined (no system_prompt_file reference) - uploaded_content = mock_store.put.call_args[0][1] - assert "system_prompt_file" not in uploaded_content - assert "You are a specialized agent with inlined prompt." in uploaded_content diff --git a/tests/unit/test_send_message.py b/tests/unit/test_send_message.py index 0fa16a9..15b640f 100644 --- a/tests/unit/test_send_message.py +++ b/tests/unit/test_send_message.py @@ -2,7 +2,7 @@ Uses real InMemoryThreadRepository (internal). Uses AsyncMock for AgentRunner (external - calls LLM). -The DeepAgentRegistry is real but with patched factory to avoid LLM calls. +Uses AsyncMock for AgentRegistry (external dependency boundary). """ from unittest.mock import AsyncMock, MagicMock diff --git a/tests/unit/test_thread_management.py b/tests/unit/test_thread_management.py index 3fe8e4a..93e4569 100644 --- a/tests/unit/test_thread_management.py +++ b/tests/unit/test_thread_management.py @@ -1,9 +1,11 @@ """Tests for thread management use cases. Uses real InMemoryThreadRepository (internal). -Uses AsyncMock for AgentRegistry.get_runner (external dependency boundary). +Uses AsyncMock for AgentRegistry (external dependency boundary). """ +from unittest.mock import AsyncMock + import pytest from src.application.use_cases.thread_management import ( @@ -13,26 +15,16 @@ ListThreadsUseCase, ) from src.domain.exceptions import AgentNotFoundError, ThreadNotFoundError -from src.infrastructure.deepagent.registry import DeepAgentRegistry -from src.infrastructure.yaml_config.adapter import YamlAgentConfigLoader +from src.domain.ports.agent_registry import AgentRegistry class TestCreateThreadUseCase: @pytest.fixture - def agents_dir(self, tmp_path): - d = tmp_path / "agents" - d.mkdir() - (d / "test-agent.yaml").write_text("name: test-agent") - return d - - @pytest.fixture - def registry(self, agents_dir, mock_mcp_tool_loader): - """Real DeepAgentRegistry with real YAML files.""" - return DeepAgentRegistry( - agents_dir=agents_dir, - config_loader=YamlAgentConfigLoader(), - mcp_tool_loader=mock_mcp_tool_loader, - ) + def registry(self): + """AsyncMock spec'd to AgentRegistry port.""" + mock = AsyncMock(spec=AgentRegistry) + mock.list_agents.return_value = ["test-agent"] + return mock async def test_create_thread(self, thread_repo, registry): use_case = CreateThreadUseCase(thread_repo, registry) diff --git a/tests/unit/test_tracing_di.py b/tests/unit/test_tracing_di.py index dd5dc34..0cee1b2 100644 --- a/tests/unit/test_tracing_di.py +++ b/tests/unit/test_tracing_di.py @@ -15,28 +15,28 @@ class TestTracingDependencyInjection: def test_default_settings_create_noop_provider(self): - settings = Settings(agents_dir="./agents") + settings = Settings() provider = _create_tracing_provider(settings) assert isinstance(provider, NoopTracingProvider) def test_disabled_langfuse_creates_noop_provider(self): tracing = TracingSettings(provider="langfuse", enabled=False) - settings = Settings(agents_dir="./agents", tracing=tracing) + settings = Settings(tracing=tracing) provider = _create_tracing_provider(settings) assert isinstance(provider, NoopTracingProvider) def test_disabled_phoenix_creates_noop_provider(self): tracing = TracingSettings(provider="phoenix", enabled=False) - settings = Settings(agents_dir="./agents", tracing=tracing) + settings = Settings(tracing=tracing) provider = _create_tracing_provider(settings) assert isinstance(provider, NoopTracingProvider) def test_unknown_provider_creates_noop(self): tracing = TracingSettings(provider="unknown", enabled=True) - settings = Settings(agents_dir="./agents", tracing=tracing) + settings = Settings(tracing=tracing) provider = _create_tracing_provider(settings) assert isinstance(provider, NoopTracingProvider) @@ -65,7 +65,7 @@ def test_enabled_langfuse_creates_langfuse_provider(self): langfuse_secret_key="sk-test", langfuse_host="https://langfuse.example.com", ) - settings = Settings(agents_dir="./agents", tracing=tracing) + settings = Settings(tracing=tracing) provider = _create_tracing_provider(settings) assert isinstance(provider, LangfuseTracingProvider) @@ -114,7 +114,7 @@ def test_enabled_phoenix_creates_phoenix_provider(self): phoenix_api_key="my-key", project_name="my-project", ) - settings = Settings(agents_dir="./agents", tracing=tracing) + settings = Settings(tracing=tracing) provider = _create_tracing_provider(settings) assert isinstance(provider, PhoenixTracingProvider) diff --git a/tests/unit/test_tracing_lifecycle.py b/tests/unit/test_tracing_lifecycle.py index 94db9f9..4587ddb 100644 --- a/tests/unit/test_tracing_lifecycle.py +++ b/tests/unit/test_tracing_lifecycle.py @@ -14,7 +14,6 @@ async def test_lifespan_calls_tracing_flush(self, mock_tracing_provider): with ( patch("src.main.close_persistence", AsyncMock()), patch("src.main.init_persistence", AsyncMock()), - patch("src.main.seed_builtin_agents", AsyncMock()), patch("src.main.mcp_tool_loader", AsyncMock()), patch("src.main.tracing_provider", mock_tracing_provider), ): @@ -30,7 +29,6 @@ async def test_lifespan_calls_tracing_shutdown(self, mock_tracing_provider): with ( patch("src.main.close_persistence", AsyncMock()), patch("src.main.init_persistence", AsyncMock()), - patch("src.main.seed_builtin_agents", AsyncMock()), patch("src.main.mcp_tool_loader", AsyncMock()), patch("src.main.tracing_provider", mock_tracing_provider), ): @@ -62,7 +60,6 @@ async def track_shutdown(): with ( patch("src.main.close_persistence", AsyncMock()), patch("src.main.init_persistence", AsyncMock()), - patch("src.main.seed_builtin_agents", AsyncMock()), patch("src.main.mcp_tool_loader", AsyncMock()), patch("src.main.tracing_provider", mock_tracing_provider), ):