Skip to main content

everruns_builtins/
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 parallel_tool_calls_preference(&self, config: &serde_json::Value) -> Option<bool> {
116        parallel_tool_calls_from_config(config)
117    }
118
119    fn config_schema(&self) -> Option<serde_json::Value> {
120        Some(serde_json::json!({
121            "type": "object",
122            "properties": {
123                "mode": {
124                    "type": "string",
125                    "title": "Parallel tool calls",
126                    "description": "prefer: request parallel tool calls (default); avoid: one tool call per turn, serialized locally; none: provider default.",
127                    "enum": ["prefer", "avoid", "none"],
128                    "default": "prefer"
129                }
130            }
131        }))
132    }
133
134    fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
135        if config.is_null() {
136            return Ok(());
137        }
138        if !config.is_object() {
139            return Err("parallel_tool_calls config must be an object".to_string());
140        }
141        match config.get("mode") {
142            None => Ok(()),
143            Some(serde_json::Value::String(mode))
144                if ParallelToolCallsMode::parse(mode).is_some() =>
145            {
146                Ok(())
147            }
148            Some(value) => Err(format!(
149                "mode must be one of \"prefer\", \"avoid\", \"none\", got {value}"
150            )),
151        }
152    }
153
154    fn localizations(&self) -> Vec<CapabilityLocalization> {
155        vec![
156            CapabilityLocalization {
157                locale: "en",
158                name: None,
159                description: None,
160                config_description: Some(
161                    "Chooses whether the agent batches independent tool calls or runs them one at a time.",
162                ),
163                config_overlay: None,
164            },
165            CapabilityLocalization {
166                locale: "uk",
167                name: Some("Паралельні виклики інструментів"),
168                description: Some(
169                    "Визначає, чи запитує агент кілька викликів інструментів за хід і чи виконує їх одночасно: prefer (запитувати паралельні), avoid (по одному, послідовно) або none (типова поведінка провайдера).",
170                ),
171                config_description: Some(
172                    "Обирає, чи агент об'єднує незалежні виклики інструментів, чи виконує їх по одному.",
173                ),
174                config_overlay: Some(serde_json::json!({
175                    "properties": {
176                        "mode": {
177                            "title": "Паралельні виклики інструментів",
178                            "description": "prefer: запитувати паралельні виклики (типово); avoid: один виклик за хід, послідовно; none: типова поведінка провайдера."
179                        }
180                    }
181                })),
182            },
183        ]
184    }
185
186    async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
187        None
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use serde_json::json;
195
196    #[test]
197    fn configuration_validation_and_runtime_preference_agree() {
198        let cap = ParallelToolCallsCapability;
199        for (config, valid, preference) in [
200            (json!(null), true, Some(true)),
201            (json!({}), true, Some(true)),
202            (json!({"mode":"prefer"}), true, Some(true)),
203            (json!({"mode":"avoid"}), true, Some(false)),
204            (json!({"mode":"none"}), true, None),
205            (json!({"mode":"loud"}), false, None),
206            (json!({"mode":"PREFER"}), false, None),
207            (json!({"mode":""}), false, None),
208            (json!({"mode":null}), false, None),
209            (json!({"mode":true}), false, None),
210            (json!({"mode":5}), false, None),
211            (json!({"mode":{}}), false, None),
212            (json!({"mode":[]}), false, None),
213            (json!([]), false, None),
214            (json!(true), false, None),
215            (json!("prefer"), false, None),
216        ] {
217            assert_eq!(
218                cap.validate_config(&config).is_ok(),
219                valid,
220                "config={config}"
221            );
222            assert_eq!(
223                cap.parallel_tool_calls_preference(&config),
224                preference,
225                "config={config}"
226            );
227        }
228    }
229    #[test]
230    fn localizations_resolve_uk() {
231        let cap = ParallelToolCallsCapability;
232        assert_eq!(
233            cap.localized_name(Some("uk-UA")),
234            "Паралельні виклики інструментів"
235        );
236    }
237}