Skip to content

Commit 30d619d

Browse files
feat(subagents): support per-subagent skill loading and custom subagent types (#2253)
* feat(subagents): support per-subagent skill loading and custom subagent types (#2230) Add per-subagent skill configuration and custom subagent type registration, aligned with Codex's role-based config layering and per-session skill injection. Backend: - SubagentConfig gains `skills` field (None=all, []=none, list=whitelist) - New CustomSubagentConfig for user-defined subagent types in config.yaml - SubagentsAppConfig gains `custom_agents` section and `get_skills_for()` - Registry resolves custom agents with three-layer config precedence - SubagentExecutor loads skills per-session as conversation items (Codex pattern) - task_tool no longer appends skills to system_prompt - Lead agent system prompt dynamically lists all registered subagent types - setup_agent tool accepts optional skills parameter - Gateway agents API transparently passes skills in CRUD operations Frontend: - Agent/CreateAgentRequest/UpdateAgentRequest types include skills field - Agent card displays skills as badges alongside tool_groups Config: - config.example.yaml documents custom_agents and per-agent skills override Tests: - 40 new tests covering all skill config, custom agents, and registry logic - Existing tests updated for new get_skills_prompt_section signature Closes #2230 * fix: address review feedback on skills PR - Remove stale get_skills_prompt_section monkeypatches from test_task_tool_core_logic.py (task_tool no longer imports this function after skill injection moved to executor) - Add key prefixes (tg:/sk:) to agent-card badges to prevent React key collisions between tool_groups and skills * fix(ci): resolve lint and test failures - Format agent-card.tsx with prettier (lint-frontend) - Remove stale "Skills Appendix" system_prompt assertion — skills are now loaded per-session by SubagentExecutor, not appended to system_prompt * fix(ci): sort imports in test_subagent_skills_config.py (ruff I001) * fix(ci): use nullish coalescing in agent-card badge condition (eslint) * fix: address review feedback on skills PR - Use model_fields_set in AgentUpdateRequest to distinguish "field omitted" from "explicitly set to null" — fixes skills=None ambiguity where None means "inherit all" but was treated as "don't change" - Move lazy import of get_subagent_config outside loop in _build_available_subagents_description to avoid repeated import overhead --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
1 parent 4e72410 commit 30d619d

14 files changed

Lines changed: 962 additions & 72 deletions

File tree

backend/app/gateway/routers/agents.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class AgentResponse(BaseModel):
2525
description: str = Field(default="", description="Agent description")
2626
model: str | None = Field(default=None, description="Optional model override")
2727
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
28+
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all, []=none)")
2829
soul: str | None = Field(default=None, description="SOUL.md content")
2930

3031

@@ -41,6 +42,7 @@ class AgentCreateRequest(BaseModel):
4142
description: str = Field(default="", description="Agent description")
4243
model: str | None = Field(default=None, description="Optional model override")
4344
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
45+
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all enabled, []=none)")
4446
soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails")
4547

4648

@@ -50,6 +52,7 @@ class AgentUpdateRequest(BaseModel):
5052
description: str | None = Field(default=None, description="Updated description")
5153
model: str | None = Field(default=None, description="Updated model override")
5254
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
55+
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (None=all, []=none)")
5356
soul: str | None = Field(default=None, description="Updated SOUL.md content")
5457

5558

@@ -94,6 +97,7 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False
9497
description=agent_cfg.description,
9598
model=agent_cfg.model,
9699
tool_groups=agent_cfg.tool_groups,
100+
skills=agent_cfg.skills,
97101
soul=soul,
98102
)
99103

@@ -215,6 +219,8 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
215219
config_data["model"] = request.model
216220
if request.tool_groups is not None:
217221
config_data["tool_groups"] = request.tool_groups
222+
if request.skills is not None:
223+
config_data["skills"] = request.skills
218224

219225
config_file = agent_dir / "config.yaml"
220226
with open(config_file, "w", encoding="utf-8") as f:
@@ -271,21 +277,32 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
271277

272278
try:
273279
# Update config if any config fields changed
274-
config_changed = any(v is not None for v in [request.description, request.model, request.tool_groups])
280+
# Use model_fields_set to distinguish "field omitted" from "explicitly set to null".
281+
# This is critical for skills where None means "inherit all" (not "don't change").
282+
fields_set = request.model_fields_set
283+
config_changed = bool(fields_set & {"description", "model", "tool_groups", "skills"})
275284

