Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
372 changes: 367 additions & 5 deletions crates/libsy/src/algorithms/llm_class.rs

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions crates/libsy/src/algorithms/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,37 @@ pub(crate) fn decisive(target: &ModelId) -> Classification {

/// Default completion budget for internal classifier and escalation judge calls.
pub(crate) const DEFAULT_JUDGE_MAX_OUTPUT_TOKENS: u64 = 4_096;

/// Default character budget for a judge payload, shared by the escalation judge and the
/// windowed classifier judges so one turn carrying a large tool result cannot decide how
/// much a judge call costs.
pub(crate) const DEFAULT_JUDGE_CHAR_BUDGET: usize = 18_000;

/// Separator marking where [`truncate_middle`] dropped a message's interior.
pub(crate) const TRIM_MARKER: &str = " ...[trimmed] ";

/// Keeps the head and tail of `text` within `limit` characters.
///
/// The head gets two thirds of the surviving budget: for a judge reading agent activity the
/// command or error signature that opens a message carries more signal than its trailing
/// output. Clipping is marked so the judge can tell a trimmed message from a short one.
pub(crate) fn truncate_middle(text: &str, limit: usize) -> String {
let chars: Vec<char> = text.chars().collect();
if chars.len() <= limit {
return text.to_string();
}
// Below the marker's own width there is no room to say the text was clipped, so keep
// what fits and drop the marker. Marking anyway would push the result past `limit`,
// which callers budgeting a payload rely on it never doing.
let marker = TRIM_MARKER.chars().count();
if limit <= marker {
return chars[..limit].iter().collect();
}
let keep = limit - marker;
let head = keep * 2 / 3;
let tail = keep - head;
let mut out: String = chars[..head].iter().collect();
out.push_str(TRIM_MARKER);
out.extend(chars[chars.len() - tail..].iter());
out
}
42 changes: 18 additions & 24 deletions crates/libsy/src/algorithms/util/escalation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use super::llm_judge::{
ClassifierInput, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig, SerdeDecoder,
StructuredJudge,
};
use super::truncate_middle;
use crate::core::algorithm::Driver;
use crate::core::classifier::{Classification, Score};
use crate::core::state::State;
Expand All @@ -25,9 +26,6 @@ use switchyard_protocol::Request;
const PROMPT_TEMPLATE: &str = include_str!("../../prompts/escalation/prompt.md");
const SCHEMA_TEMPLATE: &str = include_str!("../../prompts/escalation/schema.json");

/// Separator marking where [`truncate_middle`] dropped a message's interior.
const TRIM_MARKER: &str = " ...[trimmed] ";

/// Suffix marking a transcript cut off by [`MAX_REQUEST_CHARS`].
const TRUNCATION_SUFFIX: &str = "...<truncated>";

Expand Down Expand Up @@ -239,27 +237,6 @@ fn collect_text(content: &[ContentBlock], parts: &mut Vec<String>) {
}
}

/// Keeps the head and tail of `text` within `limit` characters.
///
/// The head gets two thirds of the surviving budget: for a trajectory judge the command or
/// error signature that opens a message carries more signal than its trailing output.
fn truncate_middle(text: &str, limit: usize) -> String {
let chars: Vec<char> = text.chars().collect();
if chars.len() <= limit {
return text.to_string();
}
let keep = limit
.saturating_sub(TRIM_MARKER.chars().count())
.max(20)
.min(chars.len());
let head = keep * 2 / 3;
let tail = keep - head;
let mut out: String = chars[..head].iter().collect();
out.push_str(TRIM_MARKER);
out.extend(chars[chars.len() - tail..].iter());
out
}

/// Renders a compact role-labelled transcript for the judge.
///
/// The framing anchors — system/developer messages and the first user message, where agent
Expand Down Expand Up @@ -496,6 +473,23 @@ mod tests {
assert_eq!(truncate_middle("short", 50), "short");
}

