mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Claude Opus 4.6
parent
8f92155b4d
commit
d1c577d25a
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ®, &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, ®, &[]);
|
||||
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, ®, &[]);
|
||||
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, ®, &[]);
|
||||
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_");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user