From 1e21857853e334e01a6be591ae0f5092c765f672 Mon Sep 17 00:00:00 2001 From: Alex McLeod Date: Sat, 4 Apr 2026 19:31:48 +0100 Subject: [PATCH 1/3] feat: load custom subagents from .claude/agents/*.md into SDK sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude Agent SDK supports custom subagent types via the `agents` parameter on ClaudeAgentOptions, but the runner never populated it. This meant sessions could only use the 4 built-in agent types (general-purpose, Explore, Plan, statusline-setup) — custom agents defined as .md files in .claude/agents/ were invisible to the SDK. Add a new agents.py utility that scans .claude/agents/*.md, parses frontmatter (name, description, tools, model, skills) and the markdown body (prompt), and constructs AgentDefinition objects. These are loaded during _setup_platform() and passed through the options dict in _ensure_adapter(), where build_options() already merges them into ClaudeAgentOptions. Co-Authored-By: Claude Opus 4.6 --- .../ambient_runner/bridges/claude/agents.py | 126 ++++++++ .../ambient_runner/bridges/claude/bridge.py | 9 + .../tests/test_custom_agents.py | 287 ++++++++++++++++++ 3 files changed, 422 insertions(+) create mode 100644 components/runners/ambient-runner/ambient_runner/bridges/claude/agents.py create mode 100644 components/runners/ambient-runner/tests/test_custom_agents.py diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/agents.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/agents.py new file mode 100644 index 000000000..c4eba428f --- /dev/null +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/agents.py @@ -0,0 +1,126 @@ +"""Load custom agent definitions from .claude/agents/*.md files. + +Scans markdown files with YAML-style frontmatter and converts them to +``AgentDefinition`` objects that the Claude Agent SDK can dispatch as +custom subagent types. +""" + +import logging +from pathlib import Path +from typing import Any + +from claude_agent_sdk import AgentDefinition + +logger = logging.getLogger(__name__) + + +def _parse_agent_file(file_path: Path) -> tuple[dict[str, str], str]: + """Parse frontmatter key-value pairs and body from a markdown file. + + Uses the same manual ``key: value`` parsing approach as + ``ambient_runner.endpoints.content._parse_frontmatter`` to avoid + adding a ``pyyaml`` dependency. + + Returns: + A tuple of (frontmatter dict, body text after closing ``---``). + """ + try: + content = file_path.read_text(encoding="utf-8") + except OSError: + return {}, "" + + if not content.startswith("---\n"): + return {}, content + + end_idx = content.find("\n---", 4) + if end_idx == -1: + return {}, content + + frontmatter_raw = content[4:end_idx] + body = content[end_idx + 4 :].strip() # skip past "\n---" + + metadata: dict[str, str] = {} + for line in frontmatter_raw.split("\n"): + if not line.strip(): + continue + parts = line.split(":", 1) + if len(parts) == 2: + key = parts[0].strip() + value = parts[1].strip().strip("\"'") + metadata[key] = value + + return metadata, body + + +def _parse_string_list(value: Any) -> list[str] | None: + """Parse a comma-separated string into a list of stripped strings. + + Returns ``None`` when *value* is falsy so that ``AgentDefinition`` + falls back to its default (inherit parent tools). + """ + if not value: + return None + if isinstance(value, list): + return [str(v).strip() for v in value if str(v).strip()] + if isinstance(value, str): + items = [v.strip() for v in value.split(",") if v.strip()] + return items if items else None + return None + + +def load_agents_from_directory(agents_dir: str) -> dict[str, AgentDefinition]: + """Load ``AgentDefinition`` objects from ``.claude/agents/*.md`` files. + + Each markdown file is expected to have YAML-style frontmatter + (``---`` delimited) followed by a body that becomes the agent's + prompt. Recognised frontmatter keys: + + * ``name`` – agent name (falls back to the filename stem) + * ``description`` – when to use this agent (falls back to + ``"Agent: {name}"``) + * ``tools`` – comma-separated list of tool names + * ``model`` – model alias or full model ID + * ``skills`` – comma-separated list of skill names + + Args: + agents_dir: Absolute path to the ``.claude/agents`` directory. + + Returns: + Mapping of agent name → ``AgentDefinition``. Empty dict when + the directory does not exist or contains no valid agents. + """ + agents: dict[str, AgentDefinition] = {} + agents_path = Path(agents_dir) + + if not agents_path.is_dir(): + logger.debug("No agents directory at %s", agents_dir) + return agents + + for md_file in sorted(agents_path.glob("*.md")): + try: + metadata, body = _parse_agent_file(md_file) + + name = metadata.get("name", md_file.stem) + description = metadata.get("description", f"Agent: {name}") + tools = _parse_string_list(metadata.get("tools")) + skills = _parse_string_list(metadata.get("skills")) + model = metadata.get("model") + + agents[name] = AgentDefinition( + description=description, + prompt=body, + tools=tools, + model=model, + skills=skills, + ) + logger.info("Loaded custom agent '%s' from %s", name, md_file.name) + + except Exception: + logger.warning( + "Failed to load agent from %s", md_file.name, exc_info=True + ) + + logger.info( + "Loaded %d custom agent(s) from %s", len(agents), agents_dir + ) + return agents diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py index 967ebea6d..436fa33ec 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/bridge.py @@ -538,6 +538,12 @@ async def _setup_platform(self) -> None: if add_dirs: os.environ["CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD"] = "1" + # Custom agents from .claude/agents/*.md + from ambient_runner.bridges.claude.agents import load_agents_from_directory + + agents_dir = os.path.join(cwd_path, ".claude", "agents") + custom_agents = load_agents_from_directory(agents_dir) + # Observability (shared helper, before MCP so rubric tool can access it) self._obs = await setup_bridge_observability(self._context, configured_model) @@ -561,6 +567,7 @@ async def _setup_platform(self) -> None: self._configured_model = configured_model self._cwd_path = cwd_path self._add_dirs = add_dirs + self._custom_agents = custom_agents self._mcp_servers = mcp_servers self._allowed_tools = allowed_tools self._system_prompt = system_prompt @@ -615,6 +622,8 @@ def _stderr_handler(line: str) -> None: options["add_dirs"] = self._add_dirs if self._configured_model: options["model"] = self._configured_model + if self._custom_agents: + options["agents"] = self._custom_agents adapter = ClaudeAgentAdapter( name="claude_code_runner", diff --git a/components/runners/ambient-runner/tests/test_custom_agents.py b/components/runners/ambient-runner/tests/test_custom_agents.py new file mode 100644 index 000000000..26ec04e8f --- /dev/null +++ b/components/runners/ambient-runner/tests/test_custom_agents.py @@ -0,0 +1,287 @@ +"""Unit tests for custom agent loading from .claude/agents/*.md files.""" + +import textwrap +from pathlib import Path +from unittest.mock import patch + +import pytest + +from ambient_runner.bridges.claude.agents import ( + _parse_agent_file, + _parse_string_list, + load_agents_from_directory, +) + + +# ------------------------------------------------------------------ +# _parse_agent_file +# ------------------------------------------------------------------ + + +class TestParseAgentFile: + """Verify frontmatter + body extraction from markdown files.""" + + def test_valid_frontmatter_and_body(self, tmp_path: Path): + md = tmp_path / "agent.md" + md.write_text( + textwrap.dedent("""\ + --- + name: test-agent + description: A test agent + tools: Read, Write, Bash + model: sonnet + --- + + You are a helpful test agent. + """) + ) + + metadata, body = _parse_agent_file(md) + + assert metadata["name"] == "test-agent" + assert metadata["description"] == "A test agent" + assert metadata["tools"] == "Read, Write, Bash" + assert metadata["model"] == "sonnet" + assert body == "You are a helpful test agent." + + def test_no_frontmatter(self, tmp_path: Path): + md = tmp_path / "plain.md" + md.write_text("Just a plain markdown file.\n") + + metadata, body = _parse_agent_file(md) + + assert metadata == {} + assert body == "Just a plain markdown file." + + def test_empty_file(self, tmp_path: Path): + md = tmp_path / "empty.md" + md.write_text("") + + metadata, body = _parse_agent_file(md) + + assert metadata == {} + assert body == "" + + def test_frontmatter_only_no_body(self, tmp_path: Path): + md = tmp_path / "no-body.md" + md.write_text("---\nname: solo\n---\n") + + metadata, body = _parse_agent_file(md) + + assert metadata["name"] == "solo" + assert body == "" + + def test_missing_file(self, tmp_path: Path): + md = tmp_path / "nonexistent.md" + + metadata, body = _parse_agent_file(md) + + assert metadata == {} + assert body == "" + + def test_quoted_values_stripped(self, tmp_path: Path): + md = tmp_path / "quoted.md" + md.write_text('---\nname: "my-agent"\ndescription: \'does things\'\n---\nBody.\n') + + metadata, body = _parse_agent_file(md) + + assert metadata["name"] == "my-agent" + assert metadata["description"] == "does things" + + def test_multiline_body(self, tmp_path: Path): + md = tmp_path / "multi.md" + md.write_text("---\nname: multi\n---\nLine one.\n\nLine two.\n") + + metadata, body = _parse_agent_file(md) + + assert metadata["name"] == "multi" + assert "Line one." in body + assert "Line two." in body + + +# ------------------------------------------------------------------ +# _parse_string_list +# ------------------------------------------------------------------ + + +class TestParseStringList: + """Verify comma-separated string → list conversion.""" + + def test_comma_separated(self): + assert _parse_string_list("Read, Write, Bash") == ["Read", "Write", "Bash"] + + def test_single_item(self): + assert _parse_string_list("Read") == ["Read"] + + def test_none_returns_none(self): + assert _parse_string_list(None) is None + + def test_empty_string_returns_none(self): + assert _parse_string_list("") is None + + def test_list_passthrough(self): + assert _parse_string_list(["Read", "Write"]) == ["Read", "Write"] + + def test_whitespace_trimmed(self): + assert _parse_string_list(" Read , Write ") == ["Read", "Write"] + + def test_empty_items_filtered(self): + assert _parse_string_list("Read,,Write,") == ["Read", "Write"] + + +# ------------------------------------------------------------------ +# load_agents_from_directory +# ------------------------------------------------------------------ + + +class TestLoadAgentsFromDirectory: + """Verify end-to-end agent loading from a directory of .md files.""" + + def _write_agent(self, agents_dir: Path, filename: str, content: str) -> None: + agents_dir.mkdir(parents=True, exist_ok=True) + (agents_dir / filename).write_text(textwrap.dedent(content)) + + @patch("ambient_runner.bridges.claude.agents.AgentDefinition") + def test_loads_multiple_agents(self, mock_agent_def, tmp_path: Path): + agents_dir = tmp_path / "agents" + self._write_agent( + agents_dir, + "researcher.md", + """\ + --- + name: researcher + description: Research agent for web lookups + tools: Read, WebSearch, WebFetch + model: sonnet + --- + + You are a research assistant. + """, + ) + self._write_agent( + agents_dir, + "writer.md", + """\ + --- + name: docs-writer + description: Technical documentation writer + tools: Read, Write, Edit + skills: vale-lint + --- + + You write documentation. + """, + ) + + result = load_agents_from_directory(str(agents_dir)) + + assert mock_agent_def.call_count == 2 + + # Verify researcher call + researcher_call = mock_agent_def.call_args_list[0] + assert researcher_call.kwargs["description"] == "Research agent for web lookups" + assert researcher_call.kwargs["prompt"] == "You are a research assistant." + assert researcher_call.kwargs["tools"] == ["Read", "WebSearch", "WebFetch"] + assert researcher_call.kwargs["model"] == "sonnet" + assert researcher_call.kwargs["skills"] is None + + # Verify writer call + writer_call = mock_agent_def.call_args_list[1] + assert writer_call.kwargs["description"] == "Technical documentation writer" + assert writer_call.kwargs["prompt"] == "You write documentation." + assert writer_call.kwargs["tools"] == ["Read", "Write", "Edit"] + assert writer_call.kwargs["skills"] == ["vale-lint"] + + assert "researcher" in result + assert "docs-writer" in result + + @patch("ambient_runner.bridges.claude.agents.AgentDefinition") + def test_fallback_name_from_filename(self, mock_agent_def, tmp_path: Path): + agents_dir = tmp_path / "agents" + self._write_agent( + agents_dir, + "my-agent.md", + """\ + --- + description: An agent without a name field + --- + + Do things. + """, + ) + + result = load_agents_from_directory(str(agents_dir)) + + assert "my-agent" in result + assert mock_agent_def.call_args.kwargs["description"] == "An agent without a name field" + + @patch("ambient_runner.bridges.claude.agents.AgentDefinition") + def test_fallback_description(self, mock_agent_def, tmp_path: Path): + agents_dir = tmp_path / "agents" + self._write_agent( + agents_dir, + "bare.md", + """\ + --- + name: bare-agent + --- + + Minimal agent. + """, + ) + + result = load_agents_from_directory(str(agents_dir)) + + assert mock_agent_def.call_args.kwargs["description"] == "Agent: bare-agent" + + def test_nonexistent_directory_returns_empty(self, tmp_path: Path): + result = load_agents_from_directory(str(tmp_path / "does-not-exist")) + assert result == {} + + def test_empty_directory_returns_empty(self, tmp_path: Path): + agents_dir = tmp_path / "agents" + agents_dir.mkdir() + + result = load_agents_from_directory(str(agents_dir)) + assert result == {} + + @patch("ambient_runner.bridges.claude.agents.AgentDefinition") + def test_ignores_non_md_files(self, mock_agent_def, tmp_path: Path): + agents_dir = tmp_path / "agents" + agents_dir.mkdir() + (agents_dir / "readme.txt").write_text("Not an agent.") + self._write_agent( + agents_dir, + "real.md", + """\ + --- + name: real + description: A real agent + --- + + Hello. + """, + ) + + result = load_agents_from_directory(str(agents_dir)) + + assert len(result) == 1 + assert "real" in result + + @patch("ambient_runner.bridges.claude.agents.AgentDefinition", side_effect=Exception("boom")) + def test_bad_file_logged_and_skipped(self, mock_agent_def, tmp_path: Path): + agents_dir = tmp_path / "agents" + self._write_agent( + agents_dir, + "bad.md", + """\ + --- + name: bad-agent + --- + + Will fail. + """, + ) + + result = load_agents_from_directory(str(agents_dir)) + assert result == {} From ee18cd152901b8e11c53988800692e27bc3e0475 Mon Sep 17 00:00:00 2001 From: Alex McLeod Date: Sat, 4 Apr 2026 19:33:52 +0100 Subject: [PATCH 2/3] style: apply ruff formatting to new agent loading files Co-Authored-By: Claude Opus 4.6 --- .../ambient_runner/bridges/claude/agents.py | 8 ++------ .../ambient-runner/tests/test_custom_agents.py | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/agents.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/agents.py index c4eba428f..b196a6641 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/agents.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/agents.py @@ -116,11 +116,7 @@ def load_agents_from_directory(agents_dir: str) -> dict[str, AgentDefinition]: logger.info("Loaded custom agent '%s' from %s", name, md_file.name) except Exception: - logger.warning( - "Failed to load agent from %s", md_file.name, exc_info=True - ) + logger.warning("Failed to load agent from %s", md_file.name, exc_info=True) - logger.info( - "Loaded %d custom agent(s) from %s", len(agents), agents_dir - ) + logger.info("Loaded %d custom agent(s) from %s", len(agents), agents_dir) return agents diff --git a/components/runners/ambient-runner/tests/test_custom_agents.py b/components/runners/ambient-runner/tests/test_custom_agents.py index 26ec04e8f..60eff2a4a 100644 --- a/components/runners/ambient-runner/tests/test_custom_agents.py +++ b/components/runners/ambient-runner/tests/test_custom_agents.py @@ -4,8 +4,6 @@ from pathlib import Path from unittest.mock import patch -import pytest - from ambient_runner.bridges.claude.agents import ( _parse_agent_file, _parse_string_list, @@ -81,7 +79,9 @@ def test_missing_file(self, tmp_path: Path): def test_quoted_values_stripped(self, tmp_path: Path): md = tmp_path / "quoted.md" - md.write_text('---\nname: "my-agent"\ndescription: \'does things\'\n---\nBody.\n') + md.write_text( + "---\nname: \"my-agent\"\ndescription: 'does things'\n---\nBody.\n" + ) metadata, body = _parse_agent_file(md) @@ -213,7 +213,10 @@ def test_fallback_name_from_filename(self, mock_agent_def, tmp_path: Path): result = load_agents_from_directory(str(agents_dir)) assert "my-agent" in result - assert mock_agent_def.call_args.kwargs["description"] == "An agent without a name field" + assert ( + mock_agent_def.call_args.kwargs["description"] + == "An agent without a name field" + ) @patch("ambient_runner.bridges.claude.agents.AgentDefinition") def test_fallback_description(self, mock_agent_def, tmp_path: Path): @@ -230,7 +233,7 @@ def test_fallback_description(self, mock_agent_def, tmp_path: Path): """, ) - result = load_agents_from_directory(str(agents_dir)) + load_agents_from_directory(str(agents_dir)) assert mock_agent_def.call_args.kwargs["description"] == "Agent: bare-agent" @@ -268,7 +271,10 @@ def test_ignores_non_md_files(self, mock_agent_def, tmp_path: Path): assert len(result) == 1 assert "real" in result - @patch("ambient_runner.bridges.claude.agents.AgentDefinition", side_effect=Exception("boom")) + @patch( + "ambient_runner.bridges.claude.agents.AgentDefinition", + side_effect=Exception("boom"), + ) def test_bad_file_logged_and_skipped(self, mock_agent_def, tmp_path: Path): agents_dir = tmp_path / "agents" self._write_agent( From 0ae47c2ba48db840ac84feb317b00f4553dbe691 Mon Sep 17 00:00:00 2001 From: Alex McLeod Date: Sat, 4 Apr 2026 22:07:02 +0100 Subject: [PATCH 3/3] fix: handle unreadable agent files and CRLF line endings Re-raise OSError from _parse_agent_file so unreadable files are skipped instead of silently creating agents with empty prompts. Normalise CRLF to LF before frontmatter parsing so Windows-origin files work correctly. Co-Authored-By: Claude Opus 4.6 --- .../ambient-runner/ambient_runner/bridges/claude/agents.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/runners/ambient-runner/ambient_runner/bridges/claude/agents.py b/components/runners/ambient-runner/ambient_runner/bridges/claude/agents.py index b196a6641..be58fdb32 100644 --- a/components/runners/ambient-runner/ambient_runner/bridges/claude/agents.py +++ b/components/runners/ambient-runner/ambient_runner/bridges/claude/agents.py @@ -27,7 +27,10 @@ def _parse_agent_file(file_path: Path) -> tuple[dict[str, str], str]: try: content = file_path.read_text(encoding="utf-8") except OSError: - return {}, "" + logger.warning("Cannot read agent file %s", file_path.name, exc_info=True) + raise + + content = content.replace("\r\n", "\n") if not content.startswith("---\n"): return {}, content