Skip to content

feat(routing): sub-agent override as a libsy classifier, restored on the serve path - #145

Merged
linj-glitch merged 10 commits into
mainfrom
linj/switch-1068-combine-subagentoverride-classifier-with-affinity-routing
Jul 27, 2026
Merged

feat(routing): sub-agent override as a libsy classifier, restored on the serve path#145
linj-glitch merged 10 commits into
mainfrom
linj/switch-1068-combine-subagentoverride-classifier-with-affinity-routing

Conversation

@linj-glitch

@linj-glitch linj-glitch commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

What

Two halves of the same story: sub-agent routing becomes a composable classifier in libsy, and returns to the serve path where traffic actually flows.

libsySubagentOverride moves from an Algorithm combinator to a Classifier, so it composes with AffinityRouter in a FallThrough cascade:

FallThrough::new(targets)
    .with_component(Arc::new(AffinityRouter::for_subagents()))   // replay an existing pin
    .with_classifier(Arc::new(SubagentOverride::new("worker")))  // else seed one for delegated work
    .with_classifier(base)                                       // else route normally

serve — the subagent_target route envelope key returns, valid on any route type:

routes:
  qa-subagent-split:
    type: model
    target:          { model: aws/anthropic/bedrock-claude-opus-5 }
    subagent_target: { model: nvidia/nemotron-3-nano-30b-a3b }

Why

Closes SWITCH-1068. Per @GRClark: "I think SubagentOverride could be a classifier in libsy terms. That way we can easily combine affinity and override."

The two sat at different layers and could not be combined: SubagentOverride was Algorithm<()> while FallThrough is Algorithm<SharedState>, so wrapping did not typecheck. And the arrangement described earlier in that thread — override wrapping an inner algorithm with affinity inside — could not have worked even with matching types: the override short-circuited to driver.call_llm_target, so a sub-agent request never entered the inner algorithm, the processor chain never saw Event::Request, and affinity never latched. Sub-agent traffic was invisible to every stateful component. As sibling classifiers this resolves itself — FallThrough replays the winning decision to the processors, so the override's turn-1 choice is latched for free.

The serve half is a restoration, not a new feature. subagent_target shipped in #112 and was deleted by #119 ("nuke components-v2"), where it appears to be collateral rather than a decision — the commit targets the crate, not this feature. #112 also created libsy's subagent_override.rs, which survived; that asymmetry is why the classifier had no serve-path counterpart. Restoring it needs review from whoever drove #119 in case dropping it was intentional.

Measured on real traffic

Both halves were exercised end to end against four SWE-Atlas QnA tasks driven by Claude Code through Switchyard, closed-book. This also confirmed the thing every sub-agent key depends on: x-claude-code-agent-id does reach Switchyard, so is_subagent_request fires on live traffic rather than only in tests.

The serve-path arm below routes delegated work to an open-weights worker on a self-hosted vLLM deployment (subagent_target), against an otherwise identical single-model control:

Arm Requests Cost $/task mean agg_score reward
Opus 5 solo 527 $36.62 $9.15 0.962 3/4
Opus 5 + GLM-5.2 worker 506 $18.71 $4.68 0.962 3/4

49% cheaper at identical quality — same mean agg_score, same reward pass rate, and the only task below ceiling (grafana, 0.846) scored the same in both arms. 322 of 506 requests (64%) were served by the worker for $4.19, while the root model handled 184 for $14.52.

Traffic shape, which is what motivates pairing the override with affinity: the split arm spawned 17 sub-agents, none single-shot — median 39 turns, max 137. A 137-turn child under a re-scoring classifier is 137 routing decisions where one would do; affinity-first collapses that to 1 decision plus 136 replays.

Caveats, stated plainly: 4 tasks, one trial per arm, and three of the four sit at the 1.000 agg_score ceiling, so the quality claim is mostly "ceiling tasks stayed at ceiling". Spawn counts varied 5-17 across runs on identical inputs, so run variance is large relative to the differences. The cost direction is solid — a big effect with a visible mechanism — but the magnitude is indicative, not decision-grade.

