From 4135aaa4acba31f45f2e8255d1cdb59f548fa04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yohan=20Gon=C3=A7alves?= Date: Fri, 24 Jul 2026 13:11:15 +0200 Subject: [PATCH 1/4] feat: store file API, skills/memories management, conditional loading via agent namespace - Add Store File API (GET/PUT/DELETE /api/v1/store/files) with LangGraphStoreFileRepository - Add _prepare_agent_namespace: copies selected skills/memories to /agents/{name}/ namespace - Add skill usage tracking endpoint (GET /api/v1/store/skills/{name}/usage) - Remove MiddlewareType, BackendType.FILESYSTEM/COMPOSITE/STATE, root_dir, store_backend - Upgrade deepagents 0.6.12 + langgraph-checkpoint-postgres + psycopg[binary] - Fix StoreBackend deprecated pattern + AsyncPostgresStore CM reference leak - Add backward compat: strip deprecated fields from old YAMLs - Backend: 449 tests pass, SonarQube clean, Trivy 0 vulns --- CONTRIBUTING.md | 117 +++--- README.md | 213 ++++++++-- agents/README.md | 4 +- pyproject.toml | 8 +- src/application/routes/store.py | 152 +++++++ src/application/routes/trace.py | 2 +- .../use_cases/manage_store_file.py | 100 +++++ src/dependencies.py | 69 ++++ src/domain/entities/agent_config.py | 20 +- src/domain/entities/message.py | 4 +- src/domain/errors/store_file.py | 10 + src/domain/logging/messages.py | 5 + src/domain/ports/store_file_repository.py | 52 +++ src/infrastructure/deepagent/factory.py | 253 ++++++++++-- src/infrastructure/store_file/__init__.py | 0 src/infrastructure/store_file/adapter.py | 71 ++++ src/infrastructure/yaml_config/adapter.py | 23 ++ src/main.py | 9 + tests/unit/test_agent_config.py | 142 ++++--- tests/unit/test_deep_agent_runner.py | 24 +- tests/unit/test_extract_source.py | 14 +- tests/unit/test_factory.py | 210 +++++++++- tests/unit/test_get_thread_history.py | 26 +- tests/unit/test_store_file_repository.py | 218 ++++++++++ tests/unit/test_store_routes.py | 383 ++++++++++++++++++ tests/unit/test_yaml_loader.py | 50 ++- uv.lock | 151 ++++++- 27 files changed, 2070 insertions(+), 260 deletions(-) create mode 100644 src/application/routes/store.py create mode 100644 src/application/use_cases/manage_store_file.py create mode 100644 src/domain/errors/store_file.py create mode 100644 src/domain/ports/store_file_repository.py create mode 100644 src/infrastructure/store_file/__init__.py create mode 100644 src/infrastructure/store_file/adapter.py create mode 100644 tests/unit/test_store_file_repository.py create mode 100644 tests/unit/test_store_routes.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57598e2..c99ff0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,9 +15,9 @@ src/ 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 + use_cases/ # SendMessage, StreamMessage, HITL decisions, thread management, store file management requests/ # Pydantic request models for the API layer - routes/ # FastAPI route handlers (thin layer: validate input, call use case, return response) + routes/ # FastAPI route handlers (health, threads, chat, trace, agents, store, websocket) infrastructure/ # Concrete implementations of domain ports deepagent/ # LangGraph Deep Agent adapter + factory yaml_config/ # YAML config file loader @@ -95,8 +95,8 @@ Key points: - Validation includes: - `name` must be 1-100 characters. - `system_prompt` and `system_prompt_file` are mutually exclusive (enforced by `@model_validator`). - - `middleware` values must match the `MiddlewareType` enum. - - `backend.type` must match the `BackendType` enum. + - `backend.type` must match the `BackendType` enum (`state` or `store`). + - `backend.store_backend` and `backend.checkpoint_backend` must be `"memory"` or `"postgres"`. - `hitl.rules` values are either `bool` or `InterruptRule` objects. - `subagents` entries require `name` and `description`. @@ -108,45 +108,6 @@ uv run python -m src schema > agent-config-schema.json --- -## How to Add a New Middleware - -### 1. Add a value to the `MiddlewareType` enum - -In `src/domain/entities/agent_config.py`: - -```python -class MiddlewareType(StrEnum): - TODO_LIST = "todo_list" - FILESYSTEM = "filesystem" - SUB_AGENT = "sub_agent" - MY_MIDDLEWARE = "my_middleware" # Add your new type -``` - -### 2. Register it in the factory - -In `src/infrastructure/deepagent/factory.py`, add the mapping: - -```python -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 -} -``` - -### 3. Use it in YAML - -```yaml -name: my-agent -middleware: - - my_middleware -``` - ---- - ## How to Add a New Backend ### 1. Add a value to the `BackendType` enum @@ -157,8 +118,6 @@ In `src/domain/entities/agent_config.py`: class BackendType(StrEnum): STATE = "state" STORE = "store" - FILESYSTEM = "filesystem" - COMPOSITE = "composite" MY_BACKEND = "my_backend" # Add your new type ``` @@ -171,14 +130,10 @@ 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) + return lambda rt: StoreBackend(store=store, namespace=lambda r: ("filesystem",)) case BackendType.MY_BACKEND: - return MyBackend(config.backend.root_dir) # Your implementation - case BackendType.COMPOSITE: - return None + return MyBackend(config.backend) # Your implementation ``` ### 3. Use it in YAML @@ -187,11 +142,69 @@ def _resolve_backend(config: AgentConfig): name: my-agent backend: type: my_backend - root_dir: "./data" + store_backend: memory + checkpoint_backend: memory ``` --- +## Store File API + +Files in the LangGraph store (skills, memories, any text blob) are managed through a dedicated set of use cases, a domain port, and an infrastructure adapter, following the same hexagonal pattern as the rest of the codebase. + +### Routes (`src/application/routes/store.py`) + +| Method | Path | Handler | +|---|---|---| +| `GET` | `/api/v1/store/files` | `list_store_files` (optional `prefix` query param) | +| `GET` | `/api/v1/store/files/{path:path}` | `get_store_file` | +| `PUT` | `/api/v1/store/files/{path:path}` | `put_store_file` (body: `StoreFilePutRequest`) | +| `DELETE` | `/api/v1/store/files/{path:path}` | `delete_store_file` | + +Response DTOs: `StoreFileResponse` (`path`, `content`) and `StoreFilePutRequest` (`content`). The `{path:path}` converter allows slashes in the path segment. A missing file on `GET` raises `StoreFileNotFoundError` (`src/domain/errors/store_file.py`), resulting in a `404`. + +### Use Cases (`src/application/use_cases/manage_store_file.py`) + +Each use case is a thin pass-through to the repository (SRP — one class per action): + +| Use Case | Method | Description | +|---|---|---| +| `ListStoreFilesUseCase` | `execute(prefix="/") -> list[str]` | List file paths matching the prefix. | +| `GetStoreFileUseCase` | `execute(path) -> str \| None` | Retrieve a single file's content; `None` if not found. | +| `PutStoreFileUseCase` | `execute(path, content) -> str` | Create or replace a file; returns the stored content. | +| `DeleteStoreFileUseCase` | `execute(path) -> None` | Delete a file (idempotent). | + +All use cases are `async` and accept a `StoreFileRepository` via constructor injection. + +### Port — `StoreFileRepository` (`src/domain/ports/store_file_repository.py`) + +Abstract interface for file CRUD on a namespace-scoped key-value store: + +| Method | Signature | +|---|---| +| `list_files` | `(prefix: str) -> list[str]` | +| `get_file` | `(path: str) -> str \| None` | +| `put_file` | `(path: str, content: str) -> None` | +| `delete_file` | `(path: str) -> None` | + +`delete_file` is idempotent — implementations must not raise if the path does not exist. + +### Adapter — `LangGraphStoreFileRepository` (`src/infrastructure/store_file/adapter.py`) + +Implements `StoreFileRepository` on top of a LangGraph `BaseStore` (`InMemoryStore` or `AsyncPostgresStore`): + +- Files are stored as `{"content": str, "encoding": "utf-8"}` values keyed by path under the `("filesystem",)` namespace by default (configurable via the `namespace` constructor arg). +- `list_files` uses `asearch(namespace, limit=100)` and filters client-side by `str.startswith(prefix)`. +- `get_file` returns `item.value.get("content")` (or `None` if the item is missing or malformed). +- `put_file` uses `aput` with the content dict. +- `delete_file` uses `adelete` (idempotent). + +### Dependency injection + +The four use cases are wired in `src/dependencies.py` via `get_list_store_files_use_case`, `get_get_store_file_use_case`, `get_put_store_file_use_case`, and `get_delete_store_file_use_case`. They share a single `LangGraphStoreFileRepository` instance built from the same store used by agent backends. + +--- + ## Running Tests 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). diff --git a/README.md b/README.md index 476fc7d..1bfdb16 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,7 @@ Every agent is defined by a single YAML file validated against the `AgentConfig` | `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`. | | `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). | +| `backend` | `BackendConfig` | `{"type": "state", "store_backend": "memory", "checkpoint_backend": "memory"}` | Persistence backend. See [Backends](#backends). | | `hitl` | `HITLConfig` | `{"rules": {}}` | Human-in-the-loop interrupt rules. | | `memory` | `list[string]` | `[]` | Paths to memory files (e.g. `"./AGENTS.md"`). | | `skills` | `list[string]` | `[]` | Paths to skill directories (e.g. `"./skills/"`). | @@ -338,31 +337,45 @@ The value must match the `API_KEY` configured on the [mcp-raganything](https://g ## Middlewares -| Name | Enum Value | Description | -|---|---|---| -| Todo List | `todo_list` | Filesystem-based task tracking middleware. | -| Filesystem | `filesystem` | Gives the agent read/write access to files on disk. | -| Sub-Agent | `sub_agent` | Enables delegation to sub-agents defined in `subagents`. | +The Todo List, Filesystem, and Sub-Agent middlewares are **always installed** by `create_deep_agent` defaults and cannot be toggled via the YAML configuration. Sub-agent delegation is enabled automatically when `subagents` is non-empty. --- ## Backends +The `BackendConfig` schema controls where agent state and checkpoints are persisted. + +| Field | Type | Default | Description | +|---|---|---|---| +| `type` | `BackendType` (`state` \| `store`) | `state` | Backend kind. `state` = in-memory LangGraph state, `store` = LangGraph store-backed. | +| `store_backend` | `Literal["memory", "postgres"]` | `memory"` | Where the LangGraph store lives. `memory` = in-process, `postgres` = PostgreSQL-backed (singleton reused across all agent builds). | +| `checkpoint_backend` | `Literal["memory", "postgres"]` | `memory"` | Where the LangGraph checkpointer lives. `memory` = in-process, `postgres` = PostgreSQL-backed (singleton reused across all agent builds). | + +### Supported `type` values + | Name | Enum Value | Description | |---|---|---| | State | `state` | Default in-memory state backend (no extra config). | -| Filesystem | `filesystem` | Persists agent state to a directory. Accepts `root_dir`. | -| Store | `store` | LangGraph store-based backend. | -| Composite | `composite` | Reserved for advanced composite configurations. | +| Store | `store` | LangGraph store-based backend. The store itself is backed by `store_backend`. | -Example with a filesystem backend: +### Postgres-backed store and checkpointer + +When `store_backend` or `checkpoint_backend` is set to `"postgres"`, the framework instantiates a single `PostgresStore` / `PostgresSaver` (from `langgraph-checkpoint-postgres`) and **reuses the same instance across every agent build**. This avoids opening a new connection pool per agent. + +> **Note:** The `langgraph-checkpoint-postgres` package is required and is included in the project dependencies. + +Example YAML enabling Postgres for both store and checkpointer: ```yaml +name: persistent-agent backend: - type: filesystem - root_dir: "./workspace" + type: store + store_backend: postgres + checkpoint_backend: postgres ``` +The `StoreBackend` is wired with a per-run namespace via `StoreBackend(store=store, namespace=lambda r: ("filesystem",))` (the deprecated `StoreBackend(runtime)` pattern has been removed). + --- ## API Reference @@ -384,6 +397,10 @@ All endpoints are prefixed appropriately. The server runs on `http://localhost:8 | `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` | +| `GET` | `/api/v1/store/files` | List file paths in the store (optional `prefix` query param) | `200` | +| `GET` | `/api/v1/store/files/{path}` | Get a single file's content by path | `200` | +| `PUT` | `/api/v1/store/files/{path}` | Create or replace a file in the store | `200` | +| `DELETE` | `/api/v1/store/files/{path}` | Delete a file from the store | `204` | | `WS` | `/api/v1/ws/{thread_id}` | WebSocket endpoint for streaming chat | -- | | `POST` | `/prompts/create` | Create a new prompt | `201` | | `GET` | `/prompts/get/{identifier}` | Get a specific prompt by identifier, version, or tag | `200` | @@ -430,8 +447,7 @@ Response (`200`): "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}, + "backend": {"type": "state", "store_backend": "memory", "checkpoint_backend": "memory"}, "hitl": {"rules": {"write_file": true, "execute": {"allowed_decisions": ["approve", "reject"]}}}, "subagents": [...] }, @@ -440,8 +456,7 @@ Response (`200`): "model": "openai:anthropic/claude-haiku-4.5:nitro", "system_prompt": "You are a helpful assistant.", "tools": [], - "middleware": [], - "backend": {"type": "state", "root_dir": null}, + "backend": {"type": "state", "store_backend": "memory", "checkpoint_backend": "memory"}, "hitl": {"rules": {}}, "subagents": [] } @@ -463,8 +478,7 @@ Response (`200`): "system_prompt": "You are a helpful assistant.", "system_prompt_file": null, "tools": [], - "middleware": [], - "backend": {"type": "state", "root_dir": null}, + "backend": {"type": "state", "store_backend": "memory", "checkpoint_backend": "memory"}, "hitl": {"rules": {}}, "memory": [], "skills": [], @@ -970,6 +984,146 @@ All prompt management operations are async and fully integrated with the FastAPI --- +## Store File API + +composable-agents exposes a small REST surface for managing **files in the LangGraph store**. Files are stored as UTF-8 text blobs keyed by a path string (e.g. `/skills/my-skill/SKILL.md`). The store is shared across all agents and backed by the same `BaseStore` instance configured per agent (`store_backend: memory` or `postgres`). + +### Endpoints + +| Method | Path | Description | Success Status | +|---|---|---|---| +| `GET` | `/api/v1/store/files?prefix=` | List file paths matching `prefix` (default `/` = all) | `200` | +| `GET` | `/api/v1/store/files/{path}` | Retrieve a single file's content | `200` | +| `PUT` | `/api/v1/store/files/{path}` | Create or replace a file (body: `{"content": "..."}`) | `200` | +| `DELETE` | `/api/v1/store/files/{path}` | Delete a file (idempotent) | `204` | + +The `{path}` segment uses FastAPI's `:path` converter, so it can contain slashes (e.g. `skills/my-skill/SKILL.md`). Do not include a leading slash in the URL. + +### Listing files + +```bash +curl 'http://localhost:8000/api/v1/store/files?prefix=/skills/' +``` + +Response (`200`) — a JSON array of path strings: + +```json +["skills/code-review/SKILL.md", "skills/debugging/SKILL.md"] +``` + +### Getting a file + +```bash +curl http://localhost:8000/api/v1/store/files/skills/code-review/SKILL.md +``` + +Response (`200`): + +```json +{"path": "skills/code-review/SKILL.md", "content": "# Code Review Skill\n\n..."} +``` + +If the file does not exist, the API returns `404` with `{"detail": "File not found: skills/code-review/SKILL.md"}`. + +### Creating or replacing a file + +```bash +curl -X PUT http://localhost:8000/api/v1/store/files/skills/code-review/SKILL.md \ + -H "Content-Type: application/json" \ + -d '{"content": "# Code Review Skill\n\nReview code for correctness and security."}' +``` + +Response (`200`): + +```json +{"path": "skills/code-review/SKILL.md", "content": "# Code Review Skill\n\nReview code for correctness and security."} +``` + +### Deleting a file + +```bash +curl -X DELETE http://localhost:8000/api/v1/store/files/skills/code-review/SKILL.md +``` + +Response: `204 No Content`. The operation is idempotent — deleting a non-existent path does not raise. + +### Agent Namespace + +When an agent is created (or updated) with `skills` or `memory` configured, the selected files are **copied into a dedicated namespace** in the store, scoped to that agent: + +- **Skills:** `/agents/{agent_name}/skills/{skill_name}/SKILL.md` +- **Memories:** `/agents/{agent_name}/memories/{filename}` + +This ensures each agent only loads the skills and memories explicitly selected in its configuration, not every file in the global `/skills/` and `/memories/` directories. + +When an agent is updated and a skill or memory is **removed** from the selection, the corresponding copy in the agent namespace is **deleted** automatically. + +To discover which agents reference a given skill, use the usage-tracking endpoint: + +```bash +curl http://localhost:8000/api/v1/store/skills/my-skill/usage +``` + +Response (`200`) — the list of agent names that have `my-skill` in their namespace: + +```json +["code-reviewer", "research-assistant"] +``` + +--- + +## Skills Management + +Skills are `SKILL.md` files stored in the LangGraph store under the `/skills/` prefix. They describe reusable capabilities that an agent can load via its `skills` config field. Skills can now be created, edited, and deleted **via the Store File API** or the **frontend UI** (dedicated "Skills" page in the sidebar). + +### Creating a skill via curl + +```bash +curl -X PUT http://localhost:8000/api/v1/store/files/skills/my-skill/SKILL.md \ + -H "Content-Type: application/json" \ + -d '{"content": "# my-skill\n\n## Description\nA skill for ..."}' +``` + +### Referencing a skill in an agent + +In the agent YAML, point the `skills` field at the skill path (without the leading slash): + +```yaml +name: my-agent +skills: + - "skills/my-skill/" +``` + +In the frontend agent form, the Skills field is a **multi-select dropdown** (`PillMultiSelect`) that lists all available skills discovered in the store (paths matching `/skills/`), replacing the previous free-text input. + +--- + +## Memories Management + +Memories are Markdown files (e.g. `AGENTS.md`) stored in the LangGraph store, typically under the `/memories/` prefix. They provide persistent context that an agent loads via its `memory` config field. Memories can now be created, edited, and deleted **via the Store File API** or the **frontend UI** (dedicated "Memories" page in the sidebar). + +### Creating a memory via curl + +```bash +curl -X PUT http://localhost:8000/api/v1/store/files/memories/AGENTS.md \ + -H "Content-Type: application/json" \ + -d '{"content": "# Project Guidelines\n\n- Always write tests.\n- Follow the hexagonal architecture."}' +``` + +### Referencing a memory in an agent + +In the agent YAML, point the `memory` field at the memory path: + +```yaml +name: my-agent +memory: + - "memories/AGENTS.md" +``` + +In the frontend agent form, the Memory field is a **multi-select dropdown** (`PillMultiSelect`) that lists all available memories from the store, replacing the previous free-text input. + +--- + ## Architecture composable-agents follows a strict **hexagonal architecture** (ports and adapters). The domain layer has zero dependencies on frameworks or infrastructure. @@ -1038,11 +1192,13 @@ composable-agents/ chat.py # POST /api/v1/chat/{id} and /stream trace.py # GET /api/v1/threads/{id}/history and /trace agents.py # GET /api/v1/agents + store.py # Store File API — /api/v1/store/files websocket.py # WS /api/v1/ws/{id} use_cases/ send_message.py # Invoke agent synchronously stream_message.py # Stream agent response get_thread_history.py # Build ThreadHistory from trace_events (group by turn_id) + manage_store_file.py # ListStoreFiles / GetStoreFile / PutStoreFile / DeleteStoreFile use cases create_agent_config.py # Create agent config (MinIO + Postgres) update_agent_config.py # Update agent config delete_agent_config.py # Delete agent config @@ -1067,6 +1223,7 @@ composable-agents/ agent_registry.py # Abstract: get_runner(name), list_agents(), close() agent_runner.py # Abstract: invoke, stream, HITL operations mcp_tool_loader.py # Abstract: load MCP tools + store_file_repository.py # Abstract: file CRUD on the LangGraph store (StoreFileRepository port) thread_repository.py # Abstract: CRUD for threads trace_event_repository.py # Abstract: persist/append/list TraceEvents tracing_provider.py # Abstract: tracing lifecycle @@ -1081,7 +1238,7 @@ composable-agents/ trace_event.py # TraceEventModel (ORM) deepagent/ adapter.py # DeepAgentRunner (LangGraph adapter) — emits TraceEvent - factory.py # create_agent_from_config (resolves tools, middleware, backend) + factory.py # create_agent_from_config (resolves tools, backend) registry.py # DeepAgentRegistry (lazy loading + caching from agents/ dir) example_tools.py # Example tools: current_time, word_count mcp/ @@ -1090,6 +1247,8 @@ composable-agents/ adapter.py # MinioAgentConfigStore (YAML blob storage) persistent_registry/ adapter.py # PersistentAgentRegistry (MinIO + Postgres backed) + store_file/ + adapter.py # LangGraphStoreFileRepository (LangGraph BaseStore adapter) postgres_repository/ adapter.py # PostgresAgentConfigRepository postgres_thread/ @@ -1184,7 +1343,7 @@ mcp_servers: ### Research Assistant with Tools -`agents/research-assistant.yaml` -- an agent with custom tools and filesystem persistence. +`agents/research-assistant.yaml` -- an agent with custom tools. ```yaml name: research-assistant @@ -1195,17 +1354,14 @@ system_prompt: | tools: - "src.infrastructure.deepagent.example_tools:current_time" - "src.infrastructure.deepagent.example_tools:word_count" -middleware: - - filesystem backend: - type: filesystem - root_dir: "./workspace" + type: state debug: false ``` ### Code Reviewer with HITL and Subagents -`agents/code-reviewer.yaml` -- a multi-agent system with human-in-the-loop approval. +`agents/code-reviewer.yaml` -- a multi-agent system with human-in-the-loop approval. The Sub-Agent middleware is installed automatically because `subagents` is non-empty. ```yaml name: code-reviewer @@ -1213,9 +1369,6 @@ model: "claude-sonnet-4-5-20250929" system_prompt: | You are an expert code reviewer. Analyze code for correctness, performance, security, and maintainability. -middleware: - - filesystem - - sub_agent backend: type: state hitl: @@ -1504,7 +1657,7 @@ Railway project See [CONTRIBUTING.md](CONTRIBUTING.md) for details on: - Project architecture and dependency rules -- How to add custom tools, middlewares, and backends +- How to add custom tools and backends - How the YAML schema works - Running tests and linting - Code style conventions diff --git a/agents/README.md b/agents/README.md index cff8479..c66ee94 100644 --- a/agents/README.md +++ b/agents/README.md @@ -4,8 +4,8 @@ | File | Description | |------|-------------| -| `single/haiku-files.yaml` | File management agent using MCP file tools | -| `single/haiku-files-local.yaml` | File management agent with local filesystem access | +| `single/haiku-files.yaml` | File management agent using MCP file tools (docker network endpoint) | +| `single/haiku-files-local.yaml` | File management agent using MCP file tools (local raganything endpoint) | | `single/haiku-files-local-structured.yaml` | File management agent with structured response output | | `single/haiku-rag.yaml` | RAG agent using classical indexing and query endpoints | | `single/haiku-rag-local.yaml` | RAG agent with local MinIO configuration | diff --git a/pyproject.toml b/pyproject.toml index 1ae29e1..025a2ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,16 +11,18 @@ version = "0.1.0" description = "Composable AI agents with YAML configuration, MCP tool integration, and PostgreSQL persistence" requires-python = ">=3.11" dependencies = [ - "deepagents>=0.6.10", + "deepagents>=0.6.12", "cryptography>=48.0.1", "langchain-core>=1.4.7", - "pyasn1>=0.6.3", + "pyasn1>=0.6.4", "pyjwt>=2.13.0", "langgraph>=1.2.5", + "langgraph-checkpoint-postgres>=3.0.5", + "psycopg[binary]>=3.2.0", "requests>=2.33.0", "fastapi>=0.128.4", "langchain-mcp-adapters>=0.3.0", - "mcp>=1.27.0", + "mcp>=1.28.1", "langchain-openai>=1.1.15", "pydantic>=2.12.5", "pydantic-settings>=2.14.2", diff --git a/src/application/routes/store.py b/src/application/routes/store.py new file mode 100644 index 0000000..42fa597 --- /dev/null +++ b/src/application/routes/store.py @@ -0,0 +1,152 @@ +"""FastAPI routes for managing files in the LangGraph store. + +Endpoints: + GET /api/v1/store/files — list file paths (optional ``prefix`` query param) + GET /api/v1/store/files/{path} — get file content (200 or 404) + PUT /api/v1/store/files/{path} — create or replace a file (200) + DELETE /api/v1/store/files/{path} — delete a file (204) +""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, status +from pydantic import BaseModel + +from src.application.use_cases.manage_store_file import ( + DeleteStoreFileUseCase, + GetStoreFileUseCase, + ListStoreFilesUseCase, + PutStoreFileUseCase, +) +from src.dependencies import ( + get_delete_store_file_use_case, + get_get_store_file_use_case, + get_list_store_files_use_case, + get_put_store_file_use_case, +) +from src.domain.errors.store_file import StoreFileNotFoundError + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/store", tags=["store"]) + + +def _normalize_path(path: str) -> str: + """Ensure the path starts with a forward slash for store key consistency.""" + return path if path.startswith("/") else f"/{path}" + + +class StoreFileResponse(BaseModel): + """Response DTO for a store file.""" + + path: str + content: str + + +class StoreFilePutRequest(BaseModel): + """Request body for creating or replacing a store file.""" + + content: str + + +@router.get("/files", response_model=list[str], status_code=status.HTTP_200_OK) +async def list_store_files( + use_case: Annotated[ListStoreFilesUseCase, Depends(get_list_store_files_use_case)], + prefix: str = Query(default="/", description="Path prefix to filter files by."), +) -> list[str]: + """List file paths in the store, optionally filtered by prefix. + + Args: + use_case: Injected list files use case. + prefix: Path prefix to filter on (default ``"/"`` = all files). + + Returns: + A list of file path strings. + """ + return await use_case.execute(prefix=prefix) + + +@router.get("/files/{path:path}", response_model=StoreFileResponse, status_code=status.HTTP_200_OK) +async def get_store_file( + path: str, + use_case: Annotated[GetStoreFileUseCase, Depends(get_get_store_file_use_case)], +) -> StoreFileResponse: + """Retrieve a single file by path. + + Args: + path: The file path (captured from the URL, no leading slash). + use_case: Injected get file use case. + + Returns: + A ``StoreFileResponse`` with the path and content. + + Raises: + StoreFileNotFoundError: If the file does not exist in the store. + """ + content = await use_case.execute(path=_normalize_path(path)) + if content is None: + raise StoreFileNotFoundError(f"File not found: {path}") + return StoreFileResponse(path=_normalize_path(path), content=content) + + +@router.put("/files/{path:path}", response_model=StoreFileResponse, status_code=status.HTTP_200_OK) +async def put_store_file( + path: str, + body: StoreFilePutRequest, + use_case: Annotated[PutStoreFileUseCase, Depends(get_put_store_file_use_case)], +) -> StoreFileResponse: + """Create or replace a file in the store. + + Args: + path: The file path (captured from the URL, no leading slash). + body: Request body containing the file content. + use_case: Injected put file use case. + + Returns: + A ``StoreFileResponse`` with the path and stored content. + """ + normalized = _normalize_path(path) + content = await use_case.execute(path=normalized, content=body.content) + return StoreFileResponse(path=normalized, content=content) + + +@router.delete("/files/{path:path}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_store_file( + path: str, + use_case: Annotated[DeleteStoreFileUseCase, Depends(get_delete_store_file_use_case)], +) -> None: + """Delete a file from the store. + + Args: + path: The file path (captured from the URL, no leading slash). + use_case: Injected delete file use case. + """ + await use_case.execute(path=_normalize_path(path)) + + +@router.get("/skills/{skill_name}/usage", response_model=list[str], status_code=status.HTTP_200_OK) +async def get_skill_usage( + skill_name: str, + use_case: Annotated[ListStoreFilesUseCase, Depends(get_list_store_files_use_case)], +) -> list[str]: + """List all agents that have a copy of the given skill. + + Scans the store for paths matching /agents/*/skills/{skill_name}/SKILL.md + and returns the sorted list of agent names. + + Args: + skill_name: Name of the skill to check usage for. + use_case: Injected list files use case. + + Returns: + A sorted list of agent names that have this skill in their namespace. + """ + all_files = await use_case.execute(prefix="/agents/") + agents = set() + for path in all_files: + if f"/skills/{skill_name}/" in path: + parts = path.split("/") + if len(parts) >= 3 and parts[1] == "agents": + agents.add(parts[2]) + return sorted(agents) diff --git a/src/application/routes/trace.py b/src/application/routes/trace.py index dc534fd..d0142c5 100644 --- a/src/application/routes/trace.py +++ b/src/application/routes/trace.py @@ -8,11 +8,11 @@ from fastapi import APIRouter, Depends +from src.application.use_cases.get_thread import GetThreadUseCase from src.dependencies import get_get_thread_use_case, get_trace_event_repository from src.domain.entities.trace_event import TraceEvent from src.domain.logging.messages import LogMessage from src.domain.ports.trace_event_repository import TraceEventRepository -from src.application.use_cases.get_thread import GetThreadUseCase logger = logging.getLogger(__name__) diff --git a/src/application/use_cases/manage_store_file.py b/src/application/use_cases/manage_store_file.py new file mode 100644 index 0000000..cc8a02f --- /dev/null +++ b/src/application/use_cases/manage_store_file.py @@ -0,0 +1,100 @@ +"""Use cases for managing files in the LangGraph store. + +Each use case is a thin orchestrator that delegates to a +:class:`StoreFileRepository` (outbound port). The use cases contain no +business logic — they are pure pass-throughs following SRP (one class = +one action). +""" + +from src.domain.ports.store_file_repository import StoreFileRepository + + +class ListStoreFilesUseCase: + """List file paths in the store filtered by an optional prefix.""" + + def __init__(self, repository: StoreFileRepository) -> None: + """Initialize the use case. + + Args: + repository: The store file repository (outbound port). + """ + self._repository = repository + + async def execute(self, prefix: str = "/") -> list[str]: + """List files matching the given prefix. + + Args: + prefix: Path prefix to filter on (default ``"/"`` = all files). + + Returns: + A list of file path strings. + """ + return await self._repository.list_files(prefix) + + +class GetStoreFileUseCase: + """Retrieve a single file's content by path.""" + + def __init__(self, repository: StoreFileRepository) -> None: + """Initialize the use case. + + Args: + repository: The store file repository (outbound port). + """ + self._repository = repository + + async def execute(self, path: str) -> str | None: + """Get file content by path. + + Args: + path: The file path to retrieve. + + Returns: + The file content as a string, or ``None`` if not found. + """ + return await self._repository.get_file(path) + + +class PutStoreFileUseCase: + """Create or replace a file in the store.""" + + def __init__(self, repository: StoreFileRepository) -> None: + """Initialize the use case. + + Args: + repository: The store file repository (outbound port). + """ + self._repository = repository + + async def execute(self, path: str, content: str) -> str: + """Store the given content at the given path. + + Args: + path: The file path to write. + content: The UTF-8 text content to store. + + Returns: + The content that was stored. + """ + await self._repository.put_file(path, content) + return content + + +class DeleteStoreFileUseCase: + """Delete a file from the store.""" + + def __init__(self, repository: StoreFileRepository) -> None: + """Initialize the use case. + + Args: + repository: The store file repository (outbound port). + """ + self._repository = repository + + async def execute(self, path: str) -> None: + """Delete a file by path. + + Args: + path: The file path to delete. + """ + await self._repository.delete_file(path) diff --git a/src/dependencies.py b/src/dependencies.py index 8755eba..286b270 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -18,6 +18,12 @@ from src.application.use_cases.list_agent_configs import ListAgentConfigsUseCase from src.application.use_cases.list_threads import ListThreadsUseCase from src.application.use_cases.load_agent_config import LoadAgentConfigUseCase +from src.application.use_cases.manage_store_file import ( + DeleteStoreFileUseCase, + GetStoreFileUseCase, + ListStoreFilesUseCase, + PutStoreFileUseCase, +) from src.application.use_cases.send_message import SendMessageUseCase from src.application.use_cases.stream_message import StreamMessageUseCase from src.application.use_cases.update_agent_config import UpdateAgentConfigUseCase @@ -28,6 +34,7 @@ from src.domain.logging.messages import LogMessage from src.domain.ports.agent_registry import AgentRegistry from src.domain.ports.prompt_manager import PromptManager +from src.domain.ports.store_file_repository import StoreFileRepository from src.domain.ports.thread_repository import ThreadRepository from src.domain.ports.trace_event_repository import TraceEventRepository from src.domain.ports.tracing_provider import TracingProvider @@ -38,6 +45,7 @@ from src.infrastructure.postgres_thread.adapter import PostgresThreadRepository from src.infrastructure.postgres_trace.adapter import PostgresTraceEventRepository from src.infrastructure.prompt_management.adapter import PhoenixPromptManagerProvider +from src.infrastructure.store_file.adapter import LangGraphStoreFileRepository from src.infrastructure.tracing.noop_adapter import NoopTracingProvider from src.infrastructure.yaml_config.adapter import YamlAgentConfigLoader from src.security import ComposableAgentsSecurity @@ -127,6 +135,7 @@ class CompositionRoot: agent_registry: AgentRegistry | None = None thread_repository: ThreadRepository | None = None trace_event_repository: TraceEventRepository | None = None + store_file_repository: StoreFileRepository | None = None _root = CompositionRoot() @@ -195,6 +204,23 @@ async def init_persistence() -> None: invoke_timeout=settings.agent_invoke_timeout, ) + # Store file repository — reuse the singleton LangGraph BaseStore from the + # deepagent factory (AsyncPostgresStore) so the file API shares the same + # connection pool as the agents. Falls back to the shared InMemoryStore + # singleton on init failure. + try: + from src.infrastructure.deepagent.factory import _create_postgres_store + + store = await _create_postgres_store(settings) + _root.store_file_repository = LangGraphStoreFileRepository(store=store) + logger.info(LogMessage.PERSISTENCE_STORE_FILE_INITIALIZED) + except Exception: + logger.exception(LogMessage.PERSISTENCE_STORE_FILE_INIT_FAILED) + from src.infrastructure.deepagent.factory import _get_memory_store + + _root.store_file_repository = LangGraphStoreFileRepository(store=_get_memory_store()) + logger.info(LogMessage.PERSISTENCE_STORE_FILE_FALLBACK_INMEMORY) + logger.info(LogMessage.PERSISTENCE_REGISTRY_SET) @@ -220,6 +246,7 @@ def reset() -> None: _root.agent_registry = None _root.thread_repository = None _root.trace_event_repository = None + _root.store_file_repository = None logger.info(LogMessage.DEPENDENCIES_INITIALIZED) @@ -369,3 +396,45 @@ def get_update_prompt_use_case() -> UpdatePromptUseCase: def get_get_prompt_content_use_case() -> GetPromptContentUseCase: """Provide a GetPromptContentUseCase instance.""" return GetPromptContentUseCase(get_prompt_manager()) + + +# ============= STORE FILE PROVIDERS ============= + + +def _require_store_file_repository() -> StoreFileRepository: + """Return store file repository, creating an in-memory fallback if not initialized. + + During tests, ``init_persistence`` is not called, so the repository would + be ``None``. To keep the API functional without a database, we lazily create + an :class:`InMemoryStore`-backed adapter on first access. + """ + if _root.store_file_repository is None: + from src.infrastructure.deepagent.factory import _get_memory_store + + _root.store_file_repository = LangGraphStoreFileRepository(store=_get_memory_store()) + return _root.store_file_repository + + +def get_store_file_repository() -> StoreFileRepository: + """Provide a :class:`StoreFileRepository` instance (singleton wired at startup).""" + return _require_store_file_repository() + + +def get_list_store_files_use_case() -> ListStoreFilesUseCase: + """Provide a :class:`ListStoreFilesUseCase` instance.""" + return ListStoreFilesUseCase(_require_store_file_repository()) + + +def get_get_store_file_use_case() -> GetStoreFileUseCase: + """Provide a :class:`GetStoreFileUseCase` instance.""" + return GetStoreFileUseCase(_require_store_file_repository()) + + +def get_put_store_file_use_case() -> PutStoreFileUseCase: + """Provide a :class:`PutStoreFileUseCase` instance.""" + return PutStoreFileUseCase(_require_store_file_repository()) + + +def get_delete_store_file_use_case() -> DeleteStoreFileUseCase: + """Provide a :class:`DeleteStoreFileUseCase` instance.""" + return DeleteStoreFileUseCase(_require_store_file_repository()) diff --git a/src/domain/entities/agent_config.py b/src/domain/entities/agent_config.py index 576351c..565ee02 100644 --- a/src/domain/entities/agent_config.py +++ b/src/domain/entities/agent_config.py @@ -1,27 +1,18 @@ from enum import StrEnum from typing import Any, Literal, Self -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from src.domain.entities.mcp_server_config import McpServerConfig -class MiddlewareType(StrEnum): - TODO_LIST = "todo_list" - FILESYSTEM = "filesystem" - SUB_AGENT = "sub_agent" - - class BackendType(StrEnum): - STATE = "state" STORE = "store" - FILESYSTEM = "filesystem" - COMPOSITE = "composite" class BackendConfig(BaseModel): - type: BackendType = BackendType.STATE - root_dir: str | None = None + type: BackendType = BackendType.STORE + checkpoint_backend: Literal["memory", "postgres"] = "memory" class InterruptRule(BaseModel): @@ -43,15 +34,16 @@ class SubAgentConfig(BaseModel): response_format: dict[str, Any] | None = None -class AgentConfig(BaseModel, frozen=True): +class AgentConfig(BaseModel): """Schema principal de configuration d'un Deep Agent via YAML.""" + model_config = ConfigDict(frozen=True, extra="forbid") + name: str = Field(..., min_length=1, max_length=100) model: str = Field(default="claude-sonnet-4-5-20250929") system_prompt: str | None = None system_prompt_file: str | None = None tools: list[str] = Field(default_factory=list) - middleware: list[MiddlewareType] = Field(default_factory=list) backend: BackendConfig = Field(default_factory=BackendConfig) hitl: HITLConfig = Field(default_factory=HITLConfig) memory: list[str] = Field(default_factory=list) diff --git a/src/domain/entities/message.py b/src/domain/entities/message.py index 7f1c55a..ab76dcd 100644 --- a/src/domain/entities/message.py +++ b/src/domain/entities/message.py @@ -82,6 +82,4 @@ def from_trace_event(event: "TraceEvent") -> "Message": turn_id=event.turn_id, ) - raise MessageBuildError( - f"Cannot build Message from trace event type {event.type!r}" - ) + raise MessageBuildError(f"Cannot build Message from trace event type {event.type!r}") diff --git a/src/domain/errors/store_file.py b/src/domain/errors/store_file.py new file mode 100644 index 0000000..7fdb1dc --- /dev/null +++ b/src/domain/errors/store_file.py @@ -0,0 +1,10 @@ +"""Store file domain errors.""" + +from src.domain.errors.base import DomainError +from src.domain.errors.codes import ErrorCode + + +class StoreFileNotFoundError(DomainError): + """File not found in the store.""" + + status_code = ErrorCode.NOT_FOUND diff --git a/src/domain/logging/messages.py b/src/domain/logging/messages.py index a2efc28..02183df 100644 --- a/src/domain/logging/messages.py +++ b/src/domain/logging/messages.py @@ -46,6 +46,11 @@ class LogMessage(StrEnum): PERSISTENCE_REGISTRY_SET = "Persistence layer initialized, agent_registry set to PersistentAgentRegistry" PERSISTENT_REGISTRY_CLOSED = "Persistent registry closed" SQLALCHEMY_ENGINE_DISPOSED = "SQLAlchemy engine disposed" + PERSISTENCE_STORE_FILE_INITIALIZED = "Store file repository initialized (AsyncPostgresStore)" + PERSISTENCE_STORE_FILE_INIT_FAILED = ( + "Failed to initialize store file repository with Postgres, falling back to InMemoryStore" + ) + PERSISTENCE_STORE_FILE_FALLBACK_INMEMORY = "Store file repository initialized (InMemoryStore fallback)" # --- Agent config management --- AGENT_CONFIG_LISTED = "Listed %d agent configs" diff --git a/src/domain/ports/store_file_repository.py b/src/domain/ports/store_file_repository.py new file mode 100644 index 0000000..b1f4280 --- /dev/null +++ b/src/domain/ports/store_file_repository.py @@ -0,0 +1,52 @@ +"""Outbound port: file repository backed by a key-value store.""" + +from abc import ABC, abstractmethod + + +class StoreFileRepository(ABC): + """Interface for reading and writing files in a namespace-scoped store. + + Implementations wrap an external key-value store (e.g. LangGraph + ``BaseStore``) and expose a simple file-centric CRUD contract. + """ + + @abstractmethod + async def list_files(self, prefix: str) -> list[str]: + """List file paths in the store that start with the given prefix. + + Args: + prefix: Path prefix to filter on (e.g. ``"/skills/"``). + + Returns: + A list of file path strings matching the prefix. + """ + + @abstractmethod + async def get_file(self, path: str) -> str | None: + """Get file content by path. + + Args: + path: The file path to retrieve. + + Returns: + The file content as a string, or ``None`` if not found. + """ + + @abstractmethod + async def put_file(self, path: str, content: str) -> None: + """Create or replace a file in the store. + + Args: + path: The file path to write. + content: The UTF-8 text content to store. + """ + + @abstractmethod + async def delete_file(self, path: str) -> None: + """Delete a file from the store. + + Idempotent: no error is raised if the path does not exist. + + Args: + path: The file path to delete. + """ diff --git a/src/infrastructure/deepagent/factory.py b/src/infrastructure/deepagent/factory.py index 92f6025..0eb3eed 100644 --- a/src/infrastructure/deepagent/factory.py +++ b/src/infrastructure/deepagent/factory.py @@ -3,12 +3,15 @@ from typing import Any from deepagents import create_deep_agent -from deepagents.backends import FilesystemBackend, StoreBackend +from deepagents.backends import StoreBackend from langgraph.checkpoint.memory import MemorySaver +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver from langgraph.store.memory import InMemoryStore +from langgraph.store.postgres import AsyncPostgresStore from pydantic import BaseModel -from src.domain.entities.agent_config import AgentConfig, BackendType, SubAgentConfig +from src.config import Settings +from src.domain.entities.agent_config import AgentConfig, SubAgentConfig from src.domain.logging.messages import LogMessage from src.domain.ports.mcp_tool_loader import McpToolLoader from src.domain.ports.prompt_manager import PromptManager @@ -17,6 +20,108 @@ logger = logging.getLogger(__name__) +def _to_pg_conn_string(database_url: str) -> str: + """Convert an asyncpg-normalized URL to a plain PostgreSQL connection string. + + The ``Settings.database_url`` is normalized to ``postgresql+asyncpg://`` for + use with SQLAlchemy/asyncpg. langgraph-postgres uses ``psycopg`` and expects + a plain ``postgresql://`` URL. This strips the ``+asyncpg`` driver suffix. + + Args: + database_url: The normalized database URL (``postgresql+asyncpg://...``). + + Returns: + A plain ``postgresql://`` connection string. + """ + return database_url.replace("postgresql+asyncpg://", "postgresql://", 1) + + +_pg_store: AsyncPostgresStore | None = None +_pg_checkpointer: AsyncPostgresSaver | None = None +_pg_store_cm: Any = None +_pg_checkpointer_cm: Any = None +_memory_store: InMemoryStore | None = None + + +def _get_memory_store() -> InMemoryStore: + """Get or create a singleton ``InMemoryStore`` shared across all agents. + + This ensures that files written via the Store File API (which also uses this + singleton when ``store_backend`` is ``"memory"``) are visible to agents that + use the in-memory store. + """ + global _memory_store + if _memory_store is None: + _memory_store = InMemoryStore() + return _memory_store + + +async def _get_shared_store(): + """Get the global shared store instance. + + If the Postgres store was initialized at startup (by ``dependencies.py``), + use that. Otherwise fall back to the shared ``InMemoryStore`` singleton. + This ensures the Store File API and all agents share the same store, + regardless of per-agent ``store_backend`` config. + """ + if _pg_store is not None: + return _pg_store + try: + return await _create_postgres_store() + except Exception: + return _get_memory_store() + + +async def _create_postgres_store(settings: Settings | None = None) -> AsyncPostgresStore: + """Get or create a singleton ``AsyncPostgresStore`` backed by Postgres. + + The store is created once and reused across all agent creations to avoid + leaking connection pools. Schema migrations are applied on first creation. + The async context manager reference is kept alive to prevent premature + connection closure by the garbage collector. + + Args: + settings: Optional settings override. Defaults to a fresh ``Settings``. + + Returns: + A configured ``AsyncPostgresStore`` with schema migrations applied. + """ + global _pg_store, _pg_store_cm + if _pg_store is not None: + return _pg_store + s = settings or Settings() + conn_string = _to_pg_conn_string(s.database_url) + _pg_store_cm = AsyncPostgresStore.from_conn_string(conn_string) + _pg_store = await _pg_store_cm.__aenter__() + await _pg_store.setup() + return _pg_store + + +async def _create_postgres_checkpointer(settings: Settings | None = None) -> AsyncPostgresSaver: + """Get or create a singleton ``AsyncPostgresSaver`` checkpointer backed by Postgres. + + The checkpointer is created once and reused across all agent creations to + avoid leaking connection pools. Schema migrations are applied on first + creation. The async context manager reference is kept alive to prevent + premature connection closure by the garbage collector. + + Args: + settings: Optional settings override. Defaults to a fresh ``Settings``. + + Returns: + A configured ``AsyncPostgresSaver`` with schema migrations applied. + """ + global _pg_checkpointer, _pg_checkpointer_cm + if _pg_checkpointer is not None: + return _pg_checkpointer + s = settings or Settings() + conn_string = _to_pg_conn_string(s.database_url) + _pg_checkpointer_cm = AsyncPostgresSaver.from_conn_string(conn_string) + _pg_checkpointer = await _pg_checkpointer_cm.__aenter__() + await _pg_checkpointer.setup() + return _pg_checkpointer + + def _resolve_response_format(value: dict[str, Any] | None) -> tuple[type[BaseModel] | None, dict[str, Any] | None]: """Resolve a ``response_format`` config value into ``(model, schema_dict)``. @@ -61,23 +166,20 @@ def _resolve_tools(config: AgentConfig) -> list | None: return tools -def _resolve_backend(config: AgentConfig): - """Cree le backend selon la config.""" - match config.backend.type: - case BackendType.STATE: - return None # Defaut de create_deep_agent - case BackendType.FILESYSTEM: - # virtual_mode=False keeps the historical behaviour (root_dir-bounded - # persistence without virtual path routing). Specified explicitly to - # silence the deepagents>=0.6 default-change deprecation warning. - return FilesystemBackend( - root_dir=config.backend.root_dir or "./workspace", - virtual_mode=False, - ) - case BackendType.STORE: - return lambda rt: StoreBackend(rt) - case BackendType.COMPOSITE: - return None # Fallback, necessite config avancee +def _resolve_backend(store): + """Create the backend for the agent. + + The only supported backend is ``StoreBackend`` which reads/writes files + from the shared LangGraph store (Postgres). This ensures skills and + memories created via the Store File API are visible to all agents. + + Args: + store: The shared store instance (Postgres or InMemoryStore). + + Returns: + A ``StoreBackend`` instance. + """ + return StoreBackend(store=store, namespace=lambda _r: ("filesystem",)) def _resolve_interrupt_on(config: AgentConfig) -> dict | None: @@ -158,18 +260,86 @@ def _resolve_tools_list(tool_paths: list[str]) -> list | None: return tools or None -def _apply_optional_kwargs(kwargs: dict, config: AgentConfig) -> None: - """Populate optional kwargs from config if their values are set.""" - backend = _resolve_backend(config) +def _apply_optional_kwargs(kwargs: dict, config: AgentConfig, store) -> None: + """Populate optional kwargs from config if their values are set. + + Args: + kwargs: The kwargs dict passed to ``create_deep_agent``. + config: Configuration de l'agent. + store: The store instance (in-memory or Postgres) to wire into ``StoreBackend``. + """ + backend = _resolve_backend(store) if backend: kwargs["backend"] = backend interrupt_on = _resolve_interrupt_on(config) if interrupt_on: kwargs["interrupt_on"] = interrupt_on - if config.memory: - kwargs["memory"] = config.memory - if config.skills: - kwargs["skills"] = config.skills + + +async def _prepare_agent_namespace( + store, + agent_name: str, + skills: list[str], + memory: list[str], +) -> tuple[str, list[str]]: + """Copy selected skills and memories to the agent's namespace in the store. + + The agent namespace follows the pattern: + - Skills: /agents/{agent_name}/skills/{skill_name}/SKILL.md + - Memories: /agents/{agent_name}/memories/{filename} + + This ensures SkillsMiddleware only discovers skills explicitly selected + for this agent, not all skills in the global /skills/ directory. + + Args: + store: The shared LangGraph BaseStore instance. + agent_name: Name of the agent. + skills: List of skill directory paths (e.g. ["/skills/mcp/", "/skills/rag/"]). + memory: List of memory file paths (e.g. ["/memories/AGENTS.md"]). + + Returns: + Tuple of (skills_source_path, memory_paths) for create_deep_agent. + """ + ns = ("filesystem",) + agent_skills_dir = f"/agents/{agent_name}/skills/" + agent_memories_dir = f"/agents/{agent_name}/memories/" + + # 1. Cleanup: delete files in agent namespace that are no longer selected + existing_items = await store.asearch(ns, limit=100) + selected_skill_names = {s.rstrip("/").split("/")[-1] for s in skills} + selected_memory_files = {m.split("/")[-1] for m in memory} + + for item in existing_items: + if item.key.startswith(agent_skills_dir): + remainder = item.key[len(agent_skills_dir) :] + skill_name = remainder.split("/")[0] if "/" in remainder else remainder + if skill_name not in selected_skill_names: + await store.adelete(ns, item.key) + elif item.key.startswith(agent_memories_dir): + filename = item.key[len(agent_memories_dir) :] + if filename not in selected_memory_files: + await store.adelete(ns, item.key) + + # 2. Copy selected skills to agent namespace + for skill_dir in skills: + skill_name = skill_dir.rstrip("/").split("/")[-1] + src_path = f"{skill_dir.rstrip('/')}/SKILL.md" + dst_path = f"{agent_skills_dir}{skill_name}/SKILL.md" + item = await store.aget(ns, src_path) + if item is not None: + await store.aput(ns, dst_path, item.value) + + # 3. Copy selected memories to agent namespace + new_memory_paths: list[str] = [] + for mem_path in memory: + filename = mem_path.split("/")[-1] + dst_path = f"{agent_memories_dir}{filename}" + item = await store.aget(ns, mem_path) + if item is not None: + await store.aput(ns, dst_path, item.value) + new_memory_paths.append(dst_path) + + return agent_skills_dir, new_memory_paths async def create_agent_from_config( @@ -187,8 +357,17 @@ async def create_agent_from_config( Tuple of (compiled agent graph, response_format_model or None). """ logger.info(LogMessage.AGENT_CREATING, config.name, config.model) - checkpointer = MemorySaver() - store = InMemoryStore() + if config.backend.checkpoint_backend == "postgres": + checkpointer = await _create_postgres_checkpointer() + else: + checkpointer = MemorySaver() + + # The store is a global singleton shared between the Store File API and all + # agents. If Postgres was initialized at startup (by dependencies.py), use + # that. Otherwise use the shared InMemoryStore. This ensures skills and + # memories created via the API are visible to agents regardless of the + # per-agent store_backend setting. + store = await _get_shared_store() local_tools = _resolve_tools(config) mcp_tools: list = [] @@ -201,17 +380,31 @@ async def create_agent_from_config( system_prompt = await get_system_prompt_from_phoenix(config.name, prompt_manager) if prompt_manager else None + # Prepare agent namespace: copy selected skills and memories to + # /agents/{name}/skills/ and /agents/{name}/memories/ so that + # SkillsMiddleware only loads the selected ones. + skills_source: str | None = None + memory_paths: list[str] | None = None + if config.skills: + skills_source, memory_paths = await _prepare_agent_namespace(store, config.name, config.skills, config.memory) + elif config.memory: + _, memory_paths = await _prepare_agent_namespace(store, config.name, [], config.memory) + kwargs = { "name": config.name, "model": config.model, "system_prompt": system_prompt if system_prompt else config.system_prompt, "tools": all_tools, - "middleware": [], "checkpointer": checkpointer, "store": store, } - _apply_optional_kwargs(kwargs, config) + if skills_source: + kwargs["skills"] = [skills_source] + if memory_paths: + kwargs["memory"] = memory_paths + + _apply_optional_kwargs(kwargs, config, store) if config.response_format: response_format_model, response_format_dict = _resolve_response_format(config.response_format) diff --git a/src/infrastructure/store_file/__init__.py b/src/infrastructure/store_file/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/store_file/adapter.py b/src/infrastructure/store_file/adapter.py new file mode 100644 index 0000000..0c6bb32 --- /dev/null +++ b/src/infrastructure/store_file/adapter.py @@ -0,0 +1,71 @@ +"""LangGraph ``BaseStore`` adapter implementing :class:`StoreFileRepository`.""" + +from langgraph.store.base import BaseStore + +from src.domain.ports.store_file_repository import StoreFileRepository + +_DEFAULT_NAMESPACE: tuple[str, ...] = ("filesystem",) + + +class LangGraphStoreFileRepository(StoreFileRepository): + """Store file repository backed by a LangGraph ``BaseStore``. + + Files are stored as key-value pairs where the key is the file path and + the value is ``{"content": str, "encoding": "utf-8"}``. + """ + + def __init__(self, store: BaseStore, namespace: tuple[str, ...] = _DEFAULT_NAMESPACE) -> None: + """Initialize the repository. + + Args: + store: The LangGraph ``BaseStore`` instance (InMemoryStore or AsyncPostgresStore). + namespace: Namespace tuple for scoping files (default ``("filesystem",)``). + """ + self._store = store + self._namespace = namespace + + async def list_files(self, prefix: str) -> list[str]: + """List file paths in the store that start with the given prefix. + + Args: + prefix: Path prefix to filter on. + + Returns: + A list of file path strings matching the prefix. + """ + items = await self._store.asearch(self._namespace, limit=100) + return [item.key for item in items if item.key.startswith(prefix)] + + async def get_file(self, path: str) -> str | None: + """Get file content by path. + + Args: + path: The file path to retrieve. + + Returns: + The file content as a string, or ``None`` if not found or + the stored value is malformed (missing ``content`` key). + """ + item = await self._store.aget(self._namespace, path) + if item is None: + return None + return item.value.get("content") + + async def put_file(self, path: str, content: str) -> None: + """Create or replace a file in the store. + + Args: + path: The file path to write. + content: The UTF-8 text content to store. + """ + await self._store.aput(self._namespace, path, {"content": content, "encoding": "utf-8"}) + + async def delete_file(self, path: str) -> None: + """Delete a file from the store. + + Idempotent: no error is raised if the path does not exist. + + Args: + path: The file path to delete. + """ + await self._store.adelete(self._namespace, path) diff --git a/src/infrastructure/yaml_config/adapter.py b/src/infrastructure/yaml_config/adapter.py index 119b632..2872a5c 100644 --- a/src/infrastructure/yaml_config/adapter.py +++ b/src/infrastructure/yaml_config/adapter.py @@ -15,6 +15,27 @@ class YamlAgentConfigLoader(AgentConfigLoader): """Charge et valide une configuration d'agent depuis un fichier YAML.""" + # Fields removed from AgentConfig that may still exist in stored YAMLs. + # They are stripped before validation to maintain backward compatibility. + _DEPRECATED_FIELDS = {"middleware"} + _DEPRECATED_BACKEND_FIELDS = {"root_dir", "store_backend"} + + _DEPRECATED_BACKEND_FIELDS = {"root_dir", "store_backend"} + _DEPRECATED_BACKEND_TYPES = {"state"} + + @staticmethod + def _strip_deprecated(raw: dict) -> None: + """Remove deprecated fields from raw YAML dict before validation.""" + for field in YamlAgentConfigLoader._DEPRECATED_FIELDS: + raw.pop(field, None) + backend = raw.get("backend") + if isinstance(backend, dict): + for field in YamlAgentConfigLoader._DEPRECATED_BACKEND_FIELDS: + backend.pop(field, None) + # Migrate deprecated backend types to "store" (the only supported type). + if backend.get("type") in YamlAgentConfigLoader._DEPRECATED_BACKEND_TYPES: + backend["type"] = "store" + @staticmethod def _parse_yaml(content: str, source: str) -> dict: try: @@ -48,6 +69,7 @@ def load(self, config_path: str | Path) -> AgentConfig: raise ConfigNotFoundError(ErrorMessage.YAML_CONFIG_NOT_FOUND.format(path=path)) raw = self._parse_yaml(path.read_text(encoding="utf-8"), str(path)) + self._strip_deprecated(raw) if raw.get("system_prompt_file"): prompt_path = path.parent / raw["system_prompt_file"] @@ -65,6 +87,7 @@ def load_from_string(self, yaml_content: str, source: str = "") -> Agent raise ConfigError(ErrorMessage.YAML_EMPTY.format(source=source)) raw = self._parse_yaml(yaml_content, source) + self._strip_deprecated(raw) if raw.get("system_prompt_file"): logger.error(LogMessage.YAML_SYSTEM_PROMPT_FILE_DISALLOWED, source) diff --git a/src/main.py b/src/main.py index 94a2683..2e3ad87 100644 --- a/src/main.py +++ b/src/main.py @@ -13,6 +13,7 @@ from src.application.routes.chat import router as chat_router from src.application.routes.health import router as health_router from src.application.routes.prompt import router as prompt_router +from src.application.routes.store import router as store_router from src.application.routes.threads import router as threads_router from src.application.routes.trace import router as trace_router from src.application.routes.websocket import router as websocket_router @@ -36,6 +37,7 @@ ) from src.domain.errors.security import InvalidApiKeyError from src.domain.errors.storage import StorageError +from src.domain.errors.store_file import StoreFileNotFoundError from src.domain.errors.thread import ThreadNotFoundError from src.domain.logging.messages import LogMessage from src.infrastructure.logging import RequestIdMiddleware, configure_logging @@ -117,6 +119,7 @@ async def lifespan(_app: FastAPI): protected.include_router(trace_router) protected.include_router(agents_router) protected.include_router(prompt_router) +protected.include_router(store_router) app.include_router(protected) @@ -158,6 +161,11 @@ async def thread_not_found_handler(_request: Request, exc: ThreadNotFoundError) return _error_response(exc) +async def store_file_not_found_handler(_request: Request, exc: StoreFileNotFoundError) -> JSONResponse: + logger.warning("Store file not found: %s", exc.detail) + return _error_response(exc) + + async def prompt_not_found_handler(_request: Request, exc: PromptNotFoundError) -> JSONResponse: logger.warning(LogMessage.LOG_PROMPT_NOT_FOUND, exc.detail) return _error_response(exc) @@ -215,6 +223,7 @@ async def invalid_api_key_handler(_request: Request, exc: InvalidApiKeyError) -> app.add_exception_handler(ConfigValidationError, config_validation_handler) app.add_exception_handler(ConfigError, config_error_handler) app.add_exception_handler(ThreadNotFoundError, thread_not_found_handler) +app.add_exception_handler(StoreFileNotFoundError, store_file_not_found_handler) app.add_exception_handler(AgentNotFoundError, agent_not_found_handler) app.add_exception_handler(AgentConfigAlreadyExistsError, agent_config_already_exists_handler) app.add_exception_handler(AgentError, agent_error_handler) diff --git a/tests/unit/test_agent_config.py b/tests/unit/test_agent_config.py index a18b1da..0fbe65c 100644 --- a/tests/unit/test_agent_config.py +++ b/tests/unit/test_agent_config.py @@ -5,6 +5,7 @@ from src.domain.entities.agent_config import ( AgentConfig, + BackendConfig, BackendType, SubAgentConfig, ) @@ -24,8 +25,8 @@ def test_minimal_config_sets_default_model(self): # Assert assert model == "claude-sonnet-4-5-20250929" - def test_minimal_config_sets_default_backend_to_state(self): - """Should default backend type to STATE.""" + def test_minimal_config_sets_default_backend_to_store(self): + """Should default backend type to STORE.""" # Arrange config = AgentConfig(name="test-agent") @@ -33,18 +34,7 @@ def test_minimal_config_sets_default_backend_to_state(self): backend_type = config.backend.type # Assert - assert backend_type == BackendType.STATE - - def test_minimal_config_has_empty_middleware(self): - """Should default middleware to empty list.""" - # Arrange - config = AgentConfig(name="test-agent") - - # Act - middleware = config.middleware - - # Assert - assert middleware == [] + assert backend_type == BackendType.STORE def test_full_config_sets_model(self): """Should store the provided model.""" @@ -53,8 +43,7 @@ def test_full_config_sets_model(self): "name": "my-agent", "model": "openai:gpt-4o", "system_prompt": "You are helpful.", - "middleware": ["todo_list", "filesystem"], - "backend": {"type": "filesystem", "root_dir": "/tmp/workspace"}, + "backend": {"type": "store"}, "hitl": {"rules": {"write_file": True}}, "subagents": [{"name": "sub", "description": "A subagent"}], } @@ -65,37 +54,6 @@ def test_full_config_sets_model(self): # Assert assert config.model == "openai:gpt-4o" - def test_full_config_sets_middleware(self): - """Should store the provided middleware list.""" - # Arrange - data = { - "name": "my-agent", - "model": "openai:gpt-4o", - "middleware": ["todo_list", "filesystem"], - "backend": {"type": "filesystem", "root_dir": "/tmp/workspace"}, - } - - # Act - config = AgentConfig(**data) - - # Assert - assert len(config.middleware) == 2 - - def test_full_config_sets_backend_root_dir(self): - """Should store the provided backend root_dir.""" - # Arrange - data = { - "name": "my-agent", - "model": "openai:gpt-4o", - "backend": {"type": "filesystem", "root_dir": "/tmp/workspace"}, - } - - # Act - config = AgentConfig(**data) - - # Assert - assert config.backend.root_dir == "/tmp/workspace" - def test_full_config_sets_subagents(self): """Should store the provided subagents list.""" # Arrange @@ -118,13 +76,6 @@ def test_rejects_empty_name(self): with pytest.raises(ValidationError): AgentConfig(name="") - def test_rejects_invalid_middleware(self): - """Should raise ValidationError when middleware is unknown.""" - # Arrange - # Act & Assert - with pytest.raises(ValidationError): - AgentConfig(name="test", middleware=["invalid"]) - def test_rejects_invalid_backend_type(self): """Should raise ValidationError when backend type is unknown.""" # Arrange @@ -189,6 +140,89 @@ def test_response_format_is_frozen(self): config.response_format = {"type": "object"} +class TestBackendConfigChanges: + """Tests for the BackendType / BackendConfig refactor.""" + + def test_backend_type_only_store(self): + """BackendType enum should only have STORE value.""" + # Assert + assert BackendType.STORE == "store" + # These should NOT exist anymore: + assert not hasattr(BackendType, "FILESYSTEM") + assert not hasattr(BackendType, "COMPOSITE") + assert not hasattr(BackendType, "STATE") + + def test_backend_config_has_checkpoint_backend_default_memory(self): + """BackendConfig should default checkpoint_backend to 'memory'.""" + # Arrange + config = BackendConfig() + + # Act + checkpoint_backend = config.checkpoint_backend + + # Assert + assert checkpoint_backend == "memory" + + def test_backend_config_accepts_postgres_checkpoint_backend(self): + """BackendConfig should accept checkpoint_backend='postgres'.""" + # Arrange + config = BackendConfig(checkpoint_backend="postgres") + + # Act + checkpoint_backend = config.checkpoint_backend + + # Assert + assert checkpoint_backend == "postgres" + + def test_backend_config_rejects_invalid_checkpoint_backend(self): + """BackendConfig should reject invalid checkpoint_backend.""" + # Arrange + # Act & Assert + with pytest.raises(ValidationError): + BackendConfig(checkpoint_backend="redis") + + def test_backend_config_has_no_root_dir(self): + """BackendConfig should NOT have root_dir field.""" + # Arrange + config = BackendConfig() + + # Act & Assert + assert not hasattr(config, "root_dir") + + def test_rejects_filesystem_backend_type(self): + """AgentConfig should reject backend type 'filesystem'.""" + # Arrange + # Act & Assert + with pytest.raises(ValidationError): + AgentConfig(name="test", backend={"type": "filesystem"}) + + def test_rejects_composite_backend_type(self): + """AgentConfig should reject backend type 'composite'.""" + # Arrange + # Act & Assert + with pytest.raises(ValidationError): + AgentConfig(name="test", backend={"type": "composite"}) + + +class TestMiddlewareRemoved: + """Tests asserting the middleware field has been removed from AgentConfig.""" + + def test_agent_config_has_no_middleware_field(self): + """AgentConfig should NOT have a middleware field.""" + # Arrange + config = AgentConfig(name="test") + + # Act & Assert + assert not hasattr(config, "middleware") + + def test_agent_config_rejects_middleware_kwarg(self): + """AgentConfig should reject middleware= kwarg.""" + # Arrange + # Act & Assert + with pytest.raises(ValidationError): + AgentConfig(name="test", middleware=["todo_list"]) + + class TestSubAgentConfig: """Tests for SubAgentConfig.""" diff --git a/tests/unit/test_deep_agent_runner.py b/tests/unit/test_deep_agent_runner.py index a3dc217..a4b6404 100644 --- a/tests/unit/test_deep_agent_runner.py +++ b/tests/unit/test_deep_agent_runner.py @@ -138,7 +138,9 @@ class TestInvokeStructuredResponse: async def test_invoke_extracts_structured_response_dict(self): # Arrange msg = _make_msg("Weather report") - graph = _make_graph([msg], state_values={"messages": [msg], "structured_response": {"temperature": 22, "condition": "sunny"}}) + graph = _make_graph( + [msg], state_values={"messages": [msg], "structured_response": {"temperature": 22, "condition": "sunny"}} + ) # Act runner = DeepAgentRunner(graph) @@ -182,7 +184,13 @@ async def test_invoke_validates_and_strips_extra_top_level_fields(self): } model = schema_to_pydantic_model(schema) msg = _make_msg("Result") - graph = _make_graph([msg], state_values={"messages": [msg], "structured_response": {"name": "Alice", "age": 30, "terraceArea": 50, "parkingSpaces": 2}}) + graph = _make_graph( + [msg], + state_values={ + "messages": [msg], + "structured_response": {"name": "Alice", "age": 30, "terraceArea": 50, "parkingSpaces": 2}, + }, + ) # Act runner = DeepAgentRunner(graph, response_format_model=model) @@ -207,7 +215,9 @@ async def test_invoke_validates_and_strips_nested_extra_fields(self): } model = schema_to_pydantic_model(schema) msg = _make_msg("Result") - graph = _make_graph([msg], state_values={"messages": [msg], "structured_response": {"building": {"floors": 3, "rooftop": True}}}) + graph = _make_graph( + [msg], state_values={"messages": [msg], "structured_response": {"building": {"floors": 3, "rooftop": True}}} + ) # Act runner = DeepAgentRunner(graph, response_format_model=model) @@ -219,7 +229,9 @@ async def test_invoke_validates_and_strips_nested_extra_fields(self): async def test_invoke_no_response_format_model_passes_raw(self): # Arrange msg = _make_msg("Result") - graph = _make_graph([msg], state_values={"messages": [msg], "structured_response": {"name": "test", "extra": True}}) + graph = _make_graph( + [msg], state_values={"messages": [msg], "structured_response": {"name": "test", "extra": True}} + ) # Act runner = DeepAgentRunner(graph, response_format_model=None) @@ -392,9 +404,7 @@ async def _astream(_input, **_kwargs): yield (chunk, MagicMock()) graph.astream = _astream - graph.get_state = MagicMock( - return_value=MagicMock(values={"messages": [_make_msg("chunk")]}, interrupts=()) - ) + graph.get_state = MagicMock(return_value=MagicMock(values={"messages": [_make_msg("chunk")]}, interrupts=())) # Act runner = DeepAgentRunner(graph) diff --git a/tests/unit/test_extract_source.py b/tests/unit/test_extract_source.py index 5da660a..a8865a6 100644 --- a/tests/unit/test_extract_source.py +++ b/tests/unit/test_extract_source.py @@ -24,22 +24,16 @@ def test_returns_none_for_missing_namespace_key(self) -> None: def test_returns_none_when_task_not_in_namespace(self) -> None: """Should return None when 'task' token is not found.""" - result = DeepAgentRunner._extract_source( - {"langgraph_checkpoint_ns": "Agent|some-other-path"} - ) + result = DeepAgentRunner._extract_source({"langgraph_checkpoint_ns": "Agent|some-other-path"}) assert result is None def test_returns_none_when_task_is_last_token(self) -> None: """Should return None when 'task' is the last token (no name follows).""" - result = DeepAgentRunner._extract_source( - {"langgraph_checkpoint_ns": "Agent|task"} - ) + result = DeepAgentRunner._extract_source({"langgraph_checkpoint_ns": "Agent|task"}) assert result is None def test_extracts_name_with_multiple_separators(self) -> None: """Should extract the token immediately after 'task'.""" - metadata = { - "langgraph_checkpoint_ns": "Agent|task|my-agent|some|extra|tokens" - } + metadata = {"langgraph_checkpoint_ns": "Agent|task|my-agent|some|extra|tokens"} result = DeepAgentRunner._extract_source(metadata) - assert result == "my-agent" \ No newline at end of file + assert result == "my-agent" diff --git a/tests/unit/test_factory.py b/tests/unit/test_factory.py index b6cb801..349dd70 100644 --- a/tests/unit/test_factory.py +++ b/tests/unit/test_factory.py @@ -56,19 +56,6 @@ async def test_creates_agent_with_minimal_config(self, mock_create): assert kwargs["name"] == "test-agent" assert kwargs["model"] == "claude-sonnet-4-5-20250929" - @patch("src.infrastructure.deepagent.factory.create_deep_agent") - async def test_passes_empty_middleware(self, mock_create): - # Arrange - mock_create.return_value = MagicMock() - config = AgentConfig(name="test", middleware=["todo_list", "filesystem"]) - - # Act - await create_agent_from_config(config) - - # Assert - kwargs = mock_create.call_args.kwargs - assert kwargs["middleware"] == [] - @patch("src.infrastructure.deepagent.factory.create_deep_agent") async def test_passes_hitl_config_as_interrupt_on(self, mock_create): # Arrange @@ -137,10 +124,28 @@ async def test_missing_tool_module_raises_value_error(self, mock_create): await create_agent_from_config(config) @patch("src.infrastructure.deepagent.factory.create_deep_agent") - async def test_filesystem_backend_passed_to_create(self, mock_create): + async def test_store_backend_always_passed_to_create(self, mock_create): + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test") + + # Act + await create_agent_from_config(config) + + # Assert — backend is always StoreBackend now, regardless of config + kwargs = mock_create.call_args.kwargs + assert "backend" in kwargs + + +class TestStoreBackendResolution: + """Tests for the StoreBackend creation with explicit namespace.""" + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_store_backend_passed_to_create(self, mock_create): + """When backend.type=store, a StoreBackend should be passed.""" # Arrange mock_create.return_value = MagicMock() - config = AgentConfig(name="test", backend={"type": "filesystem", "root_dir": "/tmp"}) + config = AgentConfig(name="test", backend={"type": "store"}) # Act await create_agent_from_config(config) @@ -148,9 +153,92 @@ async def test_filesystem_backend_passed_to_create(self, mock_create): # Assert kwargs = mock_create.call_args.kwargs assert "backend" in kwargs + # The backend should be a StoreBackend instance (not a lambda) + from deepagents.backends import StoreBackend + + assert isinstance(kwargs["backend"], StoreBackend) + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_store_backend_has_explicit_namespace(self, mock_create): + """StoreBackend should be created with an explicit namespace.""" + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test", backend={"type": "store"}) + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + backend = kwargs["backend"] + from deepagents.backends import StoreBackend + + assert isinstance(backend, StoreBackend) + # The namespace should be set (not relying on deprecated legacy detection) + assert backend._namespace is not None + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_store_backend_uses_provided_store(self, mock_create): + """StoreBackend should use the store instance from the factory.""" + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test", backend={"type": "store"}) + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + backend = kwargs["backend"] + # The store should be wired (not None — should use the InMemoryStore or PostgresStore) + assert backend._store is not None or kwargs.get("store") is not None + + +class TestPostgresStoreCheckpointer: + """Tests for the checkpoint_backend resolution.""" + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_memory_checkpointer_used_by_default(self, mock_create): + """When checkpoint_backend=memory (default), MemorySaver should be used.""" + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test") + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + from langgraph.checkpoint.memory import MemorySaver + + assert isinstance(kwargs["checkpointer"], MemorySaver) + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + @patch("src.infrastructure.deepagent.factory._create_postgres_checkpointer") + async def test_postgres_checkpointer_used_when_configured(self, mock_pg_cp, mock_create): + """When checkpoint_backend=postgres, a Postgres checkpointer should be used.""" + # Arrange + mock_pg_cp.return_value = MagicMock() + mock_create.return_value = MagicMock() + config = AgentConfig( + name="test", + backend={"type": "store", "checkpoint_backend": "postgres"}, + ) + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + assert kwargs["checkpointer"] is mock_pg_cp.return_value + + +class TestMiddlewareRemovedFromFactory: + """Tests asserting middleware is no longer passed to create_deep_agent.""" @patch("src.infrastructure.deepagent.factory.create_deep_agent") - async def test_state_backend_omits_backend_kwarg(self, mock_create): + async def test_middleware_kwarg_not_passed(self, mock_create): + """Factory should NOT pass middleware kwarg to create_deep_agent.""" # Arrange mock_create.return_value = MagicMock() config = AgentConfig(name="test") @@ -160,7 +248,7 @@ async def test_state_backend_omits_backend_kwarg(self, mock_create): # Assert kwargs = mock_create.call_args.kwargs - assert "backend" not in kwargs + assert "middleware" not in kwargs class TestResponseFormatIntegration: @@ -411,3 +499,91 @@ async def test_subagent_with_nested_response_format_passes_dict_as_is(self, mock kwargs = mock_create.call_args.kwargs subagents = kwargs["subagents"] assert subagents[0]["response_format"] == NESTED_SUBAGENT_SCHEMA + + +class TestPrepareAgentNamespace: + """Tests for conditional skill/memory loading via agent namespace.""" + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_copies_selected_skills_to_agent_namespace(self, mock_create): + """Selected skills should be copied to /agents/{name}/skills/.""" + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig( + name="test-agent", + backend={"type": "store"}, + skills=["/skills/mcp/", "/skills/rag/"], + ) + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + assert kwargs["skills"] == ["/agents/test-agent/skills/"] + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_copies_selected_memories_to_agent_namespace(self, mock_create): + """Selected memories should be copied to /agents/{name}/memories/.""" + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig( + name="test-agent", + backend={"type": "store"}, + memory=["/memories/AGENTS.md"], + ) + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + assert kwargs["memory"] == ["/agents/test-agent/memories/AGENTS.md"] + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_skills_and_memory_copied_together(self, mock_create): + """Both skills and memories should be copied when both are configured.""" + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig( + name="test-agent", + backend={"type": "store"}, + skills=["/skills/mcp/"], + memory=["/memories/AGENTS.md"], + ) + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + assert kwargs["skills"] == ["/agents/test-agent/skills/"] + assert kwargs["memory"] == ["/agents/test-agent/memories/AGENTS.md"] + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_no_skills_means_no_skills_kwarg(self, mock_create): + """When no skills are configured, skills kwarg should not be set.""" + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test-agent") + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + assert "skills" not in kwargs + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_no_memory_means_no_memory_kwarg(self, mock_create): + """When no memory is configured, memory kwarg should not be set.""" + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test-agent") + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + assert "memory" not in kwargs diff --git a/tests/unit/test_get_thread_history.py b/tests/unit/test_get_thread_history.py index 95618fd..762f5ff 100644 --- a/tests/unit/test_get_thread_history.py +++ b/tests/unit/test_get_thread_history.py @@ -139,20 +139,38 @@ async def test_execute_orders_turns_chronologically_by_timestamp(self, use_case, # Turn A (earlier) — turn_id "zzzz" sorts AFTER "aaaa" alphabetically await trace_repo.add( thread.id, - _event(thread.id, "zzzz-first-chronologically", TraceEventType.HUMAN_MESSAGE, "early q", seq=0, timestamp=early), + _event( + thread.id, "zzzz-first-chronologically", TraceEventType.HUMAN_MESSAGE, "early q", seq=0, timestamp=early + ), ) await trace_repo.add( thread.id, - _event(thread.id, "zzzz-first-chronologically", TraceEventType.AI_MESSAGE, final.model_dump_json(), seq=1, timestamp=early), + _event( + thread.id, + "zzzz-first-chronologically", + TraceEventType.AI_MESSAGE, + final.model_dump_json(), + seq=1, + timestamp=early, + ), ) # Turn B (later) — turn_id "aaaa" sorts BEFORE "zzzz" alphabetically await trace_repo.add( thread.id, - _event(thread.id, "aaaa-second-chronologically", TraceEventType.HUMAN_MESSAGE, "late q", seq=0, timestamp=late), + _event( + thread.id, "aaaa-second-chronologically", TraceEventType.HUMAN_MESSAGE, "late q", seq=0, timestamp=late + ), ) await trace_repo.add( thread.id, - _event(thread.id, "aaaa-second-chronologically", TraceEventType.AI_MESSAGE, final.model_dump_json(), seq=1, timestamp=late), + _event( + thread.id, + "aaaa-second-chronologically", + TraceEventType.AI_MESSAGE, + final.model_dump_json(), + seq=1, + timestamp=late, + ), ) history = await use_case.execute(thread.id) diff --git a/tests/unit/test_store_file_repository.py b/tests/unit/test_store_file_repository.py new file mode 100644 index 0000000..ff6a8bd --- /dev/null +++ b/tests/unit/test_store_file_repository.py @@ -0,0 +1,218 @@ +"""Tests for LangGraphStoreFileRepository. + +The repository is an outbound adapter wrapping the LangGraph ``BaseStore`` +(an external infrastructure dependency), so the ``BaseStore`` is mocked at +its boundary. The repository itself is exercised with its real +implementation. + +These tests are written TDD-Red: ``src.infrastructure.store_file.adapter`` +does not exist yet, so importing it raises ``ImportError`` and every test +fails until the adapter is implemented. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.infrastructure.store_file.adapter import LangGraphStoreFileRepository + + +@pytest.fixture +def mock_store() -> AsyncMock: + """Provide an AsyncMock simulating a LangGraph BaseStore. + + Each method is configured with a sensible default return value so that + individual tests only need to override the behaviour they exercise. + """ + store = AsyncMock() + store.asearch = AsyncMock(return_value=[]) + store.aget = AsyncMock(return_value=None) + store.aput = AsyncMock(return_value=None) + store.adelete = AsyncMock(return_value=None) + return store + + +class TestLangGraphStoreFileRepository: + """Tests for the LangGraph-backed StoreFileRepository adapter.""" + + async def test_list_files_returns_empty_when_store_empty(self, mock_store: AsyncMock) -> None: + # Arrange + repo = LangGraphStoreFileRepository(store=mock_store) + + # Act + result = await repo.list_files("/skills/") + + # Assert + assert result == [] + mock_store.asearch.assert_called_once() + + async def test_list_files_filters_by_prefix(self, mock_store: AsyncMock) -> None: + # Arrange — items expose ``.key`` and ``.value`` like LangGraph Item objects + item_skills_rag = MagicMock(key="/skills/rag/SKILL.md", value={"content": "# RAG"}) + item_memories = MagicMock(key="/memories/AGENTS.md", value={"content": "# AGENTS"}) + item_skills_review = MagicMock(key="/skills/code-review/SKILL.md", value={"content": "# Review"}) + mock_store.asearch = AsyncMock(return_value=[item_skills_rag, item_memories, item_skills_review]) + + repo = LangGraphStoreFileRepository(store=mock_store) + + # Act + result = await repo.list_files("/skills/") + + # Assert + assert "/skills/rag/SKILL.md" in result + assert "/skills/code-review/SKILL.md" in result + assert "/memories/AGENTS.md" not in result + + async def test_list_files_returns_all_keys_when_prefix_is_root(self, mock_store: AsyncMock) -> None: + # Arrange + item1 = MagicMock(key="/skills/rag/SKILL.md", value={"content": "# RAG"}) + item2 = MagicMock(key="/memories/AGENTS.md", value={"content": "# AGENTS"}) + mock_store.asearch = AsyncMock(return_value=[item1, item2]) + + repo = LangGraphStoreFileRepository(store=mock_store) + + # Act + result = await repo.list_files("/") + + # Assert + assert len(result) == 2 + assert "/skills/rag/SKILL.md" in result + assert "/memories/AGENTS.md" in result + + async def test_get_file_returns_content_when_exists(self, mock_store: AsyncMock) -> None: + # Arrange + mock_item = MagicMock(value={"content": "# My Skill", "encoding": "utf-8"}) + mock_store.aget = AsyncMock(return_value=mock_item) + + repo = LangGraphStoreFileRepository(store=mock_store) + + # Act + result = await repo.get_file("/skills/rag/SKILL.md") + + # Assert + assert result == "# My Skill" + mock_store.aget.assert_called_once_with(("filesystem",), "/skills/rag/SKILL.md") + + async def test_get_file_returns_none_when_not_found(self, mock_store: AsyncMock) -> None: + # Arrange + mock_store.aget = AsyncMock(return_value=None) + + repo = LangGraphStoreFileRepository(store=mock_store) + + # Act + result = await repo.get_file("/skills/nonexistent/SKILL.md") + + # Assert + assert result is None + + async def test_get_file_returns_none_when_value_missing_content_key(self, mock_store: AsyncMock) -> None: + # Arrange — defensive: malformed stored value without "content" + mock_item = MagicMock(value={"encoding": "utf-8"}) + mock_store.aget = AsyncMock(return_value=mock_item) + + repo = LangGraphStoreFileRepository(store=mock_store) + + # Act + result = await repo.get_file("/skills/rag/SKILL.md") + + # Assert + assert result is None + + async def test_put_file_stores_content_with_utf8_encoding(self, mock_store: AsyncMock) -> None: + # Arrange + repo = LangGraphStoreFileRepository(store=mock_store) + + # Act + await repo.put_file("/skills/rag/SKILL.md", "# My Skill") + + # Assert + mock_store.aput.assert_called_once() + call_args = mock_store.aput.call_args + # Positional contract: (namespace, key, value_dict) + assert call_args.args[0] == ("filesystem",) + assert call_args.args[1] == "/skills/rag/SKILL.md" + assert call_args.args[2]["content"] == "# My Skill" + assert call_args.args[2]["encoding"] == "utf-8" + + async def test_put_file_passes_through_unicode_content(self, mock_store: AsyncMock) -> None: + # Arrange + repo = LangGraphStoreFileRepository(store=mock_store) + unicode_content = "# RAG\n\nBonjour café — naïve façade" + + # Act + await repo.put_file("/skills/rag/SKILL.md", unicode_content) + + # Assert + stored_value = mock_store.aput.call_args.args[2] + assert stored_value["content"] == unicode_content + + async def test_delete_file_removes_from_store(self, mock_store: AsyncMock) -> None: + # Arrange + repo = LangGraphStoreFileRepository(store=mock_store) + + # Act + await repo.delete_file("/skills/rag/SKILL.md") + + # Assert + mock_store.adelete.assert_called_once_with(("filesystem",), "/skills/rag/SKILL.md") + + async def test_delete_file_is_idempotent_when_key_absent(self, mock_store: AsyncMock) -> None: + # Arrange — BaseStore.adelete returns None even for missing keys + repo = LangGraphStoreFileRepository(store=mock_store) + + # Act — should not raise + await repo.delete_file("/skills/does-not-exist/SKILL.md") + + # Assert + mock_store.adelete.assert_called_once_with(("filesystem",), "/skills/does-not-exist/SKILL.md") + + async def test_uses_custom_namespace_for_get(self, mock_store: AsyncMock) -> None: + # Arrange + repo = LangGraphStoreFileRepository(store=mock_store, namespace=("custom", "ns")) + + # Act + await repo.get_file("/test.md") + + # Assert + mock_store.aget.assert_called_once_with(("custom", "ns"), "/test.md") + + async def test_uses_custom_namespace_for_put(self, mock_store: AsyncMock) -> None: + # Arrange + repo = LangGraphStoreFileRepository(store=mock_store, namespace=("custom",)) + + # Act + await repo.put_file("/test.md", "content") + + # Assert + assert mock_store.aput.call_args.args[0] == ("custom",) + + async def test_uses_custom_namespace_for_delete(self, mock_store: AsyncMock) -> None: + # Arrange + repo = LangGraphStoreFileRepository(store=mock_store, namespace=("custom",)) + + # Act + await repo.delete_file("/test.md") + + # Assert + mock_store.adelete.assert_called_once_with(("custom",), "/test.md") + + async def test_uses_custom_namespace_for_list(self, mock_store: AsyncMock) -> None: + # Arrange + repo = LangGraphStoreFileRepository(store=mock_store, namespace=("custom", "files")) + + # Act + await repo.list_files("/") + + # Assert + mock_store.asearch.assert_called_once() + assert mock_store.asearch.call_args.args[0] == ("custom", "files") + + async def test_default_namespace_is_filesystem(self, mock_store: AsyncMock) -> None: + # Arrange + repo = LangGraphStoreFileRepository(store=mock_store) + + # Act + await repo.get_file("/any.md") + + # Assert + mock_store.aget.assert_called_once_with(("filesystem",), "/any.md") diff --git a/tests/unit/test_store_routes.py b/tests/unit/test_store_routes.py new file mode 100644 index 0000000..46cc002 --- /dev/null +++ b/tests/unit/test_store_routes.py @@ -0,0 +1,383 @@ +"""Tests for store file management routes (GET/PUT/DELETE /api/v1/store/files). + +Mirrors the pattern from ``tests/unit/test_routes.py``: dependencies are wired +through ``app.dependency_overrides`` (FastAPI stays in control of its own +providers), the API key check is bypassed, and requests go through +``httpx.AsyncClient`` with ``ASGITransport``. + +The store-file use cases are mocked at the use-case port boundary (they wrap +the repository, which itself wraps the external LangGraph ``BaseStore``). Each +use case is replaced with an ``AsyncMock`` so the route layer is exercised +against its real FastAPI wiring while the persistence layer is stubbed. + +These tests are written TDD-Red: ``src.application.routes.store``, +``src.application.use_cases.manage_store_file`` and the +``get_*_store_file_use_case`` providers do not exist yet, so importing them +raises ``ImportError`` and every test fails until the implementation lands. +""" + +from unittest.mock import AsyncMock + +import pytest +from httpx import ASGITransport, AsyncClient + +from src.application.use_cases.manage_store_file import ( + DeleteStoreFileUseCase, + GetStoreFileUseCase, + ListStoreFilesUseCase, + PutStoreFileUseCase, +) +from src.dependencies import ( + get_delete_store_file_use_case, + get_get_store_file_use_case, + get_list_store_files_use_case, + get_put_store_file_use_case, +) +from src.domain.errors.storage import StorageError +from src.main import app, security + +# -- Fixtures ------------------------------------------------------------------ + + +@pytest.fixture +def mock_list_use_case() -> AsyncMock: + uc = AsyncMock(spec=ListStoreFilesUseCase) + uc.execute = AsyncMock(return_value=[]) + return uc + + +@pytest.fixture +def mock_get_use_case() -> AsyncMock: + uc = AsyncMock(spec=GetStoreFileUseCase) + uc.execute = AsyncMock(return_value=None) + return uc + + +@pytest.fixture +def mock_put_use_case() -> AsyncMock: + uc = AsyncMock(spec=PutStoreFileUseCase) + uc.execute = AsyncMock(return_value=None) + return uc + + +@pytest.fixture +def mock_delete_use_case() -> AsyncMock: + uc = AsyncMock(spec=DeleteStoreFileUseCase) + uc.execute = AsyncMock(return_value=None) + return uc + + +@pytest.fixture(autouse=True) +def _override_dependencies( + mock_list_use_case: AsyncMock, + mock_get_use_case: AsyncMock, + mock_put_use_case: AsyncMock, + mock_delete_use_case: AsyncMock, +): + """Wire mocked use cases via app.dependency_overrides and bypass API key.""" + app.dependency_overrides[security.verify_api_key] = lambda: "" + app.dependency_overrides[get_list_store_files_use_case] = lambda: mock_list_use_case + app.dependency_overrides[get_get_store_file_use_case] = lambda: mock_get_use_case + app.dependency_overrides[get_put_store_file_use_case] = lambda: mock_put_use_case + app.dependency_overrides[get_delete_store_file_use_case] = lambda: mock_delete_use_case + + yield + + app.dependency_overrides.clear() + + +@pytest.fixture +def client(): + transport = ASGITransport(app=app) + return AsyncClient(transport=transport, base_url="http://test") + + +# -- GET /api/v1/store/files (list) -------------------------------------------- + + +class TestListStoreFilesRoute: + """Tests for GET /api/v1/store/files?prefix=...""" + + async def test_list_returns_200_with_file_paths(self, client: AsyncClient, mock_list_use_case: AsyncMock) -> None: + # Arrange + mock_list_use_case.execute = AsyncMock(return_value=["/skills/rag/SKILL.md", "/skills/code-review/SKILL.md"]) + + # Act + resp = await client.get("/api/v1/store/files", params={"prefix": "/skills/"}) + + # Assert + assert resp.status_code == 200 + body = resp.json() + assert body == ["/skills/rag/SKILL.md", "/skills/code-review/SKILL.md"] + mock_list_use_case.execute.assert_awaited_once_with(prefix="/skills/") + + async def test_list_returns_200_with_empty_list(self, client: AsyncClient, mock_list_use_case: AsyncMock) -> None: + # Arrange + mock_list_use_case.execute = AsyncMock(return_value=[]) + + # Act + resp = await client.get("/api/v1/store/files") + + # Assert + assert resp.status_code == 200 + assert resp.json() == [] + + async def test_list_uses_prefix_query_param(self, client: AsyncClient, mock_list_use_case: AsyncMock) -> None: + # Arrange + mock_list_use_case.execute = AsyncMock(return_value=["/memories/AGENTS.md"]) + + # Act + resp = await client.get("/api/v1/store/files", params={"prefix": "/memories/"}) + + # Assert + assert resp.status_code == 200 + mock_list_use_case.execute.assert_awaited_once_with(prefix="/memories/") + + async def test_list_defaults_prefix_when_not_provided( + self, client: AsyncClient, mock_list_use_case: AsyncMock + ) -> None: + # Arrange — when no prefix is given, the route should still call the use case + mock_list_use_case.execute = AsyncMock(return_value=[]) + + # Act + resp = await client.get("/api/v1/store/files") + + # Assert + assert resp.status_code == 200 + mock_list_use_case.execute.assert_awaited_once() + + async def test_list_storage_error_returns_503(self, client: AsyncClient, mock_list_use_case: AsyncMock) -> None: + # Arrange + mock_list_use_case.execute = AsyncMock(side_effect=StorageError("store unavailable")) + + # Act + resp = await client.get("/api/v1/store/files") + + # Assert + assert resp.status_code == 503 + assert "detail" in resp.json() + + +# -- GET /api/v1/store/files/{path} (get) -------------------------------------- + + +class TestGetStoreFileRoute: + """Tests for GET /api/v1/store/files/{path:path}.""" + + async def test_get_returns_200_with_path_and_content( + self, client: AsyncClient, mock_get_use_case: AsyncMock + ) -> None: + # Arrange + mock_get_use_case.execute = AsyncMock(return_value="# My Skill") + + # Act + resp = await client.get("/api/v1/store/files/skills/rag/SKILL.md") + + # Assert + assert resp.status_code == 200 + body = resp.json() + assert body["path"] == "/skills/rag/SKILL.md" + assert body["content"] == "# My Skill" + mock_get_use_case.execute.assert_awaited_once_with(path="/skills/rag/SKILL.md") + + async def test_get_returns_404_when_file_not_found(self, client: AsyncClient, mock_get_use_case: AsyncMock) -> None: + # Arrange — use case returns None for missing file + mock_get_use_case.execute = AsyncMock(return_value=None) + + # Act + resp = await client.get("/api/v1/store/files/skills/nonexistent/SKILL.md") + + # Assert + assert resp.status_code == 404 + assert "detail" in resp.json() + + async def test_get_supports_nested_paths(self, client: AsyncClient, mock_get_use_case: AsyncMock) -> None: + # Arrange + mock_get_use_case.execute = AsyncMock(return_value="nested content") + + # Act + resp = await client.get("/api/v1/store/files/skills/rag/sub/deep/SKILL.md") + + # Assert + assert resp.status_code == 200 + assert resp.json()["path"] == "/skills/rag/sub/deep/SKILL.md" + + async def test_get_storage_error_returns_503(self, client: AsyncClient, mock_get_use_case: AsyncMock) -> None: + # Arrange + mock_get_use_case.execute = AsyncMock(side_effect=StorageError("store unavailable")) + + # Act + resp = await client.get("/api/v1/store/files/any.md") + + # Assert + assert resp.status_code == 503 + + +# -- PUT /api/v1/store/files/{path} (create/replace) --------------------------- + + +class TestPutStoreFileRoute: + """Tests for PUT /api/v1/store/files/{path:path}.""" + + async def test_put_returns_200_with_path_and_content( + self, client: AsyncClient, mock_put_use_case: AsyncMock + ) -> None: + # Arrange + content = "# My Skill" + mock_put_use_case.execute = AsyncMock(return_value=content) + + # Act + resp = await client.put("/api/v1/store/files/skills/rag/SKILL.md", json={"content": content}) + + # Assert + assert resp.status_code == 200 + body = resp.json() + assert body["path"] == "/skills/rag/SKILL.md" + assert body["content"] == content + mock_put_use_case.execute.assert_awaited_once_with(path="/skills/rag/SKILL.md", content=content) + + async def test_put_creates_new_file(self, client: AsyncClient, mock_put_use_case: AsyncMock) -> None: + # Arrange + mock_put_use_case.execute = AsyncMock(return_value="new content") + + # Act + resp = await client.put("/api/v1/store/files/skills/new/SKILL.md", json={"content": "new content"}) + + # Assert + assert resp.status_code == 200 + assert resp.json()["content"] == "new content" + + async def test_put_replaces_existing_file(self, client: AsyncClient, mock_put_use_case: AsyncMock) -> None: + # Arrange + mock_put_use_case.execute = AsyncMock(return_value="updated content") + + # Act + resp = await client.put("/api/v1/store/files/skills/rag/SKILL.md", json={"content": "updated content"}) + + # Assert + assert resp.status_code == 200 + assert resp.json()["content"] == "updated content" + + async def test_put_missing_content_body_returns_422( + self, client: AsyncClient, mock_put_use_case: AsyncMock + ) -> None: + # Arrange + # Act — body without required "content" field + resp = await client.put("/api/v1/store/files/skills/rag/SKILL.md", json={}) + + # Assert + assert resp.status_code == 422 + mock_put_use_case.execute.assert_not_awaited() + + async def test_put_empty_body_returns_422(self, client: AsyncClient, mock_put_use_case: AsyncMock) -> None: + # Arrange + # Act — no JSON body at all + resp = await client.put("/api/v1/store/files/skills/rag/SKILL.md") + + # Assert + assert resp.status_code == 422 + mock_put_use_case.execute.assert_not_awaited() + + async def test_put_storage_error_returns_503(self, client: AsyncClient, mock_put_use_case: AsyncMock) -> None: + # Arrange + mock_put_use_case.execute = AsyncMock(side_effect=StorageError("store unavailable")) + + # Act + resp = await client.put("/api/v1/store/files/any.md", json={"content": "x"}) + + # Assert + assert resp.status_code == 503 + + +# -- DELETE /api/v1/store/files/{path} ----------------------------------------- + + +class TestDeleteStoreFileRoute: + """Tests for DELETE /api/v1/store/files/{path:path}.""" + + async def test_delete_returns_204_when_file_exists( + self, client: AsyncClient, mock_delete_use_case: AsyncMock + ) -> None: + # Arrange + mock_delete_use_case.execute = AsyncMock(return_value=None) + + # Act + resp = await client.delete("/api/v1/store/files/skills/rag/SKILL.md") + + # Assert + assert resp.status_code == 204 + assert resp.content == b"" + mock_delete_use_case.execute.assert_awaited_once_with(path="/skills/rag/SKILL.md") + + async def test_delete_returns_404_when_file_not_found( + self, client: AsyncClient, mock_delete_use_case: AsyncMock + ) -> None: + # Arrange — use case signals absence by raising a not-found domain error. + # We model the contract as the use case raising StorageError-like NotFound; + # however the canonical pattern in this codebase is a dedicated domain error. + # Since the route maps 404 from a domain error, we raise one here. + from src.domain.errors.base import DomainError + from src.domain.errors.codes import ErrorCode + + class StoreFileNotFoundError(DomainError): + status_code = ErrorCode.NOT_FOUND + + mock_delete_use_case.execute = AsyncMock(side_effect=StoreFileNotFoundError("not found")) + + # Act + resp = await client.delete("/api/v1/store/files/skills/nonexistent/SKILL.md") + + # Assert + assert resp.status_code == 404 + assert "detail" in resp.json() + + async def test_delete_storage_error_returns_503(self, client: AsyncClient, mock_delete_use_case: AsyncMock) -> None: + # Arrange + mock_delete_use_case.execute = AsyncMock(side_effect=StorageError("store unavailable")) + + # Act + resp = await client.delete("/api/v1/store/files/any.md") + + # Assert + assert resp.status_code == 503 + + +# -- GET /api/v1/store/skills/{skill_name}/usage -------------------------------- + + +class TestSkillUsageRoute: + """Tests for GET /api/v1/store/skills/{skill_name}/usage.""" + + async def test_returns_agents_using_skill(self, client: AsyncClient, mock_list_use_case: AsyncMock) -> None: + # Arrange + mock_list_use_case.execute = AsyncMock( + return_value=[ + "/agents/agent-a/skills/mcp/SKILL.md", + "/agents/agent-b/skills/mcp/SKILL.md", + "/agents/agent-c/skills/rag/SKILL.md", + ] + ) + + # Act + resp = await client.get("/api/v1/store/skills/mcp/usage") + + # Assert + assert resp.status_code == 200 + assert resp.json() == ["agent-a", "agent-b"] + + async def test_returns_empty_when_no_agents_use_skill( + self, client: AsyncClient, mock_list_use_case: AsyncMock + ) -> None: + # Arrange + mock_list_use_case.execute = AsyncMock( + return_value=[ + "/agents/agent-a/skills/rag/SKILL.md", + ] + ) + + # Act + resp = await client.get("/api/v1/store/skills/mcp/usage") + + # Assert + assert resp.status_code == 200 + assert resp.json() == [] diff --git a/tests/unit/test_yaml_loader.py b/tests/unit/test_yaml_loader.py index 8315ab1..164873d 100644 --- a/tests/unit/test_yaml_loader.py +++ b/tests/unit/test_yaml_loader.py @@ -97,8 +97,7 @@ def test_loads_full_config_returns_debug_flag(self, yaml_loader, tmp_path): "name: full-agent\n" 'model: "openai:gpt-4o"\n' 'system_prompt: "You are helpful."\n' - "middleware:\n - todo_list\n - filesystem\n" - 'backend:\n type: filesystem\n root_dir: "./workspace"\n' + "backend:\n type: store\n checkpoint_backend: memory\n" "hitl:\n rules:\n write_file: true\n" 'memory:\n - "./AGENTS.md"\n' 'skills:\n - "./skills/"\n' @@ -114,31 +113,58 @@ def test_loads_full_config_returns_debug_flag(self, yaml_loader, tmp_path): # Assert assert config.debug is True - def test_loads_full_config_returns_middleware(self, yaml_loader, tmp_path): - """Should parse the middleware list from a full YAML config.""" + def test_strips_deprecated_middleware_field(self, yaml_loader, tmp_path): + """Should silently strip the deprecated 'middleware' field from YAML.""" # Arrange - yaml_content = "name: full-agent\nmiddleware:\n - todo_list\n - filesystem\ndebug: true\n" + yaml_content = "name: full-agent\nmiddleware:\n - todo_list\ndebug: true\n" yaml_file = tmp_path / "agent.yaml" yaml_file.write_text(yaml_content) # Act config = yaml_loader.load(yaml_file) - # Assert - assert len(config.middleware) == 2 + # Assert — middleware is silently dropped, agent loads successfully + assert config.name == "full-agent" + assert config.debug is True + assert not hasattr(config, "middleware") - def test_loads_full_config_returns_backend_root_dir(self, yaml_loader, tmp_path): - """Should parse backend root_dir from a full YAML config.""" + def test_strips_deprecated_root_dir_field(self, yaml_loader, tmp_path): + """Should silently strip the deprecated 'root_dir' from backend in YAML.""" # Arrange - yaml_content = 'name: full-agent\nbackend:\n type: filesystem\n root_dir: "./workspace"\n' + yaml_content = 'name: full-agent\nbackend:\n type: state\n root_dir: "/tmp"\n' yaml_file = tmp_path / "agent.yaml" yaml_file.write_text(yaml_content) # Act config = yaml_loader.load(yaml_file) - # Assert - assert config.backend.root_dir == "./workspace" + # Assert — root_dir is silently dropped, agent loads successfully + assert config.name == "full-agent" + assert config.backend.type.value == "store" + + def test_migrates_state_backend_type_to_store(self, yaml_loader, tmp_path): + """Should migrate deprecated 'state' backend type to 'store'.""" + # Arrange + yaml_content = "name: full-agent\nbackend:\n type: state\n" + yaml_file = tmp_path / "agent.yaml" + yaml_file.write_text(yaml_content) + + # Act + config = yaml_loader.load(yaml_file) + + # Assert — state is migrated to store + assert config.backend.type.value == "store" + + def test_rejects_filesystem_backend_type(self, yaml_loader, tmp_path): + """Should raise when the YAML uses the removed 'filesystem' backend type.""" + # Arrange + yaml_content = 'name: full-agent\nbackend:\n type: filesystem\n root_dir: "./workspace"\n' + yaml_file = tmp_path / "agent.yaml" + yaml_file.write_text(yaml_content) + + # Act & Assert + with pytest.raises((ConfigValidationError, ConfigError)): + yaml_loader.load(yaml_file) def test_loads_full_config_returns_subagents(self, yaml_loader, tmp_path): """Should parse the subagents list from a full YAML config.""" diff --git a/uv.lock b/uv.lock index 540ed8d..ad787c3 100644 --- a/uv.lock +++ b/uv.lock @@ -579,11 +579,13 @@ dependencies = [ { name = "langchain-mcp-adapters" }, { name = "langchain-openai" }, { name = "langgraph" }, + { name = "langgraph-checkpoint-postgres" }, { name = "langsmith" }, { name = "mako" }, { name = "mcp" }, { name = "miniopy-async" }, { name = "openinference-instrumentation-langchain" }, + { name = "psycopg", extra = ["binary"] }, { name = "pyasn1" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -620,19 +622,21 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.30.0" }, { name = "cachetools", specifier = ">=7.0.5" }, { name = "cryptography", specifier = ">=48.0.1" }, - { name = "deepagents", specifier = ">=0.6.10" }, + { name = "deepagents", specifier = ">=0.6.12" }, { name = "fastapi", specifier = ">=0.128.4" }, { name = "idna", specifier = ">=3.15" }, { name = "langchain-core", specifier = ">=1.4.7" }, { name = "langchain-mcp-adapters", specifier = ">=0.3.0" }, { name = "langchain-openai", specifier = ">=1.1.15" }, { name = "langgraph", specifier = ">=1.2.5" }, + { name = "langgraph-checkpoint-postgres", specifier = ">=3.0.5" }, { name = "langsmith", specifier = ">=0.8.18" }, { name = "mako", specifier = ">=1.3.12" }, - { name = "mcp", specifier = ">=1.27.0" }, + { name = "mcp", specifier = ">=1.28.1" }, { name = "miniopy-async", specifier = ">=1.21.0" }, { name = "openinference-instrumentation-langchain", specifier = "==0.1.62" }, - { name = "pyasn1", specifier = ">=0.6.3" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, + { name = "pyasn1", specifier = ">=0.6.4" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.14.2" }, { name = "pyjwt", specifier = ">=2.13.0" }, @@ -821,7 +825,7 @@ wheels = [ [[package]] name = "deepagents" -version = "0.6.10" +version = "0.6.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain" }, @@ -831,9 +835,9 @@ dependencies = [ { name = "langsmith" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/ab/3225f47404d401559ab67819b3b20833cdefe7e283e39f796d6019c7dfa7/deepagents-0.6.10.tar.gz", hash = "sha256:bce9f8e6b7870fe1bba5e5a128588e6f38df810f60695e01dd568e6e62e74a89", size = 203631, upload-time = "2026-06-13T06:19:48.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/db/a6acdc72a9e90c3f07ed10de35c951734a02d4facb693bb59684ad368801/deepagents-0.6.12.tar.gz", hash = "sha256:1f281c0bc5a63132f62e2ee345c1dc593b23188da6e23016401f6879fbe54b5f", size = 211364, upload-time = "2026-06-25T17:26:52.775Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/81/49f1a98434b462aa60a07ef5a98437bd6a4445219b91c459e1e7e5d5564e/deepagents-0.6.10-py3-none-any.whl", hash = "sha256:21486ba213f027f7f2d5b4822bf6099f806a1d325dd33e93d3f5b9e857b2ea89", size = 228251, upload-time = "2026-06-13T06:19:47.499Z" }, + { url = "https://files.pythonhosted.org/packages/98/49/af7219b3c13520fee047bb807cfaefba17f8e4584c551d946773589a4f08/deepagents-0.6.12-py3-none-any.whl", hash = "sha256:28b8fa0119ca0a689e3e18e288c4634e4046062acfc87a1cb34289d3af3a1c88", size = 236120, upload-time = "2026-06-25T17:26:51.736Z" }, ] [[package]] @@ -1385,35 +1389,35 @@ wheels = [ [[package]] name = "langchain" -version = "1.3.9" +version = "1.3.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/7c/651d0dc4913a7a892156c03dd343b99cfe19ee729e6911ab1f4fe7567b8b/langchain-1.3.9.tar.gz", hash = "sha256:9b14ef0db9ef314299ded858b22ca2a40b8f1b05c8c9cb6b82d53a53075fef00", size = 631514, upload-time = "2026-06-12T16:53:27.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/68/a6dbad9c22df4087a0f9e79ddd46226c442b30128bfeee538d5889492a73/langchain-1.3.14.tar.gz", hash = "sha256:1b6696c72ba3bbbce54d745e0180742c9f6ece8bbc59ed5a46c3e20b9a435929", size = 645181, upload-time = "2026-07-16T13:28:18.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/55/3481619d21b9bdfbfda8680fba5cfc6cfe926789b8eaaad95353078cfa20/langchain-1.3.9-py3-none-any.whl", hash = "sha256:4af49ad1095799e4408b489fb79d4b8b49292453618b202d8a697fca59bb6871", size = 132873, upload-time = "2026-06-12T16:53:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ec/0f942e78a621f8e3162ff1ed24284f469aaf51fb4607ee5831c626f2b2bc/langchain-1.3.14-py3-none-any.whl", hash = "sha256:4d10dbe91005952cddd56d0dc77aa108964da6bae90ab20063653957e901f782", size = 139560, upload-time = "2026-07-16T13:28:16.498Z" }, ] [[package]] name = "langchain-anthropic" -version = "1.4.6" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/f5/cd397b94aeed5fa0e8ab9595b9fb578ac99f424d42220defe6626e6a1a7b/langchain_anthropic-1.4.6.tar.gz", hash = "sha256:78942d4458d883b7d362438a095ed501ed84f44d402622404482481fc973b9da", size = 706540, upload-time = "2026-06-12T16:54:15.352Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/99/b0fc215bb552a9e94af78f583334ad16a561bca3c771dbd6cbaa67f92a77/langchain_anthropic-1.5.0.tar.gz", hash = "sha256:c36f195fd73455d820f4e7cf7220d652858d707b67bce70f3fe0f57227ec6c81", size = 711155, upload-time = "2026-07-21T18:17:10.143Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/af/927dbbc5a1f5fea1a69adc2883f034cbd1430004e36f4eacd302d500393a/langchain_anthropic-1.4.6-py3-none-any.whl", hash = "sha256:dbd412a956b6b8b0716d9d8460ef71f834a6731cdbfc59e6160482a4a9fb5200", size = 51797, upload-time = "2026-06-12T16:54:14.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/bd/a612fbafa1def5ff41a72080e430a310bc08f6fce34d4fc803fb9c00f632/langchain_anthropic-1.5.0-py3-none-any.whl", hash = "sha256:b341c0fa197e7d55555a228377e66214af8cb77631ca633f8bd58655d10103ac", size = 53083, upload-time = "2026-07-21T18:17:08.955Z" }, ] [[package]] name = "langchain-core" -version = "1.4.7" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -1426,9 +1430,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/2b/fffaff399d20a56d40b9562fa19701e91abd72d8c9d9bc8c2673077b56b6/langchain_core-1.4.7.tar.gz", hash = "sha256:7a825d77de0a3f39adbd9d09612a75e85527e14a52c1601089bcc062972d9f2b", size = 952522, upload-time = "2026-06-12T19:23:57.588Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/05/986c4bb148285791eb59994e0b28947bed96cac7f24467079e4274952a37/langchain_core-1.5.0.tar.gz", hash = "sha256:e1fa09d55b354192c8f60dade06a55bd6add2318c822a684555b8d4a30a16143", size = 967401, upload-time = "2026-07-21T03:37:26.48Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3e/dcdffa60078ae7b3a00ebb4cbbf1a204a14c3609983c604886523a7d4418/langchain_core-1.4.7-py3-none-any.whl", hash = "sha256:bcadd51951140ecdcba98311dbd931ba5de02a5ba8a2288dad5069c1eea2a13d", size = 554941, upload-time = "2026-06-12T19:23:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/29/56/5ef7ba14bac95b0344da18c6e8ec108dce0baf5fc054d1117702f92af29d/langchain_core-1.5.0-py3-none-any.whl", hash = "sha256:f122efee35446632b38687119fca33711abbf3b6b555e31156762298fbe78a65", size = 558510, upload-time = "2026-07-21T03:37:24.423Z" }, ] [[package]] @@ -1516,6 +1520,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, ] +[[package]] +name = "langgraph-checkpoint-postgres" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langgraph-checkpoint" }, + { name = "orjson" }, + { name = "psycopg" }, + { name = "psycopg-pool" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/51/5a2dc42e8b5d5942b933b5b7237eae5a4dbc92508a04727c263dd383ad8a/langgraph_checkpoint_postgres-3.1.0.tar.gz", hash = "sha256:02bff4ab63d9dae8eab3a9640fce1d479da8965c9fba7b0dc04cb1f7c56f0a55", size = 148473, upload-time = "2026-05-12T03:40:10.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/cd/eff9b82bc3b5f62d481b437099f44f3ef7b1d907f166fb4ee25e8f84a1e7/langgraph_checkpoint_postgres-3.1.0-py3-none-any.whl", hash = "sha256:814cce2ef35d792bf07b090a95eed004f1acac0724fe6605536b13f6d1e7032c", size = 48988, upload-time = "2026-05-12T03:40:08.925Z" }, +] + [[package]] name = "langgraph-prebuilt" version = "1.1.0" @@ -1721,7 +1740,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.0" +version = "1.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1739,9 +1758,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] [[package]] @@ -2370,13 +2389,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, + { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, + { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, +] + [[package]] name = "pyasn1" -version = "0.6.3" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] @@ -3235,6 +3335,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" From a51d88e2dea55ce0cbb1ef9e01e4066ce10a6e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yohan=20Gon=C3=A7alves?= Date: Fri, 24 Jul 2026 13:40:26 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?body=20size=20limit,=20adelete=20safety,=20asearch=20limit,=20s?= =?UTF-8?q?tore=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PUT /api/v1/store/files: max 10MB content (Field max_length=10_000_000) - _prepare_agent_namespace: try/except on adelete (prevent crash on stale files) - asearch limit 100 → 1000 (prevent silent truncation) - _get_shared_store: asyncio.Lock (prevent concurrent pool creation) --- src/application/routes/store.py | 4 ++-- src/infrastructure/deepagent/factory.py | 30 ++++++++++++++++++------ src/infrastructure/store_file/adapter.py | 2 +- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/application/routes/store.py b/src/application/routes/store.py index 42fa597..5bfdd99 100644 --- a/src/application/routes/store.py +++ b/src/application/routes/store.py @@ -11,7 +11,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Query, status -from pydantic import BaseModel +from pydantic import BaseModel, Field from src.application.use_cases.manage_store_file import ( DeleteStoreFileUseCase, @@ -47,7 +47,7 @@ class StoreFileResponse(BaseModel): class StoreFilePutRequest(BaseModel): """Request body for creating or replacing a store file.""" - content: str + content: str = Field(..., max_length=10_000_000) @router.get("/files", response_model=list[str], status_code=status.HTTP_200_OK) diff --git a/src/infrastructure/deepagent/factory.py b/src/infrastructure/deepagent/factory.py index 0eb3eed..4c00bcf 100644 --- a/src/infrastructure/deepagent/factory.py +++ b/src/infrastructure/deepagent/factory.py @@ -1,3 +1,4 @@ +import asyncio import importlib import logging from typing import Any @@ -56,6 +57,9 @@ def _get_memory_store() -> InMemoryStore: return _memory_store +_store_lock = asyncio.Lock() + + async def _get_shared_store(): """Get the global shared store instance. @@ -63,13 +67,19 @@ async def _get_shared_store(): use that. Otherwise fall back to the shared ``InMemoryStore`` singleton. This ensures the Store File API and all agents share the same store, regardless of per-agent ``store_backend`` config. + + An ``asyncio.Lock`` protects the lazy initialization to prevent + concurrent agent creations from opening multiple connection pools. """ if _pg_store is not None: return _pg_store - try: - return await _create_postgres_store() - except Exception: - return _get_memory_store() + async with _store_lock: + if _pg_store is not None: + return _pg_store + try: + return await _create_postgres_store() + except Exception: + return _get_memory_store() async def _create_postgres_store(settings: Settings | None = None) -> AsyncPostgresStore: @@ -305,7 +315,7 @@ async def _prepare_agent_namespace( agent_memories_dir = f"/agents/{agent_name}/memories/" # 1. Cleanup: delete files in agent namespace that are no longer selected - existing_items = await store.asearch(ns, limit=100) + existing_items = await store.asearch(ns, limit=1000) selected_skill_names = {s.rstrip("/").split("/")[-1] for s in skills} selected_memory_files = {m.split("/")[-1] for m in memory} @@ -314,11 +324,17 @@ async def _prepare_agent_namespace( remainder = item.key[len(agent_skills_dir) :] skill_name = remainder.split("/")[0] if "/" in remainder else remainder if skill_name not in selected_skill_names: - await store.adelete(ns, item.key) + try: + await store.adelete(ns, item.key) + except Exception: + logger.warning("Failed to delete stale agent skill: %s", item.key) elif item.key.startswith(agent_memories_dir): filename = item.key[len(agent_memories_dir) :] if filename not in selected_memory_files: - await store.adelete(ns, item.key) + try: + await store.adelete(ns, item.key) + except Exception: + logger.warning("Failed to delete stale agent memory: %s", item.key) # 2. Copy selected skills to agent namespace for skill_dir in skills: diff --git a/src/infrastructure/store_file/adapter.py b/src/infrastructure/store_file/adapter.py index 0c6bb32..4c62819 100644 --- a/src/infrastructure/store_file/adapter.py +++ b/src/infrastructure/store_file/adapter.py @@ -33,7 +33,7 @@ async def list_files(self, prefix: str) -> list[str]: Returns: A list of file path strings matching the prefix. """ - items = await self._store.asearch(self._namespace, limit=100) + items = await self._store.asearch(self._namespace, limit=1000) return [item.key for item in items if item.key.startswith(prefix)] async def get_file(self, path: str) -> str | None: From 8df1ae1378af5ab10e998cc36e6f2f9c179291c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yohan=20Gon=C3=A7alves?= Date: Fri, 24 Jul 2026 14:33:00 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20remove=20duplicate,=20log=20stacktrace,=20fix=20tes?= =?UTF-8?q?t=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - yaml_config/adapter.py: remove duplicate _DEPRECATED_BACKEND_FIELDS declaration - factory.py: logger.warning → logger.exception in _prepare_agent_namespace cleanup (keep stacktrace) - test_store_routes.py: import real StoreFileNotFoundError from src.domain.errors.store_file instead of local redefinition --- src/infrastructure/deepagent/factory.py | 6 +++--- src/infrastructure/yaml_config/adapter.py | 2 -- tests/unit/test_store_routes.py | 11 ++--------- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/infrastructure/deepagent/factory.py b/src/infrastructure/deepagent/factory.py index 4c00bcf..c7e0711 100644 --- a/src/infrastructure/deepagent/factory.py +++ b/src/infrastructure/deepagent/factory.py @@ -327,14 +327,14 @@ async def _prepare_agent_namespace( try: await store.adelete(ns, item.key) except Exception: - logger.warning("Failed to delete stale agent skill: %s", item.key) + logger.exception("Failed to delete stale agent skill: %s", item.key) elif item.key.startswith(agent_memories_dir): - filename = item.key[len(agent_memories_dir) :] + filename = item.key[len(agent_memories_dir):] if filename not in selected_memory_files: try: await store.adelete(ns, item.key) except Exception: - logger.warning("Failed to delete stale agent memory: %s", item.key) + logger.exception("Failed to delete stale agent memory: %s", item.key) # 2. Copy selected skills to agent namespace for skill_dir in skills: diff --git a/src/infrastructure/yaml_config/adapter.py b/src/infrastructure/yaml_config/adapter.py index 2872a5c..8d9c4d1 100644 --- a/src/infrastructure/yaml_config/adapter.py +++ b/src/infrastructure/yaml_config/adapter.py @@ -18,8 +18,6 @@ class YamlAgentConfigLoader(AgentConfigLoader): # Fields removed from AgentConfig that may still exist in stored YAMLs. # They are stripped before validation to maintain backward compatibility. _DEPRECATED_FIELDS = {"middleware"} - _DEPRECATED_BACKEND_FIELDS = {"root_dir", "store_backend"} - _DEPRECATED_BACKEND_FIELDS = {"root_dir", "store_backend"} _DEPRECATED_BACKEND_TYPES = {"state"} diff --git a/tests/unit/test_store_routes.py b/tests/unit/test_store_routes.py index 46cc002..460ed70 100644 --- a/tests/unit/test_store_routes.py +++ b/tests/unit/test_store_routes.py @@ -312,15 +312,8 @@ async def test_delete_returns_204_when_file_exists( async def test_delete_returns_404_when_file_not_found( self, client: AsyncClient, mock_delete_use_case: AsyncMock ) -> None: - # Arrange — use case signals absence by raising a not-found domain error. - # We model the contract as the use case raising StorageError-like NotFound; - # however the canonical pattern in this codebase is a dedicated domain error. - # Since the route maps 404 from a domain error, we raise one here. - from src.domain.errors.base import DomainError - from src.domain.errors.codes import ErrorCode - - class StoreFileNotFoundError(DomainError): - status_code = ErrorCode.NOT_FOUND + # Arrange — use case raises the real domain error that the route maps to 404. + from src.domain.errors.store_file import StoreFileNotFoundError mock_delete_use_case.execute = AsyncMock(side_effect=StoreFileNotFoundError("not found")) From 608316dccfb5bcb044205b9dbb4702e0df57b4a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yohan=20Gon=C3=A7alves?= Date: Fri, 24 Jul 2026 15:00:11 +0200 Subject: [PATCH 4/4] feat: store preview endpoint, path traversal rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New endpoint GET /api/v1/store/files/previews?prefix=&chars= returns path+preview (eliminates N+1) - StoreFilePreview dataclass + list_files_with_preview on port/adapter (free — uses asearch values) - ListStoreFilePreviewsUseCase + dependency provider - _normalize_path: reject paths containing '..' (defense in depth) --- src/application/routes/store.py | 32 +++++++++++++++++-- .../use_cases/manage_store_file.py | 23 ++++++------- src/dependencies.py | 6 ++++ src/domain/ports/store_file_repository.py | 21 ++++++++++++ src/infrastructure/store_file/adapter.py | 24 +++++++++++++- 5 files changed, 90 insertions(+), 16 deletions(-) diff --git a/src/application/routes/store.py b/src/application/routes/store.py index 5bfdd99..148f41d 100644 --- a/src/application/routes/store.py +++ b/src/application/routes/store.py @@ -16,12 +16,14 @@ from src.application.use_cases.manage_store_file import ( DeleteStoreFileUseCase, GetStoreFileUseCase, + ListStoreFilePreviewsUseCase, ListStoreFilesUseCase, PutStoreFileUseCase, ) from src.dependencies import ( get_delete_store_file_use_case, get_get_store_file_use_case, + get_list_store_file_previews_use_case, get_list_store_files_use_case, get_put_store_file_use_case, ) @@ -33,8 +35,11 @@ def _normalize_path(path: str) -> str: - """Ensure the path starts with a forward slash for store key consistency.""" - return path if path.startswith("/") else f"/{path}" + """Ensure the path starts with a forward slash and reject path traversal.""" + normalized = path if path.startswith("/") else f"/{path}" + if ".." in normalized: + raise StoreFileNotFoundError(f"Invalid path: {path}") + return normalized class StoreFileResponse(BaseModel): @@ -44,6 +49,13 @@ class StoreFileResponse(BaseModel): content: str +class StoreFilePreviewResponse(BaseModel): + """Response DTO for a store file with a truncated preview.""" + + path: str + preview: str + + class StoreFilePutRequest(BaseModel): """Request body for creating or replacing a store file.""" @@ -67,6 +79,22 @@ async def list_store_files( return await use_case.execute(prefix=prefix) +@router.get("/files/previews", response_model=list[StoreFilePreviewResponse], status_code=status.HTTP_200_OK) +async def list_store_file_previews( + use_case: Annotated[ListStoreFilePreviewsUseCase, Depends(get_list_store_file_previews_use_case)], + prefix: str = Query(default="/", description="Path prefix to filter files by."), + chars: int = Query(default=300, ge=1, le=10000, description="Max characters per preview."), +) -> list[StoreFilePreviewResponse]: + """List files with a truncated content preview. + + Returns one ``StoreFilePreviewResponse`` per matching file, containing the + first ``chars`` characters of the file content. This avoids N+1 fetches + when a client needs to display previews (e.g. memory cards or skill names). + """ + previews = await use_case.execute(prefix=prefix, preview_chars=chars) + return [StoreFilePreviewResponse(path=p.path, preview=p.preview) for p in previews] + + @router.get("/files/{path:path}", response_model=StoreFileResponse, status_code=status.HTTP_200_OK) async def get_store_file( path: str, diff --git a/src/application/use_cases/manage_store_file.py b/src/application/use_cases/manage_store_file.py index cc8a02f..6754863 100644 --- a/src/application/use_cases/manage_store_file.py +++ b/src/application/use_cases/manage_store_file.py @@ -6,30 +6,27 @@ one action). """ -from src.domain.ports.store_file_repository import StoreFileRepository +from src.domain.ports.store_file_repository import StoreFilePreview, StoreFileRepository class ListStoreFilesUseCase: """List file paths in the store filtered by an optional prefix.""" def __init__(self, repository: StoreFileRepository) -> None: - """Initialize the use case. - - Args: - repository: The store file repository (outbound port). - """ self._repository = repository async def execute(self, prefix: str = "/") -> list[str]: - """List files matching the given prefix. + return await self._repository.list_files(prefix) - Args: - prefix: Path prefix to filter on (default ``"/"`` = all files). - Returns: - A list of file path strings. - """ - return await self._repository.list_files(prefix) +class ListStoreFilePreviewsUseCase: + """List files with a truncated content preview.""" + + def __init__(self, repository: StoreFileRepository) -> None: + self._repository = repository + + async def execute(self, prefix: str, preview_chars: int) -> list[StoreFilePreview]: + return await self._repository.list_files_with_preview(prefix, preview_chars) class GetStoreFileUseCase: diff --git a/src/dependencies.py b/src/dependencies.py index 286b270..0c1598b 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -21,6 +21,7 @@ from src.application.use_cases.manage_store_file import ( DeleteStoreFileUseCase, GetStoreFileUseCase, + ListStoreFilePreviewsUseCase, ListStoreFilesUseCase, PutStoreFileUseCase, ) @@ -425,6 +426,11 @@ def get_list_store_files_use_case() -> ListStoreFilesUseCase: return ListStoreFilesUseCase(_require_store_file_repository()) +def get_list_store_file_previews_use_case() -> ListStoreFilePreviewsUseCase: + """Provide a :class:`ListStoreFilePreviewsUseCase` instance.""" + return ListStoreFilePreviewsUseCase(_require_store_file_repository()) + + def get_get_store_file_use_case() -> GetStoreFileUseCase: """Provide a :class:`GetStoreFileUseCase` instance.""" return GetStoreFileUseCase(_require_store_file_repository()) diff --git a/src/domain/ports/store_file_repository.py b/src/domain/ports/store_file_repository.py index b1f4280..e79a092 100644 --- a/src/domain/ports/store_file_repository.py +++ b/src/domain/ports/store_file_repository.py @@ -1,6 +1,15 @@ """Outbound port: file repository backed by a key-value store.""" from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass(frozen=True) +class StoreFilePreview: + """A file path with a truncated preview of its content.""" + + path: str + preview: str class StoreFileRepository(ABC): @@ -21,6 +30,18 @@ async def list_files(self, prefix: str) -> list[str]: A list of file path strings matching the prefix. """ + @abstractmethod + async def list_files_with_preview(self, prefix: str, preview_chars: int) -> list[StoreFilePreview]: + """List files matching prefix, each with the first ``preview_chars`` of content. + + Args: + prefix: Path prefix to filter on. + preview_chars: Maximum number of characters to include in each preview. + + Returns: + A list of ``StoreFilePreview`` objects. + """ + @abstractmethod async def get_file(self, path: str) -> str | None: """Get file content by path. diff --git a/src/infrastructure/store_file/adapter.py b/src/infrastructure/store_file/adapter.py index 4c62819..3ccd20b 100644 --- a/src/infrastructure/store_file/adapter.py +++ b/src/infrastructure/store_file/adapter.py @@ -2,7 +2,7 @@ from langgraph.store.base import BaseStore -from src.domain.ports.store_file_repository import StoreFileRepository +from src.domain.ports.store_file_repository import StoreFilePreview, StoreFileRepository _DEFAULT_NAMESPACE: tuple[str, ...] = ("filesystem",) @@ -36,6 +36,28 @@ async def list_files(self, prefix: str) -> list[str]: items = await self._store.asearch(self._namespace, limit=1000) return [item.key for item in items if item.key.startswith(prefix)] + async def list_files_with_preview(self, prefix: str, preview_chars: int) -> list[StoreFilePreview]: + """List files matching prefix, each with a truncated content preview. + + Uses the values already returned by ``asearch`` — no extra DB reads. + + Args: + prefix: Path prefix to filter on. + preview_chars: Maximum characters to include in each preview. + + Returns: + A list of ``StoreFilePreview`` objects. + """ + items = await self._store.asearch(self._namespace, limit=1000) + return [ + StoreFilePreview( + path=item.key, + preview=str(item.value.get("content", ""))[:preview_chars], + ) + for item in items + if item.key.startswith(prefix) + ] + async def get_file(self, path: str) -> str | None: """Get file content by path.