/// Callers budget a payload on the promise that clipping never exceeds the limit. Below
/// the marker's own width there is no room to mark the cut, so the marker is dropped
/// rather than pushing the result over.
#[test]
fn truncate_middle_never_exceeds_a_limit_narrower_than_the_marker() {
use crate::algorithms::util::TRIM_MARKER;

let text = "a".repeat(100);
for limit in 0..=TRIM_MARKER.chars().count() + 2 {
let trimmed = truncate_middle(&text, limit);
assert!(
trimmed.chars().count() <= limit,
"limit {limit}: {trimmed:?}"
);
}
}

#[test]
fn summary_keeps_anchors_and_the_recent_window() {
let mut messages = vec![
Expand Down
6 changes: 6 additions & 0 deletions crates/switchyard-py/src/libsy_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ impl PyCustomClassifierConfig {
session_affinity=false,
message_hash_fallback=false,
recent_turn_window=None,
judge_char_budget=18_000,
max_output_tokens=4096
))]
#[allow(clippy::too_many_arguments)]
Expand All @@ -178,6 +179,7 @@ impl PyCustomClassifierConfig {
session_affinity: bool,
message_hash_fallback: bool,
recent_turn_window: Option<usize>,
judge_char_budget: usize,
max_output_tokens: u64,
) -> PyResult<Self> {
// Convert the Python schema into serde JSON and pair it with the target-selector policy;
Expand All @@ -190,6 +192,7 @@ impl PyCustomClassifierConfig {
inner.classify_trigger = classify_trigger(session_affinity);
inner.message_hash_fallback = message_hash_fallback;
inner.recent_turn_window = recent_turn_window;
inner.judge_char_budget = judge_char_budget;
inner.max_output_tokens = max_output_tokens;
Ok(Self { inner })
}
Expand Down Expand Up @@ -261,6 +264,7 @@ impl PyTaskClassifierConfig {
session_affinity=false,
message_hash_fallback=false,
recent_turn_window=None,
judge_char_budget=18_000,
max_output_tokens=4096,
prompt=None,
response_format_type="json_schema"
Expand All @@ -272,6 +276,7 @@ impl PyTaskClassifierConfig {
session_affinity: bool,
message_hash_fallback: bool,
recent_turn_window: Option<usize>,
judge_char_budget: usize,
max_output_tokens: u64,
prompt: Option<String>,
response_format_type: &str,
Expand All @@ -283,6 +288,7 @@ impl PyTaskClassifierConfig {
classify_trigger: classify_trigger(session_affinity),
message_hash_fallback,
recent_turn_window,
judge_char_budget,
contract: classifier_contract(prompt, response_format_type)?,
max_output_tokens,
},
Expand Down
21 changes: 21 additions & 0 deletions crates/switchyard-runner/src/algorithm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ struct CapabilityClassifierRouteConfig {
classify_trigger: ClassifyTrigger,
message_hash_fallback: bool,
recent_turn_window: Option<usize>,
judge_char_budget: usize,
prompt: Option<String>,
response_format_type: ClassifierResponseFormat,
max_output_tokens: u64,
Expand All @@ -132,6 +133,7 @@ struct CustomClassifierRouteConfig {
classify_trigger: ClassifyTrigger,
message_hash_fallback: bool,
recent_turn_window: Option<usize>,
judge_char_budget: usize,
max_output_tokens: u64,
}

Expand Down Expand Up @@ -245,6 +247,11 @@ pub struct LlmClassifierRouteConfig {
/// How many trailing turns the judge sees. Unset shows it the opening task
/// and the latest user follow-up only.
pub recent_turn_window: Option<usize>,
/// Most characters a windowed judge payload may use. The window narrows from the
/// oldest turn until it fits, so one large tool result cannot decide the judge's
/// cost. Ignored without `recent_turn_window`.
#[serde(default = "default_judge_char_budget")]
pub judge_char_budget: usize,
/// Replaces the packaged judge prompt. Required in custom mode.
pub prompt: Option<String>,
/// How the judge is asked for structured output. Use `json_object` when the
Expand Down Expand Up @@ -464,6 +471,9 @@ pub struct StageClassifierConfig {
/// and the latest user follow-up only.
#[serde(default)]
pub recent_turn_window: Option<usize>,
/// Most characters a windowed judge payload may use. Ignored without a window.
#[serde(default = "default_judge_char_budget")]
pub judge_char_budget: usize,
/// Replaces the packaged judge prompt.
#[serde(default)]
pub prompt: Option<String>,
Expand Down Expand Up @@ -508,6 +518,7 @@ impl StageClassifierConfig {
classify_trigger: self.classify_trigger,
message_hash_fallback: self.message_hash_fallback,
recent_turn_window: self.recent_turn_window,
judge_char_budget: self.judge_char_budget,
contract: classifier_contract(self.prompt.as_deref())
.with_response_format_type(self.response_format_type),
max_output_tokens: self.max_output_tokens,
Expand Down Expand Up @@ -844,6 +855,7 @@ impl LlmClassifierRouteConfig {
classify_trigger,
message_hash_fallback,
recent_turn_window,
judge_char_budget,
prompt,
response_format_type,
max_output_tokens,
Expand Down Expand Up @@ -899,6 +911,7 @@ impl LlmClassifierRouteConfig {
classify_trigger: *classify_trigger,
message_hash_fallback: *message_hash_fallback,
recent_turn_window: *recent_turn_window,
judge_char_budget: *judge_char_budget,
prompt: prompt.clone(),
response_format_type: *response_format_type,
max_output_tokens: *max_output_tokens,
Expand Down Expand Up @@ -994,6 +1007,7 @@ impl LlmClassifierRouteConfig {
classify_trigger: *classify_trigger,
message_hash_fallback: *message_hash_fallback,
recent_turn_window: *recent_turn_window,
judge_char_budget: *judge_char_budget,
max_output_tokens: *max_output_tokens,
},
))
Expand Down Expand Up @@ -1079,6 +1093,7 @@ fn build_subagent_router_config(
config.policy.into_libsy(),
);
classifier_config.recent_turn_window = config.recent_turn_window;
classifier_config.judge_char_budget = config.judge_char_budget;
classifier_config.max_output_tokens = config.max_output_tokens;
let classifier = Arc::new(
LlmTaskClassifier::new(LlmClassifierConfig::Custom {
Expand Down Expand Up @@ -1176,6 +1191,7 @@ fn build_algorithm(
classify_trigger: config.classify_trigger,
message_hash_fallback: config.message_hash_fallback,
recent_turn_window: config.recent_turn_window,
judge_char_budget: config.judge_char_budget,
contract: classifier_contract(config.prompt.as_deref())
.with_response_format_type(config.response_format_type),
max_output_tokens: config.max_output_tokens,
Expand Down Expand Up @@ -1211,6 +1227,7 @@ fn build_algorithm(
classifier_config.classify_trigger = config.classify_trigger;
classifier_config.message_hash_fallback = config.message_hash_fallback;
classifier_config.recent_turn_window = config.recent_turn_window;
classifier_config.judge_char_budget = config.judge_char_budget;
classifier_config.max_output_tokens = config.max_output_tokens;
LlmTaskClassifier::new(LlmClassifierConfig::Custom {
default_target: config.default_target,
Expand Down Expand Up @@ -1417,6 +1434,10 @@ fn default_classifier_max_output_tokens() -> u64 {
TaskClassifierConfig::default().max_output_tokens
}

fn default_judge_char_budget() -> usize {
TaskClassifierConfig::default().judge_char_budget
}

fn resolve_target_model_id(
route_name: &str,
name: &str,
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/toml_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ Capability mode classifies before serving. See
| `classify_trigger` | No | `every_request` | When the judge runs. `every_request` judges every request, tool continuations included. `user_turn` judges each new user message and retains that target across intervening tool calls only when requests carry a session ID; without a session ID, it behaves like `every_request`. `new_session` judges once and reuses that target for the session. |
| `message_hash_fallback` | No | `false` | Keys affinity on the first user message. Requires `classify_trigger = "new_session"`. |
| `recent_turn_window` | No | unset | When unset, the judge sees the opening task and latest user follow-up, when present. When set, it also sees trailing turns. |
| `judge_char_budget` | No | `18000` | Most characters a windowed judge payload may use. The window narrows from the oldest turn until it fits, so one large tool result cannot decide judge cost and latency. Must be at least `256`. Ignored without `recent_turn_window`. |
| `prompt` | No | packaged prompt | Replaces the capability prompt. The packaged schema is sent separately as structured-output configuration. |

Escalation mode serves the weak target first and judges the completed turn. See
Expand Down Expand Up @@ -242,6 +243,7 @@ how one route chooses between more than two models.
| `classify_trigger` | No | `every_request` | When the judge runs. `every_request` judges every request, tool continuations included. `user_turn` judges each new user message and retains that target across intervening tool calls only when requests carry a session ID; without a session ID, it behaves like `every_request`. `new_session` judges once and reuses that target for the session. |
| `message_hash_fallback` | No | `false` | Keys affinity on the first user message. Requires `classify_trigger = "new_session"`. |
| `recent_turn_window` | No | unset | When unset, the judge sees the opening task and latest user follow-up, when present. When set, it also sees trailing turns. |
| `judge_char_budget` | No | `18000` | Most characters a windowed judge payload may use. The window narrows from the oldest turn until it fits, so one large tool result cannot decide judge cost and latency. Must be at least `256`. Ignored without `recent_turn_window`. |

The selected JSON label must name a configured group. A label naming a target
rather than a group, or a group you did not configure, falls back to
Expand Down
1 change: 1 addition & 0 deletions docs/routing_algorithms/llm_classifier_routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ for the server merge behavior.
| `base_threshold` | required | Lowest `p_solve` that routes a supported task to `weak_target`. Must be between `0` and `1`. |
| `threshold_step` | `0.0` | Amount added for each boundary step. Must be finite and non-negative, and `base_threshold + 2 * threshold_step` must not exceed `1`. |
| `recent_turn_window` | unset | When unset, the judge sees the opening user task and the latest user message when they differ. When set to `N`, it sees the opening user task and the last `N` conversation messages after that task. `0` keeps only the opening task. Client system and developer instructions are not shown to the judge. |
| `judge_char_budget` | `18000` | Most characters the windowed selection may use. A window is counted in turns, and one turn carrying a large tool result can be worth tens of thousands of characters, so the window narrows from the oldest turn until the payload fits. A task statement larger than the whole budget is clipped instead, marked with `...[trimmed]`. Must be at least `256`. Ignored when `recent_turn_window` is unset. |
| `classify_trigger` | `every_request` | When the judge runs. `every_request` judges every request, tool continuations included. `user_turn` judges each new user message and holds that target across the tool calls between. `new_session` judges once and reuses that target for the session. |
| `message_hash_fallback` | `false` | When session metadata is absent, keys affinity from the first user-message text. Requires `classify_trigger = "new_session"`. |
| `prompt` | packaged capability prompt | Replaces the classifier's system prompt. The packaged verdict schema and routing policy remain active. |
Expand Down
7 changes: 6 additions & 1 deletion switchyard_rust/libsy.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ class CustomClassifierConfig:
"""Configure schema-validated routing across runtime model groups.

``max_output_tokens`` must be positive. Enabling ``message_hash_fallback``
requires ``session_affinity``.
requires ``session_affinity``. ``judge_char_budget`` caps the windowed judge
payload, must be at least 256, and is ignored without ``recent_turn_window``.
"""

def __init__(
Expand All @@ -75,6 +76,7 @@ def __init__(
session_affinity: bool = False,
message_hash_fallback: bool = False,
recent_turn_window: int | None = None,
judge_char_budget: int = 18_000,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
max_output_tokens: int = 4096,
) -> None: ...

Expand Down Expand Up @@ -156,6 +158,8 @@ class TaskClassifierConfig:

Thresholds must remain within ``[0, 1]``, ``max_output_tokens`` must be
positive, and ``message_hash_fallback`` requires ``session_affinity``.
``judge_char_budget`` caps the windowed judge payload, must be at least 256,
and is ignored without ``recent_turn_window``.
"""

def __init__(
Expand All @@ -166,6 +170,7 @@ def __init__(
session_affinity: bool = False,
message_hash_fallback: bool = False,
recent_turn_window: int | None = None,
judge_char_budget: int = 18_000,
max_output_tokens: int = 4096,
prompt: str | None = None,
response_format_type: Literal["json_schema", "json_object"] = "json_schema",
Expand Down