Skip to content

feat(libsy): advisor_gate review-gate algorithm - #371

Open
eric-liu-nvidia wants to merge 2 commits into
mainfrom
claude/advisor-review-gate-rust
Open

feat(libsy): advisor_gate review-gate algorithm#371
eric-liu-nvidia wants to merge 2 commits into
mainfrom
claude/advisor-review-gate-rust

Conversation

@eric-liu-nvidia

@eric-liu-nvidia eric-liu-nvidia commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Stack 1/3 — this PR now contains only the core algorithm (libsy), per review. The rest of the original diff moved up the stack:

Stack PR Content
1/3 this PR AdvisorGate libsy algorithm + unit tests
2/3 #382 type = "advisor" server route config
3/3 #383 /v1/stats projection + server e2e tests

Adds the advisor review gate to the Rust server as a new route type: type = "advisor".

Benchmark results (Terminal-Bench 2.1)

Configuration Accuracy
Nemotron 3 Ultra (baseline) 43.8%
Nemotron 3 Ultra + Opus 4.8 as advisor review gate 54.7% ± 0.7 pp

With $64.76 ± $4.95 additional Opus 4.8 cost, the advisor review gate lifts Nemotron 3 Ultra accuracy by almost 25%.

How it works

There are two models: an executor (the model doing the work) and an advisor (a stronger model that checks the work).

  1. The client talks to the executor as usual. While the executor is still working — its replies contain tool calls — everything passes straight through. The advisor is not involved.
  2. When the executor produces a reply with no tool calls, that means it thinks it is done (or it is proposing a plan). The proxy holds that reply back instead of returning it.
  3. The proxy sends the conversation and the held-back reply to the advisor and asks one question: is this work complete and correct? The advisor answers APPROVE or REDO.
  4. APPROVE → the held-back reply is released to the client unchanged.
    REDO → the client never sees that reply. The proxy puts the advisor's feedback into the conversation ("not done yet — here is what is missing") and calls the executor again, so it keeps working.
  5. The review happens once per session (max_reviews, default 1). After that, every call is a plain passthrough with zero overhead.

If the advisor is down or replies with something unparseable, the gate fails open: the executor's reply goes to the client as if approved. Advisor problems never block the executor.

Why this shape: giving advice up front made the executor trust the plan and skip its own test-and-iterate loop. Checking only the final "I'm done" claim leaves the executor's normal behavior untouched and catches exactly one failure mode — stopping too early.

Example

[routes.gated]
id = "switchyard-advisor"
type = "advisor"
executor_target = "executor"
advisor_target = "advisor"   # judge-only; set max_retries = 0 on its llm_client

Note for reviewers: this adds regex as a production dependency of libsy (for the configurable review trigger pattern).

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

🚀 View preview at
https://NVIDIA-NeMo.github.io/Switchyard/pr-preview/pr-371/

Built to branch gh-pages at 2026-08-12 21:51 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Advisor gate

