Skip to content

feat(libsy): add pluggable routing affinity#50

Open
ayushag-nv wants to merge 6 commits into
mainfrom
agent/subagent-aware-routing
Open

feat(libsy): add pluggable routing affinity#50
ayushag-nv wants to merge 6 commits into
mainfrom
agent/subagent-aware-routing

Conversation

@ayushag-nv

@ayushag-nv ayushag-nv commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Why

Sub-agent routing should be an optional persistence policy, not a separate routing algorithm. A normal algorithm should select a target on the first eligible request, then reuse that selection for the same stable identity. Root-agent traffic and algorithms without affinity must preserve their existing behavior.

What

  • replace the classifier-specific AgentAwareOrchAlgo POC with a public, reusable Affinity trait
  • add process-local SessionAffinity and SubAgentAffinity policies with bounded first-writer-wins state
  • add explicit AgentContext::is_subagent metadata
  • normalize Codex, NeMo Relay, Dynamo, and explicit x-switchyard-* lineage headers without adding a runtime dependency
  • let RandomAlgo optionally retain its final selection through any Affinity implementation
  • keep root-agent requests random per turn when SubAgentAffinity is enabled
  • fast-path a single configured random-routing target and retain it for subsequent child-agent turns
  • keep NoopAlgo unchanged because it does not select a routable model
  • update the libsy proxy demo, public docs, and focused tests

The affinity state is bounded to 4,096 process-local assignments. SubAgentAffinity keys by (session_id, agent_id) and intentionally excludes task_id, so one child remains on the same model as its task/turn metadata changes.

How

Affinity owns only request eligibility, stable key construction, and retained state. Algorithms remain responsible for model selection. On an affinity miss, RandomAlgo runs its normal selection, atomically retains the first winner, and routes to the canonical retained target. On a hit, it bypasses reselection. Requests the policy cannot key continue through ordinary random routing.

AffinityKey is namespaced and public, while AffinityState provides the reusable bounded storage. The integration contract test implements a custom task-based affinity policy outside the libsy crate to verify the extension point is usable by downstream libraries.

Header normalization remains a separate adapter. Explicit x-switchyard-* headers take precedence; Codex, Relay, and Dynamo headers are converted into neutral libsy metadata. No direct NeMo Relay dependency is required.

Try it

Start the buffered demo against NVIDIA Inference Hub:

export INFERENCE_HUB_SY_API_KEY="nvapi-..."
ANTHROPIC_API_KEY="$INFERENCE_HUB_SY_API_KEY" cargo run -p libsy-proxy

Send the first turn for a child agent:

curl -i http://127.0.0.1:4000/v1/chat/completions \
  -H 'content-type: application/json' \
  -H 'session-id: root-session-1' \
  -H 'thread-id: child-thread-1' \
  -H 'x-codex-parent-thread-id: root-thread-1' \
  -H 'x-openai-subagent: collab_spawn' \
  -H 'x-switchyard-task-id: task-1' \
  -H 'x-codex-turn-metadata: {"session_id":"root-session-1","thread_id":"child-thread-1","parent_thread_id":"root-thread-1","turn_id":"turn-1","subagent_kind":"collab_spawn"}' \
  -d '{"model":"libsy-random-affinity","messages":[{"role":"user","content":"Reply with exactly AFFINITY_E2E_OK"}],"stream":false}'

The first response reports the provider model and a rationale like:

x-model-router-selected-model: <provider-model-id>
x-model-router-selected-tier: <logical-target>
x-model-router-version: libsy-random-affinity-v1
x-model-router-rationale: random routing selected and retained target '<logical-target>'

Repeat the request with the same session-id and thread-id, but change x-switchyard-task-id to task-2 and the turn metadata to turn-2. The response should use the same model and report:

x-model-router-rationale: affinity reused target '<logical-target>'

The demo currently requires "stream": false; adapting an unmodified streaming Codex CLI remains outside this focused change.

Where to Start Review

  1. crates/libsy/src/affinity.rs — public trait, state, built-in policies, and header normalization
  2. crates/libsy/src/algorithms/rand.rs — optional affinity at the final selection point
  3. crates/libsy/tests/affinity.rs — downstream custom-policy contract
  4. demo/libsy-proxy/src/main.rs — live HTTP/provider wiring
  5. crates/libsy-protocol/src/envelope.rs — explicit child-agent signal

