feat(agent): drive orchestrator delegation tools from TOML subagents (#525, #526)

Completes the half-built migration from a hardcoded ARCHETYPE_TOOLS table
+ orphan skill_delegation.rs to a TOML-driven delegation surface where
each agent declares its subagents and the tool list is synthesised from
the registry at agent-build time.

Rewrites `collect_orchestrator_tools` to take the orchestrator's
AgentDefinition, the global AgentDefinitionRegistry, and the slice of
connected Composio integrations, then iterates the definition's
`subagents` field:

  * `SubagentEntry::AgentId` → one ArchetypeDelegationTool. The tool's
    `name()` comes from the target agent's `delegate_name` override (or
    `delegate_{id}` fallback) and its `description()` is the target's
    `when_to_use`. Editing an agent's TOML when_to_use now immediately
    updates the tool schema the orchestrator LLM sees — zero drift, no
    hardcoded description strings to keep in sync.

  * `SubagentEntry::Skills { skills = "*" }` → one SkillDelegationTool
    per connected Composio toolkit. Each routes to the generic
    skills_agent with `skill_filter` pre-populated. Empty integrations
    list (CLI path, or user not yet connected) produces zero tools
    rather than phantom `delegate_*` entries for unconnected toolkits.

Also in this commit:

- Wire the orphan `skill_delegation.rs` into `impl/agent/mod.rs` via
  `mod skill_delegation;` + `pub use SkillDelegationTool;`. The file has
  existed since earlier work but was never declared in the module tree,
  so it compiled as dead code.

- Delete the legacy `MAIN_AGENT_TOOL_ALLOWLIST` constant and the
  `main_agent_tools` filter in `tools/ops.rs`. They were documented as
  "no longer the primary source of truth" since the from_config builder
  switched to `collect_orchestrator_tools`, and grep confirms no
  external callers remain. Clean deletion.

- Delete the hardcoded `ARCHETYPE_TOOLS` const in
  `tools/impl/agent/mod.rs`. The 4-entry table has been replaced by the
  orchestrator TOML's `subagents` list (which covers those 4 plus
  archivist plus the skills wildcard), and the re-export in
  `tools/mod.rs` is removed accordingly.

- Update `agents/orchestrator/agent.toml`: add the `subagents` field
  listing researcher / planner / code_executor / critic / archivist /
  { skills = "*" }. Keep `spawn_subagent` in `[tools] named` as an
  advanced fallback so power users can still spawn custom workspace-
  override agent ids that aren't in the declarative subagents list.

- Add `delegate_name = "..."` to the 5 archetype TOMLs so the
  orchestrator LLM sees natural tool names (`research`, `plan`,
  `run_code`, `review_code`, `archive_session`) rather than the
  `delegate_<agent_id>` fallback.

- Update `agent/harness/session/builder.rs` (line ~461) to call the new
  `collect_orchestrator_tools` signature. Looks up the orchestrator
  definition from the global registry; passes an empty integrations
  slice because the builder is synchronous and cannot await Composio's
  async fetch. The channel-dispatch path will populate integrations in
  a later commit — the CLI/REPL path ships without per-toolkit delegation
  tools, which is acceptable regression since CLI users still reach
  Composio via `composio_execute` and the retained `spawn_subagent`
  fallback.

Tests:

  * 5 new unit tests in `orchestrator_tools.rs` cover the baseline
    AgentId + Skills wildcard expansion, empty-integrations edge case,
    unknown-id graceful skip, non-delegating agent with empty subagents,
    and the slug sanitiser for tool-name-safe Composio toolkit names.
  * Runs clean alongside all existing agent-module tests (323 pass;
    one pre-existing Windows-path failure in `self_healing::tests::
    tool_maker_prompt_includes_command` is unrelated to this PR and
    fails identically on the upstream baseline).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-04-13 18:41:59 +05:30