Also observed: the routing log records one session_id per task covering parent and all descendants, with no agent_id field. Sub-agent routing is currently unobservable in our own telemetry, and it confirms why affinity keys on session + agent — keying on session alone would pin a whole task, parent and every descendant, to one model. Telemetry gap left for a follow-up.

How tested

  • cargo test green, cargo clippy --all-targets clean, cargo fmt --all
  • uv run ruff check . clean
  • uv run mypy switchyard clean (161 files)
  • uv run pytest tests/ --ignore=tests/e2e green (1954 passed, 9 skipped)
  • tests/e2e/test_classifier_planner_chain_e2e.py fails on a live-upstream 401 (LiteLLM Virtual Key expected. Received=nvap****). Pre-existing env key-format issue, unrelated.

New coverage:

crates/libsy/tests/subagent_affinity.rs (8 tests) — drives the cascade through the public API. The key one is a pin outliving the policy that seeded it: two cascades share one AffinityRouter but disagree on the worker, and the second override is never consulted. Paired with a negative control (independent routers ⇒ the second override does win), so it cannot pass for the wrong reason. Also: a Codex compact turn is sub-agent lineage but not delegated work, so it is keyed by affinity yet not forced to the worker; and dropping either classifier still leaves a valid cascade.

tests/test_subagent_routing.py (8 tests) — detection policy, runtime branching, and route-bundle wiring, including that an unknown envelope key is still rejected.

Notes for reviewers

One policy, two plumbings. The serve wrapper never sniffs headers: is_subagent_request is a 29-line binding over switchyard_protocol::Metadata::from_headers(..).is_subagent_work() — the same function libsy's classifier calls. So compact cannot count as delegated work in one engine and not the other. This is deliberately unlike the LLM classifier, where the libsy (390-line) and serve (2055-line) implementations have genuinely diverged.

Public API removed: the Python subagent_override(inner, worker) libsy binding is gone. A classifier scores a target name resolved from the cascade's LlmTargetSet, so the old (inner: Algorithm, worker: LlmTarget) shape is not representable, and libsy ships no terminal Classifier — a Python-built cascade would fail with "every classifier abstained" on root traffic. Zero consumers across Gym, log2-infra, nv-OpenHands, open_source_model_improvement, and switchyard-bench. Exposing composition to Python needs a terminal classifier; left to a follow-up.

FallThrough::with_component is new: AffinityRouter holds assignments on the instance, so both roles must be the same Arc. Registering them separately compiles, runs, and silently never latches. This makes that unrepresentable and matches what the affinity module docs already claimed.

Serve-path scope — one wrap, no per-type branching. The whole rule is three lines in _build_switchyard_for_route:

chain  = _build_route_chain(...)
worker = _subagent_worker_runtime(...)   # None when subagent_target is unset
return chain if worker is None else SubagentOverrideRuntime(chain, worker)

model, passthrough, stage_router, deterministic, and escalation_router all funnel through that site, so they get it for free. random_routing expands into its table entries on a separate path and so is unaffected — the key parses but does nothing there. That is deliberate: sub-agent logic branching on route_type was not worth the code, so there is none.

Skill drift, separate commit: switchyard-lib-core/SKILL.md documented SubagentOverrideProfile, switchyard/lib/profiles/loader.py, is_subagent_request, and crates/switchyard-components-v2/ — all removed by #119, so the skill was telling agents to set a YAML key that did nothing. Rewritten to match reality.

Benchmark artifacts included: five routing profiles under benchmark/routing-profiles/ (the qa-subagent-* ones are the usage documentation for subagent_target, including the cross-provider case where the worker carries its own base_url/api_key), plus benchmark/run_cost.py for per-model cost reporting. Cost-table additions: Claude Opus 5 at the published $5/$25 Opus-tier rate, and GLM-5.2 at third-party reference rates — that model runs on a self-hosted deployment with no per-token billing, so the entry answers "what would this traffic have cost at market rates", not what we are billed; provider rates vary about 2x. Run outputs and generated datasets are gitignored and not included.

Signed-off-by: Lin Jia <linj@nvidia.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
@linj-glitch
linj-glitch requested a review from a team as a code owner July 26, 2026 23:59
@linj-glitch
linj-glitch requested a review from messiaen July 27, 2026 00:00
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Sub-agent routing

