Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 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,45 @@ 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
27 changes: 23 additions & 4 deletions app/src/utils/toolTimelineFormatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,29 @@ export function formatTimelineEntry(entry: ToolTimelineEntry): { title: string;
inferIntegrationName(parsedArgs?.toolkit) ??
inferIntegrationNameFromPrompt(parsedArgs?.prompt) ??
inferIntegrationName(entry.name);
return {
title: provider ? integrationActivityTitle(provider) : humanizeIdentifier(entry.name),
detail: entry.detail ?? parsedArgs?.prompt,
};

// 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, detail: entry.detail ?? parsedArgs?.prompt };
}

return {
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