Skip to main content

microclaw_engine/config/
subagents.rs

1use super::*;
2
3pub(crate) fn default_subagent_max_concurrent() -> usize {
4    4
5}
6
7pub(crate) fn default_subagent_max_active_per_chat() -> usize {
8    5
9}
10
11pub(crate) fn default_subagent_run_timeout_secs() -> u64 {
12    900
13}
14
15pub(crate) fn default_subagent_announce() -> bool {
16    false
17}
18
19pub(crate) fn default_subagent_progress_min_interval_secs() -> u64 {
20    45
21}
22
23pub(crate) fn default_subagent_max_spawn_depth() -> usize {
24    1
25}
26
27pub(crate) fn default_subagent_max_children_per_run() -> usize {
28    5
29}
30
31pub(crate) fn default_subagent_thread_bound_routing_enabled() -> bool {
32    true
33}
34
35pub(crate) fn default_subagent_announce_relay_interval_secs() -> u64 {
36    15
37}
38
39pub(crate) fn default_subagent_max_tokens_per_run() -> i64 {
40    400_000
41}
42
43pub(crate) fn default_subagent_orchestrate_max_workers() -> usize {
44    5
45}
46
47pub(crate) fn default_subagent_acp_auto_approve() -> bool {
48    true
49}
50
51#[derive(Clone, Debug, Serialize, Deserialize)]
52pub struct SubagentAcpTargetConfig {
53    #[serde(default)]
54    pub enabled: bool,
55    #[serde(default)]
56    pub command: String,
57    #[serde(default)]
58    pub args: Vec<String>,
59    #[serde(default)]
60    pub env: HashMap<String, String>,
61    #[serde(default = "default_subagent_acp_auto_approve")]
62    pub auto_approve: bool,
63}
64
65impl Default for SubagentAcpTargetConfig {
66    fn default() -> Self {
67        Self {
68            enabled: false,
69            command: String::new(),
70            args: Vec::new(),
71            env: HashMap::new(),
72            auto_approve: default_subagent_acp_auto_approve(),
73        }
74    }
75}
76
77impl SubagentAcpTargetConfig {
78    pub(crate) fn normalize(&mut self) {
79        self.command = self.command.trim().to_string();
80        self.args = self
81            .args
82            .drain(..)
83            .map(|arg| arg.trim().to_string())
84            .filter(|arg| !arg.is_empty())
85            .collect();
86        self.env = self
87            .env
88            .drain()
89            .filter_map(|(key, value)| {
90                let normalized = key.trim().to_string();
91                if normalized.is_empty() {
92                    None
93                } else {
94                    Some((normalized, value))
95                }
96            })
97            .collect();
98    }
99
100    fn command_label(&self) -> String {
101        if self.command.trim().is_empty() {
102            "acp".to_string()
103        } else {
104            Path::new(&self.command)
105                .file_name()
106                .and_then(|name| name.to_str())
107                .unwrap_or("acp")
108                .to_string()
109        }
110    }
111}
112
113#[derive(Clone, Debug)]
114pub struct ResolvedSubagentAcpTargetConfig {
115    pub name: Option<String>,
116    pub command: String,
117    pub args: Vec<String>,
118    pub env: HashMap<String, String>,
119    pub auto_approve: bool,
120}
121
122impl ResolvedSubagentAcpTargetConfig {
123    pub fn model_label(&self) -> String {
124        if let Some(name) = self.name.as_deref() {
125            format!(
126                "{name}/{}",
127                SubagentAcpTargetConfig {
128                    enabled: true,
129                    command: self.command.clone(),
130                    args: self.args.clone(),
131                    env: self.env.clone(),
132                    auto_approve: self.auto_approve,
133                }
134                .command_label()
135            )
136        } else {
137            SubagentAcpTargetConfig {
138                enabled: true,
139                command: self.command.clone(),
140                args: self.args.clone(),
141                env: self.env.clone(),
142                auto_approve: self.auto_approve,
143            }
144            .command_label()
145        }
146    }
147}
148
149#[derive(Clone, Debug, Serialize, Deserialize, Default)]
150pub struct SubagentAcpConfig {
151    #[serde(flatten)]
152    pub default_target: SubagentAcpTargetConfig,
153    #[serde(default, rename = "default_target")]
154    pub default_target_name: Option<String>,
155    #[serde(default)]
156    pub targets: HashMap<String, SubagentAcpTargetConfig>,
157}
158
159impl SubagentAcpConfig {
160    pub(crate) fn normalize(&mut self) {
161        self.default_target.normalize();
162        self.default_target_name = self
163            .default_target_name
164            .take()
165            .map(|name| name.trim().to_string())
166            .filter(|name| !name.is_empty());
167        self.targets = self
168            .targets
169            .drain()
170            .filter_map(|(key, mut value)| {
171                let normalized = key.trim().to_string();
172                if normalized.is_empty() {
173                    return None;
174                }
175                value.normalize();
176                Some((normalized, value))
177            })
178            .collect();
179    }
180
181    pub fn resolve_target(
182        &self,
183        requested_target: Option<&str>,
184    ) -> Result<ResolvedSubagentAcpTargetConfig, String> {
185        let requested_target = requested_target
186            .map(str::trim)
187            .filter(|target| !target.is_empty());
188        if let Some(name) = requested_target {
189            return self.resolve_named_target(name);
190        }
191        if let Some(name) = self.default_target_name.as_deref() {
192            return self.resolve_named_target(name);
193        }
194        if !self.default_target.command.trim().is_empty() {
195            return Ok(ResolvedSubagentAcpTargetConfig {
196                name: None,
197                command: self.default_target.command.clone(),
198                args: self.default_target.args.clone(),
199                env: self.default_target.env.clone(),
200                auto_approve: self.default_target.auto_approve,
201            });
202        }
203
204        let mut enabled_targets = self
205            .targets
206            .iter()
207            .filter(|(_, target)| target.enabled)
208            .collect::<Vec<_>>();
209        enabled_targets.sort_by_key(|(name, _)| *name);
210        match enabled_targets.as_slice() {
211            [] => Err(
212                "ACP runtime is enabled but no command is configured. Set subagents.acp.command or add an enabled target under subagents.acp.targets."
213                    .into(),
214            ),
215            [(name, _)] => self.resolve_named_target(name),
216            _ => Err(
217                "ACP runtime has multiple enabled named targets. Set runtime_target or subagents.acp.default_target."
218                    .into(),
219            ),
220        }
221    }
222
223    fn resolve_named_target(
224        &self,
225        target_name: &str,
226    ) -> Result<ResolvedSubagentAcpTargetConfig, String> {
227        let target = self.targets.get(target_name).ok_or_else(|| {
228            format!(
229                "Unknown ACP runtime target '{target_name}'. Configure it under subagents.acp.targets."
230            )
231        })?;
232        if !target.enabled {
233            return Err(format!(
234                "ACP runtime target '{target_name}' is disabled. Enable it under subagents.acp.targets.{target_name}.enabled."
235            ));
236        }
237        if target.command.trim().is_empty() {
238            return Err(format!(
239                "ACP runtime target '{target_name}' is enabled but command is empty."
240            ));
241        }
242        Ok(ResolvedSubagentAcpTargetConfig {
243            name: Some(target_name.to_string()),
244            command: target.command.clone(),
245            args: target.args.clone(),
246            env: target.env.clone(),
247            auto_approve: target.auto_approve,
248        })
249    }
250}
251
252#[derive(Clone, Debug, Serialize, Deserialize)]
253pub struct SubagentConfig {
254    #[serde(default = "default_subagent_max_concurrent")]
255    pub max_concurrent: usize,
256    #[serde(default = "default_subagent_max_active_per_chat")]
257    pub max_active_per_chat: usize,
258    #[serde(default = "default_subagent_run_timeout_secs")]
259    pub run_timeout_secs: u64,
260    #[serde(default = "default_subagent_announce")]
261    pub announce_to_chat: bool,
262    #[serde(default)]
263    pub fan_in_summary: bool,
264    #[serde(default)]
265    pub progress_reports: bool,
266    #[serde(default = "default_subagent_progress_min_interval_secs")]
267    pub progress_min_interval_secs: u64,
268    #[serde(default = "default_subagent_max_spawn_depth")]
269    pub max_spawn_depth: usize,
270    #[serde(default = "default_subagent_max_children_per_run")]
271    pub max_children_per_run: usize,
272    #[serde(default = "default_subagent_thread_bound_routing_enabled")]
273    pub thread_bound_routing_enabled: bool,
274    #[serde(default = "default_subagent_announce_relay_interval_secs")]
275    pub announce_relay_interval_secs: u64,
276    #[serde(default = "default_subagent_max_tokens_per_run")]
277    pub max_tokens_per_run: i64,
278    #[serde(default = "default_subagent_orchestrate_max_workers")]
279    pub orchestrate_max_workers: usize,
280    #[serde(default)]
281    pub acp: SubagentAcpConfig,
282    #[serde(default)]
283    pub standup: SubagentStandupConfig,
284}
285
286impl Default for SubagentConfig {
287    fn default() -> Self {
288        Self {
289            max_concurrent: default_subagent_max_concurrent(),
290            max_active_per_chat: default_subagent_max_active_per_chat(),
291            run_timeout_secs: default_subagent_run_timeout_secs(),
292            announce_to_chat: default_subagent_announce(),
293            fan_in_summary: false,
294            progress_reports: false,
295            progress_min_interval_secs: default_subagent_progress_min_interval_secs(),
296            max_spawn_depth: default_subagent_max_spawn_depth(),
297            max_children_per_run: default_subagent_max_children_per_run(),
298            thread_bound_routing_enabled: default_subagent_thread_bound_routing_enabled(),
299            announce_relay_interval_secs: default_subagent_announce_relay_interval_secs(),
300            max_tokens_per_run: default_subagent_max_tokens_per_run(),
301            orchestrate_max_workers: default_subagent_orchestrate_max_workers(),
302            acp: SubagentAcpConfig::default(),
303            standup: SubagentStandupConfig::default(),
304        }
305    }
306}
307
308pub(crate) fn default_subagent_standup_interval_secs() -> u64 {
309    1800
310}
311
312/// Proactive task-standup: periodically post a one-line status for tasks that
313/// have been running a while. Off by default — it sends unprompted messages.
314#[derive(Clone, Debug, Serialize, Deserialize)]
315pub struct SubagentStandupConfig {
316    #[serde(default)]
317    pub enabled: bool,
318    #[serde(default = "default_subagent_standup_interval_secs")]
319    pub interval_secs: u64,
320}
321
322impl Default for SubagentStandupConfig {
323    fn default() -> Self {
324        Self {
325            enabled: false,
326            interval_secs: default_subagent_standup_interval_secs(),
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    #[allow(unused_imports)]
335    use crate::config::test_prelude::*;
336
337    #[test]
338    fn test_subagent_acp_resolve_named_target() {
339        let mut acp = SubagentAcpConfig::default();
340        acp.default_target.enabled = true;
341        acp.default_target.command = "codex".into();
342        acp.targets.insert(
343            "fast".into(),
344            SubagentAcpTargetConfig {
345                enabled: true,
346                command: "claude-code".into(),
347                args: vec!["--dangerously-skip-permissions".into()],
348                env: HashMap::new(),
349                auto_approve: false,
350            },
351        );
352        acp.normalize();
353
354        let resolved = acp.resolve_target(Some("fast")).unwrap();
355        assert_eq!(resolved.name.as_deref(), Some("fast"));
356        assert_eq!(resolved.command, "claude-code");
357        assert_eq!(
358            resolved.args,
359            vec!["--dangerously-skip-permissions".to_string()]
360        );
361        assert!(!resolved.auto_approve);
362    }
363
364    #[test]
365    fn test_subagent_acp_resolve_requires_target_when_multiple_named_workers() {
366        let mut acp = SubagentAcpConfig::default();
367        acp.default_target.command.clear();
368        acp.targets.insert(
369            "one".into(),
370            SubagentAcpTargetConfig {
371                enabled: true,
372                command: "codex".into(),
373                ..SubagentAcpTargetConfig::default()
374            },
375        );
376        acp.targets.insert(
377            "two".into(),
378            SubagentAcpTargetConfig {
379                enabled: true,
380                command: "claude-code".into(),
381                ..SubagentAcpTargetConfig::default()
382            },
383        );
384        acp.normalize();
385
386        let err = acp.resolve_target(None).unwrap_err();
387        assert!(err.contains("multiple enabled named targets"));
388    }
389}