Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
35 changes: 35 additions & 0 deletions src/alembic/versions/010_add_description_to_agent_configs.py
Original file line number Diff line number Diff line change
@@ -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;
"""
)
112 changes: 112 additions & 0 deletions src/application/use_cases/_subagent_ref_utils.py
Original file line number Diff line number Diff line change
@@ -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),
)
4 changes: 4 additions & 0 deletions src/application/use_cases/create_agent_config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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))

Expand All @@ -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,
)
Expand Down
2 changes: 2 additions & 0 deletions src/application/use_cases/delete_agent_config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
10 changes: 9 additions & 1 deletion src/application/use_cases/update_agent_config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions src/domain/entities/agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/domain/entities/agent_config_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ class AgentConfigMetadata(BaseModel):
minio_path: str
created_at: datetime
updated_at: datetime
description: str | None = None
1 change: 1 addition & 0 deletions src/infrastructure/database/models/agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading