Skip to main content

everruns_core/capabilities/
parallel_tool_calls.rs

1// Parallel tool calls capability
2//
3// Controls whether the agent asks the provider to emit multiple tool calls per
4// turn (and whether the local tool scheduler runs a batch concurrently):
5//
6// - `prefer`: explicitly request parallel tool calls. On providers that expose
7//   a wire control (OpenAI/Anthropic families) this is sent on the request; the
8//   local scheduler keeps its class-aware concurrent default. Lets the model
9//   batch independent reads/searches instead of relying on each provider's
10//   undocumented default.
11// - `avoid`: ask the provider to emit at most one tool call per turn AND force
12//   the local tool scheduler to serialize the batch. The local serialization
13//   applies to every driver, so `avoid` is honored even on providers without a
14//   wire control (Gemini/Bedrock).
15// - `none`: no preference — omit the field and keep the provider default and the
16//   scheduler's concurrent schedule. Same effect as not enabling the capability;
17//   useful to neutralize an inherited preference from a parent harness.
18//
19// The resolved preference threads through `RuntimeAgent.parallel_tool_calls`
20// into both the LLM request (provider-gated, see `ChatDriver::
21// supports_parallel_tool_calls`) and `ActInput.parallel_tool_calls` (the local
22// scheduler). An explicit `parallel_tool_calls` field on harness/agent/session
23// is a lower-level escape hatch and takes precedence over this capability.
24
25use super::{Capability, CapabilityLocalization, SystemPromptContext};
26use async_trait::async_trait;
27
28/// Capability ID for the request-level parallel tool calls preference.
29pub const PARALLEL_TOOL_CALLS_CAPABILITY_ID: &str = "parallel_tool_calls";
30
31/// Resolved preference mode for the `parallel_tool_calls` capability.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ParallelToolCallsMode {
34    /// Request parallel tool calls (provider + concurrent scheduler).
35    Prefer,
36    /// Disable parallel tool calls (provider hint + serialized scheduler).
37    Avoid,
38    /// No preference — provider default and concurrent scheduler.
39    None,
40}
41
42impl ParallelToolCallsMode {
43    /// Parse a config string into a mode. Unknown values yield `None`.
44    pub fn parse(value: &str) -> Option<Self> {
45        match value {
46            "prefer" => Some(Self::Prefer),
47            "avoid" => Some(Self::Avoid),
48            "none" => Some(Self::None),
49            _ => None,
50        }
51    }
52
53    /// Map the mode onto the request-level `parallel_tool_calls` preference
54    /// carried by `RuntimeAgent`/`LlmCallConfig`/`ActInput`.
55    ///
56    /// `None` mode resolves to `None` (omit, provider default).
57    pub fn to_preference(self) -> Option<bool> {
58        match self {
59            Self::Prefer => Some(true),
60            Self::Avoid => Some(false),
61            Self::None => None,
62        }
63    }
64}
65
66/// Resolve the `parallel_tool_calls` capability config into a request-level
67/// preference.
68///
69/// - Capability present with no explicit `mode` (empty/`null` config) → `prefer`.
70/// - Valid `mode` → that mode (`none` resolves to no preference).
71/// - Malformed config (not an object) or an invalid/non-string `mode` → `None`,
72///   so a bad runtime config neutralizes the capability rather than silently
73///   enabling parallel tool calls. (`validate_config` already rejects these on
74///   the write path; this is the defensive runtime fallback.)
75pub fn parallel_tool_calls_from_config(config: &serde_json::Value) -> Option<bool> {
76    if config.is_null() {
77        return ParallelToolCallsMode::Prefer.to_preference();
78    }
79    let object = config.as_object()?;
80    match object.get("mode") {
81        None => ParallelToolCallsMode::Prefer.to_preference(),
82        Some(serde_json::Value::String(mode)) => {
83            ParallelToolCallsMode::parse(mode).and_then(ParallelToolCallsMode::to_preference)
84        }
85        Some(_) => None,
86    }
87}
88
89/// Parallel tool calls capability.
90///
91/// Adds no tools or prompt text; it only configures the outbound LLM request
92/// and the local tool scheduler.
93pub struct ParallelToolCallsCapability;
94
95#[async_trait]
96impl Capability for ParallelToolCallsCapability {
97    fn id(&self) -> &str {
98        PARALLEL_TOOL_CALLS_CAPABILITY_ID
99    }
100
101    fn name(&self) -> &str {
102        "Parallel Tool Calls"
103    }
104
105    fn description(&self) -> &str {
106        "Controls whether the agent requests multiple tool calls per turn and \
107         runs them concurrently: prefer (request parallel), avoid (one at a \
108         time, serialized), or none (provider default)."
109    }
110
111    fn category(&self) -> Option<&str> {
112        Some("Optimization")
113    }
114
115    fn config_schema(&self) -> Option<serde_json::Value> {
116        Some(serde_json::json!({
117            "type": "object",
118            "properties": {
119                "mode": {
120                    "type": "string",
121                    "title": "Parallel tool calls",
122                    "description": "prefer: request parallel tool calls (default); avoid: one tool call per turn, serialized locally; none: provider default.",
123                    "enum": ["prefer", "avoid", "none"],
124                    "default": "prefer"
125                }
126            }
127        }))
128    }
129
130    fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
131        if config.is_null() {
132            return Ok(());
133        }
134        if !config.is_object() {
135            return Err("parallel_tool_calls config must be an object".to_string());
136        }
137        match config.get("mode") {
138            None => Ok(()),
139            Some(serde_json::Value::String(mode))
140                if ParallelToolCallsMode::parse(mode).is_some() =>
141            {
142                Ok(())
143            }
144            Some(value) => Err(format!(
145                "mode must be one of \"prefer\", \"avoid\", \"none\", got {value}"
146            )),
147        }
148    }
149
150    fn localizations(&self) -> Vec<CapabilityLocalization> {
151        vec![
152            CapabilityLocalization {
153                locale: "en",
154                name: None,
155                description: None,
156                config_description: Some(
157                    "Chooses whether the agent batches independent tool calls or runs them one at a time.",
158                ),
159                config_overlay: None,
160            },
161            CapabilityLocalization {
162                locale: "uk",
163                name: Some("Паралельні виклики інструментів"),
164                description: Some(
165                    "Визначає, чи запитує агент кілька викликів інструментів за хід і чи виконує їх одночасно: prefer (запитувати паралельні), avoid (по одному, послідовно) або none (типова поведінка провайдера).",
166                ),
167                config_description: Some(
168                    "Обирає, чи агент об'єднує незалежні виклики інструментів, чи виконує їх по одному.",
169                ),
170                config_overlay: Some(serde_json::json!({
171                    "properties": {
172                        "mode": {
173                            "title": "Паралельні виклики інструментів",
174                            "description": "prefer: запитувати паралельні виклики (типово); avoid: один виклик за хід, послідовно; none: типова поведінка провайдера."
175                        }
176                    }
177                })),
178            },
179        ]
180    }
181
182    async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
183        None
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn parse_known_modes() {
193        assert_eq!(
194            ParallelToolCallsMode::parse("prefer"),
195            Some(ParallelToolCallsMode::Prefer)
196        );
197        assert_eq!(
198            ParallelToolCallsMode::parse("avoid"),
199            Some(ParallelToolCallsMode::Avoid)
200        );
201        assert_eq!(
202            ParallelToolCallsMode::parse("none"),
203            Some(ParallelToolCallsMode::None)
204        );
205        assert_eq!(ParallelToolCallsMode::parse("loud"), None);
206    }
207
208    #[test]
209    fn mode_to_preference() {
210        assert_eq!(ParallelToolCallsMode::Prefer.to_preference(), Some(true));
211        assert_eq!(ParallelToolCallsMode::Avoid.to_preference(), Some(false));
212        assert_eq!(ParallelToolCallsMode::None.to_preference(), None);
213    }
214
215    #[test]
216    fn from_config_defaults_to_prefer() {
217        // Capability present without explicit mode => prefer.
218        assert_eq!(
219            parallel_tool_calls_from_config(&serde_json::json!({})),
220            Some(true)
221        );
222        assert_eq!(
223            parallel_tool_calls_from_config(&serde_json::Value::Null),
224            Some(true)
225        );
226    }
227
228    #[test]
229    fn from_config_honors_mode() {
230        assert_eq!(
231            parallel_tool_calls_from_config(&serde_json::json!({"mode": "prefer"})),
232            Some(true)
233        );
234        assert_eq!(
235            parallel_tool_calls_from_config(&serde_json::json!({"mode": "avoid"})),
236            Some(false)
237        );
238        assert_eq!(
239            parallel_tool_calls_from_config(&serde_json::json!({"mode": "none"})),
240            None
241        );
242    }
243
244    #[test]
245    fn from_config_malformed_neutralizes() {
246        // Invalid/non-string mode and non-object configs do not silently enable
247        // parallel tool calls — they resolve to no preference.
248        assert_eq!(
249            parallel_tool_calls_from_config(&serde_json::json!({"mode": "loud"})),
250            None
251        );
252        assert_eq!(
253            parallel_tool_calls_from_config(&serde_json::json!({"mode": 5})),
254            None
255        );
256        assert_eq!(
257            parallel_tool_calls_from_config(&serde_json::json!([])),
258            None
259        );
260    }
261
262    #[test]
263    fn validate_config_accepts_known_modes_only() {
264        let cap = ParallelToolCallsCapability;
265        assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
266        assert!(cap.validate_config(&serde_json::json!({})).is_ok());
267        assert!(
268            cap.validate_config(&serde_json::json!({"mode": "prefer"}))
269                .is_ok()
270        );
271        assert!(
272            cap.validate_config(&serde_json::json!({"mode": "avoid"}))
273                .is_ok()
274        );
275        assert!(
276            cap.validate_config(&serde_json::json!({"mode": "loud"}))
277                .is_err()
278        );
279        assert!(cap.validate_config(&serde_json::json!([])).is_err());
280    }
281
282    #[test]
283    fn localizations_resolve_uk() {
284        let cap = ParallelToolCallsCapability;
285        assert_eq!(
286            cap.localized_name(Some("uk-UA")),
287            "Паралельні виклики інструментів"
288        );
289    }
290}