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
19 changes: 19 additions & 0 deletions src/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@
LEGACY_AGENT_TOOL_NAME,
ONE_SHOT_BUILTIN_AGENT_TYPES,
)
from .filter_agents_by_mcp import (
filter_agents_by_mcp_requirements,
has_required_mcp_servers,
)
from .load_agents_dir import (
clear_agent_definitions_cache,
get_active_agents_from_list,
get_agent_definitions_with_overrides,
)
from .load_plugin_agents import load_plugin_agents
from .parse_agent_markdown import parse_agent_from_markdown
from .prompt import (
format_agent_line,
get_agent_prompt,
Expand Down Expand Up @@ -97,4 +108,12 @@
# Subagent context
"SubagentContextOverrides",
"create_subagent_context",
# Custom-agent discovery
"clear_agent_definitions_cache",
"filter_agents_by_mcp_requirements",
"get_active_agents_from_list",
"get_agent_definitions_with_overrides",
"has_required_mcp_servers",
"load_plugin_agents",
"parse_agent_from_markdown",
]
2 changes: 1 addition & 1 deletion src/agent/agent_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from ..permissions.types import PermissionMode


AgentSource = Literal["built-in", "user", "plugin", "dynamic"]
AgentSource = Literal["built-in", "user", "project", "managed", "plugin", "dynamic"]


@dataclass
Expand Down
49 changes: 49 additions & 0 deletions src/agent/filter_agents_by_mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Filter agents by their declared ``required_mcp_servers``.

Port of ``hasRequiredMcpServers`` / ``filterAgentsByMcpRequirements`` in
typescript/src/tools/AgentTool/loadAgentsDir.ts:228-254.

Built-in agents are never dropped — they're trusted regardless of MCP
availability.
"""
from __future__ import annotations

from collections.abc import Iterable

from src.agent.agent_definitions import AgentDefinition, is_built_in_agent


def has_required_mcp_servers(
agent: AgentDefinition,
available_servers: Iterable[str],
) -> bool:
"""Return True iff every required pattern matches an available server.

Matching is case-insensitive substring (same as the TS reference): the
pattern ``slack`` matches the server name ``MySlackServer``. Empty
requirements pass through.
"""
if not agent.required_mcp_servers:
return True
available_lower = [s.lower() for s in available_servers]
return all(
any(pattern.lower() in server for server in available_lower)
for pattern in agent.required_mcp_servers
)


def filter_agents_by_mcp_requirements(
agents: Iterable[AgentDefinition],
available_servers: Iterable[str],
) -> list[AgentDefinition]:
"""Drop agents whose required MCP servers aren't available.

Built-ins are exempt: they're not allowed to declare requirements and
must always be reachable.
"""
available_list = list(available_servers)
return [
agent
for agent in agents
if is_built_in_agent(agent) or has_required_mcp_servers(agent, available_list)
]
157 changes: 157 additions & 0 deletions src/agent/load_agents_dir.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Discover and merge custom agent definitions from disk + plugins.

Port of ``getAgentDefinitionsWithOverrides`` in
typescript/src/tools/AgentTool/loadAgentsDir.ts. Combines built-in agents
(``src/agent/agent_definitions.py:get_built_in_agents``), plugin agents
(via ``load_plugin_agents``), and on-disk custom agents from managed /
user / project directories (via ``load_markdown_files_for_subdir``).

Last-wins merge order on duplicate ``agent_type``:
[built-in, plugin, user, project, managed]

A module-level cache keyed on cwd avoids re-walking the filesystem on
every prompt build. Call ``clear_agent_definitions_cache()`` after a
known on-disk change (e.g., the user edits ``~/.claude/agents/foo.md``)
to force a refresh.
"""
from __future__ import annotations

import logging
import os
from typing import Iterable

from src.agent.agent_definitions import AgentDefinition, get_built_in_agents
from src.agent.parse_agent_markdown import parse_agent_from_markdown
from src.utils.markdown_config_loader import (
SOURCE_MANAGED,
SOURCE_PROJECT,
SOURCE_USER,
load_markdown_files_for_subdir,
)

