mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
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:
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user