diff --git a/crates/libsy/src/algorithms.rs b/crates/libsy/src/algorithms.rs index fa6c2af5d..cd2d36b50 100644 --- a/crates/libsy/src/algorithms.rs +++ b/crates/libsy/src/algorithms.rs @@ -13,6 +13,7 @@ pub mod fall_through; pub mod llm_class; pub mod noop; pub mod passthrough; +pub mod plan_execute; pub mod rand; pub mod stage; pub mod subagent; diff --git a/crates/libsy/src/algorithms/plan_execute.rs b/crates/libsy/src/algorithms/plan_execute.rs new file mode 100644 index 000000000..ecb76054f --- /dev/null +++ b/crates/libsy/src/algorithms/plan_execute.rs @@ -0,0 +1,386 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Plans coding tasks on a capable model, then hands execution to an efficient model. + +use std::collections::HashSet; +use std::sync::Arc; + +use parking_lot::Mutex; +use switchyard_protocol::{Category, ContentBlock, Request}; + +use super::util::prompts::{append_note, drop_exact_replay, prepend_system_prompt}; +use super::util::tool_signals::ToolSignals; +use crate::core::algorithm::{Algorithm, Driver, RoutingIdentity}; +use crate::{LibsyError, Result, RoutingOutcome}; + +/// Default instruction added while the capable model is planning. +pub const DEFAULT_PLANNING_PROMPT: &str = + include_str!("../prompts/plan-execute/planning-system-prompt.md"); + +const MAX_EXECUTING_SESSIONS: usize = 4_096; + +/// Configuration for [`PlanExecute`]. +#[derive(Clone, Debug)] +pub struct PlanExecuteConfig { + /// System instruction added until the first edit or write tool call. + pub planning_prompt: String, + /// Optional instruction appended to the handoff request. + pub handoff_prompt: Option, + /// Replays visible planner reasoning summaries as assistant text at handoff. + pub planner_reasoning_as_text: bool, +} + +impl Default for PlanExecuteConfig { + fn default() -> Self { + Self { + planning_prompt: DEFAULT_PLANNING_PROMPT.trim().to_string(), + handoff_prompt: None, + planner_reasoning_as_text: false, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Phase { + Plan, + Handoff, + Execute, +} + +/// Routes planning turns to the runtime capable model, then latches execution +/// to the runtime efficient model after the first recorded mutation. +pub struct PlanExecute { + config: PlanExecuteConfig, + executing_sessions: Mutex>, +} + +impl PlanExecute { + /// Creates a plan/execute router. + /// + /// Returns an error when either configured prompt is blank. + pub fn new(config: PlanExecuteConfig) -> Result { + if config.planning_prompt.trim().is_empty() { + return Err(LibsyError::AlgorithmError { + message: "planning_prompt must not be empty".to_string(), + }); + } + if config + .handoff_prompt + .as_deref() + .is_some_and(|prompt| prompt.trim().is_empty()) + { + return Err(LibsyError::AlgorithmError { + message: "handoff_prompt must not be empty".to_string(), + }); + } + Ok(Self { + config, + executing_sessions: Mutex::new(HashSet::new()), + }) + } + + fn phase(&self, request: &Request) -> Phase { + let signals = ToolSignals::from_request(request, None); + let mutation_seen = signals.edit_count > 0 || signals.write_count > 0; + let Some(identity) = RoutingIdentity::from_request(request) else { + return if mutation_seen { + Phase::Handoff + } else { + Phase::Plan + }; + }; + + let mut sessions = self.executing_sessions.lock(); + let phase = if sessions.contains(&identity) { + Phase::Execute + } else if mutation_seen { + if sessions.len() >= MAX_EXECUTING_SESSIONS + && let Some(evicted) = sessions.iter().next().cloned() + { + sessions.remove(&evicted); + } + sessions.insert(identity.clone()); + Phase::Handoff + } else { + Phase::Plan + }; + if request + .metadata + .as_ref() + .and_then(|metadata| metadata.session_final) + == Some(true) + { + sessions.remove(&identity); + } + phase + } + + fn replay_planner_reasoning_as_text(request: &mut Request) -> usize { + let mut converted = 0; + for message in &mut request.llm_request.messages { + message.content = std::mem::take(&mut message.content) + .into_iter() + .filter_map(|block| match block { + ContentBlock::Reasoning { text, .. } => { + converted += 1; + (!text.is_empty()).then_some(ContentBlock::Text { text }) + } + other => Some(other), + }) + .collect(); + } + request + .llm_request + .messages + .retain(|message| !message.content.is_empty()); + if converted > 0 { + drop_exact_replay(request); + } + converted + } + + fn route_to(driver: &Driver, category: Category, request: Request) -> Result { + let models = driver.models_for(&category); + let Some((selected, fallbacks)) = models.split_first() else { + return Err(LibsyError::AlgorithmError { + message: format!("no models available for category {}", category.as_str()), + }); + }; + Ok(RoutingOutcome::route_to( + selected.clone(), + fallbacks.to_vec(), + request, + )) + } +} + +#[async_trait::async_trait] +impl Algorithm for PlanExecute { + fn name(&self) -> &str { + "plan_execute" + } + + async fn route( + self: Arc, + driver: Driver, + mut request: Request, + ) -> Result { + match self.phase(&request) { + Phase::Plan => { + prepend_system_prompt(&mut request, &self.config.planning_prompt); + tracing::debug!(phase = "plan", "plan-execute selected capable tier"); + Self::route_to(&driver, Category::Capable, request) + } + Phase::Handoff => { + let reasoning_converted = if self.config.planner_reasoning_as_text { + Self::replay_planner_reasoning_as_text(&mut request) + } else { + 0 + }; + let prompt_applied = if let Some(prompt) = &self.config.handoff_prompt { + append_note(&mut request, prompt); + true + } else { + false + }; + tracing::debug!( + phase = "handoff", + handoff_prompt_applied = prompt_applied, + planner_reasoning_converted = reasoning_converted, + "plan-execute selected efficient tier" + ); + Self::route_to(&driver, Category::Efficient, request) + } + Phase::Execute => { + tracing::debug!(phase = "execute", "plan-execute selected efficient tier"); + Self::route_to(&driver, Category::Efficient, request) + } + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use serde_json::json; + use switchyard_protocol::{ + ContentBlock, LlmRequest, Message, Metadata, ModelId, Request, Role, ToolCall, + }; + + use super::*; + use crate::RuntimeModels; + use crate::core::testing::{reply, test_drive_with_models}; + + const CAPABLE: &str = "model/capable"; + const EFFICIENT: &str = "model/efficient"; + + fn algorithm(config: PlanExecuteConfig) -> Arc { + Arc::new(PlanExecute::new(config).expect("config should be valid")) + } + + fn request(messages: Vec, session_id: Option<&str>) -> Request { + Request { + llm_request: LlmRequest { + model: Some("switchyard/plan-execute".to_string()), + messages, + ..LlmRequest::default() + }, + metadata: session_id.map(|session_id| Metadata { + session_id: Some(session_id.to_string()), + ..Metadata::default() + }), + ..Request::default() + } + } + + fn tool_call(name: &str, arguments: serde_json::Value) -> Message { + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolCall(ToolCall { + id: "call-1".to_string(), + name: name.to_string(), + arguments, + })], + } + } + + fn models() -> RuntimeModels { + RuntimeModels::new(HashMap::from([ + (Category::Capable, vec![ModelId::from(CAPABLE)]), + (Category::Efficient, vec![ModelId::from(EFFICIENT)]), + ])) + } + + async fn route_and_capture( + algorithm: Arc, + request: Request, + ) -> (ModelId, Request) { + let captured = Arc::new(Mutex::new(None)); + let capture = Arc::clone(&captured); + let (selected, _) = + test_drive_with_models(algorithm, request, models(), move |_target, request| { + let capture = Arc::clone(&capture); + async move { + *capture.lock() = Some(request); + Ok(reply("ok")) + } + }) + .await + .expect("routing should succeed"); + let request = captured + .lock() + .take() + .expect("answer request should be captured"); + (selected, request) + } + + #[tokio::test] + async fn plans_then_hands_off_and_latches_execution() { + const HANDOFF: &str = "Continue from the plan and repository evidence."; + let algorithm = algorithm(PlanExecuteConfig { + handoff_prompt: Some(HANDOFF.to_string()), + planner_reasoning_as_text: true, + ..PlanExecuteConfig::default() + }); + + let read_only = request( + vec![tool_call( + "exec_command", + json!({"cmd": "rg parser crates"}), + )], + Some("task-1"), + ); + let (selected, routed) = route_and_capture(Arc::clone(&algorithm), read_only).await; + assert_eq!(selected, CAPABLE); + assert_eq!(routed.llm_request.instructions.len(), 1); + + let first_edit = request( + vec![Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Reasoning { + text: "The parser needs a boundary check.".to_string(), + signature: Some("planner-signature".to_string()), + details: vec![json!({"type": "reasoning.encrypted", "data": "opaque"})], + }, + ContentBlock::ToolCall(ToolCall { + id: "call-1".to_string(), + name: "apply_patch".to_string(), + arguments: json!({"patch": "*** Begin Patch"}), + }), + ], + }], + Some("task-1"), + ); + let (selected, routed) = route_and_capture(Arc::clone(&algorithm), first_edit).await; + assert_eq!(selected, EFFICIENT); + assert_eq!( + routed.llm_request.messages[0].content[0], + ContentBlock::Text { + text: "The parser needs a boundary check.".to_string() + } + ); + assert_eq!( + routed.llm_request.messages.last(), + Some(&Message::text(Role::User, HANDOFF)) + ); + + let mut final_request = request( + vec![Message::text(Role::User, "Continue after compaction")], + Some("task-1"), + ); + final_request + .metadata + .as_mut() + .expect("session metadata should exist") + .session_final = Some(true); + let (selected, routed) = route_and_capture(Arc::clone(&algorithm), final_request).await; + assert_eq!(selected, EFFICIENT); + assert!(routed.llm_request.instructions.is_empty()); + assert_eq!(routed.llm_request.messages.len(), 1); + + let reused = request(vec![Message::text(Role::User, "New task")], Some("task-1")); + let (selected, _) = route_and_capture(algorithm, reused).await; + assert_eq!(selected, CAPABLE); + } + + #[tokio::test] + async fn mutation_without_a_session_uses_the_efficient_tier() { + let messages = vec![tool_call( + "exec_command", + json!({"cmd": "printf 'done\\n' > task.txt"}), + )]; + + let (selected, routed) = route_and_capture( + algorithm(PlanExecuteConfig::default()), + request(messages.clone(), None), + ) + .await; + + assert_eq!(selected, EFFICIENT); + assert_eq!(routed.llm_request.messages, messages); + assert!(routed.llm_request.instructions.is_empty()); + } + + #[test] + fn rejects_blank_prompts() { + for config in [ + PlanExecuteConfig { + planning_prompt: " ".to_string(), + ..PlanExecuteConfig::default() + }, + PlanExecuteConfig { + handoff_prompt: Some(" ".to_string()), + ..PlanExecuteConfig::default() + }, + ] { + assert!(matches!( + PlanExecute::new(config), + Err(LibsyError::AlgorithmError { .. }) + )); + } + } +} diff --git a/crates/libsy/src/lib.rs b/crates/libsy/src/lib.rs index 6e84c456a..9d07aeff9 100644 --- a/crates/libsy/src/lib.rs +++ b/crates/libsy/src/lib.rs @@ -25,6 +25,7 @@ pub use algorithms::llm_class::{ }; pub use algorithms::noop::Noop; pub use algorithms::passthrough::Passthrough; +pub use algorithms::plan_execute::{PlanExecute, PlanExecuteConfig}; pub use algorithms::rand::{Random, RandomClassifier}; pub use algorithms::stage::{LlmFallback, StageRouter, StageRouterConfig}; pub use algorithms::subagent::{SubagentRouter, SubagentRouterConfig}; diff --git a/crates/libsy/src/prompts/plan-execute/planning-system-prompt.md b/crates/libsy/src/prompts/plan-execute/planning-system-prompt.md new file mode 100644 index 000000000..aaf49cfcd --- /dev/null +++ b/crates/libsy/src/prompts/plan-execute/planning-system-prompt.md @@ -0,0 +1 @@ +You are in the planning phase. Inspect the task and relevant code, then form a concrete implementation plan before modifying any files. Use read-only tools as needed. Do not edit until the plan is complete. Your first edit hands execution to another model. diff --git a/crates/switchyard-runner/src/algorithm.rs b/crates/switchyard-runner/src/algorithm.rs index c0630f199..e742d48ba 100644 --- a/crates/switchyard-runner/src/algorithm.rs +++ b/crates/switchyard-runner/src/algorithm.rs @@ -13,9 +13,9 @@ use libsy::{ AdvisorGate, AdvisorGateConfig, Algorithm, ClassifierContractConfig, ClassifierResponseFormat, ClassifyTrigger, CompositeRouter, CompositeRouterConfig, CustomClassifierConfig, CustomClassifierPolicy, EscalationJudgeConfig, GateTrigger, HandoffNoteConfig, - LlmClassifierConfig, LlmFallback, LlmTaskClassifier, Noop, Passthrough, PickerMode, Random, - StageRouter, StageRouterConfig, SubagentRouter, SubagentRouterConfig, TaskClassifierConfig, - ToolSemantics, + LlmClassifierConfig, LlmFallback, LlmTaskClassifier, Noop, Passthrough, PickerMode, + PlanExecute, PlanExecuteConfig, Random, StageRouter, StageRouterConfig, SubagentRouter, + SubagentRouterConfig, TaskClassifierConfig, ToolSemantics, }; use serde::Deserialize; use switchyard_protocol::{Category, ModelId}; @@ -330,6 +330,22 @@ pub enum AlgorithmSpec { #[serde(default)] subagents: Option, }, + /// Plans on the capable target, then permanently hands off after the first mutation. + PlanExecute { + /// Target used before the first edit or write. + capable_target: String, + /// Target used from the first edit or write onward. + efficient_target: String, + /// Replaces the built-in planning instruction. + #[serde(default)] + planning_prompt: Option, + /// Appends an instruction to the handoff request. + #[serde(default)] + handoff_prompt: Option, + /// Replays visible planner reasoning summaries as assistant text at handoff. + #[serde(default)] + planner_reasoning_as_text: bool, + }, /// Asks a judge model which target should serve the request. LlmClassifier { /// Judge and tier settings, written directly in the route table. @@ -533,6 +549,11 @@ impl AlgorithmSpec { } names } + Self::PlanExecute { + capable_target, + efficient_target, + .. + } => vec![capable_target.as_str(), efficient_target.as_str()], Self::LlmClassifier { config, .. } => match config.classifier_mode() { ClassifierMode::Capability => config .weak_target @@ -655,6 +676,18 @@ impl AlgorithmSpec { Self::Passthrough { target, .. } => { category_models([(Category::Any, vec![target.clone()])]) } + Self::PlanExecute { + capable_target, + efficient_target, + .. + } => category_models([ + (Category::Capable, vec![capable_target.clone()]), + (Category::Efficient, vec![efficient_target.clone()]), + ( + Category::Any, + vec![capable_target.clone(), efficient_target.clone()], + ), + ]), Self::LlmClassifier { config } => { classifier_runtime_model_names(config.validated_classifier_mode(route_name)?) } @@ -741,6 +774,7 @@ impl AlgorithmSpec { Self::Noop { .. } | Self::Random { .. } | Self::Passthrough { .. } + | Self::PlanExecute { .. } | Self::LlmClassifier { .. } | Self::StageRouter { .. } | Self::Auto { .. } @@ -1167,6 +1201,26 @@ fn build_algorithm( let parent: Arc = Arc::new(algorithm); attach_subagent_router(route_name, parent, subagents.as_ref(), targets) } + AlgorithmSpec::PlanExecute { + planning_prompt, + handoff_prompt, + planner_reasoning_as_text, + .. + } => { + let mut config = PlanExecuteConfig::default(); + if let Some(prompt) = planning_prompt { + config.planning_prompt = prompt.clone(); + } + config.handoff_prompt.clone_from(handoff_prompt); + config.planner_reasoning_as_text = *planner_reasoning_as_text; + let algorithm = PlanExecute::new(config).map_err(|error| { + AlgorithmConfigError::with_source( + format!("plan_execute route {route_name}: {error}"), + error, + ) + })?; + Ok(Arc::new(algorithm)) + } AlgorithmSpec::LlmClassifier { config: classifier_config, .. diff --git a/crates/switchyard-runner/src/config.rs b/crates/switchyard-runner/src/config.rs index b6f9ca58d..6da7a4f28 100644 --- a/crates/switchyard-runner/src/config.rs +++ b/crates/switchyard-runner/src/config.rs @@ -818,6 +818,12 @@ base_threshold = 0.5 id = "switchyard/passthrough" type = "passthrough" target = "weak" + +[routes.plan_execute] +id = "switchyard/plan-execute" +type = "plan_execute" +capable_target = "strong" +efficient_target = "weak" "#; #[test] @@ -844,6 +850,17 @@ target = "weak" models.models_for(&Category::Any), [ModelId::from("weak/model"), ModelId::from("strong/model")] ); + let plan_execute = runner + .route("switchyard/plan-execute") + .expect("plan-execute route should exist"); + assert_eq!( + plan_execute.models().models_for(&Category::Capable), + [ModelId::from("strong/model")] + ); + assert_eq!( + plan_execute.models().models_for(&Category::Efficient), + [ModelId::from("weak/model")] + ); assert!(runner.route("switchyard/passthrough").is_some()); Ok(()) } @@ -1001,6 +1018,7 @@ new = ["send_message"] "switchyard/classifier", "switchyard/noop", "switchyard/passthrough", + "switchyard/plan-execute", "switchyard/random", ] ); diff --git a/crates/switchyard-translation/src/codecs/responses/buffered.rs b/crates/switchyard-translation/src/codecs/responses/buffered.rs index 998505913..eb76c59f4 100644 --- a/crates/switchyard-translation/src/codecs/responses/buffered.rs +++ b/crates/switchyard-translation/src/codecs/responses/buffered.rs @@ -1003,6 +1003,7 @@ fn decode_responses_tools( .get("name") .and_then(Value::as_str) .filter(|name| !name.is_empty()); + let description = tool.get("description").and_then(Value::as_str); for mut child in decode_responses_tools(tool.get("tools"), namespaces, custom_tools) { // A nested container already qualified its own children, and the // innermost name is the one that identifies the tool. @@ -1013,7 +1014,10 @@ fn decode_responses_tools( let qualified = crate::codex_namespaces::qualified_tool_name(container, &child.name); crate::codex_namespaces::record_tool_namespace( - namespaces, &qualified, container, + namespaces, + &qualified, + container, + description, ); child.name = qualified; } @@ -1706,9 +1710,15 @@ fn encode_responses_tools( } } for (namespace, children) in containers { + let description = namespaces + .and_then(|namespaces| { + crate::codex_namespaces::namespace_description(namespaces, &namespace) + }) + .unwrap_or_default(); out.push(json!({ "type": "namespace", "name": namespace, + "description": description, "tools": children, })); } diff --git a/crates/switchyard-translation/src/codex_namespaces.rs b/crates/switchyard-translation/src/codex_namespaces.rs index a16507f26..ed5da2016 100644 --- a/crates/switchyard-translation/src/codex_namespaces.rs +++ b/crates/switchyard-translation/src/codex_namespaces.rs @@ -48,8 +48,18 @@ pub fn record_tool_namespace( namespaces: &mut Map, qualified: &str, namespace: &str, + description: Option<&str>, ) { - namespaces.insert(qualified.to_string(), Value::String(namespace.to_string())); + let value = description.map_or_else( + || Value::String(namespace.to_string()), + |description| { + serde_json::json!({ + "namespace": namespace, + "description": description, + }) + }, + ); + namespaces.insert(qualified.to_string(), value); } /// Stores a collected mapping on a request's extensions, when it has entries. @@ -79,13 +89,29 @@ pub fn split_qualified_name( namespaces: &Map, qualified: &str, ) -> Option<(String, String)> { - let namespace = namespaces.get(qualified).and_then(Value::as_str)?; + let value = namespaces.get(qualified)?; + let namespace = value + .as_str() + .or_else(|| value.get("namespace").and_then(Value::as_str))?; let tool = qualified .strip_prefix(namespace)? .strip_prefix(NAMESPACE_SEPARATOR)?; Some((tool.to_string(), namespace.to_string())) } +/// Returns a retained description for `namespace`. +pub fn namespace_description<'a>( + namespaces: &'a Map, + namespace: &str, +) -> Option<&'a str> { + namespaces.values().find_map(|value| { + let object = value.as_object()?; + (object.get("namespace").and_then(Value::as_str) == Some(namespace)) + .then(|| object.get("description").and_then(Value::as_str)) + .flatten() + }) +} + /// Reverse map from an upstream tool name to its Codex tool name and namespace. /// /// The exact qualified name is always registered. A model often returns a near @@ -191,6 +217,7 @@ mod tests { &mut namespaces, &qualified_tool_name(namespace, tool), namespace, + None, ); } let mut extensions = ProviderExtensions::default(); diff --git a/crates/switchyard-translation/tests/request_translation.rs b/crates/switchyard-translation/tests/request_translation.rs index 35e963de5..6d2574db4 100644 --- a/crates/switchyard-translation/tests/request_translation.rs +++ b/crates/switchyard-translation/tests/request_translation.rs @@ -1601,6 +1601,41 @@ fn responses_request_translates_codex_tool_shape_to_openai_chat() -> TestResult Ok(()) } +#[test] +fn responses_prompt_injection_preserves_namespace_description() -> TestResult { + let engine = TranslationEngine::default(); + let policy = TranslationPolicy::default(); + let body = json!({ + "model": "switchyard", + "input": "Fix the parser", + "tools": [{ + "type": "namespace", + "name": "multi_agent_v1", + "description": "Tools for managing sub-agents.", + "tools": [{ + "type": "function", + "name": "spawn_agent", + "description": "Start one agent.", + "parameters": {"type": "object"} + }] + }] + }); + let mut request = engine + .decode_request(WireFormat::OpenAiResponses, &body, &policy)? + .request; + + prepare_request_for_target(&mut request, &"gpt-5.6-sol".into(), Some("Plan first.")); + let output = engine + .encode_request(WireFormat::OpenAiResponses, &request, &policy)? + .body; + + assert_eq!( + output["tools"][0]["description"], + "Tools for managing sub-agents." + ); + Ok(()) +} + // Verifies Python-style Responses tool definitions translate into OpenAI Chat tools. #[test] fn responses_request_translates_python_compatible_tool_shape_to_openai_chat() -> TestResult { diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index 4be77ed86..f644cae30 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -175,6 +175,19 @@ Splits traffic across targets. See | `weights` | No | equal | Finite, non-negative relative weights in `targets` order, with at least one positive value. Invalid weights are rejected at load time. | | `seed` | No | unset | Reproduces the selection sequence. | +### `plan_execute` + +Plans on a capable target, then switches to an efficient target after the first +file mutation. See [Plan/Execute Routing](../routing_algorithms/plan_execute_routing.md). + +| Key | Required | Default | Meaning | +|---|:---:|---|---| +| `capable_target` | Yes | - | Target used for read-only inspection and planning. | +| `efficient_target` | Yes | - | Target used after the first edit or write. | +| `planning_prompt` | No | packaged prompt | Replaces the planning instruction. | +| `handoff_prompt` | No | unset | Adds an instruction to the handoff request. | +| `planner_reasoning_as_text` | No | `false` | Converts visible planner reasoning summaries to assistant text at handoff. | + ### `prefill_router` !!! warning "Experimental in v0.3.0" diff --git a/docs/routing_algorithms/overview.md b/docs/routing_algorithms/overview.md index 18941521e..6347ed831 100644 --- a/docs/routing_algorithms/overview.md +++ b/docs/routing_algorithms/overview.md @@ -42,6 +42,7 @@ These options remain available when you need a different routing policy. | Strategy | Use it when | Route `type` | |---|---|---| +| [Plan/Execute](plan_execute_routing.md) | Use a capable model to inspect and plan, then switch to an efficient model after the first file mutation. | `plan_execute` | | [Composite](composite_routing.md) | Combine Task and Execution. A classifier sets the stage router's default tier. | `composite` | | [Escalation](escalation_router_routing.md) | Start on the efficient model and escalate when an LLM judge detects trouble. | `llm_classifier` with `mode = "escalation"` | | [Custom](llm_classifier_routing.md#custom-multi-target-routing) | Route among two or more models using your own classification schema and rules. | `llm_classifier` with `mode = "custom"` | diff --git a/docs/routing_algorithms/plan_execute_routing.md b/docs/routing_algorithms/plan_execute_routing.md new file mode 100644 index 000000000..b659429c4 --- /dev/null +++ b/docs/routing_algorithms/plan_execute_routing.md @@ -0,0 +1,43 @@ +# Plan/Execute Routing + +Plan/execute routing uses a capable model to inspect and plan a coding task, +then switches to an efficient model after the first file mutation. It does not +make a classifier call. + +```toml +schema_version = 1 + +[llm_clients.provider] +format = "openai_responses" +base_url = "https://example.com/v1" +api_key_env = "OPENAI_API_KEY" + +[targets.planner] +id = "provider/capable-model" +llm_client = "provider" + +[targets.executor] +id = "provider/efficient-model" +llm_client = "provider" + +[routes.plan_execute] +id = "switchyard/plan-execute" +type = "plan_execute" +capable_target = "planner" +efficient_target = "executor" +``` + +Read-only inspection stays on the capable target with a planning instruction. +The first edit or write routes the full trajectory to the efficient target and +latches that choice by session ID. A failed edit still triggers the handoff. +Without a session ID, the first mutation must remain in the request history. + +Optional settings: + +| Key | Behavior | +|---|---| +| `planning_prompt` | Replaces the built-in planning instruction. | +| `handoff_prompt` | Adds an instruction to the handoff request. | +| `planner_reasoning_as_text` | Converts visible planner reasoning summaries to assistant text at handoff. | + +Use a stable session ID with handoff processing or history compaction. diff --git a/mkdocs.yml b/mkdocs.yml index bcd196402..797ad68dd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,6 +29,7 @@ nav: - Overview: routing_algorithms/overview.md - Task (LLM Classifier): routing_algorithms/llm_classifier_routing.md - Execution (Stage Router): routing_algorithms/stage_router_routing.md + - Plan/Execute Routing: routing_algorithms/plan_execute_routing.md - Sub-Agent-Aware Routing: routing_algorithms/subagent_routing.md - Random Routing: routing_algorithms/random_routing.md - Composite Routing: routing_algorithms/composite_routing.md