logger = logging.getLogger(__name__)


# Each disk source maps to the matching ``AgentSource`` literal so
# downstream consumers can distinguish a managed-policy agent from a
# user one (e.g., to enforce "managed cannot be overridden by user").
_SOURCE_TO_AGENT_SOURCE: dict[str, str] = {
SOURCE_MANAGED: "managed",
SOURCE_USER: "user",
SOURCE_PROJECT: "project",
}

# Priority order for last-wins merge — earlier entries are overridden by
# later ones if they share an agent_type.
_MERGE_ORDER: tuple[str, ...] = (
"built-in",
"plugin",
SOURCE_USER,
SOURCE_PROJECT,
SOURCE_MANAGED,
)


# Cache is keyed on ``os.path.realpath(cwd)`` so symlinked / trailing-slash
# variants of the same project collapse into a single entry. The cache is
# session-bound; SDK callers that hop between unrelated projects can grow
# it unboundedly — acceptable for now since per-cwd discovery is cheap.
_agent_dir_cache: dict[str, list[AgentDefinition]] = {}


def _cache_key(cwd: str) -> str:
try:
return os.path.realpath(cwd)
except (OSError, ValueError):
return cwd


def clear_agent_definitions_cache() -> None:
"""Drop the discovery cache. Call after on-disk agent changes."""
_agent_dir_cache.clear()


def get_active_agents_from_list(
agents: Iterable[AgentDefinition],
) -> list[AgentDefinition]:
"""Last-wins dedup by ``agent_type`` while preserving input order.

Mirrors ``getActiveAgentsFromList`` from loadAgentsDir.ts:192-220.
Callers are responsible for arranging input order so the desired
override priority is honoured (lowest priority first, highest last).
"""
by_type: dict[str, AgentDefinition] = {}
order: list[str] = []
for agent in agents:
if agent.agent_type not in by_type:
order.append(agent.agent_type)
by_type[agent.agent_type] = agent
return [by_type[t] for t in order]


def _load_custom_agents(cwd: str) -> dict[str, list[AgentDefinition]]:
"""Group disk-discovered agents by their disk source label."""
grouped: dict[str, list[AgentDefinition]] = {
SOURCE_USER: [],
SOURCE_PROJECT: [],
SOURCE_MANAGED: [],
}
files = load_markdown_files_for_subdir("agents", cwd)
for md in files:
agent_source = _SOURCE_TO_AGENT_SOURCE.get(md.source, "user")
agent = parse_agent_from_markdown(
file_path=md.file_path,
frontmatter=md.frontmatter,
body=md.body,
source=agent_source, # type: ignore[arg-type]
base_dir=md.base_dir,
)
if agent is None:
continue
grouped[md.source].append(agent)
return grouped


def get_agent_definitions_with_overrides(cwd: str) -> list[AgentDefinition]:
"""Return the merged list of agents visible from ``cwd``.

Cache-keyed on ``cwd``. Built-ins are always included; the user can
override a built-in by defining an agent with the same ``agent_type``.
On any unexpected loader error the built-ins are returned alone — a
broken custom agent file should never disable the model's ability to
spawn the built-in agents.
"""
key = _cache_key(cwd)
cached = _agent_dir_cache.get(key)
if cached is not None:
return list(cached)

try:
builtins = list(get_built_in_agents())
try:
from src.agent.load_plugin_agents import load_plugin_agents
from src.plugins import get_loaded_plugins
plugin_agents = load_plugin_agents(get_loaded_plugins())
except Exception:
logger.exception("plugin agent loading failed; continuing without plugin agents")
plugin_agents = []

custom = _load_custom_agents(cwd)

sources_in_order: dict[str, list[AgentDefinition]] = {
"built-in": builtins,
"plugin": plugin_agents,
SOURCE_USER: custom[SOURCE_USER],
SOURCE_PROJECT: custom[SOURCE_PROJECT],
SOURCE_MANAGED: custom[SOURCE_MANAGED],
}
flat: list[AgentDefinition] = []
for source_key in _MERGE_ORDER:
flat.extend(sources_in_order.get(source_key, []))

