diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a84c7ab..ebf432d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -71,7 +71,6 @@ jobs: - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v5 - continue-on-error: true env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c99ff0a..3bd238f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,11 +94,13 @@ Key points: - `AgentConfig` is a **frozen** Pydantic `BaseModel` (immutable after creation). - Validation includes: - `name` must be 1-100 characters. + - `description` is an optional `str | None` (max 500 characters), persisted in the `agent_configs` table (migration `010_add_description_to_agent_configs`) and exposed via `AgentConfigMetadata`. - `system_prompt` and `system_prompt_file` are mutually exclusive (enforced by `@model_validator`). - `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`. + - `subagents[*].agent_ref` is an optional `str | None` referencing another existing agent by name. At runner-build time the backend resolves the referenced agent's `model`, `system_prompt`, `mcp_servers`, `tools`, and `response_format`; explicit values on the `SubAgentConfig` override the inherited ones. **One level only** — the referenced agent's own `subagents` are not resolved. Validation rejects self-references (`agent_ref == agent name`) and non-existent references with a `ConfigError` at create/update time. When an agent is updated or deleted, any agents referencing it via `agent_ref` are also invalidated in the registry. To modify the schema, edit `src/domain/entities/agent_config.py` and regenerate the JSON schema: diff --git a/README.md b/README.md index 1bfdb16..50f4483 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ Every agent is defined by a single YAML file validated against the `AgentConfig` | Field | Type | Default | Description | |---|---|---|---| | `name` | `string` (required) | -- | Unique agent name (1-100 characters). | +| `description` | `string` | `null` | Optional human-readable description of the agent (max 500 characters). Stored in the `agent_configs` table and exposed via `AgentConfigMetadata`. | | `model` | `string` | `"claude-sonnet-4-5-20250929"` | LLM model identifier. See [Supported Models](#supported-models). | | `system_prompt` | `string` | `null` | Inline system prompt. Mutually exclusive with `system_prompt_file`. | | `system_prompt_file` | `string` | `null` | Path to a text file containing the system prompt (resolved relative to the YAML file). Mutually exclusive with `system_prompt`. | @@ -176,6 +177,36 @@ Every agent is defined by a single YAML file validated against the `AgentConfig` | `tools` | `list[string]` | `[]` | Tool references specific to this sub-agent. | | `skills` | `list[string]` | `[]` | Skill paths for this sub-agent. | | `mcp_servers` | `list[McpServerConfig]` | `[]` | MCP servers for this sub-agent. | +| `agent_ref` | `string` | `null` | Name of an existing agent to reference. When set, the backend resolves the referenced agent's `model`, `system_prompt`, `mcp_servers`, `tools`, and `response_format` at runner-build time. Explicit values on the `SubAgentConfig` override the referenced agent's values. See [Agent references (`agent_ref`)](#agent-references-agent_ref). | + +### Agent references (`agent_ref`) + +A `SubAgentConfig` can reference another existing agent by name via the `agent_ref` field. This lets you compose agents without duplicating their configuration. + +**Resolution rules:** + +- At runner-build time, the backend looks up the referenced agent's `AgentConfig` and copies its `model`, `system_prompt`, `mcp_servers`, `tools`, and `response_format` into the sub-agent. +- Any field set explicitly on the `SubAgentConfig` (e.g. `model`, `instructions`) **overrides** the value inherited from the referenced agent. +- **One level only** — the referenced agent's own `subagents` are ignored. References are not resolved recursively. + +**Validation (enforced at create/update time):** + +- **Self-reference** (`agent_ref` equal to the parent agent's `name`) is rejected with a `ConfigError`. +- **Non-existent reference** (`agent_ref` pointing to an agent that does not exist) is rejected with a `ConfigError`. + +**Cache invalidation:** + +When an agent is updated or deleted, the registry also invalidates any agents that reference it via `agent_ref` in their `subagents`. This ensures stale runners are rebuilt on next use. + +Example: + +```yaml +name: orchestrator +subagents: + - name: researcher + agent_ref: research-assistant # inherits model, system_prompt, mcp_servers, tools, response_format + instructions: "Focus on recent papers." # overrides system_prompt +``` ### McpServerConfig @@ -299,6 +330,16 @@ For OpenAI-compatible endpoints (OpenRouter, LiteLLM, vLLM, etc.), set the `OPEN Agents can connect to [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers for tool access. MCP servers are defined in the agent's YAML config: +> **Registry migration:** The MCP server **registry** (CRUD API, OpenAPI→MCP generation, Swagger 2.0 conversion, Fernet encryption, mounter, startup rehydration) **and the `mcp_servers` table schema** used to live in this brick. Both have been **moved to [mcp-raganything](https://github.com/soludev/mcp-raganything)**, which now owns the `/api/v1/mcp/servers` REST surface and the table's Alembic migrations (`001_create_mcp_servers_table`, tracked in the `raganything_alembic_version` table). composable-agents no longer ships the registry routes, use cases, repository, OpenAPI factory, Swagger 2.0 converter, Fernet cipher, mounter, or any `mcp_servers` migration. +> +> What remains in this brick is: +> +> - `McpServerConfig` — the entity used by agent YAML to declare an MCP server connection. +> - `McpToolLoader` (port) / `LangchainMcpToolLoader` (adapter) — loads tools from an MCP server **by URL** at agent build time. The URLs come from the agent YAML directly, or from entries registered in mcp-raganything's registry (which composable-agents reads via its UI/backend when needed). +> - `McpConnectionError` / `McpToolLoadError` — domain errors raised when an MCP server cannot be reached or its tools fail to load. +> +> `SECRET_ENCRYPTION_KEY` is now **optional** in composable-agents (it is no longer used here); it must still be set on the mcp-raganything service that owns the registry. + ```yaml name: mcp-agent model: claude-sonnet-4-5-20250929 @@ -1210,7 +1251,7 @@ composable-agents/ domain/ entities/ agent_config.py # AgentConfig, BackendConfig, HITLConfig, SubAgentConfig - agent_config_metadata.py # AgentConfigMetadata + agent_config_metadata.py # AgentConfigMetadata (incl. description) mcp_server_config.py # McpServerConfig, McpTransportType message.py # Message (role, content, timestamp, tool_calls) — projection model thread.py # Thread (id, agent_name, timestamps) — no more MessageModel @@ -1422,6 +1463,9 @@ Relevant migrations for the trace events refactor: | `005_create_trace_events_table` | Creates the `trace_events` table with the 3 indexes above. | | `006_migrate_messages_to_trace_events` | Backfills `trace_events` from existing `messages` rows (`role = "human"` → `HUMAN_MESSAGE`, `role = "ai"` → `AI_MESSAGE`). | | `007_drop_messages_table` | Drops the legacy `messages` table. | +| `010_add_description_to_agent_configs` | Adds a `description VARCHAR(500)` column to the `agent_configs` table. | + +> The `mcp_servers` table is **not** managed here. It is owned by mcp-raganything's Alembic migration `001_create_mcp_servers_table` (tracked in the `raganything_alembic_version` table). To create a new migration manually: diff --git a/pyproject.toml b/pyproject.toml index 025a2ae..1105d6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,9 @@ line-length = 120 select = ["E", "W", "F", "I", "B", "C4", "UP", "ARG", "SIM"] ignore = ["E501", "B008"] +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["ARG002"] + [tool.ruff.lint.isort] known-first-party = ["src"] diff --git a/src/alembic/versions/010_add_description_to_agent_configs.py b/src/alembic/versions/010_add_description_to_agent_configs.py new file mode 100644 index 0000000..6a885bd --- /dev/null +++ b/src/alembic/versions/010_add_description_to_agent_configs.py @@ -0,0 +1,35 @@ +"""Add description column to agent_configs. + +Revision ID: 010 +Revises: 007 +Create Date: 2026-07-25 + +Adds an optional ``description`` column (``VARCHAR(500) NULL``) to the +``agent_configs`` table so persisted agent metadata can carry a human-readable +description sourced from the YAML configuration. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "010" +down_revision: str | None = "007" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + ALTER TABLE agent_configs ADD COLUMN description VARCHAR(500); + """ + ) + + +def downgrade() -> None: + op.execute( + """ + ALTER TABLE agent_configs DROP COLUMN description; + """ + ) diff --git a/src/application/use_cases/_subagent_ref_utils.py b/src/application/use_cases/_subagent_ref_utils.py new file mode 100644 index 0000000..d784c25 --- /dev/null +++ b/src/application/use_cases/_subagent_ref_utils.py @@ -0,0 +1,112 @@ +"""Shared helpers for subagent ``agent_ref`` validation and dependent invalidation.""" + +import logging + +import yaml # type: ignore[import-untyped] + +from src.domain.entities.agent_config import AgentConfig +from src.domain.errors.config import ConfigError +from src.domain.ports.agent_config_repository import AgentConfigRepository +from src.domain.ports.agent_config_store import AgentConfigStore +from src.domain.ports.agent_registry import AgentRegistry + +logger = logging.getLogger(__name__) + + +async def validate_subagent_refs( + config: AgentConfig, + repository: AgentConfigRepository, +) -> None: + """Validate subagent ``agent_ref`` references against the metadata repository. + + The metadata repository is the single source of truth for "does this agent + exist?" — injecting the port (rather than a bound ``exists`` callable) keeps + the use case decoupled from any one adapter's notion of existence and avoids + drift if the store/repository split is refactored later. + + Raises ``ConfigError`` on self-reference or when a referenced agent + does not exist. + + Args: + config: The agent configuration whose subagents are checked. + repository: Metadata repository used to confirm referenced agents exist. + """ + for sa in config.subagents: + if sa.agent_ref is None: + continue + if sa.agent_ref == config.name: + raise ConfigError( + f"Subagent '{sa.name}' references its own agent '{config.name}' (self-reference is not allowed)." + ) + if not await repository.exists(sa.agent_ref): + raise ConfigError( + f"Subagent '{sa.name}' references unknown agent '{sa.agent_ref}'." + ) + + +async def invalidate_dependent_agents( + config_store: AgentConfigStore, + agent_registry: AgentRegistry, + agent_name: str, +) -> None: + """Invalidate cached runners of agents that reference ``agent_name`` via subagent ``agent_ref``. + + Scans every stored YAML, parses it with ``yaml.safe_load`` (lightweight — full + AgentConfig validation is not needed here) and looks for subagents whose + ``agent_ref`` equals ``agent_name``. The ``agent_name`` itself is not invalidated + a second time (the caller is expected to have already invalidated it). + + Corrupted or unparseable YAMLs are logged at ``ERROR`` level (with the + exception) and skipped so a single bad config cannot prevent invalidation of + the rest. After the loop, a summary error lists every agent that could not be + inspected so the failure surface is observable — not silently swallowed. + + Args: + config_store: Store exposing all persisted YAML configs. + agent_registry: Registry whose cached runners must be invalidated. + agent_name: Name of the agent whose dependents must be invalidated. + """ + try: + all_names = await config_store.list_all() + except Exception: + logger.warning( + "Failed to list stored agent configs during dependent invalidation for '%s'", + agent_name, + exc_info=True, + ) + return + + failed_agents: list[str] = [] + + for other_name in all_names: + if other_name == agent_name: + continue + try: + yaml_content = await config_store.get(other_name) + data = yaml.safe_load(yaml_content) or {} + if not isinstance(data, dict): + continue + subagents = data.get("subagents") or [] + if not isinstance(subagents, list): + continue + for sa in subagents: + if isinstance(sa, dict) and sa.get("agent_ref") == agent_name: + await agent_registry.invalidate(other_name) + break + except Exception: + logger.error( + "Failed to parse YAML for agent '%s' during dependent invalidation of '%s'", + other_name, + agent_name, + exc_info=True, + ) + failed_agents.append(other_name) + continue + + if failed_agents: + logger.error( + "Dependent invalidation for '%s' skipped %d agent(s) with parse errors: %s", + agent_name, + len(failed_agents), + ", ".join(failed_agents), + ) diff --git a/src/application/use_cases/create_agent_config.py b/src/application/use_cases/create_agent_config.py index 37daf4e..1fbd58b 100644 --- a/src/application/use_cases/create_agent_config.py +++ b/src/application/use_cases/create_agent_config.py @@ -1,6 +1,7 @@ import logging from datetime import UTC, datetime +from src.application.use_cases._subagent_ref_utils import validate_subagent_refs from src.domain.entities.agent_config import AgentConfig from src.domain.entities.agent_config_metadata import AgentConfigMetadata from src.domain.errors.agent import AgentConfigAlreadyExistsError @@ -46,6 +47,8 @@ async def execute(self, name: str, yaml_content: str) -> AgentConfig: if config.name != name: raise ConfigError(ErrorMessage.AGENT_NAME_MISMATCH.format(yaml_name=config.name, name=name)) + await validate_subagent_refs(config, self._config_repository) + if await self._config_repository.exists(name): raise AgentConfigAlreadyExistsError(ErrorMessage.AGENT_CONFIG_ALREADY_EXISTS.format(name=name)) @@ -56,6 +59,7 @@ async def execute(self, name: str, yaml_content: str) -> AgentConfig: name=name, model=config.model, minio_path=f"{name}.yaml", + description=config.description, created_at=now, updated_at=now, ) diff --git a/src/application/use_cases/delete_agent_config.py b/src/application/use_cases/delete_agent_config.py index e79d11f..435f16b 100644 --- a/src/application/use_cases/delete_agent_config.py +++ b/src/application/use_cases/delete_agent_config.py @@ -1,5 +1,6 @@ import logging +from src.application.use_cases._subagent_ref_utils import invalidate_dependent_agents from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_repository import AgentConfigRepository from src.domain.ports.agent_config_store import AgentConfigStore @@ -35,5 +36,6 @@ async def execute(self, name: str) -> None: await self._config_store.delete(name) await self._config_repository.delete(name) await self._agent_registry.invalidate(name) + await invalidate_dependent_agents(self._config_store, self._agent_registry, name) logger.info(LogMessage.AGENT_CONFIG_DELETED_UC, name) diff --git a/src/application/use_cases/update_agent_config.py b/src/application/use_cases/update_agent_config.py index ec930c2..c7dd052 100644 --- a/src/application/use_cases/update_agent_config.py +++ b/src/application/use_cases/update_agent_config.py @@ -1,6 +1,7 @@ import logging from datetime import UTC, datetime +from src.application.use_cases._subagent_ref_utils import invalidate_dependent_agents, validate_subagent_refs from src.domain.entities.agent_config import AgentConfig from src.domain.errors.config import ConfigError from src.domain.errors.messages import ErrorMessage @@ -49,15 +50,22 @@ async def execute(self, name: str, yaml_content: str) -> AgentConfig: if config.name != name: raise ConfigError(ErrorMessage.AGENT_NAME_MISMATCH_URL.format(yaml_name=config.name, name=name)) + await validate_subagent_refs(config, self._config_repository) + await self._config_store.put(name, yaml_content) now = datetime.now(UTC) updated_metadata = metadata.model_copy( - update={"model": config.model, "updated_at": now}, + update={ + "model": config.model, + "description": config.description, + "updated_at": now, + }, ) await self._config_repository.save(updated_metadata) await self._agent_registry.invalidate(name) + await invalidate_dependent_agents(self._config_store, self._agent_registry, name) logger.info(LogMessage.AGENT_CONFIG_UPDATED_UC, name) return config diff --git a/src/domain/entities/agent_config.py b/src/domain/entities/agent_config.py index 565ee02..c023533 100644 --- a/src/domain/entities/agent_config.py +++ b/src/domain/entities/agent_config.py @@ -26,6 +26,7 @@ class HITLConfig(BaseModel): class SubAgentConfig(BaseModel): name: str = Field(..., min_length=1) description: str = Field(..., min_length=1) + agent_ref: str | None = None instructions: str | None = None model: str | None = None tools: list[str] = Field(default_factory=list) @@ -40,6 +41,7 @@ class AgentConfig(BaseModel): model_config = ConfigDict(frozen=True, extra="forbid") name: str = Field(..., min_length=1, max_length=100) + description: str | None = None model: str = Field(default="claude-sonnet-4-5-20250929") system_prompt: str | None = None system_prompt_file: str | None = None diff --git a/src/domain/entities/agent_config_metadata.py b/src/domain/entities/agent_config_metadata.py index d6d79a0..f03650c 100644 --- a/src/domain/entities/agent_config_metadata.py +++ b/src/domain/entities/agent_config_metadata.py @@ -11,3 +11,4 @@ class AgentConfigMetadata(BaseModel): minio_path: str created_at: datetime updated_at: datetime + description: str | None = None diff --git a/src/infrastructure/database/models/agent_config.py b/src/infrastructure/database/models/agent_config.py index 2f3bb45..2983e9c 100644 --- a/src/infrastructure/database/models/agent_config.py +++ b/src/infrastructure/database/models/agent_config.py @@ -12,5 +12,6 @@ class AgentConfigModel(Base): name: Mapped[str] = mapped_column(String(100), primary_key=True) model: Mapped[str] = mapped_column(String(200), nullable=False) minio_path: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str | None] = mapped_column(String(500), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/src/infrastructure/deepagent/factory.py b/src/infrastructure/deepagent/factory.py index c7e0711..37903c0 100644 --- a/src/infrastructure/deepagent/factory.py +++ b/src/infrastructure/deepagent/factory.py @@ -1,6 +1,7 @@ import asyncio import importlib import logging +from collections.abc import Awaitable, Callable from typing import Any from deepagents import create_deep_agent @@ -13,6 +14,7 @@ from src.config import Settings from src.domain.entities.agent_config import AgentConfig, SubAgentConfig +from src.domain.errors.config import ConfigError from src.domain.logging.messages import LogMessage from src.domain.ports.mcp_tool_loader import McpToolLoader from src.domain.ports.prompt_manager import PromptManager @@ -222,12 +224,16 @@ async def _resolve_subagents( config: AgentConfig, mcp_tool_loader: McpToolLoader | None = None, prompt_manager: PromptManager | None = None, + config_resolver: Callable[[str], Awaitable[AgentConfig]] | None = None, ) -> list | None: """Convertit les configs de sous-agents. Args: config: Configuration de l'agent principal. mcp_tool_loader: Loader MCP optionnel pour les sous-agents. + prompt_manager: Gestionnaire de prompts optionnel. + config_resolver: Résolveur optionnel permettant de charger la + ``AgentConfig`` référencée par ``SubAgentConfig.agent_ref``. Returns: Liste de dicts de sous-agents ou None. @@ -236,6 +242,11 @@ async def _resolve_subagents( return None subagents = [] for sa in config.subagents: + if sa.agent_ref is not None: + spec = await _resolve_referenced_subagent(sa, mcp_tool_loader, prompt_manager, config_resolver) + subagents.append(spec) + continue + local_tools = _resolve_tools_list(sa.tools) if sa.tools else None mcp_tools: list = [] if sa.mcp_servers and mcp_tool_loader: @@ -257,6 +268,61 @@ async def _resolve_subagents( return subagents +async def _resolve_referenced_subagent( + sa: SubAgentConfig, + mcp_tool_loader: McpToolLoader | None, + prompt_manager: PromptManager | None, + config_resolver: Callable[[str], Awaitable[AgentConfig]] | None, +) -> dict: + """Build a subagent spec from a SubAgentConfig that references another agent. + + Args: + sa: The subagent configuration carrying ``agent_ref``. + mcp_tool_loader: Optional MCP tool loader. + prompt_manager: Optional prompt manager. + config_resolver: Async callable returning the referenced AgentConfig. + + Returns: + Dict spec ready for ``create_deep_agent``. + + Raises: + ConfigError: If ``agent_ref`` is set but no ``config_resolver`` is provided. + """ + if config_resolver is None: + raise ConfigError( + f"Subagent '{sa.name}' references agent '{sa.agent_ref}' but no config_resolver was provided." + ) + + assert sa.agent_ref is not None + ref = await config_resolver(sa.agent_ref) + + if ref.subagents: + logger.warning( + "Referenced agent '%s' has its own subagents; ignoring them (one level only).", + sa.agent_ref, + ) + + instructions = await _resolve_subagent_instructions(sa, prompt_manager) + system_prompt = instructions if instructions is not None else ref.system_prompt + + local_tools = _resolve_tools_list(sa.tools) if sa.tools else None + ref_tools = _resolve_tools_list(ref.tools) if ref.tools else None + mcp_tools: list = [] + servers = list(sa.mcp_servers) + list(ref.mcp_servers) + if servers and mcp_tool_loader: + mcp_tools = await mcp_tool_loader.load_tools(servers) + all_tools = (local_tools or []) + (ref_tools or []) + mcp_tools if (local_tools or ref_tools or mcp_tools) else None + + return { + "name": sa.name, + "description": sa.description, + "system_prompt": system_prompt, + "model": sa.model if sa.model else ref.model, + "tools": all_tools, + "response_format": sa.response_format if sa.response_format else ref.response_format, + } + + def _resolve_tools_list(tool_paths: list[str]) -> list | None: """Helper pour resoudre une liste de tools.""" if not tool_paths: @@ -329,7 +395,7 @@ async def _prepare_agent_namespace( except Exception: 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) @@ -362,12 +428,16 @@ async def create_agent_from_config( config: AgentConfig, mcp_tool_loader: McpToolLoader | None = None, prompt_manager: PromptManager | None = None, + config_resolver: Callable[[str], Awaitable[AgentConfig]] | None = None, ): """Create a compiled Deep Agent from configuration. Args: config: Agent configuration. mcp_tool_loader: Optional MCP tool loader for loading remote tools. + prompt_manager: Optional prompt manager for loading system prompts. + config_resolver: Optional async callable used to resolve subagent + ``agent_ref`` references into full ``AgentConfig`` objects. Returns: Tuple of (compiled agent graph, response_format_model or None). @@ -428,7 +498,7 @@ async def create_agent_from_config( else: response_format_model = None - subagents = await _resolve_subagents(config, mcp_tool_loader, prompt_manager) + subagents = await _resolve_subagents(config, mcp_tool_loader, prompt_manager, config_resolver) if subagents: kwargs["subagents"] = subagents logger.info(LogMessage.AGENT_SUBAGENTS, config.name, len(subagents)) diff --git a/src/infrastructure/persistent_registry/adapter.py b/src/infrastructure/persistent_registry/adapter.py index c5aaf52..a155f82 100644 --- a/src/infrastructure/persistent_registry/adapter.py +++ b/src/infrastructure/persistent_registry/adapter.py @@ -1,6 +1,7 @@ import asyncio import logging +from src.domain.entities.agent_config import AgentConfig from src.domain.logging.messages import LogMessage from src.domain.ports.agent_config_loader import AgentConfigLoader from src.domain.ports.agent_config_repository import AgentConfigRepository @@ -64,8 +65,13 @@ async def get_runner(self, agent_name: str) -> AgentRunner: logger.info(LogMessage.AGENT_BUILDING, agent_name) yaml_content = await self._config_store.get(agent_name) config = self._config_loader.load_from_string(yaml_content) + + async def config_resolver(name: str) -> AgentConfig: + referenced_yaml = await self._config_store.get(name) + return self._config_loader.load_from_string(referenced_yaml) + graph, response_format_model = await create_agent_from_config( - config, self._mcp_tool_loader, self._prompt_manager + config, self._mcp_tool_loader, self._prompt_manager, config_resolver=config_resolver ) runner = DeepAgentRunner( graph, diff --git a/src/infrastructure/postgres_repository/adapter.py b/src/infrastructure/postgres_repository/adapter.py index 7bd8fd9..7ff1c7e 100644 --- a/src/infrastructure/postgres_repository/adapter.py +++ b/src/infrastructure/postgres_repository/adapter.py @@ -20,6 +20,7 @@ def _model_to_metadata(model: AgentConfigModel) -> AgentConfigMetadata: name=model.name, model=model.model, minio_path=model.minio_path, + description=model.description, created_at=model.created_at, updated_at=model.updated_at, ) @@ -52,6 +53,7 @@ async def save(self, metadata: AgentConfigMetadata) -> None: name=metadata.name, model=metadata.model, minio_path=metadata.minio_path, + description=metadata.description, created_at=metadata.created_at, updated_at=metadata.updated_at, ) diff --git a/tests/unit/test_agent_config.py b/tests/unit/test_agent_config.py index 0fbe65c..5866f05 100644 --- a/tests/unit/test_agent_config.py +++ b/tests/unit/test_agent_config.py @@ -139,6 +139,49 @@ def test_response_format_is_frozen(self): with pytest.raises(ValidationError): config.response_format = {"type": "object"} + # ------------------------------------------------------------------ + # New: optional `description` field on AgentConfig. + # ------------------------------------------------------------------ + + def test_accepts_optional_description(self): + """Should accept an optional description and store it.""" + # Arrange + config = AgentConfig(name="x", description="An agent") + + # Act + description = config.description + + # Assert + assert description == "An agent" + + def test_description_defaults_to_none(self): + """Should default description to None when not provided.""" + # Arrange + config = AgentConfig(name="x") + + # Act + description = config.description + + # Assert + assert description is None + + def test_full_config_with_description(self): + """Should parse a full config including the description field.""" + # Arrange + data = { + "name": "my-agent", + "model": "openai:gpt-4o", + "system_prompt": "You are helpful.", + "description": "A research assistant.", + "subagents": [{"name": "sub", "description": "A subagent"}], + } + + # Act + config = AgentConfig(**data) + + # Assert + assert config.description == "A research assistant." + class TestBackendConfigChanges: """Tests for the BackendType / BackendConfig refactor.""" @@ -249,3 +292,36 @@ def test_response_format_is_none_by_default(self): # Assert assert sa.response_format is None + + # ------------------------------------------------------------------ + # New: optional `agent_ref` field on SubAgentConfig. + # ------------------------------------------------------------------ + + def test_subagent_accepts_agent_ref(self): + """Should accept an optional agent_ref naming another agent.""" + # Arrange + sa = SubAgentConfig(name="sub", description="d", agent_ref="other") + + # Act + agent_ref = sa.agent_ref + + # Assert + assert agent_ref == "other" + + def test_subagent_agent_ref_defaults_to_none(self): + """Should default agent_ref to None when not provided.""" + # Arrange + sa = SubAgentConfig(name="sub", description="d") + + # Act + agent_ref = sa.agent_ref + + # Assert + assert agent_ref is None + + def test_subagent_with_agent_ref_still_requires_description(self): + """Should raise ValidationError when description missing even with agent_ref.""" + # Arrange + # Act & Assert + with pytest.raises(ValidationError): + SubAgentConfig(name="sub", agent_ref="other") diff --git a/tests/unit/test_agent_crud.py b/tests/unit/test_agent_crud.py index 3799dd2..9e3982f 100644 --- a/tests/unit/test_agent_crud.py +++ b/tests/unit/test_agent_crud.py @@ -136,6 +136,103 @@ async def test_raises_config_error_when_yaml_invalid(self, use_case, mock_agent_ with pytest.raises(ConfigError): await use_case.execute(name="bad-agent", yaml_content=INVALID_YAML) + # ------------------------------------------------------------------ + # New: description persistence + agent_ref validation on create. + # ------------------------------------------------------------------ + + async def test_saves_description_in_metadata_when_present( + self, use_case, mock_agent_config_repository, mock_agent_config_store + ): + """Should save the parsed description into the AgentConfigMetadata.""" + # Arrange + mock_agent_config_repository.exists.return_value = False + yaml_with_description = ( + "name: test-agent\n" + "model: claude-sonnet-4-5-20250929\n" + 'system_prompt: "You are a test agent."\n' + 'description: "My agent"\n' + ) + + # Act + await use_case.execute(name="test-agent", yaml_content=yaml_with_description) + + # Assert + mock_agent_config_repository.save.assert_awaited_once() + saved_metadata = mock_agent_config_repository.save.await_args.args[0] + assert saved_metadata.description == "My agent" + + async def test_rejects_subagent_agent_ref_pointing_to_nonexistent_agent( + self, use_case, mock_agent_config_repository, mock_agent_config_store + ): + """Should raise ConfigError when a subagent agent_ref does not exist.""" + # Arrange + mock_agent_config_repository.exists.return_value = False + + async def exists_side_effect(name: str) -> bool: + return False if name == "ghost" else False + + mock_agent_config_repository.exists.side_effect = exists_side_effect + yaml_with_ref = ( + "name: parent-agent\n" + "model: claude-sonnet-4-5-20250929\n" + "subagents:\n" + " - name: sub\n" + " description: d\n" + " agent_ref: ghost\n" + ) + + # Act & Assert + with pytest.raises(ConfigError): + await use_case.execute(name="parent-agent", yaml_content=yaml_with_ref) + mock_agent_config_store.put.assert_not_awaited() + + async def test_rejects_subagent_agent_ref_equal_to_agent_name( + self, use_case, mock_agent_config_repository, mock_agent_config_store + ): + """Should raise ConfigError when a subagent agent_ref equals the agent's own name.""" + # Arrange + mock_agent_config_repository.exists.return_value = False + yaml_self_ref = ( + "name: self-agent\n" + "model: claude-sonnet-4-5-20250929\n" + "subagents:\n" + " - name: sub\n" + " description: d\n" + " agent_ref: self-agent\n" + ) + + # Act & Assert + with pytest.raises(ConfigError): + await use_case.execute(name="self-agent", yaml_content=yaml_self_ref) + + async def test_accepts_subagent_agent_ref_pointing_to_existing_agent( + self, use_case, mock_agent_config_repository, mock_agent_config_store + ): + """Should succeed when subagent agent_ref points to an existing agent.""" + # Arrange + existing_names = {"researcher"} + + async def exists_side_effect(name: str) -> bool: + return name in existing_names + + mock_agent_config_repository.exists.side_effect = exists_side_effect + yaml_with_ref = ( + "name: parent-agent\n" + "model: claude-sonnet-4-5-20250929\n" + "subagents:\n" + " - name: sub\n" + " description: d\n" + " agent_ref: researcher\n" + ) + + # Act + await use_case.execute(name="parent-agent", yaml_content=yaml_with_ref) + + # Assert — the use case must have validated the referenced agent exists. + mock_agent_config_store.put.assert_awaited_once() + exists_calls = [call.args[0] for call in mock_agent_config_repository.exists.await_args_list] + assert "researcher" in exists_calls + class TestUpdateAgentConfigUseCase: """Tests for UpdateAgentConfigUseCase.""" @@ -239,6 +336,85 @@ async def test_raises_config_error_when_name_mismatch( with pytest.raises(ConfigError): await use_case.execute(name="test-agent", yaml_content=mismatched_yaml) + # ------------------------------------------------------------------ + # New: description update + dependent agents invalidation on update. + # ------------------------------------------------------------------ + + async def test_updates_description_in_metadata( + self, use_case, mock_agent_config_repository, existing_metadata + ): + """Should update the metadata description when the YAML provides one.""" + # Arrange + mock_agent_config_repository.get.return_value = existing_metadata + yaml_with_description = ( + "name: test-agent\n" + "model: claude-sonnet-4-5-20250929\n" + 'system_prompt: "You are a test agent."\n' + 'description: "Updated description"\n' + ) + + # Act + await use_case.execute(name="test-agent", yaml_content=yaml_with_description) + + # Assert + mock_agent_config_repository.save.assert_awaited_once() + saved_metadata = mock_agent_config_repository.save.await_args.args[0] + assert saved_metadata.description == "Updated description" + + async def test_invalidates_dependent_agents_referencing_updated_agent( + self, use_case, mock_agent_config_repository, mock_agent_config_store, mock_registry, yaml_loader + ): + """Should invalidate the updated agent AND dependents that reference it via agent_ref.""" + # Arrange + # Agent "X" is being updated; agents "A" references X via subagent agent_ref; "B" does not. + agent_a_yaml = ( + "name: A\n" + "model: claude-sonnet-4-5-20250929\n" + "subagents:\n" + " - name: sub\n" + " description: d\n" + " agent_ref: X\n" + ) + agent_b_yaml = ( + "name: B\n" + "model: claude-sonnet-4-5-20250929\n" + ) + agent_x_yaml = ( + "name: X\n" + "model: claude-sonnet-4-5-20250929\n" + 'system_prompt: "You are X."\n' + ) + + mock_agent_config_repository.get.return_value = AgentConfigMetadata( + name="X", + model="claude-sonnet-4-5-20250929", + minio_path="agent-configs/X.yaml", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + async def list_all_side_effect(): + return ["A", "B"] + + async def store_get_side_effect(name: str) -> str: + return { + "A": agent_a_yaml, + "B": agent_b_yaml, + "X": agent_x_yaml, + }[name] + + mock_agent_config_store.list_all.side_effect = list_all_side_effect + mock_agent_config_store.get.side_effect = store_get_side_effect + + # Act + await use_case.execute(name="X", yaml_content=agent_x_yaml) + + # Assert + invalidated = [call.args[0] for call in mock_registry.invalidate.await_args_list] + assert "X" in invalidated + assert "A" in invalidated + assert "B" not in invalidated + class TestDeleteAgentConfigUseCase: """Tests for DeleteAgentConfigUseCase.""" @@ -314,6 +490,58 @@ async def test_raises_not_found_when_agent_absent(self, use_case, mock_agent_con with pytest.raises(AgentNotFoundError): await use_case.execute(name="nonexistent") + # ------------------------------------------------------------------ + # New: dependent agents invalidation on delete. + # ------------------------------------------------------------------ + + async def test_invalidates_dependent_agents_referencing_deleted_agent( + self, use_case, mock_agent_config_repository, mock_agent_config_store, mock_registry, yaml_loader + ): + """Should invalidate the deleted agent AND dependents that reference it via agent_ref.""" + # Arrange + # Agent "X" is being deleted; agent "A" references X via subagent agent_ref; "B" does not. + agent_a_yaml = ( + "name: A\n" + "model: claude-sonnet-4-5-20250929\n" + "subagents:\n" + " - name: sub\n" + " description: d\n" + " agent_ref: X\n" + ) + agent_b_yaml = ( + "name: B\n" + "model: claude-sonnet-4-5-20250929\n" + ) + + mock_agent_config_repository.get.return_value = AgentConfigMetadata( + name="X", + model="claude-sonnet-4-5-20250929", + minio_path="agent-configs/X.yaml", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + async def list_all_side_effect(): + return ["A", "B"] + + async def store_get_side_effect(name: str) -> str: + return { + "A": agent_a_yaml, + "B": agent_b_yaml, + }[name] + + mock_agent_config_store.list_all.side_effect = list_all_side_effect + mock_agent_config_store.get.side_effect = store_get_side_effect + + # Act + await use_case.execute(name="X") + + # Assert + invalidated = [call.args[0] for call in mock_registry.invalidate.await_args_list] + assert "X" in invalidated + assert "A" in invalidated + assert "B" not in invalidated + class TestGetAgentConfigUseCase: """Tests for GetAgentConfigUseCase.""" diff --git a/tests/unit/test_factory.py b/tests/unit/test_factory.py index 349dd70..1088f33 100644 --- a/tests/unit/test_factory.py +++ b/tests/unit/test_factory.py @@ -4,12 +4,14 @@ Tests exercise the public ``create_agent_from_config`` API only. """ -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from pydantic import BaseModel from src.domain.entities.agent_config import AgentConfig +from src.domain.entities.mcp_server_config import McpServerConfig, McpTransportType +from src.domain.errors.config import ConfigError from src.infrastructure.deepagent.factory import create_agent_from_config WEATHER_SCHEMA = { @@ -587,3 +589,144 @@ async def test_no_memory_means_no_memory_kwarg(self, mock_create): # Assert kwargs = mock_create.call_args.kwargs assert "memory" not in kwargs + + +class TestSubagentAgentRef: + """Tests for subagent reference resolution via config_resolver (one level only).""" + + def _http_mcp(self, name: str) -> McpServerConfig: + return McpServerConfig(name=name, transport=McpTransportType.HTTP, url=f"https://{name}.example") + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_subagent_with_agent_ref_resolves_referenced_config(self, mock_create): + """Should resolve the referenced agent's model and system_prompt into the subagent spec.""" + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig( + name="parent", + subagents=[ + {"name": "ref-alias", "description": "d", "agent_ref": "researcher"}, + ], + ) + referenced = AgentConfig( + name="researcher", + model="m-ref", + system_prompt="ref prompt", + ) + resolver = AsyncMock(return_value=referenced) + + # Act + await create_agent_from_config(config, config_resolver=resolver) + + # Assert + kwargs = mock_create.call_args.kwargs + subagents = kwargs["subagents"] + assert subagents[0]["model"] == "m-ref" + # No instructions, no Phoenix → falls back to ref.system_prompt + assert subagents[0]["system_prompt"] == "ref prompt" + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_subagent_agent_ref_explicit_override_wins(self, mock_create): + """Explicit values on the SubAgentConfig should override the referenced agent's.""" + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig( + name="parent", + subagents=[ + { + "name": "ref-alias", + "description": "d", + "agent_ref": "researcher", + "model": "my-model", + "instructions": "explicit instructions", + }, + ], + ) + referenced = AgentConfig( + name="researcher", + model="m-ref", + system_prompt="ref prompt", + ) + resolver = AsyncMock(return_value=referenced) + + # Act + await create_agent_from_config(config, config_resolver=resolver) + + # Assert + kwargs = mock_create.call_args.kwargs + subagents = kwargs["subagents"] + assert subagents[0]["model"] == "my-model" + # instructions set → wins over ref.system_prompt + assert subagents[0]["system_prompt"] == "explicit instructions" + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_subagent_agent_ref_without_resolver_raises_config_error(self, mock_create): + """Should raise ConfigError when agent_ref is set but no config_resolver provided.""" + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig( + name="parent", + subagents=[ + {"name": "ref-alias", "description": "d", "agent_ref": "researcher"}, + ], + ) + + # Act & Assert + with pytest.raises(ConfigError): + await create_agent_from_config(config, config_resolver=None) + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_subagent_agent_ref_falls_back_to_referenced_response_format(self, mock_create): + """When sa.response_format is None, the referenced agent's response_format is used.""" + # Arrange + schema = {"type": "object", "properties": {"x": {"type": "number"}}, "required": ["x"]} + mock_create.return_value = MagicMock() + config = AgentConfig( + name="parent", + subagents=[ + {"name": "ref-alias", "description": "d", "agent_ref": "researcher"}, + ], + ) + referenced = AgentConfig( + name="researcher", + model="m-ref", + response_format=schema, + ) + resolver = AsyncMock(return_value=referenced) + + # Act + await create_agent_from_config(config, config_resolver=resolver) + + # Assert + kwargs = mock_create.call_args.kwargs + subagents = kwargs["subagents"] + assert subagents[0]["response_format"] == schema + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_subagent_agent_ref_ignores_referenced_subagents(self, mock_create): + """Referenced agent's own subagents must be ignored (one level only).""" + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig( + name="parent", + subagents=[ + {"name": "ref-alias", "description": "d", "agent_ref": "researcher"}, + ], + ) + referenced = AgentConfig( + name="researcher", + model="m-ref", + system_prompt="ref prompt", + subagents=[{"name": "nested", "description": "should be ignored"}], + ) + resolver = AsyncMock(return_value=referenced) + + # Act + await create_agent_from_config(config, config_resolver=resolver) + + # Assert — create_deep_agent was called successfully and the resolved + # subagent spec must not propagate the referenced agent's subagents. + assert mock_create.called + kwargs = mock_create.call_args.kwargs + subagents = kwargs["subagents"] + assert "subagents" not in subagents[0] or not subagents[0]["subagents"] diff --git a/tests/unit/test_postgres_repository.py b/tests/unit/test_postgres_repository.py index 2de1314..f586f6f 100644 --- a/tests/unit/test_postgres_repository.py +++ b/tests/unit/test_postgres_repository.py @@ -6,10 +6,11 @@ from src.domain.entities.agent_config_metadata import AgentConfigMetadata from src.domain.errors.agent import AgentNotFoundError +from src.infrastructure.database.models.agent_config import AgentConfigModel from src.infrastructure.postgres_repository.adapter import PostgresAgentConfigRepository -def _metadata(name: str = "test-agent") -> AgentConfigMetadata: +def _metadata(name: str = "test-agent", description: str | None = None) -> AgentConfigMetadata: now = datetime.now(UTC) return AgentConfigMetadata( name=name, @@ -17,6 +18,7 @@ def _metadata(name: str = "test-agent") -> AgentConfigMetadata: minio_path=f"agent-configs/{name}.yaml", created_at=now, updated_at=now, + description=description, ) @@ -127,3 +129,48 @@ async def test_save_upserts_existing_row(self, repository): # Assert assert result.model == "gpt-4o" + + # ------------------------------------------------------------------ + # New: description column mapping + persistence. + # ------------------------------------------------------------------ + + async def test_maps_description_from_model_to_metadata(self, repository, db_session): + """Should map the ORM description column into AgentConfigMetadata.""" + # Arrange + from sqlalchemy import insert + + now = datetime.now(UTC) + await db_session.execute( + insert(AgentConfigModel).values( + name="desc-agent", + model="claude-sonnet-4-5-20250929", + minio_path="agent-configs/desc-agent.yaml", + created_at=now, + updated_at=now, + description="A described agent", + ) + ) + await db_session.commit() + + # Act + result = await repository.get("desc-agent") + + # Assert + assert result.description == "A described agent" + + async def test_save_persists_description(self, repository, db_session): + """Should persist the metadata description into the ORM description column.""" + # Arrange + from sqlalchemy import select + + metadata = _metadata("desc-agent", description="Persisted description") + + # Act + await repository.save(metadata) + + # Assert — read the raw ORM row to confirm the column was written + result = await db_session.execute( + select(AgentConfigModel).where(AgentConfigModel.name == "desc-agent") + ) + model = result.scalar_one() + assert model.description == "Persisted description"