From 5883dfea6e44bb75046f5f2ae14ba0df0f974cb8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:24:26 +0300 Subject: [PATCH 01/56] refactor(orchestrator-tools): replace collapsed delegation tool with deferred integration actions Remove the `SkillDelegationTool` that collapsed all connected integrations into a single `delegate_to_integrations_agent` tool, keeping only the per-action `Deferred` tools that are already emitted alongside it. The collapsed tool routed through a sub-agent, which added a blocking agentic round-trip and a second prompt for work the parent could do in one call. With the deferred actions now searchable through the harness's `tool_search` bridge, the delegation layer is unnecessary overhead. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tools/orchestrator_tools.rs | 167 ++++-------------- 1 file changed, 35 insertions(+), 132 deletions(-) diff --git a/crates/openhuman-core/src/tools/orchestrator_tools.rs b/crates/openhuman-core/src/tools/orchestrator_tools.rs index 19d4934a52..8ff32e07de 100644 --- a/crates/openhuman-core/src/tools/orchestrator_tools.rs +++ b/crates/openhuman-core/src/tools/orchestrator_tools.rs @@ -8,18 +8,20 @@ //! contains discoverable, well-named tools like `research`, `plan`, //! `run_code`, etc. //! -//! For [`SubagentEntry::Skills`] wildcard expansions (#1335) we synthesise -//! a single collapsed `delegate_to_integrations_agent` tool that takes the -//! toolkit slug as an argument — keeping the orchestrator's schema cost -//! constant in the integration dimension instead of scaling with the -//! number of connected toolkits. +//! For [`SubagentEntry::Skills`] wildcard expansions we synthesise one +//! `ToolExposure::Deferred` [`ComposioActionTool`] per action of every +//! connected Composio toolkit. Those never reach the wire: a belt that opted +//! into discovery (`tool_search` in its `[tools] named`) finds them through +//! the harness's `tool_search` bridge and calls them directly, so "send this +//! email" is one search and one call. There is no `delegate_to_integrations_ +//! agent` any more — routing a single action through a sub-agent spawn cost a +//! blocking agentic round-trip and a second prompt for work the parent could +//! do in one call. //! -//! 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 synthesised delegation tool's description is pulled live from the +//! target agent's [`AgentDefinition::when_to_use`] — so descriptions +//! automatically stay in sync with the definitions and never drift from a +//! hardcoded table. //! //! Called from [`crate::agent::session_host::builder`] at //! agent-build time, with the orchestrator's own definition, the global @@ -38,7 +40,7 @@ use crate::integrations::composio::ComposioActionTool; // temporarily disabled (see tinyhumansai/openhuman#1624). #[allow(unused_imports)] use super::SpawnWorkerThreadTool; -use super::{ArchetypeDelegationTool, SkillDelegationTool}; +use super::ArchetypeDelegationTool; use crate::agent::orchestration::tools::DelegationTarget; use tinytools::Tool; @@ -52,23 +54,17 @@ use tinytools::Tool; /// `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 a single -/// collapsed [`SkillDelegationTool`] named -/// `delegate_to_integrations_agent` whose `toolkit` argument selects -/// among the slugs of every connected Composio integration in -/// `connected_integrations`. The tool routes to the generic -/// `integrations_agent` with the chosen toolkit's slug passed as -/// `skill_filter`. The collapsed form keeps the orchestrator's -/// function-calling schema constant in the integration dimension -/// (#1335). +/// Each [`SubagentEntry::Skills`] wildcard expands to the connected +/// integrations' actions as `Deferred` tools +/// ([`collect_deferred_integration_actions`]): off the wire, reachable +/// through the harness's `tool_search`, and callable directly by the agent +/// that found them. No delegation tool is synthesised for the wildcard. /// /// 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 a `delegate_to_integrations_agent` -/// tool with an empty enum). +/// without the broken delegation. A Skills wildcard with an empty +/// `connected_integrations` slice produces zero tools, which is the correct +/// behaviour when the user has not yet connected any integrations. /// /// Returns an empty Vec when `definition.subagents` is empty — callers /// (notably the builder) handle this by not extending the visible-tool @@ -147,109 +143,17 @@ pub fn collect_orchestrator_tools( ); continue; } - // Collapsed delegation tool (#1335). Previously this loop - // emitted one `delegate_` tool per connected - // integration. Every one of those tools dispatched to the - // same `integrations_agent` with a different `skill_filter`, - // so the fan-out cost the orchestrator schema bytes without - // buying any new routing capability. We now emit at most - // one `delegate_to_integrations_agent` tool that takes the - // toolkit slug as an argument; the description enumerates - // the connected toolkits so the orchestrator still - // discovers which integrations are routable. - // `sanitise_slug` is lossy — `Slack.Bot` and `Slack-Bot` - // both collapse to `slack_bot`. Once the raw id is - // discarded, one upstream integration would silently - // shadow the other. Detect the collision here, drop - // every duplicate after the first, and warn so routing - // stays unambiguous (the first arrival keeps the slug; - // later arrivals are unreachable through this enum and - // safer to omit than silently re-target). - let mut connected: Vec<(String, String)> = Vec::new(); - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - for integration in connected_integrations { - if !integration.connected { - log::debug!( - "[orchestrator_tools] skipping unconnected integration: {}", - integration.toolkit - ); - continue; - } - // Slug the toolkit name into a tool-name-safe - // (and argument-safe) form so the LLM-facing - // enum stays predictable across odd toolkit - // names (dashes, dots, spaces, mixed case). - let slug = sanitise_slug(&integration.toolkit); - if !seen.insert(slug.clone()) { - log::warn!( - "[orchestrator_tools] duplicate sanitised slug '{slug}' from raw \ - toolkit '{raw}' — dropping to keep collapsed delegation routing \ - unambiguous", - raw = integration.toolkit - ); - continue; - } - // Empty integration descriptions otherwise render as a - // bare ` - slug` line in the collapsed tool description, - // which gives the orchestrator LLM no hint about what - // the toolkit actually does. Fall back to the - // generic per-toolkit phrasing the old fan-out path - // used so brand-new or under-populated toolkits stay - // informative. - let description = if integration.description.trim().is_empty() { - format!( - "External integration via {} — see the toolkit docs for available actions.", - integration.toolkit - ) - } else { - integration.description.clone() - }; - connected.push((slug, description)); - } - // Order the enum by slug, because the order it arrives in is - // not a contract and the order it is *advertised* in is. - // - // This tool's schema and description both enumerate the - // toolkits, and the tool block is rendered ahead of the - // conversation in every provider's cached prefix — so a - // backend that returns the same integrations in a different - // order would otherwise re-write the schema, and with it - // invalidate the whole prefix including the system prompt the - // turn loop freezes for exactly that reason. The rest of the - // pipeline already treats order as meaningless: - // `connected_set_hash` sorts before hashing, which is what - // stops a reordering from reaching a reconcile at the turn - // boundary in the first place. Sorting here makes the - // advertised surface agree with that view instead of - // contradicting it a layer down. - // - // Sorting AFTER the dedup loop, never before: the collision - // rule above is "the first arrival keeps the slug", which is a - // statement about arrival order and would change meaning if the - // list were sorted first. - connected.sort_by(|(a, _), (b, _)| a.cmp(b)); - match SkillDelegationTool::for_connected(connected) { - Some(tool) => { - log::debug!( - "[orchestrator_tools] registering collapsed integrations delegation tool ({} toolkits)", - tool.connected_toolkits.len() - ); - tools.push(Box::new(tool)); - } - None => { - log::debug!( - "[orchestrator_tools] no connected integrations — collapsed delegation tool omitted" - ); - } - } - // The same toolkits' actions, one `Deferred` tool each. Never - // on the wire: a belt that opted into discovery reaches them - // through the harness's `tool_search`, so one clear action is - // a search and a call rather than an `integrations_agent` - // run. A belt that did not opt in never sees them — the - // session builder leaves them prompt-hidden, which the - // direct-call gate refuses. Approval and channel permission - // apply per call exactly as on the sub-agent path. + // The connected toolkits' actions, one `Deferred` tool each. + // Never on the wire: a belt that opted into discovery reaches + // them through the harness's `tool_search`, so one clear + // action is a search and a call. A belt that did not opt in + // never sees them — the session builder leaves them + // prompt-hidden, which the direct-call gate refuses. Approval + // and channel permission apply per call. This used to sit + // beside a collapsed `delegate_to_integrations_agent` tool + // that spawned `integrations_agent` per toolkit; with the + // actions searchable that spawn only added a blocking + // sub-agent round-trip, so the delegation tool is gone. let actions = collect_deferred_integration_actions(connected_integrations); if !actions.is_empty() { log::debug!( @@ -315,9 +219,8 @@ pub fn collect_deferred_integration_actions( /// an underscore. OpenAI-style function names only accept /// `[a-zA-Z0-9_-]{1,64}`, so this is the conservative subset. /// -/// Used both when synthesising `delegate_*` tools and when rendering the -/// delegation guide in prompts — they must agree on slug canonicalisation -/// so the prompt always references a tool name that actually exists. +/// Used when rendering integration slugs in prompts so the prompt and any +/// argument-facing enum agree on slug canonicalisation. pub(crate) fn sanitise_slug(raw: &str) -> String { raw.chars() .map(|c| { From 3fd5aa6ae9d6045f3ba5167571a2d88f669d010d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:24:33 +0300 Subject: [PATCH 02/56] fix(orchestrator): remove stale reference to sanitise_slug in doc comment The doc comment for `collect_deferred_integration_actions` still mentioned a comparison with `sanitise_slug` collisions that no longer exists in the code, making the comment misleading. The reference has been removed to keep the documentation accurate. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/tools/orchestrator_tools.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/tools/orchestrator_tools.rs b/crates/openhuman-core/src/tools/orchestrator_tools.rs index 8ff32e07de..4288f14508 100644 --- a/crates/openhuman-core/src/tools/orchestrator_tools.rs +++ b/crates/openhuman-core/src/tools/orchestrator_tools.rs @@ -184,7 +184,7 @@ pub fn collect_orchestrator_tools( /// Gated actions are left out: the model cannot call them and the prompt's /// Connected Integrations section already explains how to unlock them. /// A collision on an action slug across two toolkits keeps the first -/// arrival, like `sanitise_slug` collisions above. +/// arrival. pub fn collect_deferred_integration_actions( connected_integrations: &[ConnectedIntegration], ) -> Vec> { From 6cb0fd033fe936cee189334b32c21458a319af28 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:24:41 +0300 Subject: [PATCH 03/56] refactor(orchestration): remove collapsed skill-delegation tool The single `delegate_to_integrations_agent` tool and its test module have been removed. This collapsed delegation tool was introduced to replace the per-toolkit fan-out of delegate tools, but the approach is no longer needed as the integrations agent is now invoked through a different routing mechanism. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../orchestration/tools/skill_delegation.rs | 383 ------------------ .../tools/skill_delegation_tests.rs | 174 -------- 2 files changed, 557 deletions(-) delete mode 100644 crates/openhuman-core/src/agent/orchestration/tools/skill_delegation.rs delete mode 100644 crates/openhuman-core/src/agent/orchestration/tools/skill_delegation_tests.rs diff --git a/crates/openhuman-core/src/agent/orchestration/tools/skill_delegation.rs b/crates/openhuman-core/src/agent/orchestration/tools/skill_delegation.rs deleted file mode 100644 index 525639ec1d..0000000000 --- a/crates/openhuman-core/src/agent/orchestration/tools/skill_delegation.rs +++ /dev/null @@ -1,383 +0,0 @@ -//! Single collapsed delegation tool for Composio-backed integrations -//! (#1335). -//! -//! Replaces the previous per-toolkit fan-out where the orchestrator's -//! function-calling schema gained a new `delegate_` entry for -//! every connected integration. Every one of those tools dispatched to -//! the same `integrations_agent` with a different `skill_filter`, so -//! exposing them separately bloated the orchestrator's tool list -//! linearly with no behavioural benefit. -//! -//! The collapsed tool keeps the routing handle the orchestrator needs -//! ("send this to integrations, scoped to toolkit X") while making the -//! orchestrator's schema cost constant in the integration dimension. -//! -//! The list of connected toolkits is rendered inline in the tool -//! description so the orchestrator still discovers which integrations -//! are available without each one being its own schema entry. - -use async_trait::async_trait; -use serde_json::json; - -use crate::tools::orchestrator_tools::sanitise_slug; -use tinytools::ToolRunContext; -use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolCategory, ToolResult}; - -/// Canonical tool name surfaced to the orchestrator LLM. -pub const INTEGRATIONS_DELEGATE_TOOL_NAME: &str = "delegate_to_integrations_agent"; - -/// Single collapsed delegation tool for all connected Composio toolkits. -/// -/// Carries the slugs + one-line descriptions of every connected toolkit -/// so the tool's `description()` (which is what the orchestrator's LLM -/// sees) enumerates the routing choices without needing N tools to -/// represent them. -pub struct SkillDelegationTool { - pub tool_name: String, - /// `(slug, description)` for every currently-connected toolkit. - /// `slug` is already `sanitise_slug`'d so it can be matched against - /// the LLM-provided `toolkit` argument with a plain `==`. - pub connected_toolkits: Vec<(String, String)>, - pub tool_description: String, -} - -impl SkillDelegationTool { - /// Build the canonical collapsed tool from the connected-toolkit - /// list. Returns `None` when there are zero connected toolkits — - /// callers in `collect_orchestrator_tools` interpret that as "don't - /// expose any integrations delegation surface at all", which is the - /// right thing to do because the orchestrator can't usefully route - /// to an empty set. - pub fn for_connected(connected: Vec<(String, String)>) -> Option { - if connected.is_empty() { - return None; - } - let description = build_description(&connected); - Some(Self { - tool_name: INTEGRATIONS_DELEGATE_TOOL_NAME.to_string(), - connected_toolkits: connected, - tool_description: description, - }) - } -} - -fn build_description(connected: &[(String, String)]) -> String { - // One sentence of routing plus one short line per connected service. The - // catalogue blurbs are marketing copy ("Gmail is Google's email service, - // featuring spam protection, ...") and were costing a paragraph per - // service on every turn; a clause is enough to disambiguate a slug. - const DESCRIPTION_MAX_CHARS: usize = 80; - let mut buf = String::from( - "Act on a connected service (read or write its data) through the integrations \ - agent: `toolkit` is one of the connected slugs below, `prompt` the user's task. \ - Connected:", - ); - for (slug, desc) in connected { - buf.push_str("\n - "); - buf.push_str(slug); - let trimmed = desc.trim(); - if !trimmed.is_empty() { - buf.push_str(": "); - if trimmed.chars().count() > DESCRIPTION_MAX_CHARS { - let cut: String = trimmed.chars().take(DESCRIPTION_MAX_CHARS).collect(); - let cut = cut.rsplit_once(' ').map_or(cut.as_str(), |(head, _)| head); - buf.push_str(cut.trim_end_matches([',', ';', ':'])); - buf.push_str("..."); - } else { - buf.push_str(trimmed); - } - } - } - buf -} - -// Test-only override for the live status fetch. When set, the live re-check -// returns this value instead of touching `Config::load_or_init` / -// `fetch_connected_integrations_status`, which would otherwise read the host -// machine's login/config state and could hit the Composio backend over HTTP. -// `Some(None)` forces the "Unavailable" outcome (no live data); -// `Some(Some(vec))` injects a deterministic connected set. -#[cfg(test)] -thread_local! { - static LIVE_FETCH_OVERRIDE: std::cell::RefCell>>> = - const { std::cell::RefCell::new(None) }; -} - -#[cfg(test)] -fn set_live_fetch_override(value: Option>) { - LIVE_FETCH_OVERRIDE.with(|o| *o.borrow_mut() = Some(value)); -} - -#[cfg(test)] -fn clear_live_fetch_override() { - LIVE_FETCH_OVERRIDE.with(|o| *o.borrow_mut() = None); -} - -async fn fetch_live_connected_toolkit_slugs_once() -> Option> { - #[cfg(test)] - { - if let Some(injected) = LIVE_FETCH_OVERRIDE.with(|o| o.borrow().clone()) { - return injected; - } - } - let config = crate::config::Config::load_or_init().await.ok()?; - match crate::integrations::composio::fetch_connected_integrations_status(&config).await { - crate::integrations::composio::FetchConnectedIntegrationsStatus::Authoritative(entries) => { - let mut toolkits: Vec = entries - .into_iter() - .filter(|entry| entry.connected) - .map(|entry| sanitise_slug(&entry.toolkit)) - .collect(); - toolkits.sort(); - toolkits.dedup(); - Some(toolkits) - } - crate::integrations::composio::FetchConnectedIntegrationsStatus::Unavailable => None, - } -} - -fn resolve_connected_toolkits( - snapshot: &[(String, String)], - slug: &str, - live_connected: Option<&[String]>, -) -> (bool, Vec) { - let allowed: Vec = snapshot.iter().map(|(slug, _)| slug.clone()).collect(); - if snapshot.iter().any(|(known_slug, _)| known_slug == slug) { - return (true, allowed); - } - if let Some(live) = live_connected { - if live.iter().any(|s| s == slug) { - return (true, live.to_vec()); - } - } - (false, allowed) -} - -#[async_trait] -impl Tool for SkillDelegationTool { - fn name(&self) -> &str { - &self.tool_name - } - - fn description(&self) -> &str { - &self.tool_description - } - - fn parameters_schema(&self) -> serde_json::Value { - let slugs: Vec<&str> = self - .connected_toolkits - .iter() - .map(|(slug, _)| slug.as_str()) - .collect(); - json!({ - "type": "object", - "required": ["toolkit", "prompt"], - "properties": { - "toolkit": { - "type": "string", - "enum": slugs, - "description": "Composio toolkit slug to route to (e.g. `gmail`, `notion`). \ - Must match one of the connected toolkits enumerated in this tool's description." - }, - // `prompt` and `model` are described once in the parent's - // prompt.md ("Structured handoffs") rather than here, matching - // `ArchetypeDelegationTool`. `toolkit` keeps its description: - // it is the routing signal and points at the slugs enumerated - // in this tool's own description. - "prompt": { "type": "string" }, - "model": { - "type": "string", - "description": "Pin the child to this exact model id. Omit unless you have a reason." - } - } - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Execute - } - - fn category(&self) -> ToolCategory { - ToolCategory::System - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.execute_with_context(args, ToolCallOptions::default(), None) - .await - } - - async fn execute_with_context( - &self, - args: serde_json::Value, - _options: ToolCallOptions, - tool_context: Option<&dyn ToolRunContext>, - ) -> anyhow::Result { - let mut run_context = crate::agent::tinyagents::host::OpenHumanRunContext::new(); - run_context.thread_id = tool_context - .and_then(ToolRunContext::thread_id) - .map(ToOwned::to_owned); - execute_skill_delegation( - &self.tool_name, - &self.connected_toolkits, - args, - tool_context, - run_context, - ) - .await - } -} - -/// Execute an integration hand-off with an explicit child run carrier. -pub(crate) async fn execute_skill_delegation( - tool_name: &str, - connected_toolkits: &[(String, String)], - args: serde_json::Value, - tool_context: Option<&dyn ToolRunContext>, - run_context: crate::agent::tinyagents::host::OpenHumanRunContext, -) -> anyhow::Result { - if let Some(live_parent) = super::ambient_parent_run_context("direct-skill-delegation") { - let run_context = live_parent.data.child(); - return execute_skill_delegation_with_live_parent( - tool_name, - connected_toolkits, - args, - tool_context, - run_context, - Some(&live_parent), - ) - .await; - } - execute_skill_delegation_with_live_parent( - tool_name, - connected_toolkits, - args, - tool_context, - run_context, - None, - ) - .await -} - -/// Typed-harness counterpart that preserves a live parent for the blocking -/// integrations child. -pub(crate) async fn execute_skill_delegation_with_live_parent( - tool_name: &str, - connected_toolkits: &[(String, String)], - args: serde_json::Value, - tool_context: Option<&dyn ToolRunContext>, - run_context: crate::agent::tinyagents::host::OpenHumanRunContext, - live_parent: Option< - &tinyagents_harness::context::RunContext< - crate::agent::tinyagents::host::OpenHumanRunContext, - >, - >, -) -> anyhow::Result { - let raw_toolkit = args - .get("toolkit") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - log::debug!( - "[skill-delegation] execute start tool='{}' raw_toolkit={:?} prompt_chars={}", - tool_name, - raw_toolkit, - args.get("prompt") - .and_then(|v| v.as_str()) - .map(|s| s.chars().count()) - .unwrap_or(0) - ); - if raw_toolkit.is_empty() { - log::debug!( - "[skill-delegation] reject: missing `toolkit` argument for tool='{}'", - tool_name - ); - return Ok(ToolResult::error(format!( - "{}: `toolkit` is required and must match a connected integration slug", - tool_name - ))); - } - let slug = sanitise_slug(&raw_toolkit); - let mut live_connected: Option> = None; - let mut known = connected_toolkits - .iter() - .any(|(known_slug, _)| known_slug == &slug); - if !known { - // Safety net for same-thread OAuth races: do one live status - // refresh before rejecting an unknown toolkit, mirroring the - // spawn_subagent integrations pre-flight. - live_connected = fetch_live_connected_toolkit_slugs_once().await; - } - let (known_after_recheck, allowed) = - resolve_connected_toolkits(connected_toolkits, &slug, live_connected.as_deref()); - if known_after_recheck && !known { - log::info!( - "[skill-delegation] toolkit '{}' accepted after live re-check (session schema stale)", - slug - ); - } - known = known_after_recheck; - if !known { - log::debug!( - "[skill-delegation] reject: toolkit '{}' (sanitised='{}') not in connected set {:?}", - raw_toolkit, - slug, - allowed - ); - return Ok(ToolResult::error(format!( - "{}: toolkit `{raw_toolkit}` is not connected — allowed: [{}]", - tool_name, - allowed.join(", ") - ))); - } - - let prompt = args - .get("prompt") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if prompt.is_empty() { - log::debug!( - "[skill-delegation] reject: empty `prompt` for tool='{}' toolkit='{}'", - tool_name, - slug - ); - return Ok(ToolResult::error(format!( - "{}: `prompt` is required", - tool_name - ))); - } - - let model_override = args - .get("model") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()); - - log::debug!( - "[skill-delegation] dispatching toolkit='{}' to integrations_agent (prompt_chars={})", - slug, - prompt.chars().count() - ); - // Integration delegations stay blocking: their outcomes (send the - // email, create the page, …) are usually approval-gated mid-turn and - // the orchestrator's reply reports the concrete result. The durable - // async default applies to archetype delegations only for now. - super::dispatch::dispatch_subagent_with_live_parent( - "integrations_agent", - tool_name, - &prompt, - Some(&slug), - model_override, - tool_context, - super::dispatch::DispatchMode::Blocking, - run_context, - live_parent, - ) - .await -} - -#[cfg(test)] -#[path = "skill_delegation_tests.rs"] -mod tests; diff --git a/crates/openhuman-core/src/agent/orchestration/tools/skill_delegation_tests.rs b/crates/openhuman-core/src/agent/orchestration/tools/skill_delegation_tests.rs deleted file mode 100644 index 14fcd14746..0000000000 --- a/crates/openhuman-core/src/agent/orchestration/tools/skill_delegation_tests.rs +++ /dev/null @@ -1,174 +0,0 @@ -use super::*; - -#[test] -fn for_connected_returns_none_when_no_toolkits() { - assert!(SkillDelegationTool::for_connected(vec![]).is_none()); -} - -#[test] -fn for_connected_uses_canonical_tool_name() { - let tool = SkillDelegationTool::for_connected(vec![( - "gmail".to_string(), - "Email access.".to_string(), - )]) - .unwrap(); - assert_eq!(tool.name(), INTEGRATIONS_DELEGATE_TOOL_NAME); - assert_eq!(tool.name(), "delegate_to_integrations_agent"); -} - -#[test] -fn description_enumerates_connected_toolkits() { - let tool = SkillDelegationTool::for_connected(vec![ - ("gmail".to_string(), "Email access.".to_string()), - ("notion".to_string(), "Pages and databases.".to_string()), - ]) - .unwrap(); - let desc = tool.description(); - assert!(desc.contains("gmail")); - assert!(desc.contains("notion")); - assert!(desc.contains("Email access.")); - assert!(desc.contains("Pages and databases.")); -} - -#[test] -fn parameters_schema_enforces_toolkit_enum_against_connected_slugs() { - let tool = SkillDelegationTool::for_connected(vec![ - ("gmail".to_string(), "Email.".to_string()), - ("notion".to_string(), "Docs.".to_string()), - ]) - .unwrap(); - let schema = tool.parameters_schema(); - let enum_vals = schema["properties"]["toolkit"]["enum"] - .as_array() - .expect("toolkit enum is an array"); - let collected: Vec<&str> = enum_vals.iter().map(|v| v.as_str().unwrap()).collect(); - assert_eq!(collected, vec!["gmail", "notion"]); - - let required = schema["required"].as_array().expect("required is an array"); - let required: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); - assert!(required.contains(&"toolkit")); - assert!(required.contains(&"prompt")); -} - -#[tokio::test] -async fn execute_rejects_missing_toolkit_argument() { - let tool = - SkillDelegationTool::for_connected(vec![("gmail".to_string(), "Email.".to_string())]) - .unwrap(); - let result = tool.execute(json!({"prompt": "x"})).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("toolkit")); -} - -#[tokio::test] -async fn execute_rejects_unknown_toolkit_with_allowed_list() { - // Force the live re-check to return "Unavailable" so the test never - // reads host config or reaches the Composio backend — the reject must - // come purely from the in-memory snapshot (gmail/notion, no slack). - set_live_fetch_override(None); - let tool = SkillDelegationTool::for_connected(vec![ - ("gmail".to_string(), "Email.".to_string()), - ("notion".to_string(), "Docs.".to_string()), - ]) - .unwrap(); - let result = tool - .execute(json!({"toolkit": "slack", "prompt": "hi"})) - .await - .unwrap(); - clear_live_fetch_override(); - assert!(result.is_error); - let body = result.output(); - assert!(body.contains("slack")); - assert!(body.contains("gmail")); - assert!(body.contains("notion")); -} - -#[tokio::test] -async fn execute_rejects_empty_prompt() { - let tool = - SkillDelegationTool::for_connected(vec![("gmail".to_string(), "Email.".to_string())]) - .unwrap(); - let result = tool - .execute(json!({"toolkit": "gmail", "prompt": " "})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("prompt")); -} - -#[tokio::test] -async fn execute_normalises_toolkit_input_before_matching() { - // Mixed-case + odd-character user input must collapse onto the - // canonical slug before the connectedness check fires. - // Pin the live re-check to the same snapshot so the test is hermetic - // (no host config / backend read): `gmail` stays unknown, while the - // normalised `google_calendar` is accepted. - set_live_fetch_override(Some(vec!["google_calendar".to_string()])); - let tool = SkillDelegationTool::for_connected(vec![( - "google_calendar".to_string(), - "Calendar.".to_string(), - )]) - .unwrap(); - // "GMail" sanitises to `gmail` — NOT in the connected set, so it - // must be rejected with the unknown-toolkit message that - // enumerates the allowed slugs. - let bad = tool - .execute(json!({"toolkit": "GMail", "prompt": "x"})) - .await - .unwrap(); - assert!(bad.is_error); - let bad_body = bad.output(); - assert!( - bad_body.contains("not connected"), - "expected unknown-toolkit error path, got: {bad_body}" - ); - assert!(bad_body.contains("google_calendar")); - - // "Google-Calendar" sanitises to `google_calendar`, which IS in - // the connected set, so the toolkit gate must let it through. - // Dispatch will then fail because no agent registry is wired up - // in this unit-test process — but the error must NOT be the - // unknown-toolkit branch, because that branch was supposed to - // be bypassed by the slug normalisation. - let ok = tool - .execute(json!({"toolkit": "Google-Calendar", "prompt": "do thing"})) - .await; - match ok { - Ok(result) => { - let body = result.output(); - assert!( - !body.contains("not connected"), - "normalised slug should pass the toolkit gate, got: {body}" - ); - } - Err(err) => { - let msg = err.to_string(); - assert!( - !msg.contains("not connected"), - "normalised slug should pass the toolkit gate, got: {msg}" - ); - } - } - clear_live_fetch_override(); -} - -#[test] -fn resolve_connected_toolkits_prefers_live_recheck_for_unknown_slug() { - let snapshot = vec![("gmail".to_string(), "Email".to_string())]; - - let (known_snapshot, allowed_snapshot) = resolve_connected_toolkits(&snapshot, "gmail", None); - assert!(known_snapshot); - assert_eq!(allowed_snapshot, vec!["gmail".to_string()]); - - let live = vec!["gmail".to_string(), "notion".to_string()]; - let (known_live, allowed_live) = - resolve_connected_toolkits(&snapshot, "notion", Some(live.as_slice())); - assert!(known_live); - assert_eq!(allowed_live, live); - - let live_no_match = vec!["gmail".to_string(), "notion".to_string()]; - let (known_none, allowed_none) = - resolve_connected_toolkits(&snapshot, "slack", Some(live_no_match.as_slice())); - assert!(!known_none); - assert_eq!(allowed_none, vec!["gmail".to_string()]); -} From 09729c437554348cc9f597f1981e0f8d5f461597 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:24:57 +0300 Subject: [PATCH 04/56] refactor(orchestration): remove unused skill delegation tool Remove the `SkillDelegationTool` and its associated `INTEGRATIONS_DELEGATE_TOOL_NAME` constant, along with the `Integrations` variant in the dispatch enum and its execution path. This tool was no longer used after the delegation system was consolidated into the collapsed delegation approach, which handles toolkit scoping through a `toolkit_override` parameter instead of a separate skill-based filter. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/orchestration/tools.rs | 6 +-- .../src/agent/orchestration/tools/dispatch.rs | 45 +++---------------- 2 files changed, 6 insertions(+), 45 deletions(-) diff --git a/crates/openhuman-core/src/agent/orchestration/tools.rs b/crates/openhuman-core/src/agent/orchestration/tools.rs index a69e29af82..a5cad62ba5 100644 --- a/crates/openhuman-core/src/agent/orchestration/tools.rs +++ b/crates/openhuman-core/src/agent/orchestration/tools.rs @@ -8,8 +8,7 @@ //! - **Control**: `steer_subagent`, `continue_subagent`, `close_subagent`, //! `wait_subagent`, `wait` / `wait_loop`, `list_subagents`. //! - **Delegation**: `DelegateGraphTool`, `ArchetypeDelegationTool`, -//! `SkillDelegationTool`, `CollapsedDelegationTool` (`delegate_to`), and -//! `agent_prepare_context`. +//! `CollapsedDelegationTool` (`delegate_to`), and `agent_prepare_context`. //! //! `dispatch.rs`, `awaiting_user.rs`, and `worker_thread.rs` are `pub(crate)` //! helpers shared by the tools above (the common spawn path, the awaiting-user @@ -39,8 +38,6 @@ mod delegate_graph; mod dispatch; #[path = "tools/list_subagents.rs"] mod list_subagents; -#[path = "tools/skill_delegation.rs"] -mod skill_delegation; #[path = "tools/spawn_async_subagent.rs"] mod spawn_async_subagent; #[path = "tools/spawn_parallel_agents.rs"] @@ -94,7 +91,6 @@ pub(crate) use delegate_graph::DelegateGraphDispatch; pub use delegate_graph::DelegateGraphTool; pub(crate) use list_subagents::ListSubagentsDispatch; pub use list_subagents::ListSubagentsTool; -pub use skill_delegation::{SkillDelegationTool, INTEGRATIONS_DELEGATE_TOOL_NAME}; pub(crate) use spawn_async_subagent::SpawnAsyncSubagentDispatch; pub use spawn_async_subagent::{scope_spawn_async_subagent_spec, SpawnAsyncSubagentTool}; pub(crate) use spawn_parallel_agents::SpawnParallelAgentsDispatch; diff --git a/crates/openhuman-core/src/agent/orchestration/tools/dispatch.rs b/crates/openhuman-core/src/agent/orchestration/tools/dispatch.rs index 354d72d84d..0757411768 100644 --- a/crates/openhuman-core/src/agent/orchestration/tools/dispatch.rs +++ b/crates/openhuman-core/src/agent/orchestration/tools/dispatch.rs @@ -30,9 +30,6 @@ enum DelegationDispatchKind { Collapsed { targets: Result, String>, }, - Integrations { - connected_toolkits: Vec, - }, Archetype, } @@ -46,17 +43,6 @@ impl DelegationDispatch { ), } } - super::skill_delegation::INTEGRATIONS_DELEGATE_TOOL_NAME => { - let connected_toolkits = tool - .parameters_schema() - .pointer("/properties/toolkit/enum") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(|value| value.as_str().map(str::to_owned)) - .collect(); - DelegationDispatchKind::Integrations { connected_toolkits } - } // Only synthesized archetype delegate names enter this path. // `delegate_graph` is a concrete durable graph tool with its own // typed dispatcher, and arbitrary `delegate_*` tools must not be @@ -114,22 +100,6 @@ impl ToolDispatch<(), crate::agent::tinyagents::host::OpenHumanRunContext> for D ) .await } - DelegationDispatchKind::Integrations { connected_toolkits } => { - let connected_toolkits: Vec<(String, String)> = connected_toolkits - .iter() - .cloned() - .map(|slug| (slug, String::new())) - .collect(); - super::skill_delegation::execute_skill_delegation_with_live_parent( - self.tool.name(), - &connected_toolkits, - arguments, - Some(&tool_context), - child, - Some(parent), - ) - .await - } DelegationDispatchKind::Archetype => { let Some(agent_id) = AgentDefinitionRegistry::global().and_then(|registry| { registry.list().into_iter().find_map(|definition| { @@ -418,16 +388,11 @@ pub(crate) async fn dispatch_subagent_with_live_parent( prompt.chars().count() ); - // Propagate the per-call toolkit scope into the subagent runner so - // that the collapsed `SkillDelegationTool` can narrow - // `integrations_agent` to a single Composio toolkit (e.g. - // `delegate_to_integrations_agent { toolkit: "gmail" }` → - // integrations_agent + toolkit="gmail"). Earlier code plumbed this through - // `skill_filter_override` (which matches `{skill}__` QuickJS-style - // names), but Composio actions are named `GMAIL_*` / `NOTION_*` — - // so the filter excluded every Composio tool instead of narrowing - // them. `toolkit_override` applies the correct `{TOOLKIT}_` prefix - // check, restricted to skill-category tools. + // Propagate a per-call toolkit scope into the subagent runner as + // `toolkit_override` (the `{TOOLKIT}_` prefix check on skill-category + // tools), never as `skill_filter_override` (which matches `{skill}__` + // QuickJS-style names and would exclude every Composio action). The + // delegation tools synthesised today all pass `None` here. let worktree_action_dir = parent_workspace_descriptor .as_ref() .map(|descriptor| descriptor.root.clone()); From 1afd2a07ee1c5dbcea32fe898216188003bb39ad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:25:35 +0300 Subject: [PATCH 05/56] refactor(agent): consolidate allowed subagent id logic into AgentDefinition Extract the repeated inline logic for computing allowed subagent ids into a dedicated method on AgentDefinition, removing the special case that mapped the skills wildcard to the integrations agent. The skills entry no longer spawns a sub-agent; its tools are now searched and called directly by the agent. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../harness/definition/agent_definition.rs | 28 +++++++++++++++---- .../src/agent/harness/definition/subagents.rs | 10 +++---- .../src/agent/session_host/builder/mod.rs | 12 +------- .../src/agent/session_host/runtime_session.rs | 18 +----------- .../src/agent/session_host/turn/tools.rs | 18 +----------- 5 files changed, 30 insertions(+), 56 deletions(-) diff --git a/crates/openhuman-core/src/agent/harness/definition/agent_definition.rs b/crates/openhuman-core/src/agent/harness/definition/agent_definition.rs index 21275ba78c..8985cb991c 100644 --- a/crates/openhuman-core/src/agent/harness/definition/agent_definition.rs +++ b/crates/openhuman-core/src/agent/harness/definition/agent_definition.rs @@ -208,18 +208,16 @@ pub struct AgentDefinition { /// agent's `delegate_name` override) and whose description is the /// target agent's [`AgentDefinition::when_to_use`]. /// - /// * [`SubagentEntry::Skills`] — a single collapsed - /// [`SkillDelegationTool`] named `delegate_to_integrations_agent` - /// that takes the toolkit slug as an argument and routes to the - /// generic `integrations_agent` with the corresponding - /// `skill_filter` pre-populated (#1335). + /// * [`SubagentEntry::Skills`] — no delegation tool. The connected + /// Composio toolkits' actions join this agent's `Deferred` catalogue + /// (reached through `tool_search`, called directly), and the entry + /// admits no sub-agent id: see [`AgentDefinition::allowed_subagent_ids`]. /// /// `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::agent::orchestration::tools::ArchetypeDelegationTool - /// [`SkillDelegationTool`]: crate::agent::orchestration::tools::SkillDelegationTool #[serde(default, deserialize_with = "deserialize_subagent_entries")] pub subagents: Vec, @@ -275,6 +273,24 @@ pub struct AgentDefinition { } impl AgentDefinition { + /// The agent ids this definition may spawn, derived from + /// [`AgentDefinition::subagents`]. Only [`SubagentEntry::AgentId`] + /// entries admit a target; the `{ skills = "*" }` wildcard used to map to + /// `integrations_agent` here, which is what let a chat agent spin up a + /// sub-agent for one integration action it can now search for and call + /// itself. The runner's spawn gate (`parent.allowed_subagent_ids`) reads + /// this, so a definition without a bare id for an agent cannot reach it + /// through `spawn_async_subagent` either. + pub fn allowed_subagent_ids(&self) -> Vec { + self.subagents + .iter() + .filter_map(|entry| match entry { + SubagentEntry::AgentId(id) => Some(id.clone()), + SubagentEntry::Skills(_) => None, + }) + .collect() + } + /// Display name with fallback to id. pub fn display_name(&self) -> &str { self.display_name.as_deref().unwrap_or(&self.id) diff --git a/crates/openhuman-core/src/agent/harness/definition/subagents.rs b/crates/openhuman-core/src/agent/harness/definition/subagents.rs index 24c000bcf5..bfbb0ed7c8 100644 --- a/crates/openhuman-core/src/agent/harness/definition/subagents.rs +++ b/crates/openhuman-core/src/agent/harness/definition/subagents.rs @@ -22,11 +22,11 @@ use serde::{Deserialize, Deserializer, Serialize}; pub enum SubagentEntry { /// Delegate to a specific built-in or custom agent by id. AgentId(String), - /// Expand at build time to a single collapsed - /// `delegate_to_integrations_agent` tool whose `toolkit` argument - /// selects which connected Composio toolkit to route to, with - /// `skill_filter` pre-set on the underlying `integrations_agent` - /// dispatch (#1335). + /// Expand at build time to the connected Composio toolkits' actions as + /// `Deferred` tools — off the wire, found through the harness's + /// `tool_search`, and called directly by this agent. No sub-agent is + /// reachable through this entry: it widens the searchable catalogue, + /// not the spawnable set. Skills(SkillsWildcard), } diff --git a/crates/openhuman-core/src/agent/session_host/builder/mod.rs b/crates/openhuman-core/src/agent/session_host/builder/mod.rs index f870468ed9..190b95ac99 100644 --- a/crates/openhuman-core/src/agent/session_host/builder/mod.rs +++ b/crates/openhuman-core/src/agent/session_host/builder/mod.rs @@ -244,15 +244,5 @@ fn allowed_subagent_ids_for(agent_id: &str) -> Vec { let Some(definition) = definition else { return Vec::new(); }; - definition - .subagents - .iter() - .filter_map(|entry| match entry { - SubagentEntry::AgentId(id) => Some(id.clone()), - SubagentEntry::Skills(wildcard) if wildcard.matches_all() => { - Some("integrations_agent".to_string()) - } - SubagentEntry::Skills(_) => None, - }) - .collect() + definition.allowed_subagent_ids() } diff --git a/crates/openhuman-core/src/agent/session_host/runtime_session.rs b/crates/openhuman-core/src/agent/session_host/runtime_session.rs index d2bc0de7fc..0901c88328 100644 --- a/crates/openhuman-core/src/agent/session_host/runtime_session.rs +++ b/crates/openhuman-core/src/agent/session_host/runtime_session.rs @@ -1538,23 +1538,7 @@ impl OpenHumanSessionHost { run_queue: self.run_queue.clone(), allowed_subagent_ids: self .resolved_definition() - .map(|definition| { - definition - .subagents - .iter() - .filter_map(|entry| match entry { - crate::agent::harness::definition::SubagentEntry::AgentId(id) => { - Some(id.clone()) - } - crate::agent::harness::definition::SubagentEntry::Skills( - wildcard, - ) if wildcard.matches_all() => { - Some("integrations_agent".to_string()) - } - crate::agent::harness::definition::SubagentEntry::Skills(_) => None, - }) - .collect() - }) + .map(|definition| definition.allowed_subagent_ids()) .unwrap_or_default(), sandbox_mode: self .resolved_definition() diff --git a/crates/openhuman-core/src/agent/session_host/turn/tools.rs b/crates/openhuman-core/src/agent/session_host/turn/tools.rs index 9c78c61065..3191120e21 100644 --- a/crates/openhuman-core/src/agent/session_host/turn/tools.rs +++ b/crates/openhuman-core/src/agent/session_host/turn/tools.rs @@ -38,23 +38,7 @@ impl OpenHumanSessionHost { } let allowed_subagent_ids = self .resolved_definition() - .map(|definition| { - definition - .subagents - .iter() - .filter_map(|entry| match entry { - crate::agent::harness::definition::SubagentEntry::AgentId(id) => { - Some(id.clone()) - } - crate::agent::harness::definition::SubagentEntry::Skills(wildcard) - if wildcard.matches_all() => - { - Some("integrations_agent".to_string()) - } - crate::agent::harness::definition::SubagentEntry::Skills(_) => None, - }) - .collect() - }) + .map(|definition| definition.allowed_subagent_ids()) .unwrap_or_default(); harness::ParentExecutionContext { From 0ab0d7f9c6d0f94b82bb31723e98ddf2f9faa190 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:25:42 +0300 Subject: [PATCH 06/56] fix: remove unused import of SubagentEntry Removed an unused import of `SubagentEntry` from the `allowed_subagent_ids_for` function, as the type is no longer referenced in that scope. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/agent/session_host/builder/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/openhuman-core/src/agent/session_host/builder/mod.rs b/crates/openhuman-core/src/agent/session_host/builder/mod.rs index 190b95ac99..bb773421e9 100644 --- a/crates/openhuman-core/src/agent/session_host/builder/mod.rs +++ b/crates/openhuman-core/src/agent/session_host/builder/mod.rs @@ -223,7 +223,6 @@ pub(super) fn should_synthesize_delegation_tools(def: &AgentDefinition) -> bool /// the name extends at an `_` boundary. Empty when the registry is not up or /// the id resolves to nothing, which leaves the schema untouched. fn allowed_subagent_ids_for(agent_id: &str) -> Vec { - use crate::agent::harness::definition::SubagentEntry; let Some(registry) = crate::agent::harness::AgentDefinitionRegistry::global() else { return Vec::new(); }; From 7df470b7b8e7d33a73404971865bf357f819544d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:26:45 +0300 Subject: [PATCH 07/56] feat(agent): flatten integration actions into direct tool search Replace the collapsed `delegate_to_integrations_agent` sub-agent pattern with a direct model where every connected integration toolkit's actions are registered as `Deferred` tools on the orchestrator itself. The `## Connected Integrations` block now teaches the model to use `tool_search` for the action and call it directly, removing the delegation layer. Permission-gated tools that are not searchable are listed in a new appendix with their unlock paths so the model can guide the user instead of incorrectly claiming the action is unavailable. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../registry/agents/orchestrator/prompt.rs | 153 ++++++++++-------- 1 file changed, 82 insertions(+), 71 deletions(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.rs b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.rs index 76163d6ad0..651c062445 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.rs +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.rs @@ -2,14 +2,13 @@ //! //! The orchestrator follows a direct-first policy: respond directly or use //! cheap direct tools whenever possible, and delegate only for specialised -//! execution. It never executes Composio actions itself; the integration -//! block points to the single collapsed `delegate_to_integrations_agent` -//! tool (synthesised by `orchestrator_tools::collect_orchestrator_tools`, -//! #1335) for true external-service operations, with the toolkit slug -//! passed as an argument. That prose lives here (not in the shared -//! prompts module) so the skill-executor voice stays in -//! `integrations_agent/prompt.rs` and nobody has to branch on `agent_id` -//! in a shared section impl. +//! execution. Connected Composio integrations are part of that direct +//! surface: every connected toolkit's actions are registered as `Deferred` +//! tools (`orchestrator_tools::collect_deferred_integration_actions`), so +//! the `## Connected Integrations` block tells the model to `tool_search` +//! for the action and call it — there is no integrations sub-agent to +//! delegate to any more. That prose lives here (not in the shared prompts +//! module) so nobody has to branch on `agent_id` in a shared section impl. use crate::agent::harness::definition::SubagentEntry; use crate::agent::harness::AgentDefinitionRegistry; @@ -92,7 +91,7 @@ pub fn build(ctx: &PromptContext<'_>) -> Result { push(&mut out, &render_withheld_specialists(ctx)); push( &mut out, - &render_delegation_guide(ctx.connected_integrations, ctx.tool_call_format), + &render_connected_integrations(ctx.connected_integrations), ); push( &mut out, @@ -146,8 +145,8 @@ fn render_withheld_specialists(ctx: &PromptContext<'_>) -> String { let mut rows: Vec<(String, &'static str)> = Vec::new(); for entry in &definition.subagents { - // `Skills(_)` expands to `delegate_to_integrations_agent`, which the - // `## Connected Integrations` block below documents in full. + // `Skills(_)` expands to searchable integration actions, not a + // delegate tool; the `## Connected Integrations` block covers them. let SubagentEntry::AgentId(agent_id) = entry else { continue; }; @@ -359,7 +358,7 @@ fn render_installed_skills( } /// Render the `## Connected MCP Servers` block from the live connection -/// registry. The MCP analogue of [`render_delegation_guide`]: it lists each +/// registry. The MCP analogue of [`render_connected_integrations`]: it lists each /// connected MCP server and tells the orchestrator to hand matching requests to /// the `mcp_agent` worker — NOT to call a server's tools itself or claim it /// can't. This is what lets the orchestrator pick up a connected server @@ -466,57 +465,49 @@ fn format_connected_mcp_block( out } -/// Render the delegator-voice `## Connected Integrations` block. Only -/// toolkits the user has actively connected are listed — unauthorised -/// toolkits are hidden so the orchestrator cannot hallucinate a delegation -/// to an integration whose `delegate_*` tool does not actually exist. -/// When every toolkit is unconnected the whole section is omitted. +/// Render the `## Connected Integrations` block. Only toolkits the user has +/// actively connected are listed — unauthorised toolkits are hidden so the +/// orchestrator cannot claim access to a service it does not have. When +/// every toolkit is unconnected the whole section is omitted. /// -/// The tool name printed in the prompt is derived with the same -/// `sanitise_slug` function that `collect_orchestrator_tools` uses when -/// synthesising the real tool objects, so the names in the prompt always -/// match the names in the function-calling schema. +/// The connected toolkits' actions are `Deferred` tools on this agent's own +/// belt (`collect_deferred_integration_actions`), so the block teaches one +/// route: `tool_search` for the action, then call it. The old collapsed +/// `delegate_to_integrations_agent` spawn is gone; an integration action is +/// a search and a call, not a sub-agent run. /// -/// `tool_call_format` lets the guide adapt to the active provider. Providers -/// with native structured tool-calling (`ToolCallFormat::Native`) get the -/// historic guide unchanged. Text-protocol providers (`PFormat`/`Json`) — the -/// dispatcher chosen for models that force `native_tool_calling = false`, i.e. -/// local runtimes like Ollama / LM Studio / MLX / llama.cpp — additionally get -/// an explicit "when NOT to delegate" carve-out. Weak local models over-select -/// from the prose tool catalogue and the coercive "you MUST delegate" wording, -/// spuriously routing greetings and local-filesystem actions into -/// `delegate_to_integrations_agent` (issue #4361: "Ciao" → Connections, -/// "create a folder on Desktop" → Calendar). The carve-out is additive: the -/// always-delegate contract for genuine service requests is preserved. -fn render_delegation_guide( - integrations: &[ConnectedIntegration], - tool_call_format: ToolCallFormat, -) -> String { +/// The slug printed beside each toolkit uses the same `sanitise_slug` as the +/// rest of the prompt surface so the model's `composio_connect` argument and +/// its searches name the toolkit consistently. +/// +/// The gated-tools appendix used to live in the integrations sub-agent's +/// prompt. It moves here with the catalogue: an action behind a permission +/// toggle is not in the searchable set, so without this list the model would +/// answer "can you do X?" with a wrong "no" instead of the unlock path. +fn render_connected_integrations(integrations: &[ConnectedIntegration]) -> String { let connected: Vec<&ConnectedIntegration> = integrations.iter().filter(|ci| ci.connected).collect(); tracing::debug!( total_integrations = integrations.len(), connected_count = connected.len(), - "[delegation-guide] rendering integration section ({} connected / {} total)", + "[connected-integrations] rendering integration section ({} connected / {} total)", connected.len(), integrations.len() ); if connected.is_empty() { - tracing::debug!("[delegation-guide] section omitted — no connected integrations"); + tracing::debug!("[connected-integrations] section omitted — no connected integrations"); return String::new(); } let mut out = String::from( "## Connected Integrations\n\n\ - Their tools live in `integrations_agent`, not in your list: act on them only through \ - `delegate_to_integrations_agent` with the toolkit slug, and only when the request \ - operates on that service's data or actions (a connected service is not a reason to \ - touch it for general-knowledge, web/news, date/time or math questions). Never claim \ - you cannot access one without delegating first.\n\n", + Their actions are not in your listed tools: `tool_search` for the action in plain \ + words (\"send an email\", \"list calendar events\"), then call the tool it returns — \ + no sub-agent. Act on a service only when the request operates on that service's data \ + or actions (a connected service is not a reason to touch it for general-knowledge, \ + web/news, date/time or math questions). Never claim you cannot access one without \ + searching first.\n\n", ); - for ci in connected { - // Use the same slug canonicalisation as `collect_orchestrator_tools` - // so the `toolkit` arg the orchestrator emits always matches the - // enum the synthesised tool accepts. + for ci in &connected { let slug = sanitise_slug(&ci.toolkit); if ci.connections.len() > 1 { let _ = writeln!( @@ -550,10 +541,8 @@ fn render_delegation_guide( // SUBSET of the real per-toolkit catalogue (no bulk-delete, no // batch-modify, no admin/destructive actions, etc.). The result is a // confident wrong refusal ("nope, I can't delete emails") even when - // the action is in the actual tool list. The `integrations_agent` - // has the ground-truth tool catalogue (`tools` + `gated_tools`); only - // it can answer "can I do X?" honestly. Force-delegate capability - // questions, not just task requests. + // the action is in the catalogue. `tool_search` is the ground truth for + // callable actions and the gated appendix below for the rest. // The cross-chat bullet names the canonical header literal verbatim // so the model knows exactly which block to mistrust. Sourced from // CROSS_CHAT_HEADER (single source of truth) — drift would silently @@ -565,34 +554,56 @@ fn render_delegation_guide( "\n### Capability questions about connected toolkits\n\n\ Your prior knowledge of what a toolkit can do is unreliable: the live catalogue and \ the user's scopes decide. For \"can you do X with {{toolkit}}?\" or any action on a \ - connected toolkit, delegate first and let `integrations_agent` inspect its tools \ - (including `gated_tools`); the only honest \"no\" is one it reported. A past \ - \"I can / can't\" in the `{cross_chat_header_for_prompt}` block is a stale snapshot, \ - never an answer.\n\n", + connected toolkit, `tool_search` first; the only honest \"no\" is an empty search that \ + the permission-gated list below does not explain. A past \"I can / can't\" in the \ + `{cross_chat_header_for_prompt}` block is a stale snapshot, never an answer.\n\n", ); - if tool_call_format != ToolCallFormat::Native { + // Pref-gated actions: the toolkit has them, the user has not granted the + // scope, so they are not in the searchable catalogue. The agent cannot + // call them and cannot flip the scope itself — the per-row `unlock + // paths` carry the exact UI hint to show the user. + let gated: Vec<&&ConnectedIntegration> = connected + .iter() + .filter(|ci| !ci.gated_tools.is_empty()) + .collect(); + tracing::debug!( + connected_with_gated = gated.len(), + "[connected-integrations] gated-tools scan complete" + ); + if !gated.is_empty() { out.push_str( - "### When NOT to delegate\n\n\ - Some requests are NOT integration work — handle them directly and do NOT call \ - `delegate_to_integrations_agent`:\n\ - - **Greetings and small talk** (\"hi\", \"hello\", \"ciao\", \"thanks\", \"how are \ - you?\") — just reply.\n\ - - **Local-machine actions**: creating, reading, writing, moving, or listing files \ - and folders on this computer (e.g. \"create a folder on the Desktop\", \"make a \ - directory\", \"save this to a file\") — use your local filesystem tools. A local \ - folder/file request is NOT a Calendar, Drive, or any connected-service request.\n\n\ - Delegate ONLY when the request clearly names or operates on one of the connected \ - services listed above (its email, calendar, messages, documents, etc.). When a \ - request mixes a local action with a connected service (\"save my latest email to a \ - file on the Desktop\"), do the local part directly and delegate only the \ - service part.\n\n", + "### Additional capabilities behind a permission toggle\n\n\ + These actions exist in the toolkit but are NOT searchable or callable — the user \ + has not granted the required scope. Do NOT pretend they're unavailable. When the \ + user asks for one (or you'd otherwise need it), tell them what the action does and \ + present ALL of its `unlock paths` listed below so the user can choose how to enable \ + it. Never drop a path or rewrite it into your own framing.\n\n", ); + for ci in gated { + let _ = writeln!(out, "- **{}**:", ci.toolkit); + for gt in &ci.gated_tools { + let desc = if gt.description.is_empty() { + "(no description)" + } else { + gt.description.as_str() + }; + let _ = writeln!( + out, + " - `{}` — {} (requires `{}` scope)", + gt.name, desc, gt.required_scope + ); + for path in >.unlock_paths { + let _ = writeln!(out, " - unlock path: {path}"); + } + } + } + out.push('\n'); } tracing::debug!( section_len = out.len(), - "[delegation-guide] section emitted ({} bytes)", + "[connected-integrations] section emitted ({} bytes)", out.len() ); out From 641dfdb2180311966d87f67a0e6e03b0c1ab33b7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:27:01 +0300 Subject: [PATCH 08/56] feat(orchestrator): remove integrations sub-agent from prompt The orchestrator prompt no longer references a separate integrations agent; instead it instructs the model to search for and call integration tools directly. The unused `ToolCallFormat` import is also removed from the Rust source. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/registry/agents/orchestrator/prompt.md | 6 +++--- .../src/agent/registry/agents/orchestrator/prompt.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md index 981b61fe38..de410c80d2 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md @@ -3,8 +3,8 @@ Take the first branch that applies: 1. **Answerable without tools**: reply. Small talk, simple Q&A, general knowledge. -1b. **Needs a capability you do not see listed**: call `tool_search` with the intent in plain words before delegating or declining. Your list is a core set; one clear action on a connected service (send this message, create that issue) is a search-then-call, not a delegation. -2. **Needs a connected service's own data or actions** (inbox, messages, calendar, docs, tickets, "send/check X"): call `delegate_to_integrations_agent` with the `toolkit` from **Connected Integrations**. Use the live service even when memory could plausibly answer. A service being connected is not a reason to touch it: general knowledge, web/news lookups, headlines, date/time and math never delegate here. Not connected? Raise a connect card with `composio_connect`: the list shows what is connected, not what is connectable, so never refuse from it or send the user to settings, and never paste OAuth URLs. If the connect call reports the toolkit unavailable, relay its message; that is the only honest refusal. +1b. **Needs a capability you do not see listed**: call `tool_search` with the intent in plain words before delegating or declining. Your list is a core set; if nothing comes back, say so. +2. **Needs a connected service's own data or actions** (inbox, messages, calendar, docs, tickets, "send/check X"): `tool_search` for the action ("send an email", "list calendar events") and call the tool it returns yourself; there is no integrations sub-agent. Use the live service even when memory could plausibly answer. A service being connected is not a reason to touch it: general knowledge, web/news lookups, headlines, date/time and math never go to a service. Not connected? Raise a connect card with `composio_connect`: **Connected Integrations** shows what is connected, not what is connectable, so never refuse from it or send the user to settings, and never paste OAuth URLs. If the connect call reports the toolkit unavailable, relay its message; that is the only honest refusal. 3. **Solvable with a direct tool**: do it yourself. `web_search_tool` and `web_fetch` for a fact or a page, `memory_recall` and `memory_store` for the user's own facts, `shell` plus `apply_patch` for repository work. Keep code work end-to-end: edit and verify in the same turn; never delegate merely because a task touches a repository. 4. **Needs a specialist**: the specialists you can call are in your tool list with their own descriptions. **Capabilities not in your tool list** names the ones a skill holds; reach those through `use_skill`. Workers return only their result; carry out any `## Handoff Plan` they return yourself, under the approval gate. 5. **Distill every delegated reply**: keep what answers the question, drop the worker's notes. Never paste a sub-agent's response verbatim. @@ -31,7 +31,7 @@ Three or more steps? Track them on `todo` cards. Don't stop with a plan: execute - Preserve numeric evidence exactly: copy numbers, dates, durations, currencies and ids as observed; don't round or recompute unless asked, and then show the working. - A sub-agent's summary is claims: check it against its `Evidence used`, `Actions taken` and `Failed tool calls`. Do not introduce facts its evidence does not support. Output marked truncated, oversized, partial or unavailable is not complete: fetch more or say so. - Never pass off fabricated output as a result. If a step failed, say so and what you did instead. -- `retrieve_memory` walks already-ingested history, not a live API; for what is in an inbox right now, delegate to the live integration. +- `retrieve_memory` walks already-ingested history, not a live API; for what is in an inbox right now, search for and call the live integration's action. ## Scheduling and workflows diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.rs b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.rs index 651c062445..f8d1b4605e 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.rs +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.rs @@ -14,7 +14,7 @@ use crate::agent::harness::definition::SubagentEntry; use crate::agent::harness::AgentDefinitionRegistry; use crate::agent::prompts::{ render_datetime, render_identity, render_tools, render_user_files, render_workspace, - ConnectedIntegration, PromptContext, ToolCallFormat, + ConnectedIntegration, PromptContext, }; use crate::skills::ops_types::Workflow; use crate::tools::orchestrator_tools::sanitise_slug; From de719ef9101d0b3bb30ec4b83c63e16698fa99fe Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:27:11 +0300 Subject: [PATCH 09/56] fix(session_host): correct integration announcement to reference tool_search The announcement note for newly connected integrations now correctly tells the user that integration actions are reachable through `tool_search` instead of the outdated `delegate_to_integrations_agent` mechanism. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../openhuman-core/src/agent/session_host/announcement_notes.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/agent/session_host/announcement_notes.rs b/crates/openhuman-core/src/agent/session_host/announcement_notes.rs index 222eafd24b..36db9893df 100644 --- a/crates/openhuman-core/src/agent/session_host/announcement_notes.rs +++ b/crates/openhuman-core/src/agent/session_host/announcement_notes.rs @@ -15,7 +15,7 @@ Do not tell the user to reconnect or restart."; pub(super) fn integration_announcement_note(slugs: &[String]) -> Option { (!slugs.is_empty()).then(|| format!( - "[integration update] These integration(s) connected during this conversation and are available now via delegate_to_integrations_agent with the matching toolkit slug: {}. {ANNOUNCEMENT_TRAILER}", + "[integration update] These integration(s) connected during this conversation and are available now; their actions are reachable through tool_search: {}. {ANNOUNCEMENT_TRAILER}", slugs.join(", ") )) } From df56a37be0b729b56702f5695aab18afba2d1cbe Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:27:46 +0300 Subject: [PATCH 10/56] refactor(agent): update comments to reflect skills wildcard expansion The documentation comments across multiple files were updated to accurately describe how `{ skills = "*" }` wildcards are handled. Previously, the comments incorrectly stated that skills wildcards collapse into a single `delegate_to_integrations_agent` tool, but the actual behavior is that they expand to searchable integration actions on the agent's own belt. The tier validation logic and related comments were also corrected to remove references to the old workflow-based routing model. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/harness/definition/tier.rs | 7 +++---- .../src/agent/registry/agents/loader.rs | 18 +++++++----------- .../src/agent/session_host/builder/factory.rs | 7 +++---- .../tinyagents/host/definition_registry.rs | 10 +++++----- 4 files changed, 18 insertions(+), 24 deletions(-) diff --git a/crates/openhuman-core/src/agent/harness/definition/tier.rs b/crates/openhuman-core/src/agent/harness/definition/tier.rs index 73d734c170..9cdf67cb79 100644 --- a/crates/openhuman-core/src/agent/harness/definition/tier.rs +++ b/crates/openhuman-core/src/agent/harness/definition/tier.rs @@ -79,10 +79,9 @@ impl std::fmt::Display for AgentTier { /// pairs at boot (see /// [`crate::agent::registry::agents::validate_tier_hierarchy`]). The /// runtime spawn gate (`run_subagent`) reuses it as defense-in-depth, but -/// deliberately exempts worker *parents* — at runtime a worker only reaches the -/// spawn chokepoint via the documented collapsed `delegate_to_integrations_agent` -/// path (→ `integrations_agent`, itself a worker), which the loader intentionally -/// leaves untouched. +/// deliberately exempts worker *parents* — a worker's `subagents` list holds +/// no agent id (the loader rejects one), so the only runtime spawn a worker +/// reaches is one the host dispatched for it, not one it chose. pub fn validate_tier_transition(parent: AgentTier, child: AgentTier) -> Result<(), String> { match (parent, child) { (AgentTier::Worker, _) => Err(format!( diff --git a/crates/openhuman-core/src/agent/registry/agents/loader.rs b/crates/openhuman-core/src/agent/registry/agents/loader.rs index 368cc03a58..5160a04393 100644 --- a/crates/openhuman-core/src/agent/registry/agents/loader.rs +++ b/crates/openhuman-core/src/agent/registry/agents/loader.rs @@ -349,15 +349,12 @@ fn builtin_enabled(_b: &BuiltinAgent) -> bool { /// * `Reasoning` agents MUST NOT list another `Reasoning` agent in /// `subagents`. /// * `Worker` agents MUST NOT list any [`SubagentEntry::AgentId`] -/// entries. (Workflow wildcards are allowed: they expand to the generic -/// `integrations_agent`, which is itself a `Worker`, and the call -/// happens via a single delegation tool rather than recursive spawn.) +/// entries. (Skills wildcards are allowed: they expand to the connected +/// integrations' actions as searchable tools on the agent's own belt, +/// not to a spawn.) /// -/// Workflow-wildcard entries (`{ skills = "*" }`) are intentionally -/// untouched: they collapse to one `delegate_to_integrations_agent` -/// tool whose target is a `Worker` and whose use sites are well -/// understood. Mis-tiering of the `integrations_agent` itself is still -/// caught because it appears as a normal entry elsewhere. +/// Skills-wildcard entries (`{ skills = "*" }`) are intentionally +/// untouched: they name no agent, so there is no tier pair to check. /// /// Called from [`load_builtins`] for the bundled archetype set and from /// [`crate::agent::harness::definition::AgentDefinitionRegistry::load`] @@ -372,9 +369,8 @@ pub fn validate_tier_hierarchy(defs: &[AgentDefinition]) -> Result<()> { for entry in &def.subagents { let child_id = match entry { SubagentEntry::AgentId(id) => id.as_str(), - // Workflow wildcards always route to `integrations_agent` - // (a Worker) via a single collapsed delegation tool — - // not subject to the tier-mismatch rule. + // Skills wildcards expand to searchable integration + // actions, not to an agent — nothing to tier-check. SubagentEntry::Skills(_) => continue, }; diff --git a/crates/openhuman-core/src/agent/session_host/builder/factory.rs b/crates/openhuman-core/src/agent/session_host/builder/factory.rs index 64a2f994b2..f643255b3c 100644 --- a/crates/openhuman-core/src/agent/session_host/builder/factory.rs +++ b/crates/openhuman-core/src/agent/session_host/builder/factory.rs @@ -583,10 +583,9 @@ impl OpenHumanSessionHost { // // For an agent with `[subagents] allowlist = [...]` in its TOML (today: // orchestrator), `collect_orchestrator_tools` synthesises one - // `ArchetypeDelegationTool` per named sub-agent plus a single - // collapsed `SkillDelegationTool` - // (`delegate_to_integrations_agent`) whose `toolkit` argument - // selects among the connected Composio toolkits (#1335). + // `ArchetypeDelegationTool` per named sub-agent plus, for the + // `{ skills = "*" }` wildcard, one `Deferred` action tool per + // connected Composio action (reached through `tool_search`). // // For an agent without `subagents` (today: welcome, critic, // archivist, etc.), no delegation tools are synthesised — the diff --git a/crates/openhuman-core/src/agent/tinyagents/host/definition_registry.rs b/crates/openhuman-core/src/agent/tinyagents/host/definition_registry.rs index c1ccc0eda4..97b9237526 100644 --- a/crates/openhuman-core/src/agent/tinyagents/host/definition_registry.rs +++ b/crates/openhuman-core/src/agent/tinyagents/host/definition_registry.rs @@ -59,9 +59,9 @@ //! [`Self::with_registered_tools`], failing closed when that is absent. //! //! **4. `SubagentEntry::Skills` entries are omitted.** A `{ skills = "*" }` -//! entry is not an agent id — it collapses into the single -//! `delegate_to_integrations_agent` tool. Emitting a synthetic id here would -//! invent a delegate the host never authorized. +//! entry is not an agent id — it expands to the connected integrations' +//! actions as searchable tools. Emitting a synthetic id here would invent a +//! delegate the host never authorized. //! use std::collections::HashSet; @@ -397,8 +397,8 @@ impl OpenHumanDefinitionRegistry { /// Declared subagent **agent ids** only. /// /// [`SubagentEntry::Skills`] entries are skipped: they are a wildcard that -/// collapses to the single `delegate_to_integrations_agent` tool, not an agent -/// the parent may address by id. +/// expands to searchable integration actions, not an agent the parent may +/// address by id. fn declared_subagent_ids(def: &HostAgentDefinition) -> Vec { def.subagents .iter() From dec4dc6f6c82793572a8f0ea54648dc26f108c82 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:28:06 +0300 Subject: [PATCH 11/56] feat(agent): replace integrations_agent delegation with direct tool_search-and-call for composio act Connected-service actions are no longer routed through a dedicated `integrations_agent` sub-agent. Instead, the orchestrator synthesises per-action `Deferred` tools from the `{ skills = "*" }` wildcard, searches for them via `tool_search`, and calls them directly. This removes the `integrations_agent` from the planner's available worker set and updates all prompts and documentation to reflect that the orchestrator handles service interactions itself, eliminating an unnecessary delegation hop. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../registry/agents/orchestrator/agent.toml | 18 ++++++++++-------- .../agent/registry/agents/planner/prompt.md | 3 +-- .../registry/agents/tools_agent/prompt.md | 2 +- .../src/agent/session_host/builder/factory.rs | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/agent.toml b/crates/openhuman-core/src/agent/registry/agents/orchestrator/agent.toml index 990f2821aa..5726200424 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/agent.toml +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/agent.toml @@ -49,9 +49,11 @@ omit_memory_md = false # 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 `integrations_agent` with -# the toolkit slug pre-filled as `skill_filter`. +# * `{ skills = "*" }` → one `Deferred` action tool per action of every +# connected Composio toolkit. Off the wire, found through `tool_search`, +# called directly. This used to be a `delegate_to_integrations_agent` +# spawn; one clear service action is now a search and a call, and the +# wildcard admits no sub-agent id (`AgentDefinition::allowed_subagent_ids`). # # The orchestrator LLM sees these as first-class entries in its # function-calling schema, so routing decisions happen at the tool- @@ -206,14 +208,14 @@ hint = "coding" # delegating. # # `composio_list_connections` lets the agent detect newly-authorised -# integrations mid-session (the session-start fetch froze the Delegation -# Guide's connected list). `composio_connect` lets it raise an inline +# integrations mid-session (the session-start fetch froze the Connected +# Integrations block). `composio_connect` lets it raise an inline # connect card for ANY toolkit the user asks to connect — including ones # not yet connected (#3993); the tool validates the slug against the # backend allowlist and the OAuth handoff itself runs inside the card. -# Toolkit *action* listing and execution still live downstream in -# `integrations_agent` — the orchestrator never calls composio_list_tools -# / composio_execute directly. +# Toolkit *actions* are the per-action `Deferred` tools synthesised from +# `{ skills = "*" }` below and reached through `tool_search` — the +# orchestrator never calls composio_list_tools / composio_execute directly. named = [ # Tool packs add this proxy to the visible set when they withhold member # schemas. The hosted runtime also enforces this definition as its dispatch diff --git a/crates/openhuman-core/src/agent/registry/agents/planner/prompt.md b/crates/openhuman-core/src/agent/registry/agents/planner/prompt.md index 3b7d9e8c5a..145964ded8 100644 --- a/crates/openhuman-core/src/agent/registry/agents/planner/prompt.md +++ b/crates/openhuman-core/src/agent/registry/agents/planner/prompt.md @@ -33,14 +33,13 @@ Return **only** valid JSON matching this schema: ## Available Agent IDs - `code_executor` — Writes and runs code. Use for implementation tasks. -- `integrations_agent` — Executes skill tools (Notion, Gmail, etc.). Use for service interactions. - `tool_maker` — Writes polyfill scripts. Rarely needed in planning. - `researcher` — Reads docs, web searches. Use for information gathering. - `critic` — Reviews code quality and security. Use after code changes. ## Rules -0. **You are the reasoning tier.** The chat-tier Orchestrator handed off to you because the task needs sustained thinking. Compose plans for the **worker tier** — `code_executor`, `researcher`, `critic`, `integrations_agent`, `archivist`. **Never delegate to another reasoning agent** (no planner-spawns-planner, no planner-spawns-orchestrator); the loader rejects this at boot, and the planned runtime depth gate will reject it at spawn time. If a single worker can't cover a node, split the node — don't smuggle a second reasoning hop in. +0. **You are the reasoning tier.** The chat-tier Orchestrator handed off to you because the task needs sustained thinking. Compose plans for the **worker tier** — `code_executor`, `researcher`, `critic`, `archivist`. Connected-service actions (Gmail, Notion, Slack, …) are the orchestrator's own `tool_search`-and-call step, not a worker. **Never delegate to another reasoning agent** (no planner-spawns-planner, no planner-spawns-orchestrator); the loader rejects this at boot, and the planned runtime depth gate will reject it at spawn time. If a single worker can't cover a node, split the node — don't smuggle a second reasoning hop in. 1. **Gather before planning** — Search memory and the web first. Don't guess what you can look up. 2. **Minimise tasks** — Use the fewest nodes needed. Don't over-decompose. 3. **Dependencies matter** — Use `depends_on` to express ordering. Independent tasks run in parallel. diff --git a/crates/openhuman-core/src/agent/registry/agents/tools_agent/prompt.md b/crates/openhuman-core/src/agent/registry/agents/tools_agent/prompt.md index e283562617..aa6509b5ba 100644 --- a/crates/openhuman-core/src/agent/registry/agents/tools_agent/prompt.md +++ b/crates/openhuman-core/src/agent/registry/agents/tools_agent/prompt.md @@ -4,7 +4,7 @@ You are the **Tools Agent**. You complete ad-hoc tasks using only OpenHuman's bu ## Scope -- You do **NOT** have access to Composio / managed OAuth integrations. If a task requires acting on an external SaaS account (Gmail, Notion, GitHub, Slack, …), stop and report back — the orchestrator will spawn `integrations_agent` with the correct toolkit. +- You do **NOT** have access to Composio / managed OAuth integrations. If a task requires acting on an external SaaS account (Gmail, Notion, GitHub, Slack, …), stop and report back — the orchestrator searches for and calls the connected service's action itself. - You **DO** handle: running commands, reading and writing files in the workspace, scraping the web, searching the user's memory, querying structured data, chaining simple transformations. ## Operating rules diff --git a/crates/openhuman-core/src/agent/session_host/builder/factory.rs b/crates/openhuman-core/src/agent/session_host/builder/factory.rs index f643255b3c..1b14220b44 100644 --- a/crates/openhuman-core/src/agent/session_host/builder/factory.rs +++ b/crates/openhuman-core/src/agent/session_host/builder/factory.rs @@ -596,7 +596,7 @@ impl OpenHumanSessionHost { // This builder is synchronous and sits on the CLI / REPL / // Tauri-web code path. It still opportunistically reuses the // process-wide Composio cache when one is already warm, which - // lets the session start with the right `delegate_` + // lets the session start with the right integration action // surface and prompt block without paying a turn-1 fetch. On a // cold cache we still fall back to the empty slice and let the // first turn repair the session state if needed. From e46cabc1b3a566fe87039859025e2382133de223 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:28:13 +0300 Subject: [PATCH 12/56] fix(agent): correct tools_agent comment to reference orchestrator The comment in the tools_agent configuration previously stated that integration-specific tools belong to `integrations_agent`, but this is no longer accurate. Updated the comment to reflect that these tools are now owned by the orchestrator and searched through `tool_search`. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/registry/agents/tools_agent/agent.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/tools_agent/agent.toml b/crates/openhuman-core/src/agent/registry/agents/tools_agent/agent.toml index 0c75a3b199..f1de4c11a4 100644 --- a/crates/openhuman-core/src/agent/registry/agents/tools_agent/agent.toml +++ b/crates/openhuman-core/src/agent/registry/agents/tools_agent/agent.toml @@ -17,7 +17,7 @@ hint = "burst" # surface. Composio meta-tools and dynamic `_*` action tools are # stripped at runtime (see `filter_non_composio_indices` in the subagent # runner), so the LLM never sees integration-specific tools here; those belong -# to `integrations_agent`. Specialist-owned trading tools are also +# to the orchestrator (searched through `tool_search`). Specialist-owned trading tools are also # stripped via `disallowed_tools` above so they route through their dedicated # agents exclusively. wildcard = {} From fb5a8462b46e90f842652c6e190a0e69acf9eb23 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:28:29 +0300 Subject: [PATCH 13/56] chore: update comments to reflect removal of delegate_to_integrations_agent Updated comments across three files to replace references to the now-removed `delegate_to_integrations_agent` with descriptions of the integration action catalogue and searchable tool surface, keeping the documentation accurate after the architectural change that removed the sub-agent spawn in favor of direct integration action tools. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-app/src/lib.rs | 5 +++-- .../src/channels/runtime/dispatch/routing.rs | 16 ++++++++-------- .../composio/connected_integrations/cache.rs | 4 ++-- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/openhuman-app/src/lib.rs b/crates/openhuman-app/src/lib.rs index 930c1b8255..09c6bebd42 100644 --- a/crates/openhuman-app/src/lib.rs +++ b/crates/openhuman-app/src/lib.rs @@ -2370,8 +2370,9 @@ pub fn run() { // `core_process::CoreProcessHandle::ensure_running` via // `tokio::spawn(run_server_embedded(..))`) runs *on* that runtime, so // every JSON-RPC handler — including the deep tower - // `web channel chat → orchestrator turn → delegate_to_integrations_agent - // → sub-agent → composio_list_tools → load_config_with_timeout` — + // `web channel chat → orchestrator turn → integration action tool + // → composio execute → load_config_with_timeout` (and, at the time, the + // now-removed integrations sub-agent spawn in between) — // burns through the same 2 MB. In `crahs.log` (2026-05-17, build // 0.53.49) that tower plus the serde-monomorphised `Config` Visitor // frames pushed past the guard page and aborted with diff --git a/crates/openhuman-core/src/channels/runtime/dispatch/routing.rs b/crates/openhuman-core/src/channels/runtime/dispatch/routing.rs index b376f5a577..d2f7f5afd0 100644 --- a/crates/openhuman-core/src/channels/runtime/dispatch/routing.rs +++ b/crates/openhuman-core/src/channels/runtime/dispatch/routing.rs @@ -114,8 +114,8 @@ pub(super) async fn resolve_target_agent(channel: &str) -> AgentScoping { // // Crucially, a transient failure (backend 5xx / no client for a beat) or a // timeout must NOT be laundered into "zero connected integrations": that - // would drop `delegate_to_integrations_agent` from the turn's tool surface - // and leave the channel agent unable to reach Gmail/Slack/etc. — the exact + // would drop every integration action from the turn's searchable tool + // catalogue and leave the channel agent unable to reach Gmail/Slack/etc. — the exact // "just normal inference, no tool calling" symptom. So we take the // status-returning fetch and, on `Unavailable`/timeout, fall back to the // last cached snapshot (same defence the first-party turn path uses) rather @@ -138,7 +138,7 @@ pub(super) async fn resolve_target_agent(channel: &str) -> AgentScoping { channel = %channel, target_agent = target_id, timed_out = fetched.is_none(), - "[dispatch::routing] Composio unavailable/timed out — using cached integration snapshot instead of an empty set (keeps delegate_to_integrations_agent live)" + "[dispatch::routing] Composio unavailable/timed out — using cached integration snapshot instead of an empty set (keeps the integration action catalogue live)" ); } // Use the expiry-tolerant read for the fallback: a transient blip that @@ -190,9 +190,9 @@ pub(super) async fn resolve_target_agent(channel: &str) -> AgentScoping { /// /// Only an `Authoritative` result (the backend explicitly reporting the current /// set, even if empty) is taken at face value. `Unavailable` or a timeout falls -/// back to `cached`, so a one-off 5xx/slow call can't drop -/// `delegate_to_integrations_agent` and silently disable tool calling for the -/// turn (the "just normal inference" bug). With no cache to fall back on the +/// back to `cached`, so a one-off 5xx/slow call can't drop the integration +/// action catalogue and silently disable tool calling for the turn (the +/// "just normal inference" bug). With no cache to fall back on the /// result is empty — the same conservative default as before, but reached only /// when we genuinely have no better truth. pub(super) fn connected_with_fallback( @@ -211,8 +211,8 @@ pub(super) fn connected_with_fallback( /// * every tool name in the agent's `[tools] named = [...]` list /// (when the scope is [`ToolScope::Named`]); and /// * every name produced by the per-turn synthesised delegation tools -/// in `extra_tools` (e.g. `research`, `plan`, -/// `delegate_to_integrations_agent`). +/// in `extra_tools` (e.g. `research`, `plan`) and the deferred +/// integration action tools beside them. /// /// When the agent's tool scope is [`ToolScope::Wildcard`] **and** there /// are no `extra_tools`, returns `None` to preserve the legacy diff --git a/crates/openhuman-core/src/integrations/composio/connected_integrations/cache.rs b/crates/openhuman-core/src/integrations/composio/connected_integrations/cache.rs index 941d71f7ac..0b75e6453d 100644 --- a/crates/openhuman-core/src/integrations/composio/connected_integrations/cache.rs +++ b/crates/openhuman-core/src/integrations/composio/connected_integrations/cache.rs @@ -120,8 +120,8 @@ pub fn cached_active_integrations(config: &Config) -> Option Date: Tue, 22 Sep 2026 12:32:35 +0300 Subject: [PATCH 14/56] fix: collect allowed subagent ids into a Vec Changed the two call sites that retrieve allowed subagent IDs from a resolved definition to explicitly collect the iterator into a Vec, ensuring the returned type matches the expected owned collection rather than a lazy iterator. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/agent/session_host/runtime_session.rs | 2 +- crates/openhuman-core/src/agent/session_host/turn/tools.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/openhuman-core/src/agent/session_host/runtime_session.rs b/crates/openhuman-core/src/agent/session_host/runtime_session.rs index 0901c88328..7f49f060a5 100644 --- a/crates/openhuman-core/src/agent/session_host/runtime_session.rs +++ b/crates/openhuman-core/src/agent/session_host/runtime_session.rs @@ -1538,7 +1538,7 @@ impl OpenHumanSessionHost { run_queue: self.run_queue.clone(), allowed_subagent_ids: self .resolved_definition() - .map(|definition| definition.allowed_subagent_ids()) + .map(|definition| definition.allowed_subagent_ids().into_iter().collect()) .unwrap_or_default(), sandbox_mode: self .resolved_definition() diff --git a/crates/openhuman-core/src/agent/session_host/turn/tools.rs b/crates/openhuman-core/src/agent/session_host/turn/tools.rs index 3191120e21..62f8390b55 100644 --- a/crates/openhuman-core/src/agent/session_host/turn/tools.rs +++ b/crates/openhuman-core/src/agent/session_host/turn/tools.rs @@ -38,7 +38,7 @@ impl OpenHumanSessionHost { } let allowed_subagent_ids = self .resolved_definition() - .map(|definition| definition.allowed_subagent_ids()) + .map(|definition| definition.allowed_subagent_ids().into_iter().collect()) .unwrap_or_default(); harness::ParentExecutionContext { From 32aad8e05ab984527a0998753fad45488d129c91 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:33:30 +0300 Subject: [PATCH 15/56] test(orchestrator): replace collapsed delegation tool with per-action deferred tools Replace the single `delegate_to_integrations_agent` tool with individual `Deferred` action tools for each connected integration action, so the orchestrator can route directly to specific actions rather than delegating to a sub-agent. This removes the collapsed delegation pattern and its associated sanitisation and fallback logic, simplifying the tool catalogue and making action discovery more explicit. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tools/orchestrator_tools_tests.rs | 245 ++++++++---------- 1 file changed, 105 insertions(+), 140 deletions(-) diff --git a/crates/openhuman-core/src/tools/orchestrator_tools_tests.rs b/crates/openhuman-core/src/tools/orchestrator_tools_tests.rs index a78d896325..0b7abd0751 100644 --- a/crates/openhuman-core/src/tools/orchestrator_tools_tests.rs +++ b/crates/openhuman-core/src/tools/orchestrator_tools_tests.rs @@ -79,18 +79,44 @@ fn integration(toolkit: &str, description: &str) -> ConnectedIntegration { } } +fn integration_with_actions( + toolkit: &str, + description: &str, + actions: &[&str], +) -> ConnectedIntegration { + let mut ci = integration(toolkit, description); + ci.tools = actions + .iter() + .map(|name| crate::agent::prompts::ConnectedIntegrationTool { + name: (*name).to_string(), + description: format!("{name} action"), + parameters: None, + }) + .collect(); + ci +} + /// 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 archetype tools + 1 collapsed integrations delegation tool -/// (#1335) — independent of how many integrations are connected. +/// 2 archetype tools plus one `Deferred` action tool per connected +/// action — and no `delegate_to_integrations_agent`. One clear service +/// action is a `tool_search` and a call, never a sub-agent spawn. #[test] -fn collects_agentid_entries_and_collapses_skills_wildcard() { +fn collects_agentid_entries_and_expands_skills_wildcard_to_deferred_actions() { 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_with_actions( + "gmail", + "Send and read email via Gmail.", + &["GMAIL_SEND_EMAIL", "GMAIL_FETCH_EMAILS"], + ), + integration_with_actions( + "github", + "Manage repos, issues, and pull requests.", + &["GITHUB_CREATE_ISSUE"], + ), integration("notion", "Read and write pages and databases."), ]; @@ -106,9 +132,16 @@ fn collects_agentid_entries_and_collapses_skills_wildcard() { // restored. "research", // researcher's delegate_name override "delegate_archivist", // archivist has no delegate_name → default - "delegate_to_integrations_agent", + // Actions sorted by toolkit, then action name. + "GITHUB_CREATE_ISSUE", + "GMAIL_FETCH_EMAILS", + "GMAIL_SEND_EMAIL", ], - "skills wildcard must collapse to a single delegate_to_integrations_agent tool" + "skills wildcard must expand to the connected actions, not a delegation tool" + ); + assert!( + !names.iter().any(|name| name.starts_with("delegate_to_")), + "no integrations delegation tool may be synthesised" ); // Archetype tool descriptions come from `when_to_use`. @@ -118,47 +151,54 @@ fn collects_agentid_entries_and_collapses_skills_wildcard() { "delegate description is the target's when_to_use" ); - // The collapsed delegation tool enumerates every connected toolkit - // in its description so the orchestrator still discovers what's - // routable. - let delegate_tool = tools - .iter() - .find(|t| t.name() == "delegate_to_integrations_agent") - .unwrap(); - let desc = delegate_tool.description(); - assert!(desc.contains("gmail")); - assert!(desc.contains("github")); - assert!(desc.contains("notion")); + // Every action is `Deferred`: off the wire, reachable through + // `tool_search`. The delegation tools stay `Direct`. + for tool in &tools { + let expected = if tool.name().starts_with("delegate_") || tool.name() == "research" { + tinytools::ToolExposure::Direct + } else { + tinytools::ToolExposure::Deferred + }; + assert_eq!(tool.exposure(), expected, "exposure of {}", tool.name()); + } } -/// The collapsed delegation tool's count is constant in the -/// integration dimension (#1335 primary acceptance criterion). +/// The synthesised set scales only with the connected *actions*, never +/// adds a per-toolkit or collapsed delegation handle. #[test] -fn collapsed_delegation_tool_count_is_constant_across_integration_counts() { +fn skills_wildcard_adds_no_delegation_tool_for_any_integration_count() { let orch = sample_orchestrator(); let reg = registry_with_targets(); for n in [1usize, 3, 7, 20] { let integrations: Vec<_> = (0..n) - .map(|i| integration(&format!("tool{i}"), &format!("Toolkit number {i}."))) + .map(|i| { + integration_with_actions( + &format!("tool{i}"), + &format!("Toolkit number {i}."), + &[&format!("TOOL{i}_ACT")], + ) + }) .collect(); let tools = collect_orchestrator_tools(&orch, ®, &integrations); let delegation_count = tools .iter() - .filter(|t| t.name() == "delegate_to_integrations_agent") + .filter(|t| t.name().starts_with("delegate_to_")) + .count(); + assert_eq!(delegation_count, 0, "no integrations delegate for {n} integrations"); + let action_count = tools + .iter() + .filter(|t| t.exposure() == tinytools::ToolExposure::Deferred) .count(); - assert_eq!( - delegation_count, 1, - "expected exactly one collapsed delegation tool for {n} integrations" - ); + assert_eq!(action_count, n, "one deferred action per connected action"); } } /// An orchestrator with a Skills wildcard but no connected -/// integrations should produce zero integrations delegation tools — -/// the LLM must not be shown a routing handle for an empty set. +/// integrations should produce zero integration tools — nothing to +/// search for, nothing to advertise. #[test] -fn skills_wildcard_with_no_integrations_produces_no_delegation_tool() { +fn skills_wildcard_with_no_integrations_produces_no_integration_tools() { let orch = sample_orchestrator(); let reg = registry_with_targets(); let tools = collect_orchestrator_tools(&orch, ®, &[]); @@ -270,148 +310,73 @@ fn sanitise_slug_lowercases_and_replaces_invalid_chars() { assert_eq!(sanitise_slug("weird name!"), "weird_name_"); } -/// Unconnected integrations must be silently dropped from the -/// collapsed delegation tool's enum. Otherwise the orchestrator -/// could supply `toolkit = ""` and trigger a pre-flight -/// rejection downstream that says "not connected". +/// Unconnected integrations contribute no actions: the orchestrator +/// must not find (and call) an action on a toolkit the user has not +/// authorised and hit a "not connected" rejection downstream. #[test] -fn unconnected_integrations_are_omitted_from_collapsed_tool() { +fn unconnected_integrations_contribute_no_actions() { let orch = sample_orchestrator(); let reg = registry_with_targets(); let integrations = vec![ - integration("gmail", "Send and read email."), + integration_with_actions("gmail", "Send and read email.", &["GMAIL_SEND_EMAIL"]), ConnectedIntegration { toolkit: "github".into(), description: "GitHub access.".into(), - tools: vec![], + tools: vec![crate::agent::prompts::ConnectedIntegrationTool { + name: "GITHUB_CREATE_ISSUE".into(), + description: "Create an issue".into(), + parameters: None, + }], gated_tools: vec![], - connected: false, // not connected — must not appear in the enum + connected: false, // not connected — its actions must not appear connections: Vec::new(), non_active_status: None, }, - integration("notion", "Read and write pages."), + integration_with_actions("notion", "Read and write pages.", &["NOTION_CREATE_PAGE"]), ]; let tools = collect_orchestrator_tools(&orch, ®, &integrations); - let delegate_tool = tools - .iter() - .find(|t| t.name() == "delegate_to_integrations_agent") - .expect("collapsed delegation tool must exist when at least one integration is connected"); - let desc = delegate_tool.description(); - assert!(desc.contains("gmail")); - assert!(desc.contains("notion")); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!(names.contains(&"GMAIL_SEND_EMAIL")); + assert!(names.contains(&"NOTION_CREATE_PAGE")); assert!( - !desc.contains("github"), - "unconnected github must not leak into the delegation tool description" + !names.contains(&"GITHUB_CREATE_ISSUE"), + "unconnected github must not leak an action into the catalogue" ); - - let schema = delegate_tool.parameters_schema(); - let enum_vals = schema["properties"]["toolkit"]["enum"] - .as_array() - .expect("toolkit enum must be present"); - let slugs: Vec<&str> = enum_vals.iter().map(|v| v.as_str().unwrap()).collect(); - assert_eq!(slugs, vec!["gmail", "notion"]); -} - -/// Quirky toolkit slugs (dashes, mixed case) must be canonicalised -/// before they land in the collapsed tool's enum so the -/// LLM-provided argument can be matched with `==` rather than a -/// fuzzy comparison. -#[test] -fn collapsed_tool_enum_uses_sanitised_slugs() { - let mut orch = def("orchestrator", "t", None); - orch.subagents = vec![SubagentEntry::Skills(SkillsWildcard { skills: "*".into() })]; - let reg = registry_with_targets(); - let integrations = vec![ - integration("Google-Calendar", "Calendar."), - integration("Slack.Bot", "Chat."), - ]; - let tools = collect_orchestrator_tools(&orch, ®, &integrations); - let delegate_tool = tools - .iter() - .find(|t| t.name() == "delegate_to_integrations_agent") - .expect("collapsed tool present"); - let schema = delegate_tool.parameters_schema(); - let enum_vals = schema["properties"]["toolkit"]["enum"].as_array().unwrap(); - let slugs: Vec<&str> = enum_vals.iter().map(|v| v.as_str().unwrap()).collect(); - assert_eq!(slugs, vec!["google_calendar", "slack_bot"]); } -/// An integration with an empty description must not render as a -/// bare ` - slug` line in the collapsed tool description — the -/// orchestrator LLM would have no signal about what the toolkit -/// does. The synthesiser falls back to a generic descriptive -/// phrase keyed on the raw toolkit name. +/// Actions are advertised in a stable order — toolkit, then action — +/// whatever order the backend listed the connections in, because the +/// synthesised set feeds the tool specs a session freezes. #[test] -fn empty_integration_description_falls_back_to_generic_label() { +fn deferred_actions_are_sorted_by_toolkit_then_action() { let mut orch = def("orchestrator", "t", None); orch.subagents = vec![SubagentEntry::Skills(SkillsWildcard { skills: "*".into() })]; let reg = registry_with_targets(); let integrations = vec![ - ConnectedIntegration { - toolkit: "Brand.New".into(), - description: " ".into(), - tools: vec![], - gated_tools: vec![], - connected: true, - connections: Vec::new(), - non_active_status: None, - }, - integration("gmail", "Email."), + integration_with_actions("slack", "Chat.", &["SLACK_SEND_MESSAGE", "SLACK_LIST_CHANNELS"]), + integration_with_actions("gmail", "Email.", &["GMAIL_SEND_EMAIL"]), ]; let tools = collect_orchestrator_tools(&orch, ®, &integrations); - let delegate_tool = tools - .iter() - .find(|t| t.name() == "delegate_to_integrations_agent") - .expect("collapsed tool present"); - let desc = delegate_tool.description(); - assert!( - desc.contains("External integration via Brand.New"), - "expected fallback phrasing, got: {desc}" + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert_eq!( + names, + vec!["GMAIL_SEND_EMAIL", "SLACK_LIST_CHANNELS", "SLACK_SEND_MESSAGE"] ); - assert!(desc.contains("Email.")); } -/// Two upstream toolkits whose names sanitise to the same slug -/// must not silently both land in the collapsed enum — the second -/// arrival is dropped (with a warn log) so the orchestrator's -/// routing handle stays unambiguous. Without this guard, -/// `Slack.Bot` and `Slack-Bot` would both render as `slack_bot` -/// in the enum and the orchestrator could no longer distinguish -/// them. +/// The same action slug arriving from two toolkits keeps the first +/// arrival (by sorted toolkit) so the catalogue never carries two tools +/// under one name. #[test] -fn duplicate_sanitised_slug_drops_later_collisions() { +fn duplicate_action_names_keep_the_first_arrival() { let mut orch = def("orchestrator", "t", None); orch.subagents = vec![SubagentEntry::Skills(SkillsWildcard { skills: "*".into() })]; let reg = registry_with_targets(); let integrations = vec![ - integration("Slack.Bot", "First slack."), - integration("Slack-Bot", "Second slack — must be dropped."), - integration("Notion", "Pages."), + integration_with_actions("slack", "Chat.", &["SHARED_ACTION"]), + integration_with_actions("gmail", "Email.", &["SHARED_ACTION", "GMAIL_SEND_EMAIL"]), ]; let tools = collect_orchestrator_tools(&orch, ®, &integrations); - let delegate_tool = tools - .iter() - .find(|t| t.name() == "delegate_to_integrations_agent") - .expect("collapsed tool present"); - let schema = delegate_tool.parameters_schema(); - let enum_vals = schema["properties"]["toolkit"]["enum"].as_array().unwrap(); - let slugs: Vec<&str> = enum_vals.iter().map(|v| v.as_str().unwrap()).collect(); - // Sorted, not arrival order: the enum is advertised in a cached prefix, so - // its order is fixed by slug rather than by however the backend happened to - // list the connections (see `collect_orchestrator_tools`). The collision - // rule this test is actually about is unaffected — "first arrival keeps the - // slug" is decided before the sort, and the description assertions below - // are what pin which of the two Slacks won. - assert_eq!( - slugs, - vec!["notion", "slack_bot"], - "second slack_bot collision must be dropped, not silently shadowed, \ - and the surviving slugs must be advertised in sorted order" - ); - // The dropped description must not appear in the tool description - // either — otherwise the orchestrator would think there's a route - // it can't actually distinguish. - let desc = delegate_tool.description(); - assert!(desc.contains("First slack.")); - assert!(!desc.contains("Second slack")); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert_eq!(names, vec!["GMAIL_SEND_EMAIL", "SHARED_ACTION"]); } From f7fe3029e6bcb31a2d91f764befea71acbdd5b72 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:34:08 +0300 Subject: [PATCH 16/56] refactor(tests): replace delegation-guard tests with search-bridge tests Update the orchestrator prompt tests to reflect the removal of the `delegate_to_integrations_agent` sub-agent in favour of a `tool_search` + direct call pattern. The old tests asserted delegation-specific behaviour and a format-dependent guardrail that no longer applies; the new tests verify the unified search-bridge block, its format independence, capability routing, and gated-tool listing. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../agents/orchestrator/prompt_tests.rs | 153 ++++++++---------- 1 file changed, 64 insertions(+), 89 deletions(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs index f04ae523eb..554ff5462c 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs @@ -280,9 +280,11 @@ fn build_includes_direct_first_decision_tree() { assert!(body.contains("Take the first branch that applies:")); assert!(body.contains("**Answerable without tools**: reply.")); // Step 2 of the decision tree routes live external-service requests to - // `delegate_to_integrations_agent` rather than memory. + // a `tool_search` + direct call rather than memory or a sub-agent. assert!(body.contains("Needs a connected service's own data or actions")); assert!(body.contains("Use the live service even when memory could plausibly answer")); + assert!(body.contains("there is no integrations sub-agent")); + assert!(!body.contains("delegate_to_integrations_agent")); } #[test] @@ -312,7 +314,7 @@ fn build_routes_code_repo_work_to_run_code_tool() { } #[test] -fn build_emits_delegation_guide_with_collapsed_tool() { +fn build_emits_connected_integrations_as_search_then_call() { let integrations = vec![ConnectedIntegration { toolkit: "gmail".into(), description: "Email access.".into(), @@ -324,18 +326,22 @@ fn build_emits_delegation_guide_with_collapsed_tool() { }]; let body = build(&ctx_with(&integrations)).unwrap(); assert!(body.contains("## Connected Integrations")); - assert!(body.contains("delegate_to_integrations_agent")); assert!(body.contains("toolkit: \"gmail\"")); - // Must NOT contain the old per-toolkit fan-out tool names. + // The route is the harness's search bridge, not a sub-agent. + assert!(body.contains("`tool_search` for the action")); + assert!(body.contains("no sub-agent")); + // The removed delegate and the old per-toolkit fan-out must be gone. + assert!(!body.contains("delegate_to_integrations_agent")); assert!(!body.contains("delegate_gmail")); - // Must NOT contain the old verbose spawn_subagent snippet. + assert!(!body.contains("integrations_agent")); assert!(!body.contains("spawn_subagent(agent_id=\"integrations_agent\"")); - // Delegator voice must NOT use the skill-executor wording. + // The "you have direct access" skill-executor wording stays out: the + // actions are not on the wire, they are searchable. assert!(!body.contains("You have direct access")); - // Must keep the always-delegate contract for real service asks. + // Must keep the always-try contract for real service asks. assert!( - body.contains("Never claim you cannot access one without delegating first"), - "delegation guide must instruct the model to always attempt delegation" + body.contains("Never claim you cannot access one without searching first"), + "the block must instruct the model to search before refusing" ); } @@ -343,13 +349,13 @@ fn build_emits_delegation_guide_with_collapsed_tool() { fn build_scope_gates_integrations_delegation() { // Regression: a connected service (e.g. Gmail) is not, by itself, a // reason to operate on it — a general-knowledge / web / date ask that - // names no service must NOT spawn `delegate_to_integrations_agent`. + // names no service must NOT reach for a service action. // Guards both the static Step-2 scope gate and the rendered - // delegation-guide clause. + // connected-integrations clause. let no_integrations = build(&ctx_with(&[])).unwrap(); assert!( - no_integrations.contains("general knowledge, web/news lookups, headlines, date/time and math never delegate here"), - "Step-2 scope gate must keep general/web/date asks off integrations delegation" + no_integrations.contains("general knowledge, web/news lookups, headlines, date/time and math never go to a service"), + "Step-2 scope gate must keep general/web/date asks off integration actions" ); assert!( no_integrations.contains("A service being connected is not a reason to touch it"), @@ -369,10 +375,10 @@ fn build_scope_gates_integrations_delegation() { assert!( with_gmail .contains("a connected service is not a reason to touch it for general-knowledge"), - "delegation guide must carry the scoping clause when integrations are connected" + "connected-integrations block must carry the scoping clause when integrations are connected" ); - // The existing always-delegate contract for real service asks is preserved. - assert!(with_gmail.contains("Never claim you cannot access one without delegating first")); + // The existing always-try contract for real service asks is preserved. + assert!(with_gmail.contains("Never claim you cannot access one without searching first")); } #[test] @@ -383,29 +389,10 @@ fn build_does_not_route_scope_errors_as_disconnected() { // connectable list. assert!(body.contains("If the connect call reports the toolkit unavailable, relay its message")); assert!(body.contains("that is the only honest refusal")); - assert!(body.contains("the list shows what is connected, not what is connectable")); + assert!(body.contains("shows what is connected, not what is connectable")); assert!(body.contains("`composio_connect`")); } -#[test] -fn delegation_guide_uses_compact_collapsed_format() { - let integrations = vec![ConnectedIntegration { - toolkit: "gmail".into(), - description: "Email access.".into(), - tools: Vec::new(), - gated_tools: Vec::new(), - connected: true, - connections: Vec::new(), - non_active_status: None, - }]; - let body = build(&ctx_with(&integrations)).unwrap(); - assert!(body.contains("## Connected Integrations")); - assert!(body.contains("delegate_to_integrations_agent")); - // Old verbose / per-toolkit forms must be gone. - assert!(!body.contains("delegate_gmail")); - assert!(!body.contains("spawn_subagent(agent_id=\"integrations_agent\"")); -} - fn gmail_only() -> Vec { vec![ConnectedIntegration { toolkit: "gmail".into(), @@ -418,66 +405,54 @@ fn gmail_only() -> Vec { }] } -// Regression for #4361: on local providers (`native_tool_calling = false` -// → PFormat/Json dispatcher) the whole tool catalogue is prose and weak -// models mis-route trivial requests through the integrations delegate -// ("Ciao" → Connections, "create a folder on Desktop" → Calendar). The -// delegation guide must add an explicit non-delegation carve-out for those -// text-protocol providers. +// The block is the same for every dispatcher. The text-protocol "When NOT to +// delegate" carve-out (#4361) guarded a delegate tool that no longer exists; +// the scoping clause in the block body carries that rule for every format. #[test] -fn delegation_guide_adds_local_guardrail_for_text_protocol() { - let integrations = gmail_only(); - for format in [ToolCallFormat::PFormat, ToolCallFormat::Json] { - let guide = render_delegation_guide(&integrations, format); - assert!( - guide.contains("### When NOT to delegate"), - "text-protocol ({format:?}) guide must carve out non-integration work" - ); - // The two reported failure modes are named explicitly. - assert!( - guide.contains("create a folder on the Desktop"), - "guardrail must keep local folder/file actions off delegation ({format:?})" - ); - assert!( - guide.to_ascii_lowercase().contains("greetings"), - "guardrail must keep greetings off delegation ({format:?})" - ); - // Additive: the always-delegate contract for real service requests - // is preserved — the guardrail narrows, it does not remove it. - assert!( - guide.contains("Never claim you cannot access one without delegating first"), - "always-delegate contract must remain for genuine service asks ({format:?})" - ); - } +fn connected_integrations_block_is_format_independent() { + let guide = render_connected_integrations(&gmail_only()); + assert!(guide.contains("## Connected Integrations")); + assert!(guide.contains("`tool_search` for the action")); + assert!(!guide.contains("### When NOT to delegate")); + assert!(guide.contains("a connected service is not a reason to touch it")); + assert!(guide.contains("Never claim you cannot access one without searching first")); } -// Native structured-tool-calling providers (cloud) keep the historic guide -// byte-for-byte: no over-delegation problem, so no carve-out. +// Capability questions are answered from the searchable catalogue, never +// from priors and never by spawning a worker to look. #[test] -fn delegation_guide_omits_local_guardrail_for_native() { - let guide = render_delegation_guide(&gmail_only(), ToolCallFormat::Native); - assert!(guide.contains("## Connected Integrations")); - assert!( - !guide.contains("### When NOT to delegate"), - "native providers must keep the delegation guide unchanged" - ); - assert!(guide.contains("Never claim you cannot access one without delegating first")); +fn connected_integrations_block_routes_capability_questions_to_search() { + let guide = render_connected_integrations(&gmail_only()); + assert!(guide.contains("### Capability questions about connected toolkits")); + assert!(guide.contains("`tool_search` first")); + assert!(!guide.contains("integrations_agent")); } -// With no connected integrations the section is omitted for every format — -// the guardrail must never resurrect an otherwise-empty block. +// Pref-gated actions are not searchable, so the block lists them with their +// unlock paths — the appendix that used to live in the integrations +// sub-agent's prompt. #[test] -fn delegation_guide_empty_without_connections_for_all_formats() { - for format in [ - ToolCallFormat::PFormat, - ToolCallFormat::Json, - ToolCallFormat::Native, - ] { - assert!( - render_delegation_guide(&[], format).is_empty(), - "empty connections must omit the section ({format:?})" - ); - } +fn connected_integrations_block_lists_gated_actions_with_unlock_paths() { + let mut integrations = gmail_only(); + integrations[0].gated_tools = vec![crate::agent::prompts::GatedIntegrationTool { + name: "GMAIL_DELETE_MESSAGE".into(), + description: "Delete a message".into(), + required_scope: "delete".into(), + unlock_paths: vec!["Connections → Gmail → Delete".into()], + }]; + let guide = render_connected_integrations(&integrations); + assert!(guide.contains("### Additional capabilities behind a permission toggle")); + assert!(guide.contains("`GMAIL_DELETE_MESSAGE` — Delete a message (requires `delete` scope)")); + assert!(guide.contains("unlock path: Connections → Gmail → Delete")); + + let without = render_connected_integrations(&gmail_only()); + assert!(!without.contains("### Additional capabilities behind a permission toggle")); +} + +// With no connected integrations the section is omitted. +#[test] +fn connected_integrations_block_empty_without_connections() { + assert!(render_connected_integrations(&[]).is_empty()); } #[test] From 70618dbc5f451b1d2cbe4f9041a049e5599184d6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:34:29 +0300 Subject: [PATCH 17/56] test(orchestration): replace delegate_to_integrations_agent with research tool in tests The test assertions and mock data are updated to reflect the replacement of the `delegate_to_integrations_agent` tool with the new `research` tool across orchestration, registry, and session host tests. Comments explaining the old delegation mechanism are also revised to describe the new searchable integration actions approach. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tools/agent_prepare_context_tests.rs | 11 ++++------- .../agents/loader_tests_orchestrator_tier_tests.rs | 6 ++---- .../src/agent/session_host/runtime_adapter_tests.rs | 3 ++- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/openhuman-core/src/agent/orchestration/tools/agent_prepare_context_tests.rs b/crates/openhuman-core/src/agent/orchestration/tools/agent_prepare_context_tests.rs index 5b0bc37529..e3b77490f0 100644 --- a/crates/openhuman-core/src/agent/orchestration/tools/agent_prepare_context_tests.rs +++ b/crates/openhuman-core/src/agent/orchestration/tools/agent_prepare_context_tests.rs @@ -36,14 +36,14 @@ fn build_scout_prompt_includes_request_focus_and_catalog() { let prompt = AgentPrepareContextTool::build_scout_prompt( "summarise my unread gmail", Some("last 24h"), - "- delegate_to_integrations_agent: route to a connected integration\n", + "- research: web and docs crawler\n", ); assert!(prompt.contains("[Request]")); assert!(prompt.contains("summarise my unread gmail")); assert!(prompt.contains("[Focus]")); assert!(prompt.contains("last 24h")); assert!(prompt.contains("[Orchestrator tools]")); - assert!(prompt.contains("delegate_to_integrations_agent")); + assert!(prompt.contains("research")); assert!(prompt.contains("[context_bundle]")); } @@ -398,16 +398,13 @@ async fn catalog_lists_the_parents_synthesised_delegates_from_its_visible_specs( ], vec![ catalog_spec("echo", "durable"), - catalog_spec( - "delegate_to_integrations_agent", - "route to a connected integration", - ), + catalog_spec("research", "web and docs crawler"), catalog_spec("agent_prepare_context", "this tool"), ], ); let catalog = AgentPrepareContextTool::render_parent_tool_catalog(Some(&ctx)); assert!( - catalog.contains("- delegate_to_integrations_agent: route to a connected integration\n"), + catalog.contains("- research: web and docs crawler\n"), "the parent's delegate must be recommendable: {catalog:?}" ); assert!(catalog.contains("- echo: durable\n")); diff --git a/crates/openhuman-core/src/agent/registry/agents/loader_tests_orchestrator_tier_tests.rs b/crates/openhuman-core/src/agent/registry/agents/loader_tests_orchestrator_tier_tests.rs index 97031c47d9..fe40dd7e39 100644 --- a/crates/openhuman-core/src/agent/registry/agents/loader_tests_orchestrator_tier_tests.rs +++ b/crates/openhuman-core/src/agent/registry/agents/loader_tests_orchestrator_tier_tests.rs @@ -112,7 +112,6 @@ fn crypto_agent_has_narrow_wallet_market_tools_and_safety_on() { "composio_list_tools", "spawn_subagent", "spawn_worker_thread", - "delegate_to_integrations_agent", // Synthesised delegation tools use the unprefixed // `delegate_name` overrides — forbid those names too. "run_code", @@ -504,9 +503,8 @@ fn rejects_worker_with_subagents() { #[test] fn allows_skill_wildcards_on_any_non_worker_tier() { - // Skills wildcards collapse to delegate_to_integrations_agent - // and must not be policed by the tier check (it'd be a false - // positive — they fan out to a worker anyway). + // Skills wildcards expand to searchable integration actions, not to + // an agent, so there is no tier pair for the check to police. let mut defs = load_builtins().unwrap(); let planner = defs.iter_mut().find(|d| d.id == "planner").unwrap(); planner.subagents.push(SubagentEntry::Skills( diff --git a/crates/openhuman-core/src/agent/session_host/runtime_adapter_tests.rs b/crates/openhuman-core/src/agent/session_host/runtime_adapter_tests.rs index b0b68fe35c..0b127693c9 100644 --- a/crates/openhuman-core/src/agent/session_host/runtime_adapter_tests.rs +++ b/crates/openhuman-core/src/agent/session_host/runtime_adapter_tests.rs @@ -400,7 +400,8 @@ fn availability_notes_are_status_not_instructions() { ); assert!(note.contains("Do not tell the user to reconnect or restart")); } - assert!(notes[0].contains("delegate_to_integrations_agent") && notes[0].contains("gmail")); + assert!(notes[0].contains("tool_search") && notes[0].contains("gmail")); + assert!(!notes[0].contains("delegate_to_integrations_agent")); assert!(notes[1].contains("use_mcp_server") && notes[1].contains("filesystem, github")); assert!(notes[2].contains("run_skill") && notes[2].contains("deploy")); assert!(integration_announcement_note(&[]).is_none()); From e3133c6366ae279fa83d47ef566d5a26d11807d7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:34:42 +0300 Subject: [PATCH 18/56] test(dispatch): verify retired delegation name no longer selects typed dispatch Replace the loop that checked every synthesised delegation name with separate assertions for the collapsed and retired tools, and update the test helper calls to use "research" instead of the retired "delegate_to_integrations_agent" name. This ensures that a stale tool by the retired name cannot spawn a sub-agent for a single integration action. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../orchestration/tools/dispatch_tests.rs | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/openhuman-core/src/agent/orchestration/tools/dispatch_tests.rs b/crates/openhuman-core/src/agent/orchestration/tools/dispatch_tests.rs index 6bb7948b51..48714b1cb3 100644 --- a/crates/openhuman-core/src/agent/orchestration/tools/dispatch_tests.rs +++ b/crates/openhuman-core/src/agent/orchestration/tools/dispatch_tests.rs @@ -68,19 +68,24 @@ fn typed_dispatch_registration_recognises_every_synthesised_delegate_surface() { }]) .expect("one collapsed target is routable"), ); - let integration = Arc::new(DelegationRegistrationTool { + assert!( + DelegationDispatch::for_tool(collapsed.clone()).is_some(), + "every synthesised delegation name must select the typed dispatch: {}", + collapsed.name(), + ); + // The retired integrations delegate is not a delegation surface any more: + // its name must not select a dispatcher, so a stale tool by that name + // cannot spawn a sub-agent for one integration action. + let retired = Arc::new(DelegationRegistrationTool { name: "delegate_to_integrations_agent", parameters: serde_json::json!({ "properties": { "toolkit": { "enum": ["gmail"] } } }), }); - for tool in [collapsed, integration] { - assert!( - DelegationDispatch::for_tool(tool.clone()).is_some(), - "every synthesised delegation name must select the typed dispatch: {}", - tool.name(), - ); - } + assert!( + DelegationDispatch::for_tool(retired).is_none(), + "delegate_to_integrations_agent must no longer select the typed dispatch" + ); } #[test] @@ -453,7 +458,7 @@ fn an_async_delegation_keeps_its_output_untouched() { #[test] fn the_incomplete_envelope_frames_a_stub_without_claiming_success() { let envelope = super::incomplete_envelope( - "delegate_to_integrations_agent", + "research", "returned an unexecuted tool call instead of a result", "GMAIL_LIST_MESSAGES", super::DispatchMode::Blocking, @@ -478,7 +483,7 @@ fn an_unfinished_envelope_never_claims_completeness() { assert!(done.contains("complete as returned")); let unfinished = super::incomplete_envelope( - "delegate_to_integrations_agent", + "research", "hit its iteration cap", "partial", super::DispatchMode::Blocking, From 6b1a1a70f64cf0a296665d13e8b29e23306c9dec Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:34:52 +0300 Subject: [PATCH 19/56] test(orchestration): remove obsolete skill delegation e2e test The `skill_delegation_tool_runs_integrations_agent_e2e` test was removed because the `SkillDelegationTool` it tested has been deleted from the codebase, making the test no longer compilable or relevant. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../orchestration/tools/tools_e2e_tests.rs | 62 +------------------ 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/crates/openhuman-core/src/agent/orchestration/tools/tools_e2e_tests.rs b/crates/openhuman-core/src/agent/orchestration/tools/tools_e2e_tests.rs index 057bb51604..950caec63a 100644 --- a/crates/openhuman-core/src/agent/orchestration/tools/tools_e2e_tests.rs +++ b/crates/openhuman-core/src/agent/orchestration/tools/tools_e2e_tests.rs @@ -1,7 +1,4 @@ -use super::{ - ArchetypeDelegationTool, DelegationTarget, SkillDelegationTool, SpawnSubagentTool, - SpawnWorkerThreadTool, -}; +use super::{ArchetypeDelegationTool, DelegationTarget, SpawnSubagentTool, SpawnWorkerThreadTool}; use crate::agent::harness::definition::AgentDefinitionRegistry; use crate::agent::harness::{with_parent_context, ParentExecutionContext}; use crate::agent::messages::ChatMessage; @@ -348,63 +345,6 @@ async fn continue_subagent_without_checkpoint_or_durable_session_names_the_roste ); } -#[tokio::test] -async fn skill_delegation_tool_runs_integrations_agent_e2e() { - let _ = AgentDefinitionRegistry::init_global_builtins(); - let workspace = tempfile::TempDir::new().expect("workspace"); - let provider = Arc::new(ScriptedModel::new(vec![( - SKILL_DELEGATION_CANARY, - "skill-delegation-child-answer", - )])); - let tool = SkillDelegationTool::for_connected(vec![( - "gmail".to_string(), - "Email access.".to_string(), - )]) - .expect("delegation tool"); - - let result = with_parent_context( - parent_context( - workspace.path(), - provider.clone(), - vec![ConnectedIntegration { - toolkit: "gmail".to_string(), - description: "Email access.".to_string(), - tools: Vec::new(), - gated_tools: Vec::new(), - connected: true, - connections: Vec::new(), - non_active_status: None, - }], - ), - async { - tool.execute(json!({ - "toolkit": "gmail", - "prompt": format!("Summarize inbox state for {SKILL_DELEGATION_CANARY}"), - "model": "test-model" - })) - .await - }, - ) - .await - .expect("tool execution"); - - assert!(!result.is_error, "{}", result.output()); - // The sub-agent's answer comes back verbatim, followed by the - // inline-result note: this delegation is blocking and registers no - // worker, so the orchestrator must not go hunting for one (#6033). - let output = result.output(); - assert!( - output.starts_with("skill-delegation-child-answer"), - "the child's answer must lead the result: {output}" - ); - assert!( - output.contains("[INLINE_RESULT]") && output.contains("no sub-agent worker"), - "a blocking delegation must say its result is inline: {output}" - ); - assert!(provider.saw(SKILL_DELEGATION_CANARY)); - assert!(provider.saw("gmail")); -} - #[tokio::test] async fn spawn_worker_thread_tool_persists_worker_thread_e2e() { let _ = AgentDefinitionRegistry::init_global_builtins(); From 598938ddb3ec7c98f79b5d03d37efec5e829254c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:35:02 +0300 Subject: [PATCH 20/56] docs(collapsed_delegation): update module-level doc to reflect current architecture The module documentation for collapsed delegation was outdated, still referencing a symmetry between integration and sub-agent axes that no longer holds. The integration axis now uses `Deferred` tools and `tool_search` instead of a delegation tool, so the comment is updated to describe the current design accurately. A stale test canary constant is also removed. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../agent/orchestration/tools/collapsed_delegation.rs | 10 ++++------ .../src/agent/orchestration/tools/tools_e2e_tests.rs | 1 - 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation.rs b/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation.rs index 766ac4b3d6..0e883d808c 100644 --- a/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation.rs +++ b/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation.rs @@ -15,12 +15,10 @@ //! enum, with each target's `when_to_use` kept verbatim in the description — //! the routing information survives in full, the repetition does not. //! -//! This is the same collapse [`SkillDelegationTool`] already applied to the -//! *other* delegation axis (#1335): one `delegate_to_integrations_agent` with -//! a `toolkit` argument, instead of one `delegate_` per connected -//! Composio integration. That change made the schema constant in the -//! integration dimension; this one makes it constant in the sub-agent -//! dimension. The two are now consistent. +//! The integration axis went a step further: connected Composio actions are +//! `Deferred` tools reached through `tool_search` and called directly, so +//! that axis has no delegation tool at all (`orchestrator_tools`). This one +//! makes the sub-agent axis constant in the sub-agent dimension. //! //! # Why collapse rather than pack //! diff --git a/crates/openhuman-core/src/agent/orchestration/tools/tools_e2e_tests.rs b/crates/openhuman-core/src/agent/orchestration/tools/tools_e2e_tests.rs index 950caec63a..6cca4e02b6 100644 --- a/crates/openhuman-core/src/agent/orchestration/tools/tools_e2e_tests.rs +++ b/crates/openhuman-core/src/agent/orchestration/tools/tools_e2e_tests.rs @@ -17,7 +17,6 @@ use tinytools::Tool; const SPAWN_SUBAGENT_CANARY: &str = "tool-e2e-spawn-subagent-canary"; const ARCHETYPE_DELEGATION_CANARY: &str = "tool-e2e-archetype-delegation-canary"; -const SKILL_DELEGATION_CANARY: &str = "tool-e2e-skill-delegation-canary"; const WORKER_THREAD_CANARY: &str = "tool-e2e-worker-thread-canary"; #[tokio::test] From 1cd6d899142beffe288f06d7ddd5bda32e637eff Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:35:09 +0300 Subject: [PATCH 21/56] chore(docs): remove stale doc references from collapsed delegation module Removed outdated cross-references to `SkillDelegationTool` and a sibling tool comment that no longer reflect the current codebase structure, keeping the module documentation accurate and concise. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/orchestration/tools/collapsed_delegation.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation.rs b/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation.rs index 0e883d808c..a3db603cbe 100644 --- a/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation.rs +++ b/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation.rs @@ -50,12 +50,9 @@ //! builder's collision guard resolves a clash by dropping the *synthesised* //! tool. Naming this one `delegate` would therefore have removed the //! orchestrator's entire delegation surface for exactly those users, silently. -//! It also puts this tool in the same family as its sibling -//! `delegate_to_integrations_agent`. //! //! [`DelegateTool`]: crate::agent::tools::DelegateTool //! [`ArchetypeDelegationTool`]: super::ArchetypeDelegationTool -//! [`SkillDelegationTool`]: super::SkillDelegationTool //! [`ToolExposure::Hidden`]: tinytools::ToolExposure::Hidden use async_trait::async_trait; From 91cd0eb0ba676b99c421d562e0d941264e8c8b5b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:35:20 +0300 Subject: [PATCH 22/56] chore(docs): remove stale cross-reference from collapsed delegation doc The doc comment on `CollapsedDelegationTool::for_targets` and the corresponding test comment both referenced `SkillDelegationTool::for_connected`, which no longer exists. The cross-reference is removed to avoid confusion. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/orchestration/tools/collapsed_delegation.rs | 6 +----- .../agent/orchestration/tools/collapsed_delegation_tests.rs | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation.rs b/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation.rs index a3db603cbe..55a2cce0c8 100644 --- a/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation.rs +++ b/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation.rs @@ -97,11 +97,7 @@ impl CollapsedDelegationTool { /// Build the collapsed tool, or `None` when there is nothing to route to. /// /// `None` rather than an empty enum: a `delegate` tool whose `agent` has no - /// valid value is a schema the model can only call wrongly, and the - /// sibling [`SkillDelegationTool::for_connected`] already returns `None` on - /// an empty toolkit list for the same reason. - /// - /// [`SkillDelegationTool::for_connected`]: super::SkillDelegationTool::for_connected + /// valid value is a schema the model can only call wrongly. pub fn for_targets(targets: Vec) -> Option { if targets.is_empty() { return None; diff --git a/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation_tests.rs b/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation_tests.rs index 0de914fdcb..96a2281805 100644 --- a/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation_tests.rs +++ b/crates/openhuman-core/src/agent/orchestration/tools/collapsed_delegation_tests.rs @@ -31,7 +31,7 @@ fn tool() -> CollapsedDelegationTool { #[test] fn an_empty_target_list_produces_no_tool() { // An `agent` enum with no valid value is a schema the model can only call - // wrongly. Mirrors `SkillDelegationTool::for_connected`. + // wrongly. assert!(CollapsedDelegationTool::for_targets(Vec::new()).is_none()); } From 0605f1789fd3c1899178d4da26a637046b658d90 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:37:07 +0300 Subject: [PATCH 23/56] fix(dispatch): correct doc and test for worker spawn exemption Updated the documentation for `AgentTurnRequest` to reflect that the per-turn synthesised tools now include only `ArchetypeDelegationTool` instances and deferred Composio action tools, removing the outdated reference to `SkillDelegationTool`. Revised the comment in the worker spawn gate to clarify that a worker's `subagents` list never contains an agent id, so any runtime spawn is host-dispatched rather than originating from a collapsed integration path. Renamed the corresponding test to `tier_gate_allows_worker_parent` and updated its doc comment to match the new rationale, ensuring the test accurately guards against regressions for wildcard-integration scenarios. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/agent/bus.rs | 6 +++--- .../src/agent/subagent_host/ops/runner.rs | 7 +++---- .../subagent_host/ops_tests_tier_gate_tests.rs | 13 +++++++------ .../runtime/dispatch/mod_scoping_tests_tests.rs | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/openhuman-core/src/agent/bus.rs b/crates/openhuman-core/src/agent/bus.rs index c257cf9580..c70570d96d 100644 --- a/crates/openhuman-core/src/agent/bus.rs +++ b/crates/openhuman-core/src/agent/bus.rs @@ -114,9 +114,9 @@ pub struct AgentTurnRequest { pub visible_tool_names: Option>, /// Per-turn synthesised tools to splice alongside `tools_registry`. - /// The dispatch path uses this to carry `ArchetypeDelegationTool` / - /// `SkillDelegationTool` instances built fresh each turn from the - /// active agent's `subagents` field and the current Composio + /// The dispatch path uses this to carry `ArchetypeDelegationTool` + /// instances and deferred Composio action tools built fresh each turn + /// from the active agent's `subagents` field and the current Composio /// integrations — tools that don't exist in the global startup /// registry because they depend on per-user runtime state. /// Empty vec for agents that don't delegate. diff --git a/crates/openhuman-core/src/agent/subagent_host/ops/runner.rs b/crates/openhuman-core/src/agent/subagent_host/ops/runner.rs index dd48440930..9c08bdda81 100644 --- a/crates/openhuman-core/src/agent/subagent_host/ops/runner.rs +++ b/crates/openhuman-core/src/agent/subagent_host/ops/runner.rs @@ -65,10 +65,9 @@ use super::provider::{ /// initialised. A `None` parent yields `Ok(())`: we skip rather than mask, the /// same defensive posture the loader takes for unknown child ids. /// -/// A **worker** parent is also exempted. At runtime a worker only reaches the -/// spawn chokepoint via the documented collapsed `delegate_to_integrations_agent` -/// path (→ `integrations_agent`, itself a worker) — a shape the loader -/// intentionally leaves untouched. Re-denying it here would turn valid custom +/// A **worker** parent is also exempted. A worker's `subagents` list holds no +/// agent id (the loader rejects one), so any spawn it reaches at runtime is +/// one the host dispatched for it. Re-denying it here would turn valid custom /// worker agents that use `{ skills = "*" }` into runtime failures. The /// worker-leaf authoring rule stays enforced statically at boot, and the /// per-parent allowlist gate blocks any other worker spawn. diff --git a/crates/openhuman-core/src/agent/subagent_host/ops_tests_tier_gate_tests.rs b/crates/openhuman-core/src/agent/subagent_host/ops_tests_tier_gate_tests.rs index 0cf2602b67..a024fe8af5 100644 --- a/crates/openhuman-core/src/agent/subagent_host/ops_tests_tier_gate_tests.rs +++ b/crates/openhuman-core/src/agent/subagent_host/ops_tests_tier_gate_tests.rs @@ -67,13 +67,14 @@ fn tier_gate_allows_legal_descending_hops() { } #[test] -fn tier_gate_allows_worker_parent_for_collapsed_integration() { +fn tier_gate_allows_worker_parent() { use crate::agent::harness::definition::AgentTier; - // A worker only reaches the runtime spawn chokepoint via the documented - // collapsed `delegate_to_integrations_agent` path (→ `integrations_agent`, - // itself a worker). The gate must NOT re-deny that — the worker-leaf rule - // is a static boot-time authoring constraint, not a runtime one. Regression - // for the wildcard-integration case (CodeRabbit P2 on PR #4102). + // A worker's `subagents` list holds no agent id (the loader rejects + // one), so any spawn it reaches at runtime is one the host dispatched for + // it. The gate must NOT re-deny that — the worker-leaf rule is a static + // boot-time authoring constraint, not a runtime one; the per-parent + // allowlist gate blocks any other worker spawn. Regression for the + // wildcard-integration case (CodeRabbit P2 on PR #4102). let mut parent = make_def_named_tools(&[]); let child = make_def_named_tools(&[]); // worker by default parent.agent_tier = AgentTier::Worker; diff --git a/crates/openhuman-core/src/channels/runtime/dispatch/mod_scoping_tests_tests.rs b/crates/openhuman-core/src/channels/runtime/dispatch/mod_scoping_tests_tests.rs index 0c4c2ebe38..e95ae435ef 100644 --- a/crates/openhuman-core/src/channels/runtime/dispatch/mod_scoping_tests_tests.rs +++ b/crates/openhuman-core/src/channels/runtime/dispatch/mod_scoping_tests_tests.rs @@ -110,7 +110,7 @@ fn named_scope_without_extras_returns_named_only() { /// `ToolScope::Named` with extras returns the union of the TOML /// named list and the extras' names. This is the orchestrator's /// path: direct tools from the TOML + the synthesised delegation -/// tools (`research`, `plan`, `delegate_to_integrations_agent`) +/// tools (`research`, `plan`) and deferred integration actions /// → all of them visible to the orchestrator's LLM. The stub /// names in this test are arbitrary; they exercise the union /// logic, not the real synthesiser. From 09078cc5a130698c65f7cb9aad7466a7d75ebfe1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:42:26 +0300 Subject: [PATCH 24/56] fix(tests): update exposure assertion to match hidden archetype delegates The test now filters tools to only check those whose names start with "G", since the archetype delegates are now hidden and only the collapsed `delegate_to` tool advertises them. This aligns the assertion with the current behaviour where only deferred actions are verified for exposure. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tools/orchestrator_tools_tests.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/openhuman-core/src/tools/orchestrator_tools_tests.rs b/crates/openhuman-core/src/tools/orchestrator_tools_tests.rs index 0b7abd0751..ffd24c2ed1 100644 --- a/crates/openhuman-core/src/tools/orchestrator_tools_tests.rs +++ b/crates/openhuman-core/src/tools/orchestrator_tools_tests.rs @@ -152,14 +152,15 @@ fn collects_agentid_entries_and_expands_skills_wildcard_to_deferred_actions() { ); // Every action is `Deferred`: off the wire, reachable through - // `tool_search`. The delegation tools stay `Direct`. - for tool in &tools { - let expected = if tool.name().starts_with("delegate_") || tool.name() == "research" { - tinytools::ToolExposure::Direct - } else { - tinytools::ToolExposure::Deferred - }; - assert_eq!(tool.exposure(), expected, "exposure of {}", tool.name()); + // `tool_search`. (The archetype delegates are `Hidden` — the collapsed + // `delegate_to` tool advertises them — so only the actions are checked.) + for tool in tools.iter().filter(|t| t.name().starts_with("G")) { + assert_eq!( + tool.exposure(), + tinytools::ToolExposure::Deferred, + "exposure of {}", + tool.name() + ); } } From 63ed3181401f828bbf6a68f19b742b000deaf5aa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:42:45 +0300 Subject: [PATCH 25/56] test(composio_list_tools_stack_overflow_regression): update comments to reflect removal of integrati Updated the regression test's documentation comments to accurately describe the current architecture, where the orchestrator no longer spawns an `integrations_agent` but instead searches for and calls actions directly, while the sub-agent runner path being tested remains unchanged. Auto-committed-on: dragonfly Co-authored-by: Medulla --- ...sio_list_tools_stack_overflow_regression.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/composio_list_tools_stack_overflow_regression.rs b/tests/composio_list_tools_stack_overflow_regression.rs index 3c651921b9..4d99c3f6ee 100644 --- a/tests/composio_list_tools_stack_overflow_regression.rs +++ b/tests/composio_list_tools_stack_overflow_regression.rs @@ -4,7 +4,10 @@ //! //! While a user chat went through //! `web_channel → orchestrator turn → delegate_to_integrations_agent -//! → integrations_agent → composio_list_tools`, the in-process core +//! → integrations_agent → composio_list_tools` (the orchestrator has since +//! stopped spawning `integrations_agent` — it searches for and calls the +//! action itself — but the sub-agent runner path below is unchanged), the +//! in-process core //! aborted with `EXC_BAD_ACCESS (SIGBUS) — KERN_PROTECTION_FAILURE` //! at an address inside the **stack guard page** of a `tokio-rt-worker` //! thread. That's a stack overflow — not a Rust panic. The kernel @@ -20,7 +23,7 @@ //! ← config::ops::load_config_with_timeout //! ← ComposioListToolsTool::execute //! ← subagent_runner::run_inner_loop / run_typed_mode / run_subagent -//! ← SkillDelegationTool::execute (delegate_to_integrations_agent) +//! ← SkillDelegationTool::execute (delegate_to_integrations_agent, since removed) //! ← Agent::execute_tool_call / execute_tools / turn //! ← web_chat::run_chat_task //! ``` @@ -51,11 +54,10 @@ //! //! Faithful reproduction in cargo-test is awkward: we can't easily //! rebuild the upper chat-channel layers (`web_chat:: -//! run_chat_task → Agent::turn → execute_tools → SkillDelegationTool`) +//! run_chat_task → Agent::turn → execute_tools → `) //! without standing up an HTTP + Socket.IO stack. We drive the production -//! path from `run_subagent` downward — i.e. everything below -//! `delegate_to_integrations_agent::execute` — on a production-realistic -//! 2 MB tokio worker stack. +//! path from `run_subagent` downward — i.e. everything below a delegation +//! tool's `execute` — on a production-realistic 2 MB tokio worker stack. //! //! **Caveat — what this test does and does not catch.** Because the //! upper ~30 frames are missing, the bare path here fits in 2 MB even @@ -93,8 +95,8 @@ //! hide for longer, //! * `OPENHUMAN_WORKSPACE` pointed at a tempdir with a representative //! `config.toml` so the TOML parser does real work, -//! * `run_subagent(integrations_agent)` exactly like -//! `delegate_to_integrations_agent` does, with a stubbed `ChatModel` +//! * `run_subagent(integrations_agent)` exactly like a delegation tool +//! does, with a stubbed `ChatModel` //! that emits one `composio_list_tools` tool call on iteration 1 //! and stops on iteration 2. //! From e607cd410c6316350432260d71bdadeca6d8f039 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:43:14 +0300 Subject: [PATCH 26/56] test(orchestrator): update integration routing to use tool search instead of delegation The orchestrator no longer delegates integration work to a sub-agent via `delegate_to_integrations_agent`. Instead, it searches for and calls integration actions directly through `tool_search`, with the action itself being a deferred tool found via the search catalogue. The test is updated to reflect this new routing, and the corresponding integration specialist test is adjusted to remove the delegation call from its scripted completions. Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/agent_prompt_comprehension_e2e.rs | 47 +++++++++++++------------ 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/tests/agent_prompt_comprehension_e2e.rs b/tests/agent_prompt_comprehension_e2e.rs index 6592a6e727..92f2bd0d56 100644 --- a/tests/agent_prompt_comprehension_e2e.rs +++ b/tests/agent_prompt_comprehension_e2e.rs @@ -287,9 +287,9 @@ async fn current_user(_headers: HeaderMap) -> Json { Json(json!({ "success": true, "data": { "_id": "e2e-user-1", "username": "e2e" } })) } -/// One connected Gmail toolkit, so the orchestrator is offered -/// `delegate_to_integrations_agent` and the integrations agent has a toolkit to -/// bind to. Shapes from `tools_approval_channels_raw_coverage_e2e.rs`. +/// One connected Gmail toolkit, so the orchestrator gets its actions as a +/// searchable catalogue and the integrations agent has a toolkit to bind to. +/// Shapes from `tools_approval_channels_raw_coverage_e2e.rs`. async fn composio_toolkits() -> Json { Json(json!({ "success": true, "data": { "toolkits": ["gmail"] } })) } @@ -849,38 +849,43 @@ fn workflow_builder_reaches_propose_workflow() { }); } -/// The orchestrator routes integration work through the hand-off, and never -/// holds the raw Composio or cron tools its specialists own. +/// The orchestrator reaches an integration action by searching for it and +/// calling it directly — no integrations sub-agent — and never holds the raw +/// Composio or cron tools its specialists own. The action itself is +/// `Deferred`: off the advertised belt, found through `tool_search`. #[test] -#[ignore = "TODO(#6376): hosted TinyAgents omits integration delegation tools"] -fn orchestrator_hands_integration_work_to_the_specialist() { +#[ignore = "TODO(#6376): hosted TinyAgents omits the deferred integration catalogue"] +fn orchestrator_searches_for_and_calls_the_integration_action() { run_case(Case { agent: "orchestrator", agent_marker: "## How you work", entry: Entry::WebChat, user_message: "Check my Gmail for anything from my landlord.", scripted_completions: vec![ - call( - "delegate_to_integrations_agent", - json!({ "toolkit": "gmail", "prompt": "Find emails from my landlord." }), - ), - text_completion("No emails from your landlord."), + call("tool_search", json!({ "query": "fetch gmail emails" })), + call("GMAIL_FETCH_EMAILS", json!({ "query": "from:landlord" })), text_completion("You have no emails from your landlord."), ], - must_call: &["delegate_to_integrations_agent"], - must_not_call: &["composio_execute"], + must_call: &["tool_search", "GMAIL_FETCH_EMAILS"], + must_not_call: &["composio_execute", "delegate_to_integrations_agent"], // Not `schedule_task`: it resolves when called (see the scheduler case) // but a named agent's up-front belt does not list synthesised delegates. - must_advertise: &["delegate_to_integrations_agent", "research"], - must_not_advertise: &["composio_execute", "composio_list_tools", "cron_add"], + must_advertise: &["tool_search", "research"], + must_not_advertise: &[ + "delegate_to_integrations_agent", + "composio_execute", + "composio_list_tools", + "cron_add", + ], advertises_nothing: false, max_consecutive_calls_of: None, extra_config: "", }); } -/// The integrations specialist, reached through that hand-off, holds the -/// Composio execution surface and none of the orchestrator's hand-offs. +/// The integrations specialist (still spawnable by the runner with a toolkit, +/// no longer reachable from chat) holds the Composio execution surface and +/// none of the orchestrator's hand-offs. /// /// The toolkit-scoped integrations agent runs in text mode, so this also pins /// the text-mode `Call as: NAME[...]` catalogue rather than only native tool @@ -894,10 +899,6 @@ fn integrations_agent_holds_the_composio_surface() { entry: Entry::WebChat, user_message: "Check my Gmail for anything from my landlord.", scripted_completions: vec![ - call( - "delegate_to_integrations_agent", - json!({ "toolkit": "gmail", "prompt": "Find emails from my landlord." }), - ), // The child runs in text mode, so its own calls would be // `` text with parser-assigned ids; this case pins its // belt only. @@ -907,7 +908,7 @@ fn integrations_agent_holds_the_composio_surface() { must_call: &[], must_not_call: &[], must_advertise: &["composio_execute", "composio_list_tools"], - must_not_advertise: &["delegate_to_integrations_agent", "schedule_task", "shell"], + must_not_advertise: &["research", "schedule_task", "shell"], advertises_nothing: false, max_consecutive_calls_of: None, extra_config: "", From 2eeea2e01a6d20a1d4be0282bac91d689a0742a6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:43:37 +0300 Subject: [PATCH 27/56] test(raw_coverage): remove SkillDelegationTool tests and update integration delegation assertions The SkillDelegationTool is no longer part of the public tool surface, so the tests that exercised it are removed. The orchestrator tool synthesis test is updated to reflect that connected integrations now produce individual Deferred tools for each action rather than a single delegate_to_integrations_agent tool, and disconnected integrations are correctly excluded from the tool list. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../inference_agent_raw_coverage_e2e.rs | 25 +------- ...ools_approval_channels_raw_coverage_e2e.rs | 60 ++++++++----------- 2 files changed, 26 insertions(+), 59 deletions(-) diff --git a/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs b/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs index 4a7874b575..cc8a107615 100644 --- a/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs +++ b/tests/raw_coverage/inference_agent_raw_coverage_e2e.rs @@ -2593,7 +2593,7 @@ async fn agent_public_tools_cover_validation_and_metadata_paths() { AskClarificationTool, DelegateToPersonalityTool, DelegateTool, RUN_WORKFLOW_TOOL_NAME, RunWorkflowTool, TodoTool, }; - use openhuman_core::tools::{ArchetypeDelegationTool, SkillDelegationTool}; + use openhuman_core::tools::ArchetypeDelegationTool; let ask = AskClarificationTool::new(); assert_eq!(ask.name(), "ask_user_clarification"); @@ -2660,29 +2660,6 @@ async fn agent_public_tools_cover_validation_and_metadata_paths() { .expect("missing archetype prompt"); assert!(missing_prompt.is_error); - assert!(SkillDelegationTool::for_connected(vec![]).is_none()); - let skill_delegate = SkillDelegationTool::for_connected(vec![ - ("gmail".into(), "Email access.".into()), - ("notion".into(), "Docs.".into()), - ]) - .expect("connected tool"); - assert!(skill_delegate.description().contains("gmail")); - let unknown_toolkit = skill_delegate - .execute(json!({ "toolkit": "slack", "prompt": "search" })) - .await - .expect("unknown toolkit"); - assert!(unknown_toolkit.is_error); - assert!( - unknown_toolkit - .output() - .contains("allowed: [gmail, notion]") - ); - let blank_skill_prompt = skill_delegate - .execute(json!({ "toolkit": "gmail", "prompt": " " })) - .await - .expect("blank prompt"); - assert!(blank_skill_prompt.output().contains("`prompt` is required")); - let todo = TodoTool::new(); assert_eq!(todo.name(), "todo"); let bad_todo_op = todo diff --git a/tests/raw_coverage/tools_approval_channels_raw_coverage_e2e.rs b/tests/raw_coverage/tools_approval_channels_raw_coverage_e2e.rs index 60143ac618..ad11fe9e86 100644 --- a/tests/raw_coverage/tools_approval_channels_raw_coverage_e2e.rs +++ b/tests/raw_coverage/tools_approval_channels_raw_coverage_e2e.rs @@ -1637,8 +1637,12 @@ async fn orchestrator_tool_synthesis_covers_agent_and_integration_delegation_edg ], ); + // No `delegate_to_integrations_agent`: the Skills wildcard expands to + // the connected actions as deferred tools, and these integrations carry + // no actions, so only the archetype delegate is synthesised. The + // disconnected and duplicate-slug entries contribute nothing either way. let names = tools.iter().map(|tool| tool.name()).collect::>(); - assert_eq!(names, vec!["research", "delegate_to_integrations_agent"]); + assert_eq!(names, vec!["research"]); let research = &tools[0]; // The delegation tool's description is the target agent's `when_to_use` @@ -1662,40 +1666,26 @@ async fn orchestrator_tool_synthesis_covers_agent_and_integration_delegation_edg assert!(missing_prompt.is_error); assert!(missing_prompt.output().contains("prompt")); - let integrations = &tools[1]; - let schema = integrations.parameters_schema(); - assert_eq!( - schema.pointer("/properties/toolkit/enum"), - Some(&json!(["gmail_pro", "slack_bot"])) - ); - let description = integrations.description(); - assert!(description.contains("gmail_pro: Send and triage mail.")); - assert!(description.contains("slack_bot: External integration via Slack-Bot")); - assert!(!description.contains("Slack.Bot")); - assert!(!description.contains("Disconnected")); - - let missing_toolkit = integrations - .execute(json!({ "prompt": "send a message" })) - .await - .expect("missing toolkit returns tool error"); - assert!(missing_toolkit.is_error); - assert!(missing_toolkit.output().contains("toolkit")); - - let unknown_toolkit = integrations - .execute(json!({ "toolkit": "calendar", "prompt": "create an event" })) - .await - .expect("unknown toolkit returns tool error"); - assert!(unknown_toolkit.is_error); - assert!(unknown_toolkit.output().contains("gmail_pro")); - assert!(unknown_toolkit.output().contains("slack_bot")); - - let blank_prompt = integrations - .execute(json!({ "toolkit": "GMail-Pro", "prompt": " " })) - .await - .expect("blank prompt returns tool error after slug normalization"); - assert!(blank_prompt.is_error); - assert!(blank_prompt.output().contains("prompt")); - + // With actions on a connected toolkit, each becomes a `Deferred` tool + // the orchestrator reaches through `tool_search`; an unconnected + // toolkit's actions stay out. + let mut gmail = coverage_connected_integration("GMail Pro", "Send and triage mail.", true); + gmail.tools = vec![openhuman_core::agent::prompts::ConnectedIntegrationTool { + name: "GMAIL_SEND_EMAIL".into(), + description: "Send an email.".into(), + parameters: None, + }]; + let mut off = coverage_connected_integration("Disconnected", "Should be skipped.", false); + off.tools = vec![openhuman_core::agent::prompts::ConnectedIntegrationTool { + name: "OFF_ACTION".into(), + description: "Never advertised.".into(), + parameters: None, + }]; + let tools = collect_orchestrator_tools(&orchestrator, ®istry, &[gmail, off]); + let names = tools.iter().map(|tool| tool.name()).collect::>(); + assert_eq!(names, vec!["research", "GMAIL_SEND_EMAIL"]); + assert_eq!(tools[1].exposure(), tinytools::ToolExposure::Deferred); + assert_eq!(tools[1].description(), "Send an email."); } #[tokio::test] From 7b08d0539501702dab007c280d044b56f28fd585 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:43:56 +0300 Subject: [PATCH 28/56] fix(scripts/prompt-eval): update composio-gmail-read case for orchestrator route The composio-gmail-read test case now expects the orchestrator to call `tool_search` directly instead of delegating to the integrations agent. The `_why` and `_gate` fields were updated to reflect that the delegate_to_integrations_agent hand-off has been removed, and `delegate_to_integrations_agent` was moved from the expected calls to the forbidden calls list. Auto-committed-on: dragonfly Co-authored-by: Medulla --- scripts/prompt-eval/cases.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/prompt-eval/cases.json b/scripts/prompt-eval/cases.json index d1c53d2511..6de529d889 100644 --- a/scripts/prompt-eval/cases.json +++ b/scripts/prompt-eval/cases.json @@ -145,13 +145,14 @@ "id": "composio-gmail-read", "enabled": false, "surface": "composio", - "_why": "Integration hand-off to a connected toolkit, read-only. Exercises delegate_to_integrations_agent and the text-mode integrations_agent (see #6334).", + "_why": "Read-only action on a connected toolkit. Exercises the orchestrator's tool_search → direct GMAIL_* call route (the delegate_to_integrations_agent hand-off is gone; see #6334 for the earlier shape).", "entry": "agent_chat", "message": "What is the subject line of the most recent email in my Gmail inbox? Just read it, don't change anything.", "expect_calls": [ - "delegate_to_integrations_agent" + "tool_search" ], "forbid_calls": [ + "delegate_to_integrations_agent", "composio_connect", "GMAIL_SEND_EMAIL", "GMAIL_DELETE_MESSAGE", @@ -169,7 +170,7 @@ "params": {}, "expect_regex": "(?s)\\{[^{}]*\"status\"\\s*:\\s*\"ACTIVE\"[^{}]*\"toolkit\"\\s*:\\s*\"gmail\"[^{}]*\\}|\\{[^{}]*\"toolkit\"\\s*:\\s*\"gmail\"[^{}]*\"status\"\\s*:\\s*\"ACTIVE\"[^{}]*\\}" }, - "_gate": "Do not run until a real transcript has shown a text-mode integrations_agent call in `calls` in a form these forbids match. Until then GMAIL_* forbids are unproven against the live inbox." + "_gate": "Do not run until a real transcript has shown the orchestrator's direct GMAIL_* call in `calls` in a form these forbids match. Until then GMAIL_* forbids are unproven against the live inbox." }, { "id": "orchestrator-reminder", From 050e030317b89efb3c23a8c552b4347123506208 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:44:09 +0300 Subject: [PATCH 29/56] docs(prompt-evals): update composio-gmail-read to reflect orchestrator's direct tool_search path The orchestrator no longer uses `delegate_to_integrations_agent` for integration actions; it now finds them through `tool_search` and calls them directly. The prompt-evals documentation is updated to describe this new path, and the plan document is amended to note that the delegate has been removed. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/plans/jev-tool-search-baseline.md | 4 +++- docs/prompt-evals.md | 11 ++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/plans/jev-tool-search-baseline.md b/docs/plans/jev-tool-search-baseline.md index 578d1bb23b..2f36c1eaee 100644 --- a/docs/plans/jev-tool-search-baseline.md +++ b/docs/plans/jev-tool-search-baseline.md @@ -13,7 +13,9 @@ answer. Before this work the orchestrator reached a Composio action only through `delegate_to_integrations_agent` → an `integrations_agent` sub-run whose -toolkit was narrowed by `rank_tools_by_prompt` (the `overlap` row). Every +toolkit was narrowed by `rank_tools_by_prompt` (the `overlap` row). That +delegate has since been removed from the orchestrator: search-then-call is +its only route to an integration action. Every `tool_search` row is one search followed by a direct call of the tool it returns; no sub-agent. diff --git a/docs/prompt-evals.md b/docs/prompt-evals.md index 8579b52623..99db0148cd 100644 --- a/docs/prompt-evals.md +++ b/docs/prompt-evals.md @@ -130,7 +130,7 @@ scoring proves too coarse. | `orchestrator-reminder` | orchestration → scheduler | hands off through `schedule_task` | **a cron job**, remove it afterwards | | `orchestrator-direct-answer` | orchestration | answers a trivial question without spawning | nothing | | `orchestrator-research-trip` | orchestration | a research question goes straight to `web_search_tool` and streams an answer; never `request_plan_review`, `todo` or a spawn | nothing | -| `composio-gmail-read` | composio | reads the latest Gmail subject via `delegate_to_integrations_agent`; never sends, deletes or reconnects | nothing | +| `composio-gmail-read` | composio | reads the latest Gmail subject via `tool_search` and a direct `GMAIL_*` call; never sends, deletes or reconnects | nothing | | `skill-notion-read` | skills | lists Notion pages through `run_skill`; never installs a skill | nothing | | `mcp-none-configured` | MCP, **error path** | with no MCP server configured, says so; never installs one, never fabricates results | nothing | | `web-search-fact` | web search | one built-in `web_search_tool` lookup, not a `research` spawn | nothing | @@ -149,10 +149,11 @@ MCP server to make MCP "testable"; that changes the baseline being measured. Cases run in file order, by increasing account risk. Cases that touch nothing run first, because they also validate the rig on real inference; the only writing case (`orchestrator-reminder`) runs last. -**`composio-gmail-read` is disabled by default.** The toolkit-scoped `integrations_agent` -runs in text mode, so its calls may not reach `calls` in the same shape as -native tool calls. Until a real transcript has shown one landing in a form its -`GMAIL_*` forbids match, those forbids are unproven. They would fail to notice +**`composio-gmail-read` is disabled by default.** The orchestrator finds the +`GMAIL_*` action through `tool_search` and calls it directly; that call may +reach `calls` under the action name or through the harness's `tool_call` +bridge. Until a real transcript has shown one landing in a form its `GMAIL_*` +forbids match, those forbids are unproven. They would fail to notice a send, not prevent it. Run the earlier cases, inspect a real `calls` field, and only then run it. Do not repair the matcher mid-run. The runner skips this case unless `--allow-disabled` is explicitly supplied after matcher validation. From b1da24947e829a4bd63fcc2ea4dfe64b5e3dcbc6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:44:33 +0300 Subject: [PATCH 30/56] docs(architecture): clarify that integrations agent is not reachable from chat Update documentation across multiple files to reflect that the orchestrator no longer delegates to the integrations agent for Composio actions. Instead, connected actions are exposed as `Deferred` tools on the orchestrator's own belt, found through `tool_search` and called directly. This removes the `SkillDelegationTool` and the `delegate_to_integrations_agent` tool, simplifying the delegation model and making the orchestrator the direct caller for integration actions. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/agent/orchestration/README.md | 8 +++++--- crates/openhuman-core/src/agent/registry/README.md | 2 +- crates/openhuman-core/src/tools/README.md | 2 +- docs/prompt-evals.md | 5 +++-- gitbooks/developing/architecture/agent-harness.md | 4 ++-- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/openhuman-core/src/agent/orchestration/README.md b/crates/openhuman-core/src/agent/orchestration/README.md index a22a39256d..0bebe469fe 100644 --- a/crates/openhuman-core/src/agent/orchestration/README.md +++ b/crates/openhuman-core/src/agent/orchestration/README.md @@ -124,9 +124,11 @@ name: - Control: `steer_subagent`, `continue_subagent`, `close_subagent`, `wait_subagent`, `wait`, `wait_loop`, `list_subagents`. - Delegation: `DelegateGraphTool` (`delegate_graph.rs`), - `ArchetypeDelegationTool` and `SkillDelegationTool` (names set per - instance, e.g. `delegate_to_integrations_agent`), `CollapsedDelegationTool` - (`delegate_to`), and `agent_prepare_context`. + `ArchetypeDelegationTool` (name set per instance, e.g. `research`), + `CollapsedDelegationTool` (`delegate_to`), and `agent_prepare_context`. + There is no integrations delegate: connected Composio actions are + `Deferred` tools on the orchestrator's own belt, found through + `tool_search` and called directly (`tools/orchestrator_tools.rs`). `dispatch.rs` (`dispatch_subagent`, the shared spawn path every tool above calls), `awaiting_user.rs` (the awaiting-user envelope), and diff --git a/crates/openhuman-core/src/agent/registry/README.md b/crates/openhuman-core/src/agent/registry/README.md index abf028cd35..9825ca74cb 100644 --- a/crates/openhuman-core/src/agent/registry/README.md +++ b/crates/openhuman-core/src/agent/registry/README.md @@ -97,7 +97,7 @@ The 29 archetypes in this directory: | `goals_agent` | Background: keeps `MEMORY_GOALS.md` fresh from session context | | `help` | Answers "how does OpenHuman work" questions from the bundled GitBook docs | | `image_agent` | Image generation/edit specialist | -| `integrations_agent` | Drives a single Composio toolkit (gmail, notion, github, …) per spawn | +| `integrations_agent` | Drives a single Composio toolkit (gmail, notion, github, …) per spawn; no chat agent delegates to it — the orchestrator searches for and calls connected actions itself | | `mcp_agent` (feature `mcp`) | Calls tools on an already-connected MCP server | | `morning_briefing` | Proactive scheduled daily summary (tasks, calendar, email, skills) | | `orchestrator` | Default user-facing `chat`-tier agent; direct-first, delegates only when it materially helps | diff --git a/crates/openhuman-core/src/tools/README.md b/crates/openhuman-core/src/tools/README.md index 90d77361bc..0235898f9d 100644 --- a/crates/openhuman-core/src/tools/README.md +++ b/crates/openhuman-core/src/tools/README.md @@ -27,7 +27,7 @@ The agent tool layer. Defines the core [`Tool`] trait every agent-callable capab | `crates/openhuman-core/src/tools/schemas.rs` (thin shell over the `schemas/` submodule: `apify.rs`, `composio.rs`, `registry.rs`, `web_search.rs`) | JSON-RPC `tools` namespace controllers + `handle_*` fns. `all_controller_schemas` / `all_registered_controllers` (re-exported as `all_tools_*`). | | `crates/openhuman-core/src/tools/policy.rs` | `ToolPolicy` trait + `PolicyDecision` (`Allow`/`Deny`) + allow-all `DefaultToolPolicy`. Evaluated on the agent hot path before each `execute()`. | | `crates/openhuman-core/src/tools/schema.rs` | Re-exports `SchemaCleanr`, `CleaningStrategy` and `GEMINI_UNSUPPORTED_KEYWORDS` from `tinyagents_harness::tool` (local `$ref` resolution, provider-rejected keyword stripping, literal-union flattening). The only in-crate caller is `generated.rs`, which runs `SchemaCleanr::validate` on generated tool schemas at admission. | -| `crates/openhuman-core/src/tools/orchestrator_tools.rs` | Synthesizes named per-subagent tools from the orchestrator's `subagents = [...]` definition; collapses skill wildcards into `delegate_to_integrations_agent`. | +| `crates/openhuman-core/src/tools/orchestrator_tools.rs` | Synthesizes named per-subagent tools from the orchestrator's `subagents = [...]` definition; expands the skills wildcard into one `Deferred` `ComposioActionTool` per connected action (reached through `tool_search`, no delegate). | | `crates/openhuman-core/src/tools/generated.rs` | `GeneratedToolDefinition` + wrapper for runtime/profile-supplied generated capability tools (provider/capability/risk metadata for policy). | | `crates/openhuman-core/src/tools/user_filter.rs` | `filter_tools_by_user_preference` + UI-toggle-ID → Rust-tool-name map. Unmapped tools are always retained. | | [`crates/openhuman-core/src/tools/status/`](status/mod.rs) | Tool-call lifecycle state (`ToolLifecycleState`) and human-readable failure classification (`ToolFailureClass`, `classify`). Pure data/logic; no persistence, no RPC. | diff --git a/docs/prompt-evals.md b/docs/prompt-evals.md index 99db0148cd..7b99452671 100644 --- a/docs/prompt-evals.md +++ b/docs/prompt-evals.md @@ -26,8 +26,9 @@ A green tier 1 is therefore not evidence of comprehension. Only tier 2 is. The six cases cover the agents where mis-routing has cost something: `workflow_builder` (must reach `propose_workflow`, never three consecutive -catalog searches), `orchestrator` (hands integration work off, never holds the -raw Composio or cron tools), `integrations_agent`, `scheduler_agent`, and the +catalog searches), `orchestrator` (searches for and calls integration actions +itself, never holds the raw Composio or cron tools), `integrations_agent`, +`scheduler_agent`, and the two zero-belt agents `summarizer` and `trigger_triage` (advertise nothing). Fleet-wide static coverage of all agents lives in the prompt tests under `crates/openhuman-core/src/agent/registry/agents/`, not here. diff --git a/gitbooks/developing/architecture/agent-harness.md b/gitbooks/developing/architecture/agent-harness.md index 1bf597cb6f..96f320e364 100644 --- a/gitbooks/developing/architecture/agent-harness.md +++ b/gitbooks/developing/architecture/agent-harness.md @@ -344,7 +344,7 @@ Each archetype lives under `agents//` with an `agent.toml` (metadata, tool | `archivist` | Memory distillation - what to persist, what to forget. | | `tool_maker` | Self-healing - writes polyfills for missing shell commands. | | `tools_agent` | Generic specialist for arbitrary tool-bound tasks. | -| `integrations_agent` | Bound to a specific Composio toolkit (Gmail, GitHub, Slack…) for that toolkit's actions. | +| `integrations_agent` | Bound to a specific Composio toolkit (Gmail, GitHub, Slack…) for that toolkit's actions. Not reachable from chat: the orchestrator finds a connected action through `tool_search` and calls it directly. | | `trigger_triage` | Classifies incoming external events into drop / notify / spawn-reactor / spawn-agent. | | `trigger_reactor` | Lightweight reaction to a triaged trigger that doesn't need a full orchestrator turn. | | `morning_briefing` | Curated daily digest run by cron. | @@ -399,7 +399,7 @@ Each `AgentDefinition` carries an `agent_tier` field (`chat` / `reasoning` / `wo | `reasoning` | `worker` | another `reasoning`, any `chat` | `planner` (today the canonical one) | | `worker` | nothing[^1] | anything | researcher, code_executor, critic, archivist, tool_maker, integrations_agent, … | -[^1]: Skill-wildcard entries (`{ skills = "*" }`) are exempt because they collapse to a single `delegate_to_integrations_agent` tool whose target is a worker; they're a fan-out delegation surface, not a recursive spawn. +[^1]: Skill-wildcard entries (`{ skills = "*" }`) are exempt because they name no agent: they expand to the connected Composio actions as `Deferred` tools the agent reaches through `tool_search`, not to a spawn. **Why the rules.** From 5027e3d0dc66917bf6c22f46a0f95b7f7143e103 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:45:18 +0300 Subject: [PATCH 31/56] feat(timeline): format direct connected-service actions by provider Replaced the special-case handling for `delegate_to_integrations_agent` with a general mechanism that recognises any upper-case Composio action slug (e.g. `GMAIL_SEND_EMAIL`) and labels the timeline entry by the service provider, using the action name as the detail. This makes the timeline consistent for direct tool calls from the orchestrator, while unknown toolkits or non-matching names fall back to the generic humanised label. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../__tests__/toolTimelineFormatting.test.ts | 41 ++++---------- app/src/utils/toolTimelineFormatting.ts | 53 ++++++++++++++----- 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/app/src/utils/__tests__/toolTimelineFormatting.test.ts b/app/src/utils/__tests__/toolTimelineFormatting.test.ts index b0b2b073c2..ba97947f26 100644 --- a/app/src/utils/__tests__/toolTimelineFormatting.test.ts +++ b/app/src/utils/__tests__/toolTimelineFormatting.test.ts @@ -68,43 +68,25 @@ describe('formatTimelineEntry', () => { }); }); - it('formats delegate_to_integrations_agent with a known toolkit arg', () => { + it('labels a direct connected-service action by its provider', () => { expect( formatTimelineEntry( entry({ - name: 'delegate_to_integrations_agent', - argsBuffer: JSON.stringify({ - toolkit: 'gmail', - prompt: 'Find the latest invoice from Stripe.', - }), + name: 'GMAIL_SEND_EMAIL', + argsBuffer: JSON.stringify({ to: 'alex@example.com' }), }) ) - ).toEqual({ - title: 'Making requests to your Gmail account', - detail: 'Find the latest invoice from Stripe.', - }); - }); - - it('formats delegate_to_integrations_agent with an unknown toolkit arg', () => { + ).toEqual({ title: 'Making requests to your Gmail account', detail: 'Send email' }); expect( - formatTimelineEntry( - entry({ - name: 'delegate_to_integrations_agent', - argsBuffer: JSON.stringify({ toolkit: 'slack_bot', prompt: 'post update' }), - }) - ) - ).toEqual({ title: 'Checking your Slack Bot', detail: 'post update' }); + formatTimelineEntry(entry({ name: 'GOOGLE_CALENDAR_CREATE_EVENT' })) + ).toEqual({ title: 'Updating your Google Calendar', detail: 'Create event' }); }); - it('formats delegate_to_integrations_agent without a toolkit arg as a generic connected-app label', () => { - expect( - formatTimelineEntry( - entry({ - name: 'delegate_to_integrations_agent', - argsBuffer: JSON.stringify({ prompt: 'do something useful' }), - }) - ) - ).toEqual({ title: 'Checking your connected app', detail: 'do something useful' }); + it('keeps the generic label for upper-case names on unknown toolkits', () => { + expect(formatTimelineEntry(entry({ name: 'STRIPE_LIST_CHARGES' }))).toEqual({ + title: 'STRIPE LIST CHARGES', + detail: undefined, + }); }); it('formats delegate_tools_agent with toolkit context from args', () => { @@ -402,7 +384,6 @@ describe('isKnownClientTool', () => { expect(isKnownClientTool('file_read')).toBe(true); expect(isKnownClientTool('shell')).toBe(true); expect(isKnownClientTool('subagent:researcher')).toBe(true); - expect(isKnownClientTool('delegate_to_integrations_agent')).toBe(true); // The streamed search-slot name, so the client label wins over the // server's humanized "Web Search Tool". expect(isKnownClientTool('web_search_tool')).toBe(true); diff --git a/app/src/utils/toolTimelineFormatting.ts b/app/src/utils/toolTimelineFormatting.ts index a10cc5d5ea..667a1640a1 100644 --- a/app/src/utils/toolTimelineFormatting.ts +++ b/app/src/utils/toolTimelineFormatting.ts @@ -304,21 +304,23 @@ export function formatTimelineEntry(entry: ToolTimelineEntry): { title: string; inferIntegrationNameFromPrompt(parsedArgs?.prompt) ?? inferIntegrationName(entry.name); - let title: string; - if (provider) { - title = integrationActivityTitle(provider); - } else if (entry.name === 'delegate_to_integrations_agent') { - const rawToolkit = parsedArgs?.toolkit?.trim(); - title = rawToolkit - ? integrationActivityTitle(humanizeIdentifier(rawToolkit)) - : 'Checking your connected app'; - } else { - title = humanizeIdentifier(entry.name); - } - + const title = provider ? integrationActivityTitle(provider) : humanizeIdentifier(entry.name); return { title, detail: entry.detail ?? parsedArgs?.prompt }; } + // A connected-service action called directly (`GMAIL_SEND_EMAIL`, + // `SLACK_SEND_MESSAGE`): the orchestrator finds these through + // `tool_search` and calls them itself, so this is the row a user sees + // for "send that email". Label it by the service, with the action as + // the detail, rather than a raw humanised slug. + const directAction = inferIntegrationActionName(entry.name); + if (directAction) { + return { + title: integrationActivityTitle(directAction.provider), + detail: entry.detail ?? directAction.action, + }; + } + // ── Tool-specific formatting with args-derived detail ────────────── // Pass the completed result text so args-aware formatters can surface // details only known post-execution (e.g. the resolved search provider). @@ -639,6 +641,33 @@ function inferIntegrationName(input?: string): string | undefined { return undefined; } +/** + * Split a Composio action slug (`GMAIL_SEND_EMAIL`) into its known provider + * and a readable action ("Send email"). `undefined` for anything that is not + * an upper-case `_` name on a known toolkit, so ordinary + * tools and unknown toolkits keep their generic label. + */ +function inferIntegrationActionName( + name: string +): { provider: string; action: string } | undefined { + const match = name.match(/^([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*?)_([A-Z0-9_]+)$/); + if (!match) return undefined; + // Try the longest toolkit prefix first (`GOOGLE_CALENDAR_...`), then the + // shortest (`GMAIL_...`). + const parts = name.split('_'); + for (let i = Math.min(parts.length - 1, 2); i >= 1; i -= 1) { + const toolkit = parts.slice(0, i).join('_'); + if (KNOWN_TOOLKIT_RE.test(toolkit)) { + const action = parts.slice(i).join(' ').toLowerCase(); + return { + provider: normalizeIntegrationName(toolkit), + action: action.charAt(0).toUpperCase() + action.slice(1), + }; + } + } + return undefined; +} + function integrationActivityTitle(provider: string): string { switch (provider) { case 'GitHub': From de8a58e38110b41932c3d8c55b78de62287a11f2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 12:45:29 +0300 Subject: [PATCH 32/56] fix(timeline): simplify integration action name validation Replaced the regex match and destructuring with a single test call to validate the integration action name format, removing an unused variable and making the guard clause more concise. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/utils/toolTimelineFormatting.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/utils/toolTimelineFormatting.ts b/app/src/utils/toolTimelineFormatting.ts index 667a1640a1..0392a37b43 100644 --- a/app/src/utils/toolTimelineFormatting.ts +++ b/app/src/utils/toolTimelineFormatting.ts @@ -650,8 +650,7 @@ function inferIntegrationName(input?: string): string | undefined { function inferIntegrationActionName( name: string ): { provider: string; action: string } | undefined { - const match = name.match(/^([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*?)_([A-Z0-9_]+)$/); - if (!match) return undefined; + if (!/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(name)) return undefined; // Try the longest toolkit prefix first (`GOOGLE_CALENDAR_...`), then the // shortest (`GMAIL_...`). const parts = name.split('_'); From 641db94c2c6a8a29d846ce6a69b50f0c575e7e09 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 18:38:31 +0300 Subject: [PATCH 33/56] fix(imports): reorder ArchetypeDelegationTool import to resolve unused import warning The import of ArchetypeDelegationTool was placed after the SpawnWorkerThreadTool import, which caused a compiler warning about an unused import when the latter is commented out. Moving the ArchetypeDelegationTool import before the commented block ensures it is always recognized as used, and the test file is reformatted for consistency. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tools/orchestrator_tools.rs | 2 +- .../src/tools/orchestrator_tools_tests.rs | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/openhuman-core/src/tools/orchestrator_tools.rs b/crates/openhuman-core/src/tools/orchestrator_tools.rs index 4288f14508..1f50a01073 100644 --- a/crates/openhuman-core/src/tools/orchestrator_tools.rs +++ b/crates/openhuman-core/src/tools/orchestrator_tools.rs @@ -38,9 +38,9 @@ use crate::integrations::composio::ComposioActionTool; // SpawnWorkerThreadTool import kept commented while the worker-thread spawn is // temporarily disabled (see tinyhumansai/openhuman#1624). +use super::ArchetypeDelegationTool; #[allow(unused_imports)] use super::SpawnWorkerThreadTool; -use super::ArchetypeDelegationTool; use crate::agent::orchestration::tools::DelegationTarget; use tinytools::Tool; diff --git a/crates/openhuman-core/src/tools/orchestrator_tools_tests.rs b/crates/openhuman-core/src/tools/orchestrator_tools_tests.rs index ffd24c2ed1..0a797dc579 100644 --- a/crates/openhuman-core/src/tools/orchestrator_tools_tests.rs +++ b/crates/openhuman-core/src/tools/orchestrator_tools_tests.rs @@ -186,7 +186,10 @@ fn skills_wildcard_adds_no_delegation_tool_for_any_integration_count() { .iter() .filter(|t| t.name().starts_with("delegate_to_")) .count(); - assert_eq!(delegation_count, 0, "no integrations delegate for {n} integrations"); + assert_eq!( + delegation_count, 0, + "no integrations delegate for {n} integrations" + ); let action_count = tools .iter() .filter(|t| t.exposure() == tinytools::ToolExposure::Deferred) @@ -354,14 +357,22 @@ fn deferred_actions_are_sorted_by_toolkit_then_action() { orch.subagents = vec![SubagentEntry::Skills(SkillsWildcard { skills: "*".into() })]; let reg = registry_with_targets(); let integrations = vec![ - integration_with_actions("slack", "Chat.", &["SLACK_SEND_MESSAGE", "SLACK_LIST_CHANNELS"]), + integration_with_actions( + "slack", + "Chat.", + &["SLACK_SEND_MESSAGE", "SLACK_LIST_CHANNELS"], + ), integration_with_actions("gmail", "Email.", &["GMAIL_SEND_EMAIL"]), ]; let tools = collect_orchestrator_tools(&orch, ®, &integrations); let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); assert_eq!( names, - vec!["GMAIL_SEND_EMAIL", "SLACK_LIST_CHANNELS", "SLACK_SEND_MESSAGE"] + vec![ + "GMAIL_SEND_EMAIL", + "SLACK_LIST_CHANNELS", + "SLACK_SEND_MESSAGE" + ] ); } From d905eeea2b8c1f32f997606f28987030b827b466 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 18:45:54 +0300 Subject: [PATCH 34/56] chore(prompts): simplify wording in orchestrator, planner, and tools agent prompts Remove the redundant phrase "searches for and" from the tools agent prompt and simplify the orchestrator prompt by removing the explicit "call" verb before `tool_search`. In the planner prompt, replace the verbose description of connected-service actions with a more concise statement. These changes make the prompts clearer and more direct without altering their intended behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/registry/agents/orchestrator/prompt.md | 2 +- .../openhuman-core/src/agent/registry/agents/planner/prompt.md | 2 +- .../src/agent/registry/agents/tools_agent/prompt.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md index de410c80d2..ea822a9d05 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md @@ -3,7 +3,7 @@ Take the first branch that applies: 1. **Answerable without tools**: reply. Small talk, simple Q&A, general knowledge. -1b. **Needs a capability you do not see listed**: call `tool_search` with the intent in plain words before delegating or declining. Your list is a core set; if nothing comes back, say so. +1b. **Needs a capability you do not see listed**: `tool_search` with the intent in plain words before delegating or declining. Your list is a core set; if nothing comes back, say so. 2. **Needs a connected service's own data or actions** (inbox, messages, calendar, docs, tickets, "send/check X"): `tool_search` for the action ("send an email", "list calendar events") and call the tool it returns yourself; there is no integrations sub-agent. Use the live service even when memory could plausibly answer. A service being connected is not a reason to touch it: general knowledge, web/news lookups, headlines, date/time and math never go to a service. Not connected? Raise a connect card with `composio_connect`: **Connected Integrations** shows what is connected, not what is connectable, so never refuse from it or send the user to settings, and never paste OAuth URLs. If the connect call reports the toolkit unavailable, relay its message; that is the only honest refusal. 3. **Solvable with a direct tool**: do it yourself. `web_search_tool` and `web_fetch` for a fact or a page, `memory_recall` and `memory_store` for the user's own facts, `shell` plus `apply_patch` for repository work. Keep code work end-to-end: edit and verify in the same turn; never delegate merely because a task touches a repository. 4. **Needs a specialist**: the specialists you can call are in your tool list with their own descriptions. **Capabilities not in your tool list** names the ones a skill holds; reach those through `use_skill`. Workers return only their result; carry out any `## Handoff Plan` they return yourself, under the approval gate. diff --git a/crates/openhuman-core/src/agent/registry/agents/planner/prompt.md b/crates/openhuman-core/src/agent/registry/agents/planner/prompt.md index 145964ded8..87f0e979cf 100644 --- a/crates/openhuman-core/src/agent/registry/agents/planner/prompt.md +++ b/crates/openhuman-core/src/agent/registry/agents/planner/prompt.md @@ -39,7 +39,7 @@ Return **only** valid JSON matching this schema: ## Rules -0. **You are the reasoning tier.** The chat-tier Orchestrator handed off to you because the task needs sustained thinking. Compose plans for the **worker tier** — `code_executor`, `researcher`, `critic`, `archivist`. Connected-service actions (Gmail, Notion, Slack, …) are the orchestrator's own `tool_search`-and-call step, not a worker. **Never delegate to another reasoning agent** (no planner-spawns-planner, no planner-spawns-orchestrator); the loader rejects this at boot, and the planned runtime depth gate will reject it at spawn time. If a single worker can't cover a node, split the node — don't smuggle a second reasoning hop in. +0. **You are the reasoning tier.** The chat-tier Orchestrator handed off to you because the task needs sustained thinking. Compose plans for the **worker tier** — `code_executor`, `researcher`, `critic`, `archivist`. Connected-service actions belong to the orchestrator, not a worker. **Never delegate to another reasoning agent** (no planner-spawns-planner, no planner-spawns-orchestrator); the loader rejects this at boot, and the planned runtime depth gate will reject it at spawn time. If a single worker can't cover a node, split the node — don't smuggle a second reasoning hop in. 1. **Gather before planning** — Search memory and the web first. Don't guess what you can look up. 2. **Minimise tasks** — Use the fewest nodes needed. Don't over-decompose. 3. **Dependencies matter** — Use `depends_on` to express ordering. Independent tasks run in parallel. diff --git a/crates/openhuman-core/src/agent/registry/agents/tools_agent/prompt.md b/crates/openhuman-core/src/agent/registry/agents/tools_agent/prompt.md index aa6509b5ba..a532b33827 100644 --- a/crates/openhuman-core/src/agent/registry/agents/tools_agent/prompt.md +++ b/crates/openhuman-core/src/agent/registry/agents/tools_agent/prompt.md @@ -4,7 +4,7 @@ You are the **Tools Agent**. You complete ad-hoc tasks using only OpenHuman's bu ## Scope -- You do **NOT** have access to Composio / managed OAuth integrations. If a task requires acting on an external SaaS account (Gmail, Notion, GitHub, Slack, …), stop and report back — the orchestrator searches for and calls the connected service's action itself. +- You do **NOT** have access to Composio / managed OAuth integrations. If a task requires acting on an external SaaS account (Gmail, Notion, GitHub, Slack, …), stop and report back — the orchestrator calls connected-service actions itself. - You **DO** handle: running commands, reading and writing files in the workspace, scraping the web, searching the user's memory, querying structured data, chaining simple transformations. ## Operating rules From cfc1da58983f02c065ded704804eeb14e8fb4a9e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 19:05:43 +0300 Subject: [PATCH 35/56] fix(session): exclude deferred tool schemas from text-dialect prompt When a text dialect is used with deferred tools, the policy's allow-set incorrectly included every deferred schema in the prompt, consuming excessive bytes and contradicting the prompt by showing signatures for tools the model was told to search for. The change now filters out deferred tool names from the visible set and injects bridge tools for `tool_search` and `tool_call` so the model can properly invoke matches without seeing their full schemas. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/session_host/turn/context.rs | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/openhuman-core/src/agent/session_host/turn/context.rs b/crates/openhuman-core/src/agent/session_host/turn/context.rs index 6854356508..3d70b30fe3 100644 --- a/crates/openhuman-core/src/agent/session_host/turn/context.rs +++ b/crates/openhuman-core/src/agent/session_host/turn/context.rs @@ -215,8 +215,37 @@ impl OpenHumanSessionHost { // prompt build. The synthesised delegates belong here: the catalogue // this renders is what tells the model a `delegate_*` tool exists. let all_tools = self.all_tool_refs(); - let prompt_tools = PromptTool::from_tool_refs(all_tools.iter().copied()); - let prompt_visible_tool_names = self.tool_policy_session.visible_tool_names_for_prompt(); + let mut prompt_tools = PromptTool::from_tool_refs(all_tools.iter().copied()); + let mut prompt_visible_tool_names = + self.tool_policy_session.visible_tool_names_for_prompt(); + // A `Deferred` tool is off the advertised surface, and on a TEXT + // dialect the catalogue this prompt renders IS that surface — the + // harness clears `request.tools` and the model calls what it reads + // here. The policy's allow-set is the wrong filter for it: the host + // deliberately admits the deferred names there so they stay + // *callable* (`reachable_names` in the session builder), so filtering + // by it rendered every deferred schema into the prompt. That cost the + // bytes deferral exists to save (measured: 107 connected Composio + // actions rendered 55 KB of a 71 KB prompt) and, worse, contradicted + // the prompt: the model was told to `tool_search` for an action whose + // signature was already in its catalogue. + // + // The bridge takes their place, when there is one. The harness mints + // `tool_search` / `tool_call` schemas onto `request.tools` for a run + // with a deferred catalogue, but a text dialect drops that set, and + // with `host_renders_tool_catalogue` the harness appends nothing of + // its own — so without these two entries the model is told to invoke + // matches with a `tool_call` it can never see a signature for, and + // answers with intent instead of a call. + if !self.deferred_tool_names.is_empty() { + prompt_visible_tool_names.retain(|name| !self.deferred_tool_names.contains(name)); + for bridge in crate::agent::tinyagents::discovery::bridge_prompt_tools( + self.deferred_tool_names.len(), + ) { + prompt_visible_tool_names.insert(bridge.name.to_string()); + prompt_tools.push(bridge); + } + } // Load AGENTS.md instruction layers once per system-prompt build (never // re-read per turn — the caller builds the prompt once at session start // and reuses the bytes, preserving the frozen-prefix / KV-cache From c830a4397b827a93a47098f130a37d383223e202 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 19:06:19 +0300 Subject: [PATCH 36/56] feat(agent): add bridge prompt tools for text dialect tool discovery Add a function that generates tool schemas for the tool_search/tool_call bridge when a run uses a text dialect with a deferred catalogue. Text dialects fold the catalogue into the system prompt and clear the request tools, so the bridge schemas must be included in the host's own catalogue for the model to understand how to invoke tool calls from search results. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/tinyagents/discovery/mod.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/openhuman-core/src/agent/tinyagents/discovery/mod.rs b/crates/openhuman-core/src/agent/tinyagents/discovery/mod.rs index f77dafa80d..a0126104ba 100644 --- a/crates/openhuman-core/src/agent/tinyagents/discovery/mod.rs +++ b/crates/openhuman-core/src/agent/tinyagents/discovery/mod.rs @@ -122,6 +122,49 @@ pub(crate) fn discovery_policy() -> ToolDiscoveryPolicy { policy } +/// The `tool_search` / `tool_call` bridge, rendered for a TEXT dialect's +/// prompt catalogue. +/// +/// The harness mints these two schemas onto `request.tools` whenever a run +/// has a deferred catalogue, which is all a native-tool-calling provider +/// needs. A text dialect (P-Format / code) never sees that set: the dialect +/// folds the catalogue into the system prompt and clears `request.tools`, +/// and because OpenHuman sets `host_renders_tool_catalogue` the harness +/// appends nothing of its own. So on those dialects the bridge only reaches +/// the model if the host's own catalogue carries it — otherwise the model +/// reads "invoke a match with `tool_call`" in a search result naming a tool +/// no signature in its catalogue describes, and answers with intent instead +/// of a call. +/// +/// Built from [`tinyagents_harness::tool::discover::bridge_schemas`] rather +/// than hand-written prose, so the signature the model reads is the one +/// admission accepts. `deferred` only sizes the placeholder catalogue the +/// manifest is rendered from; the per-tool manifest itself is deliberately +/// left out of the prompt, because listing every deferred tool there is the +/// cost deferral exists to avoid. +pub(crate) fn bridge_prompt_tools(deferred: usize) -> Vec> +{ + if deferred == 0 { + return Vec::new(); + } + use tinyagents_harness::tool::discover::{bridge_schemas, DeferredCatalog}; + let mut policy = discovery_policy(); + // A zero budget renders the manifest as a bare count instead of naming + // every deferred tool — the prompt advertises that a search exists, not + // what it would find. + policy.manifest_token_budget = 0; + bridge_schemas(&DeferredCatalog::build(Vec::new()), &policy) + .into_iter() + .map(|schema| { + crate::agent::prompts::PromptTool::owned( + schema.name, + schema.description, + schema.parameters.to_string(), + ) + }) + .collect() +} + /// The verb-gated token-overlap ranker the Composio sub-agent narrows its /// toolkit with (`tinyagents_harness::tool::select::rank_tools_by_prompt`), /// behind the [`ToolRanker`] trait so it can be installed, compared, or From a1de5c5561bb18e35e3109fa9e7a5fbe5f32b31f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 19:07:47 +0300 Subject: [PATCH 37/56] feat(agent): switch PromptTool fields to Cow to support owned tool entries The `name` and `description` fields in `PromptTool` now use `Cow<'a, str>` instead of `&'a str`, allowing the struct to hold either borrowed or owned strings. A new `owned` constructor is added for tools that are created ephemerally during prompt building, such as those from the harness's tool search and tool call bridge, which have no long-lived registration to borrow from. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../openhuman-core/src/agent/prompts/types.rs | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/openhuman-core/src/agent/prompts/types.rs b/crates/openhuman-core/src/agent/prompts/types.rs index 66a6901d24..63f29d36a9 100644 --- a/crates/openhuman-core/src/agent/prompts/types.rs +++ b/crates/openhuman-core/src/agent/prompts/types.rs @@ -233,24 +233,35 @@ pub struct ConnectedIntegrationTool { /// description)` tuples) all adapt to this. #[derive(Debug, Clone)] pub struct PromptTool<'a> { - pub name: &'a str, - pub description: &'a str, + pub name: std::borrow::Cow<'a, str>, + pub description: std::borrow::Cow<'a, str>, pub parameters_schema: Option, } impl<'a> PromptTool<'a> { pub fn new(name: &'a str, description: &'a str) -> Self { Self { - name, - description, + name: std::borrow::Cow::Borrowed(name), + description: std::borrow::Cow::Borrowed(description), parameters_schema: None, } } + /// An entry the catalogue owns rather than borrows: a tool that exists + /// only for this prompt build (the harness's `tool_search` / `tool_call` + /// bridge), with no registration to borrow a name from. + pub fn owned(name: String, description: String, parameters_schema: String) -> PromptTool<'static> { + PromptTool { + name: std::borrow::Cow::Owned(name), + description: std::borrow::Cow::Owned(description), + parameters_schema: Some(parameters_schema), + } + } + pub fn with_schema(name: &'a str, description: &'a str, parameters_schema: String) -> Self { Self { - name, - description, + name: std::borrow::Cow::Borrowed(name), + description: std::borrow::Cow::Borrowed(description), parameters_schema: Some(parameters_schema), } } @@ -272,8 +283,8 @@ impl<'a> PromptTool<'a> { tools .into_iter() .map(|t| PromptTool { - name: t.name(), - description: t.description(), + name: std::borrow::Cow::Borrowed(t.name()), + description: std::borrow::Cow::Borrowed(t.description()), parameters_schema: Some(t.parameters_schema().to_string()), }) .collect() From c5657718a65187707701379d88c757f101e1d51c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 19:09:00 +0300 Subject: [PATCH 38/56] fix: wrap tool name and description in `Cow::Borrowed` to match expected type The `PromptTool` struct expects its `name` and `description` fields to be `Cow<'_, str>`, but the code was assigning `&str` values directly. Wrapping them in `Cow::Borrowed` resolves the type mismatch and ensures the borrow is explicit, preventing compilation errors in contexts where the lifetime of the string reference is not automatically coerced. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/agent/debug/mod.rs | 4 ++-- crates/openhuman-core/src/agent/prompts/sections.rs | 2 +- .../openhuman-core/src/agent/subagent_host/ops/runner.rs | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/openhuman-core/src/agent/debug/mod.rs b/crates/openhuman-core/src/agent/debug/mod.rs index 9e1f681c6b..617bae1686 100644 --- a/crates/openhuman-core/src/agent/debug/mod.rs +++ b/crates/openhuman-core/src/agent/debug/mod.rs @@ -449,8 +449,8 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result> = rendered_tools .iter() .map(|t| PromptTool { - name: t.name(), - description: t.description(), + name: std::borrow::Cow::Borrowed(t.name()), + description: std::borrow::Cow::Borrowed(t.description()), parameters_schema: Some(t.parameters_schema().to_string()), }) .collect(); diff --git a/crates/openhuman-core/src/agent/prompts/sections.rs b/crates/openhuman-core/src/agent/prompts/sections.rs index 9cdf145ec3..d9e4d18738 100644 --- a/crates/openhuman-core/src/agent/prompts/sections.rs +++ b/crates/openhuman-core/src/agent/prompts/sections.rs @@ -381,7 +381,7 @@ impl PromptSection for ToolsSection { let visible: Vec = ctx .tools .iter() - .filter(|tool| !has_filter || ctx.visible_tool_names.contains(tool.name)) + .filter(|tool| !has_filter || ctx.visible_tool_names.contains(tool.name.as_ref())) .map(|tool| { let parameters = match tool.parameters_schema.as_deref() { Some(schema) => match serde_json::from_str(schema) { diff --git a/crates/openhuman-core/src/agent/subagent_host/ops/runner.rs b/crates/openhuman-core/src/agent/subagent_host/ops/runner.rs index 9c08bdda81..2e3099396a 100644 --- a/crates/openhuman-core/src/agent/subagent_host/ops/runner.rs +++ b/crates/openhuman-core/src/agent/subagent_host/ops/runner.rs @@ -1370,14 +1370,14 @@ async fn run_typed_mode( .map(|&i| { let t = parent.all_tools[i].as_ref(); PromptTool { - name: t.name(), - description: t.description(), + name: std::borrow::Cow::Borrowed(t.name()), + description: std::borrow::Cow::Borrowed(t.description()), parameters_schema: Some(t.parameters_schema().to_string()), } }) .chain(dynamic_tools.iter().map(|t| PromptTool { - name: t.name(), - description: t.description(), + name: std::borrow::Cow::Borrowed(t.name()), + description: std::borrow::Cow::Borrowed(t.description()), parameters_schema: Some(t.parameters_schema().to_string()), })) .collect(); From 9461e44d9d74a541213ba878a2314eeb16f0af62 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 19:11:49 +0300 Subject: [PATCH 39/56] test(tool_prep): use PromptTool::with_schema in test helper Replaced the manual construction of a PromptTool struct with the dedicated `with_schema` constructor in the `render_tools_at` test helper, making the test code more idiomatic and reducing boilerplate. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/subagent_host/tool_prep_tests.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/openhuman-core/src/agent/subagent_host/tool_prep_tests.rs b/crates/openhuman-core/src/agent/subagent_host/tool_prep_tests.rs index aaf3a7f0aa..4e7ab31a6b 100644 --- a/crates/openhuman-core/src/agent/subagent_host/tool_prep_tests.rs +++ b/crates/openhuman-core/src/agent/subagent_host/tool_prep_tests.rs @@ -417,13 +417,11 @@ fn a_dynamic_tool_list_without_spawn_tools_is_untouched() { /// Render the `## Tools` section the way a sub-agent prompt does, at `format`. fn render_tools_at(format: crate::agent::prompts::ToolCallFormat, instructions: &str) -> String { use crate::agent::prompts::{LearnedContextData, PromptTool}; - let tools = [PromptTool { - name: "composio_execute", - description: "Run one Composio action.", - parameters_schema: Some( - r#"{"type":"object","properties":{"action":{"type":"string"}}}"#.into(), - ), - }]; + let tools = [PromptTool::with_schema( + "composio_execute", + "Run one Composio action.", + r#"{"type":"object","properties":{"action":{"type":"string"}}}"#.into(), + )]; let visible = std::collections::HashSet::new(); let ctx = PromptContext { workspace_dir: std::path::Path::new("/tmp"), From 4f81a2ca813012cadd7dbd2dd254af5bfff30e7d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 19:14:50 +0300 Subject: [PATCH 40/56] test: add tests for bridge prompt tools behaviour with deferred catalogue Three new tests verify that bridge_prompt_tools correctly advertises search and call tools when a deferred catalogue is present, does not enumerate the full deferred catalogue in the prompt, and returns an empty list when no deferred catalogue exists. These tests guard against a live failure where the model would narrate a tool call it could not execute because the required signatures were missing from the prompt. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinyagents/discovery/discovery_tests.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/openhuman-core/src/agent/tinyagents/discovery/discovery_tests.rs b/crates/openhuman-core/src/agent/tinyagents/discovery/discovery_tests.rs index 406a2084f5..effa922a89 100644 --- a/crates/openhuman-core/src/agent/tinyagents/discovery/discovery_tests.rs +++ b/crates/openhuman-core/src/agent/tinyagents/discovery/discovery_tests.rs @@ -81,3 +81,50 @@ async fn overlap_ranker_ranks_by_token_overlap_and_names_its_kind() { .await .is_err()); } + +/// A text dialect renders the prompt catalogue and the harness clears +/// `request.tools`, so the bridge only reaches the model through the host's +/// own catalogue. Without these entries the model reads a search result +/// telling it to "invoke a match with `tool_call`" and has no signature for +/// that name — observed live as a turn that narrates the call it is about to +/// make and then stops. +#[test] +fn bridge_prompt_tools_advertise_search_and_call_when_something_is_deferred() { + let _g = guard(); + apply_tool_search_config(&ToolSearchConfig::default()); + let bridge = bridge_prompt_tools(12); + let names: Vec<&str> = bridge.iter().map(|t| t.name.as_ref()).collect(); + assert_eq!(names, vec!["tool_search", "tool_call"]); + for tool in &bridge { + assert!( + tool.parameters_schema + .as_deref() + .is_some_and(|schema| schema.contains("\"type\":\"object\"")), + "{} must carry a callable schema, got {:?}", + tool.name, + tool.parameters_schema + ); + } +} + +/// The manifest is left out on purpose: naming every deferred tool in the +/// prompt is the cost deferral exists to avoid. +#[test] +fn bridge_prompt_tools_do_not_enumerate_the_deferred_catalogue() { + let _g = guard(); + apply_tool_search_config(&ToolSearchConfig::default()); + let search = bridge_prompt_tools(3).into_iter().next().expect("bridge"); + assert!( + search.description.len() < 1_000, + "the bridge description must stay a pointer to the search, not a catalogue: {} bytes", + search.description.len() + ); +} + +/// Nothing deferred, nothing to advertise: a belt that did not opt into +/// discovery must not pay two schemas for a bridge it cannot use. +#[test] +fn bridge_prompt_tools_are_empty_without_a_deferred_catalogue() { + let _g = guard(); + assert!(bridge_prompt_tools(0).is_empty()); +} From 06786d956bad9a3202f28af531f1e805bac3ee24 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 19:18:38 +0300 Subject: [PATCH 41/56] feat(prompts): extract deferred-tool bridge into shared helper Extract the logic that replaces deferred tool schemas with discovery bridge entries into a dedicated function, removing the inline implementation that was duplicated in the turn context builder. This ensures consistent behaviour across both call sites and centralises the fix for two bugs: deferred schemas were being rendered into the prompt despite deferral, and the discovery bridge entries were missing for text-dialect providers, causing the model to narrate intent instead of making tool calls. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../openhuman-core/src/agent/prompts/types.rs | 41 +++++++++++++++++++ .../src/agent/session_host/runtime_session.rs | 9 +++- .../src/agent/session_host/turn/context.rs | 33 +++------------ 3 files changed, 53 insertions(+), 30 deletions(-) diff --git a/crates/openhuman-core/src/agent/prompts/types.rs b/crates/openhuman-core/src/agent/prompts/types.rs index 63f29d36a9..b0a854b1e0 100644 --- a/crates/openhuman-core/src/agent/prompts/types.rs +++ b/crates/openhuman-core/src/agent/prompts/types.rs @@ -291,6 +291,47 @@ impl<'a> PromptTool<'a> { } } +/// Swap a prompt catalogue's `Deferred` entries for the discovery bridge. +/// +/// On a TEXT dialect (P-Format / code) the catalogue this prompt renders IS +/// the model's callable surface: the harness folds it into the system prompt +/// and clears `request.tools`. Two things follow, and both were wrong before +/// this helper existed: +/// +/// * **Deferred tools must leave the catalogue.** The filter each prompt site +/// used is the policy's allow-set, which deliberately admits deferred names +/// so a found tool stays *callable* (`reachable_names` in the session +/// builder). Filtering the prompt by it rendered every deferred schema into +/// the prompt — measured live at 107 connected Composio actions for 55 KB of +/// a 71 KB prompt, the exact cost deferral exists to avoid — and told the +/// model to search for an action whose signature it could already read. +/// +/// * **The bridge must take their place.** The harness mints `tool_search` / +/// `tool_call` onto `request.tools`, which a text dialect drops, and with +/// `host_renders_tool_catalogue` it appends nothing itself. Without these +/// entries the model reads "invoke a match with `tool_call`" in a search +/// result and has no signature for that name; observed live as a turn that +/// narrates the call it is about to make and then stops. +/// +/// A native-tool-calling provider is unaffected: it reads `request.tools`, +/// where the harness already puts exactly this pair. +pub fn swap_deferred_for_discovery_bridge<'a>( + prompt_tools: &mut Vec>, + visible_tool_names: &mut std::collections::HashSet, + deferred_tool_names: &std::collections::HashSet, +) { + if deferred_tool_names.is_empty() { + return; + } + visible_tool_names.retain(|name| !deferred_tool_names.contains(name)); + for bridge in + crate::agent::tinyagents::discovery::bridge_prompt_tools(deferred_tool_names.len()) + { + visible_tool_names.insert(bridge.name.to_string()); + prompt_tools.push(bridge); + } +} + /// How the tool catalogue should render each tool entry. Driven by the /// dispatcher choice on the agent — JSON-schema rendering is the /// historic format; P-Format is the new default text protocol. diff --git a/crates/openhuman-core/src/agent/session_host/runtime_session.rs b/crates/openhuman-core/src/agent/session_host/runtime_session.rs index 7f49f060a5..76e8a02e01 100644 --- a/crates/openhuman-core/src/agent/session_host/runtime_session.rs +++ b/crates/openhuman-core/src/agent/session_host/runtime_session.rs @@ -357,8 +357,13 @@ impl OpenHumanTurnPrelude { .chain(surface.synthesized_tools.iter()) .map(|tool| tool.as_ref()) .collect::>(); - let prompt_tools = PromptTool::from_tool_refs(tool_refs.iter().copied()); - let visible_tool_names = surface.tool_policy_session.visible_tool_names_for_prompt(); + let mut prompt_tools = PromptTool::from_tool_refs(tool_refs.iter().copied()); + let mut visible_tool_names = surface.tool_policy_session.visible_tool_names_for_prompt(); + crate::agent::prompts::swap_deferred_for_discovery_bridge( + &mut prompt_tools, + &mut visible_tool_names, + &surface.deferred_tool_names, + ); let agents_md = if self.config.agents_md_enabled { crate::agent::prompts::load_agents_md_layers(&self.workspace_dir, &self.action_dir) } else { diff --git a/crates/openhuman-core/src/agent/session_host/turn/context.rs b/crates/openhuman-core/src/agent/session_host/turn/context.rs index 3d70b30fe3..ccdf326be7 100644 --- a/crates/openhuman-core/src/agent/session_host/turn/context.rs +++ b/crates/openhuman-core/src/agent/session_host/turn/context.rs @@ -218,34 +218,11 @@ impl OpenHumanSessionHost { let mut prompt_tools = PromptTool::from_tool_refs(all_tools.iter().copied()); let mut prompt_visible_tool_names = self.tool_policy_session.visible_tool_names_for_prompt(); - // A `Deferred` tool is off the advertised surface, and on a TEXT - // dialect the catalogue this prompt renders IS that surface — the - // harness clears `request.tools` and the model calls what it reads - // here. The policy's allow-set is the wrong filter for it: the host - // deliberately admits the deferred names there so they stay - // *callable* (`reachable_names` in the session builder), so filtering - // by it rendered every deferred schema into the prompt. That cost the - // bytes deferral exists to save (measured: 107 connected Composio - // actions rendered 55 KB of a 71 KB prompt) and, worse, contradicted - // the prompt: the model was told to `tool_search` for an action whose - // signature was already in its catalogue. - // - // The bridge takes their place, when there is one. The harness mints - // `tool_search` / `tool_call` schemas onto `request.tools` for a run - // with a deferred catalogue, but a text dialect drops that set, and - // with `host_renders_tool_catalogue` the harness appends nothing of - // its own — so without these two entries the model is told to invoke - // matches with a `tool_call` it can never see a signature for, and - // answers with intent instead of a call. - if !self.deferred_tool_names.is_empty() { - prompt_visible_tool_names.retain(|name| !self.deferred_tool_names.contains(name)); - for bridge in crate::agent::tinyagents::discovery::bridge_prompt_tools( - self.deferred_tool_names.len(), - ) { - prompt_visible_tool_names.insert(bridge.name.to_string()); - prompt_tools.push(bridge); - } - } + crate::agent::prompts::swap_deferred_for_discovery_bridge( + &mut prompt_tools, + &mut prompt_visible_tool_names, + &self.deferred_tool_names, + ); // Load AGENTS.md instruction layers once per system-prompt build (never // re-read per turn — the caller builds the prompt once at session start // and reuses the bytes, preserving the frozen-prefix / KV-cache From 1d250926d7695df0f795b14e382fa982bd60b782 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 19:25:31 +0300 Subject: [PATCH 42/56] test: add tests for deferred tool swapping behaviour Add two unit tests for the `swap_deferred_for_discovery_bridge` function. The first test verifies that deferred tools are removed from the visible catalogue and replaced with discovery bridge entries, while the second confirms that the function is a no-op when no deferred set is provided. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/prompts/mod_tests.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/openhuman-core/src/agent/prompts/mod_tests.rs b/crates/openhuman-core/src/agent/prompts/mod_tests.rs index 1e40785c09..939aa759dc 100644 --- a/crates/openhuman-core/src/agent/prompts/mod_tests.rs +++ b/crates/openhuman-core/src/agent/prompts/mod_tests.rs @@ -212,3 +212,63 @@ fn tool_call_format_maps_to_the_dialect_and_harness_vocabulary() { assert_eq!(host.code_style(), style); } } + +/// The prompt catalogue is the callable surface on a text dialect, so a +/// deferred tool must leave it and the discovery bridge must take its place. +/// Rendering the deferred set instead (what the policy allow-set does, since +/// it admits those names to keep a found tool callable) both spends the bytes +/// deferral exists to save and tells the model to search for a signature it +/// can already read. +#[test] +fn swapping_deferred_entries_leaves_the_bridge_in_their_place() { + let mut tools = vec![ + PromptTool::new("shell", "Run a command."), + PromptTool::new("GMAIL_SEND_EMAIL", "Send an email."), + PromptTool::new("GMAIL_FETCH_EMAILS", "Read email."), + ]; + let mut visible: HashSet = ["shell", "GMAIL_SEND_EMAIL", "GMAIL_FETCH_EMAILS"] + .into_iter() + .map(str::to_string) + .collect(); + let deferred: HashSet = ["GMAIL_SEND_EMAIL", "GMAIL_FETCH_EMAILS"] + .into_iter() + .map(str::to_string) + .collect(); + + swap_deferred_for_discovery_bridge(&mut tools, &mut visible, &deferred); + + assert!(visible.contains("shell"), "a direct tool stays advertised"); + assert!( + !visible.contains("GMAIL_SEND_EMAIL") && !visible.contains("GMAIL_FETCH_EMAILS"), + "deferred tools must not be rendered into the catalogue: {visible:?}" + ); + assert!( + visible.contains("tool_search") && visible.contains("tool_call"), + "the bridge replaces them so the model can reach what it finds: {visible:?}" + ); + for name in ["tool_search", "tool_call"] { + assert!( + tools.iter().any(|tool| tool.name == name + && tool + .parameters_schema + .as_deref() + .is_some_and(|schema| schema.contains("\"type\":\"object\""))), + "{name} must reach the catalogue with a callable schema" + ); + } +} + +/// Nothing deferred: the catalogue and the advertised set are untouched, and +/// a belt that never opted into discovery does not pay for two bridge +/// schemas it cannot use. +#[test] +fn swapping_is_a_no_op_without_a_deferred_set() { + let mut tools = vec![PromptTool::new("shell", "Run a command.")]; + let mut visible: HashSet = ["shell"].into_iter().map(str::to_string).collect(); + + swap_deferred_for_discovery_bridge(&mut tools, &mut visible, &HashSet::new()); + + assert_eq!(tools.len(), 1); + assert_eq!(visible.len(), 1); + assert!(visible.contains("shell")); +} From 263adadb712e96faee29270ff75e1f6f4da7cbef Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 19:26:09 +0300 Subject: [PATCH 43/56] chore: files changed crates/openhuman-core/src/agent/prompts/types.rs,crates/openhuman-core/src/agen Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/agent/prompts/types.rs | 6 +++++- crates/openhuman-core/src/agent/tinyagents/discovery/mod.rs | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/openhuman-core/src/agent/prompts/types.rs b/crates/openhuman-core/src/agent/prompts/types.rs index b0a854b1e0..a33c268659 100644 --- a/crates/openhuman-core/src/agent/prompts/types.rs +++ b/crates/openhuman-core/src/agent/prompts/types.rs @@ -250,7 +250,11 @@ impl<'a> PromptTool<'a> { /// An entry the catalogue owns rather than borrows: a tool that exists /// only for this prompt build (the harness's `tool_search` / `tool_call` /// bridge), with no registration to borrow a name from. - pub fn owned(name: String, description: String, parameters_schema: String) -> PromptTool<'static> { + pub fn owned( + name: String, + description: String, + parameters_schema: String, + ) -> PromptTool<'static> { PromptTool { name: std::borrow::Cow::Owned(name), description: std::borrow::Cow::Owned(description), diff --git a/crates/openhuman-core/src/agent/tinyagents/discovery/mod.rs b/crates/openhuman-core/src/agent/tinyagents/discovery/mod.rs index a0126104ba..9f4988fc29 100644 --- a/crates/openhuman-core/src/agent/tinyagents/discovery/mod.rs +++ b/crates/openhuman-core/src/agent/tinyagents/discovery/mod.rs @@ -142,8 +142,9 @@ pub(crate) fn discovery_policy() -> ToolDiscoveryPolicy { /// manifest is rendered from; the per-tool manifest itself is deliberately /// left out of the prompt, because listing every deferred tool there is the /// cost deferral exists to avoid. -pub(crate) fn bridge_prompt_tools(deferred: usize) -> Vec> -{ +pub(crate) fn bridge_prompt_tools( + deferred: usize, +) -> Vec> { if deferred == 0 { return Vec::new(); } From d8f2a9ecc171c4fb66de2af29bf60dad29566402 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 19:45:21 +0300 Subject: [PATCH 44/56] fix(orchestrator): tighten tool-search and live-data instructions Clarify that a tool search must be followed by an actual tool call in the same message, and remove the redundant instruction about making the call in the same message from the live-data section since it is already covered by the tool-search rule. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/registry/agents/orchestrator/prompt.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md index ea822a9d05..a1f49f2ad6 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md @@ -4,12 +4,12 @@ Take the first branch that applies: 1. **Answerable without tools**: reply. Small talk, simple Q&A, general knowledge. 1b. **Needs a capability you do not see listed**: `tool_search` with the intent in plain words before delegating or declining. Your list is a core set; if nothing comes back, say so. -2. **Needs a connected service's own data or actions** (inbox, messages, calendar, docs, tickets, "send/check X"): `tool_search` for the action ("send an email", "list calendar events") and call the tool it returns yourself; there is no integrations sub-agent. Use the live service even when memory could plausibly answer. A service being connected is not a reason to touch it: general knowledge, web/news lookups, headlines, date/time and math never go to a service. Not connected? Raise a connect card with `composio_connect`: **Connected Integrations** shows what is connected, not what is connectable, so never refuse from it or send the user to settings, and never paste OAuth URLs. If the connect call reports the toolkit unavailable, relay its message; that is the only honest refusal. +2. **Needs a connected service's own data or actions** (inbox, messages, calendar, docs, tickets, "send/check X"): `tool_search` for the action ("send an email", "list calendar events") and call what it returns yourself; there is no integrations sub-agent, so a search you only announce never runs — emit the call in that same message, then the action's. Use the live service even when memory could plausibly answer. A service being connected is not a reason to touch it: general knowledge, web/news lookups, headlines, date/time and math never go to a service. Not connected? Raise a connect card with `composio_connect`: **Connected Integrations** shows what is connected, not what is connectable, so never refuse from it or send the user to settings, and never paste OAuth URLs. If the connect call reports the toolkit unavailable, relay its message; that is the only honest refusal. 3. **Solvable with a direct tool**: do it yourself. `web_search_tool` and `web_fetch` for a fact or a page, `memory_recall` and `memory_store` for the user's own facts, `shell` plus `apply_patch` for repository work. Keep code work end-to-end: edit and verify in the same turn; never delegate merely because a task touches a repository. 4. **Needs a specialist**: the specialists you can call are in your tool list with their own descriptions. **Capabilities not in your tool list** names the ones a skill holds; reach those through `use_skill`. Workers return only their result; carry out any `## Handoff Plan` they return yourself, under the approval gate. 5. **Distill every delegated reply**: keep what answers the question, drop the worker's notes. Never paste a sub-agent's response verbatim. -Live or time-sensitive asks (weather, forecasts, prices, recent news, "use live data") get answered now: one quick fact direct, anything broader via `research`. Don't stop at a lead-in; make the tool call in the same message. +Live or time-sensitive asks (weather, forecasts, prices, recent news, "use live data") get answered now: one quick fact direct, anything broader via `research`. Before searching, check **Connected MCP Servers**: if one can answer, hand it to `use_mcp_server`. ## Sub-agents From 059a6d3ba643ae3dc7bc2696d75bb2687a1aef17 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 19:47:23 +0300 Subject: [PATCH 45/56] fix(prompt): clarify tool_search and call sequence for connected services Reworded the instruction for handling connected services to make it explicit that the agent must both search for the tool and call it in the same message, removing the ambiguous phrasing about announcing a search that never runs. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/registry/agents/orchestrator/prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md index a1f49f2ad6..0edf3693ef 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md @@ -4,7 +4,7 @@ Take the first branch that applies: 1. **Answerable without tools**: reply. Small talk, simple Q&A, general knowledge. 1b. **Needs a capability you do not see listed**: `tool_search` with the intent in plain words before delegating or declining. Your list is a core set; if nothing comes back, say so. -2. **Needs a connected service's own data or actions** (inbox, messages, calendar, docs, tickets, "send/check X"): `tool_search` for the action ("send an email", "list calendar events") and call what it returns yourself; there is no integrations sub-agent, so a search you only announce never runs — emit the call in that same message, then the action's. Use the live service even when memory could plausibly answer. A service being connected is not a reason to touch it: general knowledge, web/news lookups, headlines, date/time and math never go to a service. Not connected? Raise a connect card with `composio_connect`: **Connected Integrations** shows what is connected, not what is connectable, so never refuse from it or send the user to settings, and never paste OAuth URLs. If the connect call reports the toolkit unavailable, relay its message; that is the only honest refusal. +2. **Needs a connected service's own data or actions** (inbox, messages, calendar, docs, tickets, "send/check X"): `tool_search` for the action ("send an email", "list calendar events"), then call what it returns. No sub-agent runs it for you, and an announced search never happens: emit the call itself in that message. Use the live service even when memory could plausibly answer. A service being connected is not a reason to touch it: general knowledge, web/news lookups, headlines, date/time and math never go to a service. Not connected? Raise a connect card with `composio_connect`: **Connected Integrations** shows what is connected, not what is connectable, so never refuse from it or send the user to settings, and never paste OAuth URLs. If the connect call reports the toolkit unavailable, relay its message; that is the only honest refusal. 3. **Solvable with a direct tool**: do it yourself. `web_search_tool` and `web_fetch` for a fact or a page, `memory_recall` and `memory_store` for the user's own facts, `shell` plus `apply_patch` for repository work. Keep code work end-to-end: edit and verify in the same turn; never delegate merely because a task touches a repository. 4. **Needs a specialist**: the specialists you can call are in your tool list with their own descriptions. **Capabilities not in your tool list** names the ones a skill holds; reach those through `use_skill`. Workers return only their result; carry out any `## Handoff Plan` they return yourself, under the approval gate. 5. **Distill every delegated reply**: keep what answers the question, drop the worker's notes. Never paste a sub-agent's response verbatim. From dcf8363d1c5ff578d6c0f9cbeb753e812faeb2a7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 19:51:59 +0300 Subject: [PATCH 46/56] chore: files changed crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../agent/registry/agents/orchestrator/prompt_tests.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs index 554ff5462c..dd4f98b5d5 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs @@ -283,7 +283,11 @@ fn build_includes_direct_first_decision_tree() { // a `tool_search` + direct call rather than memory or a sub-agent. assert!(body.contains("Needs a connected service's own data or actions")); assert!(body.contains("Use the live service even when memory could plausibly answer")); - assert!(body.contains("there is no integrations sub-agent")); + assert!(body.contains("No sub-agent runs it for you")); + // The lead-in rule lives on the branch where the failure was observed: a + // live run had the model answer "let me search for the right tool" and + // end the turn without emitting the `tool_search` call. + assert!(body.contains("an announced search never happens")); assert!(!body.contains("delegate_to_integrations_agent")); } @@ -294,7 +298,7 @@ fn build_routes_live_facts_to_research_tool() { assert!(body.contains("weather, forecasts, prices, recent news")); assert!(body.contains("\"use live data\"")); // A lead-in line is welcome, but only in the same message as the call. - assert!(body.contains("Don't stop at a lead-in; make the tool call in the same message.")); + assert!(body.contains("emit the call itself in that message")); assert!( !body.contains("delegate_researcher"), "orchestrator prompt should name the synthesized researcher tool" @@ -529,7 +533,7 @@ fn build_includes_evidence_aware_synthesis_contract() { assert!(body.contains("truncated, oversized, partial or unavailable")); assert!(body.contains("Preserve numeric evidence exactly")); assert!(body.contains("plus whatever `tool_search` returns")); - assert!(body.contains("call `tool_search` with the intent in plain words")); + assert!(body.contains("`tool_search` with the intent in plain words")); } #[test] From 6d6bd1d384af787df5f89c7eced897e650b2028a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 20:26:07 +0300 Subject: [PATCH 47/56] chore(deps): update tinyagents subproject commit Updated the pinned commit for the tinyagents vendored dependency to incorporate upstream fixes or improvements. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinyagents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyagents b/vendor/tinyagents index 0bc4ec443b..914f8202c4 160000 --- a/vendor/tinyagents +++ b/vendor/tinyagents @@ -1 +1 @@ -Subproject commit 0bc4ec443bdbd87170ea014b0a7e79991348394b +Subproject commit 914f8202c4f6c82ed0519a412a5fcb78c9dba318 From 7d9ffca14d91a0e806b98fc7a7af59803d73726a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 20:31:29 +0300 Subject: [PATCH 48/56] chore(deps): update tinyagents subproject commit Update the pinned commit of the tinyagents subproject to include the latest upstream changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinyagents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyagents b/vendor/tinyagents index 28760a45c2..00785f617b 160000 --- a/vendor/tinyagents +++ b/vendor/tinyagents @@ -1 +1 @@ -Subproject commit 28760a45c22f1e449d837f68d3b5115eeb15eae7 +Subproject commit 00785f617bb58c21984a64ba40410927fb3724f5 From a62a8fee1690b696a509a8ac7187c4d1969a48cb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 20:31:54 +0300 Subject: [PATCH 49/56] chore(deps): pin vendor/tinyagents to the DSML tool_call tag fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same base main already pins (3c9ba00, the session-todo-list work this tree depends on), plus the tinytools bump from tinyhumansai/tinyagents#196 → tinyhumansai/tinytools#21: a `<|DSML|tool_call>` block parsed as narrative and the call was dropped silently. `deepseek-v4-flash` emits that form on the code dialect, which is the path this branch's integration work now leans on. Repoint to the merge commit once #196 lands. Co-authored-by: Medulla --- vendor/tinyagents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyagents b/vendor/tinyagents index 00785f617b..8a26019ec5 160000 --- a/vendor/tinyagents +++ b/vendor/tinyagents @@ -1 +1 @@ -Subproject commit 00785f617bb58c21984a64ba40410927fb3724f5 +Subproject commit 8a26019ec568a8b65b92f1f91010562139fba775 From d7d062a223b51d4103939f1621124f4ccca8490a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 20:38:32 +0300 Subject: [PATCH 50/56] fix(orchestrator): clarify tool-search instruction in prompt Reworded the instruction for emitting a tool call after a search to remove the redundant "so an announced search never happens" clause and simplify the phrasing, making the rule clearer that the call must be emitted directly rather than announced. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/registry/agents/orchestrator/prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md index eb04d3def9..81d7d48b43 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md @@ -4,7 +4,7 @@ Take the first branch that applies: 1. **Answerable without tools**: reply. Small talk, simple Q&A, general knowledge. 1b. **Needs a capability you do not see listed**: `tool_search` with the intent in plain words before delegating or declining. Your list is a core set; if nothing comes back, say so. -2. **Needs a connected service's own data or actions** (inbox, messages, calendar, docs, tickets, "send/check X"): `tool_search` for the action ("send an email", "list calendar events"), then call what it returns. No sub-agent runs it for you, so an announced search never happens: emit the call itself in that message. Use the live service even when memory could plausibly answer. A service being connected is not a reason to touch it: general knowledge, web/news lookups, headlines, date/time, math, and anything public on the web (a public repository, a product page, docs) never go to a service; those are `web_fetch` / `web_search_tool` / `research` work. Reach for a toolkit only for the user's own account data or actions on it. Not connected? Raise a connect card with `composio_connect`: **Connected Integrations** shows what is connected, not what is connectable, so never refuse from it or send the user to settings, and never paste OAuth URLs. If the connect call reports the toolkit unavailable, relay its message; that is the only honest refusal. +2. **Needs a connected service's own data or actions** (inbox, messages, calendar, docs, tickets, "send/check X"): `tool_search` for the action ("send an email", "list calendar events"), then call what it returns. No sub-agent runs it for you, and an announced search never happens: emit the call. Use the live service even when memory could plausibly answer. A service being connected is not a reason to touch it: general knowledge, web/news lookups, headlines, date/time, math, and anything public on the web (a public repository, a product page, docs) never go to a service; those are `web_fetch` / `web_search_tool` / `research` work. Reach for a toolkit only for the user's own account data or actions on it. Not connected? Raise a connect card with `composio_connect`: **Connected Integrations** shows what is connected, not what is connectable, so never refuse from it or send the user to settings, and never paste OAuth URLs. If the connect call reports the toolkit unavailable, relay its message; that is the only honest refusal. 3. **Solvable with a direct tool**: do it yourself. `web_search_tool` and `web_fetch` for a fact or a page, `memory_recall` and `memory_store` for the user's own facts, `shell` plus `apply_patch` for repository work. Keep code work end-to-end: edit and verify in the same turn; never delegate merely because a task touches a repository. 4. **Needs a specialist**: the specialists you can call are in your tool list with their own descriptions. **Capabilities not in your tool list** names the ones a skill holds; reach those through `use_skill`. Workers return only their result; carry out any `## Handoff Plan` they return yourself, under the approval gate. 5. **Distill every delegated reply**: keep what answers the question, drop the worker's notes. Never paste a sub-agent's response verbatim. From f895a999a84e40ef590d321f5ce29ee67dbf4820 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 20:41:43 +0300 Subject: [PATCH 51/56] fix(prompt): correct ambiguous phrasing in orchestrator prompt Changed "an announced search never happens: emit the call" to "an announced search never runs: emit it" to clarify that the search action itself should be executed rather than merely announced, removing the misleading implication that the search should not occur. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/registry/agents/orchestrator/prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md index 81d7d48b43..dc88921fe0 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md @@ -4,7 +4,7 @@ Take the first branch that applies: 1. **Answerable without tools**: reply. Small talk, simple Q&A, general knowledge. 1b. **Needs a capability you do not see listed**: `tool_search` with the intent in plain words before delegating or declining. Your list is a core set; if nothing comes back, say so. -2. **Needs a connected service's own data or actions** (inbox, messages, calendar, docs, tickets, "send/check X"): `tool_search` for the action ("send an email", "list calendar events"), then call what it returns. No sub-agent runs it for you, and an announced search never happens: emit the call. Use the live service even when memory could plausibly answer. A service being connected is not a reason to touch it: general knowledge, web/news lookups, headlines, date/time, math, and anything public on the web (a public repository, a product page, docs) never go to a service; those are `web_fetch` / `web_search_tool` / `research` work. Reach for a toolkit only for the user's own account data or actions on it. Not connected? Raise a connect card with `composio_connect`: **Connected Integrations** shows what is connected, not what is connectable, so never refuse from it or send the user to settings, and never paste OAuth URLs. If the connect call reports the toolkit unavailable, relay its message; that is the only honest refusal. +2. **Needs a connected service's own data or actions** (inbox, messages, calendar, docs, tickets, "send/check X"): `tool_search` for the action ("send an email", "list calendar events"), then call what it returns. No sub-agent runs it for you, and an announced search never runs: emit it. Use the live service even when memory could plausibly answer. A service being connected is not a reason to touch it: general knowledge, web/news lookups, headlines, date/time, math, and anything public on the web (a public repository, a product page, docs) never go to a service; those are `web_fetch` / `web_search_tool` / `research` work. Reach for a toolkit only for the user's own account data or actions on it. Not connected? Raise a connect card with `composio_connect`: the list shows what is connected, not what is connectable, so never refuse from it or send the user to settings, and never paste OAuth URLs. If the connect call reports the toolkit unavailable, relay its message; that is the only honest refusal. 3. **Solvable with a direct tool**: do it yourself. `web_search_tool` and `web_fetch` for a fact or a page, `memory_recall` and `memory_store` for the user's own facts, `shell` plus `apply_patch` for repository work. Keep code work end-to-end: edit and verify in the same turn; never delegate merely because a task touches a repository. 4. **Needs a specialist**: the specialists you can call are in your tool list with their own descriptions. **Capabilities not in your tool list** names the ones a skill holds; reach those through `use_skill`. Workers return only their result; carry out any `## Handoff Plan` they return yourself, under the approval gate. 5. **Distill every delegated reply**: keep what answers the question, drop the worker's notes. Never paste a sub-agent's response verbatim. From 3a7754905a813be91724e872694b636fca556db2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 20:44:24 +0300 Subject: [PATCH 52/56] fix(prompt): remove redundant instruction from orchestrator prompt Removed the phrase "Your list is a core set" from the orchestrator prompt's first branch condition, as it was redundant with the existing instruction to report when no results are returned from a tool search. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/registry/agents/orchestrator/prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md index dc88921fe0..47c556066a 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt.md @@ -3,7 +3,7 @@ Take the first branch that applies: 1. **Answerable without tools**: reply. Small talk, simple Q&A, general knowledge. -1b. **Needs a capability you do not see listed**: `tool_search` with the intent in plain words before delegating or declining. Your list is a core set; if nothing comes back, say so. +1b. **Needs a capability you do not see listed**: `tool_search` with the intent in plain words before delegating or declining; if nothing comes back, say so. 2. **Needs a connected service's own data or actions** (inbox, messages, calendar, docs, tickets, "send/check X"): `tool_search` for the action ("send an email", "list calendar events"), then call what it returns. No sub-agent runs it for you, and an announced search never runs: emit it. Use the live service even when memory could plausibly answer. A service being connected is not a reason to touch it: general knowledge, web/news lookups, headlines, date/time, math, and anything public on the web (a public repository, a product page, docs) never go to a service; those are `web_fetch` / `web_search_tool` / `research` work. Reach for a toolkit only for the user's own account data or actions on it. Not connected? Raise a connect card with `composio_connect`: the list shows what is connected, not what is connectable, so never refuse from it or send the user to settings, and never paste OAuth URLs. If the connect call reports the toolkit unavailable, relay its message; that is the only honest refusal. 3. **Solvable with a direct tool**: do it yourself. `web_search_tool` and `web_fetch` for a fact or a page, `memory_recall` and `memory_store` for the user's own facts, `shell` plus `apply_patch` for repository work. Keep code work end-to-end: edit and verify in the same turn; never delegate merely because a task touches a repository. 4. **Needs a specialist**: the specialists you can call are in your tool list with their own descriptions. **Capabilities not in your tool list** names the ones a skill holds; reach those through `use_skill`. Workers return only their result; carry out any `## Handoff Plan` they return yourself, under the approval gate. From d34e8e4759864fe6e58ae1493babe585474274da Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 20:46:56 +0300 Subject: [PATCH 53/56] fix(orchestrator): update prompt test assertions to match new wording Updated three test assertions in the orchestrator prompt tests to reflect changes in the prompt text. The phrase "an announced search never happens" was changed to "an announced search never runs", the lead-in line assertion was updated to include the full phrase "an announced search never runs: emit it", and the assertion about the connected list was prefixed with "the list shows" to match the updated prompt wording. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/agent/registry/agents/orchestrator/prompt_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs index c1a217683a..1b0cf40e4b 100644 --- a/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs +++ b/crates/openhuman-core/src/agent/registry/agents/orchestrator/prompt_tests.rs @@ -287,7 +287,7 @@ fn build_includes_direct_first_decision_tree() { // The lead-in rule lives on the branch where the failure was observed: a // live run had the model answer "let me search for the right tool" and // end the turn without emitting the `tool_search` call. - assert!(body.contains("an announced search never happens")); + assert!(body.contains("an announced search never runs")); assert!(!body.contains("delegate_to_integrations_agent")); } @@ -298,7 +298,7 @@ fn build_routes_live_facts_to_research_tool() { assert!(body.contains("weather, forecasts, prices, recent news")); assert!(body.contains("\"use live data\"")); // A lead-in line is welcome, but only in the same message as the call. - assert!(body.contains("emit the call itself in that message")); + assert!(body.contains("an announced search never runs: emit it")); assert!( !body.contains("delegate_researcher"), "orchestrator prompt should name the synthesized researcher tool" @@ -393,7 +393,7 @@ fn build_does_not_route_scope_errors_as_disconnected() { // connectable list. assert!(body.contains("If the connect call reports the toolkit unavailable, relay its message")); assert!(body.contains("that is the only honest refusal")); - assert!(body.contains("shows what is connected, not what is connectable")); + assert!(body.contains("the list shows what is connected, not what is connectable")); assert!(body.contains("`composio_connect`")); } From 9a8a000b17a2f5b5a6b6c5f47c7fd505a24382ba Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 20:52:13 +0300 Subject: [PATCH 54/56] test: reformat test assertions for readability in toolTimelineFormatting Condense multi-line test entries into single-line calls and adjust the formatting of the Google Calendar assertion to improve readability without changing the test logic. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../utils/__tests__/toolTimelineFormatting.test.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/src/utils/__tests__/toolTimelineFormatting.test.ts b/app/src/utils/__tests__/toolTimelineFormatting.test.ts index ba97947f26..5fbbdb0d2e 100644 --- a/app/src/utils/__tests__/toolTimelineFormatting.test.ts +++ b/app/src/utils/__tests__/toolTimelineFormatting.test.ts @@ -71,15 +71,13 @@ describe('formatTimelineEntry', () => { it('labels a direct connected-service action by its provider', () => { expect( formatTimelineEntry( - entry({ - name: 'GMAIL_SEND_EMAIL', - argsBuffer: JSON.stringify({ to: 'alex@example.com' }), - }) + entry({ name: 'GMAIL_SEND_EMAIL', argsBuffer: JSON.stringify({ to: 'alex@example.com' }) }) ) ).toEqual({ title: 'Making requests to your Gmail account', detail: 'Send email' }); - expect( - formatTimelineEntry(entry({ name: 'GOOGLE_CALENDAR_CREATE_EVENT' })) - ).toEqual({ title: 'Updating your Google Calendar', detail: 'Create event' }); + expect(formatTimelineEntry(entry({ name: 'GOOGLE_CALENDAR_CREATE_EVENT' }))).toEqual({ + title: 'Updating your Google Calendar', + detail: 'Create event', + }); }); it('keeps the generic label for upper-case names on unknown toolkits', () => { From f94ffcf4c4cc8974921b35d19460709a25099c0e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 20:53:38 +0300 Subject: [PATCH 55/56] chore(deps): follow the tinyagents pin after the tinytools repoint Co-authored-by: Medulla --- vendor/tinyagents | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinyagents b/vendor/tinyagents index 8a26019ec5..b02b2e0b41 160000 --- a/vendor/tinyagents +++ b/vendor/tinyagents @@ -1 +1 @@ -Subproject commit 8a26019ec568a8b65b92f1f91010562139fba775 +Subproject commit b02b2e0b419615234af823bbdb18a809a5920c8a From c745015e78668acf00f4d6f50deb8381a495aeaa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 22 Sep 2026 21:41:22 +0300 Subject: [PATCH 56/56] chore(deps): update tinyjuice dependencies and reformat test code Update the tinyjuice dependency to use an explicit version for the dirs crate and add serde_json as a dependency for tinyjuice-bus. Also reformat several test assertions in web_fetch_tests.rs to improve code readability without changing any behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-app/Cargo.lock | 3 ++- .../src/tools/impl/network/web_fetch_tests.rs | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/openhuman-app/Cargo.lock b/crates/openhuman-app/Cargo.lock index 4904cec2de..47474fb0a3 100644 --- a/crates/openhuman-app/Cargo.lock +++ b/crates/openhuman-app/Cargo.lock @@ -7502,7 +7502,7 @@ name = "tinyjuice" version = "0.2.5" dependencies = [ "async-trait", - "dirs", + "dirs 7.0.0", "hex", "log", "once_cell", @@ -7522,6 +7522,7 @@ name = "tinyjuice-bus" version = "0.2.5" dependencies = [ "serde", + "serde_json", ] [[package]] diff --git a/crates/openhuman-core/src/tools/impl/network/web_fetch_tests.rs b/crates/openhuman-core/src/tools/impl/network/web_fetch_tests.rs index 68fca6c8d7..6773250135 100644 --- a/crates/openhuman-core/src/tools/impl/network/web_fetch_tests.rs +++ b/crates/openhuman-core/src/tools/impl/network/web_fetch_tests.rs @@ -135,7 +135,10 @@ fn a_missing_content_type_falls_back_to_content_detection() { #[test] fn an_empty_content_type_does_not_veto_detection() { - assert!(is_html("x", Some(""))); + assert!(is_html( + "x", + Some("") + )); } #[test] @@ -156,7 +159,9 @@ fn the_schema_offers_the_raw_escape_hatch() { #[test] fn the_declared_cap_is_sized_for_extracted_markdown_not_raw_markup() { let tool = WebFetchTool::new(Arc::new(SecurityPolicy::default()), vec![], None, None); - let cap = tool.max_result_size_chars().expect("web_fetch declares a cap"); + let cap = tool + .max_result_size_chars() + .expect("web_fetch declares a cap"); assert!( (8_000..=32_000).contains(&cap), "cap should sit in the same range as Hermes (15k chars) and Codex \