mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
perf(agent): orchestrator harness efficiency improvements (#1314)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
e58b5abfcf
commit
8d9a1ca788
@@ -0,0 +1,31 @@
|
||||
# Delegation Policy
|
||||
|
||||
## When to delegate vs. act directly
|
||||
|
||||
The orchestrator follows a direct-first policy. This document codifies the four-tier decision tree the orchestrator applies to every user message.
|
||||
|
||||
## Tier 1 — Reply directly (no tools)
|
||||
Apply when: small talk, simple factual Q&A, acknowledgements, clarification requests, context already in the system prompt.
|
||||
Cost: 0 tokens (output only).
|
||||
Rule: if you can answer without calling any tool, do so.
|
||||
|
||||
## Tier 2 — Use a direct tool
|
||||
Apply when: the task needs a tool but not specialised execution (time lookup, memory read/write, cron scheduling, workspace state, listing connections).
|
||||
Cost: 1 tool call + parse overhead (~200-400 tokens).
|
||||
Rule: prefer `current_time`, `cron_*`, `memory_*`, `memory_tree`, `read_workspace_state`, `composio_list_connections`, `ask_user_clarification`.
|
||||
|
||||
## Tier 3 — Spawn a sub-agent (inline)
|
||||
Apply when: the task requires specialised execution (writing code, crawling docs, running shell, calling an external integration) that the orchestrator cannot do directly.
|
||||
Cost: full sub-agent turn (~1-5k tokens depending on archetype).
|
||||
Rule: spawn the narrowest archetype that can complete the task. Prefer inline spawn (`spawn_worker_thread` with no dedicated thread) for tasks that complete in <5 turns.
|
||||
|
||||
## Tier 4 — Spawn a dedicated worker thread
|
||||
Apply when: the task is long (>5 turns estimated), produces a large transcript, or the user explicitly wants it tracked as a separate thread.
|
||||
Cost: same as Tier 3 but the parent thread is not flooded.
|
||||
Rule: use `spawn_worker_thread` and surface a brief summary back to the parent. Do not chain workers (workers cannot spawn workers).
|
||||
|
||||
## Anti-patterns to avoid
|
||||
- Spawning a sub-agent to answer a question the orchestrator already has context for.
|
||||
- Delegating a tool call to a sub-agent when `current_tier <= 2` applies.
|
||||
- Using `spawn_subagent` when `delegate_{archetype}` covers the task — `delegate_*` tools carry the full archetype definition and have correct tool filtering pre-configured.
|
||||
- Passing the entire parent conversation as context to a sub-agent — pass only the task-relevant slice.
|
||||
@@ -4,6 +4,7 @@ delegate_name = "run_code"
|
||||
when_to_use = "Sandboxed developer — writes, runs, and debugs code until tests pass. Use for any task that requires producing or modifying source files and exercising them with shell or test commands."
|
||||
temperature = 0.4
|
||||
max_iterations = 10
|
||||
max_result_chars = 16000
|
||||
sandbox_mode = "sandboxed"
|
||||
omit_identity = true
|
||||
omit_memory_context = true
|
||||
|
||||
@@ -301,7 +301,20 @@ mod tests {
|
||||
assert!(matches!(def.model, ModelSpec::Hint(ref h) if h == "reasoning"));
|
||||
match def.tools {
|
||||
ToolScope::Named(tools) => {
|
||||
assert!(tools.iter().any(|t| t == "spawn_subagent"));
|
||||
// spawn_subagent was removed in #1141; spawn_worker_thread is the replacement
|
||||
assert!(
|
||||
tools.iter().any(|t| t == "spawn_worker_thread"),
|
||||
"orchestrator must have spawn_worker_thread"
|
||||
);
|
||||
assert!(
|
||||
!tools.iter().any(|t| t == "spawn_subagent"),
|
||||
"spawn_subagent must not appear — removed in #1141"
|
||||
);
|
||||
// consolidated memory_tree* → single memory_tree with mode dispatch
|
||||
assert!(
|
||||
tools.iter().any(|t| t == "memory_tree"),
|
||||
"orchestrator must have memory_tree"
|
||||
);
|
||||
assert!(!tools.iter().any(|t| t == "shell"));
|
||||
assert!(!tools.iter().any(|t| t == "file_write"));
|
||||
}
|
||||
|
||||
@@ -59,9 +59,7 @@ hint = "reasoning"
|
||||
|
||||
[tools]
|
||||
# Direct tools — things the orchestrator calls itself rather than
|
||||
# delegating. `spawn_subagent` is retained as an advanced fallback so
|
||||
# power users can still spawn arbitrary agent ids that are not listed
|
||||
# in `subagents` above (e.g. workspace-override custom agents).
|
||||
# delegating.
|
||||
#
|
||||
# `composio_list_connections` is the orchestrator's only composio_*
|
||||
# tool: it exists so the agent can detect newly-authorised integrations
|
||||
@@ -74,38 +72,30 @@ named = [
|
||||
"query_memory",
|
||||
"memory_store",
|
||||
"memory_forget",
|
||||
"memory_tree_search_entities",
|
||||
"memory_tree_query_topic",
|
||||
"memory_tree_query_source",
|
||||
"memory_tree_query_global",
|
||||
"memory_tree_drill_down",
|
||||
"memory_tree_fetch_leaves",
|
||||
"memory_tree",
|
||||
# WhatsApp local-data tools (issue #1341). The scanner ingests chats
|
||||
# and messages into `whatsapp_data.db` on the user's machine; these
|
||||
# three read-only tools let the orchestrator quote, summarise and
|
||||
# search that local data without exposing the scanner's
|
||||
# `whatsapp_data_ingest` write-path. Pair with the `memory_tree_*`
|
||||
# tools above for cross-source / action-item flows once the scanner
|
||||
# `whatsapp_data_ingest` write-path. Pair with the `memory_tree`
|
||||
# tool above for cross-source / action-item flows once the scanner
|
||||
# also forwards messages into the memory tree.
|
||||
"whatsapp_data_list_chats",
|
||||
"whatsapp_data_list_messages",
|
||||
"whatsapp_data_search_messages",
|
||||
"read_workspace_state",
|
||||
"ask_user_clarification",
|
||||
"spawn_subagent",
|
||||
"spawn_worker_thread",
|
||||
"composio_list_connections",
|
||||
# Time + scheduling — lets the orchestrator answer "what time is it",
|
||||
# "remind me in 10 minutes", "every morning at 8" directly rather than
|
||||
# delegating or telling the user it can't. `current_time` grounds
|
||||
# relative-time parsing; `cron_add` / `cron_list` / `cron_remove`
|
||||
# manage recurring + one-shot agent/shell jobs; `schedule` is the
|
||||
# simpler shell-only alias for one-shot reminders.
|
||||
# manage recurring + one-shot agent/shell jobs.
|
||||
"current_time",
|
||||
"cron_add",
|
||||
"cron_list",
|
||||
"cron_remove",
|
||||
"schedule",
|
||||
# Coding-harness coordination primitives from #1208. `todowrite`
|
||||
# gives the orchestrator a shared todo store to track multi-step
|
||||
# work across delegations; `plan_exit` is the stable marker that
|
||||
|
||||
@@ -21,52 +21,17 @@ 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, delegate to `integrations_agent` with the right toolkit.
|
||||
- If code writing/execution/debugging is required, delegate to `code_executor`.
|
||||
- If web/doc crawling is required, delegate to `researcher`.
|
||||
- If complex multi-step decomposition is required, delegate to `planner` (and only then route deeper if necessary).
|
||||
- If code review is requested, delegate to `critic`.
|
||||
- If external SaaS integration work is required, use `delegate_{toolkit}` (e.g. `delegate_gmail`, `delegate_notion`).
|
||||
- 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**.
|
||||
|
||||
## Available Sub-Agents
|
||||
|
||||
| Archetype | When to Use |
|
||||
| ----------------- | -------------------------------------------------------------------------- |
|
||||
| **Planner** | Complex tasks that need a multi-step plan before execution. |
|
||||
| **Code Executor** | Writing, modifying, or running code. Runs sandboxed. |
|
||||
| **Skills Agent** | Interacting with connected services (Notion, Gmail, etc.) via skill tools. |
|
||||
| **Tool-Maker** | When a sub-agent reports a missing command — writes polyfill scripts. |
|
||||
| **Researcher** | Finding information in docs, web, or files. Compresses to dense markdown. |
|
||||
| **Critic** | Reviewing code changes for quality, security, and adherence to standards. |
|
||||
|
||||
## Direct Tools (call these yourself — no delegation needed)
|
||||
|
||||
Some capabilities are cheap, read-only, or purely declarative — delegating them
|
||||
to a sub-agent wastes a turn. Use these directly:
|
||||
|
||||
| Tool | When to use |
|
||||
| --------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `current_time` | Any time the user refers to "now", "in 10 minutes", "tomorrow", "tonight", or before scheduling anything. |
|
||||
| `cron_add` / `cron_list` / `cron_remove` | Reminders, recurring tasks, follow-ups. Use `job_type: "agent"` with a `prompt` to have a future agent run fire (e.g. send a pushover reminder). Use cron expressions for recurring, `at` for one-shot absolute times, `every` for fixed intervals. |
|
||||
| `schedule` | Lightweight alias for one-shot shell reminders. Prefer `cron_add` with `job_type: "agent"` for anything that should produce a user-visible message. |
|
||||
| `query_memory` | Pull long-term user context (preferences, past conversations, saved notes) before answering personal questions. |
|
||||
| `memory_store` / `memory_forget` | Persist a fact the user asked you to remember, or drop one they asked you to forget. |
|
||||
| `read_workspace_state` | Get git status + file tree before planning a code task. |
|
||||
| `composio_list_connections` | Check which external integrations (Gmail, Notion, GitHub, …) the user has authorised *right now*. Session-start list may be stale. |
|
||||
| `ask_user_clarification` | Ask one focused question when the request is ambiguous — don't guess. |
|
||||
| `spawn_subagent` | Inline delegation: the sub-agent's work is collapsed into a single result in this thread. Use for quick tasks. |
|
||||
| `spawn_worker_thread` | Dedicated delegation: creates a fresh 'worker' thread for the sub-agent. Use for long, complex, or multi-step tasks to avoid cluttering the parent thread. |
|
||||
|
||||
**Scheduling rule of thumb.** To "remind me in 10 minutes", call `current_time`
|
||||
first. If `cron_add` is available and enabled for this runtime, then call
|
||||
`cron_add` with `schedule = {kind:"at", at:"<iso-time>"}`, `job_type:"agent"`,
|
||||
and a `prompt` that tells a future agent what to deliver (e.g. "Send pushover:
|
||||
'stand up and stretch'"). If `cron_add` is disabled by config, absent from your
|
||||
tool list, or returns an error, do not promise the reminder: tell the user you
|
||||
can't schedule it in this environment and, if helpful, provide the computed time
|
||||
or a manual fallback.
|
||||
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.
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -77,20 +42,27 @@ or a manual fallback.
|
||||
- **Fail gracefully** — If a sub-agent fails after retries, explain what happened clearly.
|
||||
- **Escalate when appropriate** — If orchestration is the wrong mode or a specialist cannot make progress, hand control back to OpenHuman Core with a concise explanation and let Core handle general interactions.
|
||||
|
||||
**Scheduling rule of thumb.** To "remind me in 10 minutes", call `current_time`
|
||||
first. If `cron_add` is available and enabled for this runtime, then call
|
||||
`cron_add` with `schedule = {kind:"at", at:"<iso-time>"}`, `job_type:"agent"`,
|
||||
and a `prompt` that tells a future agent what to deliver (e.g. "Send pushover:
|
||||
'stand up and stretch'"). If `cron_add` is disabled by config, absent from your
|
||||
tool list, or returns an error, do not promise the reminder: tell the user you
|
||||
can't schedule it in this environment and, if helpful, provide the computed time
|
||||
or a manual fallback.
|
||||
|
||||
## Dedicated worker threads
|
||||
|
||||
`spawn_subagent` accepts an optional `dedicated_thread: true` flag. When set, the
|
||||
sub-agent's run is persisted into a fresh **worker**-labeled thread the user can
|
||||
open from the thread list, and you receive a compact reference (worker thread id
|
||||
+ brief summary) instead of the full sub-agent transcript. Use this **only**
|
||||
when the sub-task is genuinely long or complex and the parent thread should not
|
||||
be flooded with the sub-agent's output — for example multi-step research,
|
||||
multi-file refactors, or batch integration work that produces a large
|
||||
transcript. For everyday delegation keep `dedicated_thread` off (the default)
|
||||
and surface the result inline.
|
||||
Use `spawn_worker_thread` for genuinely long or complex delegated tasks where the full
|
||||
sub-agent transcript would flood the parent thread — for example multi-step research,
|
||||
multi-file refactors, or batch integration work. It creates a persisted **worker**-labeled
|
||||
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.
|
||||
|
||||
Worker threads are one level deep by design: a sub-agent never sees
|
||||
`spawn_subagent` or `spawn_worker_thread`, so a worker cannot itself spawn another worker.
|
||||
For routine delegation use the matching `delegate_*` tool 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.
|
||||
|
||||
## Connecting external services
|
||||
|
||||
@@ -146,16 +118,16 @@ User: what time is it?
|
||||
|
||||
## Memory tree retrieval
|
||||
|
||||
Six tools query the user's ingested email/chat/document memory:
|
||||
Use `memory_tree` with a `mode` argument to query the user's ingested email/chat/document history:
|
||||
|
||||
- `memory_tree_search_entities(query)` — resolve a name to a canonical id (e.g. "alice" → `email:alice@example.com`). ALWAYS call this first when the user mentions someone by name.
|
||||
- `memory_tree_query_topic(entity_id, query?)` — all mentions of an entity, cross-source. Pass `query` for semantic rerank.
|
||||
- `memory_tree_query_source(source_kind?, time_window_days?, query?)` — filter by source type (chat/email/document) and time window. Use for "in my email last week…" intents.
|
||||
- `memory_tree_query_global(window_days)` — cross-source daily digest (the 7-day digest is pre-loaded into context on session start and refreshed every ~30 min, so only call this for a different window or to refresh on demand).
|
||||
- `memory_tree_drill_down(node_id)` — when a summary is too coarse, expand it one level.
|
||||
- `memory_tree_fetch_leaves(chunk_ids)` — pull raw chunks for citation.
|
||||
- `mode: "search_entities"` — resolve a name to a canonical id (e.g. "alice" → `email:alice@example.com`). ALWAYS call this first when the user mentions someone by name.
|
||||
- `mode: "query_topic"` — all cross-source mentions of an `entity_id` from `search_entities`.
|
||||
- `mode: "query_source"` — filter by `source_kind` (chat/email/document) and `time_window_days`. Use for "in my email last week…" intents.
|
||||
- `mode: "query_global"` — cross-source daily digest over `time_window_days` (7-day digest is pre-loaded into context on session start — only call for a different window or to force refresh).
|
||||
- `mode: "drill_down"` — expand a coarse `node_id` summary one level.
|
||||
- `mode: "fetch_leaves"` — pull raw `chunk_ids` for citation.
|
||||
|
||||
Top-down expansion is the cost-control story: start with cheap summaries (`query_*`), only call `drill_down` / `fetch_leaves` when the user wants details or you need a quote.
|
||||
Start cheap (query_* summaries), only drill_down/fetch_leaves when you need verbatim content.
|
||||
|
||||
## Citations
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! 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 `spawn_subagent(integrations_agent, toolkit=…)` for true
|
||||
//! 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
|
||||
//! prompts module) so the skill-executor voice stays in
|
||||
//! `integrations_agent/prompt.rs` and nobody has to branch on `agent_id`
|
||||
@@ -13,6 +14,7 @@ use crate::openhuman::context::prompt::{
|
||||
render_datetime, render_tools, render_user_files, render_workspace, ConnectedIntegration,
|
||||
PromptContext,
|
||||
};
|
||||
use crate::openhuman::tools::orchestrator_tools::sanitise_slug;
|
||||
use anyhow::Result;
|
||||
use std::fmt::Write;
|
||||
|
||||
@@ -62,36 +64,49 @@ pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Render the delegator-voice `## Delegation Guide — Integrations`
|
||||
/// block. Only toolkits the user has actively connected are listed —
|
||||
/// unauthorised toolkits are hidden so the orchestrator can't hallucinate
|
||||
/// a spawn against an integration the `spawn_subagent` pre-flight will
|
||||
/// immediately reject. When every toolkit is unconnected, the whole
|
||||
/// section is omitted.
|
||||
/// Render the delegator-voice `## Connected Integrations` block. Only
|
||||
/// toolkits the user has actively connected are listed — unauthorised
|
||||
/// toolkits are hidden so the orchestrator cannot hallucinate a delegation
|
||||
/// to an integration whose `delegate_*` tool does not actually exist.
|
||||
/// When every toolkit is unconnected the whole section is omitted.
|
||||
///
|
||||
/// The tool name printed in the prompt is derived with the same
|
||||
/// `sanitise_slug` function that `collect_orchestrator_tools` uses when
|
||||
/// synthesising the real tool objects, so the names in the prompt always
|
||||
/// match the names in the function-calling schema.
|
||||
fn render_delegation_guide(integrations: &[ConnectedIntegration]) -> String {
|
||||
let connected: Vec<&ConnectedIntegration> =
|
||||
integrations.iter().filter(|ci| ci.connected).collect();
|
||||
tracing::debug!(
|
||||
total_integrations = integrations.len(),
|
||||
connected_count = connected.len(),
|
||||
"[delegation-guide] rendering integration section ({} connected / {} total)",
|
||||
connected.len(),
|
||||
integrations.len()
|
||||
);
|
||||
if connected.is_empty() {
|
||||
tracing::debug!("[delegation-guide] section omitted — no connected integrations");
|
||||
return String::new();
|
||||
}
|
||||
let mut out = String::from(
|
||||
"## Delegation Guide — Integrations\n\n\
|
||||
For any task that touches one of these external services, \
|
||||
delegate to `integrations_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\
|
||||
Only the integrations listed below are currently authorised. \
|
||||
If the user asks about another service, tell them to connect \
|
||||
it in **Skills** page before retrying.\n\n",
|
||||
"## Connected Integrations\n\n\
|
||||
Delegate tasks for these services using the matching `delegate_{toolkit}` tool:\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.
|
||||
let slug = sanitise_slug(&ci.toolkit);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"- **{}** — {}\n Delegate with: `spawn_subagent(agent_id=\"integrations_agent\", toolkit=\"{}\", prompt=<task>)`",
|
||||
ci.toolkit, ci.description, ci.toolkit,
|
||||
"- **{}** (delegate via `delegate_{}`): {}",
|
||||
ci.toolkit, slug, ci.description
|
||||
);
|
||||
}
|
||||
tracing::debug!(
|
||||
section_len = out.len(),
|
||||
"[delegation-guide] section emitted ({} bytes)",
|
||||
out.len()
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -127,7 +142,7 @@ mod tests {
|
||||
fn build_returns_nonempty_body() {
|
||||
let body = build(&ctx_with(&[])).unwrap();
|
||||
assert!(!body.is_empty());
|
||||
assert!(!body.contains("## Delegation Guide"));
|
||||
assert!(!body.contains("## Connected Integrations"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,14 +169,29 @@ mod tests {
|
||||
connected: true,
|
||||
}];
|
||||
let body = build(&ctx_with(&integrations)).unwrap();
|
||||
assert!(body.contains("## Delegation Guide — Integrations"));
|
||||
assert!(body.contains(
|
||||
"spawn_subagent(agent_id=\"integrations_agent\", toolkit=\"gmail\", prompt=<task>)"
|
||||
));
|
||||
assert!(body.contains("## Connected Integrations"));
|
||||
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() {
|
||||
let integrations = vec![ConnectedIntegration {
|
||||
toolkit: "gmail".into(),
|
||||
description: "Email access.".into(),
|
||||
tools: Vec::new(),
|
||||
connected: true,
|
||||
}];
|
||||
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("spawn_subagent(agent_id=\"integrations_agent\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hides_unconnected_integrations() {
|
||||
// Only connected toolkits make it into the Delegation Guide
|
||||
@@ -196,6 +226,6 @@ mod tests {
|
||||
connected: false,
|
||||
}];
|
||||
let body = build(&ctx_with(&integrations)).unwrap();
|
||||
assert!(!body.contains("## Delegation Guide"));
|
||||
assert!(!body.contains("## Connected Integrations"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ delegate_name = "plan"
|
||||
when_to_use = "Architect — break a complex task into a small DAG of subtasks with explicit acceptance criteria. Reads memory and searches the web to ground plans in real context. Read-only; produces JSON, not code."
|
||||
temperature = 0.4
|
||||
max_iterations = 8
|
||||
max_result_chars = 8000
|
||||
sandbox_mode = "read_only"
|
||||
omit_identity = true
|
||||
omit_memory_context = false
|
||||
|
||||
@@ -4,6 +4,7 @@ delegate_name = "research"
|
||||
when_to_use = "Web & docs crawler — reads real documentation, compresses to dense markdown. Use for any task that requires looking up external knowledge."
|
||||
temperature = 0.4
|
||||
max_iterations = 8
|
||||
max_result_chars = 8000
|
||||
sandbox_mode = "none"
|
||||
omit_identity = true
|
||||
omit_memory_context = true
|
||||
|
||||
@@ -127,6 +127,14 @@ pub struct AgentDefinition {
|
||||
#[serde(default = "defaults::max_iterations")]
|
||||
pub max_iterations: usize,
|
||||
|
||||
/// Maximum character length for this sub-agent's output before the
|
||||
/// harness truncates it before feeding it back as a tool result to the
|
||||
/// parent. `None` means no cap (the default for most agents). Set to
|
||||
/// a value for research/planner/code agents to prevent context flooding
|
||||
/// from large outputs.
|
||||
#[serde(default)]
|
||||
pub max_result_chars: Option<usize>,
|
||||
|
||||
/// Wall-clock timeout for the sub-agent's execution (seconds).
|
||||
#[serde(default)]
|
||||
pub timeout_secs: Option<u64>,
|
||||
|
||||
@@ -19,6 +19,7 @@ fn make_def(id: &str) -> AgentDefinition {
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
max_iterations: 8,
|
||||
max_result_chars: None,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
background: false,
|
||||
|
||||
@@ -69,7 +69,9 @@ pub struct ParentExecutionContext {
|
||||
/// Memory context loaded for the current turn. Auto-injected into
|
||||
/// subagent prompts so they have access to conversation history and
|
||||
/// skill sync data without running their own memory queries.
|
||||
pub memory_context: Option<String>,
|
||||
/// Wrapped in `Arc` so cloning into sub-agents is O(1) — a reference
|
||||
/// count bump rather than a full string copy per spawn.
|
||||
pub memory_context: Arc<Option<String>>,
|
||||
|
||||
/// Parent's event-bus session id (for tracing & DomainEvents).
|
||||
pub session_id: String,
|
||||
|
||||
@@ -359,6 +359,7 @@ mod tests {
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
max_iterations: 1,
|
||||
max_result_chars: None,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
background: false,
|
||||
|
||||
@@ -1058,7 +1058,7 @@ impl Agent {
|
||||
memory: Arc::clone(&self.memory),
|
||||
agent_config: self.config.clone(),
|
||||
skills: Arc::new(self.skills.clone()),
|
||||
memory_context: self.last_memory_context.clone(),
|
||||
memory_context: Arc::new(self.last_memory_context.clone()),
|
||||
session_id: self.event_session_id().to_string(),
|
||||
channel: self.event_channel().to_string(),
|
||||
connected_integrations: self.connected_integrations.clone(),
|
||||
|
||||
@@ -139,11 +139,36 @@ pub async fn run_subagent(
|
||||
// want to gate on it (e.g. `composio_execute` rejecting
|
||||
// Write/Admin slugs under `ReadOnly`) read it via
|
||||
// `current_sandbox_mode()`; tools that don't care just ignore it.
|
||||
let outcome = with_current_sandbox_mode(definition.sandbox_mode, async {
|
||||
let mut outcome = with_current_sandbox_mode(definition.sandbox_mode, async {
|
||||
run_typed_mode(definition, task_prompt, &options, &parent, &task_id).await
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Truncate result to the definition's cap if set.
|
||||
// Use char-count (not byte-length) to avoid panicking on multi-byte
|
||||
// UTF-8 sequences at the truncation boundary.
|
||||
if let Some(cap) = definition.max_result_chars {
|
||||
let original_chars = outcome.output.chars().count();
|
||||
if original_chars > cap {
|
||||
tracing::debug!(
|
||||
agent_id = %definition.id,
|
||||
original_chars,
|
||||
cap,
|
||||
"[subagent_runner] truncating oversized result to max_result_chars cap"
|
||||
);
|
||||
// Find the byte offset of the cap-th character boundary so
|
||||
// `truncate` never lands mid-codepoint.
|
||||
let byte_offset = outcome
|
||||
.output
|
||||
.char_indices()
|
||||
.nth(cap)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(outcome.output.len());
|
||||
outcome.output.truncate(byte_offset);
|
||||
outcome.output.push_str("\n[...truncated]");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
agent_id = %definition.id,
|
||||
task_id = %task_id,
|
||||
@@ -730,7 +755,7 @@ async fn run_typed_mode(
|
||||
|
||||
let mut context_parts: Vec<&str> = Vec::new();
|
||||
if !definition.omit_memory_context {
|
||||
if let Some(ref mem_ctx) = parent.memory_context {
|
||||
if let Some(ref mem_ctx) = *parent.memory_context {
|
||||
context_parts.push(mem_ctx);
|
||||
}
|
||||
}
|
||||
@@ -1361,3 +1386,7 @@ fn parse_tool_arguments(arguments: &str) -> serde_json::Value {
|
||||
#[cfg(test)]
|
||||
#[path = "ops_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ops_truncation_tests.rs"]
|
||||
mod truncation_tests;
|
||||
|
||||
@@ -20,6 +20,7 @@ fn make_def_named_tools(names: &[&str]) -> AgentDefinition {
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
max_iterations: 5,
|
||||
max_result_chars: None,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: crate::openhuman::agent::harness::definition::SandboxMode::None,
|
||||
background: false,
|
||||
@@ -232,7 +233,7 @@ fn make_parent(provider: Arc<dyn Provider>, tools: Vec<Box<dyn Tool>>) -> Parent
|
||||
memory: noop_memory(),
|
||||
agent_config: crate::openhuman::config::AgentConfig::default(),
|
||||
skills: Arc::new(vec![]),
|
||||
memory_context: None,
|
||||
memory_context: Arc::new(None),
|
||||
session_id: "test-session".into(),
|
||||
channel: "test".into(),
|
||||
connected_integrations: vec![],
|
||||
@@ -432,7 +433,9 @@ async fn typed_mode_no_memory_context_in_user_message() {
|
||||
async fn typed_mode_includes_memory_context_when_definition_allows_it() {
|
||||
let provider = ScriptedProvider::new(vec![text_response("ok")]);
|
||||
let mut parent = make_parent(provider.clone(), vec![stub("file_read")]);
|
||||
parent.memory_context = Some("[Memory context]\n- prior fact: branch X failed\n".into());
|
||||
parent.memory_context = Arc::new(Some(
|
||||
"[Memory context]\n- prior fact: branch X failed\n".into(),
|
||||
));
|
||||
let mut def = make_def_named_tools(&[]);
|
||||
def.omit_memory_context = false;
|
||||
|
||||
@@ -681,3 +684,6 @@ async fn typed_mode_progress_emission_is_a_noop_without_sink() {
|
||||
.expect("runner should succeed");
|
||||
assert_eq!(outcome.iterations, 1);
|
||||
}
|
||||
|
||||
// Truncation tests live in ops_truncation_tests.rs to keep this file
|
||||
// under the ~500-line guideline.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/// Tests for the `max_result_chars` truncation logic in `ops.rs`.
|
||||
///
|
||||
/// Kept in a dedicated file so `ops_tests.rs` stays under ~500 lines.
|
||||
/// The logic under test lives in `run_subagent` — tests here cover the
|
||||
/// char-safe truncation path directly without spinning up a provider.
|
||||
|
||||
#[test]
|
||||
fn max_result_chars_cap_is_enforced() {
|
||||
// Verify that max_result_chars truncation uses char count (not bytes)
|
||||
// and produces a truncated result ending with "[...truncated]".
|
||||
let cap = 10usize;
|
||||
let input = "hello world this is long".to_string();
|
||||
let original_chars = input.chars().count();
|
||||
let mut output = input.clone();
|
||||
if original_chars > cap {
|
||||
let byte_offset = output
|
||||
.char_indices()
|
||||
.nth(cap)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(output.len());
|
||||
output.truncate(byte_offset);
|
||||
output.push_str("\n[...truncated]");
|
||||
}
|
||||
assert_eq!(&output[..10], "hello worl");
|
||||
assert!(output.ends_with("[...truncated]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_result_chars_cap_is_char_safe_for_multibyte() {
|
||||
// A cap landing in the middle of a multi-byte UTF-8 sequence must
|
||||
// not panic. "café" has 4 chars but 'é' is 2 bytes — truncating at
|
||||
// byte offset 4 with a raw String::truncate() would panic.
|
||||
let cap = 3usize; // keep "caf", drop "é"
|
||||
let input = "café latte".to_string();
|
||||
let original_chars = input.chars().count();
|
||||
let mut output = input.clone();
|
||||
if original_chars > cap {
|
||||
let byte_offset = output
|
||||
.char_indices()
|
||||
.nth(cap)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(output.len());
|
||||
output.truncate(byte_offset);
|
||||
output.push_str("\n[...truncated]");
|
||||
}
|
||||
assert_eq!(output, "caf\n[...truncated]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_result_chars_not_applied_when_none() {
|
||||
let cap: Option<usize> = None;
|
||||
let original = "short output".to_string();
|
||||
let mut output = original.clone();
|
||||
if let Some(c) = cap {
|
||||
let char_len = output.chars().count();
|
||||
if char_len > c {
|
||||
let byte_offset = output
|
||||
.char_indices()
|
||||
.nth(c)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(output.len());
|
||||
output.truncate(byte_offset);
|
||||
output.push_str("\n[...truncated]");
|
||||
}
|
||||
}
|
||||
assert_eq!(output, original);
|
||||
}
|
||||
@@ -481,6 +481,18 @@ impl PromptSection for ToolsSection {
|
||||
}
|
||||
|
||||
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
|
||||
// Native function-calling: the provider already sends full JSON
|
||||
// schemas in the API request — no need to repeat the tool catalogue
|
||||
// in the system prompt (pure token bloat). However, any non-empty
|
||||
// `dispatcher_instructions` (e.g. the "## Tool Use Protocol" block
|
||||
// from NativeToolDispatcher) must still be included so the model
|
||||
// receives its behavioural guidance.
|
||||
if ctx.tool_call_format == ToolCallFormat::Native {
|
||||
if ctx.dispatcher_instructions.trim().is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
return Ok(ctx.dispatcher_instructions.to_string());
|
||||
}
|
||||
let mut out = String::from("## Tools\n\n");
|
||||
let has_filter = !ctx.visible_tool_names.is_empty();
|
||||
for tool in ctx.tools {
|
||||
|
||||
@@ -318,20 +318,14 @@ fn tools_section_pformat_renders_signature_not_schema() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_section_uses_pformat_signature_for_every_dispatcher() {
|
||||
// Tool rendering is uniform across dispatchers: always the
|
||||
fn tools_section_uses_pformat_signature_for_text_dispatchers() {
|
||||
// Tool rendering is uniform across text dispatchers: always the
|
||||
// compact `Call as: name[args]` signature, never a raw JSON
|
||||
// schema dump. Native tool calls still carry the full schema
|
||||
// in the provider request; the `Json` / `PFormat` text
|
||||
// dispatchers supply any extra framing via
|
||||
// `ctx.dispatcher_instructions`.
|
||||
// schema dump. Native tool calls are handled differently — see
|
||||
// `tools_section_empty_for_native` below.
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(TestTool)];
|
||||
let prompt_tools = PromptTool::from_tools(&tools);
|
||||
for format in [
|
||||
ToolCallFormat::PFormat,
|
||||
ToolCallFormat::Json,
|
||||
ToolCallFormat::Native,
|
||||
] {
|
||||
for format in [ToolCallFormat::PFormat, ToolCallFormat::Json] {
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: Path::new("/tmp"),
|
||||
model_name: "test-model",
|
||||
@@ -1327,3 +1321,102 @@ fn user_reflections_render_above_user_memory_when_both_present() {
|
||||
"reflections must render before user-memory block"
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ToolsSection native-skip tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tools_section_empty_for_native() {
|
||||
// Native function-calling: the provider sends full JSON schemas in the
|
||||
// API request — repeating them in the system prompt is pure token bloat.
|
||||
// ToolsSection must return an empty string for Native mode.
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(TestTool)];
|
||||
let prompt_tools = PromptTool::from_tools(&tools);
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: Path::new("/tmp"),
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
tool_call_format: ToolCallFormat::Native,
|
||||
connected_integrations: &[],
|
||||
connected_identities_md: String::new(),
|
||||
include_profile: false,
|
||||
include_memory_md: false,
|
||||
curated_snapshot: None,
|
||||
user_identity: None,
|
||||
};
|
||||
let out = ToolsSection.build(&ctx).unwrap();
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"Native mode should produce empty ToolsSection, got: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_section_nonempty_for_pformat() {
|
||||
// PFormat is a text-driven format — the model discovers tools by reading
|
||||
// the prose `## Tools` section. It must be non-empty.
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(TestTool)];
|
||||
let prompt_tools = PromptTool::from_tools(&tools);
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: Path::new("/tmp"),
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
tool_call_format: ToolCallFormat::PFormat,
|
||||
connected_integrations: &[],
|
||||
connected_identities_md: String::new(),
|
||||
include_profile: false,
|
||||
include_memory_md: false,
|
||||
curated_snapshot: None,
|
||||
user_identity: None,
|
||||
};
|
||||
let out = ToolsSection.build(&ctx).unwrap();
|
||||
assert!(
|
||||
out.contains("## Tools"),
|
||||
"PFormat should render tool catalogue header, got: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_section_native_with_dispatcher_instructions_returns_instructions() {
|
||||
// Native mode must still include non-empty dispatcher_instructions
|
||||
// (e.g. the "## Tool Use Protocol" block from NativeToolDispatcher) so
|
||||
// the model receives behavioural guidance even though the tool catalogue
|
||||
// itself is omitted.
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(TestTool)];
|
||||
let prompt_tools = PromptTool::from_tools(&tools);
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: Path::new("/tmp"),
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
dispatcher_instructions: "## Tool Use Protocol\n\nUse native tool calling.",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
tool_call_format: ToolCallFormat::Native,
|
||||
connected_integrations: &[],
|
||||
connected_identities_md: String::new(),
|
||||
include_profile: false,
|
||||
include_memory_md: false,
|
||||
curated_snapshot: None,
|
||||
user_identity: None,
|
||||
};
|
||||
let out = ToolsSection.build(&ctx).unwrap();
|
||||
assert!(
|
||||
out.contains("## Tool Use Protocol"),
|
||||
"Native mode with non-empty dispatcher_instructions must include them, got: {out:?}"
|
||||
);
|
||||
assert!(
|
||||
!out.contains("## Tools"),
|
||||
"Native mode must not include the tool catalogue header, got: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result<S
|
||||
memory: agent.memory_arc(),
|
||||
agent_config: agent.agent_config().clone(),
|
||||
skills: Arc::new(agent.skills().to_vec()),
|
||||
memory_context: None, // Sub-agent queries memory via tools if needed
|
||||
memory_context: Arc::new(None), // Sub-agent queries memory via tools if needed
|
||||
session_id: format!("triage-{}", uuid::Uuid::new_v4()),
|
||||
channel: "triage".to_string(),
|
||||
connected_integrations: agent.connected_integrations().to_vec(),
|
||||
|
||||
@@ -438,8 +438,29 @@ async fn resolve_target_agent(channel: &str) -> AgentScoping {
|
||||
// the helper is agent-agnostic so future agents that delegate
|
||||
// (e.g. a custom workspace-override planner that subdivides work)
|
||||
// pick this up for free.
|
||||
//
|
||||
// Wrap the Composio fetch in the same 3-second timeout used by
|
||||
// `build_connection_state_block` so a slow/unresponsive Composio API
|
||||
// can never block turn dispatch indefinitely.
|
||||
const COMPOSIO_FETCH_TIMEOUT_SECS: u64 = 3;
|
||||
let extra_tools = if !definition.subagents.is_empty() {
|
||||
let connected = fetch_connected_integrations(&config).await;
|
||||
let connected = match tokio::time::timeout(
|
||||
Duration::from_secs(COMPOSIO_FETCH_TIMEOUT_SECS),
|
||||
fetch_connected_integrations(&config),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(list) => list,
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
channel = %channel,
|
||||
target_agent = target_id,
|
||||
"[dispatch::routing] Composio fetch timed out after {}s — proceeding without connected integrations",
|
||||
COMPOSIO_FETCH_TIMEOUT_SECS
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
tracing::debug!(
|
||||
channel = %channel,
|
||||
target_agent = target_id,
|
||||
@@ -571,6 +592,7 @@ mod scoping_tests {
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
max_iterations: 8,
|
||||
max_result_chars: None,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
background: false,
|
||||
|
||||
@@ -55,10 +55,8 @@ pub struct ContextConfig {
|
||||
/// `summarizer` sub-agent (orchestrator session only). The summarizer
|
||||
/// compresses the payload into a dense note that preserves
|
||||
/// identifiers and key facts, and the compressed summary replaces
|
||||
/// the raw payload before it enters agent history. Set to `0` to
|
||||
/// disable summarization entirely (the default). Set to any value
|
||||
/// `> 0` to enable summarization once a payload crosses that token
|
||||
/// threshold.
|
||||
/// the raw payload before it enters agent history. Default: 4000 tokens.
|
||||
/// Set to 0 to disable.
|
||||
///
|
||||
/// Token count is estimated as `chars / 4` (the same heuristic used
|
||||
/// by `tree_summarizer::estimate_tokens`). Pairs with
|
||||
@@ -123,11 +121,11 @@ fn default_tool_result_budget_bytes() -> usize {
|
||||
}
|
||||
|
||||
fn default_summarizer_payload_threshold_tokens() -> usize {
|
||||
// Disabled: 0 short-circuits the payload_summarizer wiring in the
|
||||
// agent builder (see session/builder.rs `> 0` guard). The summarizer
|
||||
// sub-agent was being invoked recursively in some flows; keep off
|
||||
// until that's root-caused.
|
||||
0
|
||||
// Re-enabled at 4000 tokens after the recursive-dispatch root cause
|
||||
// was fixed by the `omit_skills_catalog = true` guard on the
|
||||
// summarizer archetype (which prevents it from seeing `spawn_subagent`
|
||||
// and thus cannot recurse). 0 would leave this entirely disabled.
|
||||
4000
|
||||
}
|
||||
|
||||
fn default_summarizer_max_payload_tokens() -> usize {
|
||||
|
||||
@@ -384,7 +384,7 @@ mod tests {
|
||||
all_tools: Arc::new(vec![]),
|
||||
all_tool_specs: Arc::new(vec![]),
|
||||
skills: Arc::new(vec![]),
|
||||
memory_context: None,
|
||||
memory_context: std::sync::Arc::new(None),
|
||||
connected_integrations: vec![],
|
||||
composio_client: None,
|
||||
on_progress: None,
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
//! LLM-callable wrappers for the Phase 4 memory-tree retrieval primitives
|
||||
//! (issue #710). Each tool is a thin shim over one typed function in
|
||||
//! [`crate::openhuman::memory::tree::retrieval`]; the `*_rpc` variants are
|
||||
//! intentionally avoided because they wrap responses in an `RpcOutcome`
|
||||
//! envelope that is noisier than what the LLM needs.
|
||||
//! Consolidated memory-tree tool — dispatches to the correct retrieval
|
||||
//! primitive based on the `mode` argument. Reduces the orchestrator's
|
||||
//! tool surface from 6 entries to 1.
|
||||
//!
|
||||
//! All six tools share the same shape:
|
||||
//! 1. Deserialize args into the matching `*Request` struct from
|
||||
//! [`crate::openhuman::memory::tree::retrieval::rpc`].
|
||||
//! 2. Load the active workspace `Config` via
|
||||
//! [`crate::openhuman::config::rpc::load_config_with_timeout`].
|
||||
//! 3. Call the typed retrieval function.
|
||||
//! 4. Serialise the response to JSON and return it as `ToolResult::success`.
|
||||
//!
|
||||
//! The tools are stateless unit structs — there is no per-instance state to
|
||||
//! carry, so they slot directly into `Vec<Box<dyn Tool>>` without needing
|
||||
//! constructors. Logs use the `[tool]` / `[memory_tree]` prefixes per the
|
||||
//! repo's debug-logging conventions.
|
||||
//! The individual per-mode structs are still re-exported for callers that
|
||||
//! need them directly (e.g. tool registration in ops.rs for agents that
|
||||
//! prefer the individual tools). The consolidated [`MemoryTreeTool`] is
|
||||
//! the recommended single entry point for the orchestrator.
|
||||
|
||||
mod drill_down;
|
||||
mod fetch_leaves;
|
||||
@@ -24,9 +14,177 @@ mod query_source;
|
||||
mod query_topic;
|
||||
mod search_entities;
|
||||
|
||||
// Re-export individual tool types for callers that need them directly
|
||||
// (e.g. tool registration in ops.rs).
|
||||
pub use drill_down::MemoryTreeDrillDownTool;
|
||||
pub use fetch_leaves::MemoryTreeFetchLeavesTool;
|
||||
pub use query_global::MemoryTreeQueryGlobalTool;
|
||||
pub use query_source::MemoryTreeQuerySourceTool;
|
||||
pub use query_topic::MemoryTreeQueryTopicTool;
|
||||
pub use search_entities::MemoryTreeSearchEntitiesTool;
|
||||
|
||||
use crate::openhuman::tools::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
/// Single multi-mode tool that consolidates all six memory-tree retrieval
|
||||
/// primitives behind one LLM-facing entry. The `mode` field routes to the
|
||||
/// appropriate underlying implementation.
|
||||
pub struct MemoryTreeTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MemoryTreeTool {
|
||||
fn name(&self) -> &str {
|
||||
"memory_tree"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Query the user's ingested email/chat/document memory tree. \
|
||||
Set `mode` to one of: `search_entities` (resolve a name to a \
|
||||
canonical id — call first when the user mentions someone by name), \
|
||||
`query_topic` (all cross-source mentions of an entity), \
|
||||
`query_source` (filter by source type + time window), \
|
||||
`query_global` (cross-source daily digest), \
|
||||
`drill_down` (expand a coarse summary one level), \
|
||||
`fetch_leaves` (pull raw chunks for citation)."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["search_entities", "query_topic", "query_source",
|
||||
"query_global", "drill_down", "fetch_leaves"],
|
||||
"description": "Which retrieval operation to run."
|
||||
},
|
||||
// search_entities params
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "search_entities: substring to match. query_topic/query_source: semantic rerank query (optional)."
|
||||
},
|
||||
"kinds": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "search_entities: optional entity kind filter (email, url, handle, person, ...)."
|
||||
},
|
||||
// query_topic params
|
||||
"entity_id": {
|
||||
"type": "string",
|
||||
"description": "query_topic: canonical entity id returned by search_entities."
|
||||
},
|
||||
// query_source params
|
||||
"source_kind": {
|
||||
"type": "string",
|
||||
"description": "query_source: source type to filter (chat, email, document, ...)."
|
||||
},
|
||||
"time_window_days": {
|
||||
"type": "integer",
|
||||
"description": "query_source/query_global: look-back window in days."
|
||||
},
|
||||
// drill_down params
|
||||
"node_id": {
|
||||
"type": "string",
|
||||
"description": "drill_down: id of the summary node to expand."
|
||||
},
|
||||
"max_depth": {
|
||||
"type": "integer",
|
||||
"description": "drill_down: how many levels to expand (default 1, max 3)."
|
||||
},
|
||||
// fetch_leaves params
|
||||
"chunk_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "fetch_leaves: list of chunk ids to pull."
|
||||
},
|
||||
// shared
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max results (default varies by mode)."
|
||||
}
|
||||
},
|
||||
"required": ["mode"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let mode = args
|
||||
.get("mode")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("memory_tree: `mode` is required"))?;
|
||||
log::debug!("[tool][memory_tree] mode={mode}");
|
||||
match mode {
|
||||
"search_entities" => MemoryTreeSearchEntitiesTool.execute(args).await,
|
||||
"query_topic" => MemoryTreeQueryTopicTool.execute(args).await,
|
||||
"query_source" => MemoryTreeQuerySourceTool.execute(args).await,
|
||||
"query_global" => MemoryTreeQueryGlobalTool.execute(args).await,
|
||||
"drill_down" => MemoryTreeDrillDownTool.execute(args).await,
|
||||
"fetch_leaves" => MemoryTreeFetchLeavesTool.execute(args).await,
|
||||
other => Err(anyhow::anyhow!(
|
||||
"memory_tree: unknown mode `{other}`. Valid: search_entities, query_topic, query_source, query_global, drill_down, fetch_leaves"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod memory_tree_dispatcher_tests {
|
||||
use super::*;
|
||||
use crate::openhuman::tools::traits::Tool;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn memory_tree_tool_name_is_correct() {
|
||||
assert_eq!(MemoryTreeTool.name(), "memory_tree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_tree_schema_requires_mode() {
|
||||
let schema = MemoryTreeTool.parameters_schema();
|
||||
let required = schema.get("required").and_then(|r| r.as_array()).unwrap();
|
||||
assert!(required.iter().any(|v| v.as_str() == Some("mode")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_tree_schema_mode_enum_has_all_six_modes() {
|
||||
let schema = MemoryTreeTool.parameters_schema();
|
||||
let modes: Vec<&str> = schema
|
||||
.get("properties")
|
||||
.unwrap()
|
||||
.get("mode")
|
||||
.unwrap()
|
||||
.get("enum")
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect();
|
||||
assert!(modes.contains(&"search_entities"));
|
||||
assert!(modes.contains(&"query_topic"));
|
||||
assert!(modes.contains(&"query_source"));
|
||||
assert!(modes.contains(&"query_global"));
|
||||
assert!(modes.contains(&"drill_down"));
|
||||
assert!(modes.contains(&"fetch_leaves"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_tree_unknown_mode_returns_error() {
|
||||
let result = MemoryTreeTool
|
||||
.execute(json!({"mode": "invalid_mode"}))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("unknown mode"),
|
||||
"Expected 'unknown mode' in: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_tree_missing_mode_returns_error() {
|
||||
let result = MemoryTreeTool.execute(json!({})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,12 +134,7 @@ pub fn all_tools_with_runtime(
|
||||
Box::new(MemoryStoreTool::new(memory.clone(), security.clone())),
|
||||
Box::new(MemoryRecallTool::new(memory.clone())),
|
||||
Box::new(MemoryForgetTool::new(memory.clone(), security.clone())),
|
||||
Box::new(MemoryTreeSearchEntitiesTool),
|
||||
Box::new(MemoryTreeQueryTopicTool),
|
||||
Box::new(MemoryTreeQuerySourceTool),
|
||||
Box::new(MemoryTreeQueryGlobalTool),
|
||||
Box::new(MemoryTreeDrillDownTool),
|
||||
Box::new(MemoryTreeFetchLeavesTool),
|
||||
Box::new(MemoryTreeTool),
|
||||
// WhatsApp data store — read-only agent surface (issue #1341).
|
||||
// The matching `whatsapp_data_ingest` write-path stays internal-only
|
||||
// (registered in `src/core/all.rs::build_internal_only_controllers`)
|
||||
|
||||
@@ -124,6 +124,17 @@ pub fn collect_orchestrator_tools(
|
||||
continue;
|
||||
}
|
||||
for integration in connected_integrations {
|
||||
// Only emit a delegate_* tool for integrations that are
|
||||
// actually connected — exposing unconnected entries would
|
||||
// let the orchestrator call a tool whose pre-flight
|
||||
// will immediately reject with "not connected".
|
||||
if !integration.connected {
|
||||
log::debug!(
|
||||
"[orchestrator_tools] skipping unconnected integration: {}",
|
||||
integration.toolkit
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Slug the toolkit name into a tool-name-safe form.
|
||||
// Composio toolkit slugs are already lowercase / dash-
|
||||
// separated (e.g. "gmail", "google_calendar"), but
|
||||
@@ -175,7 +186,11 @@ pub fn collect_orchestrator_tools(
|
||||
/// Allows ASCII alphanumerics and underscores; everything else becomes
|
||||
/// an underscore. OpenAI-style function names only accept
|
||||
/// `[a-zA-Z0-9_-]{1,64}`, so this is the conservative subset.
|
||||
fn sanitise_slug(raw: &str) -> String {
|
||||
///
|
||||
/// Used both when synthesising `delegate_*` tools and when rendering the
|
||||
/// delegation guide in prompts — they must agree on slug canonicalisation
|
||||
/// so the prompt always references a tool name that actually exists.
|
||||
pub(crate) fn sanitise_slug(raw: &str) -> String {
|
||||
raw.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '_' {
|
||||
@@ -213,6 +228,7 @@ mod tests {
|
||||
skill_filter: None,
|
||||
extra_tools: vec![],
|
||||
max_iterations: 8,
|
||||
max_result_chars: None,
|
||||
timeout_secs: None,
|
||||
sandbox_mode: SandboxMode::None,
|
||||
background: false,
|
||||
@@ -356,4 +372,38 @@ mod tests {
|
||||
assert_eq!(sanitise_slug("slack.bot"), "slack_bot");
|
||||
assert_eq!(sanitise_slug("weird name!"), "weird_name_");
|
||||
}
|
||||
|
||||
/// Unconnected integrations must be silently skipped — exposing a
|
||||
/// `delegate_*` tool for a toolkit whose OAuth token is absent would
|
||||
/// let the orchestrator call a tool whose pre-flight check immediately
|
||||
/// rejects with "not connected".
|
||||
#[test]
|
||||
fn unconnected_integrations_are_skipped() {
|
||||
let orch = sample_orchestrator();
|
||||
let reg = registry_with_targets();
|
||||
let integrations = vec![
|
||||
integration("gmail", "Send and read email."),
|
||||
ConnectedIntegration {
|
||||
toolkit: "github".into(),
|
||||
description: "GitHub access.".into(),
|
||||
tools: vec![],
|
||||
connected: false, // not connected — must not produce a tool
|
||||
},
|
||||
integration("notion", "Read and write pages."),
|
||||
];
|
||||
let tools = collect_orchestrator_tools(&orch, ®, &integrations);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
assert!(
|
||||
names.contains(&"delegate_gmail"),
|
||||
"connected gmail must produce a tool"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"delegate_github"),
|
||||
"unconnected github must NOT produce a tool"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"delegate_notion"),
|
||||
"connected notion must produce a tool"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ fn stub_parent_context() -> ParentExecutionContext {
|
||||
memory: Arc::new(StubMemory),
|
||||
agent_config: AgentConfig::default(),
|
||||
skills: Arc::new(vec![]),
|
||||
memory_context: Some("ctx".into()),
|
||||
memory_context: Arc::new(Some("ctx".into())),
|
||||
session_id: "test-session".into(),
|
||||
channel: "test-channel".into(),
|
||||
connected_integrations: vec![],
|
||||
|
||||
@@ -80,13 +80,29 @@ fn alice_phoenix_thread() -> EmailThread {
|
||||
}
|
||||
}
|
||||
|
||||
/// The orchestrator definition must list every memory-tree tool name so
|
||||
/// the bus filter actually exposes them to the LLM. A wired-up wrapper
|
||||
/// that's invisible to the orchestrator is dead code.
|
||||
/// The orchestrator definition must list the consolidated `memory_tree` tool
|
||||
/// so the bus filter exposes it to the LLM. A wired-up wrapper that's
|
||||
/// invisible to the orchestrator is dead code.
|
||||
///
|
||||
/// NOTE: #1141 consolidated the 6 individual `memory_tree_*` tools
|
||||
/// (`memory_tree_search_entities`, `memory_tree_query_topic`, etc.) into a
|
||||
/// single `memory_tree` tool with a `mode` dispatch parameter. The orchestrator
|
||||
/// TOML was updated accordingly.
|
||||
#[test]
|
||||
fn orchestrator_lists_memory_tree_tools() {
|
||||
let toml = include_str!("../src/openhuman/agent/agents/orchestrator/agent.toml");
|
||||
for name in [
|
||||
// Exact entry match — substring match would also hit comments or prefixed names.
|
||||
let has_memory_tree_entry = toml
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.any(|line| line == "\"memory_tree\"" || line == "\"memory_tree\",");
|
||||
assert!(
|
||||
has_memory_tree_entry,
|
||||
"orchestrator agent.toml must list 'memory_tree' as a named tool entry"
|
||||
);
|
||||
// Verify the old individual tool names are gone — they were removed in #1141
|
||||
// when all 6 were consolidated into the single `memory_tree` dispatcher.
|
||||
for old_name in [
|
||||
"memory_tree_search_entities",
|
||||
"memory_tree_query_topic",
|
||||
"memory_tree_query_source",
|
||||
@@ -94,9 +110,15 @@ fn orchestrator_lists_memory_tree_tools() {
|
||||
"memory_tree_drill_down",
|
||||
"memory_tree_fetch_leaves",
|
||||
] {
|
||||
let entry = format!("\"{old_name}\"");
|
||||
let entry_comma = format!("\"{old_name}\",");
|
||||
let old_tool_present = toml
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.any(|line| line == entry || line == entry_comma);
|
||||
assert!(
|
||||
toml.contains(name),
|
||||
"orchestrator agent.toml must list '{name}' so the LLM can call it"
|
||||
!old_tool_present,
|
||||
"orchestrator agent.toml must NOT list '{old_name}' — removed in #1141 (use 'memory_tree' with mode= dispatch)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ async fn test_integrations_agent_has_current_date_context() -> Result<()> {
|
||||
memory: Arc::new(StubMemory),
|
||||
agent_config: openhuman_core::openhuman::config::AgentConfig::default(),
|
||||
skills: Arc::new(vec![]),
|
||||
memory_context: None,
|
||||
memory_context: Arc::new(None),
|
||||
session_id: "test-session".into(),
|
||||
channel: "test".into(),
|
||||
connected_integrations: vec![],
|
||||
|
||||
Reference in New Issue
Block a user