Skip to content

fix(libsy): harden the classifier judge path - #351

Open
gburachas wants to merge 3 commits into
NVIDIA-NeMo:mainfrom
gburachas:pr/classifier-reinforcement
Open

fix(libsy): harden the classifier judge path#351
gburachas wants to merge 3 commits into
NVIDIA-NeMo:mainfrom
gburachas:pr/classifier-reinforcement

Conversation

@gburachas

@gburachas gburachas commented Aug 11, 2026

Copy link
Copy Markdown

Closes #278, closes #346, closes #279.

Three related fixes to the llm_classifier judge path, kept in one PR because they touch the same two functions and would conflict as separate branches. Each is one reviewable commit.

1. Redact upstream error content from judge warning logs (#278)

report_fail_open logs error = %error, and LlmClientError::UpstreamHttp's Display interpolates the raw upstream body — on a warning that fires on every turn whose judge is unavailable. Providers routinely echo the offending request back in a 400, and the judge's request is a condensed view of the user's conversation.

This keeps everything #205 built — report_fail_open, the bounded reason taxonomy, record_classifier_fail_open — and changes one parameter: it now takes an already-redacted summary produced by an exhaustive match over LibsyError/LlmClientError.

Two properties worth preserving in review:

  • the match is deliberately exhaustive rather than defaulting to to_string(), so a new error variant cannot start leaking by omission. Resolving a future compile error there with a _ => arm would discard the whole guarantee.
  • the test asserts both that the summary omits the secret and that error.to_string() still contains it. The second assertion documents that the hazard is still live in Display.

Not covered by #271, which redacts Debug and caps body size; the Display path is untouched by it.

2. Bound judge consultations with a configurable deadline (#346)

judge_deadline_ms on the route. The judge call sits in front of the routed call, so a stalled judge stalls the turn with no bound of its own.

The whole consultation is bounded, not just the HTTP call — a judge that returns headers promptly then stalls mid-stream would otherwise hold the turn just as long. Expiry folds into the existing fail-open path with reason = "deadline_exceeded", so it appears on /metrics alongside the other reasons rather than as an untracked silent path. Default None preserves current behaviour exactly.

3. Reinforce the routing task after windowed conversation content (#279)

With recent_turn_window set, the judge sees conversation content after the task, and tends to assess the content rather than the request. This appends a short reinforcement line end-positioned, which is the only position where it works — the test asserts contents.last(), not merely presence.

Only the windowed path is affected. The default path carries user task messages only (the anchor and latest follow-up, per #309), so there is nothing there to outrank.

How tested

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace, and again with --test-threads=1 (this path registers process-global OTel instruments)
  • uv run pytest tests/ -m "not integration" — 871 passed, 9 skipped
  • uv run ruff check .
  • uv run --only-group docs mkdocs build --strict
  • Commits signed off per the DCO

Rebased onto current main, which required re-siting all three against #309 (build_messages rewritten), #312 (judge system prompt moved to instructions), #332/#338/#340 (verdict() rewritten) and #337 (LibsyError::MissingClient removed).

One rebase detail worth flagging, since it is the kind of thing that passes review while being wrong: a test here asserted messages.len() == 2, which held only while the judge system prompt lived in messages[0]. After #312 it lives in instructions. The assertion is now == 1 plus an assertion that instructions is non-empty — decrementing the number alone would have silently stopped checking that the rubric reaches the model at all.

Summary by CodeRabbit

  • New Features

    • Added optional time limits for LLM judge consultations, with automatic fallback when a judge times out.
    • Added end-position reinforcement for windowed classifier prompts.
    • Exposed judge_deadline_ms configuration through Python and server configuration.
  • Bug Fixes

    • Improved error handling with sanitized diagnostic summaries that omit sensitive response content.
  • Documentation

    • Documented judge timeout configuration and fallback behavior.

Signed-off-by: Giedrius Burachas <gburachas@nvidia.com>
Signed-off-by: Giedrius Burachas <gburachas@nvidia.com>
…ntent

Signed-off-by: Giedrius Burachas <gburachas@nvidia.com>
@gburachas
gburachas requested a review from a team as a code owner August 11, 2026 04:01
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The classifier now reinforces windowed prompts, supports optional judge deadlines across Rust, Python, and server configuration, bounds judge consultations, and redacts untrusted error content from judge logs.

Changes

Windowed prompt reinforcement

Layer / File(s) Summary
Windowed classifier message construction
crates/libsy/src/algorithms/llm_class.rs
Windowed requests append an end-position routing instruction. Non-windowed requests remain unchanged. Tests cover both shapes.

Configurable judge deadlines

Layer / File(s) Summary
Judge deadline runtime behavior
crates/libsy/src/algorithms/util/llm_judge.rs
Judge deadlines reject zero durations and bound model calls and response streaming. Expiry uses the existing fail-open path.
Classifier deadline propagation
crates/libsy/src/algorithms/llm_class.rs
TaskClassifierConfig deserializes optional millisecond deadlines and applies them to capability judges.
Configuration surfaces
crates/switchyard-py/src/libsy_bindings.rs, crates/switchyard-server/src/config.rs, docs/reference/toml_schema.md
Python bindings, server routes, and TOML documentation support judge_deadline_ms.

Judge error redaction

Layer / File(s) Summary
Sanitized error reporting
crates/libsy/src/algorithms/util.rs, crates/libsy/src/algorithms/util/robustness.rs, crates/libsy/src/algorithms/util/llm_judge.rs
Error summaries retain approved operational details and exclude untrusted bodies, messages, and boxed sources. Tests cover the redaction cases.

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

Poem

I’m a rabbit guarding the route,
With JSON tucked safely about.
Deadlines tick fast,
Strange errors stay masked,
And windowed prompts point the way out. 🐇

🚥 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 summarizes the primary changes that harden the classifier judge path.
Linked Issues check ✅ Passed The changes satisfy the redaction, deadline, and windowed-prompt reinforcement objectives in [#278], [#346], and [#279].
Out of Scope Changes check ✅ Passed The code, configuration, bindings, tests, and documentation changes directly support the three linked issue objectives.

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

🤖 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/util/robustness.rs`:
- Around line 74-75: Update the error matching logic in the surrounding
robustness function to add an explicit LlmClientError::General(_) arm returning
the same redacted "client call failed" message, while retaining the wildcard arm
for future non-exhaustive variants.
- Line 34: Update the error-to-string handling around the
LibsyError::AlgorithmError match arm so it no longer returns the unconstrained
message field verbatim. Replace it with a fixed safe summary, and apply the same
treatment to LlmClientError::Configuration::message; only include explicitly
whitelisted structural fields if needed.

In `@crates/switchyard-server/src/config.rs`:
- Around line 397-403: Ensure judge_deadline_ms is not silently ignored: either
propagate it through escalation and custom routes or keep it capability-only and
reject it during mode validation. Update the relevant configuration validation
flow around the mode handling block, and document the supported mode scope in
docs/reference/toml_schema.md at line 121.

In `@docs/reference/toml_schema.md`:
- Line 121: Update the judge_deadline_ms entry in the TOML schema reference to
state that configured values must be greater than 0, while preserving its
existing millisecond deadline and fallback behavior description.
🪄 Autofix

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: 11fbe7ac-a93b-4219-8ce6-08fb14cbfb42

📥 Commits

Reviewing files that changed from the base of the PR and between fb3fc30 and 68803ec.

📒 Files selected for processing (7)
  • crates/libsy/src/algorithms/llm_class.rs
  • crates/libsy/src/algorithms/util.rs
  • crates/libsy/src/algorithms/util/llm_judge.rs
  • crates/libsy/src/algorithms/util/robustness.rs
  • crates/switchyard-py/src/libsy_bindings.rs
  • crates/switchyard-server/src/config.rs
  • docs/reference/toml_schema.md

)
}
// Built by libsy from static text plus a serde position, so safe verbatim.
LibsyError::AlgorithmError { message } => message.clone(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the error definitions and all direct constructors.
rg -n -C 5 'enum\s+(LibsyError|LlmClientError)\b' crates
rg -n -C 3 'LibsyError::AlgorithmError\s*\{|LlmClientError::Configuration\s*\{' crates

Repository: NVIDIA-NeMo/Switchyard

Length of output: 41652


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- error definitions ---'
cat -n crates/libsy/src/error.rs | sed -n '1,95p'
cat -n crates/protocol/src/client.rs | sed -n '1,125p'

printf '%s\n' '--- robustness implementation and tests ---'
cat -n crates/libsy/src/algorithms/util/robustness.rs | sed -n '1,165p'

printf '%s\n' '--- dynamic AlgorithmError constructors ---'
rg -n -C 8 'LibsyError::AlgorithmError\s*\{' crates/libsy/src crates/protocol/src crates/libsy-llm-client/src

printf '%s\n' '--- dynamic Configuration constructors ---'
cat -n crates/libsy-llm-client/src/client.rs | sed -n '125,155p;375,405p;600,630p'
cat -n crates/libsy-llm-client/src/run.rs | sed -n '232,255p'
cat -n crates/switchyard-translation/src/helpers.rs | sed -n '82,102p'

Repository: NVIDIA-NeMo/Switchyard

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

roots = [Path("crates/libsy/src"), Path("crates/libsy-llm-client/src"), Path("crates/switchyard-translation/src")]
pattern = re.compile(r"(LibsyError::AlgorithmError|LlmClientError::Configuration)\s*\{")
for root in roots:
    for path in root.rglob("*.rs"):
        lines = path.read_text(errors="replace").splitlines()
        for i, line in enumerate(lines):
            if not pattern.search(line):
                continue
            window = "\n".join(lines[i:min(i + 10, len(lines))])
            msg = re.search(r"message\s*:\s*(.*)", window)
            if msg:
                rhs = msg.group(1).strip()
                kind = "literal/static" if rhs.startswith('"') else "dynamic/propagated"
                print(f"{path}:{i+1}: {kind}: {rhs[:180]}")
PY

printf '%s\n' '--- relevant message inputs and call sites ---'
rg -n -C 6 'invalid_weights\(|failed to aggregate efficient response|parse_json_verdict|response schema is invalid|Configuration \{' \
  crates/libsy/src crates/libsy-llm-client/src crates/switchyard-translation/src

Repository: NVIDIA-NeMo/Switchyard

Length of output: 30449


Do not log message fields verbatim. AlgorithmError::message and LlmClientError::Configuration::message accept unconstrained dynamic content, including response, request, and upstream error details. Return fixed summaries for these variants, or whitelist only structural fields.

🤖 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/robustness.rs` at line 34, Update the
error-to-string handling around the LibsyError::AlgorithmError match arm so it
no longer returns the unconstrained message field verbatim. Replace it with a
fixed safe summary, and apply the same treatment to
LlmClientError::Configuration::message; only include explicitly whitelisted
structural fields if needed.

Comment on lines +74 to +75
// `General` and any future variant are unvetted by construction.
_ => "client call failed".to_string(),

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify enum variants and whether Rust requires a wildcard arm.
rg -n -C 12 'enum\s+LlmClientError\b|non_exhaustive|LlmClientError::General' crates

Repository: NVIDIA-NeMo/Switchyard

Length of output: 27974


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- LlmClientError definition ---'
sed -n '18,95p' crates/protocol/src/client.rs

printf '%s\n' '--- robustness implementation ---'
sed -n '1,90p' crates/libsy/src/algorithms/util/robustness.rs

printf '%s\n' '--- workspace/package boundaries ---'
rg -n -C 3 'name\s*=\s*"switchyard-protocol"|name\s*=\s*"libsy"|switchyard_protocol' crates/protocol/Cargo.toml crates/libsy/Cargo.toml crates/libsy/src/algorithms/util/robustness.rs

Repository: NVIDIA-NeMo/Switchyard

Length of output: 8475


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- remaining LlmClientError variants ---'
sed -n '85,125p' crates/protocol/src/client.rs

printf '%s\n' '--- all safe_client_error arms and enum constructors ---'
rg -n -C 2 'LlmClientError::|pub enum LlmClientError' crates/libsy/src/algorithms/util/robustness.rs crates/protocol/src crates/libsy/src

Repository: NVIDIA-NeMo/Switchyard

Length of output: 22808


Match LlmClientError::General explicitly.

LlmClientError is #[non_exhaustive], so retain _ for future variants. Add an explicit General(_) arm to document the redaction policy for every current variant.

🤖 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/robustness.rs` around lines 74 - 75, Update
the error matching logic in the surrounding robustness function to add an
explicit LlmClientError::General(_) arm returning the same redacted "client call
failed" message, while retaining the wildcard arm for future non-exhaustive
variants.

Comment on lines +397 to +403
/// Bounds one judge consultation, in milliseconds.
///
/// The judge call sits in front of the routed call, so a stalled judge stalls
/// the turn. On expiry the judge counts as unavailable and the route falls back
/// as it does for any other judge failure. Omit to leave it unbounded.
#[serde(default)]
judge_deadline_ms: Option<u64>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Do not silently ignore judge_deadline_ms outside capability mode.

Escalation and custom routes accept this setting, but only capability mode forwards it to TaskClassifierConfig. A configured deadline therefore has no effect for those modes.

  • crates/switchyard-server/src/config.rs#L397-L403: Scope judge_deadline_ms to capability mode, or add deadline support for every documented mode.
  • crates/switchyard-server/src/config.rs#L605-L663: If deadline support remains capability-only, reject judge_deadline_ms for escalation and custom modes.
  • docs/reference/toml_schema.md#L121-L121: Document the supported mode scope.
📍 Affects 2 files
  • crates/switchyard-server/src/config.rs#L397-L403 (this comment)
  • crates/switchyard-server/src/config.rs#L605-L663
  • docs/reference/toml_schema.md#L121-L121
🤖 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 397 - 403, Ensure
judge_deadline_ms is not silently ignored: either propagate it through
escalation and custom routes or keep it capability-only and reject it during
mode validation. Update the relevant configuration validation flow around the
mode handling block, and document the supported mode scope in
docs/reference/toml_schema.md at line 121.

| `mode` | No | `capability` | Classifier behavior. Set it explicitly for new configurations. |
| `classifier_target` | Yes | — | Target the judge is called through. Not a routing destination. |
| `max_output_tokens` | No | `4096` | Maximum completion tokens for the judge verdict. Must be at least `1`. |
| `judge_deadline_ms` | No | unset | Bounds one judge consultation, in milliseconds. On expiry the judge counts as unavailable and the route falls back as it does for any other judge failure. |

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

Document the zero-value restriction.

judge_deadline_ms = 0 is rejected during classifier construction. State that the value must be greater than 0.

🤖 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 `@docs/reference/toml_schema.md` at line 121, Update the judge_deadline_ms
entry in the TOML schema reference to state that configured values must be
greater than 0, while preserving its existing millisecond deadline and fallback
behavior description.

@ayushag-nv

Copy link
Copy Markdown
Contributor

@gburachas can you address coderabbit comments first, then I can review.

Comment on lines +36 to +45
/// Restates the task after the conversation the judge is asked to route.
///
/// The rubric leads the request, which works while the payload is a single short
/// message. Once `recent_turn_window` includes real conversation, those instructions
/// sit far from the generation point and the judge sometimes *answers* the
/// conversation instead of classifying it — the reply then fails to parse, the
/// verdict is unavailable, and the turn silently falls back. Repeating the task at
/// the end is what reliably prevents that.
const END_POSITION_REINFORCEMENT: &str =
"Route the conversation above. Output ONLY the routing JSON object, nothing else.";

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 looks like some new feature add, may be we should discuss this once. Can you break down into smaller directed PRs for ease of review. Looks like it solves three issues simultaneously

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants