Skip to content

feat(libsy): add EscalationRouter algorithm - #172

Closed
linj-glitch wants to merge 29 commits into
mainfrom
linj/switch-1054-escalation-router
Closed

feat(libsy): add EscalationRouter algorithm#172
linj-glitch wants to merge 29 commits into
mainfrom
linj/switch-1054-escalation-router

Conversation

@linj-glitch

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

Copy link
Copy Markdown
Contributor

Implements SWITCH-1054. Adds EscalationRouter to libsy: every session starts on the efficient model, an LLM judge reads the trajectory before each turn's model call, and a confirmed escalation latches the session to the capable model for the rest of the task. Judging ahead of the call means a turn costs one model call and the target's response — streamed or aggregated — reaches the caller untouched. A judge failure fails open to the efficient tier and never latches.

The router is a FallThrough over one classifier holding the whole policy, matching the LlmTaskClassifier shape. The confirmation streak lives in the composition's per-session State and doubles as the latch — it only reaches the threshold on the turn the judge escalated, and a confirmed session never consults the judge again to clear it:

Streak Decides Tier
>= confirmations session already escalated — judge not called strong
raised to confirmations this turn judge verdicts have confirmed strong
below confirmations everything else weak

The last row is unconditional, which is what keeps a judge outage from failing the turn.

Because the streak is both the counter and the latch, there is no separate affinity component and no second per-session store. Escalation is session-scoped throughout; per-subagent escalation would need both the streak and the latch keyed by (session, agent) and is not attempted here.

The decision's tier comes from the classifier's routing_tier, and its reason from the composition's own with_decision_reason. A turn that escalated is distinguishable from one that was already latched by the judge consultation recorded before it in the trace, so no per-rule reason string is carried. Nothing in core/ or fall_through.rs changes.

Python default changes

This PR also flips five defaults in the Python escalation router. Four of them make Python match the Rust EscalationJudgeConfig added here, so the two implementations of the same router do not disagree on main:

Setting Before After Rust
escalate_confirmations 1 2 confirmations: 2
recent_turn_window 14 28 recent_turn_window: 28
window_message_chars 300 500 window_message_chars: 500
max_request_chars 12 000 18 000 MAX_REQUEST_CHARS
judge_timeout_s 5.0 30.0 no equivalent knob

judge_timeout_s is the exception: Rust has no judge-timeout setting — the judge target's own timeout_secs governs there — so that value rests on the benchmark rather than on parity. All five are the benchmarked configuration.

Existing Python users will see different routing after this merges. escalate_confirmations 1→2 is the behavioural one: the judge must now confirm on a second judged turn before the strong latch fires, which on the benchmarked workload suppressed roughly two thirds of escalations at an equal-or-better solve rate.

@linj-glitch
linj-glitch requested a review from a team as a code owner July 28, 2026 22:13
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
Signed-off-by: ayushag <ayushag@nvidia.com>
Comment on lines +136 to +148
fn is_pinned(&self, session_id: &str) -> bool {
self.pins.lock().contains_key(session_id)
}

fn pin(&self, session_id: &str) {
let mut pins = self.pins.lock();
if pins.len() >= MAX_PINS {
if let Some(evicted) = pins.keys().next().cloned() {
pins.remove(&evicted);
}
}
pins.insert(session_id.to_string(), ());
}

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.

we should not be re-inventing pinning here. Try to use AffinityRouter stuff in here if possible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — replaced the custom pins map with AffinityRouter::new().with_latch_only([capable_name]). Pinning now goes through the same mechanism as the rest of the codebase.

