Skip to main content

orchestral_runtime/planner/
llm.rs

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/// LLM request payload
23#[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/// Tool definition for LLM function calling.
32#[derive(Debug, Clone)]
33pub struct ToolDefinition {
34    pub name: String,
35    pub description: String,
36    /// JSON Schema describing the tool parameters.
37    pub parameters: serde_json::Value,
38}
39
40/// LLM response that may contain a structured tool call instead of text.
41#[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/// LLM client trait
54#[async_trait]
55pub trait LlmClient: Send + Sync {
56    async fn complete(&self, request: LlmRequest) -> Result<String, LlmError>;
57
58    /// Chat with tool definitions. LLM must call one of the provided tools.
59    /// Default implementation falls back to text-only `complete`.
60    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/// LLM errors
109#[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/// Planner config for LLM
120#[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
145/// LLM-based planner
146pub 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
453/// Mock LLM client for tests/examples
454pub 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}