Layer / File(s) Summary
Rust classifier and public API
crates/libsy/src/algorithms/...
Adds SubagentOverride, which scores delegated work to a fixed worker and abstains otherwise, and exposes it with AffinityRouter.
Cascade composition and affinity validation
crates/libsy/src/algorithms/fall_through.rs, crates/libsy/tests/subagent_affinity.rs
Adds dual-role FallThrough registration and tests override routing, affinity replay, fallback behavior, shared state, and child isolation.
Python API removal
crates/switchyard-py/src/libsy_bindings.rs, switchyard/libsy/algorithms.py, switchyard_rust/libsy.py, tests/test_libsy_minimal_bindings.py
Removes the Python sub-agent override binding, exports, helper, and corresponding test.
Routing guidance
.agents/skills/switchyard-lib-core/SKILL.md
Documents Rust classifier routing, metadata detection, cascade wiring, and affinity behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Poem

I’m a rabbit routing through the hay,
Sending child turns the proper way.
Rust scores workers, pins them tight,
Python steps aside from flight.
Affinity keeps each path in view—
Hop, hop, the cascade works anew!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: SubagentOverride became a libsy classifier and is used again on the serve path.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/libsy/tests/subagent_affinity.rs`:
- Around line 231-242: Update distinct_children_are_pinned_independently to seed
child-1 with the fixed worker override, then route child-2 through a
shared-affinity cascade whose override selects reviewer. Assert child-2 resolves
to reviewer and re-check child-1 still resolves to worker, exercising
independent sibling pinning rather than allowing both paths to pass via the same
override.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 64a8be41-03e9-407f-8ebc-1844ddd0a527

📥 Commits

Reviewing files that changed from the base of the PR and between 0a03235 and 2ff0518.

📒 Files selected for processing (12)
  • .agents/skills/switchyard-lib-core/SKILL.md
  • crates/libsy/src/algorithms.rs
  • crates/libsy/src/algorithms/fall_through.rs
  • crates/libsy/src/algorithms/subagent_override.rs
  • crates/libsy/src/algorithms/util.rs
  • crates/libsy/src/algorithms/util/affinity.rs
  • crates/libsy/src/algorithms/util/subagent.rs
  • crates/libsy/tests/subagent_affinity.rs
  • crates/switchyard-py/src/libsy_bindings.rs
  • switchyard/libsy/algorithms.py
  • switchyard_rust/libsy.py
  • tests/test_libsy_minimal_bindings.py
💤 Files with no reviewable changes (3)
  • crates/libsy/src/algorithms/util/affinity.rs
  • crates/libsy/src/algorithms/subagent_override.rs
  • tests/test_libsy_minimal_bindings.py

Comment thread crates/libsy/tests/subagent_affinity.rs
@linj-glitch linj-glitch changed the title refactor(libsy): make SubagentOverride a classifier composable with affinity feat(routing): sub-agent override as a libsy classifier, restored on the serve path Jul 27, 2026
@linj-glitch
linj-glitch force-pushed the linj/switch-1068-combine-subagentoverride-classifier-with-affinity-routing branch from 9def569 to 1a3ae7c Compare July 27, 2026 06:42
Signed-off-by: Lin Jia <linj@nvidia.com>
Comment thread benchmark/routing-profiles/qa-single-opus-5.yaml Outdated
@linj-glitch
linj-glitch force-pushed the linj/switch-1068-combine-subagentoverride-classifier-with-affinity-routing branch from 9933bed to 60f7024 Compare July 27, 2026 18:04
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

🚀 View preview at
https://NVIDIA-NeMo.github.io/Switchyard/pr-preview/pr-145/

Built to branch gh-pages at 2026-07-27 21:53 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@linj-glitch
linj-glitch enabled auto-merge (squash) July 27, 2026 21:53
@linj-glitch
linj-glitch merged commit 65beb85 into main Jul 27, 2026
20 checks passed
@linj-glitch
linj-glitch deleted the linj/switch-1068-combine-subagentoverride-classifier-with-affinity-routing branch July 27, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants