Skip to main content

vtcode_core/core/agent/
tool_batching.rs

1//! Tool call batching for parallel execution.
2//!
3//! Groups tool calls into sequential and parallel batches based on their
4//! read-only classification and preflight safety status. Read-only tools
5//! can be parallelized; mutating tools must be sequential.
6
7use serde_json::Value;
8
9/// A single step in a fallback chain.
10#[derive(Debug, Clone)]
11pub struct FallbackStep {
12    pub tool_name: String,
13    pub args: Value,
14}
15
16/// A recommended fallback tool call if the primary fails.
17#[derive(Debug, Clone)]
18pub struct FallbackRecommendation {
19    pub tool_name: String,
20    pub args: Value,
21    /// Ordered chain of additional fallbacks to try if the primary
22    /// `tool_name`/`args` fails.  Each step is tried in sequence.
23    pub chain: Vec<FallbackStep>,
24}
25
26impl FallbackRecommendation {
27    /// Returns `true` when the recommendation carries a non-empty tool name
28    /// so downstream callers can skip invalid fallbacks early.
29    pub fn is_valid(&self) -> bool {
30        !self.tool_name.trim().is_empty()
31    }
32
33    /// Total number of fallback steps including the primary.
34    pub fn step_count(&self) -> usize {
35        1 + self.chain.len()
36    }
37
38    /// Iterate all fallback steps: primary first, then chain.
39    pub fn steps(&self) -> impl Iterator<Item = FallbackStep> {
40        std::iter::once(FallbackStep {
41            tool_name: self.tool_name.clone(),
42            args: self.args.clone(),
43        })
44        .chain(self.chain.iter().cloned())
45    }
46}
47
48/// A prepared tool call with its classification and effective arguments.
49#[derive(Debug, Clone)]
50pub struct PreparedToolCall {
51    pub canonical_name: String,
52    pub readonly_classification: bool,
53    pub parallel_safe_after_preflight: bool,
54    pub effective_args: Value,
55    pub fallback_recommendation: Option<FallbackRecommendation>,
56    pub already_preflighted: bool,
57}
58
59impl PreparedToolCall {
60    pub fn new(
61        canonical_name: String,
62        readonly_classification: bool,
63        parallel_safe_after_preflight: bool,
64        effective_args: Value,
65    ) -> Self {
66        Self {
67            canonical_name,
68            readonly_classification,
69            parallel_safe_after_preflight,
70            effective_args,
71            fallback_recommendation: None,
72            already_preflighted: true,
73        }
74    }
75
76    pub fn with_fallback_recommendation(mut self, fallback_recommendation: Option<FallbackRecommendation>) -> Self {
77        self.fallback_recommendation = fallback_recommendation;
78        self
79    }
80
81    pub fn can_parallelize(&self) -> bool {
82        self.readonly_classification && self.parallel_safe_after_preflight
83    }
84}
85
86/// Whether a batch of tool calls should be executed sequentially or in parallel.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum PreparedToolBatchKind {
89    Sequential,
90    ParallelReadonly,
91}
92
93/// A batch of tool calls grouped by execution strategy.
94#[derive(Debug, Clone)]
95pub struct PreparedToolBatch {
96    pub kind: PreparedToolBatchKind,
97    pub calls: Vec<PreparedToolCall>,
98}
99
100impl PreparedToolBatch {
101    pub fn plan_layout(
102        parallelizable: impl IntoIterator<Item = bool>,
103        allow_parallel: bool,
104    ) -> Vec<(PreparedToolBatchKind, usize)> {
105        Self::plan_layout_with_limit(parallelizable, allow_parallel, 0)
106    }
107
108    /// Plans contiguous execution groups while respecting an optional
109    /// parallel fan-out limit. A limit of zero means unlimited parallelism.
110    pub fn plan_layout_with_limit(
111        parallelizable: impl IntoIterator<Item = bool>,
112        allow_parallel: bool,
113        max_parallel: usize,
114    ) -> Vec<(PreparedToolBatchKind, usize)> {
115        let mut layout = Vec::new();
116        let mut parallel_batch_len = 0usize;
117
118        for can_parallelize in parallelizable {
119            if allow_parallel && can_parallelize {
120                parallel_batch_len += 1;
121                continue;
122            }
123
124            if parallel_batch_len > 0 {
125                push_parallel_batch_layout(&mut layout, &mut parallel_batch_len, max_parallel);
126            }
127            layout.push((PreparedToolBatchKind::Sequential, 1));
128        }
129
130        push_parallel_batch_layout(&mut layout, &mut parallel_batch_len, max_parallel);
131
132        layout
133    }
134
135    /// Compatibility wrapper for callers that still provide tool names.
136    ///
137    /// Scheduling is based only on the validated parallel-safety bit; tool
138    /// names never affect the layout. New callers should use
139    /// [`Self::plan_layout`] or [`Self::plan_layout_with_limit`].
140    #[deprecated(note = "tool names do not affect scheduling; use plan_layout")]
141    pub fn plan_layout_with_names<'a>(
142        calls: impl IntoIterator<Item = (bool, &'a str)>,
143        allow_parallel: bool,
144    ) -> Vec<(PreparedToolBatchKind, usize)> {
145        Self::plan_layout(calls.into_iter().map(|(can_parallelize, _)| can_parallelize), allow_parallel)
146    }
147
148    pub fn plan(calls: impl IntoIterator<Item = PreparedToolCall>, allow_parallel: bool) -> Vec<Self> {
149        Self::plan_with_limit(calls, allow_parallel, 0)
150    }
151
152    pub fn plan_with_limit(
153        calls: impl IntoIterator<Item = PreparedToolCall>,
154        allow_parallel: bool,
155        max_parallel: usize,
156    ) -> Vec<Self> {
157        let calls: Vec<_> = calls.into_iter().collect();
158        let layout = Self::plan_layout_with_limit(
159            calls.iter().map(PreparedToolCall::can_parallelize),
160            allow_parallel,
161            max_parallel,
162        );
163        let mut calls = calls.into_iter();
164
165        layout
166            .into_iter()
167            .map(|(kind, len)| Self { kind, calls: calls.by_ref().take(len).collect() })
168            .collect()
169    }
170}
171
172fn push_parallel_batch_layout(
173    layout: &mut Vec<(PreparedToolBatchKind, usize)>,
174    parallel_batch_len: &mut usize,
175    max_parallel: usize,
176) {
177    let mut remaining = *parallel_batch_len;
178    let effective_limit = if max_parallel == 0 { usize::MAX } else { max_parallel };
179
180    while remaining > 0 {
181        let batch_len = remaining.min(effective_limit);
182        match batch_len {
183            1 => layout.push((PreparedToolBatchKind::Sequential, 1)),
184            len => layout.push((PreparedToolBatchKind::ParallelReadonly, len)),
185        }
186        remaining -= batch_len;
187    }
188
189    *parallel_batch_len = 0;
190}
191
192/// Whether all calls in a batch are parallel-safe.
193pub fn is_parallel_safe_tool_batch(calls: &[PreparedToolCall]) -> bool {
194    calls.iter().all(PreparedToolCall::can_parallelize)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::{PreparedToolBatch, PreparedToolBatchKind};
200
201    #[test]
202    fn bounded_layout_splits_parallel_groups_without_reordering() {
203        let layout = PreparedToolBatch::plan_layout_with_limit([true, true, true, false, true], true, 2);
204
205        assert_eq!(
206            layout,
207            vec![
208                (PreparedToolBatchKind::ParallelReadonly, 2),
209                (PreparedToolBatchKind::Sequential, 1),
210                (PreparedToolBatchKind::Sequential, 1),
211                (PreparedToolBatchKind::Sequential, 1),
212            ]
213        );
214    }
215
216    #[test]
217    fn single_parallel_call_is_serialized_when_limit_is_one() {
218        let layout = PreparedToolBatch::plan_layout_with_limit([true], true, 1);
219
220        assert_eq!(layout, vec![(PreparedToolBatchKind::Sequential, 1)]);
221    }
222}