Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
8 changes: 6 additions & 2 deletions benchmark/codex_model_catalog_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@


def _fallback_codex_model_template() -> dict[str, Any]:
"""Return the minimum model metadata shape accepted by Codex."""
"""Return the minimum model metadata shape accepted by Codex.

``base_instructions`` is omitted on purpose: Codex prefers a served value over
its own bundled prompt, so supplying a stub would replace the agent's system
prompt and make a routed run incomparable with a direct one.
"""
return {
"slug": "switchyard",
"display_name": "Switchyard",
Expand All @@ -46,7 +51,6 @@ def _fallback_codex_model_template() -> dict[str, Any]:
"additional_speed_tiers": [],
"availability_nux": None,
"upgrade": None,
"base_instructions": "You are Codex, a coding agent.",
"supports_reasoning_summaries": True,
"default_reasoning_summary": "none",
"support_verbosity": True,
Expand Down
265 changes: 261 additions & 4 deletions crates/libsy/src/algorithms/llm_class.rs

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions crates/libsy/src/algorithms/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,33 @@ 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();
}
let keep = limit
.saturating_sub(TRIM_MARKER.chars().count())
.max(20)
.min(chars.len());
Comment thread
ardada2468 marked this conversation as resolved.
Outdated
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
}
25 changes: 1 addition & 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
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
20 changes: 20 additions & 0 deletions crates/switchyard-runner/src/algorithm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,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 @@ -131,6 +132,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 @@ -161,6 +163,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 @@ -364,6 +371,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 @@ -411,6 +421,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 @@ -552,6 +563,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 @@ -606,6 +618,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 @@ -686,6 +699,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 @@ -783,6 +797,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 @@ -881,6 +896,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 @@ -1159,6 +1175,10 @@ fn default_classifier_max_output_tokens() -> u64 {
TaskClassifierConfig::default().max_output_tokens
}

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

/// Keys each configured system prompt by the target it belongs to.
fn tier_prompts(
capable: &str,
Expand Down
13 changes: 7 additions & 6 deletions crates/switchyard-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1523,9 +1523,13 @@ fn model_entry_json(model: &str, capabilities: ModelCapabilities) -> Value {
//
// Two kinds of fields live here. context_window, tool_calling, and reasoning are model
// facts a backend can publish; the route declares them in config today. The rest
// (shell_type, apply_patch_tool_type, base_instructions, the reasoning-level presets,
// truncation_policy) are Codex client conventions no backend returns, so they stay
// constant.
// (shell_type, apply_patch_tool_type, the reasoning-level presets, truncation_policy)
// are Codex client conventions no backend returns, so they stay constant.
//
// `base_instructions` is deliberately absent. Codex prefers a served value over its own
// bundled prompt, so any stub here silently replaces the agent's system prompt on every
// routed turn. Omitting the key leaves the client's prompt alone; a proxy has no better
// value to supply.
//
// TODO: source context_window, tool_calling, and reasoning from the backend, not route
// config. Switchyard is a proxy, so it should re-publish what the backend advertises
Expand Down Expand Up @@ -1553,9 +1557,6 @@ fn codex_model_entry_json(model: &str, capabilities: ModelCapabilities, priority
"additional_speed_tiers": [],
"availability_nux": null,
"upgrade": null,
// Required `ModelInfo` string. Unlike the launcher, the server cannot read
// Codex's bundled prompt, so it sends a minimal stub.
"base_instructions": "You are Codex, a coding agent.",
"supports_reasoning_summaries": reasoning,
"default_reasoning_summary": "none",
"support_verbosity": reasoning,
Expand Down
12 changes: 12 additions & 0 deletions crates/switchyard-server/tests/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2324,6 +2324,18 @@ target = "shared"
codex_metadata["declared"]["apply_patch_tool_type"],
"freeform"
);
// Codex prefers a served `base_instructions` over its own bundled prompt, so the
// catalog must omit the key rather than send a stub: a proxy replacing the agent's
// system prompt makes a routed run incomparable with a direct one.
for (slug, entry) in &codex_metadata {
let entry = entry
.as_object()
.unwrap_or_else(|| panic!("codex entry {slug} is not an object"));
assert!(
!entry.contains_key("base_instructions"),
"{slug}: {entry:?}"
);
}
// Constant fields Codex requires: a typo here would fail its decode, so pin them.
assert_eq!(codex_metadata["declared"]["visibility"], "list");
assert_eq!(codex_metadata["declared"]["supported_in_api"], json!(true));
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 @@ -189,6 +189,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. 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 @@ -218,6 +219,7 @@ policy selector, and routes to any configured target label.
| `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. Ignored without `recent_turn_window`. |

Classifier prompts must not contain `{{RESPONSE_SCHEMA}}`. Switchyard supplies
the schema automatically: through the structured-output request in `json_schema`
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]`. 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
2 changes: 2 additions & 0 deletions switchyard_rust/libsy.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,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,
) -> None: ...

Expand Down Expand Up @@ -149,6 +150,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