active = get_active_agents_from_list(flat)
_agent_dir_cache[key] = active
return list(active)
except Exception:
logger.exception("agent discovery failed; falling back to built-ins")
return list(get_built_in_agents())
109 changes: 109 additions & 0 deletions src/agent/load_plugin_agents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Load agent definitions exposed by enabled plugins.

Mirrors ``loadPluginAgents`` in
typescript/src/utils/plugins/loadPluginAgents.ts. For each enabled
plugin with a non-empty ``agents_paths``, walks the directory
recursively for ``*.md`` files, parses each via ``parse_agent_from_markdown``,
and namespaces the resulting ``agent_type`` as
``"<plugin-name>:<sub:dirs>:<base>"`` so nested folders cannot collide.

Plugin agents intentionally drop ``permission_mode``, ``hooks``, and
``mcp_servers`` from the parsed definition — those grant capabilities
beyond install-time trust and must come from user-controlled settings,
not third-party plugin manifests.
"""
from __future__ import annotations

import logging
from dataclasses import replace
from pathlib import Path

from src.agent.agent_definitions import AgentDefinition
from src.agent.parse_agent_markdown import parse_agent_from_markdown
from src.plugins.types import LoadedPlugin
from src.skills.frontmatter import parse_frontmatter

logger = logging.getLogger(__name__)


def _scan_md_files(directory: str) -> list[tuple[str, str]]:
"""Recursively list ``*.md`` files under ``directory``.

Returns ``(absolute_file_path, relative_namespace)`` pairs where
``relative_namespace`` is the parent-dir path relative to
``directory``, with separators turned into ``:`` (so a file at
``<dir>/foo/bar.md`` yields namespace ``"foo"``). Files directly
under ``directory`` yield ``""``.
"""
base = Path(directory)
if not base.is_dir():
return []
out: list[tuple[str, str]] = []
try:
for path in base.rglob("*.md"):
if not path.is_file():
continue
rel = path.parent.relative_to(base)
namespace = ":".join(rel.parts) if rel.parts else ""
out.append((str(path), namespace))
except (OSError, PermissionError):
return []
return sorted(out)


def _build_namespaced_agent_type(
plugin_name: str, namespace: str, base_name: str,
) -> str:
parts = [plugin_name]
if namespace:
parts.append(namespace)
parts.append(base_name)
return ":".join(parts)


def load_plugin_agents(plugins: list[LoadedPlugin]) -> list[AgentDefinition]:
"""Return all agent definitions discovered across the given plugins.

Agent types are namespaced as ``<plugin>:<sub:dirs>:<base>`` to
mirror the TS ``walkPluginMarkdown`` convention — without the
``<sub:dirs>`` segment, plugins shipping multiple agents named
``review.md`` in different folders would silently collide.
"""
agents: list[AgentDefinition] = []
for plugin in plugins:
if not plugin.enabled or not plugin.agents_paths:
continue
for agents_dir in plugin.agents_paths:
for file_path, namespace in _scan_md_files(agents_dir):
try:
content = Path(file_path).read_text(encoding="utf-8")
except (OSError, PermissionError, UnicodeDecodeError) as exc:
logger.debug(
"plugin %s: failed to read %s: %s",
plugin.name, file_path, exc,
)
continue
parsed = parse_frontmatter(content)
agent = parse_agent_from_markdown(
file_path=file_path,
frontmatter=parsed.frontmatter,
body=parsed.body,
source="plugin",
base_dir=plugin.path,
)
if agent is None:
continue
namespaced = replace(
agent,
agent_type=_build_namespaced_agent_type(
plugin.name, namespace, agent.agent_type,
),
source="plugin",
# Strip elevated capabilities: plugins cannot grant
# permission overrides, hooks, or MCP servers.
permission_mode=None,
hooks=None,
mcp_servers=None,
)
agents.append(namespaced)
return agents
Loading