276285
if config_changed:
277286
updated: dict = {
278287
"name": agent_cfg.name,
279-
"description": request.description if request.description is not None else agent_cfg.description,
288+
"description": request.description if "description" in fields_set else agent_cfg.description,
280289
}
281-
new_model = request.model if request.model is not None else agent_cfg.model
290+
new_model = request.model if "model" in fields_set else agent_cfg.model
282291
if new_model is not None:
283292
updated["model"] = new_model
284293

285-
new_tool_groups = request.tool_groups if request.tool_groups is not None else agent_cfg.tool_groups
294+
new_tool_groups = request.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups
286295
if new_tool_groups is not None:
287296
updated["tool_groups"] = new_tool_groups
288297

298+
# skills: None = inherit all, [] = no skills, ["a","b"] = whitelist
299+
if "skills" in fields_set:
300+
new_skills = request.skills
301+
else:
302+
new_skills = agent_cfg.skills
303+
if new_skills is not None:
304+
updated["skills"] = new_skills
305+
289306
config_file = agent_dir / "config.yaml"
290307
with open(config_file, "w", encoding="utf-8") as f:
291308
yaml.dump(updated, f, default_flow_style=False, allow_unicode=True)

backend/packages/harness/deerflow/agents/lead_agent/prompt.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,36 @@ def _build_skill_evolution_section(skill_evolution_enabled: bool) -> str:
164164
"""
165165

166166

167+
def _build_available_subagents_description(available_names: list[str], bash_available: bool) -> str:
168+
"""Dynamically build subagent type descriptions from registry.
169+
170+
Mirrors Codex's pattern where agent_type_description is dynamically generated
171+
from all registered roles, so the LLM knows about every available type.
172+
"""
173+
# Built-in descriptions (kept for backward compatibility with existing prompt quality)
174+
builtin_descriptions = {
175+
"general-purpose": "For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.",
176+
"bash": (
177+
"For command execution (git, build, test, deploy operations)" if bash_available else "Not available in the current sandbox configuration. Use direct file/web tools or switch to AioSandboxProvider for isolated shell access."
178+
),
179+
}
180+
181+
# Lazy import moved outside loop to avoid repeated import overhead
182+
from deerflow.subagents.registry import get_subagent_config
183+
184+
lines = []
185+
for name in available_names:
186+
if name in builtin_descriptions:
187+
lines.append(f"- **{name}**: {builtin_descriptions[name]}")
188+
else:
189+
config = get_subagent_config(name)
190+
if config is not None:
191+
desc = config.description.split("\n")[0].strip() # First line only for brevity
192+
lines.append(f"- **{name}**: {desc}")
193+
194+
return "\n".join(lines)
195+
196+
167197
def _build_subagent_section(max_concurrent: int) -> str:
168198
"""Build the subagent system prompt section with dynamic concurrency limit.
169199
@@ -174,13 +204,12 @@ def _build_subagent_section(max_concurrent: int) -> str:
174204
Formatted subagent section string.
175205
"""
176206
n = max_concurrent
177-
bash_available = "bash" in get_available_subagent_names()
178-
available_subagents = (
179-
"- **general-purpose**: For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.\n- **bash**: For command execution (git, build, test, deploy operations)"
180-
if bash_available
181-
else "- **general-purpose**: For ANY non-trivial task - web research, code exploration, file operations, analysis, etc.\n"
182-
"- **bash**: Not available in the current sandbox configuration. Use direct file/web tools or switch to AioSandboxProvider for isolated shell access."
183-
)
207+
available_names = get_available_subagent_names()
208+
bash_available = "bash" in available_names
209+
210+
# Dynamically build subagent type descriptions from registry (aligned with Codex's
211+
# agent_type_description pattern where all registered roles are listed in the tool spec).
212+
available_subagents = _build_available_subagents_description(available_names, bash_available)
184213
direct_tool_examples = "bash, ls, read_file, web_search, etc." if bash_available else "ls, read_file, web_search, etc."
185214
direct_execution_example = (
186215
'# User asks: "Run the tests"\n# Thinking: Cannot decompose into parallel sub-tasks\n# → Execute directly\n\nbash("npm test") # Direct execution, not task()'

backend/packages/harness/deerflow/config/subagents_config.py

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,47 @@ class SubagentOverrideConfig(BaseModel):
2525
min_length=1,
2626
description="Model name for this subagent (None = inherit from parent agent)",
2727
)
28+
skills: list[str] | None = Field(
29+
default=None,
30+
description="Skill names whitelist for this subagent (None = inherit all enabled skills, [] = no skills)",
31+
)
32+
33+
34+
class CustomSubagentConfig(BaseModel):
35+
"""User-defined subagent type declared in config.yaml."""
36+
37+
description: str = Field(
38+
description="When the lead agent should delegate to this subagent",
39+
)
40+
system_prompt: str = Field(
41+
description="System prompt that guides the subagent's behavior",
42+
)
43+
tools: list[str] | None = Field(
44+
default=None,
45+
description="Tool names whitelist (None = inherit all tools from parent)",
46+
)
47+
disallowed_tools: list[str] | None = Field(
48+
default_factory=lambda: ["task", "ask_clarification", "present_files"],
49+
description="Tool names to deny",
50+
)
51+
skills: list[str] | None = Field(
52+
default=None,
53+
description="Skill names whitelist (None = inherit all enabled skills, [] = no skills)",
54+
)
55+
model: str = Field(
56+
default="inherit",
57+
description="Model to use - 'inherit' uses parent's model",
58+
)
59+
max_turns: int = Field(
60+
default=50,
61+
ge=1,
62+
description="Maximum number of agent turns before stopping",
63+
)
64+
timeout_seconds: int = Field(
65+
default=900,
66+
ge=1,
67+
description="Maximum execution time in seconds",
68+
)
2869

2970

3071
class SubagentsAppConfig(BaseModel):
@@ -44,6 +85,10 @@ class SubagentsAppConfig(BaseModel):
4485
default_factory=dict,
4586
description="Per-agent configuration overrides keyed by agent name",
4687
)
88+
custom_agents: dict[str, CustomSubagentConfig] = Field(
89+
default_factory=dict,
90+
description="User-defined subagent types keyed by agent name",
91+
)
4792

4893
def get_timeout_for(self, agent_name: str) -> int:
4994
"""Get the effective timeout for a specific agent.
@@ -82,6 +127,20 @@ def get_max_turns_for(self, agent_name: str, builtin_default: int) -> int:
82127
return self.max_turns
83128
return builtin_default
84129

