feat(code): fall back to folder name for subagents (#4504)

Allows subagents defined under `agents/{name}/AGENTS.md` to load when
the YAML frontmatter omits `name`, using the containing folder name as
the implicit name. Explicit invalid `name` values still fail validation,
and descriptions remain required.

## Spec divergence

The Agent Skills specification (`deepagents.middleware.skills`) requires
`name` in frontmatter and enforces that it matches the parent directory
name exactly — no fallback. This PR relaxes that rule for subagents
only: `name` is optional, and the folder name is used when it's absent.
Subagents are already uniquely identified by their folder, so requiring
a redundant `name` field adds friction without adding information. The
divergence is documented in the module and function docstrings in
`subagents.py`.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Mason Daugherty <github@mdrxy.com>
Co-authored-by: Mason Daugherty <mason@langchain.dev>
This commit is contained in:
John Kennedy
2026-07-05 20:36:42 -07:00
committed by GitHub
parent ba1979d64c
commit 9db3db5f31
2 changed files with 194 additions and 24 deletions
+61 -23
View File
@@ -8,7 +8,7 @@ Directory structure:
Example file (researcher/AGENTS.md):
---
name: researcher
name: researcher # optional; defaults to the folder name
description: Research topics on the web before writing content
model: anthropic:claude-haiku-4-5-20251001
---
@@ -18,6 +18,14 @@ Example file (researcher/AGENTS.md):
## Your Process
1. Search for relevant information
2. Summarize findings clearly
The `name` field is optional; when omitted it defaults to the folder name
(e.g. `researcher`). This diverges from the Agent Skills specification
(`deepagents.middleware.skills`), which requires `name` in frontmatter and
warns when it does not match the parent directory name. Subagents use the
folder name as an implicit fallback instead because subagent definitions are
already uniquely identified by their folder — requiring a redundant `name`
field adds friction without adding information.
"""
from __future__ import annotations
@@ -56,14 +64,26 @@ class SubagentMetadata(TypedDict):
"""Absolute path to the subagent definition file."""
def _parse_subagent_file(file_path: Path) -> SubagentMetadata | None:
def _parse_subagent_file(
file_path: Path, *, fallback_name: str | None = None
) -> SubagentMetadata | None:
"""Parse a subagent markdown file with YAML frontmatter.
The file must have YAML frontmatter (delimited by ---) containing at minimum
'name' and 'description' fields. The body of the file becomes the system_prompt.
a 'description' field. The body of the file becomes the system_prompt.
Unlike the Agent Skills spec, `name` is optional here — when omitted the
folder name passed via `fallback_name` is used instead. Skills require
`name` in frontmatter and warn when it doesn't match the directory name;
subagents relax that to a fallback so users don't repeat the folder name
redundantly.
Args:
file_path: Path to the markdown file.
fallback_name: Name to use when the frontmatter omits `name` entirely.
A present-but-empty, whitespace-only, or non-string `name` is
treated as invalid and rejected rather than falling back, so a typo
surfaces loudly instead of being silently masked by the folder name.
Returns:
SubagentMetadata if parsing succeeds, None otherwise.
@@ -79,7 +99,7 @@ def _parse_subagent_file(file_path: Path) -> SubagentMetadata | None:
if not match:
logger.warning(
"Skipping subagent %s: missing YAML frontmatter. The file must start "
"with a '---' delimited block containing 'name' and 'description'.",
"with a '---' delimited block containing at least 'description'.",
file_path,
)
return None
@@ -95,27 +115,36 @@ def _parse_subagent_file(file_path: Path) -> SubagentMetadata | None:
# Validate frontmatter structure and required fields
if not isinstance(frontmatter, dict):
logger.warning(
"Skipping subagent %s: frontmatter must be a mapping with 'name' and "
"'description' fields.",
"Skipping subagent %s: frontmatter must be a mapping with a "
"'description' field.",
file_path,
)
return None
name = frontmatter.get("name")
description = frontmatter.get("description")
name_value = frontmatter.get("name", fallback_name)
description_value = frontmatter.get("description")
model = frontmatter.get("model")
# Validate types: name and description must be non-empty strings
# model is optional but must be string if present
name_valid = isinstance(name, str) and name
description_valid = isinstance(description, str) and description
# Validate types: name and description must be non-empty strings (leading and
# trailing whitespace is stripped, so a whitespace-only value is rejected).
# model is optional but must be a string if present.
name = (
name_value.strip()
if isinstance(name_value, str) and name_value.strip()
else None
)
description = (
description_value.strip()
if isinstance(description_value, str) and description_value.strip()
else None
)
model_valid = model is None or isinstance(model, str)
if not (name_valid and description_valid and model_valid):
if name is None or description is None or not model_valid:
invalid_fields: list[str] = []
if not name_valid:
if name is None:
invalid_fields.append("name (non-empty string required)")
if not description_valid:
if description is None:
invalid_fields.append("description (non-empty string required)")
if not model_valid:
invalid_fields.append("model (string required when present)")
@@ -126,6 +155,15 @@ def _parse_subagent_file(file_path: Path) -> SubagentMetadata | None:
)
return None
if "name" not in frontmatter:
# Fallback engaged. Log it so a typo'd key (e.g. `nmae:`) that silently
# resolves to the folder name is at least diagnosable at debug level.
logger.debug(
"Subagent %s: 'name' omitted from frontmatter; using folder name %r.",
file_path,
name,
)
return {
"name": name,
"description": description,
@@ -186,19 +224,19 @@ def _load_subagents_from_dir(
)
continue
subagent = _parse_subagent_file(subagent_file)
subagent = _parse_subagent_file(subagent_file, fallback_name=entry.name)
if subagent:
subagent["source"] = source
# The folder name and the frontmatter `name` are independent, so two
# folders can declare the same `name` and silently collapse to one
# entry. Iteration order is filesystem-dependent, so warn rather than
# let a definition vanish without explanation.
# The folder name and a declared `name` can differ, so two folders can
# resolve to the same subagent name and silently collapse to one entry.
# Iteration order is filesystem-dependent, so warn rather than let a
# definition vanish without explanation.
existing = subagents.get(subagent["name"])
if existing is not None:
logger.warning(
"Subagent name collision in %s: %s and %s both declare "
"name=%r. Using %s; give each subagent a unique 'name' in "
"its frontmatter.",
"Subagent name collision in %s: %s and %s both resolve to "
"name=%r. Using %s; give each subagent a unique folder or "
"frontmatter 'name'.",
agents_dir,
existing["path"],
subagent["path"],
+133 -1
View File
@@ -99,7 +99,7 @@ Always structure your response with headings.
assert "1. Write clearly" in result["system_prompt"]
def test_parse_subagent_missing_name(self, tmp_path: Path) -> None:
"""Test that subagent without name is rejected."""
"""Test that subagent without name is rejected without a fallback."""
subagent_file = tmp_path / "invalid.md"
subagent_file.write_text("""---
description: Missing name field
@@ -110,6 +110,69 @@ Content
assert _parse_subagent_file(subagent_file) is None
def test_parse_subagent_uses_fallback_name(self, tmp_path: Path) -> None:
"""Test that fallback name is used when frontmatter omits name."""
subagent_file = tmp_path / "AGENTS.md"
subagent_file.write_text("""---
description: Missing name field
---
Content
""")
result = _parse_subagent_file(subagent_file, fallback_name="helper")
assert result is not None
assert result["name"] == "helper"
assert result["description"] == "Missing name field"
def test_parse_subagent_frontmatter_name_overrides_fallback(
self, tmp_path: Path
) -> None:
"""Test that an explicit frontmatter name wins over the fallback."""
subagent_file = tmp_path / "AGENTS.md"
subagent_file.write_text("""---
name: explicit
description: Has explicit name
---
Content
""")
result = _parse_subagent_file(subagent_file, fallback_name="folder")
assert result is not None
assert result["name"] == "explicit"
@pytest.mark.parametrize(
"name_line",
[
'name: ""', # present-but-empty string
'name: " "', # present-but-whitespace-only
"name:", # present-but-null
"name: 123", # present-but-non-string
],
)
def test_parse_subagent_invalid_name_not_rescued_by_fallback(
self, tmp_path: Path, name_line: str
) -> None:
"""Test that a present-but-invalid name is rejected despite a fallback.
The fallback only applies when `name` is omitted entirely; an explicit
empty/whitespace-only/null/non-string value must fail loudly rather than
silently resolving to the folder name.
"""
subagent_file = tmp_path / "AGENTS.md"
subagent_file.write_text(f"""---
{name_line}
description: Has invalid name
---
Content
""")
assert _parse_subagent_file(subagent_file, fallback_name="helper") is None
def test_parse_subagent_missing_description(self, tmp_path: Path) -> None:
"""Test that subagent without description is rejected."""
subagent_file = tmp_path / "invalid.md"
@@ -228,6 +291,27 @@ class TestLoadSubagentsFromDir:
assert "researcher" in result
assert result["researcher"]["source"] == "user"
def test_load_uses_folder_name_when_frontmatter_omits_name(
self, tmp_path: Path
) -> None:
"""Test loading a subagent whose frontmatter omits name."""
agents_dir = tmp_path / "agents"
folder = agents_dir / "helper"
folder.mkdir(parents=True)
(folder / "AGENTS.md").write_text("""---
description: Helpful assistant
---
Content
""")
result = _load_subagents_from_dir(agents_dir, "user")
assert len(result) == 1
assert "helper" in result
assert result["helper"]["name"] == "helper"
assert result["helper"]["description"] == "Helpful assistant"
def test_load_multiple_subagents(self, tmp_path: Path) -> None:
"""Test loading multiple subagents."""
agents_dir = tmp_path / "agents"
@@ -322,6 +406,26 @@ class TestListSubagents:
assert result[0]["name"] == "researcher"
assert result[0]["source"] == "user"
def test_list_uses_folder_name_when_frontmatter_omits_name(
self, tmp_path: Path
) -> None:
"""Test that the folder-name fallback surfaces through list_subagents."""
user_dir = tmp_path / "user_agents"
folder = user_dir / "researcher"
folder.mkdir(parents=True)
(folder / "AGENTS.md").write_text("""---
description: Research assistant
---
You are a research assistant.
""")
result = list_subagents(user_agents_dir=user_dir)
assert len(result) == 1
assert result[0]["name"] == "researcher"
assert result[0]["source"] == "user"
def test_list_project_only(self, tmp_path: Path) -> None:
"""Test listing from project directory only."""
project_dir = tmp_path / "project_agents"
@@ -579,6 +683,34 @@ class TestDiagnostics:
assert "name collision" in caplog.text
assert "researcher" in caplog.text
def test_warns_on_collision_between_fallback_and_frontmatter_name(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A folder-name fallback colliding with an explicit name is flagged."""
agents_dir = tmp_path / "agents"
# This folder omits `name`, so it resolves to its folder name "helper".
fallback_folder = agents_dir / "helper"
fallback_folder.mkdir(parents=True)
(fallback_folder / "AGENTS.md").write_text("""---
description: Resolves to folder name
---
Content
""")
# This folder declares name="helper" explicitly, colliding with the above.
explicit_folder = agents_dir / "other"
explicit_folder.mkdir(parents=True)
(explicit_folder / "AGENTS.md").write_text(
make_subagent_content("helper", "Declares name explicitly")
)
with caplog.at_level(logging.WARNING):
result = _load_subagents_from_dir(agents_dir, "project")
assert len(result) == 1
assert "name collision" in caplog.text
assert "helper" in caplog.text
def test_no_warning_for_valid_or_unrelated_entries(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None: