Skip to content

Linj router exp#61

Open
linj-glitch wants to merge 4 commits into
NVIDIA-NeMo:mainfrom
linj-glitch:linj_router_exp
Open

Linj router exp#61
linj-glitch wants to merge 4 commits into
NVIDIA-NeMo:mainfrom
linj-glitch:linj_router_exp

Conversation

@linj-glitch

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

Copy link
Copy Markdown
Contributor

What

Short summary of what this PR changes.

Why

The motivation — what problem does this solve, or which ticket does it close?

Closes #

How tested

  • uv run ruff check . clean
  • uv run mypy switchyard clean
  • uv run pytest tests/ green
  • Manual smoke (describe what was run)

Checklist

  • One class per file; filename = snake_case of the primary class.
  • New public symbols exported from switchyard/__init__.py.__all__ if intended for downstream use.
  • Unit tests added for new components / bug fixes.
  • README / --help updated if customer-facing surface changed.
  • Commits signed off (Signed-off-by: Your Name <email>) per the DCO.

Notes for reviewers

Anything reviewers should pay extra attention to — risky paths, follow-up tickets, intentional trade-offs.

Summary by CodeRabbit

  • New Features

    • Added escalation-router routing, starting conversations on a lightweight model and moving them to a stronger model when needed.
    • Added configurable judge behavior, confirmation thresholds, session isolation, and fallback handling.
    • Added new benchmark profiles for escalation and single-model comparisons.
    • Added a benchmark comparison utility with side-by-side performance, token, cost, and task results.
  • Documentation

    • Added comprehensive escalation-router setup and usage guidance.
  • Bug Fixes

    • Expanded cost estimates for additional supported models and providers.

@linj-glitch linj-glitch requested a review from a team as a code owner July 13, 2026 23:10
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds an LLM-judged escalation router with strong-tier session latching, YAML and Python configuration, CLI dispatch, tests, documentation, benchmark profiles, run comparison tooling, and expanded model pricing.

Changes

Escalation Router

Layer / File(s) Summary
Judge processing and escalation state
switchyard/lib/processors/escalation_judge_request_processor.py, tests/test_escalation_judge_request_processor.py
Implements weak-by-default routing, judge verdict handling, confirmation windows, fail-open behavior, transcript limits, deep session keys, affinity latching, and classifier statistics.
Router configuration and profile construction
switchyard/lib/profiles/*, switchyard/__init__.py, tests/test_escalation_router_profile.py
Adds immutable router configuration, profile construction, tier backends, DeepSeek-specific headers, affinity wiring, and public exports.
Route-bundle validation and dispatch
switchyard/cli/route_bundle.py, tests/test_route_bundle.py
Adds escalation_router validation, aliases, target discovery, YAML conversion, runtime dispatch, and configuration error tests.
Documentation and profile guidance
docs/routing_algorithms/*, mkdocs.yml, .agents/skills/switchyard-lib-core/SKILL.md
Documents routing behavior, configuration, affinity isolation, known limits, navigation, profile references, and cost-estimation rules.
Benchmark profiles, comparison, and pricing
benchmark/compare_runs.py, benchmark/routing-profiles/*, switchyard/lib/cost_estimator.py, crates/switchyard-components/src/stats/cost.rs
Adds benchmark routes, side-by-side run metrics, and pricing aliases for Nemotron, Claude Opus, and Claude Sonnet models.

Estimated code review effort: 5 (Critical) | ~120 minutes

Poem

I’m a rabbit routing through the hay,
Judging each turn along the way.
Weak hops first, then strong takes flight,
Costs and trials line up right.
New paths bloom in docs tonight.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is too vague to convey the main change; it reads like a branch or experiment label rather than a meaningful summary. Use a concise, specific title that names the primary change, such as adding the escalation router profile and related routing support.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
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: 10

🧹 Nitpick comments (1)
docs/routing_algorithms/escalation_router_routing.md (1)

64-75: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Config table omits several valid judge.* keys and enable_stats.

Per _ESCALATION_JUDGE_KEYS in switchyard/cli/route_bundle.py and EscalationRouterConfig, judge.confirmations, judge.confirmation_window, judge.disable_reasoning, judge.window_message_chars, and top-level enable_stats are all accepted config keys with documented defaults in the config docstring, but none appear in this table. Users tuning confirmation/latch behavior have no doc reference for these.

🤖 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/routing_algorithms/escalation_router_routing.md` around lines 64 - 75,
Update the configuration table to document the accepted keys
judge.confirmations, judge.confirmation_window, judge.disable_reasoning,
judge.window_message_chars, and top-level enable_stats. Use the documented
defaults and meanings from EscalationRouterConfig and its config docstring,
keeping the existing table format and entries 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 `@benchmark/compare_runs.py`:
- Around line 72-73: Update the trial-count extraction in the run comparison
rows to safely handle missing harbor-level n_completed_trials and
n_errored_trials fields. Reuse the available stats.evals.*.n_trials data to
derive a sensible completed-trials count when the aggregate completed field is
absent, while preserving existing values when present and avoiding
direct-subscript KeyErrors.
- Around line 25-28: Update the result-loading logic around results in the
benchmark comparison flow so it does not silently use only results[0] when
multiple job result files exist. Either aggregate all matched result.json files
into the comparison input or explicitly raise an error when more than one file
is present, while preserving the existing missing-results failure behavior.

In `@benchmark/routing-profiles/tb-lite-escalation-opus-deepseek-gemini.yaml`:
- Around line 12-29: Add pricing-table aliases for the exact model IDs
anthropic/claude-opus-4.7 and google/gemini-3.5-flash, mapping each to its
corresponding existing cost entry so strong and judge usage is reported instead
of unknown. Leave the profile configuration unchanged.

In `@docs/routing_algorithms/escalation_router_routing.md`:
- Line 73: Update the session_key_depth link to use the exact generated anchor
for the “Repeated-trial benchmarking (k>1)” heading, verifying the slug with
MkDocs or rendered HTML before finalizing the fragment.

In `@switchyard/lib/processors/escalation_judge_request_processor.py`:
- Around line 738-745: Update the request assembly and cap enforcement around
_assemble so exceeding config.max_request_chars never uses a head-only slice
that can remove the task or newest evidence. Re-budget the available length by
trimming system/developer anchors first, then remove the oldest window lines as
needed, while preserving the first-user task and latest evidence in the final
assembled text.
- Around line 280-289: Update the dump_verdicts_to_stderr configuration to be
opt-in by default, and ensure the escalation verdict stderr payload excludes or
explicitly redacts user-derived fields such as task_hint, judge-generated
reason, and fail-open exception details. Preserve dumping only when callers
explicitly enable the setting.
- Around line 479-488: After the successful await of _affinity.pin in the
confirmed branch of the escalation processor, remove the corresponding session
key from _escalate_streaks before updating routing metadata and returning.
Preserve the existing behavior for unconfirmed verdicts and only clear the
streak after pin succeeds.

In `@switchyard/lib/profiles/escalation_router_config.py`:
- Line 84: Update the judge configuration flow in build() to use judge_timeout_s
as the fallback whenever judge_target.endpoint.timeout_secs is unset, while
preserving the explicit endpoint timeout when provided. Add or update a test
covering both precedence cases.
- Line 90: Update EscalationRouterProfileConfig.build() to consume enable_stats
and thread its value through profile composition so the documented stats
processors and tier wrappers are enabled when true and omitted when false. Add
coverage for both enable_stats values, or remove the option if it cannot be
wired to those behaviors.

In `@switchyard/lib/profiles/escalation_router_profile_config.py`:
- Around line 126-130: Update the ComponentChainProfile construction in the
escalation router profile configuration to translate the validated
fallback_target_on_evict ID into its corresponding deterministic tier label,
"strong" or "weak", before passing it as fallback_target_on_evict. Preserve the
existing behavior for standard IDs and add coverage for custom strong.id and
weak.id values.

---

Nitpick comments:
In `@docs/routing_algorithms/escalation_router_routing.md`:
- Around line 64-75: Update the configuration table to document the accepted
keys judge.confirmations, judge.confirmation_window, judge.disable_reasoning,
judge.window_message_chars, and top-level enable_stats. Use the documented
defaults and meanings from EscalationRouterConfig and its config docstring,
keeping the existing table format and entries 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: 22a2f7fd-102a-4b09-9fa8-179ab4009a53

📥 Commits

Reviewing files that changed from the base of the PR and between a2a939d and be7f575.

📒 Files selected for processing (21)
  • .agents/skills/switchyard-lib-core/SKILL.md
  • benchmark/compare_runs.py
  • benchmark/routing-profiles/tb-lite-escalation-opus-deepseek-gemini.yaml
  • benchmark/routing-profiles/tb-lite-escalation-opus-deepseek-nvidia.yaml
  • benchmark/routing-profiles/tb-lite-single-deepseek-v4-pro-nvidia.yaml
  • benchmark/routing-profiles/tb-lite-single-opus-4-7-nvidia.yaml
  • benchmark/routing-profiles/tb-lite-single-opus-4-8-nvidia.yaml
  • crates/switchyard-components/src/stats/cost.rs
  • docs/routing_algorithms/escalation_router_routing.md
  • docs/routing_algorithms/overview.md
  • mkdocs.yml
  • switchyard/__init__.py
  • switchyard/cli/route_bundle.py
  • switchyard/lib/cost_estimator.py
  • switchyard/lib/processors/escalation_judge_request_processor.py
  • switchyard/lib/profiles/__init__.py
  • switchyard/lib/profiles/escalation_router_config.py
  • switchyard/lib/profiles/escalation_router_profile_config.py
  • tests/test_escalation_judge_request_processor.py
  • tests/test_escalation_router_profile.py
  • tests/test_route_bundle.py

Comment thread benchmark/compare_runs.py
Comment on lines +25 to +28
results = sorted(run_dir.glob("jobs/*/result.json"))
if not results:
raise FileNotFoundError(f"no jobs/*/result.json under {run_dir}")
harbor = json.loads(results[0].read_text())

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 | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -a '^result\.json$' benchmark -x sh -c '
  case "$1" in
    */jobs/*/result.json) printf "%s\n" "$1" ;;
  esac
' sh {} | head -100

rg -n -C3 'jobs/\*/result\.json|n_completed_trials|reward_stats' benchmark tests

