Skip to main content

behest_tool/
strategy.rs

1//! Tool execution strategies.
2//!
3//! Controls how multiple tool calls are executed:
4//! - [`Sequential`](ToolExecutionStrategy::Sequential): one at a time
5//! - [`Parallel`](ToolExecutionStrategy::Parallel): concurrently with a cap
6//! - [`Auto`](ToolExecutionStrategy::Auto): intelligently groups based on tool metadata
7
8use std::sync::Arc;
9
10use behest_core::tool_types::ToolCall;
11
12use crate::Tool;
13
14/// How tool calls should be executed.
15#[derive(Debug, Clone, Default)]
16pub enum ToolExecutionStrategy {
17    /// Execute tool calls one at a time, in order.
18    Sequential,
19    /// Execute tool calls concurrently, up to the given limit.
20    Parallel {
21        /// Maximum number of concurrent tool executions.
22        max_concurrency: usize,
23    },
24    /// Automatically decide based on tool metadata:
25    /// - Read-only + concurrency-safe tools → parallel
26    /// - Write/destructive tools → sequential
27    /// - Requires-approval tools → held until approved
28    #[default]
29    Auto,
30}
31
32/// A plan for executing tool calls.
33#[derive(Debug, Clone)]
34pub struct ExecutionPlan {
35    /// Groups of tool calls. Each group runs sequentially,
36    /// but calls within a group may run in parallel if `parallel` is true.
37    pub groups: Vec<ExecutionGroup>,
38}
39
40/// A group of tool calls to execute together.
41#[derive(Debug, Clone)]
42pub struct ExecutionGroup {
43    /// Whether calls in this group can run in parallel.
44    pub parallel: bool,
45    /// The tool calls in this group.
46    pub calls: Vec<ToolCall>,
47}
48
49impl ExecutionPlan {
50    /// Creates a sequential plan (all calls in one non-parallel group).
51    #[must_use]
52    pub fn sequential(calls: Vec<ToolCall>) -> Self {
53        Self {
54            groups: vec![ExecutionGroup {
55                parallel: false,
56                calls,
57            }],
58        }
59    }
60
61    /// Creates a parallel plan (all calls in one parallel group).
62    #[must_use]
63    pub fn parallel(calls: Vec<ToolCall>) -> Self {
64        Self {
65            groups: vec![ExecutionGroup {
66                parallel: true,
67                calls,
68            }],
69        }
70    }
71}
72
73impl ToolExecutionStrategy {
74    /// Produces an execution plan for the given tool calls.
75    #[must_use]
76    pub fn plan(&self, calls: &[ToolCall], tools: &[Arc<dyn Tool>]) -> ExecutionPlan {
77        match self {
78            Self::Sequential => ExecutionPlan::sequential(calls.to_vec()),
79            Self::Parallel { .. } => ExecutionPlan::parallel(calls.to_vec()),
80            Self::Auto => Self::auto_plan(calls, tools),
81        }
82    }
83
84    fn auto_plan(calls: &[ToolCall], tools: &[Arc<dyn Tool>]) -> ExecutionPlan {
85        if calls.len() <= 1 {
86            return ExecutionPlan::sequential(calls.to_vec());
87        }
88
89        // Build a name → tool lookup
90        let tool_map: std::collections::HashMap<&str, &Arc<dyn Tool>> =
91            tools.iter().map(|t| (t.name(), t)).collect();
92
93        // Partition into parallel-safe and sequential calls
94        let mut parallel_safe: Vec<ToolCall> = Vec::new();
95        let mut sequential: Vec<ToolCall> = Vec::new();
96        let mut pending_approval: Vec<ToolCall> = Vec::new();
97
98        for call in calls {
99            let tool = tool_map.get(call.name.as_str());
100            let is_read_only = tool.is_some_and(|t| t.is_read_only());
101            let is_concurrency_safe = tool.is_some_and(|t| t.is_concurrency_safe());
102            let requires_approval = tool.is_some_and(|t| t.requires_approval());
103
104            if requires_approval {
105                pending_approval.push(call.clone());
106            } else if is_read_only || is_concurrency_safe {
107                parallel_safe.push(call.clone());
108            } else {
109                sequential.push(call.clone());
110            }
111        }
112
113        let mut groups: Vec<ExecutionGroup> = Vec::new();
114
115        // Pending approval tools each get their own group (must wait for approval)
116        for call in pending_approval {
117            groups.push(ExecutionGroup {
118                parallel: false,
119                calls: vec![call],
120            });
121        }
122
123        // Parallel-safe tools run together
124        if !parallel_safe.is_empty() {
125            groups.push(ExecutionGroup {
126                parallel: true,
127                calls: parallel_safe,
128            });
129        }
130
131        // Sequential tools each get their own group
132        for call in sequential {
133            groups.push(ExecutionGroup {
134                parallel: false,
135                calls: vec![call],
136            });
137        }
138
139        if groups.is_empty() {
140            ExecutionPlan::sequential(calls.to_vec())
141        } else {
142            ExecutionPlan { groups }
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::FunctionTool;
151
152    fn make_ro_tool(name: &str) -> Arc<dyn Tool> {
153        Arc::new(
154            FunctionTool::new(name, format!("{name} tool"), serde_json::json!({}), |_| {
155                Box::pin(async { Ok(crate::ToolOutput::text("ok")) })
156            })
157            .read_only(),
158        )
159    }
160
161    fn make_rw_tool(name: &str) -> Arc<dyn Tool> {
162        Arc::new(FunctionTool::new(
163            name,
164            format!("{name} tool"),
165            serde_json::json!({}),
166            |_| Box::pin(async { Ok(crate::ToolOutput::text("ok")) }),
167        ))
168    }
169
170    fn make_call(name: &str) -> ToolCall {
171        ToolCall::new(format!("call_{name}"), name, serde_json::Value::Null)
172    }
173
174    #[test]
175    fn auto_parallelizes_ro_tools() {
176        let tools: Vec<Arc<dyn Tool>> = vec![make_ro_tool("search"), make_ro_tool("fetch")];
177        let calls = vec![make_call("search"), make_call("fetch")];
178
179        let plan = ToolExecutionStrategy::Auto.plan(&calls, &tools);
180        assert_eq!(plan.groups.len(), 1);
181        assert!(plan.groups[0].parallel);
182        assert_eq!(plan.groups[0].calls.len(), 2);
183    }
184
185    #[test]
186    fn auto_serializes_rw_tools() {
187        let tools: Vec<Arc<dyn Tool>> = vec![make_rw_tool("create"), make_rw_tool("delete")];
188        let calls = vec![make_call("create"), make_call("delete")];
189
190        let plan = ToolExecutionStrategy::Auto.plan(&calls, &tools);
191        assert_eq!(plan.groups.len(), 2);
192        assert!(!plan.groups[0].parallel);
193        assert!(!plan.groups[1].parallel);
194    }
195
196    #[test]
197    fn auto_mixed_partitions() {
198        let ro1 = make_ro_tool("search");
199        let rw1 = make_rw_tool("save");
200        let ro2 = make_ro_tool("fetch");
201        let tools: Vec<Arc<dyn Tool>> = vec![ro1, rw1, ro2];
202        let calls = vec![make_call("search"), make_call("save"), make_call("fetch")];
203
204        let plan = ToolExecutionStrategy::Auto.plan(&calls, &tools);
205        // Parallel group first (search + fetch), then sequential save
206        assert_eq!(plan.groups.len(), 2);
207        assert!(plan.groups[0].parallel);
208        assert_eq!(plan.groups[0].calls.len(), 2);
209        assert!(!plan.groups[1].parallel);
210        assert_eq!(plan.groups[1].calls.len(), 1);
211    }
212
213    #[test]
214    fn sequential_strategy_always_one_group() {
215        let tools: Vec<Arc<dyn Tool>> = vec![make_ro_tool("a"), make_ro_tool("b")];
216        let calls = vec![make_call("a"), make_call("b")];
217
218        let plan = ToolExecutionStrategy::Sequential.plan(&calls, &tools);
219        assert_eq!(plan.groups.len(), 1);
220        assert!(!plan.groups[0].parallel);
221        assert_eq!(plan.groups[0].calls.len(), 2);
222    }
223
224    #[test]
225    fn parallel_strategy_always_one_group() {
226        let tools: Vec<Arc<dyn Tool>> = vec![make_ro_tool("a"), make_ro_tool("b")];
227        let calls = vec![make_call("a"), make_call("b")];
228
229        let plan = ToolExecutionStrategy::Parallel { max_concurrency: 4 }.plan(&calls, &tools);
230        assert_eq!(plan.groups.len(), 1);
231        assert!(plan.groups[0].parallel);
232    }
233
234    #[test]
235    fn single_call_uses_sequential() {
236        let tools: Vec<Arc<dyn Tool>> = vec![make_ro_tool("a")];
237        let calls = vec![make_call("a")];
238
239        let plan = ToolExecutionStrategy::Auto.plan(&calls, &tools);
240        assert_eq!(plan.groups.len(), 1);
241        assert!(!plan.groups[0].parallel);
242    }
243}