Skip to main content

switchyard_libsy/algorithms/
plan_execute.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Plans coding tasks on a capable model, then hands execution to an efficient model.
5
6use std::collections::HashSet;
7use std::sync::Arc;
8
9use parking_lot::Mutex;
10use switchyard_protocol::{Category, ContentBlock, Request};
11
12use super::util::prompts::{append_note, drop_exact_replay, prepend_system_prompt};
13use super::util::tool_signals::ToolSignals;
14use crate::core::algorithm::{Algorithm, Driver, RoutingIdentity};
15use crate::{LibsyError, Result, RoutingOutcome};
16
17/// Default instruction added while the capable model is planning.
18pub const DEFAULT_PLANNING_PROMPT: &str =
19    include_str!("../prompts/plan-execute/planning-system-prompt.md");
20
21const MAX_EXECUTING_SESSIONS: usize = 4_096;
22
23/// Configuration for [`PlanExecute`].
24#[derive(Clone, Debug)]
25pub struct PlanExecuteConfig {
26    /// System instruction added until the first edit or write tool call.
27    pub planning_prompt: String,
28    /// Optional instruction appended to the handoff request.
29    pub handoff_prompt: Option<String>,
30    /// Replays visible planner reasoning summaries as assistant text at handoff.
31    pub planner_reasoning_as_text: bool,
32}
33
34impl Default for PlanExecuteConfig {
35    fn default() -> Self {
36        Self {
37            planning_prompt: DEFAULT_PLANNING_PROMPT.trim().to_string(),
38            handoff_prompt: None,
39            planner_reasoning_as_text: false,
40        }
41    }
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45enum Phase {
46    Plan,
47    Handoff,
48    Execute,
49}
50
51/// Routes planning turns to the runtime capable model, then latches execution
52/// to the runtime efficient model after the first recorded mutation.
53pub struct PlanExecute {
54    config: PlanExecuteConfig,
55    executing_sessions: Mutex<HashSet<RoutingIdentity>>,
56}
57
58impl PlanExecute {
59    /// Creates a plan/execute router.
60    ///
61    /// Returns an error when either configured prompt is blank.
62    pub fn new(config: PlanExecuteConfig) -> Result<Self> {
63        if config.planning_prompt.trim().is_empty() {
64            return Err(LibsyError::AlgorithmError {
65                message: "planning_prompt must not be empty".to_string(),
66            });
67        }
68        if config
69            .handoff_prompt
70            .as_deref()
71            .is_some_and(|prompt| prompt.trim().is_empty())
72        {
73            return Err(LibsyError::AlgorithmError {
74                message: "handoff_prompt must not be empty".to_string(),
75            });
76        }
77        Ok(Self {
78            config,
79            executing_sessions: Mutex::new(HashSet::new()),
80        })
81    }
82
83    fn phase(&self, request: &Request) -> Result<Phase> {
84        let signals = ToolSignals::from_request(request, None);
85        let mutation_seen = signals.edit_count > 0 || signals.write_count > 0;
86        let Some(identity) = RoutingIdentity::from_request(request) else {
87            return Ok(if mutation_seen {
88                Phase::Handoff
89            } else {
90                Phase::Plan
91            });
92        };
93        let is_session_final = request
94            .metadata
95            .as_ref()
96            .and_then(|metadata| metadata.session_final)
97            == Some(true);
98
99        let mut sessions = self.executing_sessions.lock();
100        let phase = if sessions.contains(&identity) {
101            Phase::Execute
102        } else if mutation_seen {
103            if !is_session_final {
104                if sessions.len() >= MAX_EXECUTING_SESSIONS {
105                    return Err(LibsyError::AlgorithmError {
106                        message: format!(
107                            "plan_execute reached its limit of {MAX_EXECUTING_SESSIONS} executing sessions. \
108                             Finish an existing session with session_final before retrying the handoff"
109                        ),
110                    });
111                }
112                sessions.insert(identity.clone());
113            }
114            Phase::Handoff
115        } else {
116            Phase::Plan
117        };
118        if is_session_final {
119            sessions.remove(&identity);
120        }
121        Ok(phase)
122    }
123
124    fn replay_planner_reasoning_as_text(request: &mut Request) -> usize {
125        let mut converted = 0;
126        for message in &mut request.llm_request.messages {
127            message.content = std::mem::take(&mut message.content)
128                .into_iter()
129                .filter_map(|block| match block {
130                    ContentBlock::Reasoning { text, .. } => {
131                        converted += 1;
132                        (!text.is_empty()).then_some(ContentBlock::Text { text })
133                    }
134                    other => Some(other),
135                })
136                .collect();
137        }
138        request
139            .llm_request
140            .messages
141            .retain(|message| !message.content.is_empty());
142        if converted > 0 {
143            drop_exact_replay(request);
144        }
145        converted
146    }
147
148    fn route_to(driver: &Driver, category: Category, request: Request) -> Result<RoutingOutcome> {
149        let models = driver.models_for(&category);
150        let Some((selected, fallbacks)) = models.split_first() else {
151            return Err(LibsyError::AlgorithmError {
152                message: format!("no models available for category {}", category.as_str()),
153            });
154        };
155        Ok(RoutingOutcome::route_to(
156            selected.clone(),
157            fallbacks.to_vec(),
158            request,
159        ))
160    }
161}
162
163#[async_trait::async_trait]
164impl Algorithm for PlanExecute {
165    fn name(&self) -> &str {
166        "plan_execute"
167    }
168
169    async fn route(
170        self: Arc<Self>,
171        driver: Driver,
172        mut request: Request,
173    ) -> Result<RoutingOutcome> {
174        match self.phase(&request)? {
175            Phase::Plan => {
176                prepend_system_prompt(&mut request, &self.config.planning_prompt);
177                tracing::debug!(phase = "plan", "plan-execute selected capable tier");
178                Self::route_to(&driver, Category::Capable, request)
179            }
180            Phase::Handoff => {
181                let reasoning_converted = if self.config.planner_reasoning_as_text {
182                    Self::replay_planner_reasoning_as_text(&mut request)
183                } else {
184                    0
185                };
186                let prompt_applied = if let Some(prompt) = &self.config.handoff_prompt {
187                    append_note(&mut request, prompt);
188                    true
189                } else {
190                    false
191                };
192                tracing::debug!(
193                    phase = "handoff",
194                    handoff_prompt_applied = prompt_applied,
195                    planner_reasoning_converted = reasoning_converted,
196                    "plan-execute selected efficient tier"
197                );
198                Self::route_to(&driver, Category::Efficient, request)
199            }
200            Phase::Execute => {
201                tracing::debug!(phase = "execute", "plan-execute selected efficient tier");
202                Self::route_to(&driver, Category::Efficient, request)
203            }
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use std::collections::HashMap;
211    use std::sync::Arc;
212
213    use serde_json::json;
214    use switchyard_protocol::{
215        ContentBlock, LlmRequest, Message, Metadata, ModelId, Request, Role, ToolCall,
216    };
217
218    use super::*;
219    use crate::RuntimeModels;
220    use crate::core::testing::{reply, test_drive_with_models};
221
222    const CAPABLE: &str = "model/capable";
223    const EFFICIENT: &str = "model/efficient";
224
225    fn algorithm(config: PlanExecuteConfig) -> Arc<dyn Algorithm> {
226        Arc::new(PlanExecute::new(config).expect("config should be valid"))
227    }
228
229    fn request(messages: Vec<Message>, session_id: Option<&str>) -> Request {
230        Request {
231            llm_request: LlmRequest {
232                model: Some("switchyard/plan-execute".to_string()),
233                messages,
234                ..LlmRequest::default()
235            },
236            metadata: session_id.map(|session_id| Metadata {
237                session_id: Some(session_id.to_string()),
238                ..Metadata::default()
239            }),
240            ..Request::default()
241        }
242    }
243
244    fn tool_call(name: &str, arguments: serde_json::Value) -> Message {
245        Message {
246            role: Role::Assistant,
247            content: vec![ContentBlock::ToolCall(ToolCall {
248                id: "call-1".to_string(),
249                name: name.to_string(),
250                arguments,
251            })],
252        }
253    }
254
255    fn models() -> RuntimeModels {
256        RuntimeModels::new(HashMap::from([
257            (Category::Capable, vec![ModelId::from(CAPABLE)]),
258            (Category::Efficient, vec![ModelId::from(EFFICIENT)]),
259        ]))
260    }
261
262    async fn route_and_capture(
263        algorithm: Arc<dyn Algorithm>,
264        request: Request,
265    ) -> (ModelId, Request) {
266        let captured = Arc::new(Mutex::new(None));
267        let capture = Arc::clone(&captured);
268        let (selected, _) =
269            test_drive_with_models(algorithm, request, models(), move |_target, request| {
270                let capture = Arc::clone(&capture);
271                async move {
272                    *capture.lock() = Some(request);
273                    Ok(reply("ok"))
274                }
275            })
276            .await
277            .expect("routing should succeed");
278        let request = captured
279            .lock()
280            .take()
281            .expect("answer request should be captured");
282        (selected, request)
283    }
284
285    #[tokio::test]
286    async fn plans_then_hands_off_and_latches_execution() {
287        const HANDOFF: &str = "Continue from the plan and repository evidence.";
288        let algorithm = algorithm(PlanExecuteConfig {
289            handoff_prompt: Some(HANDOFF.to_string()),
290            planner_reasoning_as_text: true,
291            ..PlanExecuteConfig::default()
292        });
293
294        let read_only = request(
295            vec![tool_call(
296                "exec_command",
297                json!({"cmd": "rg parser crates"}),
298            )],
299            Some("task-1"),
300        );
301        let (selected, routed) = route_and_capture(Arc::clone(&algorithm), read_only).await;
302        assert_eq!(selected, CAPABLE);
303        assert_eq!(routed.llm_request.instructions.len(), 1);
304
305        let first_edit = request(
306            vec![Message {
307                role: Role::Assistant,
308                content: vec![
309                    ContentBlock::Reasoning {
310                        text: "The parser needs a boundary check.".to_string(),
311                        signature: Some("planner-signature".to_string()),
312                        details: vec![json!({"type": "reasoning.encrypted", "data": "opaque"})],
313                    },
314                    ContentBlock::ToolCall(ToolCall {
315                        id: "call-1".to_string(),
316                        name: "apply_patch".to_string(),
317                        arguments: json!({"patch": "*** Begin Patch"}),
318                    }),
319                ],
320            }],
321            Some("task-1"),
322        );
323        let (selected, routed) = route_and_capture(Arc::clone(&algorithm), first_edit).await;
324        assert_eq!(selected, EFFICIENT);
325        assert_eq!(
326            routed.llm_request.messages[0].content[0],
327            ContentBlock::Text {
328                text: "The parser needs a boundary check.".to_string()
329            }
330        );
331        assert_eq!(
332            routed.llm_request.messages.last(),
333            Some(&Message::text(Role::User, HANDOFF))
334        );
335
336        let mut final_request = request(
337            vec![Message::text(Role::User, "Continue after compaction")],
338            Some("task-1"),
339        );
340        final_request
341            .metadata
342            .as_mut()
343            .expect("session metadata should exist")
344            .session_final = Some(true);
345        let (selected, routed) = route_and_capture(Arc::clone(&algorithm), final_request).await;
346        assert_eq!(selected, EFFICIENT);
347        assert!(routed.llm_request.instructions.is_empty());
348        assert_eq!(routed.llm_request.messages.len(), 1);
349
350        let reused = request(vec![Message::text(Role::User, "New task")], Some("task-1"));
351        let (selected, _) = route_and_capture(algorithm, reused).await;
352        assert_eq!(selected, CAPABLE);
353    }
354
355    #[tokio::test]
356    async fn capacity_preserves_execution_after_compaction() {
357        let algorithm = algorithm(PlanExecuteConfig::default());
358        let sessions: Vec<_> = (0..MAX_EXECUTING_SESSIONS)
359            .map(|index| format!("task-{index}"))
360            .collect();
361        for session in &sessions {
362            let first_edit = request(
363                vec![tool_call("Write", json!({"file_path": "task.py"}))],
364                Some(session),
365            );
366            let (selected, _) = route_and_capture(Arc::clone(&algorithm), first_edit).await;
367            assert_eq!(selected, EFFICIENT);
368        }
369
370        let overflow = request(
371            vec![tool_call("Write", json!({"file_path": "task.py"}))],
372            Some("overflow"),
373        );
374        let (driver, _) = Driver::new("plan_execute", Arc::new(models()));
375        let result = Arc::clone(&algorithm).route(driver, overflow.clone()).await;
376        assert!(matches!(
377            result,
378            Err(LibsyError::AlgorithmError { message })
379                if message.contains("limit of 4096 executing sessions")
380        ));
381
382        for session in &sessions {
383            let mut compacted = request(
384                vec![Message::text(Role::User, "Continue after compaction")],
385                Some(session),
386            );
387            if Some(session) == sessions.last() {
388                compacted.metadata.as_mut().unwrap().session_final = Some(true);
389            }
390            let (selected, routed) = route_and_capture(Arc::clone(&algorithm), compacted).await;
391            assert_eq!(selected, EFFICIENT, "session {session} lost its latch");
392            assert!(routed.llm_request.instructions.is_empty());
393        }
394
395        let (selected, _) = route_and_capture(Arc::clone(&algorithm), overflow).await;
396        assert_eq!(selected, EFFICIENT);
397        let compacted = request(
398            vec![Message::text(Role::User, "Continue after compaction")],
399            Some("overflow"),
400        );
401        let (selected, routed) = route_and_capture(algorithm, compacted).await;
402        assert_eq!(selected, EFFICIENT);
403        assert!(routed.llm_request.instructions.is_empty());
404    }
405
406    #[tokio::test]
407    async fn final_handoff_does_not_evict_an_executing_session_at_capacity() {
408        let algorithm = Arc::new(PlanExecute::new(PlanExecuteConfig::default()).unwrap());
409        algorithm.executing_sessions.lock().extend(
410            (0..MAX_EXECUTING_SESSIONS)
411                .map(|index| RoutingIdentity::Session(format!("task-{index}"))),
412        );
413        let mut final_request = request(
414            vec![tool_call("Write", json!({"file_path": "task.py"}))],
415            Some("final-handoff"),
416        );
417        final_request.metadata.as_mut().unwrap().session_final = Some(true);
418
419        let (selected, routed) = route_and_capture(algorithm.clone(), final_request).await;
420        assert_eq!(selected, EFFICIENT);
421        assert!(routed.llm_request.instructions.is_empty());
422        let sessions = algorithm.executing_sessions.lock();
423        assert_eq!(sessions.len(), MAX_EXECUTING_SESSIONS);
424        assert!(!sessions.contains(&RoutingIdentity::Session("final-handoff".to_string())));
425    }
426
427    #[tokio::test]
428    async fn mutation_without_a_session_uses_the_efficient_tier() {
429        let messages = vec![tool_call(
430            "exec_command",
431            json!({"cmd": "printf 'done\\n' > task.txt"}),
432        )];
433
434        let (selected, routed) = route_and_capture(
435            algorithm(PlanExecuteConfig::default()),
436            request(messages.clone(), None),
437        )
438        .await;
439
440        assert_eq!(selected, EFFICIENT);
441        assert_eq!(routed.llm_request.messages, messages);
442        assert!(routed.llm_request.instructions.is_empty());
443    }
444
445    #[tokio::test]
446    async fn editor_view_keeps_planning() {
447        let messages = vec![tool_call(
448            "str_replace_based_edit_tool",
449            json!({"command": "view", "path": "/app/main.py"}),
450        )];
451
452        let (selected, _) = route_and_capture(
453            algorithm(PlanExecuteConfig::default()),
454            request(messages, None),
455        )
456        .await;
457
458        assert_eq!(selected, CAPABLE);
459    }
460
461    #[test]
462    fn rejects_blank_prompts() {
463        for config in [
464            PlanExecuteConfig {
465                planning_prompt: "  ".to_string(),
466                ..PlanExecuteConfig::default()
467            },
468            PlanExecuteConfig {
469                handoff_prompt: Some("  ".to_string()),
470                ..PlanExecuteConfig::default()
471            },
472        ] {
473            assert!(matches!(
474                PlanExecute::new(config),
475                Err(LibsyError::AlgorithmError { .. })
476            ));
477        }
478    }
479}