gemini_adk_rs/planners/
plan_re_act.rs

1//! Plan-ReAct planner — structures LLM output through planning-then-action.
2//!
3//! Mirrors ADK-Python's `PlanReActPlanner`. Forces the model to generate
4//! explicit plans using tagged sections before executing actions.
5
6use async_trait::async_trait;
7
8use super::{Planner, PlannerError};
9use crate::llm::LlmRequest;
10
11/// Tags used to structure the model's planning output.
12const TAG_PLANNING: &str = "/*PLANNING*/";
13const TAG_REPLANNING: &str = "/*REPLANNING*/";
14const TAG_REASONING: &str = "/*REASONING*/";
15const TAG_ACTION: &str = "/*ACTION*/";
16const TAG_FINAL_ANSWER: &str = "/*FINAL_ANSWER*/";
17
18/// Plan-ReAct planner that constrains the model to plan before acting.
19///
20/// The model is instructed to use specific tags to separate planning,
21/// reasoning, action, and final answer sections. The planner then
22/// filters the response to preserve only relevant sections.
23#[derive(Debug, Clone)]
24pub struct PlanReActPlanner {
25    /// Whether to include tool use instructions.
26    include_tool_instructions: bool,
27}
28
29impl PlanReActPlanner {
30    /// Create a new Plan-ReAct planner.
31    pub fn new() -> Self {
32        Self {
33            include_tool_instructions: true,
34        }
35    }
36
37    /// Set whether to include tool use instructions in the planning prompt.
38    pub fn with_tool_instructions(mut self, include: bool) -> Self {
39        self.include_tool_instructions = include;
40        self
41    }
42}
43
44impl Default for PlanReActPlanner {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50#[async_trait]
51impl Planner for PlanReActPlanner {
52    fn build_planning_instruction(
53        &self,
54        _request: &LlmRequest,
55    ) -> Result<Option<String>, PlannerError> {
56        let mut instruction = format!(
57            r#"For every turn, you must follow the format below and use these exact tags to organize your output:
58
591. {TAG_PLANNING} — Create a natural language plan for how to approach the query. Plans should be:
60   - Coherent and cover all aspects of the query
61   - Decomposed into numbered steps
62   - Aware of available tools and their capabilities
63
642. {TAG_REASONING} — For each step in your plan, explain your reasoning before taking action.
65
663. {TAG_ACTION} — Execute one step at a time using available tools when needed.
67
684. {TAG_REPLANNING} — If new information changes your approach, create an updated plan.
69
705. {TAG_FINAL_ANSWER} — After completing all steps, provide the final answer."#
71        );
72
73        if self.include_tool_instructions {
74            instruction.push_str(
75                "\n\nWhen using tools:\n\
76                 - Only use tools that are available to you\n\
77                 - Write self-contained tool calls\n\
78                 - Prefer using information from previous tool results over making redundant calls",
79            );
80        }
81
82        Ok(Some(instruction))
83    }
84
85    fn process_planning_response(
86        &self,
87        response_text: &str,
88    ) -> Result<Option<String>, PlannerError> {
89        // Extract and keep only the meaningful sections
90        let mut filtered = String::new();
91        let mut in_planning = false;
92        let mut in_reasoning = false;
93
94        for line in response_text.lines() {
95            let trimmed = line.trim();
96
97            if trimmed.contains(TAG_PLANNING) || trimmed.contains(TAG_REPLANNING) {
98                in_planning = true;
99                in_reasoning = false;
100                continue;
101            }
102            if trimmed.contains(TAG_REASONING) {
103                in_reasoning = true;
104                in_planning = false;
105                continue;
106            }
107            if trimmed.contains(TAG_ACTION) || trimmed.contains(TAG_FINAL_ANSWER) {
108                in_planning = false;
109                in_reasoning = false;
110                // Keep action and final answer lines
111                if !filtered.is_empty() {
112                    filtered.push('\n');
113                }
114                filtered.push_str(line);
115                continue;
116            }
117
118            if in_planning || in_reasoning {
119                // Planning and reasoning are treated as thoughts — kept but annotated
120                if !filtered.is_empty() {
121                    filtered.push('\n');
122                }
123                filtered.push_str(line);
124            } else {
125                // Regular content — keep as is
126                if !filtered.is_empty() {
127                    filtered.push('\n');
128                }
129                filtered.push_str(line);
130            }
131        }
132
133        if filtered.trim().is_empty() || filtered == response_text {
134            Ok(None)
135        } else {
136            Ok(Some(filtered))
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn builds_instruction_with_tags() {
147        let planner = PlanReActPlanner::new();
148        let request = LlmRequest::default();
149        let instruction = planner.build_planning_instruction(&request).unwrap();
150        let text = instruction.unwrap();
151        assert!(text.contains(TAG_PLANNING));
152        assert!(text.contains(TAG_REASONING));
153        assert!(text.contains(TAG_ACTION));
154        assert!(text.contains(TAG_FINAL_ANSWER));
155    }
156
157    #[test]
158    fn instruction_includes_tool_guidance() {
159        let planner = PlanReActPlanner::new().with_tool_instructions(true);
160        let request = LlmRequest::default();
161        let text = planner
162            .build_planning_instruction(&request)
163            .unwrap()
164            .unwrap();
165        assert!(text.contains("Only use tools"));
166    }
167
168    #[test]
169    fn instruction_without_tool_guidance() {
170        let planner = PlanReActPlanner::new().with_tool_instructions(false);
171        let request = LlmRequest::default();
172        let text = planner
173            .build_planning_instruction(&request)
174            .unwrap()
175            .unwrap();
176        assert!(!text.contains("Only use tools"));
177    }
178
179    #[test]
180    fn process_passthrough_plain_text() {
181        let planner = PlanReActPlanner::new();
182        let result = planner
183            .process_planning_response("Just a plain response")
184            .unwrap();
185        assert!(result.is_none());
186    }
187
188    #[test]
189    fn process_filters_tagged_response() {
190        let planner = PlanReActPlanner::new();
191        let response = format!(
192            "{TAG_PLANNING}\nStep 1: Search\nStep 2: Summarize\n{TAG_ACTION}\nSearching...\n{TAG_FINAL_ANSWER}\nThe answer is 42."
193        );
194        let result = planner.process_planning_response(&response).unwrap();
195        // Should produce a filtered version (not None since it's different from input)
196        assert!(result.is_some());
197    }
198}