fix(orchestrator): prefer live integrations over memory_tree for inbox/doc queries (#1731)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Rafael Figuereo <rafaelfiguereod@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Steven Enamakel
2026-05-14 05:37:00 -07:00
committed by GitHub
co-authored by Rafael Figuereo dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent 1c58d478c5
commit 4d5e0d5aad
10 changed files with 612 additions and 546 deletions
Generated
+203 -201
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -45,7 +45,7 @@ env_logger = "0.11"
base64 = "0.22"
aes-gcm = "0.10"
argon2 = "0.5"
rand = "0.9"
rand = "0.10"
dirs = "5"
sha2 = "0.10"
hmac = "0.12"
@@ -110,7 +110,7 @@ tower = { version = "0.5", default-features = false }
opentelemetry = { version = "0.31", default-features = false, features = ["trace", "metrics"] }
opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics"] }
opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] }
sentry = { version = "0.47.0", default-features = false, features = ["backtrace", "contexts", "panic", "tracing", "debug-images", "reqwest", "rustls", "test"] }
sentry = { version = "0.47.0", default-features = false, features = ["backtrace", "contexts", "panic", "tracing", "debug-images", "reqwest", "rustls"] }
tokio-stream = { version = "0.1.18", features = ["full"] }
url = "2"
socketioxide = { version = "0.15", features = ["extensions"] }
+357 -319
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
export const DEFAULT_PORT = 18473;
export const MOCK_JWT = "e2e-mock-jwt-token";
export const MAX_PORT_RETRY_ATTEMPTS = 10;
export const MAX_MOCK_DELAY_MS = 30_000;
let requestLog = [];
let mockBehavior = {};
@@ -59,7 +60,8 @@ export function parseBehaviorJson(key, fallback) {
export function getDelayMs(key) {
const value = Number(mockBehavior[key] || 0);
return Number.isFinite(value) && value > 0 ? value : 0;
if (!Number.isFinite(value) || value <= 0) return 0;
return Math.min(value, MAX_MOCK_DELAY_MS);
}
export function sleep(ms) {
+6 -3
View File
@@ -178,10 +178,13 @@ pub async fn rpc_auth_middleware(req: axum::extract::Request, next: Next) -> Res
///
/// Uses `rand::rng()` (thread-local, OS-seeded CSPRNG) introduced in rand 0.9.
fn generate_token() -> String {
use rand::RngCore as _;
use rand::RngExt as _;
log::trace!("[auth] generate_token: start (32 bytes)");
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
rand::rng().fill(&mut bytes);
let token = hex::encode(bytes);
log::trace!("[auth] generate_token: complete (64 hex chars)");
token
}
/// Write `token` to `path` with owner-only read+write permissions on Unix.
@@ -17,19 +17,23 @@ Follow this sequence for every user message:
1. **Can I answer directly without tools?**
- Yes: reply directly (small talk, simple Q&A, basic factual answers).
- No: continue.
2. **Can I solve this with direct tools?**
- Yes: use direct tools first (`current_time`, `cron_*`, `memory_*`, `composio_list_connections`, etc.).
2. **Does the request name (or imply) a connected external service?**
- Words like "email/inbox/gmail", "calendar", "notion doc", "drive file", "slack/whatsapp/telegram message", "linear ticket", "send to X", "check X", etc. mean the user wants the **live** service.
- Find the matching toolkit in the **Connected Integrations** section and call `delegate_to_integrations_agent` with that `toolkit`.
- **Do this even if `memory_tree` could plausibly answer.** The user wants the live source of truth, not a stale summary. Use `memory_tree` only when the user explicitly asks about historical/ingested context (e.g. "what did we discuss last month", "summarise my recent activity") or when a live lookup just failed.
- If the relevant toolkit is not in **Connected Integrations**, tell the user to connect it via Settings → Connections → [Service] (see "Connecting external services" below). Do **not** silently fall back to `memory_tree`.
3. **Can I solve this with direct tools?**
- Yes: use direct tools (`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_to_integrations_agent` with `toolkit` set to the relevant slug (see the **Connected Integrations** section for the current list).
4. **Does this need other specialised execution?**
- 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.
5. **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**. 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** — but a live external-service request is *not* something to answer from memory, it requires the integration. Use `spawn_worker_thread` for long tasks that need their own thread.
## Rules
@@ -108,19 +112,33 @@ checking notion
"Q2 roadmap" — 3 bullets: ship auth, cut v0.4, hire designer
```
(`delegate_to_integrations_agent` with `toolkit: "notion"`. The user wants the live doc, not a memory summary.)
User: any new emails from alice today?
```text
checking gmail
one, 2pm: "lunch friday?", wants to grab food, no agenda
```
(`delegate_to_integrations_agent` with `toolkit: "gmail"`. Do **not** start with `memory_tree`; the user is asking about live inbox state.)
Short answers can skip the ack:
User: what time is it?
`7:31pm`
## Memory tree retrieval
## Memory tree retrieval (historical context only)
Use `memory_tree` with a `mode` argument to query the user's ingested email/chat/document history:
`memory_tree` queries the user's **already-ingested** email/chat/document history. It is a retrospective index, **not** a live API for connected services. If the user is asking what's in their inbox / calendar / docs *right now*, use `delegate_to_integrations_agent` instead (step 2 of the decision tree).
- `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.
Reach for `memory_tree` when the user asks about prior context that's already been summarised — "what did Alice and I discuss last month", "summarise my recent activity", "remind me what we decided on Q2 roadmap" — or when a live integration call has just failed and a stale answer is still useful.
Modes:
- `mode: "search_entities"` — resolve a name to a canonical id (e.g. "alice" → `email:alice@example.com`). Call this first when the user mentions someone by name *and* you've decided memory_tree is the right tool.
- `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_source"` — filter by `source_kind` (chat/email/document) and `time_window_days`. Use for retrospective "in my email last week…" intents — **not** for live "check my inbox" 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.
@@ -158,8 +158,12 @@ mod tests {
let body = build(&ctx_with(&[])).unwrap();
assert!(body.contains("## Delegation Decision Tree (Direct-First)"));
assert!(body.contains(
"Default bias: **do not spawn a sub-agent when a direct response or direct tool call is sufficient**."
"Default bias: **do not spawn a sub-agent when a direct response or direct tool call is sufficient**"
));
// Step 2 of the decision tree now explicitly routes live external-service
// requests to `delegate_to_integrations_agent` rather than `memory_tree`.
assert!(body.contains("Does the request name (or imply) a connected external service?"));
assert!(body.contains("Do this even if `memory_tree` could plausibly answer"));
}
#[test]
@@ -107,9 +107,8 @@ fn new_tree_id(kind: TreeKind) -> String {
/// sized for uniqueness across the file-system and Obsidian wikilink
/// namespaces.
pub fn new_summary_id(level: u32) -> String {
use rand::Rng;
let ms = chrono::Utc::now().timestamp_millis() as u64;
let rand_tail: u32 = rand::thread_rng().gen();
let rand_tail: u32 = rand::random();
format!("summary:{:013}:L{}-{:08x}", ms, level, rand_tail)
}
+2 -2
View File
@@ -209,9 +209,9 @@ fn generate_code() -> String {
/// on macOS). The 32 random bytes (256 bits) are hex-encoded for a
/// 64-character token, providing 256 bits of entropy.
fn generate_token() -> String {
use rand::RngCore;
use rand::RngExt as _;
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
rand::rng().fill(&mut bytes);
format!("zc_{}", hex::encode(bytes))
}
@@ -1,4 +1,4 @@
use rand::Rng;
use rand::RngExt;
use std::f64::consts::TAU;
#[derive(Debug, Clone, Copy)]
@@ -25,7 +25,7 @@ impl Default for HumanPathOptions {
}
/// Returns `(x, y, dwell_ms)` steps for a humanized cursor path.
pub fn human_path<R: Rng>(
pub fn human_path<R: RngExt>(
start: (i32, i32),
end: (i32, i32),
opts: &HumanPathOptions,
@@ -95,7 +95,7 @@ fn cubic_bezier(
)
}
fn dwell_ms<R: Rng>(opts: &HumanPathOptions, rng: &mut R) -> u64 {
fn dwell_ms<R: RngExt>(opts: &HumanPathOptions, rng: &mut R) -> u64 {
let mean = finite_or_default(opts.mean_step_ms, HumanPathOptions::default().mean_step_ms);
let stddev = finite_or_default(
opts.stddev_step_ms,
@@ -119,7 +119,7 @@ fn finite_or_default(value: f64, default: f64) -> f64 {
}
}
fn sample_normal<R: Rng>(mean: f64, stddev: f64, rng: &mut R) -> f64 {
fn sample_normal<R: RngExt + RngExt>(mean: f64, stddev: f64, rng: &mut R) -> f64 {
if stddev <= 0.0 {
return mean;
}
@@ -127,7 +127,7 @@ fn sample_normal<R: Rng>(mean: f64, stddev: f64, rng: &mut R) -> f64 {
.random::<f64>()
.clamp(f64::MIN_POSITIVE, 1.0 - f64::EPSILON);
let u2 = rng.random::<f64>();
let z0 = (-2.0 * u1.ln()).sqrt() * (TAU * u2).cos();
let z0 = (-2.0_f64 * u1.ln()).sqrt() * (TAU * u2).cos();
mean + z0 * stddev
}