mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
* 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:
co-authored by
Claude Opus 4.6
parent
1836c7691f
commit
ea088d9f15
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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: "",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user