co-authored by Claude Opus 4.6
parent 8f92155b4d
commit d1c577d25a
11 changed files with 408 additions and 79 deletions
@@ -1,5 +1,6 @@
id = "archivist"
display_name = "Archivist"
delegate_name = "archive_session"
when_to_use = "Background librarian — extracts lessons from a completed session, updates MEMORY.md, and indexes to FTS5. Runs cheap and slow."
temperature = 0.4
max_iterations = 3
@@ -1,5 +1,6 @@
id = "code_executor"
display_name = "Code Executor"
delegate_name = "run_code"
when_to_use = "Sandboxed developer — writes, runs, and debugs code until tests pass. Use for any task that requires producing or modifying source files and exercising them with shell or test commands."
temperature = 0.4
max_iterations = 10
@@ -1,5 +1,6 @@
id = "critic"
display_name = "Critic"
delegate_name = "review_code"
when_to_use = "Adversarial reviewer — reviews diffs and code against project rules, flags vulnerabilities, regressions, and missing tests. Read-only."
temperature = 0.4
max_iterations = 5
@@ -9,13 +9,46 @@ omit_memory_context = true
omit_safety_preamble = true
omit_skills_catalog = true
# Delegation surface. The `collect_orchestrator_tools` helper reads this
# at agent-build time and synthesises one `delegate_*` tool per entry:
#
# * Bare agent ids → one `ArchetypeDelegationTool` each, using the
# target agent's `delegate_name` override (if any) or `delegate_{id}`
# as the tool name, and the target's `when_to_use` as the
# LLM-visible tool description.
#
# * `{ skills = "*" }` → one `SkillDelegationTool` per connected
# Composio toolkit, all routing to the generic `skills_agent` with
# the toolkit slug pre-filled as `skill_filter`.
#
# The orchestrator LLM sees these as first-class entries in its
# function-calling schema, so routing decisions happen at the tool-
# selection layer rather than hidden inside a mega-tool's enum param.
#
# NOTE: `subagents = [...]` MUST appear before any `[table]` section
# header — once a TOML table opens, subsequent top-level keys are
# consumed by that table, and `subagents` would get parsed as
# `tools.subagents` (which fails because `ToolScope` is an enum).
subagents = [
"researcher",
"planner",
"code_executor",
"critic",
"archivist",
{ skills = "*" },
]
[model]
hint = "reasoning"
[tools]
# Direct tools — things the orchestrator calls itself rather than
# delegating. `spawn_subagent` is retained as an advanced fallback so
# power users can still spawn arbitrary agent ids that are not listed
# in `subagents` above (e.g. workspace-override custom agents).
named = [
"spawn_subagent",
"query_memory",
"read_workspace_state",
"ask_user_clarification",
"spawn_subagent",
]
@@ -1,5 +1,6 @@
id = "planner"
display_name = "Planner"
delegate_name = "plan"
when_to_use = "Architect — break a complex task into a small DAG of subtasks with explicit acceptance criteria. Reads memory and searches the web to ground plans in real context. Read-only; produces JSON, not code."
temperature = 0.4
max_iterations = 8
@@ -1,5 +1,6 @@
id = "researcher"
display_name = "Researcher"
delegate_name = "research"
when_to_use = "Web & docs crawler — reads real documentation, compresses to dense markdown. Use for any task that requires looking up external knowledge."
temperature = 0.4
max_iterations = 8
+49 -7
View File
@@ -453,17 +453,59 @@ impl Agent {
}
}
// Generate the orchestrator's tool set: one tool per skill +
// one tool per archetype (research, run_code, etc.) + spawn_subagent
// as a fallback. These are the only tools the LLM sees in its
// function-calling schema. Sub-agents still access the full `tools`
// registry via ParentExecutionContext.
let orchestrator_tools = tools::orchestrator_tools::collect_orchestrator_tools();
// Generate the orchestrator's delegation tool set from its
// declarative `subagents = [...]` field: one `ArchetypeDelegationTool`
// per named sub-agent, plus one `SkillDelegationTool` per connected
// Composio toolkit when the orchestrator includes a
// `{ skills = "*" }` wildcard. These are the only delegation tools
// the main-agent LLM sees; sub-agents themselves still access the
// full `tools` registry via `ParentExecutionContext` and apply
// their own per-definition whitelist in the subagent runner.
//
// This builder is synchronous and sits on the CLI / REPL code
// path. It does not have access to the async Composio fetcher,
// so we pass an empty slice of connected integrations here — the
// skill-wildcard expansion therefore produces zero delegation
// tools in this path, which is correct behaviour for CLI (the
// CLI user can still reach any toolkit via the generic
// `composio_execute` tool or `spawn_subagent`).
//
// The channel-dispatch path (`channels::runtime::dispatch`) has
// its own tool registry assembly and will populate the
// connected integrations list from the live Composio fetch in a
// later commit (#525/#526 dispatch routing).
let orchestrator_tools =
match crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::global()
{
Some(reg) => match reg.get("orchestrator") {
Some(orch_def) => tools::orchestrator_tools::collect_orchestrator_tools(
orch_def,
reg,
&[],
),
None => {
log::debug!(
"[agent::builder] orchestrator definition not in registry — \
skipping delegation tool synthesis"
);
Vec::new()
}
},
None => {
log::debug!(
"[agent::builder] AgentDefinitionRegistry not initialised — \
skipping delegation tool synthesis"
);
Vec::new()
}
};
let visible: std::collections::HashSet<String> = orchestrator_tools
.iter()
.map(|t| t.name().to_string())
.collect();
// De-duplicate: spawn_subagent is already in the base registry.
// De-duplicate: some synthesised tool names may collide with
// already-registered tools (unlikely for `delegate_*` names but
// cheap to guard against).
let existing_names: std::collections::HashSet<String> =
tools.iter().map(|t| t.name().to_string()).collect();
tools.extend(
+2 -23
View File
@@ -2,6 +2,7 @@ mod archetype_delegation;
mod ask_clarification;
mod complete_onboarding;
mod delegate;
mod skill_delegation;
mod spawn_subagent;
use crate::core::event_bus::{publish_global, DomainEvent};
@@ -10,29 +11,6 @@ use crate::openhuman::agent::harness::fork_context::current_parent;
use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions};
use crate::openhuman::tools::traits::ToolResult;
pub(crate) const ARCHETYPE_TOOLS: &[(&str, &str, &str)] = &[
(
"research",
"researcher",
"Search the web, read docs, and gather information. Returns a dense markdown summary with sources.",
),
(
"run_code",
"code_executor",
"Write, run, debug, and test code in a sandboxed environment. Has shell, file access, and git.",
),
(
"review_code",
"critic",
"Review code changes for quality, security, and correctness. Read-only — returns findings, never edits.",
),
(
"plan",
"planner",
"Break a complex goal into a structured step-by-step plan with dependencies. Use for tasks with 3+ steps.",
),
];
pub(crate) async fn dispatch_subagent(
agent_id: &str,
tool_name: &str,
@@ -122,4 +100,5 @@ pub use archetype_delegation::ArchetypeDelegationTool;
pub use ask_clarification::AskClarificationTool;
pub use complete_onboarding::CompleteOnboardingTool;
pub use delegate::DelegateTool;
pub use skill_delegation::SkillDelegationTool;
pub use spawn_subagent::SpawnSubagentTool;
-1
View File
@@ -8,7 +8,6 @@ pub mod traits;
#[path = "impl/mod.rs"]
mod implementations;
pub(crate) use implementations::agent::ARCHETYPE_TOOLS;
pub use implementations::*;
pub use ops::*;
#[allow(unused_imports)]
-17
View File
@@ -266,23 +266,6 @@ pub fn all_tools_with_runtime(
tools
}
/// Legacy allowlist — no longer the primary source of truth for
/// orchestrator tool visibility. The `from_config` builder now uses
/// `orchestrator_tools::collect_orchestrator_tools()` to generate
/// the visible tool set dynamically. Kept for backward compatibility
/// with callers that reference this constant.
pub const MAIN_AGENT_TOOL_ALLOWLIST: &[&str] = &["spawn_subagent"];
/// Filter a full tool registry down to only the tools the main agent
/// should see. Sub-agents receive the unfiltered registry via
/// `ParentExecutionContext::all_tools` and apply their own per-definition
/// whitelist in the subagent runner.
pub fn main_agent_tools(all: Vec<Box<dyn Tool>>) -> Vec<Box<dyn Tool>> {
all.into_iter()
.filter(|t| MAIN_AGENT_TOOL_ALLOWLIST.contains(&t.name()))
.collect()
}
/// Hardware peripheral tools — always empty (boards removed); config kept for compatibility.
pub async fn create_peripheral_tools(
_config: &crate::openhuman::config::PeripheralsConfig,
+318 -30
View File
@@ -1,46 +1,334 @@
//! Dynamic orchestrator tool generation.
//!
//! Instead of a single `spawn_subagent` mega-tool, this module generates
//! one tool per subagent archetype. The orchestrator's function-calling
//! schema becomes a flat list of well-named tools:
//! The orchestrator agent doesn't directly execute work — it routes it to
//! specialised sub-agents. Rather than exposing a single generic
//! `spawn_subagent(agent_id, prompt)` mega-tool, we synthesise one named
//! tool per entry in the orchestrator's `subagents = [...]` TOML field,
//! so the LLM's function-calling schema contains discoverable, well-named
//! tools like `research`, `plan`, `run_code`, `delegate_gmail`,
//! `delegate_github`, etc.
//!
//! `research`, `run_code`, `review_code`, `plan`
//! Each synthesised tool's description is pulled live from the target
//! agent's [`AgentDefinition::when_to_use`] (for
//! [`SubagentEntry::AgentId`]) or from the connected Composio toolkit
//! metadata (for [`SubagentEntry::Skills`] wildcard expansions) — so
//! descriptions automatically stay in sync with the definitions and
//! never drift from a hardcoded table.
//!
//! Each tool's `execute()` internally calls `run_subagent` with the
//! correct definition. The LLM just picks the right tool by name.
//! Called from [`crate::openhuman::agent::harness::session::builder`] at
//! agent-build time, with the orchestrator's own definition, the global
//! registry (for delegation target lookups), and the current list of
//! connected Composio integrations.
//!
//! [`AgentDefinition::when_to_use`]: crate::openhuman::agent::harness::definition::AgentDefinition::when_to_use
//! [`SubagentEntry::AgentId`]: crate::openhuman::agent::harness::definition::SubagentEntry::AgentId
//! [`SubagentEntry::Skills`]: crate::openhuman::agent::harness::definition::SubagentEntry::Skills
use super::{ArchetypeDelegationTool, SpawnSubagentTool, Tool, ARCHETYPE_TOOLS};
use crate::openhuman::agent::harness::definition::{
AgentDefinition, AgentDefinitionRegistry, SubagentEntry,
};
use crate::openhuman::context::prompt::ConnectedIntegration;
/// Build the orchestrator's tool list: one tool per installed skill +
/// one tool per archetype. Also includes `spawn_subagent` as a fallback
/// for advanced use cases (fork mode, custom agent_ids).
use super::{ArchetypeDelegationTool, SkillDelegationTool, Tool};
/// Synthesise the delegation tool list for an agent based on its
/// declarative `subagents` field.
///
/// Call this at agent build time when the visible-tool filter is active
/// (i.e. the main agent is an orchestrator).
pub fn collect_orchestrator_tools() -> Vec<Box<dyn Tool>> {
/// Each [`SubagentEntry::AgentId`] is resolved against `registry` and
/// rendered as an [`ArchetypeDelegationTool`] whose `name()` defaults to
/// `delegate_{target.id}` (overridable via the target agent's
/// `delegate_name` field) and whose `description()` is the target's
/// `when_to_use` — so editing an agent's TOML description immediately
/// updates the tool schema the orchestrator LLM sees, with zero drift.
///
/// Each [`SubagentEntry::Skills`] wildcard expands to one
/// [`SkillDelegationTool`] per connected Composio integration in
/// `connected_integrations`. The synthesised tool routes to the generic
/// `skills_agent` with `skill_filter = Some("{toolkit_slug}")` pre-set.
///
/// Entries that reference unknown agent ids (not in the registry) are
/// logged at `warn` and skipped — the orchestrator still builds, just
/// without the broken delegation. Entries that reference Skills wildcards
/// with an empty `connected_integrations` slice produce zero tools, which
/// is the correct behaviour when the user has not yet connected any
/// integrations (the LLM should not see phantom `delegate_gmail` tools
/// for unconnected toolkits).
///
/// Returns an empty Vec when `definition.subagents` is empty — callers
/// (notably the builder) handle this by not extending the visible-tool
/// set, so non-delegating agents behave identically to how they did
/// before this module existed.
pub fn collect_orchestrator_tools(
definition: &AgentDefinition,
registry: &AgentDefinitionRegistry,
connected_integrations: &[ConnectedIntegration],
) -> Vec<Box<dyn Tool>> {
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
// ── Archetype-based tools (static) ────────────────────────────────
for (tool_name, agent_id, description) in ARCHETYPE_TOOLS {
log::info!(
"[orchestrator_tools] registering archetype delegation tool: {} -> {}",
tool_name,
agent_id
);
tools.push(Box::new(ArchetypeDelegationTool {
tool_name: tool_name.to_string(),
agent_id: agent_id.to_string(),
tool_description: description.to_string(),
}));
for entry in &definition.subagents {
match entry {
SubagentEntry::AgentId(agent_id) => {
let Some(target) = registry.get(agent_id) else {
log::warn!(
"[orchestrator_tools] subagent '{}' referenced by '{}' is not in the registry — skipping",
agent_id,
definition.id
);
continue;
};
let tool_name = target
.delegate_name
.clone()
.unwrap_or_else(|| format!("delegate_{}", target.id));
log::debug!(
"[orchestrator_tools] registering archetype delegation tool: {} -> {}",
tool_name,
target.id
);
tools.push(Box::new(ArchetypeDelegationTool {
tool_name,
agent_id: target.id.clone(),
tool_description: target.when_to_use.clone(),
}));
}
SubagentEntry::Skills(wildcard) => {
if !wildcard.matches_all() {
log::warn!(
"[orchestrator_tools] subagent skills wildcard '{}' referenced by '{}' is not supported (only \"*\") — skipping",
wildcard.skills,
definition.id
);
continue;
}
for integration in connected_integrations {
// Slug the toolkit name into a tool-name-safe form.
// Composio toolkit slugs are already lowercase / dash-
// separated (e.g. "gmail", "google_calendar"), but
// we guard against surprises so a quirky slug can
// never produce an invalid function-calling schema.
let slug = sanitise_slug(&integration.toolkit);
let tool_name = format!("delegate_{}", slug);
// Prefer the toolkit's own one-line description when
// available; fall back to a generic template so the
// LLM still gets a meaningful tool description even
// on brand-new or poorly-populated toolkits.
let description = if integration.description.trim().is_empty() {
format!(
"Delegate to the skills agent with the `{}` integration pre-selected.",
integration.toolkit
)
} else {
format!(
"Delegate to the skills agent using `{}`. {}",
integration.toolkit, integration.description
)
};
log::debug!(
"[orchestrator_tools] registering skill delegation tool: {} -> skills_agent (skill_filter={})",
tool_name,
slug
);
tools.push(Box::new(SkillDelegationTool {
tool_name,
skill_id: slug,
tool_description: description,
}));
}
}
}
}
// ── spawn_subagent as fallback for advanced use ────────────────────
tools.push(Box::new(SpawnSubagentTool::new()));
log::info!(
"[orchestrator_tools] total orchestrator tools: {}",
tools.len()
"[orchestrator_tools] assembled {} delegation tool(s) for agent '{}' ({} integrations connected)",
tools.len(),
definition.id,
connected_integrations.len()
);
tools
}
/// Produce a tool-name-safe slug from a free-form integration id.
/// Allows ASCII alphanumerics and underscores; everything else becomes
/// an underscore. OpenAI-style function names only accept
/// `[a-zA-Z0-9_-]{1,64}`, so this is the conservative subset.
fn sanitise_slug(raw: &str) -> String {
raw.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::agent::harness::definition::{
DefinitionSource, ModelSpec, PromptSource, SandboxMode, SkillsWildcard, ToolScope,
};
fn def(id: &str, when_to_use: &str, delegate_name: Option<&str>) -> AgentDefinition {
AgentDefinition {
id: id.into(),
when_to_use: when_to_use.into(),
display_name: None,
system_prompt: PromptSource::Inline(String::new()),
omit_identity: true,
omit_memory_context: true,
omit_safety_preamble: true,
omit_skills_catalog: true,
model: ModelSpec::Inherit,
temperature: 0.4,
tools: ToolScope::Wildcard,
disallowed_tools: vec![],
skill_filter: None,
category_filter: None,
max_iterations: 8,
timeout_secs: None,
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: delegate_name.map(String::from),
source: DefinitionSource::Builtin,
}
}
/// A real orchestrator definition that delegates to two named agents
/// (one with an explicit `delegate_name`, one without) plus a skills
/// wildcard. Exercises every branch of `collect_orchestrator_tools`.
fn sample_orchestrator() -> AgentDefinition {
let mut orch = def(
"orchestrator",
"Routes work to the right specialist",
None,
);
orch.subagents = vec![
SubagentEntry::AgentId("researcher".into()),
SubagentEntry::AgentId("archivist".into()),
SubagentEntry::Skills(SkillsWildcard { skills: "*".into() }),
];
orch
}
fn registry_with_targets() -> AgentDefinitionRegistry {
let mut reg = AgentDefinitionRegistry::default();
reg.insert(def(
"researcher",
"Web & docs crawler — reads real documentation",
Some("research"),
));
// `archivist` has no `delegate_name` override — tool name should
// fall back to `delegate_archivist`.
reg.insert(def(
"archivist",
"Background librarian — extracts lessons from a completed session",
None,
));
reg
}
fn integration(toolkit: &str, description: &str) -> ConnectedIntegration {
ConnectedIntegration {
toolkit: toolkit.into(),
description: description.into(),
tools: vec![],
}
}
/// Baseline: an orchestrator with 2 AgentId entries + a Skills
/// wildcard, against a registry that knows both targets and a
/// connected_integrations list with three toolkits, should produce
/// 2 + 3 = 5 delegation tools, each with the expected name and
/// description source.
#[test]
fn collects_agentid_entries_and_expands_skills_wildcard() {
let orch = sample_orchestrator();
let reg = registry_with_targets();
let integrations = vec![
integration("gmail", "Send and read email via Gmail."),
integration("github", "Manage repos, issues, and pull requests."),
integration("notion", "Read and write pages and databases."),
];
let tools = collect_orchestrator_tools(&orch, &reg, &integrations);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert_eq!(
names,
vec![
"research", // researcher's delegate_name override
"delegate_archivist", // archivist has no delegate_name → default
"delegate_gmail",
"delegate_github",
"delegate_notion",
],
"tool names should come from delegate_name overrides, id fallbacks, and sanitised toolkit slugs"
);
// Descriptions should come from when_to_use for archetype tools,
// and from a templated string mentioning the toolkit display name
// for skill tools.
let research_tool = tools.iter().find(|t| t.name() == "research").unwrap();
assert!(research_tool.description().contains("crawler"));
let gmail_tool = tools.iter().find(|t| t.name() == "delegate_gmail").unwrap();
assert!(gmail_tool.description().contains("gmail"));
assert!(gmail_tool.description().contains("email"));
}
/// An orchestrator with a Skills wildcard but no connected
/// integrations should produce zero skill delegation tools — the LLM
/// must not be shown phantom `delegate_*` tools for toolkits that
/// aren't authorised.
#[test]
fn skills_wildcard_with_no_integrations_produces_no_tools() {
let orch = sample_orchestrator();
let reg = registry_with_targets();
let tools = collect_orchestrator_tools(&orch, &reg, &[]);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert_eq!(names, vec!["research", "delegate_archivist"]);
}
/// An AgentId entry that points at an id not present in the registry
/// should be logged and silently skipped, rather than panicking or
/// aborting tool assembly. The orchestrator still builds.
#[test]
fn unknown_subagent_id_is_skipped_not_fatal() {
let mut orch = def("orchestrator", "test", None);
orch.subagents = vec![
SubagentEntry::AgentId("researcher".into()),
SubagentEntry::AgentId("ghost_agent_nope".into()),
];
let reg = registry_with_targets();
let tools = collect_orchestrator_tools(&orch, &reg, &[]);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert_eq!(names, vec!["research"]);
}
/// An empty `subagents` list should produce zero tools — regular
/// non-delegating agents (welcome, code_executor, etc.) reach this
/// path without any subagents and must not pick up stray tools.
#[test]
fn empty_subagents_produces_no_tools() {
let orch = def("welcome", "First agent", None);
let reg = registry_with_targets();
let tools = collect_orchestrator_tools(&orch, &reg, &[]);
assert!(tools.is_empty());
}
/// Toolkit slugs with dashes, spaces, or mixed case should be
/// normalised to `[a-z0-9_]` before being used as part of a function
/// name — the OpenAI tool-calling schema has strict character rules.
#[test]
fn sanitise_slug_lowercases_and_replaces_invalid_chars() {
assert_eq!(sanitise_slug("Gmail"), "gmail");
assert_eq!(sanitise_slug("google-calendar"), "google_calendar");
assert_eq!(sanitise_slug("slack.bot"), "slack_bot");
assert_eq!(sanitise_slug("weird name!"), "weird_name_");
}
}