130+
def get_skills_for(self, agent_name: str) -> list[str] | None:
131+
"""Get the skills override for a specific agent.
132+
133+
Args:
134+
agent_name: The name of the subagent.
135+
136+
Returns:
137+
Skill names whitelist if overridden, None otherwise (subagent will inherit all enabled skills).
138+
"""
139+
override = self.agents.get(agent_name)
140+
if override is not None and override.skills is not None:
141+
return override.skills
142+
return None
143+
85144

86145
_subagents_config: SubagentsAppConfig = SubagentsAppConfig()
87146

@@ -105,15 +164,20 @@ def load_subagents_config_from_dict(config_dict: dict) -> None:
105164
parts.append(f"max_turns={override.max_turns}")
106165
if override.model is not None:
107166
parts.append(f"model={override.model}")
167+
if override.skills is not None:
168+
parts.append(f"skills={override.skills}")
108169
if parts:
109170
overrides_summary[name] = ", ".join(parts)
110171

111-
if overrides_summary:
172+
custom_agents_names = list(_subagents_config.custom_agents.keys())
173+
174+
if overrides_summary or custom_agents_names:
112175
logger.info(
113-
"Subagents config loaded: default timeout=%ss, default max_turns=%s, per-agent overrides=%s",
176+
"Subagents config loaded: default timeout=%ss, default max_turns=%s, per-agent overrides=%s, custom_agents=%s",
114177
_subagents_config.timeout_seconds,
115178
_subagents_config.max_turns,
116-
overrides_summary,
179+
overrides_summary or "none",
180+
custom_agents_names or "none",
117181
)
118182
else:
119183
logger.info(

backend/packages/harness/deerflow/subagents/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ class SubagentConfig:
1313
system_prompt: The system prompt that guides the subagent's behavior.
1414
tools: Optional list of tool names to allow. If None, inherits all tools.
1515
disallowed_tools: Optional list of tool names to deny.
16+
skills: Optional list of skill names to load. If None, inherits all enabled skills.
17+
If an empty list, no skills are loaded.
1618
model: Model to use - 'inherit' uses parent's model.
1719
max_turns: Maximum number of agent turns before stopping.
1820
timeout_seconds: Maximum execution time in seconds (default: 900 = 15 minutes).
@@ -23,6 +25,7 @@ class SubagentConfig:
2325
system_prompt: str
2426
tools: list[str] | None = None
2527
disallowed_tools: list[str] | None = field(default_factory=lambda: ["task"])
28+
skills: list[str] | None = None
2629
model: str = "inherit"
2730
max_turns: int = 50
2831
timeout_seconds: int = 900

backend/packages/harness/deerflow/subagents/executor.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from langchain.agents import create_agent
1515
from langchain.tools import BaseTool
16-
from langchain_core.messages import AIMessage, HumanMessage
16+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
1717
from langchain_core.runnables import RunnableConfig
1818

1919
from deerflow.agents.thread_state import SandboxState, ThreadDataState, ThreadState
@@ -184,7 +184,63 @@ def _create_agent(self):
184184
state_schema=ThreadState,
185185
)
186186

