Skip to content

Subagent improvements: researcher subagent, cascade cancel, child tool allowlist#10256

Open
skanderkaroui wants to merge 23 commits into
mainfrom
agent-subagent-improvements
Open

Subagent improvements: researcher subagent, cascade cancel, child tool allowlist#10256
skanderkaroui wants to merge 23 commits into
mainfrom
agent-subagent-improvements

Conversation

@skanderkaroui

@skanderkaroui skanderkaroui commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Subagent improvements: researcher subagent, cascade cancel, child tool allowlist

Three independent changes adopting Claude Code subagent principles in the omi agent stack.

Changes

  1. feat(agent-cloud): researcher subagent — declares a researcher subagent (SDK agents option) restricted to execute_sql / semantic_search / get_daily_recap. Broad data exploration runs in a disposable child context and returns distilled findings, keeping raw rows out of the main session.
  2. fix(desktop): cascade run cancellation to delegated child agentscancelRun now cancels non-terminal delegated children (delegations parent_run_id link), recursively, best-effort, after the parent's own cancellation is persisted. Background agents (no delegation row) are deliberately not cascaded — they are user-visible floating-bar work designed to outlive the parent turn (pinned by regression test).
  3. feat(desktop): spawn-time tool allowlist for children — optional toolPolicy.allowedToolNames on spawn_agent / spawn_background_agent / delegateAgent, carried in run metadata, enforced by intersection at capability registration in the broker. Empty or malformed policy fails closed. Narrow-only semantics.

