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
335 changes: 331 additions & 4 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 @@ -28,3 +28,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 @@ -15,6 +15,7 @@ use super::llm_judge::{
ClassifierInput, JudgeClassifier, JudgePolicy, JudgeRuntimeConfig, SerdeDecoder,
StructuredJudge,
};
use super::truncate_middle;
use crate::core::classifier::{Classification, Score};
use crate::core::state::State;
use crate::{LibsyError, Result};
Expand All @@ -23,9 +24,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 @@ -210,27 +208,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 @@ -462,6 +439,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 @@ -145,6 +145,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 @@ -155,6 +156,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 @@ -167,6 +169,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 @@ -263,6 +266,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 @@ -274,6 +278,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 @@ -285,6 +290,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 @@ -104,6 +104,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 @@ -130,6 +131,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 @@ -160,6 +162,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 @@ -363,6 +370,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 @@ -404,6 +414,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 @@ -579,6 +590,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 @@ -633,6 +645,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 @@ -713,6 +726,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 @@ -810,6 +824,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 subagent_targets = resolved_targets
.iter()
Expand Down Expand Up @@ -908,6 +923,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 @@ -960,6 +976,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 {
judge_target: classifier,
Expand Down Expand Up @@ -1172,6 +1189,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_targets<'a>(
route_name: &str,
names: impl IntoIterator<Item = &'a str>,
Expand Down
4 changes: 4 additions & 0 deletions crates/switchyard-runner/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ struct RouteConfig {
tool_calling: Option<bool>,
reasoning: Option<bool>,
vision: Option<bool>,
base_instructions: Option<String>,
algorithm: AlgorithmSpec,
}

Expand All @@ -87,6 +88,7 @@ impl<'de> Deserialize<'de> for RouteConfig {
let tool_calling = take_optional(&mut table, "tool_calling")?;
let reasoning = take_optional(&mut table, "reasoning")?;
let vision = take_optional(&mut table, "vision")?;
let base_instructions = take_optional(&mut table, "base_instructions")?;
let algorithm = AlgorithmSpec::deserialize(toml::Value::Table(table))
.map_err(serde::de::Error::custom)?;
Ok(Self {
Expand All @@ -95,6 +97,7 @@ impl<'de> Deserialize<'de> for RouteConfig {
tool_calling,
reasoning,
vision,
base_instructions,
algorithm,
})
}
Expand Down Expand Up @@ -127,6 +130,7 @@ impl RouteConfig {
tool_calling: self.tool_calling,
reasoning: self.reasoning,
vision: self.vision,
base_instructions: self.base_instructions.clone(),
}
}

Expand Down
14 changes: 11 additions & 3 deletions crates/switchyard-runner/src/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use crate::DecisionTarget;
///
/// An unset capability is undeclared: it serializes as `null` in the OpenAI
/// `data` entry, and the Codex entry falls back to a safe default for it.
#[derive(Clone, Copy, Default)]
#[derive(Clone, Default)]
pub struct ModelCapabilities {
pub context_window: Option<u32>,
pub tool_calling: Option<bool>,
Expand All @@ -36,6 +36,14 @@ pub struct ModelCapabilities {
/// sending*. An undeclared vision-capable route therefore loses the image in the
/// client, and the proxy never receives one to forward.
pub vision: Option<bool>,
/// Base instructions this route advertises to Codex.
///
/// Codex adopts a served value in place of its own bundled prompt, and its catalog
/// decoder rejects an entry that supplies neither `base_instructions` nor
/// `model_messages.instructions_template` — one rejected entry discards the whole
/// catalog. A proxy therefore cannot decline to answer the question, only choose the
/// answer, so the operator supplies it. Unset serves a placeholder and warns.
pub base_instructions: Option<String>,
}

/// Caller credential family required by a forwarded-auth route.
Expand Down Expand Up @@ -161,8 +169,8 @@ impl Route {
}

/// Returns model-list capability metadata.
pub fn capabilities(&self) -> ModelCapabilities {
self.capabilities
pub fn capabilities(&self) -> &ModelCapabilities {
&self.capabilities
}

/// Returns the forwarded caller credential family.
Expand Down
2 changes: 1 addition & 1 deletion crates/switchyard-runner/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ pub struct Runner {
pub struct ModelInfo<'a> {
pub id: &'a ModelId,
pub algorithm: &'a str,
pub capabilities: ModelCapabilities,
pub capabilities: &'a ModelCapabilities,
}

/// Fully resolved routing decision.
Expand Down
Loading