|
| 1 | +# Subagent loader (file-based agents): design + invariants |
| 2 | + |
| 3 | +This package (`openhands.sdk.subagent`) centralizes **subagent discovery** and **registration**. |
| 4 | +It exists so that contributors (human or agentic) can answer: |
| 5 | + |
| 6 | +- “Where did this agent come from?” |
| 7 | +- “Why did this definition win over the other one?” |
| 8 | + |
| 9 | +without reverse-engineering `LocalConversation` and the loader. |
| 10 | + |
| 11 | +## Scope |
| 12 | + |
| 13 | +- **File-based agents**: Markdown files (`*.md`) with YAML frontmatter. |
| 14 | +- **Plugin agents**: `Plugin.agents` (already parsed by the plugin loader; registered here). |
| 15 | +- **Programmatic agents**: `register_agent(...)` (highest precedence, never overwritten). |
| 16 | +- **Built-in agents**: `subagent/builtins/*.md` (lowest precedence; used only as a fallback). |
| 17 | + |
| 18 | +Relevant implementation files: |
| 19 | + |
| 20 | +- `load.py`: filesystem discovery + parse-error handling. |
| 21 | +- `schema.py`: Markdown/YAML schema and parsing rules. |
| 22 | +- `registry.py`: registry API + “first registration wins” semantics. |
| 23 | +- `conversation/impl/local_conversation.py`: the **call order** that establishes precedence. |
| 24 | + |
| 25 | +## Invariant 1: discovery locations & file rules |
| 26 | + |
| 27 | +### Directories scanned |
| 28 | + |
| 29 | +**Project-level (higher priority than user-level):** |
| 30 | + |
| 31 | +1. `{project}/.agents/agents/*.md` |
| 32 | +2. `{project}/.openhands/agents/*.md` |
| 33 | + |
| 34 | +**User-level:** |
| 35 | + |
| 36 | +3. `~/.agents/agents/*.md` |
| 37 | +4. `~/.openhands/agents/*.md` |
| 38 | + |
| 39 | +Notes: |
| 40 | + |
| 41 | +- Only the **top-level** `*.md` files are scanned. |
| 42 | + - Subdirectories (e.g. `{project}/.agents/skills/…`) are ignored. |
| 43 | +- `README.md` / `readme.md` is always skipped. |
| 44 | +- Directory iteration is deterministic (`sorted(dir.iterdir())`). |
| 45 | + |
| 46 | +### Parse failures must be non-fatal |
| 47 | + |
| 48 | +If a single file fails to parse (invalid YAML frontmatter, malformed Markdown, etc.), |
| 49 | +loading must: |
| 50 | + |
| 51 | +- log a warning (with stack trace), and |
| 52 | +- continue scanning other files. |
| 53 | + |
| 54 | +(See `load_agents_from_dir` in `load.py`.) |
| 55 | + |
| 56 | +## Invariant 2: resolution / precedence (“who wins”) |
| 57 | + |
| 58 | +### Core rule: first registration wins |
| 59 | + |
| 60 | +Once an agent name is registered in the global registry (`_agent_factories`), later |
| 61 | +sources must not overwrite it. |
| 62 | + |
| 63 | +This is enforced by using: |
| 64 | + |
| 65 | +- `register_agent(...)` (raises on duplicates; used for programmatic registration) |
| 66 | +- `register_agent_if_absent(...)` (skips duplicates; used for plugins, file agents, builtins) |
| 67 | + |
| 68 | +### Effective precedence order |
| 69 | + |
| 70 | +When a `LocalConversation` becomes ready, it establishes the following priority: |
| 71 | + |
| 72 | +1. **Programmatic** `register_agent(...)` (pre-existing; must never be overwritten) |
| 73 | +2. **Plugin-provided** agents (`Plugin.agents` → `register_plugin_agents`) |
| 74 | +3. **Project** file-based agents |
| 75 | + - `{project}/.agents/agents/*.md` then `{project}/.openhands/agents/*.md` |
| 76 | +4. **User** file-based agents |
| 77 | + - `~/.agents/agents/*.md` then `~/.openhands/agents/*.md` |
| 78 | +5. **SDK built-ins** (`subagent/builtins/*.md`) |
| 79 | + |
| 80 | +This is the order implemented by: |
| 81 | + |
| 82 | +- `LocalConversation._ensure_plugins_loaded()` → registers plugin agents |
| 83 | +- `LocalConversation._register_file_based_agents()` → registers project/user file agents, then built-ins |
| 84 | + |
| 85 | +### Deduplication rules inside file-based loading |
| 86 | + |
| 87 | +File-based loading has *two* layers of “first wins” deduplication: |
| 88 | + |
| 89 | +1. **Within a level** (`load_project_agents` / `load_user_agents`): |
| 90 | + - `.agents/agents` wins over `.openhands/agents` for the same agent name. |
| 91 | +2. **Across levels** (`register_file_agents`): |
| 92 | + - project wins over user for the same agent name. |
| 93 | + |
| 94 | +If you change these rules, update the unit tests in `tests/sdk/subagent/`. |
| 95 | + |
| 96 | +## Invariant 3: Markdown agent schema & semantics |
| 97 | + |
| 98 | +### Frontmatter keys |
| 99 | + |
| 100 | +Supported YAML frontmatter keys (see `AgentDefinition.load` in `schema.py`): |
| 101 | + |
| 102 | +- `name` (default: filename stem) |
| 103 | +- `description` |
| 104 | +- `tools` (default: `[]`) |
| 105 | + - accepts either a string (`tools: ReadTool`) or a list |
| 106 | +- `model` (default: `inherit`) |
| 107 | + - `inherit` means “use the parent agent’s LLM instance” |
| 108 | + - any other string means “copy parent LLM and override the `model` field” |
| 109 | +- `color` (optional) |
| 110 | + |
| 111 | +**Unknown keys are preserved** in `AgentDefinition.metadata`. |
| 112 | + |
| 113 | +### Body → system prompt |
| 114 | + |
| 115 | +The Markdown **body content** becomes the agent’s `system_prompt`. |
| 116 | + |
| 117 | +Currently, when the agent is instantiated, this is applied as: |
| 118 | + |
| 119 | +- `AgentContext(system_message_suffix=agent_def.system_prompt)` |
| 120 | + |
| 121 | +meaning it is appended to the parent system message (not a complete replacement). |
| 122 | + |
| 123 | +### Tools mapping |
| 124 | + |
| 125 | +`tools` values are stored as tool names (`list[str]`) and mapped at instantiation time to: |
| 126 | + |
| 127 | +- `Tool(name=tool_name)` |
| 128 | + |
| 129 | +No validation is performed at load time beyond “stringification”. |
| 130 | + |
| 131 | +### Trigger examples in description |
| 132 | + |
| 133 | +The loader extracts `<example>…</example>` tags from `description` (case-insensitive) |
| 134 | +into `AgentDefinition.when_to_use_examples`. |
| 135 | + |
| 136 | +These examples are used for triggering / routing logic elsewhere. |
| 137 | + |
| 138 | +### Minimal example |
| 139 | + |
| 140 | +```markdown |
| 141 | +--- |
| 142 | +name: code-reviewer |
| 143 | +description: | |
| 144 | + Reviews code changes. |
| 145 | + |
| 146 | + <example>please review this PR</example> |
| 147 | + <example>can you do a security review?</example> |
| 148 | +tools: |
| 149 | + - ReadTool |
| 150 | + - GrepTool |
| 151 | +model: inherit |
| 152 | +color: purple |
| 153 | +# Any extra keys are preserved in `metadata`: |
| 154 | +audience: maintainers |
| 155 | +--- |
| 156 | + |
| 157 | +You are a meticulous code reviewer. |
| 158 | +Focus on correctness, security, and clear reasoning. |
| 159 | +``` |
| 160 | + |
| 161 | +## User-facing documentation |
| 162 | + |
| 163 | +User docs for Markdown agents live in the docs repo. If you change any of the |
| 164 | +invariants above, update both this file and the user docs. |
| 165 | + |
| 166 | +- Docs PR tracking this feature: https://github.com/OpenHands/docs/pull/358 |
0 commit comments