Skip to content
Open
45 changes: 45 additions & 0 deletions app/src/utils/__tests__/toolTimelineFormatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,51 @@ describe('formatTimelineEntry', () => {
});
});

it('formats delegate_to_integrations_agent with a known toolkit arg', () => {
expect(
formatTimelineEntry(
entry({
name: 'delegate_to_integrations_agent',
argsBuffer: JSON.stringify({
toolkit: 'gmail',
prompt: 'Find the latest invoice from Stripe.',
}),
})
)
).toEqual({
title: 'Making requests to your Gmail account',
detail: 'Find the latest invoice from Stripe.',
});
});

it('formats delegate_to_integrations_agent with an unknown toolkit arg', () => {
expect(
formatTimelineEntry(
entry({
name: 'delegate_to_integrations_agent',
argsBuffer: JSON.stringify({ toolkit: 'slack_bot', prompt: 'post update' }),
})
)
).toEqual({
title: 'Checking your Slack Bot',
detail: 'post update',
});
});

it('formats delegate_to_integrations_agent without a toolkit arg as a generic connected-app label', () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(
formatTimelineEntry(
entry({
name: 'delegate_to_integrations_agent',
argsBuffer: JSON.stringify({ prompt: 'do something useful' }),
})
)
).toEqual({
title: 'Checking your connected app',
detail: 'do something useful',
});
});

