Skip to content

feat(libsy): add escalation as config knob on LlmTaskClassifier - #211

Merged
linj-glitch merged 11 commits into
mainfrom
linj/switch-1054-escalation-as-knob
Jul 31, 2026
Merged

feat(libsy): add escalation as config knob on LlmTaskClassifier#211
linj-glitch merged 11 commits into
mainfrom
linj/switch-1054-escalation-as-knob

Conversation

@linj-glitch

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

Copy link
Copy Markdown
Contributor

Draft of Ryan and Sabhatina's proposed design: escalation as a builder method on the existing capability classifier rather than a separate algorithm.

LlmTaskClassifier.with_escalation(EscalationJudgeConfig) swaps the inner classifier for an EscalationClassifier that calls the efficient model first, appends its reply to the transcript, consults the trajectory judge, and either returns the efficient response directly (on decline) or routes to capable (once the streak confirms). The session latch is the streak counter in State, replacing the AffinityRouter.

Targets PR 203 (the Option interface change) because EscalationClassifier relies on returning Some(efficient_response) to avoid a second model call on non-escalating turns.

Summary by CodeRabbit

  • New Features
    • Added intelligent escalation routing that can switch from an efficient model to a more capable model when repeated issues are detected.
    • Added configurable escalation thresholds and transcript context handling.
    • Added support for recording the selected model and optional routing rationale for one-off requests.
    • Added structured escalation verdict validation and guidance for common failure patterns.

@linj-glitch
linj-glitch force-pushed the linj/switch-1054-classifier-changes branch from 45caf7a to 15e3dd8 Compare July 30, 2026 20:34

@messiaen messiaen left a comment

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.

leaving initial comment

Comment thread crates/libsy/src/algorithms/llm_class.rs Outdated
@linj-glitch
linj-glitch marked this pull request as ready for review July 30, 2026 20:46
@linj-glitch
linj-glitch force-pushed the linj/switch-1054-escalation-as-knob branch from cba756f to f355e8a Compare July 30, 2026 20:46
@linj-glitch
linj-glitch requested a review from a team as a code owner July 30, 2026 20:46
Base automatically changed from linj/switch-1054-classifier-changes to main July 30, 2026 21:05
…tion constructor

Signed-off-by: Lin Jia <linj@nvidia.com>
@linj-glitch
linj-glitch force-pushed the linj/switch-1054-escalation-as-knob branch from 8810d80 to e1c69bc Compare July 30, 2026 21:16
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds trajectory-based escalation routing and response stream conversion. The escalation feature evaluates agent progress using a judge model, summarizes conversation history for efficient request shaping, and routes to a capable model after a configured count of consecutive escalation verdicts. The stream conversion feature allows buffered responses to be consumed as synthetic chunk streams. Server configuration gains an optional escalation field to enable the feature per route.

Changes

Escalation routing