Comment on lines +115 to +134
fn load_judge_config() -> Result<JudgeConfig> {
let response_schema: Value =
serde_json::from_str(SCHEMA_TEMPLATE).map_err(|error| LibsyError::AlgorithmError {
message: format!("escalation response schema is invalid: {error}"),
})?;
let prompt_schema = response_schema
.pointer("/json_schema/schema")
.ok_or_else(|| LibsyError::AlgorithmError {
message: "escalation response schema has no json_schema.schema".to_string(),
})?;
let prompt_schema = serde_json::to_string_pretty(prompt_schema).map_err(|error| {
LibsyError::AlgorithmError {
message: format!("escalation prompt schema could not be rendered: {error}"),
}
})?;
Ok(JudgeConfig {
system_prompt: PROMPT_TEMPLATE.replace("{{RESPONSE_SCHEMA}}", &prompt_schema),
response_schema: Some(response_schema),
})
}

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 this seems to follow the same pattern and duplicate code from llm_class.rs -- can we uniffy ?? Only diff is in the run loop I believe how the latch behaviour work -- for that may be you should extend the AffinityRouter for EscalationAffinity ... ??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — extracted load_judge_config(prompt, schema) into util/llm_judge.rs so both classifiers share it. For the run-loop difference (the judge needs the efficient model's response, not just the original request), added minimal EscalationJudge/EscalationPolicy types that plug into JudgeClassifier — latch logic stays in AffinityRouter, judge plumbing in the existing abstraction.

Comment on lines +47 to +63
impl Decision for EscalationDecision {
fn selected_model(&self) -> &str {
&self.model
}

fn routing_tier(&self) -> Option<&'static str> {
Some(self.tier)
}

fn reasoning(&self) -> Option<&str> {
Some(self.reason)
}

fn as_any(&self) -> &dyn std::any::Any {
self
}
}

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 do we need this ? can't we use Decision directly ??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed — JudgeClassifier uses its own internal JudgeDecision. No custom struct needed.

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.

@linj-glitch looks like it's still there?

I think we need a default Decision struct. SimpleDecision or similar for example

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

EscalationDecision is gone as of the fall-through refactor (c7f27a0) — escalation no longer defines a Decision at all. The cascade publishes FallThroughDecision, and the tier/rationale that used to be hardcoded per branch now come from whichever classifier won the turn.

On the broader point: agreed, and the duplication is worse than one type. Four Decision impls in production today, ~73 lines, and three of them carry no payload at all:

Fields Differs by
NoopDecision model
PassthroughDecision model_id nothing but the field name
JudgeDecision model is_routed_call() == false, static reason
FallThroughDecision model, tier, reasoning

NoopDecision and PassthroughDecision are the same struct twice. Eleven more files re-roll their own for tests and examples.

One type covers all four:

pub struct SimpleDecision { /* model, tier, reasoning, routed */ }

SimpleDecision::new("strong")
    .with_tier("strong")
    .with_reasoning("judge escalated the run")
SimpleDecision::new("judge").unrouted()   // JudgeDecision

Home would be switchyard-protocol, next to the Decision trait, re-exported from libsy. And the existing names become aliases —

pub type NoopDecision = SimpleDecision;
pub type PassthroughDecision = SimpleDecision;
pub type FallThroughDecision = SimpleDecision;

— so nothing public is removed and the RandomDecision downcast in rand.rs keeps working.

Net is comfortably negative in libsy, and consumers get one type to downcast to instead of four. Happy to land it here or as a follow-up — it touches four algorithms plus the protocol crate, so it may read cleaner as its own PR. Your call.

