Skip to content

Commit ab8f5f2

Browse files
VascoSch92enyst
andauthored
feat(delegate): File-based agent definitions with markdown + YAML frontmatter (#2183)
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
1 parent e25a1ef commit ab8f5f2

29 files changed

Lines changed: 1520 additions & 463 deletions

examples/01_standalone_sdk/25_agent_delegation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@
2020
get_logger,
2121
)
2222
from openhands.sdk.context import Skill
23+
from openhands.sdk.subagent import register_agent
2324
from openhands.sdk.tool import register_tool
2425
from openhands.tools.delegate import (
2526
DelegateTool,
2627
DelegationVisualizer,
27-
register_agent,
2828
)
2929
from openhands.tools.preset.default import get_default_tools
3030

examples/01_standalone_sdk/41_task_tool_set.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717

1818
from openhands.sdk import LLM, Agent, AgentContext, Conversation, Tool
1919
from openhands.sdk.context import Skill
20-
from openhands.tools.delegate import DelegationVisualizer, register_agent
20+
from openhands.sdk.subagent import register_agent
21+
from openhands.tools.delegate import DelegationVisualizer
2122
from openhands.tools.task import TaskToolSet
2223

2324

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Example: Defining a sub-agent inline with AgentDefinition.
2+
3+
Defines a grammar-checker sub-agent using AgentDefinition, registers it,
4+
and delegates work to it from an orchestrator agent. The orchestrator then
5+
asks the builtin default agent to judge the results.
6+
"""
7+
8+
import os
9+
from pathlib import Path
10+
11+
from openhands.sdk import (
12+
LLM,
13+
Agent,
14+
Conversation,
15+
Tool,
16+
agent_definition_to_factory,
17+
register_agent,
18+
)
19+
from openhands.sdk.subagent import AgentDefinition
20+
from openhands.sdk.tool import register_tool
21+
from openhands.tools.delegate import DelegateTool, DelegationVisualizer
22+
23+
24+
# 1. Define a sub-agent using AgentDefinition
25+
grammar_checker = AgentDefinition(
26+
name="grammar-checker",
27+
description="Checks documents for grammatical errors.",
28+
tools=["file_editor"],
29+
system_prompt="You are a grammar expert. Find and list grammatical errors.",
30+
)
31+
32+
# 2. Register it in the delegate registry
33+
register_agent(
34+
name=grammar_checker.name,
35+
factory_func=agent_definition_to_factory(grammar_checker),
36+
description=grammar_checker.description,
37+
)
38+
39+
# 3. Set up the orchestrator agent with the DelegateTool
40+
llm = LLM(
41+
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
42+
api_key=os.getenv("LLM_API_KEY"),
43+
base_url=os.getenv("LLM_BASE_URL"),
44+
usage_id="file-agents-demo",
45+
)
46+
47+
register_tool("DelegateTool", DelegateTool)
48+
main_agent = Agent(
49+
llm=llm,
50+
tools=[Tool(name="DelegateTool")],
51+
)
52+
conversation = Conversation(
53+
agent=main_agent,
54+
workspace=Path.cwd(),
55+
visualizer=DelegationVisualizer(name="Orchestrator"),
56+
)
57+
58+
# 4. Ask the orchestrator to delegate to our agent
59+
task = (
60+
"Please delegate to the grammar-checker agent and ask it to review "
61+
"the README.md file in search of grammatical errors.\n"
62+
"Then ask the default agent to judge the errors."
63+
)
64+
conversation.send_message(task)
65+
conversation.run()
66+
67+
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
68+
print(f"\nTotal cost: ${cost:.4f}")
69+
print(f"EXAMPLE_COST: {cost:.4f}")

openhands-sdk/openhands/sdk/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@
4747
create_mcp_tools,
4848
)
4949
from openhands.sdk.plugin import Plugin
50+
from openhands.sdk.subagent import (
51+
agent_definition_to_factory,
52+
load_agents_from_dir,
53+
load_project_agents,
54+
load_user_agents,
55+
register_agent,
56+
)
5057
from openhands.sdk.tool import (
5158
Action,
5259
Observation,
@@ -113,6 +120,11 @@
113120
"Workspace",
114121
"LocalWorkspace",
115122
"RemoteWorkspace",
123+
"register_agent",
124+
"load_project_agents",
125+
"load_user_agents",
126+
"load_agents_from_dir",
127+
"agent_definition_to_factory",
116128
"load_project_skills",
117129
"load_skills_from_dir",
118130
"load_user_skills",

openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@
4646
from openhands.sdk.security.confirmation_policy import (
4747
ConfirmationPolicyBase,
4848
)
49+
from openhands.sdk.subagent import (
50+
AgentDefinition,
51+
register_file_agents,
52+
register_plugin_agents,
53+
)
54+
from openhands.sdk.subagent.registry import register_builtins_agents
4955
from openhands.sdk.tool.schema import Action, Observation
5056
from openhands.sdk.utils.cipher import Cipher
5157
from openhands.sdk.workspace import LocalWorkspace
@@ -310,6 +316,7 @@ def _ensure_plugins_loaded(self) -> None:
310316
return
311317

312318
all_plugin_hooks: list[HookConfig] = []
319+
all_plugin_agents: list[AgentDefinition] = []
313320

314321
# Load plugins if specified
315322
if self._plugin_specs:
@@ -347,6 +354,10 @@ def _ensure_plugins_loaded(self) -> None:
347354
if plugin.hooks and not plugin.hooks.is_empty():
348355
all_plugin_hooks.append(plugin.hooks)
349356

357+
# Collect agent definitions
358+
if plugin.agents:
359+
all_plugin_agents.extend(plugin.agents)
360+
350361
# Update agent with merged content
351362
self.agent = self.agent.model_copy(
352363
update={
@@ -361,6 +372,10 @@ def _ensure_plugins_loaded(self) -> None:
361372

362373
logger.info(f"Loaded {len(self._plugin_specs)} plugin(s) via Conversation")
363374

375+
# Register file-based agents defined in plugins
376+
if all_plugin_agents:
377+
register_plugin_agents(all_plugin_agents)
378+
364379
# Combine explicit hook_config with plugin hooks
365380
# Explicit hooks run first (before plugin hooks)
366381
final_hook_config = self._pending_hook_config
@@ -387,21 +402,44 @@ def _ensure_plugins_loaded(self) -> None:
387402

388403
self._plugins_loaded = True
389404

405+
def _register_file_based_agents(self) -> None:
406+
"""Discover and register file-based agents into the agent registry.
407+
408+
Agents are loaded from Markdown definition files and registered via
409+
`register_agent_if_absent`, so they never overwrite agents that were
410+
already registered programmatically or by plugins.
411+
412+
Registration order (highest to lowest priority):
413+
1. Programmatic `register_agent()` calls (already in the registry)
414+
2. Plugin agents (registered during plugin loading, i.e.,
415+
in _ensure_plugins_loaded())
416+
3. Project-level file agents (`{project}/.agents/agents/*.md`,
417+
then `{project}/.openhands/agents/*.md`)
418+
4. User-level file agents (`~/.agents/agents/*.md`,
419+
then `~/.openhands/agents/*.md`)
420+
5. SDK builtin agents (`subagent/builtins/*.md`)
421+
"""
422+
# register project-level and then user-level file-based agents
423+
register_file_agents(self.workspace.working_dir)
424+
# register builtins agents
425+
register_builtins_agents()
426+
390427
def _ensure_agent_ready(self) -> None:
391-
"""Ensure agent is fully initialized with plugins loaded.
428+
"""Ensure the agent is fully initialized with plugins and agents loaded.
392429
393-
This method combines plugin loading and agent initialization to ensure
394-
the agent is initialized exactly once with complete configuration.
430+
Performs one-time lazy initialization on the first `send_message()`
431+
or `run()` call. The steps executed (in order) are:
395432
396-
Called lazily on first send_message() or run() to:
397-
1. Load plugins (if specified)
398-
2. Initialize agent with complete plugin config and hooks
399-
3. Register LLMs in the registry
433+
1. Load plugins (merges skills, MCP config, and hooks).
434+
2. Register file-based agents into the agent registry.
435+
3. Initialize the agent with complete plugin config and hooks.
436+
4. Register LLMs in the LLM registry.
400437
401438
This preserves the design principle that constructors should not perform
402439
I/O or error-prone operations, while eliminating double initialization.
403440
404-
Thread-safe: Uses state lock to prevent concurrent initialization.
441+
Thread-safe: uses a double-checked lock on the conversation state to
442+
prevent concurrent initialization.
405443
"""
406444
# Fast path: if already initialized, skip lock acquisition entirely.
407445
# This is crucial for concurrent send_message() calls during run(),
@@ -419,6 +457,9 @@ def _ensure_agent_ready(self) -> None:
419457
# Load plugins first (merges skills, MCP config, hooks)
420458
self._ensure_plugins_loaded()
421459

