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#[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}