Comment on lines +199 to +248
async fn consult_judge(
&self,
ctx: Context,
driver: &Driver,
request: &Request,
efficient_text: &str,
) -> bool {
let judge_model = self.judge_target.semantic_name.as_str();
let warn = |error: &dyn std::fmt::Display| {
tracing::warn!(
target: "libsy",
judge_model,
error = %error,
"escalation judge unavailable; skipping escalation"
);
};

let judge_request = self.build_judge_request(request, efficient_text);
let judge_decision: Arc<dyn Decision> = Arc::new(JudgeCallDecision {
model: self.judge_target.semantic_name.clone(),
});

let response = match driver
.call_llm_target(ctx, &self.judge_target, judge_request, judge_decision)
.await
{
Ok(r) => r,
Err(e) => {
warn(&e);
return false;
}
};

let agg = match response.llm_response.into_agg().await {
Ok(a) => a,
Err(e) => {
warn(&e);
return false;
}
};

let text = completion_text(&agg);
match parse_verdict(text.trim()) {
Some(v) => v.should_escalate,
None => {
tracing::warn!(
target: "libsy",
judge_model,
"escalation judge verdict did not parse; skipping escalation"
);

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.

The JudgeClassifier abstraction is generic enough to make a llm call using judge inputs and give u a output ? Why do we need to implement this again ??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, removed. Added EscalationJudge (implements Judge, reads the efficient response from state.extra) and EscalationPolicy (implements JudgePolicy, maps verdict to classification). JudgeClassifier handles the call.

@ayushag-nv
ayushag-nv force-pushed the ayushag/llm-class-affinity branch from 67b9ac4 to 58a2da2 Compare July 28, 2026 22:34
Comment on lines +75 to +85
let candidate = state
.extra
.get(CANDIDATE_KEY)
.and_then(|v| {
if let StateValue::String(s) = v {
Some(s.as_str())
} else {
None
}
})
.unwrap_or("");

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.

what does a candidate here refer to ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

candidate is the efficient model's response text — stored in state.extra under CANDIDATE_KEY just before the judge call. EscalationJudge::build_request reads it to construct the prompt for the judge: "here's what the efficient model replied — was it sufficient?" Without it the judge would only see the original request and couldn't evaluate actual output quality.

}

#[async_trait]
impl Algorithm<SharedState> for EscalationRouter {

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.

@linj-glitch Can this be implemented the FallThrough pattern ? We are implementing all the algos using that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed it's the right long-term direction — Greg flagged the same thing. The blocker is that FallThrough currently has no Event::Response: processors only see Event::Request and Event::Decision, so there's no hook to feed the efficient model's reply into a classifier mid-run. Once Processor gets a response event, escalation folds in naturally. Greg said "we can do that later," so keeping it as a standalone algo for now and we can refactor when the event is there.

@linj-glitch
linj-glitch force-pushed the linj/switch-1054-escalation-router branch from 3b34ba2 to 135864e Compare July 28, 2026 23:31
…ix API migration

Replace the generic 42-line prompt with the validated 179-line agentic trajectory
judge from switchyard/lib/processors/prompts/escalation_judge.md. Simplify schema
to {escalate, reason}. Migrate EscalationRouter off the removed SharedState/generic
Algorithm API: manage session state internally via Mutex<HashMap>, delegate stateful
logic to a plain async fn (execute) to avoid MutexGuard lifetime conflicts with
async_trait's 'static bound from AffinityRouter's S: 'static impl constraint.

Signed-off-by: Lin Jia <linj@nvidia.com>
…ionRouter

Matches the pattern FallThrough::route already uses: dispatch through
dyn Classifier<State>/dyn Processor<State> so async_trait doesn't
propagate the S: 'static bound from AffinityRouter's impl. Replaces
the scratch-State workaround and passes real session state through.

Signed-off-by: Lin Jia <linj@nvidia.com>
Base automatically changed from ayushag/llm-class-affinity to main July 29, 2026 06:23
…lation-router

Signed-off-by: Lin Jia <linj@nvidia.com>

# Conflicts:
#	crates/libsy/README.md
#	crates/libsy/examples/research_agent.rs
#	crates/libsy/examples/research_agent_core.rs
#	crates/libsy/src/algorithms/llm_class.rs
#	crates/libsy/src/algorithms/util/affinity.rs
#	crates/libsy/src/lib.rs
#	crates/libsy/tests/observability.rs
#	crates/switchyard-server/src/config.rs
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds a new EscalationRouter algorithm to libsy that routes requests to an efficient model until a judge detects trouble, then escalates and pins to a capable model. It also refactors judge config loading into a shared utility, adds timeout support to JudgeClassifier, and includes new prompt/schema assets and crate docs.

Changes

Escalation Router Feature

Layer / File(s) Summary
Module wiring and settings
crates/libsy/src/algorithms.rs, crates/libsy/src/algorithms/escalation.rs
New escalation submodule declared and re-exported; adds EscalationJudgeSettings configuring window sizing, char caps, and judge timeout.
Judge types and policy
crates/libsy/src/algorithms/escalation.rs
Adds EscalationVerdict, EscalationJudge request-building, and EscalationPolicy mapping verdicts to routing decisions.
Router core logic
crates/libsy/src/algorithms/escalation.rs
Implements EscalationRouter (new, with_settings, call_tier) with pin/latch behavior, min-turn bypass, judge consultation, and fail-open handling.
Transcript summarization helpers
crates/libsy/src/algorithms/escalation.rs
Adds helpers to build a bounded judge transcript: turn counting, text extraction, middle truncation, and windowed summarization.
Unit tests
crates/libsy/src/algorithms/escalation.rs
Adds RecordingClient and tests covering routing, pinning, fail-open on invalid/failed/timed-out judge calls, streaming passthrough, and helper functions.
Shared judge config loader and timeout
crates/libsy/src/algorithms/util.rs, crates/libsy/src/algorithms/util/llm_judge.rs
Adds load_judge_config to build JudgeConfig from prompt/schema templates, re-exports it, and adds with_timeout/timeout-wrapped consultation to JudgeClassifier::verdict.
TaskClassifier refactor
crates/libsy/src/algorithms/llm_class.rs
TaskClassifier::load_judge_config now delegates to the shared super::util::load_judge_config instead of inline parsing; updates imports.
Prompt, schema, and docs
crates/libsy/src/prompts/escalation/prompt.md, crates/libsy/src/prompts/escalation/schema.json, crates/libsy/src/lib.rs
Adds the escalation judge prompt with escalation rules and examples, the EscalationDecision JSON schema, and crate-level documentation for EscalationRouter.

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

Poem

A hop from swift to strong I go,
when trouble's patterns start to show. 🐇
The judge decides, I latch and stay,
capable now, come what may.
Prompts and schemas, tests all bright—
this bunny's code review's just right! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 clearly summarizes the main change: adding the EscalationRouter algorithm to libsy.

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 (3)
crates/libsy/src/algorithms/escalation.rs (2)

327-350: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The escalation path re-implements call_tier. Lines 346-349 duplicate the driver.info + call_llm_target sequence because the decision has to exist earlier for the affinity latch. Threading a prebuilt decision through call_tier keeps the publish/serve sequence in one place.

♻️ Sketch
-    async fn call_tier(
-        &self,
-        ctx: Context,
-        driver: &Driver,
-        request: Request,
-        target: &LlmTarget,
-        tier: &'static str,
-        reason: &'static str,
-    ) -> Result<Response> {
-        let decision: Arc<dyn Decision> = Arc::new(EscalationDecision {
-            model: target.semantic_name.clone(),
-            tier,
-            reason,
-        });
+    async fn serve(
+        &self,
+        ctx: Context,
+        driver: &Driver,
+        request: Request,
+        target: &LlmTarget,
+        decision: Arc<dyn Decision>,
+    ) -> Result<Response> {
         driver.info(ctx.clone(), decision.clone()).await?;
         driver.call_llm_target(ctx, target, request, decision).await
     }
🤖 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/escalation.rs` around lines 327 - 350, Refactor
the escalation flow around call_tier to accept an optional prebuilt Decision,
allowing the affinity latch to use it before serving while keeping driver.info
and call_llm_target centralized in call_tier. Update the escalation caller to
construct and pass the capable-model decision, and remove its duplicated
publish/serve sequence while preserving existing behavior for calls without a
prebuilt decision.

83-95: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider surfacing the judge's reason instead of discarding it.

EscalationVerdict::reason is parsed then dropped (#[allow(dead_code)]), and EscalationDecision::reason is a &'static str, so the trace never records why the judge escalated. Since JudgePolicy::to_classification only returns a Classification, the cheapest fix is a tracing::info! of the reason inside EscalationPolicy::to_classification; widening EscalationDecision::reason to Cow<'static, str> would let it reach the trace.

🤖 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/escalation.rs` around lines 83 - 95, Update
EscalationPolicy::to_classification to surface the parsed
EscalationVerdict::reason via tracing::info! before returning the
Classification. Remove the unnecessary dead-code allowance on reason, and keep
EscalationDecision unchanged unless the implementation is instead extended to
carry the reason into tracing.
crates/libsy/src/algorithms/util/llm_judge.rs (1)

174-201: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a direct unit test for load_judge_config's error branches.

Now that this is a shared, multi-caller utility, it's only exercised indirectly through LlmTaskClassifier's tests (happy path). A couple of focused tests here (invalid schema JSON, missing /json_schema/schema pointer) would pin down the LibsyError::AlgorithmError contract independent of any one caller.

🤖 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/util/llm_judge.rs` around lines 174 - 201, Add
focused unit tests for the shared load_judge_config function covering invalid
schema JSON and a schema missing the /json_schema/schema pointer. Assert both
cases return LibsyError::AlgorithmError with the expected messages, while
keeping the existing successful configuration behavior unchanged.
🤖 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/escalation.rs`:
- Around line 460-483: Update the local assemble closure in the conversation
assembly flow to construct the header using the current window length on every
invocation, rather than capturing the pre-loop header. Ensure each call after
window.remove(0) reports the actual number of messages included while preserving
the existing turn, total-count, anchors, and truncation behavior.

In `@crates/libsy/src/prompts/escalation/prompt.md`:
- Line 14: Update every escalation hold example in the prompt, including the
examples near the referenced lines, so each {"escalate": false} response also
includes a concise reason field. Keep the existing escalation behavior and
wording otherwise unchanged, and ensure no hold example omits the required
reason.

---

Nitpick comments:
In `@crates/libsy/src/algorithms/escalation.rs`:
- Around line 327-350: Refactor the escalation flow around call_tier to accept
an optional prebuilt Decision, allowing the affinity latch to use it before
serving while keeping driver.info and call_llm_target centralized in call_tier.
Update the escalation caller to construct and pass the capable-model decision,
and remove its duplicated publish/serve sequence while preserving existing
behavior for calls without a prebuilt decision.
- Around line 83-95: Update EscalationPolicy::to_classification to surface the
parsed EscalationVerdict::reason via tracing::info! before returning the
Classification. Remove the unnecessary dead-code allowance on reason, and keep
EscalationDecision unchanged unless the implementation is instead extended to
carry the reason into tracing.

In `@crates/libsy/src/algorithms/util/llm_judge.rs`:
- Around line 174-201: Add focused unit tests for the shared load_judge_config
function covering invalid schema JSON and a schema missing the
/json_schema/schema pointer. Assert both cases return LibsyError::AlgorithmError
with the expected messages, while keeping the existing successful configuration
behavior unchanged.
🪄 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: 18a50d5d-1568-4c23-b149-1960647d06aa

📥 Commits

Reviewing files that changed from the base of the PR and between f3e29af and 4a042c3.

📒 Files selected for processing (8)
  • crates/libsy/src/algorithms.rs
  • crates/libsy/src/algorithms/escalation.rs
  • crates/libsy/src/algorithms/llm_class.rs
  • crates/libsy/src/algorithms/util.rs
  • crates/libsy/src/algorithms/util/llm_judge.rs
  • crates/libsy/src/lib.rs
  • crates/libsy/src/prompts/escalation/prompt.md
  • crates/libsy/src/prompts/escalation/schema.json

Comment on lines +460 to +483
let header = format!(
"Conversation turn {turn}; showing the last {} of {} messages after the task framing.",
window.len(),
messages.len(),
);
let assemble = |window: &[String]| {
std::iter::once(header.as_str())
.chain(anchors.iter().map(String::as_str))
.chain(window.iter().map(String::as_str))
.collect::<Vec<_>>()
.join("\n")
};

let mut text = assemble(&window);
while text.chars().count() > settings.max_request_chars && !window.is_empty() {
window.remove(0);
text = assemble(&window);
}
if text.chars().count() > settings.max_request_chars {
let keep = settings
.max_request_chars
.saturating_sub(TRUNCATION_SUFFIX.chars().count() + 1);
text = text.chars().take(keep).collect::<String>() + TRUNCATION_SUFFIX;
}

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 | 🟠 Major | ⚡ Quick win

Header message count goes stale after the char-cap drop loop.

header is computed from window.len() before the loop at Lines 474-477 drops the oldest window lines. Whenever max_request_chars forces a drop, the transcript tells the judge it is seeing the last N messages while only N - dropped are present — precisely the "pace" signal the doc comment says the header exists to convey. Recompute it inside assemble.

🐛 Proposed fix
-    let header = format!(
-        "Conversation turn {turn}; showing the last {} of {} messages after the task framing.",
-        window.len(),
-        messages.len(),
-    );
-    let assemble = |window: &[String]| {
-        std::iter::once(header.as_str())
-            .chain(anchors.iter().map(String::as_str))
-            .chain(window.iter().map(String::as_str))
-            .collect::<Vec<_>>()
-            .join("\n")
-    };
+    let total = messages.len();
+    let assemble = |window: &[String]| {
+        let header = format!(
+            "Conversation turn {turn}; showing the last {} of {total} messages after the task framing.",
+            window.len(),
+        );
+        std::iter::once(header)
+            .chain(anchors.iter().cloned())
+            .chain(window.iter().cloned())
+            .collect::<Vec<_>>()
+            .join("\n")
+    };
📝 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
let header = format!(
"Conversation turn {turn}; showing the last {} of {} messages after the task framing.",
window.len(),
messages.len(),
);
let assemble = |window: &[String]| {
std::iter::once(header.as_str())
.chain(anchors.iter().map(String::as_str))
.chain(window.iter().map(String::as_str))
.collect::<Vec<_>>()
.join("\n")
};
let mut text = assemble(&window);
while text.chars().count() > settings.max_request_chars && !window.is_empty() {
window.remove(0);
text = assemble(&window);
}
if text.chars().count() > settings.max_request_chars {
let keep = settings
.max_request_chars
.saturating_sub(TRUNCATION_SUFFIX.chars().count() + 1);
text = text.chars().take(keep).collect::<String>() + TRUNCATION_SUFFIX;
}
let total = messages.len();
let assemble = |window: &[String]| {
let header = format!(
"Conversation turn {turn}; showing the last {} of {total} messages after the task framing.",
window.len(),
);
std::iter::once(header)
.chain(anchors.iter().cloned())
.chain(window.iter().cloned())
.collect::<Vec<_>>()
.join("\n")
};
let mut text = assemble(&window);
while text.chars().count() > settings.max_request_chars && !window.is_empty() {
window.remove(0);
text = assemble(&window);
}
if text.chars().count() > settings.max_request_chars {
let keep = settings
.max_request_chars
.saturating_sub(TRUNCATION_SUFFIX.chars().count() + 1);
text = text.chars().take(keep).collect::<String>() + TRUNCATION_SUFFIX;
}
🤖 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/escalation.rs` around lines 460 - 483, Update the
local assemble closure in the conversation assembly flow to construct the header
using the current window length on every invocation, rather than capturing the
pre-loop header. Ensure each call after window.remove(0) reports the actual
number of messages included while preserving the existing turn, total-count,
anchors, and truncation behavior.


Escalation is one-way for the rest of the task and expensive. Escalate
only on a clear PATTERN of trouble, never on a single failed command.
When the evidence is thin or ambiguous, return {"escalate": false}.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Hold examples omit the required reason field.

Lines 14, 141, 163 and 165 show {"escalate": false} with no reason, but schema.json lists reason in required and EscalationVerdict.reason is a non-optional String with deny_unknown_fields. On providers where the response schema is advisory rather than enforced, a model copying these examples produces a reply that fails to parse and burns a judge call before falling open. Add a short reason to every example.

📝 Example fix
-When the evidence is thin or ambiguous, return {"escalate": false}.
+When the evidence is thin or ambiguous, return
+{"escalate": false, "reason": "evidence too thin to justify escalation"}.

Also applies to: 138-173

🤖 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/prompts/escalation/prompt.md` at line 14, Update every
escalation hold example in the prompt, including the examples near the
referenced lines, so each {"escalate": false} response also includes a concise
reason field. Keep the existing escalation behavior and wording otherwise
unchanged, and ensure no hold example omits the required reason.

…s (SWITCH-1054)

Signed-off-by: Lin Jia <linj@nvidia.com>
…ration

Signed-off-by: Lin Jia <linj@nvidia.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
Signed-off-by: Lin Jia <linj@nvidia.com>
…mpletion is empty

Signed-off-by: Lin Jia <linj@nvidia.com>
Comment on lines +53 to +60
/// Completion budget for one judge reply, covering any reasoning the judge model emits
/// alongside the verdict.
///
/// A runaway guard, not a budget: output tokens cost what they generate, so a generous cap is
/// nearly free, while a tight one truncates mid-reasoning into unparseable JSON and fails the
/// judge open on every call. Sized well above the verdict itself — the benchmarked judge's
/// `reason` ran ~40 tokens at the median and ~136 at its longest — leaving the remainder as
/// reasoning headroom.

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.

do we need so many lines of comments for 1 variable ? Can we just do it either 1 or 2 lines

@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.

Will continue review. Here are some initial comments.

Overall I think it can be simpler.

Comment thread crates/libsy/src/algorithms/escalation.rs Outdated
Comment thread crates/libsy/src/algorithms/escalation.rs Outdated
Comment thread crates/libsy/src/algorithms/escalation.rs Outdated
Comment on lines +47 to +63
impl Decision for EscalationDecision {
fn selected_model(&self) -> &str {
&self.model
}

fn routing_tier(&self) -> Option<&'static str> {
Some(self.tier)
}

fn reasoning(&self) -> Option<&str> {
Some(self.reason)
}

fn as_any(&self) -> &dyn std::any::Any {
self
}
}

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.

@linj-glitch looks like it's still there?

I think we need a default Decision struct. SimpleDecision or similar for example

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 are we changing python files?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These are the benchmarked defaults, and four of the five make Python match the Rust EscalationJudgeConfig this PR adds: escalate_confirmations 1→2, recent_turn_window 14→28, window_message_chars 300→500, max_request_chars 12k→18k. system_chars and first_user_chars already agreed.

Splitting them out would land the Rust router on main while the Python one still routes on different numbers — same router, two behaviours.

judge_timeout_s 5→30 is the exception: Rust has no judge-timeout setting (the judge target's own timeout_secs governs there), so that one rests on the benchmark rather than on parity.

Your underlying point stands though — the title doesn't advertise a Python behaviour change. I've added a section to the PR description calling it out.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Wait are we trying to change the algorithm and do the port at the same time?

Shouldn't we port the algo as is since that's what the benchmark results were generated against?

Comment thread crates/libsy/src/algorithms/llm_class.rs
Comment thread crates/libsy/src/algorithms/fall_through.rs Outdated
Comment thread crates/libsy/src/algorithms/fall_through.rs Outdated
Comment thread crates/libsy/src/core/classifier.rs Outdated
Comment thread crates/libsy/src/algorithms/escalation.rs Outdated
linj-glitch and others added 8 commits July 29, 2026 14:25
…assifier

Signed-off-by: Lin Jia <linj@nvidia.com>
…on state

Signed-off-by: Lin Jia <linj@nvidia.com>
Signed-off-by: Greg Clark <grclark@nvidia.com>
Signed-off-by: Greg Clark <grclark@nvidia.com>
Signed-off-by: Greg Clark <grclark@nvidia.com>
Signed-off-by: Greg Clark <grclark@nvidia.com>
@linj-glitch

linj-glitch commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Two commits on the escalation router, and how they were verified.

73c7318 — the judged turn was off by one. conversation_turn returned assistants + 1, which was right when the judge ran before the turn's call. It now runs after, on a transcript with this turn's reply already appended, so every judge prompt was labelled Conversation turn N+1. The unit test missed it by exercising the helper directly instead of the appended-reply path; it now asserts the real contract.

184d7f8 — judges and reasoning models. Targets a route consults as a judge (judge_target, classifier_target, or a stage_router classifier) get chat_template_kwargs.enable_thinking = false folded into their extra_body; serving targets are untouched. A verdict is a boolean the router parses, so thinking buys no routing quality while costing latency on the request path of every unlatched turn, and it keeps the judge's completion budget honest — a judge that spends its budget thinking and is cut off before answering has no verdict to read. No new config field: writing extra_body.chat_template_kwargs yourself takes the key back, so { enable_thinking = true } opts a judge in.

This commit also removes the reasoning_content fallback added in 4f13352. That fallback was chasing the wrong symptom. The empty-content replies that motivated it are finish_reason: length — the model still thinking when it hit the cap — and in that case reasoning_content holds prose with no verdict in it, so the fallback failed to parse and errored exactly as the direct parse does. Given headroom and a strict response_format, the JSON lands in content where it belongs, thinking on or off. The judge reads content only.

Verified

Latch, live against Inference Hub — weak nemotron-3-ultra, strong claude-opus-5, judge nemotron-nano-3.5-preview, confirmations = 1, one session, four turns of a stuck-agent loop:

Turn Served Duration Upstream calls
1 nemotron-3-ultra 4193 ms efficient + judge
2 nemotron-3-ultra 2552 ms efficient + judge
3 claude-opus-5 4610 ms efficient + judge + capable
4 claude-opus-5 1941 ms capable only

Turn 4 is the fastest of the run despite the most expensive model — one call, no efficient, no judge. /metrics confirms the shape: 3 efficient, 3 judge, 2 capable, and switchyard_decisions_total splits 2 weak / 2 strong with the tier labels attached.

Judge parsing was checked live with a judge opted into thinking: three turns, three parsed verdicts, zero judge verdict unavailable warnings — the verdict arrives in content regardless.

633 workspace tests pass; cargo clippy --workspace --all-targets and cargo fmt --check clean.

Known gaps, not addressed here

Inner model calls are unattributed. The escalation classifier's efficient call and the shared judge call pass Context::default(), so they record algorithm="" while only the final routed call carries algorithm="escalation" — six of eight upstream calls in the run above. I had a commit threading the run's context onto Driver; it was reverted as the wrong shape, since context is threaded through the algorithm by design. The underlying constraint is that Classifier::score takes no ctx, so a classifier making its own call has nothing else to pass. Unchanged from before this PR.

Judge spend is missing from cost accounting. The judge appears in switchyard_llm_calls_total and the duration histogram but not in switchyard_prompt_tokens_total / completion_tokens_total, because token counters live in the server crate and are fed from trace.last() — inner calls never reach that layer. Python records judge usage into the classifier bucket to keep the escalation benchmark's cost math honest. Separately, completion_tokens_total{tier="weak"} includes the efficient completion discarded on an escalating turn.

Together these mean the router's measured cost is both understated and unattributable to the algorithm. Worth its own issue before benchmark numbers are read from these metrics.

@linj-glitch
linj-glitch force-pushed the linj/switch-1054-escalation-router branch 5 times, most recently from 1f5b4b7 to 45f8119 Compare July 30, 2026 08:59
Comment on lines 125 to 134
impl Driver {
/// Build an empty driver with its step channel ready. Created per call by
/// [`run_stream`](Algorithm::run_stream).
pub(crate) fn new() -> Self {
/// [`run_stream`](Algorithm::run_stream), which stamps `context` first.
pub(crate) fn new(context: Context) -> Self {
Self {
driver: TypeErasedDriver::new(),
routed_call: Arc::new(Mutex::new(None)),
context,
}
}

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 doesn't make sense. Context is passed through the algorithm by design. overriding it or storing it defeats the purpose

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.

is this same as the python one ?

… first

Signed-off-by: Lin Jia <linj@nvidia.com>
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.

5 participants