Layer / File(s) Summary
Judge contract and transcript shaping
crates/libsy/src/algorithms/util/escalation.rs, crates/libsy/src/prompts/escalation/*
Defines EscalationJudgeConfig with validation, EscalationJudge and EscalationPolicy types, build_judge factory, prompt and schema assets, and transcript utilities: conversation_turn, collect_text, truncate_middle, summarize_for_judge, with unit tests covering request shaping, text extraction, and transcript condensation.
Polymorphic classifier and public API
crates/libsy/src/algorithms/llm_class.rs, crates/libsy/src/algorithms.rs, crates/libsy/src/algorithms/util.rs, crates/protocol/src/client.rs
Adds EscalationClassifier that delegates efficient calls to judge evaluation, tracks escalation streaks in session state, and latches to capable tier. Updates LlmTaskClassifier to use trait-object dispatch, adds new_with_escalation constructor, and exports EscalationJudgeConfig and SimpleDecision.
Server configuration and validation
crates/switchyard-server/src/config.rs
Adds optional escalation field to LlmClassifier route variant, conditionally constructs escalation or standard classifier via build_algorithm, and validates configuration (e.g., confirmations ≥ 1) in unit tests.
Routing behavior tests
crates/libsy/src/algorithms/llm_class.rs
Tests efficient-only responses when judge declines, escalation to capable tier after verdict confirmation, and direct capable serving after session latches without further judge calls.

Stream utilities

Layer / File(s) Summary
Aggregated response to stream conversion
crates/protocol/src/stream.rs
Adds into_stream method to convert buffered AggLlmResponse to synthetic LlmResponseChunk stream, mapping content blocks to chunk variants and verifying round-trip fidelity via into_agg.

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

Poem

A rabbit scouts the winding way,
Where tiny steps may stray or stay.
A judge now reads each turn's own trace,
And calls the stronger warren's grace.
Efficient bounds, then capable stride—
The path remembers what will guide.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 and concisely describes the main change: adding escalation configuration to LlmTaskClassifier.
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.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch linj/switch-1054-escalation-as-knob

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

🤖 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/src/algorithms/llm_class.rs`:
- Around line 366-370: Remove the unwrap from the assistant-message construction
in the judge request flow. Build the assistant message directly from the
existing agg value before it is moved into efficient_response, preserving the
completed trajectory behavior and avoiding any production panic.

In `@crates/libsy/src/algorithms/util/escalation.rs`:
- Around line 290-313: Update the local assemble closure in the escalation
routine to construct the “showing the last N of M messages” header from the
current window length on every call, rather than capturing a header computed
before the trimming loop. Preserve the existing anchors, window assembly, and
truncation behavior while ensuring the header reflects entries removed by
window.remove(0).
🪄 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: 3d52a83e-9259-46e1-8eaa-901d66f5aedb

📥 Commits

Reviewing files that changed from the base of the PR and between cdb893b and e1c69bc.

📒 Files selected for processing (7)
  • crates/libsy/src/algorithms.rs
  • crates/libsy/src/algorithms/llm_class.rs
  • crates/libsy/src/algorithms/util.rs
  • crates/libsy/src/algorithms/util/escalation.rs
  • crates/libsy/src/prompts/escalation/prompt.md
  • crates/libsy/src/prompts/escalation/schema.json
  • crates/protocol/src/client.rs

Comment thread crates/libsy/src/algorithms/llm_class.rs Outdated
Comment thread crates/libsy/src/algorithms/util/escalation.rs Outdated
@linj-glitch
linj-glitch force-pushed the linj/switch-1054-escalation-as-knob branch from 78b8f41 to 570221b Compare July 30, 2026 22:54
…ifier route

Signed-off-by: Lin Jia <linj@nvidia.com>
Comment thread crates/libsy/src/algorithms/llm_class.rs
Comment thread crates/libsy/src/algorithms/llm_class.rs Outdated
Comment thread crates/protocol/src/stream.rs
@linj-glitch

linj-glitch commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Terminal-Bench 2.0 smoke through the Rust libsy server (switchyard-server --config), escalation route, on Harbor-GKE. Tiers: weak nvidia/nvidia/nemotron-3-ultra, strong azure/anthropic/claude-opus-5, judge nvidia/nvidia/nemotron-nano-3.5-preview, confirmations = 2.

Harness Inbound Served by Switchyard Routing Terminal cause
Codex (rh-cli-0730223559) openai_responses 9 × 200 8 weak, 1 strong task unsolved (reward=0)
Claude Code (rh-cli-0730225003) anthropic_messages 12 × 200 12 weak error_max_turns (preset sets max_turns=12)

Observed

  • Escalation fired in a real task. The Codex run served 8 turns weak, then routed one to claude-opus-5.
  • Both inbound wire formats work. The Claude run drives Anthropic Messages against OpenAI-format tiers, so it exercises translation on top of the escalation path's buffer-and-re-emit — 12 streaming turns, no errors, no reconnects.
  • No Switchyard-side errors in either run: every request 200, no judge verdict unavailable.
  • Claude never escalated: the judge declined for 12 turns on a task where the agent was making steady progress.

Are the results expected?

Yes. Neither terminal state is a routing fault:

  • Codex ran to completion and did not solve adaptive-rejection-sampler — a benchmark outcome for one task on a weak-tier model.
  • Claude was cut off by the preset's own max_turns=12 while working productively (writing R, running tests, debugging its own upper-hull construction). Harbor reports that exit as NonZeroAgentExitCodeError; the agent log shows error_max_turns, and modelUsage attributes 337k input / 8.9k output tokens to switchyard, so every turn round-tripped through the router.

This validates plumbing and routing behaviour, not routing quality — neither run passed its task, and solve rates need the full sweep.

@messiaen

Copy link
Copy Markdown
Contributor

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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/switchyard-server/src/config.rs (1)

190-205: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mandatory classifier_config fields go unused once escalation is set.

classifier_config: TaskClassifierConfig (Line 195-196) is not Option and has no top-level #[serde(default)], so build_algorithm's Some(judge_config) branch (Lines 345-353) never reads it. Operators enabling escalation on an llm_classifier route must still supply whatever required fields TaskClassifierConfig has (e.g. base_threshold, always present in VALID_CONFIG), even though those values have no effect.

Consider modeling the two modes so unused fields cannot be supplied, for example by giving TaskClassifierConfig's scalar fields defaults, or restructuring LlmClassifier so classifier_config and escalation are two branches of one field rather than two independently-required/optional fields.

#!/bin/bash
# Description: Check whether TaskClassifierConfig's scalar fields have serde defaults.
set -euo pipefail

rg -n -B2 -A 20 'struct TaskClassifierConfig' crates/libsy/src/algorithms/llm_class.rs
🤖 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/switchyard-server/src/config.rs` around lines 190 - 205, The
LlmClassifier configuration currently requires classifier_config fields even
when escalation mode ignores them. Update TaskClassifierConfig or the
LlmClassifier representation so escalation and normal classification are modeled
as distinct configuration modes, allowing escalation routes to omit unused
classifier settings while preserving required fields for non-escalation routes.
🤖 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/protocol/src/stream.rs`:
- Around line 109-121: Update the ContentBlock-to-LlmResponseChunk conversion to
preserve ContentBlock::Reasoning.signature through into_agg(), and handle every
ContentBlock variant rather than silently dropping unmapped blocks via _ => {}.
Extend the chunk/accumulator contract as needed so all content is retained, or
return an explicit conversion error when a variant cannot be represented.
- Around line 103-119: Update the conversion loop around the output iteration
and ContentBlock::ToolCall branch to maintain a separate, unique index for each
tool call rather than reusing the outer output index. Use that tool-call index
for ToolCallDelta.index while preserving the existing output index for text and
reasoning chunks and ensuring each tool call increments independently.

---

Nitpick comments:
In `@crates/switchyard-server/src/config.rs`:
- Around line 190-205: The LlmClassifier configuration currently requires
classifier_config fields even when escalation mode ignores them. Update
TaskClassifierConfig or the LlmClassifier representation so escalation and
normal classification are modeled as distinct configuration modes, allowing
escalation routes to omit unused classifier settings while preserving required
fields for non-escalation routes.
🪄 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: 1889bbbd-7a8f-4aae-ab51-e9252195ffa9

📥 Commits

Reviewing files that changed from the base of the PR and between e1c69bc and 5888eb3.

📒 Files selected for processing (3)
  • crates/libsy/src/algorithms/llm_class.rs
  • crates/protocol/src/stream.rs
  • crates/switchyard-server/src/config.rs

Comment thread crates/protocol/src/stream.rs Outdated
Comment thread crates/protocol/src/stream.rs
@linj-glitch
linj-glitch enabled auto-merge (squash) July 31, 2026 16:23
@linj-glitch
linj-glitch merged commit c5041dd into main Jul 31, 2026
17 checks passed
@linj-glitch
linj-glitch deleted the linj/switch-1054-escalation-as-knob branch July 31, 2026 16:31
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.

2 participants