1mod catalog;
2mod http;
3mod parsing;
4mod prompt;
5
6use std::collections::BTreeSet;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use thiserror::Error;
11use tracing::{debug, info, warn};
12
13use orchestral_core::planner::{PlanError, Planner, PlannerContext, PlannerOutput};
14use orchestral_core::types::Intent;
15
16pub use self::http::{HttpLlmClient, HttpLlmClientConfig};
17use self::parsing::{extract_json, parse_action_selection, parse_planner_output, ActionSelection};
18use self::prompt::{build_action_selector_prompt, build_planner_prompt, truncate_for_log};
19
20const MAX_PROMPT_LOG_CHARS: usize = 4_000;
21const MAX_LLM_OUTPUT_LOG_CHARS: usize = 8_000;
22#[derive(Debug, Clone)]
24pub struct LlmRequest {
25 pub system: String,
26 pub user: String,
27 pub model: String,
28 pub temperature: f32,
29}
30
31#[derive(Debug, Clone)]
33pub struct ToolDefinition {
34 pub name: String,
35 pub description: String,
36 pub parameters: serde_json::Value,
38}
39
40#[derive(Debug, Clone)]
42pub enum LlmResponse {
43 Text(String),
44 ToolCall {
45 id: String,
46 name: String,
47 arguments: serde_json::Value,
48 },
49}
50
51pub type StreamChunkCallback = Arc<dyn Fn(String) + Send + Sync>;
52
53#[async_trait]
55pub trait LlmClient: Send + Sync {
56 async fn complete(&self, request: LlmRequest) -> Result<String, LlmError>;
57
58 async fn complete_with_tools(
61 &self,
62 request: LlmRequest,
63 tools: &[ToolDefinition],
64 ) -> Result<LlmResponse, LlmError> {
65 let _ = tools;
66 let text = self.complete(request).await?;
67 Ok(LlmResponse::Text(text))
68 }
69
70 async fn complete_stream(
71 &self,
72 request: LlmRequest,
73 on_chunk: StreamChunkCallback,
74 ) -> Result<String, LlmError> {
75 let full = self.complete(request).await?;
76 for token in full.split_inclusive(char::is_whitespace) {
77 if !token.is_empty() {
78 on_chunk(token.to_string());
79 }
80 }
81 Ok(full)
82 }
83}
84
85#[async_trait]
86impl LlmClient for Arc<dyn LlmClient> {
87 async fn complete(&self, request: LlmRequest) -> Result<String, LlmError> {
88 (**self).complete(request).await
89 }
90
91 async fn complete_with_tools(
92 &self,
93 request: LlmRequest,
94 tools: &[ToolDefinition],
95 ) -> Result<LlmResponse, LlmError> {
96 (**self).complete_with_tools(request, tools).await
97 }
98
99 async fn complete_stream(
100 &self,
101 request: LlmRequest,
102 on_chunk: StreamChunkCallback,
103 ) -> Result<String, LlmError> {
104 (**self).complete_stream(request, on_chunk).await
105 }
106}
107
108#[derive(Debug, Error)]
110pub enum LlmError {
111 #[error("http error: {0}")]
112 Http(String),
113 #[error("response error: {0}")]
114 Response(String),
115 #[error("serialization error: {0}")]
116 Serialization(String),
117}
118
119#[derive(Debug, Clone)]
121pub struct LlmPlannerConfig {
122 pub model: String,
123 pub temperature: f32,
124 pub max_history: usize,
125 pub system_prompt: String,
126 pub log_full_prompts: bool,
127 pub selector_min_action_count: usize,
128 pub selector_max_actions: usize,
129}
130
131impl Default for LlmPlannerConfig {
132 fn default() -> Self {
133 Self {
134 model: "anthropic/claude-sonnet-4.5".to_string(),
135 temperature: 0.2,
136 max_history: 20,
137 system_prompt: String::new(),
138 log_full_prompts: false,
139 selector_min_action_count: 30,
140 selector_max_actions: 30,
141 }
142 }
143}
144
145pub struct LlmPlanner<C: LlmClient> {
147 pub client: C,
148 pub config: LlmPlannerConfig,
149}
150
151impl<C: LlmClient> LlmPlanner<C> {
152 pub fn new(client: C, config: LlmPlannerConfig) -> Self {
153 Self { client, config }
154 }
155
156 fn build_prompt(&self, intent: &Intent, context: &PlannerContext) -> (String, String) {
157 build_planner_prompt(
158 &self.config.system_prompt,
159 intent,
160 context,
161 self.config.max_history,
162 )
163 }
164
165 fn build_selector_prompt(&self, intent: &Intent, context: &PlannerContext) -> (String, String) {
166 build_action_selector_prompt(
167 &self.config.system_prompt,
168 intent,
169 context,
170 self.config.max_history,
171 self.config.selector_max_actions,
172 )
173 }
174
175 fn should_run_action_selector(&self, context: &PlannerContext) -> bool {
176 let action_count = context.available_actions.len();
177 action_count >= self.config.selector_min_action_count
178 && action_count > self.config.selector_max_actions
179 }
180
181 fn apply_selection(
182 &self,
183 context: &PlannerContext,
184 selection: ActionSelection,
185 ) -> Option<ResolvedActionSelection> {
186 let selected = selection
187 .selected_actions
188 .into_iter()
189 .collect::<BTreeSet<_>>();
190 let blocked = selection
191 .blocked_actions
192 .into_iter()
193 .collect::<BTreeSet<_>>();
194
195 let mut resolved_actions = Vec::new();
196 let mut resolved_selected_names = Vec::new();
197 let mut resolved_blocked_names = Vec::new();
198
199 for action in &context.available_actions {
200 if blocked.contains(&action.name) {
201 resolved_blocked_names.push(action.name.clone());
202 continue;
203 }
204 if selected.contains(&action.name)
205 && resolved_actions.len() < self.config.selector_max_actions
206 {
207 resolved_selected_names.push(action.name.clone());
208 resolved_actions.push(action.clone());
209 }
210 }
211
212 if resolved_actions.is_empty() {
213 return None;
214 }
215
216 if resolved_actions.len() >= context.available_actions.len()
217 && resolved_blocked_names.is_empty()
218 {
219 return None;
220 }
221
222 let filtered_context = PlannerContext {
223 available_actions: resolved_actions,
224 history: context.history.clone(),
225 runtime_info: context.runtime_info.clone(),
226 skill_instructions: context.skill_instructions.clone(),
227 skill_summaries: context.skill_summaries.clone(),
228 loop_context: context.loop_context.clone(),
229 };
230
231 Some(ResolvedActionSelection {
232 filtered_context,
233 selected_actions: resolved_selected_names,
234 blocked_actions: resolved_blocked_names,
235 reason: selection.reason,
236 })
237 }
238
239 async fn maybe_select_actions(
240 &self,
241 intent: &Intent,
242 context: &PlannerContext,
243 ) -> Result<Option<ResolvedActionSelection>, PlanError> {
244 if !self.should_run_action_selector(context) {
245 return Ok(None);
246 }
247
248 let (system, user) = self.build_selector_prompt(intent, context);
249 info!(
250 model = %self.config.model,
251 temperature = self.config.temperature,
252 action_count = context.available_actions.len(),
253 selector_max_actions = self.config.selector_max_actions,
254 selector_min_action_count = self.config.selector_min_action_count,
255 "action selector request prepared"
256 );
257 if tracing::enabled!(tracing::Level::DEBUG) {
258 if self.config.log_full_prompts {
259 debug!(
260 system_prompt = %system,
261 user_prompt = %user,
262 system_chars = system.chars().count(),
263 user_chars = user.chars().count(),
264 "action selector prompts (full)"
265 );
266 } else {
267 debug!(
268 system_prompt = %truncate_for_log(&system, MAX_PROMPT_LOG_CHARS),
269 user_prompt = %truncate_for_log(&user, MAX_PROMPT_LOG_CHARS),
270 system_chars = system.chars().count(),
271 user_chars = user.chars().count(),
272 "action selector prompts"
273 );
274 }
275 }
276
277 let request = LlmRequest {
278 system,
279 user,
280 model: self.config.model.clone(),
281 temperature: self.config.temperature,
282 };
283 let output = self
284 .client
285 .complete(request)
286 .await
287 .map_err(|e| PlanError::LlmError(e.to_string()))?;
288 if tracing::enabled!(tracing::Level::DEBUG) {
289 debug!(
290 llm_output = %truncate_for_log(&output, MAX_LLM_OUTPUT_LOG_CHARS),
291 "action selector raw llm output"
292 );
293 }
294
295 let json_str = match extract_json(&output) {
296 Some(json) => json,
297 None => {
298 warn!(
299 "action selector output did not contain JSON; falling back to full action set"
300 );
301 return Ok(None);
302 }
303 };
304 if tracing::enabled!(tracing::Level::DEBUG) {
305 debug!(
306 selector_json = %truncate_for_log(&json_str, MAX_LLM_OUTPUT_LOG_CHARS),
307 "action selector extracted json"
308 );
309 }
310
311 let selection = match parse_action_selection(&json_str) {
312 Ok(selection) => selection,
313 Err(error) => {
314 warn!(error = %error, "action selector output was invalid; falling back to full action set");
315 return Ok(None);
316 }
317 };
318
319 let resolved = match self.apply_selection(context, selection) {
320 Some(resolved) => resolved,
321 None => {
322 warn!("action selector did not resolve any narrower action subset; falling back to full action set");
323 return Ok(None);
324 }
325 };
326
327 info!(
328 selected_count = resolved.selected_actions.len(),
329 blocked_count = resolved.blocked_actions.len(),
330 selected_actions = %resolved.selected_actions.join(", "),
331 blocked_actions = %resolved.blocked_actions.join(", "),
332 reason = ?resolved.reason,
333 "action selector resolved actions"
334 );
335 Ok(Some(resolved))
336 }
337}
338
339struct ResolvedActionSelection {
340 filtered_context: PlannerContext,
341 selected_actions: Vec<String>,
342 blocked_actions: Vec<String>,
343 reason: Option<String>,
344}
345
346#[async_trait]
347impl<C: LlmClient> Planner for LlmPlanner<C> {
348 async fn plan(
349 &self,
350 intent: &Intent,
351 context: &PlannerContext,
352 ) -> Result<PlannerOutput, PlanError> {
353 let selected_context = self
354 .maybe_select_actions(intent, context)
355 .await?
356 .map(|selection| selection.filtered_context);
357 let planner_context = selected_context.as_ref().unwrap_or(context);
358
359 let (system, user) = self.build_prompt(intent, planner_context);
360 info!(
361 model = %self.config.model,
362 temperature = self.config.temperature,
363 intent_len = intent.content.len(),
364 action_count = planner_context.available_actions.len(),
365 history_count = planner_context.history.len(),
366 "planner request prepared"
367 );
368 if tracing::enabled!(tracing::Level::DEBUG) {
369 if self.config.log_full_prompts {
370 debug!(
371 system_prompt = %system,
372 user_prompt = %user,
373 system_chars = system.chars().count(),
374 user_chars = user.chars().count(),
375 "planner prompts (full)"
376 );
377 } else {
378 let system_preview = truncate_for_log(&system, MAX_PROMPT_LOG_CHARS);
379 let user_preview = truncate_for_log(&user, MAX_PROMPT_LOG_CHARS);
380 debug!(
381 system_prompt = %system_preview,
382 user_prompt = %user_preview,
383 system_chars = system.chars().count(),
384 user_chars = user.chars().count(),
385 "planner prompts"
386 );
387 }
388 }
389 let request = LlmRequest {
390 system,
391 user,
392 model: self.config.model.clone(),
393 temperature: self.config.temperature,
394 };
395 let output = self
396 .client
397 .complete(request)
398 .await
399 .map_err(|e| PlanError::LlmError(e.to_string()))?;
400 if tracing::enabled!(tracing::Level::DEBUG) {
401 debug!(
402 llm_output = %truncate_for_log(&output, MAX_LLM_OUTPUT_LOG_CHARS),
403 "planner raw llm output"
404 );
405 }
406
407 let json_str = extract_json(&output)
408 .ok_or_else(|| PlanError::Generation("LLM output did not contain JSON".to_string()))?;
409 if tracing::enabled!(tracing::Level::DEBUG) {
410 debug!(
411 plan_json = %truncate_for_log(&json_str, MAX_LLM_OUTPUT_LOG_CHARS),
412 "planner extracted json"
413 );
414 }
415
416 let output = parse_planner_output(&json_str)?;
417 match &output {
418 PlannerOutput::SingleAction(call) => {
419 info!(
420 output_type = "single_action",
421 action = %call.action,
422 reason = ?call.reason,
423 "planner parsed output"
424 );
425 }
426 PlannerOutput::MiniPlan(plan) => {
427 info!(
428 output_type = "mini_plan",
429 goal = %plan.goal,
430 step_count = plan.steps.len(),
431 "planner parsed output"
432 );
433 }
434 PlannerOutput::Done(message) => {
435 info!(
436 output_type = "done",
437 message = %truncate_for_log(message, MAX_PROMPT_LOG_CHARS),
438 "planner parsed output"
439 );
440 }
441 PlannerOutput::NeedInput(question) => {
442 info!(
443 output_type = "need_input",
444 question = %truncate_for_log(question, MAX_PROMPT_LOG_CHARS),
445 "planner parsed output"
446 );
447 }
448 }
449 Ok(output)
450 }
451}
452
453pub struct MockLlmClient {
455 pub response: String,
456}
457
458#[async_trait]
459impl LlmClient for MockLlmClient {
460 async fn complete(&self, _request: LlmRequest) -> Result<String, LlmError> {
461 Ok(self.response.clone())
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use orchestral_core::action::ActionMeta;
469 use orchestral_core::planner::{PlannerContext, SkillInstruction};
470 use orchestral_core::types::Intent;
471 use serde_json::json;
472 use std::collections::VecDeque;
473 use std::sync::Mutex;
474
475 #[test]
476 fn test_planner_prompt_uses_new_output_shapes() {
477 let planner = LlmPlanner::new(
478 MockLlmClient {
479 response: "{}".to_string(),
480 },
481 LlmPlannerConfig {
482 system_prompt: "Base prompt.".to_string(),
483 ..LlmPlannerConfig::default()
484 },
485 );
486
487 let actions = vec![
488 ActionMeta::new("write_doc", "Write markdown to file")
489 .with_capabilities(["filesystem_write", "side_effect"])
490 .with_input_kinds(["path", "text"])
491 .with_output_kinds(["path"])
492 .with_input_schema(json!({
493 "type":"object",
494 "properties":{
495 "path":{"type":"string","description":"Target markdown path","example":"guide.md"},
496 "content":{"type":"string","description":"Markdown content"}
497 },
498 "required":["path","content"]
499 }))
500 .with_output_schema(
501 json!({
502 "type":"object",
503 "properties":{
504 "path":{"type":"string","description":"Resolved path"},
505 "bytes":{"type":"integer","description":"Written bytes"}
506 }
507 }),
508 ),
509 ActionMeta::new("file_read", "Read a file")
510 .with_capabilities(["filesystem_read"])
511 .with_input_kinds(["path"])
512 .with_output_kinds(["text"]),
513 ];
514 let context = PlannerContext::new(actions);
515 let intent = Intent::new("generate a guide");
516 let (system, user) = planner.build_prompt(&intent, &context);
517
518 assert!(system.contains("Orchestral Planner"));
519 assert!(system.contains("Legacy workflow/stage outputs are disabled."));
520 assert!(system.contains("SINGLE_ACTION"));
521 assert!(system.contains("MINI_PLAN"));
522 assert!(!system.contains("Action Catalog"));
523 assert!(user.contains("\"type\":\"SINGLE_ACTION\""));
524 assert!(user.contains("\"type\":\"MINI_PLAN\""));
525 assert!(user.contains("\"type\":\"DONE\""));
526 assert!(user.contains("\"type\":\"NEED_INPUT\""));
527 assert!(!user.contains("\"type\":\"WORKFLOW\""));
528 assert!(!user.contains("\"type\":\"STAGE_CHOICE\""));
529 assert!(user.contains("DONE must never claim to execute commands"));
530 }
531
532 #[test]
533 fn test_planner_prompt_contains_skill_knowledge() {
534 let planner = LlmPlanner::new(
535 MockLlmClient {
536 response: "{}".to_string(),
537 },
538 LlmPlannerConfig::default(),
539 );
540
541 let actions = vec![ActionMeta::new("mcp__alpha", "Call MCP server alpha")
542 .with_capabilities(["mcp", "side_effect"])
543 .with_input_kinds(["structured"])
544 .with_output_kinds(["structured"])
545 .with_input_schema(json!({
546 "type":"object",
547 "properties":{
548 "operation":{"type":"string"},
549 "tool":{"type":"string"},
550 "arguments":{"type":"object"}
551 }
552 }))
553 .with_output_schema(json!({
554 "type":"object",
555 "properties":{
556 "server":{"type":"string"},
557 "result":{}
558 }
559 }))];
560 let context =
561 PlannerContext::new(actions).with_skill_instructions(vec![SkillInstruction {
562 skill_name: "demo".to_string(),
563 instructions: "Always write then verify.".to_string(),
564 skill_path: Some("skills/demo/SKILL.md".to_string()),
565 scripts_dir: Some(".claude/skills/demo/scripts".to_string()),
566 venv_python: None,
567 }]);
568 let intent = Intent::new("need tools and skills");
569 let (system, _user) = planner.build_prompt(&intent, &context);
570
571 assert!(system.contains("Activated Skills:"));
572 assert!(system.contains("never invent script filenames"));
573 assert!(system.contains("- demo"));
574 assert!(system.contains("Always write then verify."));
575 assert!(system.contains("[skill file: skills/demo/SKILL.md]"));
576 assert!(system.contains("[scripts: .claude/skills/demo/scripts]"));
577 assert!(!system.contains("Action Catalog"));
578 }
579
580 #[test]
581 fn test_parse_done_output() {
582 let raw = r#"{"type":"DONE","message":"你好"}"#;
583 let parsed = parse_planner_output(raw).expect("parse done");
584 match parsed {
585 PlannerOutput::Done(message) => {
586 assert_eq!(message, "你好");
587 }
588 _ => panic!("expected done output"),
589 }
590 }
591
592 #[test]
593 fn test_parse_single_action_output() {
594 let raw = r#"{"type":"SINGLE_ACTION","action":"file_read","params":{"path":"README.md"},"reason":"read readme"}"#;
595 let parsed = parse_planner_output(raw).expect("parse single action");
596 match parsed {
597 PlannerOutput::SingleAction(call) => {
598 assert_eq!(call.action, "file_read");
599 assert_eq!(call.params["path"], "README.md");
600 assert_eq!(call.reason.as_deref(), Some("read readme"));
601 }
602 _ => panic!("expected single_action output"),
603 }
604 }
605
606 #[test]
607 fn test_parse_need_input_output() {
608 let raw = r#"{"type":"NEED_INPUT","question":"请提供文件路径"}"#;
609 let parsed = parse_planner_output(raw).expect("parse need_input");
610 match parsed {
611 PlannerOutput::NeedInput(question) => {
612 assert_eq!(question, "请提供文件路径");
613 }
614 _ => panic!("expected need_input output"),
615 }
616 }
617
618 #[test]
619 fn test_parse_mini_plan_output() {
620 let raw = r#"{
621 "type":"MINI_PLAN",
622 "goal":"inspect then summarize",
623 "steps":[
624 {"id":"list_docs","action":"shell","params":{"command":"find ./docs -maxdepth 1 -type f"}},
625 {"id":"read_readme","action":"file_read","depends_on":["list_docs"],"params":{"path":"README.md"}}
626 ],
627 "on_complete":"{{read_readme.content}}"
628 }"#;
629 let parsed = parse_planner_output(raw).expect("parse mini plan");
630 match parsed {
631 PlannerOutput::MiniPlan(plan) => {
632 assert_eq!(plan.goal, "inspect then summarize");
633 assert_eq!(plan.steps.len(), 2);
634 assert_eq!(plan.steps[1].depends_on.len(), 1);
635 assert_eq!(plan.on_complete.as_deref(), Some("{{read_readme.content}}"));
636 }
637 _ => panic!("expected mini_plan output"),
638 }
639 }
640
641 #[test]
642 fn test_extract_json_ignores_non_json_braces() {
643 let raw = r#"Preface {not json} -> {"type":"DONE","message":"ok"} trailing"#;
644 let json = extract_json(raw).expect("json");
645 assert_eq!(json, r#"{"type":"DONE","message":"ok"}"#);
646 }
647
648 #[test]
649 fn test_extract_json_handles_braces_inside_strings() {
650 let raw = r#"noise {"type":"DONE","message":"value with } brace"} end"#;
651 let json = extract_json(raw).expect("json");
652 assert_eq!(json, r#"{"type":"DONE","message":"value with } brace"}"#);
653 }
654
655 #[test]
656 fn test_parse_action_selection_output() {
657 let raw = r#"{"selected_actions":["file_read","file_write"],"blocked_actions":["shell"],"reason":"typed actions are enough"}"#;
658 let parsed = parse_action_selection(raw).expect("parse action selection");
659
660 assert_eq!(parsed.selected_actions, vec!["file_read", "file_write"]);
661 assert_eq!(parsed.blocked_actions, vec!["shell"]);
662 assert_eq!(parsed.reason.as_deref(), Some("typed actions are enough"));
663 }
664
665 struct RecordingMockLlmClient {
666 responses: Mutex<VecDeque<String>>,
667 requests: Mutex<Vec<LlmRequest>>,
668 }
669
670 #[async_trait]
671 impl LlmClient for RecordingMockLlmClient {
672 async fn complete(&self, request: LlmRequest) -> Result<String, LlmError> {
673 self.requests.lock().expect("requests lock").push(request);
674 self.responses
675 .lock()
676 .expect("responses lock")
677 .pop_front()
678 .ok_or_else(|| LlmError::Response("no queued mock response".to_string()))
679 }
680 }
681
682 #[tokio::test]
683 async fn test_llm_planner_uses_selector_filtered_actions() {
684 let planner = LlmPlanner::new(
685 RecordingMockLlmClient {
686 responses: Mutex::new(VecDeque::from(vec![
687 r#"{"selected_actions":["file_read","file_write"],"blocked_actions":["shell"],"reason":"typed file actions are sufficient"}"#.to_string(),
688 r#"{"type":"DONE","message":"ok"}"#.to_string(),
689 ])),
690 requests: Mutex::new(Vec::new()),
691 },
692 LlmPlannerConfig {
693 selector_min_action_count: 1,
694 selector_max_actions: 2,
695 ..LlmPlannerConfig::default()
696 },
697 );
698
699 let context = PlannerContext::new(vec![
700 ActionMeta::new("shell", "shell"),
701 ActionMeta::new("file_read", "read"),
702 ActionMeta::new("file_write", "write"),
703 ]);
704 let result = planner
705 .plan(&Intent::new("read and write the file safely"), &context)
706 .await
707 .expect("planner result");
708
709 match result {
710 PlannerOutput::Done(message) => assert_eq!(message, "ok"),
711 other => panic!("expected done output, got {:?}", other),
712 }
713
714 let requests = planner.client.requests.lock().expect("requests lock");
715 assert_eq!(requests.len(), 2);
716 assert!(requests[0].system.contains("Orchestral Action Selector."));
717 assert!(requests[1].system.contains("- file_read: read"));
718 assert!(requests[1].system.contains("- file_write: write"));
719 assert!(!requests[1].system.contains("- shell: shell"));
720 }
721
722 #[tokio::test]
723 async fn test_llm_planner_falls_back_when_selector_returns_unknown_actions() {
724 let planner = LlmPlanner::new(
725 RecordingMockLlmClient {
726 responses: Mutex::new(VecDeque::from(vec![
727 r#"{"selected_actions":["unknown_action"],"blocked_actions":[],"reason":"bad output"}"#.to_string(),
728 r#"{"type":"DONE","message":"ok"}"#.to_string(),
729 ])),
730 requests: Mutex::new(Vec::new()),
731 },
732 LlmPlannerConfig {
733 selector_min_action_count: 1,
734 selector_max_actions: 1,
735 ..LlmPlannerConfig::default()
736 },
737 );
738
739 planner
740 .plan(
741 &Intent::new("inspect the workspace"),
742 &PlannerContext::new(vec![
743 ActionMeta::new("shell", "shell"),
744 ActionMeta::new("file_read", "read"),
745 ]),
746 )
747 .await
748 .expect("planner result");
749
750 let requests = planner.client.requests.lock().expect("requests lock");
751 assert_eq!(requests.len(), 2);
752 assert!(requests[1].system.contains("- shell: shell"));
753 assert!(requests[1].system.contains("- file_read: read"));
754 }
755}