Test Plan

  • cargo fmt --all --check — pass
  • cargo clippy --workspace --all-targets -- -D warnings — pass
  • cargo test --workspace — pass
  • uv run ruff check . — pass
  • focused live NVIDIA Inference Hub e2e through libsy-proxy — pass:
    • turn 1 returned HTTP 200 and reported random routing selected and retained
    • turn 2 used the same provider model across changed task/turn metadata and reported affinity reused
  • broad tests/e2e/ run — incomplete: 17 passed, then 5 setup errors reached --maxfail=5; 21 tests did not run because the existing passthrough fixture invokes the removed switchyard passthrough CLI command

The single-target behavior is covered by single_target_subagent_is_retained_without_reselection; the live demo currently uses its two configured provider targets.

Summary by CodeRabbit

  • New Features

    • Added reusable session and sub-agent model affinity policies.
    • Random routing can now retain model selections across related requests.
    • Added metadata normalization for common harness and provider headers.
    • Added an affinity-aware proxy demonstration supporting multiple request formats.
    • Added agent context details for improved routing and lineage tracking.
  • Documentation

    • Documented affinity behavior, supported metadata headers, proxy usage, and current limitations.
  • Tests

    • Added coverage for session, sub-agent, concurrent, and custom affinity behavior.

@ayushag-nv
ayushag-nv force-pushed the agent/subagent-aware-routing branch from 85d291e to 24264aa Compare July 10, 2026 08:04
@messiaen messiaen self-assigned this Jul 10, 2026
@messiaen
messiaen self-requested a review July 10, 2026 11:57
@messiaen messiaen assigned ayushag-nv and unassigned messiaen Jul 10, 2026
@messiaen
messiaen force-pushed the grclark/feat/libsy-routers-poc branch from 1df67dc to aa72aa0 Compare July 10, 2026 17:06
@linj-glitch linj-glitch self-assigned this Jul 11, 2026
@messiaen
messiaen force-pushed the grclark/feat/libsy-routers-poc branch from fcdc0ad to eab29f6 Compare July 14, 2026 14:20
Base automatically changed from grclark/feat/libsy-routers-poc to main July 14, 2026 14:28
@ayushag-nv
ayushag-nv force-pushed the agent/subagent-aware-routing branch 2 times, most recently from 55e2f0f to 36d53c3 Compare July 16, 2026 21:42
@ayushag-nv ayushag-nv changed the title feat(libsy): add sub-agent-aware model pool routing feat(libsy): add pluggable routing affinity Jul 16, 2026
/// Id of the task the request belongs to.
pub task_id: Option<String>,
/// Agent-specific lineage and semantic routing signals.
pub agent_context: Option<Box<AgentContext>>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why box?


/// Namespaced stable identity used to retain a model assignment.
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct AffinityKey {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i believe there's another rust piece for affinity, keep either one and delete the other

// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Demo proxy combining Switchyard HTTP/translation with libsy random routing and affinity.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

all of this can be under libsy/bin or libsy/example

}