Product invariants

  • INV-AGENT-* (agent control-plane): touched desktop/macos/agent/src/runtime/**. Lifecycle section affected by the cascade (guard tests updated: tests/cancellation.test.ts — 3 new behavioral cases; tests/run-tool-capability.test.ts — 3 new toolPolicy cases; tests/control-tools.test.ts — spawn schema cases; new tests/spawn-tool-policy.test.ts). The toolPolicy change strengthens "leaf workers get no agent-management tools": policies can only narrow the broker-computed set, never widen it; capability checks remain kernel-internal.
  • INV-CHAT-1 (chat continuity): matched path-glob only (regenerated Desktop/Sources/Generated/* via generate-tool-surfaces.mjs); no chat write-path, projection, or timeline behavior changed.

Failure-Class: none

Verification

  • desktop/macos/agent: npm test 49 files / 587 pass; npx tsc --noEmit clean; cascade regression test verified to fail on unfixed code.
  • desktop/macos && ./scripts/agent-logic-harness.sh: passed (Swift lifecycle/state, agent runtime focused, pi-mono exact package; 129s).
  • agent-cloud: npm test 3/3 (first tests in the package); live one-shot CLI run against a seeded local DB copy showed the model delegating to the researcher and returning distilled findings (no raw rows).

Round 2 — measured optimization loop (4 further commits)

Driven by an instrumented evaluation loop on a real 27MB database copy (~40 live runs + session simulations; experiment infra external to this repo):

  1. perf: researcher tuning — haiku pin (fastest + ~3x cheaper, accuracy parity), row-heavy delegation routing, range-based recap wording. Includes tested negative results (batch prompting and prompt-level turn discipline are model-dependent — rejected).
  2. feat: WS streaming contract + hermetic e2eincludePartialMessages was never enabled (all streaming code was dead; measured 17-46s per-turn client silence). Subagent progress surfaces as subagent:* tool_activity; internal subagent text can never leak (canary-asserted). Fixes a prewarm race that made Stop a silent no-op on fast first queries. Adds the scripted-SDK e2e harness and registers agent-cloud-tests in the CI check manifest (first CI coverage for this package).
  3. fix: session survives WS reconnects — reconnect amnesia reproduced live ("I don't have any earlier conversation context") and fixed: server-scope session with swappable sink, session_state hello, stale-socket detach race handled. Proxy follow-up noted for history injection keying.
  4. perf: data-tool reshapes + silent-gap ceilingget_daily_recap gains explicit date ranges (was anchored to "now" — root cause of 13+ SQL-turn fallbacks), authoritative empty-range statements, hourly timeline/top windows; new get_app_usage day-by-app matrix tool; task near-duplicate guidance; 10s turn heartbeat; real-SDK interrupts map to interrupted: true partial results instead of errors.

End-to-end validated deltas (same 8-turn conversation, real data): wall 256s → 155s (−39%), cost $0.73 → $0.33 (−55%), worst silent gap 46s → 13s (typical 1–4s), stop → marked partial, reconnect → context retained. Task battery: −62% wall, −62% tool calls, correctness verified against ground truth. Suite: 15/15 (4 files) via desktop/macos/agent-cloud/scripts/run-tests.sh; full preflight 43/43.

Round 3 — honest chat error messages + bounded error telemetry (desktop)

Driven by a PostHog deep dive into the opaque "Something went wrong" bucket (401 events / 108 users / 30 days): raw_error decomposes it entirely (exhausted Anthropic credits 117, OAuth timeout 29, expired tokens 23, runtime crashes 23, connection 12, oversized payloads 10), and the three "unexplained" error spikes were retry storms caused by the copy itself — users with exhausted credits obeying "please try again".

  • AgentErrorClassifier: bounded classification (12 codes) with honest copy and retryable verdicts; "try again" only when a retry can help. Bridge copy routes through it.
  • chat_agent_error gains bounded error_code, retryable, failure_code, failure_source, adapter_id, provider (the daemon's typed envelope was parsed then dropped). Closed vocabularies only — never raw exception text (test-pinned).
  • 11 new behavioral tests; changelog fragment; line-count ratchet reconciled (ChatProvider compacted, AgentBridge shrink recorded).
  • Also unwedges main: the d9947b5 refactor left two files with swift-format drift, and fixing DashboardPage adds a line its frozen ratchet baseline rejected — both files formatted + justified 4544 baseline record ride along.
  • Invariants: INV-AUTH-1 and INV-CHAT-1 both path-matched only — no session-death or chat write-path behavior changed.

Verification: swift build green; AgentErrorClassifierTests + ChatQueryTelemetryTests 34/34; check_desktop_test_quality at baseline; full preflight 20/20 on the source branch and re-run green after this merge.

…exploration

Declare a 'researcher' subagent via the Claude Agent SDK agents option,
restricted to the three omi-tools data tools (execute_sql, semantic_search,
get_daily_recap). Broad/exploratory data questions now run in a disposable
child context that returns distilled findings, keeping raw query rows out of
the main session's context window — the main driver of long-session
degradation. Task added to allowedTools (required for subagent invocation);
option assembly extracted to query-config.mjs so the definitions are unit
testable (first tests in agent-cloud).

Verification:
- npm test (agent-cloud): 3/3 pass (researcher tool allowlist, schema in
  prompt, Task in allowedTools).
- Live one-shot CLI run against a seeded local omi.db copy (120 screenshots,
  3 action items): model output began "I'll delegate this to the researcher
  agent", returned correct distilled findings (60/30/30 app split, rotation
  pattern, open tasks) with no raw rows. SDK 0.2.41 accepted the agents
  option end-to-end. Note: CLI-mode tool_activity logging does not surface
  subagent tool calls; delegation evidenced by model output and cost profile.
cancelRun only cancelled its target: delegated (run-and-wait) children kept
running as orphans after the user stopped the parent. The delegations
parent_run_id link existed but nothing traversed it on cancel — only the
mid-batch spawn-failure path (control-tools sibling cleanup) reaped children.

cancelRun now cascades best-effort to every delegated child after the
parent's own cancellation is persisted and dispatched. Recursion covers
grandchildren; terminal children hit the existing accepted:false early-return
(idempotent). Delegation rows stay untouched — executeDelegationAsync settles
them to "cancelled" when the child's execution resolves, so the parent's
pending await resolves normally with no double-settle.

Background agents spawned via spawn_background_agent have no delegation row
and are deliberately NOT cascaded: they are user-visible floating-bar work
designed to outlive the parent turn (pinned by regression test).

Intentional edges: cancelling an already-terminal parent does not reap
still-running spawn-mode children (early-return precedes cascade); the
multi-spawn failure cleanup now cascades transitively through cancelled
delegated siblings.

Failure-Class: none

Verification:
- npx vitest run tests/cancellation.test.ts: 5/5 pass (3 new: in-flight
  delegated child cascades with single delegation settle; terminal child
  untouched; background agent not cancelled).
- Confirmed the new cascade test fails on the unfixed kernel-runs.ts
  (adapter.cancelled length 1 vs expected 2) and passes with the fix.
- npx tsc --noEmit: clean.
Add optional toolPolicy.allowedToolNames to SpawnBackgroundAgentInput and
DelegateAgentInput. The policy travels in run metadata JSON (already
persisted by both spawn paths and already parsed by the capability broker —
no schema change) and is enforced at capability registration: the broker
intersects it with the role/adapter-computed tool set before freezing
allowedToolNames, so every authorize() call is covered. Empty or malformed
policy fails closed to no tools. Restriction-only semantics: a policy can
narrow, never widen, the child's tool set. Exposed on the spawn_agent and
spawn_background_agent control-tool schemas and manifests so the coordinator
model can restrict children (Claude Code principle: specialized subagents
with reduced toolsets). run_agent_and_wait deliberately skipped until needed.

Generated tool surfaces regenerated via agent/scripts/generate-tool-surfaces.mjs
(GeneratedToolCapabilities.swift, GeneratedToolExecutors.swift, tool-manifest
fixture).

Verification:
- npm test (desktop/macos/agent): 49 files, 587/587 pass, including new
  cases: broker intersection honored (get_memories allowed, search_memories
  tool_not_allowed), empty/malformed policy fails closed, policy persisted
  through both spawn paths, absent policy leaves behavior unchanged, both
  spawn schemas accept valid toolPolicy and reject malformed shapes.
- npx tsc --noEmit: clean.
- Note: runtime-stdio-contract tests require a built dist/ (fresh-worktree
  timeout is environmental, passes after npm run build; verified unrelated
  to this change by stash-testing).
Evidence (2026-07-21, 26 instrumented runs on the mini against a real 27MB
omi.db copy — 5,110 screenshots, 305 open tasks, 1,059 memories):
- haiku researcher: fastest (44s vs 64s inherited-Opus forced runs) and
  ~3x cheaper ($0.13 vs $0.44 on content recall) with equal-or-better
  accuracy (pinned the correct last-seen date where baseline was off by one).
- Delegation tax (~20s) only pays off on row-heavy tasks; natural delegation
  correctly fired on patterns/triage/recall and stayed home on quick lookups.
- Worst observed pathology: researcher called get_daily_recap once per day
  (12-turn run). Prompt-level turn-discipline instructions were tested and
  are model-dependent (Opus obeys, haiku gets worse) — rejected; fixing the
  tool description is model-independent.

Changes: pin researcher model to haiku; route delegation by row-volume in
both the agent description and the main-prompt guideline; state in both the
tool description and researcher prompt that one get_daily_recap call covers
any date range.

Verification: npm test 3/3 (model + range-wording pinned); full 8-turn
session sim on real data ran the researcher config 23% faster and 22%
cheaper than baseline with parity accuracy (results in PR discussion).
… progress, interrupted partials — with hermetic e2e

Root cause found by instrumented session sims on real data: agent.mjs never
set includePartialMessages, so its entire stream_event handling was dead code
— no token ever streamed, text arrived only as whole assistant messages, and
every turn contained 17-46s of total client silence (measured; the mechanism
behind 'Response stopped' churn). Enabling partials cut measured max silent
gaps to ~13s before the mini link dropped mid-validation; the durable guard
is the new hermetic e2e below.

Changes:
- includePartialMessages: true in both query option blocks; the existing
  delta/dedup handling was already written for it.
- stream-routing.mjs: subagent-origin messages (parent_tool_use_id) surface
  tool starts as 'subagent:<name>' tool_activity and never leak internal text
  into the answer stream.
- result messages carry interrupted: true when a stop/preemption cut the turn
  short — clients can finally distinguish partials from complete answers.
- Fix (found by the e2e): the prewarm-completion branch reset turnActive,
  clobbering a query that arrived before prewarm finished and turning that
  turn's stop into a silent no-op. Prewarm never sets turnActive; it no
  longer clears it.
- Controllable SDK seam (OMI_AGENT_SDK_MODULE) + OMI_AGENT_DISABLE_UPDATES
  gate so the server runs hermetically under test.
- tests/agent-ws-e2e.test.mjs: spawns the real server with a scripted SDK
  fixture, drives a real WebSocket, asserts: text streams as multiple deltas,
  subagent progress surfaces, the SECRET-INTERNAL leak canary never reaches
  the client, and stopped turns return interrupted: true with partial text.
- agent-cloud-tests registered in .github/checks-manifest.yaml (local + ci
  lanes) — also puts the existing unit tests under CI for the first time.

Verification: bash desktop/macos/agent-cloud/scripts/run-tests.sh from clean
node_modules — 3 files, 8/8 pass (unit + e2e). python3
.github/scripts/test_run_checks.py — manifest contract OK. Live partial
validation on the mini (2 turns before network drop): max_gap 20-46s → ~13s,
subagent tool events now visible to the client.
…ntact

Reconnect amnesia, reproduced live on real data before this fix: stop a
turn, reconnect (what the phone does after backgrounding), ask "which of
those task themes you listed earlier had the most items?" — the agent
replied "I don't have any earlier conversation context here". The session
was owned by the WS connection and torn down on close.

The VM is single-user, so the persistent session now lives at server scope
with a swappable client sink: disconnect detaches the sink (in-flight turns
keep running; their result text survives for the reattached client),
reconnect reattaches, and a session_state hello tells the client whether a
live session with context exists. detachSink only clears its own socket's
sink — a stale close event firing after a newer socket attached must not
silence the fresh connection (race caught by the e2e while writing it).

Follow-up (proxy-side, separate PR): agent-proxy injects last-10-message
history on each connection's first query; with surviving sessions that
becomes redundant on reconnect — the session_state hello is the signal it
should key on.

Failure-Class: none

Verification: npm test — 9/9 including the new e2e case: turn counter
proves the same SDK session consumes queries across a disconnect/reconnect
(a fresh session would restart the count); hello reports active:true.
…l-battery evidence

Evidence loop (2026-07-22, 18 live runs + 2 session sims on a real 27MB DB):
round 1 exposed four waste patterns; round 2 after these changes measured
wall −62%, tool calls 53→20, cost −33% across the affected tasks with
correctness verified against ground truth (dupes 297/8 exact, Jul-14 4x
activity ratio, honest no-data answer, Warp 37% share).

Tool reshapes (new data-tools.mjs, unit-tested, injection-safe dates):
- get_daily_recap accepts start_date/end_date — it could only anchor to
  "now" (days_ago), so date-specific recaps fell back to 13+ hand-rolled SQL
  turns (82s→29s, 17→2 tools). Short ranges add an hourly timeline and top
  window titles.
- Empty ranges return an authoritative zero-activity statement — an
  empty-ish reply previously triggered 7 verification queries (39s→21s).
- New get_app_usage: day-by-app matrix in one call, replacing per-day
  GROUP BY spelunking (compare task 73s→44s, 16→3 tools). Researcher gets it.
- Task near-duplicate guidance (COUNT(DISTINCT description)) — the store
  carries ~63% near-duplicate task extractions; the dupes task dropped
  221s→28s while matching exact ground truth.
- Documented negative result: the ~2 ToolSearch turns per conversation are
  SDK schema deferral; allowedTools naming does not preload (tested).

WS contract (session sims on fixed streaming code):
- Turn heartbeat (OMI_TURN_HEARTBEAT_MS, default 10s): light turns now gap
  1-3s but delegated turns still went 40-64s silent while subagent text is
  (deliberately) swallowed — a periodic status caps the client-visible gap
  regardless of source.
- Real-SDK interrupts terminalize with a non-success subtype; a user stop
  previously surfaced as an error. Non-success + interruptRequested now maps
  to an interrupted:true partial result. Fixture models the real subtype.

Also validated this cycle: reconnect memory probe answers from context in
5.5s with zero tool calls (was: total amnesia).

Verification: npm test — 4 files, 15/15 (data-tools unit tests; e2e asserts
heartbeats during held turns and interrupted partials). Battery and sim
logs: scratchpad eval/ (round1 vs round2 TSVs).
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Thanks for the careful work here — the direction looks useful and the focused regression coverage is strong.

I reviewed this as an agent-control/agent-behavior change. The important maintainer consequence is that this PR changes how Omi agents are instructed and constrained:

  • the cloud agent can now invoke a researcher subagent for broad database exploration, with that subagent restricted to the Omi data tools;
  • spawned/delegated children can receive a narrow-only toolPolicy.allowedToolNames allowlist, enforced at capability registration;
  • cancelling a parent run now cascades to delegated child runs, while user-visible background agents intentionally continue.

That all looks conceptually safe from this pass: the tool policy intersects with the existing role/adapter allowlist rather than widening it, malformed persisted policy fails closed, and the cancellation behavior is covered by regression tests. I also ran the targeted tests locally:

  • desktop/macos/agent: npm test -- tests/cancellation.test.ts tests/control-tools.test.ts tests/run-tool-capability.test.ts tests/spawn-tool-policy.test.ts → 113 passed
  • desktop/macos/agent-cloud: npm test -- tests/query-config.test.mjs → 3 passed

I’m leaving this as a positive signal rather than formal approval because it still warrants human maintainer sign-off before merge: it changes agent behavior/control-plane semantics, touches a lockfile/dependency surface, and GitHub still shows blocked/pending/cancelled checks. Once the queued/cancelled CI state is resolved, this looks like a good candidate for maintainer review.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added positive-signal Good PR — positive signal, not a formal approval dependency-review Touches dependencies or lockfiles; needs dependency review needs-maintainer-review Needs a human maintainer to sign off before merge labels Jul 22, 2026
Rule table over the observed 30-day chat_agent_error corpus: billing
exhaustion, auth expiry, OAuth timeout, connection, oversized payload,
runtime crash, tool-schema rejection, rate limit/overload, local data
errors. Each class carries honest user copy and a retryable verdict —
the opaque bucket's top cause (exhausted Anthropic credits, 117/401
events) was being told 'please try again', producing measured retry
storms (32 events from 2 users on Jul 6 alone).

Verification: AgentErrorClassifierTests (9 behavioral cases, committed
with the test file later in this series); full build green.
userFacingAgentErrorMessage delegates to AgentErrorClassifier — copy
and retry advice now come from one bounded table instead of ad-hoc
substring checks that collapsed most causes into 'Something went wrong.
Please try again.'

Verification: build green; behavior pinned by AgentErrorClassifierTests.
…etry

chat_agent_error carried only the coarse error_class enum; the daemon's
typed failure envelope (failureCode/source/adapterId/provider/retryable)
was parsed into AgentRuntimeFailure and then dropped, which is why the
churn dashboard needed raw-string archaeology. The failed event now
threads an optional ChatQueryErrorDetail: error_code (classifier code or
daemon failureCode), retryable, failure_code, failure_source, adapter_id,
provider — all closed vocabularies, never raw exception text.

Note: enum case gains a slot, so this commit compiles only with the two
follow-up call-site commits (per-file commit series per maintainer
request; series tip is build- and test-green).

Verification: ChatQueryTelemetryTests 25/25 incl. new payload tests
asserting bounded properties and no raw-text leakage.
Pattern-match gains the detail slot; no behavior change.

Verification: build green at series tip.
The disposition-classify site has the full Error in hand — derive
ChatQueryErrorDetail.from(error) so agentError strings and runtime
failure envelopes reach telemetry as bounded dimensions.

Verification: build green; payload pinned by ChatQueryTelemetryTests.
Nine cases mirroring the observed error corpus: billing exhaustion must
not say 'try again', auth expiry routes to reconnect, connection and
runtime crashes are retryable, local data errors are classified for bug
tracking, tool-schema rejections don't blame the user, unknown preserves
readable messages.
Asserts error_code/retryable reach the payload, daemon taxonomy fields
survive from AgentRuntimeFailure, and raw exception text never enters
analytics properties.
Collapse the detail-threading call to one line (fits the 120-col limit)
instead of raising the 6214-line ratchet baseline.
AgentBridge.swift shrank when the ad-hoc error heuristics moved to the
classifier; the down-only ratchet records the new ceiling.
Error copy for chat failures is exercised by the same hermetic chat
flow that covers AgentBridge and ChatQueryTelemetry.
Pre-existing drift on main (blocks every branch's swift-format-lint
preflight); formatted with the pinned wrapper, no behavior change.
…tchet

The pinned swift-format output for the drifted file is 4544 lines; the
4543 baseline froze the unformatted shape, wedging swift-format-lint
against the ratchet for anyone touching the file. Formatter shape, not
feature growth.
Second drifted file from the same upstream refactor (d9947b5);
formatted with the pinned wrapper, no behavior change. Full lint-scope
is clean after this.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependency-review Touches dependencies or lockfiles; needs dependency review needs-maintainer-review Needs a human maintainer to sign off before merge positive-signal Good PR — positive signal, not a formal approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants