Skip to content

feat(libsy): add EscalationRouter algorithm - #172

Open
linj-glitch wants to merge 16 commits into
mainfrom
linj/switch-1054-escalation-router
Open

feat(libsy): add EscalationRouter algorithm#172
linj-glitch wants to merge 16 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: calls the efficient model first, runs a judge against its response, and escalates to the capable model if quality is insufficient. Escalated sessions are pinned to the capable model for subsequent turns.

Summary by CodeRabbit

  • New Features

    • Added trajectory-based model escalation from an efficient model to a capable model when repeated issues are detected.
    • Sessions remain pinned to the capable model after escalation.
    • Added configurable judge timeouts and strict escalation decision formatting.
    • Preserved streaming responses without buffering during routing.
  • Bug Fixes

    • Judge failures, invalid responses, and timeouts now safely continue with the efficient model.
  • Documentation

    • Added guidance describing escalation behavior and decision criteria.

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

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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately highlights the main change: adding the EscalationRouter algorithm to libsy.
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.

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

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.

3 participants