feat(agent): add subagents + delegate_name fields to AgentDefinition (#525, #526)

Introduces the schema change needed to make agent definitions the single source
of truth for both direct tools and delegation targets:

- `subagents: Vec<SubagentEntry>` — declarative list of agents this agent can
  spawn, expanding at build time into synthesised delegate_* tools on the LLM's
  function-calling surface. Supports two TOML shapes via `#[serde(untagged)]`:
    * Bare string (`"researcher"`) → `SubagentEntry::AgentId`
    * Inline table (`{ skills = "*" }`) → `SubagentEntry::Skills(SkillsWildcard)`
  The `Skills` variant expands dynamically to one delegate_{toolkit} tool per
  connected Composio toolkit at runtime.

- `delegate_name: Option<String>` — optional override for the tool name this
  agent is exposed as when another agent lists it in `subagents`. Defaults to
  `delegate_{id}` when absent, lets the researcher agent be exposed as
  `research`, code_executor as `run_code`, etc.

Schema only — no runtime behavior change yet. Follow-up commits wire the field
into `collect_orchestrator_tools`, the dispatch path, and the debug dump.

TOML placement note: `subagents = [...]` must appear before the `[tools]` table
header in agent TOMLs. Once a table section opens, every subsequent top-level
key is consumed by that table, so placing `subagents` after `[tools]` parses it
as `tools.subagents` and fails deserializing ToolScope. The test doc-comment
records this constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-04-13 18:28:38 +05:30
co-authored by Claude Opus 4.6
parent af454afbb3
commit 8f92155b4d
5 changed files with 192 additions and 1 deletions
Generated
+1 -1
View File
@@ -4298,7 +4298,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openhuman"
version = "0.52.5"
version = "0.52.6"
dependencies = [
"aes-gcm",
"anyhow",
@@ -65,6 +65,8 @@ pub fn fork_definition() -> AgentDefinition {
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: true,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
}
}
+179
View File
@@ -115,12 +115,90 @@ pub struct AgentDefinition {
#[serde(default, skip_serializing_if = "is_false")]
pub uses_fork_context: bool,
// ── delegation surface ─────────────────────────────────────────────
/// Subagents this agent is allowed to spawn via synthesised
/// `delegate_*` tools. Each entry expands at agent-build time into
/// one tool the LLM can call in its function-calling schema:
///
/// * [`SubagentEntry::AgentId`] — one [`ArchetypeDelegationTool`]
/// whose name defaults to `delegate_{agent_id}` (or the target
/// agent's `delegate_name` override) and whose description is the
/// target agent's [`AgentDefinition::when_to_use`].
///
/// * [`SubagentEntry::Skills`] — one [`SkillDelegationTool`] per
/// connected Composio toolkit, each named `delegate_{toolkit}`,
/// all routing to the generic `skills_agent` with an appropriate
/// `skill_filter` pre-populated.
///
/// `subagents` is intentionally separate from [`AgentDefinition::tools`]
/// so that reading a TOML makes the distinction obvious: `tools` is
/// "what I execute directly", `subagents` is "what I can delegate to".
///
/// [`ArchetypeDelegationTool`]: crate::openhuman::tools::impl::agent::ArchetypeDelegationTool
/// [`SkillDelegationTool`]: crate::openhuman::tools::impl::agent::SkillDelegationTool
#[serde(default)]
pub subagents: Vec<SubagentEntry>,
/// Optional override for the tool name this agent is exposed as when
/// another agent lists it in its [`subagents`]. Defaults to
/// `delegate_{id}` when absent. Kept separate from `display_name` so
/// the UI display and the LLM tool name can diverge (e.g.
/// `display_name = "Researcher"`, `delegate_name = "research"`).
#[serde(default)]
pub delegate_name: Option<String>,
// ── source bookkeeping ──────────────────────────────────────────────
/// Tracks where the definition was loaded from (Builtin vs. File).
#[serde(skip)]
pub source: DefinitionSource,
}
// ─────────────────────────────────────────────────────────────────────────────
// Subagent delegation entries
// ─────────────────────────────────────────────────────────────────────────────
/// One entry in [`AgentDefinition::subagents`]. Parses from TOML as either
/// a bare string (agent id) or an inline table (`{ skills = "*" }`) thanks
/// to `#[serde(untagged)]`.
///
/// # TOML shapes
///
/// ```toml
/// subagents = [
/// "researcher", # AgentId("researcher")
/// "code_executor", # AgentId("code_executor")
/// { skills = "*" }, # Skills { pattern: "*" }
/// ]
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum SubagentEntry {
/// Delegate to a specific built-in or custom agent by id.
AgentId(String),
/// Expand at build time to one `delegate_{toolkit}` tool per
/// connected Composio toolkit, each routing to the generic
/// `skills_agent` with `skill_filter` pre-set.
Skills(SkillsWildcard),
}
/// The `{ skills = "*" }` inline table in a `subagents` list.
///
/// Today only `"*"` is meaningful (expand to every connected toolkit).
/// Future: a `Vec<String>` variant to restrict expansion to specific
/// toolkit slugs (e.g. `{ skills = ["gmail", "notion"] }`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillsWildcard {
/// Glob / wildcard pattern. Only `"*"` is currently supported.
pub skills: String,
}
impl SkillsWildcard {
/// True when this wildcard should expand to every connected toolkit.
pub fn matches_all(&self) -> bool {
self.skills == "*"
}
}
fn is_false(b: &bool) -> bool {
!b
}
@@ -411,6 +489,8 @@ mod tests {
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
}
}
@@ -466,4 +546,103 @@ mod tests {
def2.display_name = Some("Beta Specialist".into());
assert_eq!(def2.display_name(), "Beta Specialist");
}
// ── subagents parsing ─────────────────────────────────────────────
/// Parses a minimal TOML document with a `subagents` list containing
/// both a bare agent-id string and an inline `{ skills = "*" }` table.
/// Ensures the `#[serde(untagged)]` enum routes each shape to the
/// correct variant without the TOML needing explicit tags.
///
/// NOTE: `subagents = [...]` must appear **before** the `[tools]`
/// table header in the TOML — once you open a TOML table section,
/// every subsequent top-level key is consumed by that table, so
/// `subagents` placed after `[tools]` would be parsed as
/// `tools.subagents` and fail because `ToolScope` is an enum, not
/// a struct with a `subagents` field.
#[test]
fn subagents_parses_mixed_string_and_table_entries() {
let toml_src = r#"
id = "orchestrator"
when_to_use = "Routes work to the right specialist"
temperature = 0.4
max_iterations = 15
subagents = [
"researcher",
"code_executor",
{ skills = "*" },
]
[tools]
named = ["query_memory"]
"#;
let def: AgentDefinition = toml::from_str(toml_src).expect("toml parse");
assert_eq!(def.subagents.len(), 3);
assert_eq!(
def.subagents[0],
SubagentEntry::AgentId("researcher".into())
);
assert_eq!(
def.subagents[1],
SubagentEntry::AgentId("code_executor".into())
);
assert_eq!(
def.subagents[2],
SubagentEntry::Skills(SkillsWildcard { skills: "*".into() })
);
}
/// `subagents` is optional — omitting it should yield an empty Vec
/// rather than a deserialization error. Most non-delegating agents
/// (welcome, archivist, code_executor, etc.) will not list any.
#[test]
fn subagents_defaults_to_empty_when_omitted() {
let toml_src = r#"
id = "welcome"
when_to_use = "First agent a new user speaks to"
temperature = 0.7
max_iterations = 6
[tools]
named = ["complete_onboarding", "memory_recall"]
"#;
let def: AgentDefinition = toml::from_str(toml_src).expect("toml parse");
assert!(def.subagents.is_empty());
assert!(def.delegate_name.is_none());
}
/// The `delegate_name` field lets an agent expose itself under a
/// shorter / more natural tool name than `delegate_{id}`. For example
/// the `researcher` agent is exposed as `research` in the
/// orchestrator's tool list.
#[test]
fn delegate_name_overrides_default() {
let toml_src = r#"
id = "researcher"
when_to_use = "Web & docs crawler"
delegate_name = "research"
temperature = 0.4
max_iterations = 8
"#;
let def: AgentDefinition = toml::from_str(toml_src).expect("toml parse");
assert_eq!(def.delegate_name.as_deref(), Some("research"));
}
/// `SkillsWildcard::matches_all` is the predicate the tool builder
/// checks before expanding a wildcard into per-toolkit tools. Only
/// the literal `"*"` should be accepted today — any other pattern
/// (reserved for future specific-toolkit lists) must not match.
#[test]
fn skills_wildcard_only_star_matches_all() {
let star = SkillsWildcard {
skills: "*".into(),
};
assert!(star.matches_all());
let specific = SkillsWildcard {
skills: "gmail".into(),
};
assert!(!specific.matches_all());
}
}
@@ -791,6 +791,8 @@ mod tests {
sandbox_mode: super::super::definition::SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: super::super::definition::DefinitionSource::Builtin,
}
}
+8
View File
@@ -644,6 +644,8 @@ mod tests {
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
}
}
@@ -890,6 +892,8 @@ mod tests {
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
};
@@ -935,6 +939,8 @@ mod tests {
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
};
@@ -988,6 +994,8 @@ mod tests {
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
};