460+
# register file-based agents
461+
self._register_file_based_agents()
462+
422463
# Initialize agent with complete configuration
423464
self.agent.init_state(self._state, on_event=self._on_event)
424465

openhands-sdk/openhands/sdk/plugin/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from openhands.sdk.plugin.loader import load_plugins
1515
from openhands.sdk.plugin.plugin import Plugin
1616
from openhands.sdk.plugin.types import (
17-
AgentDefinition,
1817
CommandDefinition,
1918
Marketplace,
2019
MarketplaceMetadata,
@@ -36,7 +35,6 @@
3635
"PluginAuthor",
3736
"PluginSource",
3837
"ResolvedPluginSource",
39-
"AgentDefinition",
4038
"CommandDefinition",
4139
# Plugin loading
4240
"load_plugins",

openhands-sdk/openhands/sdk/plugin/plugin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@
1818
from openhands.sdk.logger import get_logger
1919
from openhands.sdk.plugin.fetch import fetch_plugin
2020
from openhands.sdk.plugin.types import (
21-
AgentDefinition,
2221
CommandDefinition,
2322
PluginAuthor,
2423
PluginManifest,
2524
)
25+
from openhands.sdk.subagent.schema import AgentDefinition
2626

2727

2828
if TYPE_CHECKING:

openhands-sdk/openhands/sdk/plugin/types.py

Lines changed: 0 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from __future__ import annotations
44

55
import json
6-
import re
76
from pathlib import Path
87
from typing import TYPE_CHECKING, Any
98

@@ -177,105 +176,6 @@ class PluginManifest(BaseModel):
177176
model_config = {"extra": "allow"}
178177

179178

180-
def _extract_examples(description: str) -> list[str]:
181-
"""Extract <example> tags from description for agent triggering."""
182-
pattern = r"<example>(.*?)</example>"
183-
matches = re.findall(pattern, description, re.DOTALL | re.IGNORECASE)
184-
return [m.strip() for m in matches if m.strip()]
185-
186-
187-
class AgentDefinition(BaseModel):
188-
"""Agent definition loaded from markdown file.
189-
190-
Agents are specialized configurations that can be triggered based on
191-
user input patterns. They define custom system prompts and tool access.
192-
"""
193-
194-
name: str = Field(description="Agent name (from frontmatter or filename)")
195-
description: str = Field(default="", description="Agent description")
196-
model: str = Field(
197-
default="inherit", description="Model to use ('inherit' uses parent model)"
198-
)
199-
color: str | None = Field(default=None, description="Display color for the agent")
200-
tools: list[str] = Field(
201-
default_factory=list, description="List of allowed tools for this agent"
202-
)
203-
system_prompt: str = Field(default="", description="System prompt content")
204-
source: str | None = Field(
205-
default=None, description="Source file path for this agent"
206-
)
207-
# whenToUse examples extracted from description
208-
when_to_use_examples: list[str] = Field(
209-
default_factory=list,
210-
description="Examples of when to use this agent (for triggering)",
211-
)
212-
# Raw frontmatter for any additional fields
213-
metadata: dict[str, Any] = Field(
214-
default_factory=dict, description="Additional metadata from frontmatter"
215-
)
216-
217-
@classmethod
218-
def load(cls, agent_path: Path) -> AgentDefinition:
219-
"""Load an agent definition from a markdown file.
220-
221-
Agent markdown files have YAML frontmatter with:
222-
- name: Agent name
223-
- description: Description with optional <example> tags for triggering
224-
- model: Model to use (default: 'inherit')
225-
- color: Display color
226-
- tools: List of allowed tools
227-
228-
The body of the markdown is the system prompt.
229-
230-
Args:
231-
agent_path: Path to the agent markdown file.
232-
233-
Returns:
234-
Loaded AgentDefinition instance.
235-
"""
236-
with open(agent_path) as f:
237-
post = frontmatter.load(f)
238-
239-
fm = post.metadata
240-
content = post.content.strip()
241-
242-
# Extract frontmatter fields with proper type handling
243-
name = str(fm.get("name", agent_path.stem))
244-
description = str(fm.get("description", ""))
245-
model = str(fm.get("model", "inherit"))
246-
color_raw = fm.get("color")
247-
color: str | None = str(color_raw) if color_raw is not None else None
248-
tools_raw = fm.get("tools", [])
249-
250-
# Ensure tools is a list of strings
251-
tools: list[str]
252-
if isinstance(tools_raw, str):
253-
tools = [tools_raw]
254-
elif isinstance(tools_raw, list):
255-
tools = [str(t) for t in tools_raw]
256-
else:
257-
tools = []
258-
259-
# Extract whenToUse examples from description
260-
when_to_use_examples = _extract_examples(description)
261-
262-
# Remove known fields from metadata to get extras
263-
known_fields = {"name", "description", "model", "color", "tools"}
264-
metadata = {k: v for k, v in fm.items() if k not in known_fields}
265-
266-
return cls(
267-
name=name,
268-
description=description,
269-
model=model,
270-
color=color,
271-
tools=tools,
272-
system_prompt=content,
273-
source=str(agent_path),
274-
when_to_use_examples=when_to_use_examples,
275-
metadata=metadata,
276-
)
277-
278-
279179
class CommandDefinition(BaseModel):
280180
"""Command definition loaded from markdown file.
281181

0 commit comments

Comments
 (0)