Layer / File(s) Summary
Public API and route contract
Cargo.toml, crates/libsy/Cargo.toml, crates/libsy/src/algorithms.rs, crates/libsy/src/algorithms/advisor_gate.rs, crates/libsy/src/lib.rs
Adds the AdvisorGate configuration types, public exports, module wiring, and workspace dependencies.
AdvisorGate execution flow
crates/libsy/src/algorithms/advisor_gate.rs, crates/libsy/src/algorithms/util/*
Buffers executor turns, invokes the advisor on configured triggers, handles APPROVE and REDO, supports streaming replay, enforces scoped budgets, and records telemetry.
Server route construction
crates/switchyard-server/src/config.rs
Adds Advisor routes, target resolution, trigger validation, capability handling, defaults, and configuration tests.
Advisor gate statistics
crates/switchyard-server/src/stats/algorithms.rs, crates/switchyard-server/src/stats/algorithms/advisor_gate.rs
Projects advisor-gate Prometheus counters into reset-relative serialized snapshots.
End-to-end advisor coverage
crates/switchyard-server/tests/server.rs
Tests approval, redo, budgets, streaming, routing logs, token counting, failures, and statistics resets.

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

Poem

A rabbit reviews each turn,
“APPROVE” makes bright replies return.
“REDO” sends feedback down the track,
Streams replay, then hop right back.
Budgets, stats, and tests all bloom.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 86.96% which is sufficient. The required threshold is 80.00%.
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 identifies the main change: adding the libsy advisor-gate review algorithm.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/switchyard-server/src/stats/algorithms.rs`:
- Line 31: Add concise Rust doc comments for the public advisor_gate field in
crates/switchyard-server/src/stats/algorithms.rs (lines 31-31), describing when
it appears in the serialized response. Also document every public field in
crates/switchyard-server/src/stats/algorithms/advisor_gate.rs (lines 42-53),
specifying each field’s count and grouping semantics.

In `@crates/switchyard-server/src/stats/algorithms/advisor_gate.rs`:
- Line 192: Update the stats binding in the test around StatsAccumulator::new to
be mutable, so the later StatsAccumulator::reset(&mut self) call compiles.
🪄 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: 585f7428-96a2-4510-9e3b-71963e67e64e

📥 Commits

Reviewing files that changed from the base of the PR and between 58f355a and 315d4b0.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock
📒 Files selected for processing (11)
  • Cargo.toml
  • crates/libsy/Cargo.toml
  • crates/libsy/src/algorithms.rs
  • crates/libsy/src/algorithms/advisor_gate.rs
  • crates/libsy/src/algorithms/util/llm_judge.rs
  • crates/libsy/src/algorithms/util/prompts.rs
  • crates/libsy/src/lib.rs
  • crates/switchyard-server/src/config.rs
  • crates/switchyard-server/src/stats/algorithms.rs
  • crates/switchyard-server/src/stats/algorithms/advisor_gate.rs
  • crates/switchyard-server/tests/server.rs

Comment thread crates/switchyard-server/src/stats/algorithms.rs Outdated
Comment thread crates/switchyard-server/src/stats/algorithms/advisor_gate.rs Outdated
@eric-liu-nvidia
eric-liu-nvidia force-pushed the claude/advisor-review-gate-rust branch from 315d4b0 to 136bc21 Compare August 11, 2026 21:13
@ayushag-nv

Copy link
Copy Markdown
Contributor

@eric-liu-nvidia can you address coderabbit first

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

Let's break this up into 2 or 3 MRs

  • one for core logic
  • one more for potential telemetry
  • one more for server side config changes

also, if we have any prompts, let's make sure they're also knobs

}

#[tokio::test]
async fn advisor_route_approve_flow_and_stats() -> TestResult {

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.

Can you wrap all of your tests in a #[cfg(test)] mod tests {? That will ensure they don't get compiled unless you are running tests. Grep for those strings in most other files for an 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.

These live in tests/server.rs, which is a Cargo integration-test target — files under tests/ are compiled only by cargo test, so a #[cfg(test)] wrapper there is a no-op (the existing ~40 tests in this file are bare for the same reason; the #[cfg(test)] mod tests pattern you're pointing at applies to unit tests inside src/, which the libsy tests in this stack do use — advisor_gate/tests.rs is declared #[cfg(test)] mod tests;). Happy to wrap if you'd still prefer it for consistency.

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.

2k lines of code for one file is huge. May be try to move some utilities in the util folder and reduce the scope. Prompt should not live in the code file, we have prompts folder for 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.

Done in 97ec7bd — the file is split into focused submodules: advisor_gate.rs (config, budget ledger, gate flow, ~640 lines), advisor_gate/turn.rs (turn buffering/inspection/replay), advisor_gate/transcript.rs (transcript serialization + verdict parsing), advisor_gate/telemetry.rs (metrics + audit lines), advisor_gate/tests.rs (the test suite). Prompts moved out of code into crates/libsy/src/prompts/advisor-gate/*.md via include_str!, same as capability-classifier. I used an advisor_gate/ submodule rather than algorithms/util/ because these helpers are gate-specific with a single consumer — util/ holds cross-algorithm shared code; happy to move any that become shared.

Comment on lines +731 to +761
fn has_tool_use(agg: &AggLlmResponse) -> bool {
agg.outputs.iter().any(|output| {
output.stop_reason == Some(StopReason::ToolUse)
|| output
.content
.iter()
.any(|block| matches!(block, ContentBlock::ToolCall(_)))
})
}

/// The turn's visible text: all text blocks joined; empty means none.
fn visible_text(agg: &AggLlmResponse) -> Option<String> {
let text: Vec<&str> = agg
.outputs
.iter()
.flat_map(|output| output.content.iter())
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
if text.is_empty() {
return None;
}
let joined = text.join("\n");
if joined.is_empty() {
None
} else {
Some(joined)
}
}

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 can be util and removed from the main algo file

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.

Moved to advisor_gate/turn.rs in 97ec7bd (turn-inspection helpers live together there).

Comment on lines +764 to +803
fn reasoning_text(agg: &AggLlmResponse) -> Option<String> {
let text: Vec<&str> = agg
.outputs
.iter()
.flat_map(|output| output.content.iter())
.filter_map(|block| match block {
ContentBlock::Reasoning { text, .. } => Some(text.as_str()),
_ => None,
})
.collect();
if text.is_empty() {
return None;
}
let joined = text.join("\n");
if joined.is_empty() {
None
} else {
Some(joined)
}
}

/// Tool results carried by the conversation so far (both wires normalize
/// tool results into `ContentBlock::ToolResult`).
fn count_tool_results(messages: &[Message]) -> u32 {
let count = messages
.iter()
.flat_map(|message| message.content.iter())
.filter(|block| matches!(block, ContentBlock::ToolResult(_)))
.count();
u32::try_from(count).unwrap_or(u32::MAX)
}

/// Assistant turns already in the request — the stall checkpoint's clock.
fn assistant_turns(messages: &[Message]) -> u32 {
let count = messages
.iter()
.filter(|message| message.role == Role::Assistant)
.count();
u32::try_from(count).unwrap_or(u32::MAX)
}

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.

These too can move to util or some other file

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.

Moved to advisor_gate/turn.rs in 97ec7bd.

@eric-liu-nvidia
eric-liu-nvidia force-pushed the claude/advisor-review-gate-rust branch from 136bc21 to e10e5f8 Compare August 12, 2026 17:17
@eric-liu-nvidia eric-liu-nvidia changed the title feat(libsy): advisor review gate as a native algorithm and advisor route type feat(libsy): advisor_gate review-gate algorithm Aug 12, 2026
@eric-liu-nvidia

Copy link
Copy Markdown
Contributor Author

@nachiketb-nvidia done — split into a 3-PR stack along exactly those lines:

Stack PR Content
1/3 this PR core logic: AdvisorGate libsy algorithm + unit tests
2/3 #382 server-side config: type = "advisor" route
3/3 #383 telemetry: /v1/stats projection + server e2e tests

#382 targets this PR's branch and #383 targets #382's, so each diff shows only its own layer; they merge bottom-up.

On prompts as knobs: they already are — reviewer_system_prompt and redo_feedback_prefix are TOML fields on the route (in #382); the constants in libsy are only the defaults.

@ayushag-nv CodeRabbit is addressed: the doc-comment suggestion is applied in #383, and the "does not compile" finding is incorrect — StatsAccumulator::reset takes &self (interior mutability via the inner mutex, crates/switchyard-server/src/stats/accumulator.rs:140), same pattern as the existing stage_router projection test; the test builds and passes in CI.

//! whole instance — see [`budget_scope`]) is reviewed at most `max_reviews`
//! times; afterwards every call is a pure passthrough.
//!
//! This design is a near-superset of solo executor behavior: identical until

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 you have stats on when the algo claims executor to be "planning" ? How many turns and which turn is detected as needing advisor tool call?

}

/// The trigger with its pattern compiled once at construction.
enum CompiledTrigger {

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 should move to tool_signal.rs and can you use the existing signal buckets in addition to your patterns to detect planning mode? I have seen almost all turns using a tool call in TB 2.1 style tasks, curious what patterns you are detecting.

@eric-liu-nvidia
eric-liu-nvidia force-pushed the claude/advisor-review-gate-rust branch from 97ec7bd to d50e1e7 Compare August 12, 2026 21:50
Signed-off-by: zengyuanl <zengyuanl@nvidia.com>
Signed-off-by: zengyuanl <zengyuanl@nvidia.com>
@ting-hong-shieh

Copy link
Copy Markdown
Contributor

Prompt contract review

[P1] Preserve the requested deliverable on REDOreviewer-system-prompt.md:1-6, redo-feedback-prefix.md:1, and advisor_gate.rs:403-432.

The reviewer prompt covers both a proposed plan and a completion claim, but every REDO tells the executor that the task is “NOT yet complete” and to “keep working until it is genuinely done.” AdvisorGate::redo() appends that text as a new user message before invoking the executor. For a plan-only request, this later instruction can be read as authorization to move from revising the plan to implementing it. Please make the prefix defer to the original request—for example, “Revise the plan or continue the implementation, as required by the original request”—and add a plan-only test that asserts REDO feedback does not expand the requested deliverable.

[P2] Tell the reviewer when the transcript is truncatedreviewer-system-prompt.md:1 and transcript.rs:31-62.

The system prompt says the advisor receives “the full transcript” and “every action,” but review_transcript() drops the middle when the serialized messages exceed transcript_max_chars (200,000 by default). The marker exposes the omission in the transcript text, but the higher-priority system prompt still asserts that nothing is missing. This can make the advisor treat absent evidence as evidence that an action did not happen. Please either remove the completeness claim and state that long transcripts may omit the middle, or avoid truncating input that the prompt describes as complete. A test should cover the over-cap path and the reviewer instruction together.

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.

6 participants