187-
def _build_initial_state(self, task: str) -> dict[str, Any]:
187+
async def _load_skill_messages(self) -> list[SystemMessage]:
188+
"""Load skill content as conversation items based on config.skills.
189+
190+
Aligned with Codex's pattern: each subagent loads its own skills
191+
per-session and injects them as conversation items (developer messages),
192+
not as system prompt text. The config.skills whitelist controls which
193+
skills are loaded:
194+
- None: load all enabled skills
195+
- []: no skills
196+
- ["skill-a", "skill-b"]: only these skills
197+
198+
Returns:
199+
List of SystemMessages containing skill content.
200+
"""
201+
if self.config.skills is not None and len(self.config.skills) == 0:
202+
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} skills=[] — skipping skill loading")
203+
return []
204+
205+
try:
206+
from deerflow.skills.loader import load_skills
207+
208+
# Use asyncio.to_thread to avoid blocking the event loop (LangGraph ASGI requirement)
209+
all_skills = await asyncio.to_thread(load_skills, enabled_only=True)
210+
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} loaded {len(all_skills)} enabled skills from disk")
211+
except Exception:
212+
logger.warning(f"[trace={self.trace_id}] Failed to load skills for subagent {self.config.name}", exc_info=True)
213+
return []
214+
215+
if not all_skills:
216+
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} no enabled skills found")
217+
return []
218+
219+
# Filter by config.skills whitelist
220+
if self.config.skills is not None:
221+
allowed = set(self.config.skills)
222+
skills = [s for s in all_skills if s.name in allowed]
223+
else:
224+
skills = all_skills
225+
226+
if not skills:
227+
return []
228+
229+
# Read each skill's SKILL.md content and create conversation items
230+
messages = []
231+
for skill in skills:
232+
try:
233+
content = await asyncio.to_thread(skill.skill_file.read_text, encoding="utf-8")
234+
content = content.strip()
235+
if content:
236+
messages.append(SystemMessage(content=f'<skill name="{skill.name}">\n{content}\n</skill>'))
237+
logger.info(f"[trace={self.trace_id}] Subagent {self.config.name} loaded skill: {skill.name}")
238+
except Exception:
239+
logger.debug(f"[trace={self.trace_id}] Failed to read skill {skill.name}", exc_info=True)
240+
241+
return messages
242+
243+
async def _build_initial_state(self, task: str) -> dict[str, Any]:
188244
"""Build the initial state for agent execution.
189245
190246
Args:
@@ -193,8 +249,17 @@ def _build_initial_state(self, task: str) -> dict[str, Any]:
193249
Returns:
194250
Initial state dictionary.
195251
"""
252+
# Load skills as conversation items (Codex pattern)
253+
skill_messages = await self._load_skill_messages()
254+
255+
messages: list = []
256+
# Skill content injected as developer/system messages before the task
257+
messages.extend(skill_messages)
258+
# Then the actual task
259+
messages.append(HumanMessage(content=task))
260+
196261
state: dict[str, Any] = {
197-
"messages": [HumanMessage(content=task)],
262+
"messages": messages,
198263
}
199264

200265
# Pass through sandbox and thread data from parent
@@ -230,7 +295,7 @@ async def _aexecute(self, task: str, result_holder: SubagentResult | None = None
230295

231296
try:
232297
agent = self._create_agent()
233-
state = self._build_initial_state(task)
298+
state = await self._build_initial_state(task)
234299

235300
# Build config with thread_id for sandbox access and recursion limit
236301
run_config: RunnableConfig = {

0 commit comments

Comments
 (0)