feat(prompt): unified delegation guide + dynamic per-toolkit tool registration (#447) (#570)

* feat(prompt): unified delegation guide + dynamic per-toolkit tool registration (#447)

Cuts main agent prompt token cost ~98% on accounts with connected
integrations and replaces the generic Composio dispatcher with native
per-action tool calling inside skills_agent.

## Why

Issue #447: prompt payloads were ballooning because main/orchestrator
re-shipped a full prose enumeration of every connected toolkit's
actions on every turn (~13.7k tokens for one gmail integration alone),
and skills_agent had no way to scope tools to a single toolkit so it
inherited a flat dispatcher (composio_execute) that the LLM couldn't
reliably call without first round-tripping composio_list_tools.

## What

### main / orchestrator prompt
- Replace `ConnectedIntegrationsSection`'s per-action prose dump with
  a unified Delegation Guide: one bullet per integration (toolkit +
  one-line description + spawn snippet). Tokens added per
  integration: ~30. Savings vs old per-action listing: ~5k+ tokens
  per connected toolkit.
- Drop every "Composio" reference from delegating-agent prose so the
  surface stays provider-neutral.

### skills_agent
- Add a `toolkit` argument to `spawn_subagent` with three pre-flight
  validation modes resolved against the parent's connected
  integrations list before any LLM call:
    * missing toolkit         -> ToolResult::error (model retries)
    * unknown toolkit         -> ToolResult::error (model retries)
    * in allowlist, not connected -> ToolResult::success (model
      surfaces "authorize in Settings -> Integrations" to user; not
      flagged as a tool failure in the agent loop or web channel)
- New `ComposioActionTool` (`src/openhuman/composio/action_tool.rs`)
  implements the `Tool` trait per Composio action. The sub-agent
  runner constructs ~N of these at spawn time from the cached
  integration overview and injects them via a new `extra_tools`
  parameter through `run_typed_mode` -> `run_inner_loop`.
- `filter_tool_indices` drops every skill-category parent tool when
  `is_skills_agent_with_toolkit` is true so the only skill-category
  entries the sub-agent sees are the freshly-built per-action tools.
  This eliminates apify_*, composio_list_*, composio_authorize, and
  composio_execute from the toolkit-scoped surface.
- Sub-agent renderer takes the parent's actual `tool_call_format`
  instead of hardcoding PFormat. Native dispatchers no longer carry a
  prose `## Tools` section at all (schemas already travel through the
  request body's `tools` field) — eliminates a ~30k-token duplication
  that was blowing past the model's context window.

### integration overview fetch
- `fetch_connected_integrations_uncached` now merges Composio's
  toolkit allowlist with the user's active connections and returns
  one `ConnectedIntegration` per allowlisted toolkit with a
  `connected: bool` flag. Unconnected entries carry no schemas, just
  the toolkit name + description, so the orchestrator can mention
  them without trying to invoke them.
- `ConnectedIntegrationTool` preserves the action's full JSON
  parameter schema so `ComposioActionTool` can advertise it through
  native function-calling.

### plumbing
- `ParentExecutionContext` carries a `ComposioClient` and the
  parent's resolved `tool_call_format`, populated alongside
  `connected_integrations` in `Session::fetch_connected_integrations`.
  Triage and test paths pass `None` / `PFormat` defaults.
- `dispatch_subagent` (the `SkillDelegationTool` path) plumbs its
  pre-bound skill_id through `toolkit_override` instead of the
  broken `skill_filter_override` (which used `{skill}__` prefix
  matching that never matched Composio's `TOOLKIT_*` naming).
- `web::run_chat_task` failures now log the underlying error at WARN
  so debugging an in-flight failure no longer requires turning on
  TRACE for socket events.
- `scripts/stage-core-sidecar.mjs` queries `cargo metadata` for the
  real target directory instead of assuming `<repo>/target` so the
  staging step works under workspace-level `target-dir` overrides
  (the vezures-workspace shared `.cargo-target` setup).

## Verified end-to-end

Two RPC tests against a freshly-rebuilt sidecar (gmail connected,
notion / slack / etc. allowlisted but not connected):

| Test | Behavior | Tokens | Iterations |
|---|---|---|---|
| Connected (gmail, "fetch 5 unread emails") | spawn_subagent -> 62 dynamic gmail tools registered -> 1 GMAIL_FETCH_EMAILS call with smart args -> markdown table response | 42,911 input | 1 |
| Not-connected (notion, "create a page") | main answers directly without spawning, tells user to authorize in Settings -> Integrations | 3,319 input | 1 |

Both flows complete cleanly. The connected path proves dynamic
per-action tool registration is working with full schema validation;
the unconnected path proves the unified delegation guide + ToolResult::success
return for not-connected toolkits keeps the model on a graceful path
without polluting the chat with error styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(prompt): update test callers + assertions for new renderer signature

Follow-up to #447. The main patch changed three signatures that test
callers hadn't been updated for, and flipped one assertion that was
validating the now-removed prose schema duplication.

- `SubagentRunOptions` test constructors in subagent_runner.rs (2
  sites) now pass `toolkit_override: None`.
- `ConnectedIntegration` test constructor in orchestrator_tools.rs
  now passes `connected: true` (the default for test integrations —
  they're treated as authorized so delegation logic still runs).
- 12 `render_subagent_system_prompt` test callers in prompt.rs now
  pass `&[]` for the new `extra_tools` slice and
  `ToolCallFormat::PFormat` for the new `tool_call_format` argument.
- `render_subagent_system_prompt_honors_identity_safety_and_skills_flags`
  used to assert `rendered.contains("Parameters:")` on the Json
  dispatcher branch — that was valid in the old world where the
  prose `## Tools` section dumped full JSON schemas for Json/Native
  formats. The main patch deliberately removes that dump (it was the
  ~30k-token duplication of the native `tools` field), so the test
  now asserts the opposite: no `## Tools` header and no `Parameters:`
  line are emitted for Native/Json dispatchers. The schemas still
  travel through the provider request's `tools` field.

Also picks up `rustfmt` rewraps in action_tool.rs and ops.rs from a
background linter run — pure whitespace, no semantic change.

Verified green against the full `cargo test --lib` suite for every
test touched by this PR:
- openhuman::context::prompt::tests                         (26 passed)
- openhuman::agent::harness::subagent_runner::tests         (19 passed)
- openhuman::composio::ops::tests                           (2 passed)
- openhuman::tools::impl::agent::tests                      (0 scoped)

The 7 remaining failures in `cargo test --lib` are pre-existing
Windows-path/filesystem flakes in subsystems this PR doesn't touch
(self_healing polyfill path separator, cron scheduler shell spawning,
local_ai::paths absolute-path detection, security::policy sandbox
path handling, composio::trigger_history jsonl archive, and a real
pre-existing `Option::unwrap()` panic in browser::screenshot).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(harness): add composio_client + tool_call_format to integration test stub

Follow-up to #447. The `tests/agent_harness_public.rs` integration
test constructs a `ParentExecutionContext` in `stub_parent_context`
that was missing the two fields added in the main patch
(`composio_client` and `tool_call_format`). `cargo test --lib`
doesn't compile integration tests, which is why this slipped past
local verification — it only surfaced on Linux CI.

- `composio_client: None` — the stub parent has no composio client
  because these tests don't exercise the integration-overview path.
- `tool_call_format: ToolCallFormat::PFormat` — default legacy
  format; none of the tests in this file exercise the sub-agent
  renderer's format branching, so PFormat is the safe pick.

Verified locally that `cargo test --no-run` compiles all integration
test targets including `agent_harness_public`. (The
`agent_memory_loader_public` link error in the local run is a
pre-existing Windows `libucrt`/`fgets` C-runtime issue unrelated to
this PR — won't reproduce on Linux CI.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(prompt,composio): address CodeRabbit review on #570 (#447)

Four fixes from the CodeRabbit review of PR #570, ranked by impact:

## `context/prompt.rs` — Json dispatcher needs prose tool catalog

The initial patch gated the prose `## Tools` section behind
`matches!(tool_call_format, ToolCallFormat::PFormat)`, which
conflated Json with Native. Only `ToolCallFormat::Native` uses the
provider's native function-calling channel where schemas travel via
the request body's `tools` field. `ToolCallFormat::Json` is a
prompt-driven format (the model wraps JSON tool calls in
`<tool_call>` tags) and relies on the prose catalog the same way
PFormat does — without it the model sees protocol instructions but
no visible tool names or schemas.

Inverted the guard to `!matches!(tool_call_format, Native)` so
PFormat and Json both render the `## Tools` section with their
respective per-entry shapes (compact `Call as:` signature for
PFormat, inline `Parameters:` JSON schema for Json). Updated the
`render_subagent_system_prompt_honors_identity_safety_and_skills_flags`
test: the Json assertion now expects `Parameters:` to be present
again (reverting commit 2's flip), and a new Native-branch assertion
guards against the ~54k-token schema duplication the original PR
fixed.

## `composio/ops.rs` — don't cache degraded snapshots

All three backend calls in `fetch_connected_integrations_uncached`
(`list_toolkits`, `list_connections`, `list_tools`) previously
returned `Some(Vec::new())` or fell through to an empty inner vec
on transient errors. The outer `fetch_connected_integrations`
caches whatever the uncached path returns, so a single transient
5xx would silently hide every integration, mark connected toolkits
as disconnected, or register dynamic Composio tools with zero
callable actions — until the cache is invalidated or the process
restarts. Changed all three branches to return `None`, which
signals the caller to NOT cache the result and retry on the next
call.

## `composio/ops.rs` — prefix match needs a delimiter

`starts_with(&slug.to_uppercase())` false-matches when two toolkit
slugs share a text prefix (e.g. `git` vs `github`). The current
allowlist doesn't trigger this, but adding a new toolkit could
silently leak actions between integrations. Anchored the prefix
with an underscore so `GMAIL_SEND_EMAIL` matches `gmail_`, not
just `gmail`.

## `scripts/stage-core-sidecar.mjs` — resolve CARGO_TARGET_DIR vs repo root

`resolve(process.env.CARGO_TARGET_DIR)` uses the process's current
working directory, but the `cargo build` spawn below runs with
`cwd: root`. For an absolute env var this is identical, but a
relative `CARGO_TARGET_DIR` and a cwd outside the repo would make
the two paths disagree and the binary lookup would miss. Defensive
fix: `resolve(root, process.env.CARGO_TARGET_DIR)` so both paths
anchor to the same base.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-04-14 13:07:26 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 1836c7691f
commit ea088d9f15
18 changed files with 753 additions and 122 deletions
+27 -3
View File
@@ -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}`);
@@ -85,6 +85,24 @@ pub struct ParentExecutionContext {
/// Active Composio integrations the parent has fetched.
pub connected_integrations: Vec<crate::openhuman::context::prompt::ConnectedIntegration>,
/// 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<crate::openhuman::composio::ComposioClient>,
/// 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! {
@@ -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.
@@ -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,
@@ -91,6 +91,14 @@ pub struct Agent {
/// [`ConnectedIntegrationsSection`] so the orchestrator knows which
/// external services are available.
pub(super) connected_integrations: Vec<crate::openhuman::context::prompt::ConnectedIntegration>,
/// 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<crate::openhuman::composio::ComposioClient>,
/// 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)
+125 -5
View File
@@ -61,6 +61,15 @@ pub struct SubagentRunOptions {
/// skill or system tools for this specific call.
pub category_filter_override: Option<ToolCategory>,
/// 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<String>,
/// Optional context blob the parent wants to inject before the
/// task prompt. Rendered as a `[Context]\n…\n` prefix.
pub context: Option<String>,
@@ -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<ToolSpec> = 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<Box<dyn Tool>> = 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<ToolSpec> = allowed_indices
.iter()
.map(|&i| parent.all_tool_specs[i].clone())
.collect();
let allowed_names: HashSet<String> = allowed_indices
let mut allowed_names: HashSet<String> = 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<crate::openhuman::context::prompt::ConnectedIntegration> =
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<Box<dyn Tool>> = 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<ChatMessage>,
parent_tools: &[Box<dyn Tool>],
extra_tools: &[Box<dyn Tool>],
tool_specs: &[ToolSpec],
allowed_names: &HashSet<String>,
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,
},
+10
View File
@@ -157,6 +157,16 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result<S
session_id: format!("triage-{}", uuid::Uuid::new_v4()),
channel: "triage".to_string(),
connected_integrations: agent.connected_integrations().to_vec(),
// Triage doesn't spawn `skills_agent(toolkit=…)`, so the
// dynamic per-action tool path is unused here. If a future
// triage flow needs composio access, add a public
// `composio_client()` accessor on `Agent` and wire it in.
composio_client: None,
// Triage runs sub-agents with the parent's existing dispatcher
// — fall back to PFormat if no accessor is available. Triage
// doesn't currently spawn anything that depends on the new
// dispatcher-aware sub-agent renderer.
tool_call_format: crate::openhuman::context::prompt::ToolCallFormat::PFormat,
};
tracing::debug!(
+7
View File
@@ -198,6 +198,13 @@ pub async fn start_chat(
.await;
}
Err(err) => {
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(),
+121
View File
@@ -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<IntegrationClient>` 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<Value>,
) -> 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<ToolResult> {
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)))
}
}
}
}
+2
View File
@@ -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};
+98 -39
View File
@@ -490,10 +490,15 @@ pub async fn fetch_connected_integrations(config: &Config) -> Vec<ConnectedInteg
/// The actual backend fetch, called on cache miss.
///
/// Returns `Some(vec)` when the backend was reachable (even if the user
/// has zero connections — that's a valid cacheable state). Returns `None`
/// when we couldn't even build a client (no auth), signalling the caller
/// should NOT cache this result.
/// Returns `Some(vec)` when the backend was reachable. The returned
/// vector is the merged **integration overview** — every toolkit in
/// the backend allowlist appears as one entry, with a `connected`
/// flag indicating whether the user has an active OAuth connection.
/// Connected entries also carry the per-action tool catalogue
/// (fetched in a single batched call).
///
/// Returns `None` when we couldn't even build a client (no auth),
/// signalling the caller should NOT cache this result.
async fn fetch_connected_integrations_uncached(
config: &Config,
) -> Option<Vec<ConnectedIntegration>> {
@@ -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<String> = 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<String> = 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<String> = {
let mut slugs: Vec<String> = 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<String> = {
let mut v: Vec<String> = 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<ConnectedIntegration> = toolkit_slugs
// Deduplicate the allowlist so a backend that returns duplicates
// doesn't produce dual entries downstream.
let mut unique_toolkits: Vec<String> = 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<ConnectedIntegration> = unique_toolkits
.iter()
.map(|slug| {
let tools: Vec<ConnectedIntegrationTool> = 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<ConnectedIntegrationTool> = 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"
);
}
+9
View File
@@ -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<Box<dyn Tool>> = 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);
+213 -66
View File
@@ -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<ConnectedIntegrationTool>,
/// 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<serde_json::Value>,
}
/// 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=<task>)`",
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<dyn Tool>],
extra_tools: &[Box<dyn Tool>],
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<dyn Tool>],
extra_tools: &[Box<dyn Tool>],
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 (`<tool_call>name[a|b]</tool_call>`
// for PFormat, `<tool_call>{"name":...}</tool_call>` 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 `<tool_call>` 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: "",
@@ -149,6 +149,7 @@ mod tests {
PromptContext {
workspace_dir: Path::new("/tmp"),
model_name: "test-model",
agent_id: "",
tools: &[],
skills: &[],
dispatcher_instructions: "",
+9 -6
View File
@@ -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()),
};
@@ -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=\"<name>\"` 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<String> = 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::<Vec<_>>()
.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()),
};
@@ -235,6 +235,7 @@ mod tests {
toolkit: toolkit.into(),
description: description.into(),
tools: vec![],
connected: true,
}
}
+2
View File
@@ -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,
}
}