it('formats delegate_tools_agent with toolkit context from args', () => {
expect(
formatTimelineEntry(
Expand Down
24 changes: 23 additions & 1 deletion app/src/utils/toolTimelineFormatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,30 @@ export function formatTimelineEntry(entry: ToolTimelineEntry): { title: string;
inferIntegrationName(parsedArgs?.toolkit) ??
inferIntegrationNameFromPrompt(parsedArgs?.prompt) ??
inferIntegrationName(entry.name);

// The collapsed `delegate_to_integrations_agent` tool has no toolkit
// baked into its name; if we couldn't infer a provider from args or
// prompt, surface a generic connected-app label (matches the
// `spawn_subagent → integrations_agent` fallback above) instead of
// humanising the tool name into "To Integrations Agent". Unknown
// toolkit slugs from args fall back to a humanised toolkit label so
// composio integrations outside KNOWN_TOOLKIT_RE still render
// meaningfully (e.g. `slack_bot` → "Slack Bot") rather than the
// generic copy.
let title: string;
if (provider) {
title = integrationActivityTitle(provider);
} else if (entry.name === 'delegate_to_integrations_agent') {
const rawToolkit = parsedArgs?.toolkit?.trim();
title = rawToolkit
? integrationActivityTitle(humanizeIdentifier(rawToolkit))
: 'Checking your connected app';
} else {
title = humanizeIdentifier(entry.name);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
return {
title: provider ? integrationActivityTitle(provider) : humanizeIdentifier(entry.name),
title,
detail: entry.detail ?? parsedArgs?.prompt,
};
}
Expand Down
8 changes: 3 additions & 5 deletions src/openhuman/agent/agents/orchestrator/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,15 @@ Follow this sequence for every user message:
- Yes: use direct tools first (`current_time`, `cron_*`, `memory_*`, `composio_list_connections`, etc.).
- No: continue.
3. **Does this need specialised execution?**
- If external SaaS integration work is required, use `delegate_{toolkit}` (e.g. `delegate_gmail`, `delegate_notion`).
- If external SaaS integration work is required, use `delegate_to_integrations_agent` with `toolkit` set to the relevant slug (see the **Connected Integrations** section for the current list).
- If code writing/execution/debugging is required, use `delegate_run_code`.
- If web/doc crawling is required, use `delegate_researcher`.
- If complex multi-step decomposition is required, use `delegate_plan`.
- If code review is requested, use `delegate_critic`.
- If memory archiving or distillation is required, use `delegate_archivist`.
4. **After delegation**, summarise results clearly and concisely.

Default bias: **do not spawn a sub-agent when a direct response or direct tool call is sufficient**.

When delegating: use `delegate_researcher` for web/doc lookups, `delegate_run_code` for coding, `delegate_plan` for complex decomposition, `delegate_critic` for reviews, `delegate_archivist` for memory writes, `delegate_{toolkit}` for external integrations. Use `spawn_worker_thread` for long tasks that need their own thread.
Default bias: **do not spawn a sub-agent when a direct response or direct tool call is sufficient**. Use `spawn_worker_thread` for long tasks that need their own thread.

## Rules

Expand Down Expand Up @@ -59,7 +57,7 @@ multi-file refactors, or batch integration work. It creates a persisted **worker
thread the user can open from the thread list, and returns a compact `[worker_thread_ref]`
(thread id + brief summary) to the parent instead of the full transcript.

For routine delegation use the matching `delegate_*` tool and surface the result inline.
For routine delegation use the matching specialist `delegate_*` tool (or `delegate_to_integrations_agent` for external services) and surface the result inline.

Worker threads are one level deep by design: a sub-agent spawned via `spawn_worker_thread`
cannot itself call `spawn_worker_thread`, so workers never nest.
Expand Down
30 changes: 18 additions & 12 deletions src/openhuman/agent/agents/orchestrator/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
//! The orchestrator follows a direct-first policy: respond directly or use
//! cheap direct tools whenever possible, and delegate only for specialised
//! execution. It never executes Composio actions itself; the integration
//! block points to `delegate_{toolkit}` tools (synthesised by
//! `orchestrator_tools::collect_orchestrator_tools`) for true
//! external-service operations. That prose lives here (not in the shared
//! block points to the single collapsed `delegate_to_integrations_agent`
//! tool (synthesised by `orchestrator_tools::collect_orchestrator_tools`,
//! #1335) for true external-service operations, with the toolkit slug
//! passed as an argument. That prose lives here (not in the shared
//! prompts module) so the skill-executor voice stays in
//! `integrations_agent/prompt.rs` and nobody has to branch on `agent_id`
//! in a shared section impl.
Expand Down Expand Up @@ -90,15 +91,16 @@ fn render_delegation_guide(integrations: &[ConnectedIntegration]) -> String {
}
let mut out = String::from(
"## Connected Integrations\n\n\
Delegate tasks for these services using the matching `delegate_{toolkit}` tool:\n\n",
Delegate tasks for these services with `delegate_to_integrations_agent`, passing the toolkit slug as `toolkit`:\n\n",
);
for ci in connected {
// Use the same slug canonicalisation as `collect_orchestrator_tools`
// so the tool name in the prompt always matches the synthesised tool.
// so the `toolkit` arg the orchestrator emits always matches the
// enum the synthesised tool accepts.
let slug = sanitise_slug(&ci.toolkit);
let _ = writeln!(
out,
"- **{}** (delegate via `delegate_{}`): {}",
"- **{}** (`toolkit: \"{}\"`): {}",
ci.toolkit, slug, ci.description
);
}
Expand Down Expand Up @@ -161,7 +163,7 @@ mod tests {
}

#[test]
fn build_emits_delegation_guide_with_spawn_snippet() {
fn build_emits_delegation_guide_with_collapsed_tool() {
let integrations = vec![ConnectedIntegration {
toolkit: "gmail".into(),
description: "Email access.".into(),
Expand All @@ -170,15 +172,18 @@ mod tests {
}];
let body = build(&ctx_with(&integrations)).unwrap();
assert!(body.contains("## Connected Integrations"));
assert!(body.contains("delegate_gmail"));
assert!(body.contains("delegate_to_integrations_agent"));
assert!(body.contains("toolkit: \"gmail\""));
// Must NOT contain the old per-toolkit fan-out tool names.
assert!(!body.contains("delegate_gmail"));
// Must NOT contain the old verbose spawn_subagent snippet.
assert!(!body.contains("spawn_subagent(agent_id=\"integrations_agent\""));
// Delegator voice must NOT use the skill-executor wording.
assert!(!body.contains("You have direct access"));
}

#[test]
fn delegation_guide_uses_compact_delegate_format() {
fn delegation_guide_uses_compact_collapsed_format() {
let integrations = vec![ConnectedIntegration {
toolkit: "gmail".into(),
description: "Email access.".into(),
Expand All @@ -187,15 +192,16 @@ mod tests {
}];
let body = build(&ctx_with(&integrations)).unwrap();
assert!(body.contains("## Connected Integrations"));
assert!(body.contains("delegate_gmail"));
// Must NOT contain the old verbose spawn_subagent snippet.
assert!(body.contains("delegate_to_integrations_agent"));
// Old verbose / per-toolkit forms must be gone.
assert!(!body.contains("delegate_gmail"));
assert!(!body.contains("spawn_subagent(agent_id=\"integrations_agent\""));
}

#[test]
fn build_hides_unconnected_integrations() {
// Only connected toolkits make it into the Delegation Guide
// — unconnected entries would just trigger a spawn_subagent
// — unconnected entries would just trigger a downstream
// pre-flight rejection, so keeping them out keeps the prompt
// focused on what the orchestrator can actually delegate.
let integrations = vec![
Expand Down
17 changes: 10 additions & 7 deletions src/openhuman/agent/harness/definition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,11 @@ pub struct AgentDefinition {
/// agent's `delegate_name` override) and whose description is the
/// target agent's [`AgentDefinition::when_to_use`].
///
/// * [`SubagentEntry::Skills`] — one [`SkillDelegationTool`] per
/// connected Composio toolkit, each named `delegate_{toolkit}`,
/// all routing to the generic `integrations_agent` with an appropriate
/// `skill_filter` pre-populated.
/// * [`SubagentEntry::Skills`] — a single collapsed
/// [`SkillDelegationTool`] named `delegate_to_integrations_agent`
/// that takes the toolkit slug as an argument and routes to the
/// generic `integrations_agent` with the corresponding
/// `skill_filter` pre-populated (#1335).
///
/// `subagents` is intentionally separate from [`AgentDefinition::tools`]
/// so that reading a TOML makes the distinction obvious: `tools` is
Expand Down Expand Up @@ -207,9 +208,11 @@ pub struct AgentDefinition {
pub enum SubagentEntry {
/// Delegate to a specific built-in or custom agent by id.
AgentId(String),
/// Expand at build time to one `delegate_{toolkit}` tool per
/// connected Composio toolkit, each routing to the generic
/// `integrations_agent` with `skill_filter` pre-set.
/// Expand at build time to a single collapsed
/// `delegate_to_integrations_agent` tool whose `toolkit` argument
/// selects which connected Composio toolkit to route to, with
/// `skill_filter` pre-set on the underlying `integrations_agent`
/// dispatch (#1335).
Skills(SkillsWildcard),
}

Expand Down
6 changes: 4 additions & 2 deletions src/openhuman/agent/harness/session/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -848,8 +848,10 @@ impl Agent {
//
// For an agent with `subagents = [...]` in its TOML (today:
// orchestrator), `collect_orchestrator_tools` synthesises one
// `ArchetypeDelegationTool` per named sub-agent plus one
// `SkillDelegationTool` per connected Composio toolkit.
// `ArchetypeDelegationTool` per named sub-agent plus a single
// collapsed `SkillDelegationTool`
// (`delegate_to_integrations_agent`) whose `toolkit` argument
// selects among the connected Composio toolkits (#1335).
//
// For an agent without `subagents` (today: welcome, critic,
// archivist, etc.), no delegation tools are synthesised — the
Expand Down
9 changes: 5 additions & 4 deletions src/openhuman/agent/harness/tool_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,11 @@ pub(crate) async fn agent_turn(
///
/// * `extra_tools` — per-turn synthesised tools to splice alongside the
/// persistent `tools_registry`. The agent-dispatch path uses this to
/// surface delegation tools (`research`, `delegate_gmail`, …) that
/// are synthesised fresh per turn from the active agent's
/// `subagents` field and the current Composio integration list, and
/// therefore are not registered in the global startup-time registry.
/// surface delegation tools (`research`, `plan`,
/// `delegate_to_integrations_agent`, …) that are synthesised fresh
/// per turn from the active agent's `subagents` field and the
/// current Composio integration list, and therefore are not
/// registered in the global startup-time registry.
///
/// The combined tool list seen by the LLM this turn is
/// `tools_registry.iter().chain(extra_tools.iter())`, further narrowed
Expand Down
11 changes: 7 additions & 4 deletions src/openhuman/channels/runtime/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,8 @@ async fn resolve_target_agent(channel: &str) -> AgentScoping {
/// * every tool name in the agent's `[tools] named = [...]` list
/// (when the scope is [`ToolScope::Named`]); and
/// * every name produced by the per-turn synthesised delegation tools
/// in `extra_tools` (e.g. `research`, `delegate_gmail`).
/// in `extra_tools` (e.g. `research`, `plan`,
/// `delegate_to_integrations_agent`).
///
/// When the agent's tool scope is [`ToolScope::Wildcard`] **and** there
/// are no `extra_tools`, returns `None` to preserve the legacy
Expand Down Expand Up @@ -632,9 +633,11 @@ mod scoping_tests {

/// `ToolScope::Named` with extras returns the union of the TOML
/// named list and the extras' names. This is the orchestrator's
/// path: 4 direct tools from the TOML + N synthesised delegation
/// tools (`research`, `plan`, `delegate_gmail`, …) → all of them
/// visible to the orchestrator's LLM.
/// path: direct tools from the TOML + the synthesised delegation
/// tools (`research`, `plan`, `delegate_to_integrations_agent`)
/// → all of them visible to the orchestrator's LLM. The stub
/// names in this test are arbitrary; they exercise the union
/// logic, not the real synthesiser.
#[test]
fn named_scope_with_extras_returns_union() {
let def = def_with_scope(ToolScope::Named(vec![
Expand Down
7 changes: 4 additions & 3 deletions src/openhuman/tools/impl/agent/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ pub(crate) async fn dispatch_subagent(
);

// Propagate the per-call toolkit scope into the subagent runner so
// that `SkillDelegationTool`s can narrow `integrations_agent` to a single
// Composio toolkit (e.g. `delegate_gmail` → integrations_agent +
// toolkit="gmail"). Earlier code plumbed this through
// that the collapsed `SkillDelegationTool` can narrow
// `integrations_agent` to a single Composio toolkit (e.g.
// `delegate_to_integrations_agent { toolkit: "gmail" }` →
// integrations_agent + toolkit="gmail"). Earlier code plumbed this through
// `skill_filter_override` (which matches `{skill}__` QuickJS-style
// names), but Composio actions are named `GMAIL_*` / `NOTION_*` —
// so the filter excluded every Composio tool instead of narrowing
Expand Down
Loading
Loading