diff --git a/scripts/stage-core-sidecar.mjs b/scripts/stage-core-sidecar.mjs index 669a0dd00..69093d6ce 100644 --- a/scripts/stage-core-sidecar.mjs +++ b/scripts/stage-core-sidecar.mjs @@ -32,6 +32,32 @@ function rustHostTriple() { return triple; } +function cargoTargetDir() { + if (process.env.CARGO_TARGET_DIR) { + // Resolve against the repo root so this path stays consistent + // with the `cargo build` invocation below (which runs with + // `cwd: root`). If the script were invoked from a different + // working directory and `CARGO_TARGET_DIR` were relative, a + // bare `resolve()` would anchor it to the wrong cwd and the + // staged binary lookup would miss. + return resolve(root, process.env.CARGO_TARGET_DIR); + } + const res = spawnSync( + "cargo", + ["metadata", "--format-version", "1", "--no-deps", "--manifest-path", "Cargo.toml"], + { cwd: root, encoding: "utf8", shell: false, maxBuffer: 64 * 1024 * 1024 }, + ); + if (res.status === 0 && res.stdout) { + try { + const meta = JSON.parse(res.stdout); + if (meta.target_directory) return resolve(meta.target_directory); + } catch { + // fall through to default + } + } + return join(root, "target"); +} + const triple = rustHostTriple(); const isWindows = process.platform === "win32"; const binName = isWindows ? "openhuman-core.exe" : "openhuman-core"; @@ -41,9 +67,7 @@ console.log( ); run("cargo", ["build", "--manifest-path", "Cargo.toml", "--bin", "openhuman-core"]); -const targetDir = process.env.CARGO_TARGET_DIR - ? resolve(process.env.CARGO_TARGET_DIR) - : join(root, "target"); +const targetDir = cargoTargetDir(); const source = join(targetDir, "debug", binName); if (!existsSync(source)) { console.error(`[core:stage] compiled binary not found: ${source}`); diff --git a/src/openhuman/agent/harness/fork_context.rs b/src/openhuman/agent/harness/fork_context.rs index f33cda6d9..8923f0cb8 100644 --- a/src/openhuman/agent/harness/fork_context.rs +++ b/src/openhuman/agent/harness/fork_context.rs @@ -85,6 +85,24 @@ pub struct ParentExecutionContext { /// Active Composio integrations the parent has fetched. pub connected_integrations: Vec, + + /// Composio client — populated alongside `connected_integrations` + /// when the parent agent fetches its integration list. Used by the + /// sub-agent runner to dynamically construct per-action + /// [`ComposioActionTool`](crate::openhuman::composio::ComposioActionTool) + /// entries at spawn time when `skills_agent` is scoped to a + /// specific toolkit. `None` when the user isn't signed in to + /// Composio or the backend was unreachable. + pub composio_client: Option, + + /// The parent's active tool-call format (Native / PFormat / Json). + /// Sub-agents render their system prompts with this format so the + /// `## Tool Use Protocol` section instructs the model in the + /// dialect the sub-agent's runtime will actually parse — without + /// this, sub-agents inherit a hardcoded PFormat default while the + /// runtime uses native function-calling, and the model emits + /// uncallable P-Format tool_call blocks. + pub tool_call_format: crate::openhuman::context::prompt::ToolCallFormat, } tokio::task_local! { diff --git a/src/openhuman/agent/harness/session/builder.rs b/src/openhuman/agent/harness/session/builder.rs index 5289e958a..010f2dfea 100644 --- a/src/openhuman/agent/harness/session/builder.rs +++ b/src/openhuman/agent/harness/session/builder.rs @@ -315,6 +315,7 @@ impl AgentBuilder { context, on_progress: None, connected_integrations: Vec::new(), + composio_client: None, // Default to `true` (omit) so legacy / custom agents built // without a definition stay lean. Opt-in agents thread their // `omit_profile = false` through the builder. diff --git a/src/openhuman/agent/harness/session/turn.rs b/src/openhuman/agent/harness/session/turn.rs index 9c40d25fb..473bc61e1 100644 --- a/src/openhuman/agent/harness/session/turn.rs +++ b/src/openhuman/agent/harness/session/turn.rs @@ -841,6 +841,8 @@ impl Agent { session_id: self.event_session_id().to_string(), channel: self.event_channel().to_string(), connected_integrations: self.connected_integrations.clone(), + composio_client: self.composio_client.clone(), + tool_call_format: self.tool_dispatcher.tool_call_format(), } } @@ -993,6 +995,9 @@ impl Agent { /// Fetches the user's active Composio connections and populates /// `self.connected_integrations` so the system prompt can surface them. + /// Also caches a [`ComposioClient`] on the session so the sub-agent + /// runner can construct per-action tools for `skills_agent` spawns + /// without rebuilding the client on every call. /// /// Delegates to the shared [`crate::openhuman::composio::fetch_connected_integrations`] /// which is the single source of truth for integration discovery. @@ -1008,6 +1013,7 @@ impl Agent { }; self.connected_integrations = crate::openhuman::composio::fetch_connected_integrations(&config).await; + self.composio_client = crate::openhuman::composio::build_composio_client(&config); } /// Builds the system prompt for the current turn, including tool @@ -1026,6 +1032,7 @@ impl Agent { let ctx = PromptContext { workspace_dir: &self.workspace_dir, model_name: &self.model_name, + agent_id: &self.agent_definition_name, tools: &prompt_tools, skills: &self.skills, dispatcher_instructions: &instructions, diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index 1262492d9..4d3c4f18c 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -91,6 +91,14 @@ pub struct Agent { /// [`ConnectedIntegrationsSection`] so the orchestrator knows which /// external services are available. pub(super) connected_integrations: Vec, + /// Composio client, built alongside `connected_integrations` and + /// shared into [`harness::ParentExecutionContext`] at turn start + /// so the sub-agent runner can dynamically construct per-action + /// [`crate::openhuman::composio::ComposioActionTool`] instances + /// when `skills_agent` is spawned with a `toolkit` argument. + /// `None` when the user isn't signed in or the backend is + /// unreachable. + pub(super) composio_client: Option, /// Mirrors the agent definition's `omit_profile` flag. Threaded into /// [`PromptContext::include_profile`] in `turn::build_system_prompt` /// so only user-facing agents (welcome, orchestrator, triggers) diff --git a/src/openhuman/agent/harness/subagent_runner.rs b/src/openhuman/agent/harness/subagent_runner.rs index 0bcd13412..79674cd0d 100644 --- a/src/openhuman/agent/harness/subagent_runner.rs +++ b/src/openhuman/agent/harness/subagent_runner.rs @@ -61,6 +61,15 @@ pub struct SubagentRunOptions { /// skill or system tools for this specific call. pub category_filter_override: Option, + /// Optional Composio toolkit scope (e.g. `"gmail"`, `"notion"`). + /// When set, skill-category tools are further restricted to those + /// whose name starts with the uppercased `{toolkit}_` prefix, and + /// the sub-agent's rendered `Connected Integrations` section is + /// narrowed to only that toolkit's entry. Used by main/orchestrator + /// when spawning `skills_agent` for a specific platform so the + /// sub-agent only sees one integration's tool catalogue. + pub toolkit_override: Option, + /// Optional context blob the parent wants to inject before the /// task prompt. Rendered as a `[Context]\n…\n` prefix. pub context: Option, @@ -214,7 +223,8 @@ async fn run_typed_mode( let category_filter = options .category_filter_override .or(definition.category_filter); - let allowed_indices = filter_tool_indices( + let toolkit_filter = options.toolkit_override.as_deref(); + let mut allowed_indices = filter_tool_indices( &parent.all_tools, &definition.tools, &definition.disallowed_tools, @@ -225,14 +235,87 @@ async fn run_typed_mode( category_filter, ); - let filtered_specs: Vec = allowed_indices + // ── Dynamic per-action toolkit tools (skills_agent + toolkit) ────── + // + // When `skills_agent` is spawned with a `toolkit` argument (e.g. + // `toolkit="gmail"`), build one [`ComposioActionTool`] per action + // in that toolkit and inject them into the sub-agent's tool list. + // Each carries the action's real JSON schema, so the LLM's native + // tool-calling path validates arguments before they hit the wire + // — no more "guess parameters from prose then dispatch through + // composio_execute" round-trips. + // + // Generic dispatchers (`composio_execute`, `composio_list_tools`) + // are stripped from the parent-filtered indices in this path so + // the model only sees one way to call each action. + let mut dynamic_tools: Vec> = Vec::new(); + let is_skills_agent_with_toolkit = definition.id == "skills_agent" && toolkit_filter.is_some(); + if is_skills_agent_with_toolkit { + // Drop EVERY skill-category parent tool. In the new + // architecture all integration discovery / authorization / + // dispatching is the orchestrator's responsibility (via the + // Delegation Guide and `spawn_subagent` pre-flight). The + // sub-agent's only job is to execute per-action tools for + // its bound toolkit, so leftover meta-tools (composio_*, + // apify_*, other-toolkit dispatchers) are pure noise that + // confuses the model and wastes tokens. + allowed_indices.retain(|&i| parent.all_tools[i].category() != ToolCategory::Skill); + + if let (Some(tk), Some(client)) = (toolkit_filter, parent.composio_client.as_ref()) { + // The spawn_subagent pre-flight already verified the + // toolkit is in the allowlist AND has an active + // connection, so the matching entry must be present and + // marked connected. Defensive lookup anyway. + if let Some(integration) = parent + .connected_integrations + .iter() + .find(|ci| ci.connected && ci.toolkit.eq_ignore_ascii_case(tk)) + { + for action in &integration.tools { + dynamic_tools.push(Box::new( + crate::openhuman::composio::ComposioActionTool::new( + client.clone(), + action.name.clone(), + action.description.clone(), + action.parameters.clone(), + ), + )); + } + tracing::debug!( + agent_id = %definition.id, + toolkit = %tk, + action_count = dynamic_tools.len(), + "[subagent_runner:typed] dynamically registered per-action composio tools" + ); + } else { + tracing::warn!( + agent_id = %definition.id, + toolkit = %tk, + "[subagent_runner:typed] toolkit not found among parent's connected integrations; sub-agent will have no callable actions (spawn_subagent pre-flight should have caught this)" + ); + } + } else if toolkit_filter.is_some() { + tracing::warn!( + agent_id = %definition.id, + "[subagent_runner:typed] toolkit requested but composio client is unavailable on parent context" + ); + } + } + + let mut filtered_specs: Vec = allowed_indices .iter() .map(|&i| parent.all_tool_specs[i].clone()) .collect(); - let allowed_names: HashSet = allowed_indices + let mut allowed_names: HashSet = allowed_indices .iter() .map(|&i| parent.all_tools[i].name().to_string()) .collect(); + // Append dynamic tool specs / names so they're discoverable by the + // provider (native tool-calling) and by the inner loop's allowlist. + for tool in &dynamic_tools { + filtered_specs.push(tool.spec()); + allowed_names.insert(tool.name().to_string()); + } tracing::debug!( agent_id = %definition.id, @@ -264,14 +347,37 @@ async fn run_typed_mode( definition.omit_profile, definition.omit_memory_md, ); + + // Sub-agent prompt rendering: only ever surface CONNECTED + // integrations. When narrowed to a specific toolkit, we further + // restrict to that one entry. Not-connected entries belong only + // in the orchestrator's Delegation Guide; they have no place in + // a sub-agent that's actually executing work. + let narrowed_integrations: Vec = + match toolkit_filter { + Some(tk) => parent + .connected_integrations + .iter() + .filter(|ci| ci.connected && ci.toolkit.eq_ignore_ascii_case(tk)) + .cloned() + .collect(), + None => parent + .connected_integrations + .iter() + .filter(|ci| ci.connected) + .cloned() + .collect(), + }; let rendered_prompt = extract_cache_boundary(&render_subagent_system_prompt( &parent.workspace_dir, &model, &allowed_indices, &parent.all_tools, + &dynamic_tools, &archetype_prompt_body, render_options, - &parent.connected_integrations, + parent.tool_call_format, + &narrowed_integrations, )); let system_prompt = rendered_prompt.text; let system_prompt_cache_boundary = rendered_prompt.cache_boundary; @@ -305,6 +411,7 @@ async fn run_typed_mode( parent.provider.as_ref(), &mut history, &parent.all_tools, + &dynamic_tools, &filtered_specs, &allowed_names, &model, @@ -397,10 +504,14 @@ async fn run_fork_mode( // Use the parent's iteration cap, not the synthetic fork definition's. let max_iterations = parent.agent_config.max_tool_iterations.max(1); + // Fork mode replays the parent's exact tool list — no dynamic + // toolkit-scoped tools, so `extra_tools` is empty. + let fork_extra_tools: Vec> = Vec::new(); let (output, iterations, agg_usage) = run_inner_loop( parent.provider.as_ref(), &mut history, &parent.all_tools, + &fork_extra_tools, fork.tool_specs.as_slice(), &allowed_names, &model, @@ -513,6 +624,7 @@ async fn run_inner_loop( provider: &dyn Provider, history: &mut Vec, parent_tools: &[Box], + extra_tools: &[Box], tool_specs: &[ToolSpec], allowed_names: &HashSet, model: &str, @@ -595,7 +707,11 @@ async fn run_inner_loop( "Error: tool '{}' is not available to the {} sub-agent", call.name, agent_id ) - } else if let Some(tool) = parent_tools.iter().find(|t| t.name() == call.name) { + } else if let Some(tool) = extra_tools + .iter() + .find(|t| t.name() == call.name) + .or_else(|| parent_tools.iter().find(|t| t.name() == call.name)) + { let args = parse_tool_arguments(&call.arguments); let timeout = crate::openhuman::tool_timeout::tool_execution_timeout_duration(); match tokio::time::timeout(timeout, tool.execute(args)).await { @@ -1200,6 +1316,8 @@ mod tests { session_id: "test-session".into(), channel: "test".into(), connected_integrations: vec![], + composio_client: None, + tool_call_format: crate::openhuman::context::prompt::ToolCallFormat::PFormat, } } @@ -1266,6 +1384,7 @@ mod tests { SubagentRunOptions { skill_filter_override: None, category_filter_override: None, + toolkit_override: None, context: None, task_id: Some("t1".into()), }, @@ -1396,6 +1515,7 @@ mod tests { SubagentRunOptions { skill_filter_override: Some("notion".into()), category_filter_override: None, + toolkit_override: None, context: None, task_id: None, }, diff --git a/src/openhuman/agent/triage/escalation.rs b/src/openhuman/agent/triage/escalation.rs index f1aca54d7..8f9f4feed 100644 --- a/src/openhuman/agent/triage/escalation.rs +++ b/src/openhuman/agent/triage/escalation.rs @@ -157,6 +157,16 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result { + log::warn!( + "[web-channel] run_chat_task failed client_id={} thread_id={} request_id={} error={}", + client_id_task, + thread_id_task, + request_id_task, + err + ); publish_web_channel_event(WebChannelEvent { event: "chat_error".to_string(), client_id: client_id_task.clone(), diff --git a/src/openhuman/composio/action_tool.rs b/src/openhuman/composio/action_tool.rs new file mode 100644 index 000000000..8e3f8046a --- /dev/null +++ b/src/openhuman/composio/action_tool.rs @@ -0,0 +1,121 @@ +//! Per-action Composio tool wrapper. +//! +//! A [`ComposioActionTool`] is a [`Tool`] that represents exactly one +//! Composio action (e.g. `GMAIL_SEND_EMAIL`). It holds the action's +//! name, description, and parameter JSON schema so the LLM's native +//! tool-calling path can validate arguments before they hit the wire. +//! +//! These are constructed **dynamically at spawn time** by the sub-agent +//! runner when `skills_agent` is spawned with a `toolkit` argument — +//! one tool per action in the chosen toolkit. The generic +//! [`ComposioExecuteTool`](super::tools::ComposioExecuteTool) dispatcher +//! is deliberately excluded from `skills_agent`'s tool list in that +//! path so the model doesn't see two ways to call the same action. +//! +//! Lifetime: these tools live for the duration of a single sub-agent +//! spawn. The underlying [`ComposioClient`] is cheap to clone (it +//! wraps an `Arc` internally), so each tool holds +//! its own owned clone and calls `client.execute_tool` directly when +//! invoked — no config reload or client rebuild on the hot path. + +use async_trait::async_trait; +use serde_json::Value; + +use super::client::ComposioClient; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult}; + +/// A single Composio action exposed as a first-class tool. +pub struct ComposioActionTool { + client: ComposioClient, + /// Action slug as-shipped to Composio, e.g. `"GMAIL_SEND_EMAIL"`. + action_name: String, + /// Human-readable description from the Composio tool-list response. + description: String, + /// Full JSON schema for the action's parameters. Falls back to + /// `{"type":"object"}` when the upstream response omits it so the + /// LLM still gets a valid (if loose) shape. + parameters: Value, +} + +impl ComposioActionTool { + pub fn new( + client: ComposioClient, + action_name: String, + description: String, + parameters: Option, + ) -> Self { + let parameters = parameters.unwrap_or_else(|| serde_json::json!({"type": "object"})); + Self { + client, + action_name, + description, + parameters, + } + } +} + +#[async_trait] +impl Tool for ComposioActionTool { + fn name(&self) -> &str { + &self.action_name + } + + fn description(&self) -> &str { + &self.description + } + + fn parameters_schema(&self) -> Value { + self.parameters.clone() + } + + fn permission_level(&self) -> PermissionLevel { + // Conservative default: many actions mutate external state + // (send mail, create issues, modify calendars). Match + // ComposioExecuteTool's write-level treatment so channel + // permission caps behave identically whether the model goes + // through the dispatcher or a per-action tool. + PermissionLevel::Write + } + + fn category(&self) -> ToolCategory { + ToolCategory::Skill + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let started = std::time::Instant::now(); + let res = self + .client + .execute_tool(&self.action_name, Some(args)) + .await; + let elapsed_ms = started.elapsed().as_millis() as u64; + + match res { + Ok(resp) => { + crate::core::event_bus::publish_global( + crate::core::event_bus::DomainEvent::ComposioActionExecuted { + tool: self.action_name.clone(), + success: resp.successful, + error: resp.error.clone(), + cost_usd: resp.cost_usd, + elapsed_ms, + }, + ); + Ok(ToolResult::success( + serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into()), + )) + } + Err(e) => { + crate::core::event_bus::publish_global( + crate::core::event_bus::DomainEvent::ComposioActionExecuted { + tool: self.action_name.clone(), + success: false, + error: Some(e.to_string()), + cost_usd: 0.0, + elapsed_ms, + }, + ); + Ok(ToolResult::error(format!("{}: {e}", self.action_name))) + } + } + } +} diff --git a/src/openhuman/composio/mod.rs b/src/openhuman/composio/mod.rs index 772664901..dbcd59b38 100644 --- a/src/openhuman/composio/mod.rs +++ b/src/openhuman/composio/mod.rs @@ -35,6 +35,7 @@ //! [`DomainEvent::ComposioTriggerReceived`]: //! crate::core::event_bus::DomainEvent::ComposioTriggerReceived +pub mod action_tool; pub mod bus; pub mod client; pub mod ops; @@ -45,6 +46,7 @@ pub mod tools; pub mod trigger_history; pub mod types; +pub use action_tool::ComposioActionTool; pub use bus::{register_composio_trigger_subscriber, ComposioTriggerSubscriber}; pub use client::{build_composio_client, ComposioClient}; pub use ops::{fetch_connected_integrations, invalidate_connected_integrations_cache}; diff --git a/src/openhuman/composio/ops.rs b/src/openhuman/composio/ops.rs index 8b3fed956..b396645fa 100644 --- a/src/openhuman/composio/ops.rs +++ b/src/openhuman/composio/ops.rs @@ -490,10 +490,15 @@ pub async fn fetch_connected_integrations(config: &Config) -> Vec Option> { @@ -504,76 +509,130 @@ async fn fetch_connected_integrations_uncached( return None; }; + // Pull the backend allowlist — every toolkit the orchestrator can + // possibly suggest, regardless of whether the user has authorized + // it yet. This is the universe of valid `toolkit` arguments to + // `spawn_subagent(skills_agent, …)`. + // + // On transient backend errors we return `None` instead of a + // degraded `Some(Vec::new())` so `fetch_connected_integrations` + // does NOT cache the failure. Caching an empty allowlist would + // hide every integration from the orchestrator until the process + // restarts or the cache is explicitly invalidated — a single 5xx + // during startup would silently break delegation for the whole + // session. + let allowlisted_toolkits: Vec = match client.list_toolkits().await { + Ok(resp) => resp.toolkits, + Err(e) => { + tracing::warn!("[composio] fetch_connected_integrations: list_toolkits failed: {e}"); + return None; + } + }; + + if allowlisted_toolkits.is_empty() { + tracing::debug!("[composio] fetch_connected_integrations: backend allowlist is empty"); + return Some(Vec::new()); + } + let connections = match client.list_connections().await { Ok(resp) => resp.connections, Err(e) => { tracing::warn!("[composio] fetch_connected_integrations: list_connections failed: {e}"); - return Some(Vec::new()); + // Same rationale as above — caching a snapshot where + // every toolkit is marked as not-connected would + // silently wipe main's Delegation Guide's "available + // now" bullets for the rest of the session. + return None; } }; - let active: Vec<_> = connections + // Active connection slugs (status filter mirrors the original logic). + let connected_slugs: std::collections::HashSet = connections .iter() .filter(|c| c.status == "ACTIVE" || c.status == "CONNECTED") + .map(|c| c.toolkit.clone()) .collect(); - if active.is_empty() { - return Some(Vec::new()); - } - - // Collect the unique toolkit slugs so we can batch-fetch their tools. - let toolkit_slugs: Vec = { - let mut slugs: Vec = active.iter().map(|c| c.toolkit.clone()).collect(); - slugs.sort(); - slugs.dedup(); - slugs + // Fetch available tool schemas — only for the connected slugs, + // since not-connected toolkits won't be invoked from a sub-agent. + let connected_slugs_vec: Vec = { + let mut v: Vec = connected_slugs.iter().cloned().collect(); + v.sort(); + v }; - - // Fetch available tool schemas for all connected toolkits in one call. - let tools_by_toolkit = match client.list_tools(Some(&toolkit_slugs)).await { - Ok(resp) => resp.tools, - Err(e) => { - tracing::warn!("[composio] fetch_connected_integrations: list_tools failed: {e}"); - Vec::new() + let tools_by_toolkit = if connected_slugs_vec.is_empty() { + Vec::new() + } else { + match client.list_tools(Some(&connected_slugs_vec)).await { + Ok(resp) => resp.tools, + Err(e) => { + tracing::warn!("[composio] fetch_connected_integrations: list_tools failed: {e}"); + // Same rationale as list_toolkits/list_connections — + // caching connected entries with empty `tools` vectors + // would cause `subagent_runner::run_typed_mode` to + // build zero dynamic Composio action tools for a + // toolkit-scoped `skills_agent` spawn, silently + // leaving the sub-agent with nothing callable. + return None; + } } }; - // Build the per-toolkit integration entries. - let mut integrations: Vec = toolkit_slugs + // Deduplicate the allowlist so a backend that returns duplicates + // doesn't produce dual entries downstream. + let mut unique_toolkits: Vec = allowlisted_toolkits.clone(); + unique_toolkits.sort(); + unique_toolkits.dedup(); + + // Build one entry per allowlisted toolkit. Connected entries + // carry their action catalogue; not-connected entries carry an + // empty `tools` vec. + let mut integrations: Vec = unique_toolkits .iter() .map(|slug| { - let tools: Vec = tools_by_toolkit - .iter() - .filter(|t| { - // Composio action slugs are prefixed with the toolkit - // name in uppercase, e.g. GMAIL_SEND_EMAIL. - t.function.name.starts_with(&slug.to_uppercase()) - }) - .map(|t| ConnectedIntegrationTool { - name: t.function.name.clone(), - description: t.function.description.clone().unwrap_or_default(), - }) - .collect(); + let connected = connected_slugs.contains(slug); + // Anchor the prefix with an underscore so slugs that share + // a text prefix (e.g. `git` vs `github`) don't false-match + // each other's actions. `GMAIL_SEND_EMAIL` matches `gmail_`, + // not just `gmail`, so siblings stay in their own buckets. + let action_prefix = format!("{}_", slug.to_uppercase()); + let tools: Vec = if connected { + tools_by_toolkit + .iter() + .filter(|t| t.function.name.starts_with(&action_prefix)) + .map(|t| ConnectedIntegrationTool { + name: t.function.name.clone(), + description: t.function.description.clone().unwrap_or_default(), + parameters: t.function.parameters.clone(), + }) + .collect() + } else { + Vec::new() + }; ConnectedIntegration { toolkit: slug.clone(), description: toolkit_description(slug).to_string(), tools, + connected, } }) .collect(); integrations.sort_by(|a, b| a.toolkit.cmp(&b.toolkit)); + let connected_count = integrations.iter().filter(|i| i.connected).count(); tracing::info!( - count = integrations.len(), + total = integrations.len(), + connected = connected_count, "[composio] fetch_connected_integrations: done" ); for ci in &integrations { tracing::debug!( toolkit = %ci.toolkit, + connected = ci.connected, tool_count = ci.tools.len(), - "[composio] connected integration" + "[composio] integration overview" ); } diff --git a/src/openhuman/context/debug_dump.rs b/src/openhuman/context/debug_dump.rs index 61e0fd7da..e6991f217 100644 --- a/src/openhuman/context/debug_dump.rs +++ b/src/openhuman/context/debug_dump.rs @@ -450,6 +450,7 @@ fn render_main_agent_dump( let ctx = PromptContext { workspace_dir, model_name, + agent_id: "orchestrator", tools: &prompt_tools, skills: &[], dispatcher_instructions: &dispatcher_instructions, @@ -573,13 +574,21 @@ fn render_subagent_dump( definition.omit_memory_md, ); + // Debug dump runs outside the agent lifecycle, so there's no + // dynamic per-action toolkit to inject and no live dispatcher to + // read the format from. Use empty extra_tools and the legacy + // PFormat default to preserve existing dump output for + // contributors who diff against committed snapshots. + let no_extra_tools: Vec> = Vec::new(); let raw = render_subagent_system_prompt( workspace_dir, &model, &allowed_indices, tools_vec, + &no_extra_tools, &archetype_body, options, + crate::openhuman::context::prompt::ToolCallFormat::PFormat, connected_integrations, ); let rendered = extract_cache_boundary(&raw); diff --git a/src/openhuman/context/prompt.rs b/src/openhuman/context/prompt.rs index df2ca0c82..ea98ff140 100644 --- a/src/openhuman/context/prompt.rs +++ b/src/openhuman/context/prompt.rs @@ -50,18 +50,32 @@ pub struct LearnedContextData { pub tree_root_summaries: Vec<(String, String)>, } -/// A connected external integration (e.g. a Composio OAuth connection) -/// surfaced in the system prompt so the orchestrator knows which services -/// are available and can delegate to the Skills Agent accordingly. +/// An external integration (e.g. a Composio OAuth-backed toolkit) +/// surfaced in the system prompt so the orchestrator knows which +/// services are available — both **already connected** and **available +/// to authorize**. Delegation guidance differs by status: +/// +/// - **Connected**: the orchestrator may `spawn_subagent(skills_agent, +/// toolkit=…)` directly. `tools` carries the per-action catalogue +/// used to dynamically register native tool-calling schemas inside +/// the spawned sub-agent. +/// - **Not connected**: the orchestrator must NOT delegate. Instead it +/// tells the user to authorize the toolkit in Settings → +/// Integrations. `tools` is empty in this case. #[derive(Debug, Clone)] pub struct ConnectedIntegration { /// Toolkit slug, e.g. `"gmail"`, `"notion"`. pub toolkit: String, /// Human-readable one-line description of what this integration can do. pub description: String, - /// Composio action slugs available for this toolkit, e.g. - /// `["GMAIL_SEND_EMAIL", "GMAIL_FETCH_EMAILS"]`. + /// Per-action catalogue (only populated when `connected == true`). pub tools: Vec, + /// Whether the user has an active OAuth connection for this + /// toolkit. When `false`, the toolkit is in the backend allowlist + /// but no authorization has been completed yet — `tools` is empty + /// and the orchestrator must point the user at Settings instead + /// of attempting to delegate. + pub connected: bool, } /// A single action available on a connected integration. @@ -71,6 +85,13 @@ pub struct ConnectedIntegrationTool { pub name: String, /// One-line description of the action. pub description: String, + /// JSON schema for the action's parameters, carried through from + /// the backend tool-list response. `None` when the backend didn't + /// supply a schema. Consumed by [`ComposioActionTool`] when the + /// subagent runner dynamically registers per-action tools for a + /// `skills_agent(toolkit=…)` spawn — the schema becomes the + /// native tool-calling signature the LLM sees. + pub parameters: Option, } /// A lightweight tool descriptor for prompt rendering. @@ -152,6 +173,13 @@ pub enum ToolCallFormat { pub struct PromptContext<'a> { pub workspace_dir: &'a Path, pub model_name: &'a str, + /// Id of the agent this prompt is being built for (e.g. `orchestrator`, + /// `skills_agent`, `welcome`). Drives per-agent rendering decisions — + /// notably [`ConnectedIntegrationsSection`], which dumps the full + /// Composio tool catalog only for `skills_agent` and leaves delegating + /// agents with a toolkit-only summary. Empty string means "unknown", + /// which is treated as a delegating agent. + pub agent_id: &'a str, pub tools: &'a [PromptTool<'a>], pub skills: &'a [Skill], pub dispatcher_instructions: &'a str, @@ -616,30 +644,78 @@ impl PromptSection for ConnectedIntegrationsSection { return Ok(String::new()); } + // Skill-executing agents (`skills_agent` and its specialisations) + // need the full per-action catalog so they can emit Composio calls + // directly. Every other agent (main/orchestrator/welcome/planner/…) + // is a delegator — it only needs a Delegation Guide: a toolkit + // list + the exact `spawn_subagent` invocation to use. Dumping + // the full tool catalog into a delegator's system prompt wastes + // thousands of tokens and teaches the model to call actions + // that aren't actually in its tool list. + let is_skill_executor = ctx.agent_id == "skills_agent"; + + if is_skill_executor { + // skills_agent only ever sees CONNECTED integrations — + // unconnected ones are filtered out by the sub-agent + // runner before we get here, but we double-filter for + // safety in case some other caller invokes the section + // directly. + let mut out = String::from( + "## Connected Integrations\n\n\ + You have direct access to the following external services. \ + The corresponding action tools are in your tool list with \ + their typed parameter schemas — call them by name.\n\n", + ); + for integration in ctx.connected_integrations.iter().filter(|ci| ci.connected) { + let _ = writeln!( + out, + "- **{}** — {}", + integration.toolkit, integration.description, + ); + } + out.push('\n'); + return Ok(out); + } + + // Delegating agent path — render a single unified Delegation + // Guide. Every integration in the backend allowlist is listed + // with the same spawn snippet, regardless of whether the user + // has authorized it yet. Auth state is the `spawn_subagent` + // pre-flight's responsibility: + // + // - Connected toolkit → spawn proceeds normally. + // - In allowlist but not connected → pre-flight returns a + // structured error telling the orchestrator to ask the + // user to authorize it in Settings → Integrations and NOT + // to retry. + // - Not in allowlist → pre-flight returns a different error + // listing the valid toolkits. + // + // Keeping the prompt small and uniform — one bullet per + // integration, no auth-state branching in prose — and + // trusting the runtime check as the single source of truth + // for which toolkits are actually callable. let mut out = String::from( - "## Connected Integrations\n\n\ - The user has the following external services connected. \ - To interact with any of these, delegate to the **Skills Agent** \ - (`skills_agent`) via `spawn_subagent`.\n\n", + "## Delegation Guide — Integrations\n\n\ + For any task that touches one of these external services, \ + delegate to `skills_agent` with the matching `toolkit` \ + argument. The sub-agent receives the full action catalogue \ + for that integration as native tool schemas — do not \ + attempt to call integration actions directly from this \ + agent.\n\n\ + If a spawn returns an authorization error, surface it to \ + the user and ask them to authorize the integration in \ + **Settings → Integrations** before retrying.\n\n", ); for integration in ctx.connected_integrations { let _ = writeln!( out, - "### {} — {}\n", - integration.toolkit, integration.description, + "- **{}** — {}\n Delegate with: `spawn_subagent(agent_id=\"skills_agent\", toolkit=\"{}\", prompt=)`", + integration.toolkit, integration.description, integration.toolkit, ); - if integration.tools.is_empty() { - let _ = writeln!( - out, - "Use `composio_list_tools` to discover available actions.\n", - ); - } else { - for tool in &integration.tools { - let _ = writeln!(out, "- `{}`: {}", tool.name, tool.description,); - } - out.push('\n'); - } } + out.push('\n'); + Ok(out) } } @@ -833,8 +909,10 @@ pub fn render_subagent_system_prompt( model_name: &str, allowed_indices: &[usize], parent_tools: &[Box], + extra_tools: &[Box], archetype_body: &str, options: SubagentRenderOptions, + tool_call_format: ToolCallFormat, connected_integrations: &[ConnectedIntegration], ) -> String { render_subagent_system_prompt_with_format( @@ -842,9 +920,10 @@ pub fn render_subagent_system_prompt( model_name, allowed_indices, parent_tools, + extra_tools, archetype_body, options, - ToolCallFormat::PFormat, + tool_call_format, connected_integrations, ) } @@ -858,6 +937,7 @@ pub fn render_subagent_system_prompt_with_format( model_name: &str, allowed_indices: &[usize], parent_tools: &[Box], + extra_tools: &[Box], archetype_body: &str, options: SubagentRenderOptions, tool_call_format: ToolCallFormat, @@ -917,19 +997,29 @@ pub fn render_subagent_system_prompt_with_format( // // Rendering uses the caller-specified `tool_call_format` so // sub-agents and the main dispatcher stay in lockstep. - out.push_str("## Tools\n\n"); - for &i in allowed_indices { - let Some(tool) = parent_tools.get(i) else { - tracing::warn!( - index = i, - tool_count = parent_tools.len(), - "[context::prompt] dropping out-of-range tool index in subagent render" - ); - continue; - }; - match tool_call_format { + // Tool catalogue rendering is dispatcher-format-aware: + // + // - **Native**: The provider receives full tool schemas through + // the request body's `tools` field (via `filtered_specs` in the + // sub-agent runner) and emits structured `tool_calls`. Listing + // the same tools again as prose in the system prompt is pure + // duplication — for a skills_agent spawn with 62 dynamic gmail + // tools, that duplication added ~54k tokens and blew past the + // model's context window. We skip the prose `## Tools` section + // entirely in this mode. + // + // - **PFormat / Json**: Both are prompt-driven formats — the + // model discovers tools by reading the prose `## Tools` section + // and emits text-wrapped tool calls (`name[a|b]` + // for PFormat, `{"name":...}` for Json). + // Neither uses the native `tools` request field, so we MUST + // list each tool in prose — including dynamically-registered + // `extra_tools` — or the model has no way to know they exist. + if !matches!(tool_call_format, ToolCallFormat::Native) { + out.push_str("## Tools\n\n"); + let render_one = |out: &mut String, tool: &dyn Tool| match tool_call_format { ToolCallFormat::PFormat => { - let sig = render_pformat_signature_for_box_tool(tool.as_ref()); + let sig = render_pformat_signature_for_box_tool(tool); let _ = writeln!( out, "- **{}**: {}\n Call as: `{}`", @@ -938,7 +1028,7 @@ pub fn render_subagent_system_prompt_with_format( sig ); } - ToolCallFormat::Json | ToolCallFormat::Native => { + ToolCallFormat::Json => { let _ = writeln!( out, "- **{}**: {}\n Parameters: `{}`", @@ -947,6 +1037,23 @@ pub fn render_subagent_system_prompt_with_format( tool.parameters_schema() ); } + ToolCallFormat::Native => { + // Unreachable — outer guard skips Native entirely. + } + }; + for &i in allowed_indices { + let Some(tool) = parent_tools.get(i) else { + tracing::warn!( + index = i, + tool_count = parent_tools.len(), + "[context::prompt] dropping out-of-range tool index in subagent render" + ); + continue; + }; + render_one(&mut out, tool.as_ref()); + } + for tool in extra_tools { + render_one(&mut out, tool.as_ref()); } } @@ -1015,57 +1122,46 @@ pub fn render_subagent_system_prompt_with_format( ); } - // 3d. Connected integrations — rendered so the agent knows which - // external services are available. Wording varies based on - // whether the agent can delegate (has spawn_subagent) or must - // call Composio tools directly. + // 3d. Connected integrations — short toolkit header only. The + // per-action catalogue is intentionally NOT rendered here: + // when the sub-agent runner spawns `skills_agent` with a + // `toolkit` argument it dynamically registers per-action + // tools whose JSON schemas travel through the provider's + // native tool-calling channel, making a prose enumeration + // redundant (and a token sink). For sub-agents that can + // delegate (`spawn_subagent` in their tool list), the + // wording instead points them at the Skills Agent. if !connected_integrations.is_empty() { let has_spawn = allowed_indices.iter().any(|&i| { parent_tools .get(i) .map_or(false, |t| t.name() == "spawn_subagent") }); - let has_composio_execute = allowed_indices.iter().any(|&i| { - parent_tools - .get(i) - .map_or(false, |t| t.name() == "composio_execute") - }); out.push_str("## Connected Integrations\n\n"); - if has_composio_execute { - out.push_str( - "The user has the following external services connected. \ - Use `composio_execute` with the appropriate action slug to interact \ - with them.\n\n", - ); - } else if has_spawn { + if has_spawn { out.push_str( "The user has the following external services connected. \ To interact with any of these, delegate to the **Skills Agent** \ - (`skills_agent`) via `spawn_subagent`.\n\n", + (`skills_agent`) via `spawn_subagent`, passing the matching \ + `toolkit` argument.\n\n", ); } else { - out.push_str("The user has the following external services connected.\n\n"); + out.push_str( + "You have direct access to the following external service. \ + Action tools are available in your tool list with their \ + typed parameter schemas — call them by name.\n\n", + ); } for integration in connected_integrations { let _ = writeln!( out, - "### {} — {}\n", + "- **{}** — {}", integration.toolkit, integration.description, ); - if integration.tools.is_empty() { - let _ = writeln!( - out, - "Use `composio_list_tools` to discover available actions.\n", - ); - } else { - for tool in &integration.tools { - let _ = writeln!(out, "- `{}`: {}", tool.name, tool.description); - } - out.push('\n'); - } } + out.push('\n'); } // 4. Insert the cache boundary before the dynamic tail. Typed @@ -1255,6 +1351,7 @@ mod tests { let ctx = PromptContext { workspace_dir: Path::new("/tmp"), model_name: "test-model", + agent_id: "", tools: &prompt_tools, skills: &[], dispatcher_instructions: "instr", @@ -1286,6 +1383,7 @@ mod tests { let ctx = PromptContext { workspace_dir: &workspace, model_name: "test-model", + agent_id: "", tools: &prompt_tools, skills: &[], dispatcher_instructions: "", @@ -1322,6 +1420,7 @@ mod tests { let ctx = PromptContext { workspace_dir: Path::new("/tmp"), model_name: "test-model", + agent_id: "", tools: &prompt_tools, skills: &[], dispatcher_instructions: "instr", @@ -1385,6 +1484,7 @@ mod tests { let ctx = PromptContext { workspace_dir: Path::new("/tmp"), model_name: "test-model", + agent_id: "", tools: &prompt_tools, skills: &[], dispatcher_instructions: "", @@ -1419,6 +1519,7 @@ mod tests { let ctx = PromptContext { workspace_dir: Path::new("/tmp"), model_name: "test-model", + agent_id: "", tools: &prompt_tools, skills: &[], dispatcher_instructions: "", @@ -1457,6 +1558,7 @@ mod tests { let ctx = PromptContext { workspace_dir: Path::new("/tmp"), model_name: "test-model", + agent_id: "", tools: &prompt_tools, skills: &[], dispatcher_instructions: "", @@ -1483,6 +1585,7 @@ mod tests { let ctx = PromptContext { workspace_dir: Path::new("/tmp"), model_name: "test-model", + agent_id: "", tools: &prompt_tools, skills: &[], dispatcher_instructions: "", @@ -1511,8 +1614,10 @@ mod tests { "test-model", &[0], &tools, + &[], "You are a focused sub-agent.", SubagentRenderOptions::narrow(), + ToolCallFormat::PFormat, &[], )); @@ -1575,6 +1680,7 @@ mod tests { "reasoning-v1", &[0], &tools, + &[], "You are a specialist.", SubagentRenderOptions { include_identity: true, @@ -1591,6 +1697,15 @@ mod tests { assert!(rendered.contains("### SOUL.md")); assert!(rendered.contains("## Safety")); assert!(rendered.contains("## Available Skills")); + // Json is a prompt-driven format (the model wraps JSON tool + // calls in `` tags); it does NOT use the provider's + // native function-calling channel. So the prose `## Tools` + // section MUST still be rendered for Json, with each tool's + // parameter schema inline so the model knows what to emit. + // Only `ToolCallFormat::Native` gets the section omitted (see + // the `native` branch below and the `!matches!(…, Native)` + // guard in the renderer). + assert!(rendered.contains("## Tools")); assert!(rendered.contains("Parameters:")); assert!(rendered.contains("\"type\"")); @@ -1599,6 +1714,7 @@ mod tests { "reasoning-v1", &[0], &tools, + &[], "You are a specialist.", SubagentRenderOptions::narrow(), ToolCallFormat::Native, @@ -1606,6 +1722,12 @@ mod tests { ); assert!(native.contains("native tool-calling output")); assert!(!native.contains("## Safety")); + // Native is the only format where the prose `## Tools` section + // is intentionally omitted — schemas travel through the + // provider's `tools` field instead. Regression guard against + // the ~54k-token schema duplication from the #447 PR. + assert!(!native.contains("\n## Tools\n")); + assert!(!native.contains("Parameters:")); let _ = std::fs::remove_dir_all(workspace); } @@ -1640,6 +1762,7 @@ mod tests { "test-model", &[0], &tools, + &[], "You are the welcome agent.", SubagentRenderOptions { include_identity: false, @@ -1648,6 +1771,7 @@ mod tests { include_profile: true, include_memory_md: false, }, + ToolCallFormat::PFormat, &[], ); @@ -1695,8 +1819,10 @@ mod tests { "test-model", &[0], &tools, + &[], "You are a narrow specialist.", SubagentRenderOptions::narrow(), // include_profile defaults to false + ToolCallFormat::PFormat, &[], ); @@ -1731,6 +1857,7 @@ mod tests { "test-model", &[0], &tools, + &[], "You are a specialist.", SubagentRenderOptions { include_identity: true, @@ -1739,6 +1866,7 @@ mod tests { include_profile: true, include_memory_md: false, }, + ToolCallFormat::PFormat, &[], ); @@ -1769,8 +1897,10 @@ mod tests { "test-model", &[0], &tools, + &[], "You are the welcome agent.", SubagentRenderOptions::narrow(), + ToolCallFormat::PFormat, &[], ); @@ -1820,8 +1950,10 @@ mod tests { "test-model", &[0], &tools, + &[], "# Welcome Agent\n\nYou are the welcome agent.", options, + ToolCallFormat::PFormat, &[], ); @@ -1864,8 +1996,10 @@ mod tests { "test-model", &[0], &tools, + &[], "You are a narrow specialist.", options, + ToolCallFormat::PFormat, &[], ); @@ -1902,6 +2036,7 @@ mod tests { "test-model", &[0], &tools, + &[], "You are the welcome agent.", SubagentRenderOptions { include_identity: false, @@ -1910,6 +2045,7 @@ mod tests { include_profile: false, include_memory_md: true, }, + ToolCallFormat::PFormat, &[], ); @@ -1946,8 +2082,10 @@ mod tests { "test-model", &[0], &tools, + &[], "You are a narrow specialist.", SubagentRenderOptions::narrow(), + ToolCallFormat::PFormat, &[], ); @@ -1984,6 +2122,7 @@ mod tests { "test-model", &[0], &tools, + &[], "You are the orchestrator.", SubagentRenderOptions { include_identity: false, @@ -1992,6 +2131,7 @@ mod tests { include_profile: true, include_memory_md: true, }, + ToolCallFormat::PFormat, &[], ); @@ -2041,8 +2181,10 @@ mod tests { "test-model", &[0], &tools, + &[], "You are the orchestrator.", opts, + ToolCallFormat::PFormat, &[], ); let second = render_subagent_system_prompt( @@ -2050,8 +2192,10 @@ mod tests { "test-model", &[0], &tools, + &[], "You are the orchestrator.", opts, + ToolCallFormat::PFormat, &[], ); @@ -2099,6 +2243,7 @@ mod tests { let ctx = PromptContext { workspace_dir: &workspace, model_name: "test-model", + agent_id: "", tools: &prompt_tools, skills: &[], dispatcher_instructions: "", @@ -2138,6 +2283,7 @@ mod tests { let ctx_narrow = PromptContext { workspace_dir: &workspace, model_name: "test-model", + agent_id: "", tools: &prompt_tools, skills: &[], dispatcher_instructions: "", @@ -2217,6 +2363,7 @@ mod tests { let ctx = PromptContext { workspace_dir: Path::new("/tmp"), model_name: "model", + agent_id: "", tools: &[], skills: &[], dispatcher_instructions: "", diff --git a/src/openhuman/learning/prompt_sections.rs b/src/openhuman/learning/prompt_sections.rs index e2e93dbdb..67a77588e 100644 --- a/src/openhuman/learning/prompt_sections.rs +++ b/src/openhuman/learning/prompt_sections.rs @@ -149,6 +149,7 @@ mod tests { PromptContext { workspace_dir: Path::new("/tmp"), model_name: "test-model", + agent_id: "", tools: &[], skills: &[], dispatcher_instructions: "", diff --git a/src/openhuman/tools/impl/agent/mod.rs b/src/openhuman/tools/impl/agent/mod.rs index 02a559071..26b619d05 100644 --- a/src/openhuman/tools/impl/agent/mod.rs +++ b/src/openhuman/tools/impl/agent/mod.rs @@ -58,16 +58,19 @@ pub(crate) async fn dispatch_subagent( prompt.chars().count() ); - // Propagate the per-call skill filter into the subagent runner so + // Propagate the per-call toolkit scope into the subagent runner so // that `SkillDelegationTool`s can narrow `skills_agent` to a single // Composio toolkit (e.g. `delegate_gmail` → skills_agent + - // skill_filter="gmail"). Previously this argument was hardcoded to - // `None`, which meant the toolkit pre-selection never reached the - // subagent and skills_agent always saw the full Composio catalog — - // the downstream half of the #526 leak. + // 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. let options = SubagentRunOptions { - skill_filter_override: skill_filter.map(str::to_string), + skill_filter_override: None, category_filter_override: None, + toolkit_override: skill_filter.map(str::to_string), context: None, task_id: Some(task_id.clone()), }; diff --git a/src/openhuman/tools/impl/agent/spawn_subagent.rs b/src/openhuman/tools/impl/agent/spawn_subagent.rs index d29ddafd6..8aa2266f6 100644 --- a/src/openhuman/tools/impl/agent/spawn_subagent.rs +++ b/src/openhuman/tools/impl/agent/spawn_subagent.rs @@ -54,9 +54,12 @@ impl Tool for SpawnSubagentTool { } fn description(&self) -> &str { - "Delegate a task to a specialised sub-agent. \ - See the Delegation Guide in the system prompt for \ - available agent_ids and when to use each." + "Delegate a task to a specialised sub-agent. See the Delegation \ + Guide in the system prompt for available agent_ids and when to \ + use each. When delegating to `skills_agent`, you MUST also pass \ + `toolkit=\"\"` naming the Composio integration the \ + sub-task targets (e.g. `gmail`, `notion`); the sub-agent will \ + only see that toolkit's actions." } fn parameters_schema(&self) -> serde_json::Value { @@ -103,6 +106,10 @@ impl Tool for SpawnSubagentTool { "enum": ["system", "skill"], "description": "Optional tool-category restriction. `skill` scopes the sub-agent to integration tools (for example Composio-backed SaaS actions); `system` scopes it to built-in Rust tools. Overrides the definition's `category_filter` for this single spawn." }, + "toolkit": { + "type": "string", + "description": "Composio toolkit slug to scope this spawn to — e.g. `gmail`, `notion`, `slack`. REQUIRED when `agent_id = \"skills_agent\"`. Narrows the sub-agent's visible Composio actions AND its Connected Integrations prompt section to only that toolkit's catalogue, so the sub-agent's context window only carries the platform it was asked to operate on. Must match a currently-connected integration (see the Delegation Guide)." + }, "mode": { "type": "string", "enum": ["typed", "fork"], @@ -149,6 +156,12 @@ impl Tool for SpawnSubagentTool { None => None, }; + let toolkit_override = args + .get("toolkit") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("typed"); // ── Validation ───────────────────────────────────────────────── @@ -190,6 +203,83 @@ impl Tool for SpawnSubagentTool { } }; + // ── skills_agent toolkit gate ────────────────────────────────── + // skills_agent is a platform-parameterised specialist. Every + // spawn MUST name a CONNECTED toolkit so the sub-agent only + // sees one integration's tool catalogue instead of all of + // them. We split validation into three cases so the model + // gets a precise, actionable error on every failure mode — + // nothing reaches the LLM loop unless the spawn is valid. + if definition.id == "skills_agent" { + let parent_ctx = current_parent(); + let allowlist: Vec<&crate::openhuman::context::prompt::ConnectedIntegration> = + parent_ctx + .as_ref() + .map(|p| p.connected_integrations.iter().collect()) + .unwrap_or_default(); + let connected_slugs: Vec = allowlist + .iter() + .filter(|ci| ci.connected) + .map(|ci| ci.toolkit.clone()) + .collect(); + + match toolkit_override.as_deref() { + None => { + return Ok(ToolResult::error(format!( + "spawn_subagent(skills_agent): the `toolkit` argument is required. \ + Pass one of the currently-connected toolkits: [{}]. \ + See the Delegation Guide in your system prompt for which toolkit \ + matches each task.", + connected_slugs.join(", ") + ))); + } + Some(tk) => { + let entry = allowlist + .iter() + .find(|ci| ci.toolkit.eq_ignore_ascii_case(tk)); + match entry { + None => { + // Toolkit isn't even in the backend allowlist. + return Ok(ToolResult::error(format!( + "spawn_subagent(skills_agent): toolkit '{tk}' is not in \ + the backend allowlist. Valid toolkits: [{}]. Check the \ + Delegation Guide in your system prompt for the exact slug.", + allowlist + .iter() + .map(|ci| ci.toolkit.as_str()) + .collect::>() + .join(", ") + ))); + } + Some(ci) if !ci.connected => { + // Toolkit exists in the allowlist but isn't connected. + // This is NOT a tool error — it's an expected condition + // the orchestrator should communicate to the user. We + // return `ToolResult::success` so: + // 1. The agent loop doesn't prepend "Error: " to + // the result text (which would bias the model + // toward defensive failure language). + // 2. The web channel emits `success: true` on the + // `tool_result` socket event, so the frontend + // doesn't render this as a failed tool call. + // The model still reads the explanation and produces + // an appropriate user-facing response. + return Ok(ToolResult::success(format!( + "Integration '{tk}' is available but the user has not \ + authorized it yet. Do NOT retry this spawn. Tell the user \ + the integration is available and ask them to authorize \ + '{tk}' in Settings → Integrations before retrying the \ + original request." + ))); + } + Some(_) => { + // Connected — fall through to spawn. + } + } + } + } + } + // ── Publish SubagentSpawned event ────────────────────────────── let parent_session = current_parent() .map(|p| p.session_id.clone()) @@ -208,6 +298,7 @@ impl Tool for SpawnSubagentTool { let options = SubagentRunOptions { skill_filter_override: None, category_filter_override, + toolkit_override, context, task_id: Some(task_id.clone()), }; diff --git a/src/openhuman/tools/orchestrator_tools.rs b/src/openhuman/tools/orchestrator_tools.rs index 0bb721516..7f0e7afff 100644 --- a/src/openhuman/tools/orchestrator_tools.rs +++ b/src/openhuman/tools/orchestrator_tools.rs @@ -235,6 +235,7 @@ mod tests { toolkit: toolkit.into(), description: description.into(), tools: vec![], + connected: true, } } diff --git a/tests/agent_harness_public.rs b/tests/agent_harness_public.rs index 4f0cb6d68..f08828682 100644 --- a/tests/agent_harness_public.rs +++ b/tests/agent_harness_public.rs @@ -127,6 +127,8 @@ fn stub_parent_context() -> ParentExecutionContext { session_id: "test-session".into(), channel: "test-channel".into(), connected_integrations: vec![], + composio_client: None, + tool_call_format: openhuman_core::openhuman::context::prompt::ToolCallFormat::PFormat, } }