#[async_trait]
impl Profile for LibsyAffinityProfile {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

lets forget anything Profile*

ayushag-nv and others added 6 commits July 17, 2026 10:01
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: Greg Clark <grclark@nvidia.com>
@messiaen
messiaen force-pushed the agent/subagent-aware-routing branch from 924763d to adfc7bc Compare July 17, 2026 14:09
@ayushag-nv
ayushag-nv marked this pull request as ready for review July 17, 2026 20:19
@ayushag-nv
ayushag-nv requested a review from a team as a code owner July 17, 2026 20:19
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Affinity Routing

Layer / File(s) Summary
Metadata and affinity policies
crates/libsy-protocol/src/envelope.rs, crates/libsy/src/affinity.rs, crates/libsy/src/lib.rs, crates/libsy/tests/affinity.rs, crates/libsy/README.md
Adds AgentContext, header normalization, session and sub-agent affinity policies, bounded assignment state, public exports, documentation, and contract tests.
Random router affinity integration
crates/libsy/src/algorithms/rand.rs, crates/libsy/README.md
Extends RandomAlgo with optional affinity-based selection, retention, concurrency handling, and coverage for session and sub-agent behavior.
Affinity-aware proxy execution
Cargo.toml, demo/libsy-proxy/*
Adds the workspace demo, translating requests through Switchyard, routing with SubAgentAffinity, calling the upstream backend, and returning routing metadata.

CI Toolchain Updates

Layer / File(s) Summary
Pinned uv workflow configuration
.github/workflows/ci.yml, .github/workflows/perf.yml
Pins setup-uv to version 0.11.29 across CI and performance jobs.

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

Poem

I’m a rabbit routing dreams through the night,
Keeping each agent’s model just right.
Headers bloom, affinities bind,
Proxy hops neatly aligned.
“Hop!” says the hare, “the tests all shine!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.54% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: introducing pluggable routing affinity in libsy.
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.

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: 2

🧹 Nitpick comments (1)
crates/libsy/src/algorithms/rand.rs (1)

427-448: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Force both requests to miss before either retains.

This test allows the first future to complete retention before the second is polled, so it can pass even if first-writer atomicity regresses. Use a barrier with different proposals, then assert both callers receive one canonical assignment.

As per coding guidelines, “Add concise Rust comments or /// documentation for important behavior-encoding tests and complex concurrency logic.”

🤖 Prompt for 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.

In `@crates/libsy/src/algorithms/rand.rs` around lines 427 - 448, Strengthen
concurrent_first_subagent_turns_use_one_assignment by synchronizing both
requests with a barrier so they miss before either retains its proposal, using
different proposed models. Assert both completed requests receive the same
canonical selected_model, and add a concise Rust comment documenting the
first-writer atomicity behavior being tested.

Source: Coding guidelines

🤖 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/README.md`:
- Around line 50-57: Add the missing Arc and Algorithm imports to the README
Rust example so the snippet compiles as shown, while preserving the existing
RandomAlgo and SubAgentAffinity imports and usage.

In `@demo/libsy-proxy/src/main.rs`:
- Around line 123-141: Update libsy_request to validate and reject requests with
stream: true before decoding or forwarding them upstream. Return the existing
invalid-request error type with a clear streaming-unsupported message, while
preserving the current non-streaming request construction path.

---

Nitpick comments:
In `@crates/libsy/src/algorithms/rand.rs`:
- Around line 427-448: Strengthen
concurrent_first_subagent_turns_use_one_assignment by synchronizing both
requests with a barrier so they miss before either retains its proposal, using
different proposed models. Assert both completed requests receive the same
canonical selected_model, and add a concise Rust comment documenting the
first-writer atomicity behavior being tested.
🪄 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: 9157671b-adad-47d6-877d-ecb42423c775

📥 Commits

Reviewing files that changed from the base of the PR and between 694aa27 and adfc7bc.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock
📒 Files selected for processing (13)
  • .github/workflows/ci.yml
  • .github/workflows/perf.yml
  • Cargo.toml
  • crates/libsy-protocol/src/envelope.rs
  • crates/libsy/Cargo.toml
  • crates/libsy/README.md
  • crates/libsy/src/affinity.rs
  • crates/libsy/src/algorithms/rand.rs
  • crates/libsy/src/lib.rs
  • crates/libsy/tests/affinity.rs
  • demo/libsy-proxy/Cargo.toml
  • demo/libsy-proxy/README.md
  • demo/libsy-proxy/src/main.rs

Comment thread crates/libsy/README.md
Comment on lines +50 to +57
```rust
use libsy::affinity::SubAgentAffinity;
use libsy::RandomAlgo;

let algo: Arc<dyn Algorithm> = Arc::new(
RandomAlgo::new(targets)
.with_affinity(Arc::new(SubAgentAffinity::new())),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="crates/libsy/README.md"

# Show the relevant section with line numbers
sed -n '1,120p' "$FILE" | nl -ba | sed -n '1,120p'

Repository: NVIDIA-NeMo/Switchyard

Length of output: 200


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="crates/libsy/README.md"

awk 'NR>=1 && NR<=120 { printf "%4d  %s\n", NR, $0 }' "$FILE"

Repository: NVIDIA-NeMo/Switchyard

Length of output: 6391


Add the missing imports to the example
Arc and Algorithm are used in the snippet but not imported, so the example does not compile as shown.

🤖 Prompt for 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.

In `@crates/libsy/README.md` around lines 50 - 57, Add the missing Arc and
Algorithm imports to the README Rust example so the snippet compiles as shown,
while preserving the existing RandomAlgo and SubAgentAffinity imports and usage.

Comment on lines +123 to +141
fn libsy_request(input: &ProfileInput, translation: &TranslationEngine) -> Result<Request> {
let format = wire_format(input.request.request_type());
let decoded = translation
.decode_request(format, input.request.body(), &TranslationPolicy::default())
.map_err(|error| SwitchyardError::InvalidRequest(error.to_string()))?;
let mut metadata = metadata_from_headers(&input.metadata.headers);
metadata
.extra_metadata
.get_or_insert_with(Default::default)
.insert(
"inbound_format".to_string(),
inbound_format(input.request.request_type()).to_string(),
);

Ok(Request {
llm_request: decoded.request,
raw_request: Some(input.request.body().clone()),
metadata: Some(metadata),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject streaming before making the upstream call.

A request with stream: true is forwarded, but Line 71 later requires a buffered body, producing an avoidable server error after external work has begun.

Proposed validation
     let decoded = translation
         .decode_request(format, input.request.body(), &TranslationPolicy::default())
         .map_err(|error| SwitchyardError::InvalidRequest(error.to_string()))?;
+    if decoded.request.stream {
+        return Err(SwitchyardError::InvalidRequest(
+            "libsy-proxy currently supports buffered requests only".to_string(),
+        ));
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn libsy_request(input: &ProfileInput, translation: &TranslationEngine) -> Result<Request> {
let format = wire_format(input.request.request_type());
let decoded = translation
.decode_request(format, input.request.body(), &TranslationPolicy::default())
.map_err(|error| SwitchyardError::InvalidRequest(error.to_string()))?;
let mut metadata = metadata_from_headers(&input.metadata.headers);
metadata
.extra_metadata
.get_or_insert_with(Default::default)
.insert(
"inbound_format".to_string(),
inbound_format(input.request.request_type()).to_string(),
);
Ok(Request {
llm_request: decoded.request,
raw_request: Some(input.request.body().clone()),
metadata: Some(metadata),
})
fn libsy_request(input: &ProfileInput, translation: &TranslationEngine) -> Result<Request> {
let format = wire_format(input.request.request_type());
let decoded = translation
.decode_request(format, input.request.body(), &TranslationPolicy::default())
.map_err(|error| SwitchyardError::InvalidRequest(error.to_string()))?;
if decoded.request.stream {
return Err(SwitchyardError::InvalidRequest(
"libsy-proxy currently supports buffered requests only".to_string(),
));
}
let mut metadata = metadata_from_headers(&input.metadata.headers);
metadata
.extra_metadata
.get_or_insert_with(Default::default)
.insert(
"inbound_format".to_string(),
inbound_format(input.request.request_type()).to_string(),
);
Ok(Request {
llm_request: decoded.request,
raw_request: Some(input.request.body().clone()),
metadata: Some(metadata),
})
🤖 Prompt for 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.

In `@demo/libsy-proxy/src/main.rs` around lines 123 - 141, Update libsy_request to
validate and reject requests with stream: true before decoding or forwarding
them upstream. Return the existing invalid-request error type with a clear
streaming-unsupported message, while preserving the current non-streaming
request construction path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's remove this [demo/libsy-proxy]

}

#[derive(Default, Deserialize)]
struct CodexTurnMetadata {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can these fields be combined with protocol::Metadata?

/// Explicit `x-switchyard-*` headers win. NeMo Relay and Dynamo correlation
/// headers are accepted without linking either runtime. Codex's structured turn
/// metadata is preferred over its compatibility projections.
pub fn metadata_from_headers(headers: &BTreeMap<String, Vec<String>>) -> Metadata {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is fine for now. I think we'll wan to consolidate header -> metadata processing in the future

@linj-glitch

linj-glitch commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Feedback from the Codex agent: metadata_from_headers currently lives in the affinity module, but normalized agent metadata seems useful independently of affinity—for example, fixed sub-agent target routing.

Would it be okay to move this normalization into shared ingress/request-metadata infrastructure in this PR? If that would expand or conflict with the affinity work already in flight, Lin Jia can follow up with a separate PR instead.

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.

4 participants