1use 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
17pub 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#[derive(Clone, Debug)]
25pub struct PlanExecuteConfig {
26 pub planning_prompt: String,
28 pub handoff_prompt: Option<String>,
30 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
51pub struct PlanExecute {
54 config: PlanExecuteConfig,
55 executing_sessions: Mutex<HashSet<RoutingIdentity>>,
56}
57
58impl PlanExecute {
59 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}