Repository: NVIDIA-NeMo/Switchyard

Length of output: 1966


Do not ignore additional job results in benchmark/compare_runs.py:25-28. The glob can match multiple jobs/*/result.json files, but only results[0] is read, so multi-job runs will report a single lexicographically first job instead of the run as a whole. Aggregate the results or fail fast when more than one file is present.

🤖 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 `@benchmark/compare_runs.py` around lines 25 - 28, Update the result-loading
logic around results in the benchmark comparison flow so it does not silently
use only results[0] when multiple job result files exist. Either aggregate all
matched result.json files into the comparison input or explicitly raise an error
when more than one file is present, while preserving the existing
missing-results failure behavior.

Comment thread benchmark/compare_runs.py
Comment on lines +72 to +73
row("completed trials", [str(r["harbor"]["n_completed_trials"]) for r in runs.values()])
row("errored trials", [str(r["harbor"]["n_errored_trials"]) for r in runs.values()])

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

Handle Harbor results without run-level trial counters.

The baseline fixture in tests/test_run_baseline_script.py contains stats.evals.*.n_trials but no stats.n_completed_trials or stats.n_errored_trials; these direct subscripts therefore crash the utility. Use safe reads and derive a sensible completed count when the aggregate fields are absent.

🤖 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 `@benchmark/compare_runs.py` around lines 72 - 73, Update the trial-count
extraction in the run comparison rows to safely handle missing harbor-level
n_completed_trials and n_errored_trials fields. Reuse the available
stats.evals.*.n_trials data to derive a sensible completed-trials count when the
aggregate completed field is absent, while preserving existing values when
present and avoiding direct-subscript KeyErrors.

Comment on lines +12 to +29
model: anthropic/claude-opus-4.7
api_key: ${OPENROUTER_API_KEY}
base_url: https://openrouter.ai/api/v1
format: openai
timeout_secs: 600
extra_body:
reasoning:
effort: high
weak:
model: deepseek/deepseek-v4-pro
api_key: ${OPENROUTER_API_KEY}
base_url: https://openrouter.ai/api/v1
format: openai
timeout_secs: 600
judge:
model: google/gemini-3.5-flash
api_key: ${OPENROUTER_API_KEY}
base_url: https://openrouter.ai/api/v1

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== profile file ==\n'
git ls-files 'benchmark/routing-profiles/tb-lite-escalation-opus-deepseek-gemini.yaml' && \
  sed -n '1,120p' benchmark/routing-profiles/tb-lite-escalation-opus-deepseek-gemini.yaml

printf '\n== search pricing/lookup references ==\n'
rg -n "claude-opus-4\.7|gemini-3\.5-flash|enable_stats|pricing|alias|unknown IDs|zero" -S .

printf '\n== locate likely pricing files ==\n'
git ls-files | rg 'pricing|cost|model.*price|models?.*yaml|models?.*json|bench'

Repository: NVIDIA-NeMo/Switchyard

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== cost table around gemini/opus mappings ==\n'
sed -n '1,340p' crates/switchyard-components/src/stats/cost.rs

printf '\n== tests around unknown/default behavior ==\n'
sed -n '1,120p' tests/test_cost_estimator_gemini.py 2>/dev/null || true
sed -n '1,360p' crates/switchyard-components/src/stats/cost.rs | sed -n '240,320p'

Repository: NVIDIA-NeMo/Switchyard

Length of output: 12882


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== cost table around gemini/opus mappings ==\n'
sed -n '1,340p' crates/switchyard-components/src/stats/cost.rs

printf '\n== tests around unknown/default behavior ==\n'
sed -n '1,120p' tests/test_cost_estimator_gemini.py 2>/dev/null || true

Repository: NVIDIA-NeMo/Switchyard

Length of output: 11870


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== opus/gemini pricing entries ==\n'
rg -n -A4 -B4 'claude-opus-4\.7|gemini-3\.5-flash|unknown_model_defaults_to_zero|ModelPrice' crates/switchyard-components/src/stats/cost.rs

printf '\n== any other pricing tables or normalization helpers ==\n'
rg -n 'claude-opus-4\.7|gemini-3\.5-flash|normalize.*model|pricing.*alias|model.*alias' crates switchyard tests -S

Repository: NVIDIA-NeMo/Switchyard

Length of output: 12339


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== python cost estimator pricing table ==\n'
sed -n '1,260p' switchyard/lib/cost_estimator.py

printf '\n== live stats model normalization ==\n'
sed -n '1,140p' switchyard/lib/live_stats_collector.py

printf '\n== exact references around claude/gemini pricing in python ==\n'
rg -n -A3 -B3 'claude-opus-4\.7|gemini-3\.5-flash|MODEL_PRICING|_PRICE_PER_1M|normalize_model_name' switchyard/lib switchyard/tests tests -S

Repository: NVIDIA-NeMo/Switchyard

Length of output: 35836


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -A6 -B6 'MODEL_PRICING|claude-opus-4\.7|gemini-3\.5-flash|_normalize_model_name|_PRICE_PER_1M' switchyard/lib/cost_estimator.py switchyard/lib/live_stats_collector.py

Repository: NVIDIA-NeMo/Switchyard

Length of output: 11983


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,240p' switchyard/lib/cost_estimator.py

Repository: NVIDIA-NeMo/Switchyard

Length of output: 11369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== cost estimator call sites ==\n'
rg -n -A4 -B4 'estimate_cost\(|MODEL_PRICING|estimated_cost_usd|to_dict\(\)|routing/stats' switchyard crates tests -S

printf '\n== model name normalization in stats accumulation ==\n'
rg -n -A4 -B4 '_normalize_model_name|estimated_cost_usd|record\(|snapshot\(|to_dict\(' switchyard/lib/live_stats_collector.py switchyard/lib/stats* crates/switchyard-components/src/stats -S

printf '\n== route/profile model names used for stats ==\n'
rg -n -A3 -B3 'model: anthropic/claude-opus-4\.7|model: google/gemini-3\.5-flash|anthropic/claude-opus-4\.7|google/gemini-3\.5-flash' benchmark switchyard crates tests -S

Repository: NVIDIA-NeMo/Switchyard

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== search for model-id rewrite/normalization rules ==\n'
rg -n -A4 -B4 'claude-opus-4\.7|claude-opus-4-7|google/gemini-3\.5-flash|gcp/google/gemini-3\.5-flash|replace\(|normalize.*model|alias.*model' switchyard crates tests docs -S

printf '\n== route/stats serialization path ==\n'
rg -n -A4 -B4 'routing/stats|estimated_cost_usd|estimated_cost|ModelStats|model_id|model_name' crates/switchyard-components/src switchyard/lib switchyard/server -S

Repository: NVIDIA-NeMo/Switchyard

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '140,320p' switchyard/lib/live_stats_collector.py

Repository: NVIDIA-NeMo/Switchyard

Length of output: 5875


Add pricing aliases for the exact model IDs used here. anthropic/claude-opus-4.7 and google/gemini-3.5-flash don’t match the current cost-table keys, so this profile’s strong/judge spend will show up as unknown and underreported in routing stats.

🤖 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 `@benchmark/routing-profiles/tb-lite-escalation-opus-deepseek-gemini.yaml`
around lines 12 - 29, Add pricing-table aliases for the exact model IDs
anthropic/claude-opus-4.7 and google/gemini-3.5-flash, mapping each to its
corresponding existing cost entry so strong and judge usage is reported instead
of unknown. Leave the profile configuration unchanged.

| `judge.prompt` | built-in | Judge system-prompt override. |
| `judge.max_request_chars` | `12000` | Cap on the judge transcript; oldest window messages are dropped first. |
| `fallback_target_on_evict` | required | `strong` or `weak`; context-window-eviction reroute target. |
| `session_key_depth` | `0` | See [Repeated-trial benchmarking](#repeated-trial-benchmarking) below. |

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

Broken link fragment to “Repeated-trial benchmarking (k>1)”.

MkDocs slugifies headings by converting disallowed characters (including (, ), >) to dashes and collapsing consecutive dashes, so this heading's actual anchor is not #repeated-trial-benchmarking. The link will 404/fail to scroll to the section.

📝 Proposed fix
-| `session_key_depth` | `0` | See [Repeated-trial benchmarking](`#repeated-trial-benchmarking`) below. |
+| `session_key_depth` | `0` | See [Repeated-trial benchmarking](`#repeated-trial-benchmarking-k1`) below. |

Verify the exact generated anchor (e.g. via mkdocs build --strict or inspecting rendered HTML) since punctuation slugification can vary.

📝 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
| `session_key_depth` | `0` | See [Repeated-trial benchmarking](#repeated-trial-benchmarking) below. |
| `session_key_depth` | `0` | See [Repeated-trial benchmarking](`#repeated-trial-benchmarking-k1`) below. |
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 73-73: Link fragments should be valid

(MD051, link-fragments)

🤖 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/routing_algorithms/escalation_router_routing.md` at line 73, Update the
session_key_depth link to use the exact generated anchor for the “Repeated-trial
benchmarking (k>1)” heading, verifying the slug with MkDocs or rendered HTML
before finalizing the fragment.

Source: Linters/SAST tools

Comment on lines +280 to +289
dump_verdicts_to_stderr: bool = True
"""Emit one ``escalation_verdict={...}`` JSON line to ``sys.stderr`` per
judge call (verdict or fail-open).

Written directly (not via the logging module) so it lands in the
benchmark server's captured log regardless of uvicorn's logger config —
mirrors :attr:`LLMClassifierConfig.dump_signals_to_stderr`. Grep
``escalation_verdict=`` to reconstruct per-turn judge decisions and
escalation timing for a run. Disable for callers that share stderr
with an interactive TUI."""

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

Do not log user-derived verdict data by default.

The default stderr dump includes task_hint, judge-generated reason, and fail-open exception details. These can expose prompts, secrets, or provider data in benchmark/server logs. Make dumping opt-in and omit or explicitly redact user-derived fields.

Also applies to: 548-556

🤖 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 `@switchyard/lib/processors/escalation_judge_request_processor.py` around lines
280 - 289, Update the dump_verdicts_to_stderr configuration to be opt-in by
default, and ensure the escalation verdict stderr payload excludes or explicitly
redacts user-derived fields such as task_hint, judge-generated reason, and
fail-open exception details. Preserve dumping only when callers explicitly
enable the setting.

Comment on lines +479 to +488
if confirmed:
log.info(
"EscalationJudgeRequestProcessor: escalating to strong tier "
"(turn %d): %s",
turn,
verdict.reason,
)
await self._affinity.pin(ctx, request, TIER_STRONG)
ctx.metadata[CTX_DETERMINISTIC_ROUTING_TIER] = TIER_STRONG
return request

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

Clear the confirmation streak after successfully latching.

The confirmed streak remains in _escalate_streaks. If the bounded affinity pin is later evicted, the same session key can resume with an already-confirmed count and immediately re-escalate. Remove the entry after pin() succeeds.

Proposed fix
 if confirmed:
     await self._affinity.pin(ctx, request, TIER_STRONG)
+    self._escalate_streaks.pop(self._streak_key(ctx, request), None)
     ctx.metadata[CTX_DETERMINISTIC_ROUTING_TIER] = TIER_STRONG
📝 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
if confirmed:
log.info(
"EscalationJudgeRequestProcessor: escalating to strong tier "
"(turn %d): %s",
turn,
verdict.reason,
)
await self._affinity.pin(ctx, request, TIER_STRONG)
ctx.metadata[CTX_DETERMINISTIC_ROUTING_TIER] = TIER_STRONG
return request
if confirmed:
log.info(
"EscalationJudgeRequestProcessor: escalating to strong tier "
"(turn %d): %s",
turn,
verdict.reason,
)
await self._affinity.pin(ctx, request, TIER_STRONG)
self._escalate_streaks.pop(self._streak_key(ctx, request), None)
ctx.metadata[CTX_DETERMINISTIC_ROUTING_TIER] = TIER_STRONG
return request
🤖 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 `@switchyard/lib/processors/escalation_judge_request_processor.py` around lines
479 - 488, After the successful await of _affinity.pin in the confirmed branch
of the escalation processor, remove the corresponding session key from
_escalate_streaks before updating routing metadata and returning. Preserve the
existing behavior for unconfirmed verdicts and only clear the streak after pin
succeeds.

Comment on lines +738 to +745
text = _assemble()
# Drop oldest window lines first: for a trajectory judge the newest
# evidence is strictly the most valuable.
while len(text) > config.max_request_chars and window_lines:
window_lines.pop(0)
text = _assemble()
if len(text) > config.max_request_chars:
text = text[: config.max_request_chars - 15] + "...<truncated>"

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

Preserve the task and newest evidence when enforcing the global cap.

After dropping window lines, the final head-only slice can discard the first-user task and all recent evidence when system/developer anchors exceed the cap. Re-budget or trim system anchors first instead of truncating the assembled transcript from the end.

🤖 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 `@switchyard/lib/processors/escalation_judge_request_processor.py` around lines
738 - 745, Update the request assembly and cap enforcement around _assemble so
exceeding config.max_request_chars never uses a head-only slice that can remove
the task or newest evidence. Re-budget the available length by trimming
system/developer anchors first, then remove the oldest window lines as needed,
while preserving the first-user task and latest evidence in the final assembled
text.

judge_window_message_chars: int = Field(default=300, ge=50)
judge_max_request_chars: int = Field(default=12_000, ge=1_000)
judge_system_prompt: str | None = Field(default=None, min_length=1)
judge_timeout_s: float = Field(default=5.0, gt=0.0)

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

Wire judge_timeout_s into the judge configuration.

This setting is never read: build() uses judge_target.endpoint.timeout_secs or 5.0, so a configured judge_timeout_s is ignored. Use it as the default when the judge target has no explicit timeout, and cover that precedence in a test.

🤖 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 `@switchyard/lib/profiles/escalation_router_config.py` at line 84, Update the
judge configuration flow in build() to use judge_timeout_s as the fallback
whenever judge_target.endpoint.timeout_secs is unset, while preserving the
explicit endpoint timeout when provided. Add or update a test covering both
precedence cases.

default=DEFAULT_DETERMINISTIC_TIER_TIMEOUT_S,
gt=0.0,
)
enable_stats: bool = True

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 | 🏗️ Heavy lift

Implement or remove the inert enable_stats option.

EscalationRouterProfileConfig.build() never reads this field, so it cannot enable or disable the documented stats processors and tier wrappers. Thread it through the profile composition and test both values.

🤖 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 `@switchyard/lib/profiles/escalation_router_config.py` at line 90, Update
EscalationRouterProfileConfig.build() to consume enable_stats and thread its
value through profile composition so the documented stats processors and tier
wrappers are enabled when true and omitted when false. Add coverage for both
enable_stats values, or remove the option if it cannot be wired to those
behaviors.

Comment on lines +126 to +130
return ComponentChainProfile(
request_processors=request_processors,
backend=backend,
fallback_target_on_evict=config.fallback_target_on_evict,
)

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

Translate fallback target IDs to deterministic tier labels.

The config accepts strong.id / weak.id, but the backend recognizes only "strong" / "weak". With custom IDs, eviction writes an unrecognized ctx.selected_target, then routing falls back to stale metadata or weak instead of the requested fallback. Map the validated target ID to its tier label before passing it here, and test custom IDs.

🤖 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 `@switchyard/lib/profiles/escalation_router_profile_config.py` around lines 126
- 130, Update the ComponentChainProfile construction in the escalation router
profile configuration to translate the validated fallback_target_on_evict ID
into its corresponding deterministic tier label, "strong" or "weak", before
passing it as fallback_target_on_evict. Preserve the existing behavior for
standard IDs and add coverage for custom strong.id and weak.id values.

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.

1 participant