1use std::collections::BTreeMap;
4use std::collections::BTreeSet;
5use std::sync::Arc;
6
7use serde::Deserialize;
8use serde::Serialize;
9use serde_json::Value;
10
11use super::ActiveCommandContext;
12use super::Middleware;
13use super::MiddlewareCommandContext;
14use super::MiddlewareCommandOutput;
15use super::ModelContext;
16use super::PromptSection;
17use super::RuntimeContext;
18use super::SessionStartContext;
19use super::SessionStartSource;
20use super::SubmissionResult;
21use super::manifest::{MiddlewareManifest, MiddlewareSettingChoices, MiddlewareSettingManifest};
22use super::tools::Catalog;
23use super::tools::labeled_tool_heading;
24use super::tools::render_tool_event;
25use crate::BoxFuture;
26use crate::Error;
27use crate::Result;
28use crate::agent::{Agent, AgentRole};
29use crate::backend::checkpoint::Checkpoint;
30use crate::backend::checkpoint::CheckpointStore;
31use crate::backend::model::internal_user_message;
32use crate::protocol::EventMsg;
33use crate::protocol::FrontendBlock;
34use crate::protocol::FrontendBlockUpdate;
35use crate::protocol::FrontendCommand;
36use crate::protocol::FrontendContribution;
37use crate::protocol::FrontendEvent;
38use crate::protocol::FrontendPreviewUpdate;
39use crate::protocol::MessageAuthor;
40use crate::protocol::Op;
41use crate::protocol::internal_message_kind;
42use crate::protocol::message_metadata;
43
44use self::runtime::Shared;
45
46mod runtime;
47mod tools;
48
49use self::tools::{InterruptAgent, ListAgents, SendMessage, SpawnAgent, WaitAgent, fork_context};
50#[cfg(test)]
51use self::tools::{cleanup_error, supervise, wait_parameters, wait_timeout};
52
53const MAX_TASK_NAME_BYTES: usize = 64;
54const IDENTITY_KEY: &str = "subagents.identity";
55const SPAWN_CONTEXT_KEY: &str = "subagents.spawn_context";
56mod text {
57 pub const COMMAND_DESCRIPTION: &str = "open a subagent thread";
58 pub const DEFAULTS_MAX_AGENTS: i64 = 101;
59 pub const DEFAULTS_MAX_CONCURRENCY: i64 = 8;
60 pub const DEFAULTS_MAX_DEPTH: i64 = 4;
61 pub const DEFAULTS_WAIT_MS: i64 = 30000;
62 pub const MANIFEST_DESCRIPTION: &str = "Delegate independent work to durable child agents";
63 pub const MANIFEST_LABEL: &str = "Subagents";
64 pub const PROMPT_DEFAULT: &str = "Complete the task and report concisely to your parent.";
65 pub const PROMPT_ROOT: &str = "Delegate independent work to subagents when it can run in parallel. Spawn with fresh context by default; include recent turns only when the task requires them, and full history only when essential. They share your workspace; continue your own work while they run, and wait only when you need their results.";
66 pub const RENDER_AGENT: &str = "Agent";
67 pub const RENDER_AGENTS: &str = "Agents";
68 pub const RENDER_EMPTY: &str = "no subagents";
69 pub const RENDER_INTERRUPT: &str = "Interrupt";
70 pub const RENDER_MESSAGE: &str = "Message";
71 pub const RENDER_OPEN: &str = "Open subagent";
72 pub const RENDER_WAIT: &str = "Wait";
73 pub const SETTING_MAX_AGENTS_DESCRIPTION: &str = "Maximum retained agents, including the root";
74 pub const SETTING_MAX_AGENTS_LABEL: &str = "Maximum agents";
75 pub const SETTING_MAX_AGENTS_STEP: i64 = 1;
76 pub const SETTING_MAX_CONCURRENCY_DESCRIPTION: &str =
77 "Maximum active agents, including the root";
78 pub const SETTING_MAX_CONCURRENCY_LABEL: &str = "Maximum concurrency";
79 pub const SETTING_MAX_CONCURRENCY_STEP: i64 = 1;
80 pub const SETTING_MAX_DEPTH_DESCRIPTION: &str = "Maximum child-agent nesting depth";
81 pub const SETTING_MAX_DEPTH_LABEL: &str = "Maximum depth";
82 pub const SETTING_MAX_DEPTH_STEP: i64 = 1;
83 pub const SETTING_MODEL_ROUTE_DESCRIPTION: &str =
84 "Model route used by child agents when a spawn does not select one";
85 pub const SETTING_MODEL_ROUTE_LABEL: &str = "Default model";
86 pub const SETTING_MODEL_ROUTE_UNSET_LABEL: &str = "Inherit parent";
87 pub const TOOL_INTERRUPT_AGENT_DESCRIPTION: &str = "Interrupt a subagent in this chat's task tree and return its prior status. Cannot target /root.";
88 pub const TOOL_LIST_AGENTS_DESCRIPTION: &str =
89 "List this chat's subagents and their canonical task paths; does not list peer Bots.";
90 pub const TOOL_PARAMETER_TARGET_DESCRIPTION: &str = "Exact canonical task path from spawn_agent or list_agents, such as /root/reviewer; not a Bot handle or Bot ID.";
91 pub const TOOL_SEND_MESSAGE_DESCRIPTION: &str = "Send collaboration context to an agent in this chat's subagent task tree; completed or interrupted children are started again for the message. A child may message its parent at the parent's canonical path, including /root. The root agent is never restarted.";
92 pub const TOOL_SPAWN_AGENT_DESCRIPTION: &str = "Start an async child for independent work in this chat's subagent task tree; return its canonical task path.";
93 pub const TOOL_SPAWN_AGENT_PARAMETER_FORK_TURNS_DESCRIPTION: &str = "`none` for a fresh child (default), a positive integer for required recent turns, or `all` only when full history is essential.";
94 pub const TOOL_SPAWN_AGENT_PARAMETER_MODEL_DESCRIPTION: &str =
95 "Registered route; defaults to the child route, then parent.";
96 pub const TOOL_SPAWN_AGENT_PARAMETER_REASONING_EFFORT_DESCRIPTION: &str =
97 "Reasoning effort for the selected model; defaults to middleware configuration.";
98 pub const TOOL_SPAWN_AGENT_PARAMETER_TASK_NAME_DESCRIPTION: &str =
99 "1-64 lowercase letters, digits, or underscores.";
100 pub const TOOL_WAIT_AGENT_DESCRIPTION: &str =
101 "Wait for an update from this chat's subagent task tree.";
102}
103const MIN_WAIT_MS: u64 = 10_000;
104const MAX_WAIT_MS: u64 = 120_000;
105const MAX_CONFIGURED_DEPTH: u8 = 16;
106const MAX_CONFIGURED_CONCURRENCY: usize = 64;
107const MAX_CONFIGURED_AGENTS: usize = 256;
108const _: () = {
109 assert!(text::DEFAULTS_WAIT_MS >= MIN_WAIT_MS as i64);
110 assert!(text::DEFAULTS_WAIT_MS <= MAX_WAIT_MS as i64);
111 assert!(text::DEFAULTS_MAX_DEPTH >= 1);
112 assert!(text::DEFAULTS_MAX_DEPTH <= MAX_CONFIGURED_DEPTH as i64);
113 assert!(text::DEFAULTS_MAX_CONCURRENCY >= 2);
114 assert!(text::DEFAULTS_MAX_CONCURRENCY <= MAX_CONFIGURED_CONCURRENCY as i64);
115 assert!(text::DEFAULTS_MAX_AGENTS >= text::DEFAULTS_MAX_CONCURRENCY);
116 assert!(text::DEFAULTS_MAX_AGENTS <= MAX_CONFIGURED_AGENTS as i64);
117 assert!(text::SETTING_MAX_DEPTH_STEP > 0);
118 assert!(text::SETTING_MAX_CONCURRENCY_STEP > 0);
119 assert!(text::SETTING_MAX_AGENTS_STEP > 0);
120};
121const DEFAULT_WAIT_MS: u64 = text::DEFAULTS_WAIT_MS as u64;
122pub const DEFAULT_MAX_DEPTH: u8 = text::DEFAULTS_MAX_DEPTH as u8;
124pub const DEFAULT_MAX_CONCURRENCY: usize = text::DEFAULTS_MAX_CONCURRENCY as usize;
126pub const DEFAULT_MAX_AGENTS: usize = text::DEFAULTS_MAX_AGENTS as usize;
128const SETTINGS: &[MiddlewareSettingManifest] = &[
129 MiddlewareSettingManifest::Select {
130 id: "model_route",
131 label: text::SETTING_MODEL_ROUTE_LABEL,
132 description: text::SETTING_MODEL_ROUTE_DESCRIPTION,
133 choices: MiddlewareSettingChoices::ModelRoutes,
134 unset_label: Some(text::SETTING_MODEL_ROUTE_UNSET_LABEL),
135 default: None,
136 max_bytes: 4 * 1024,
137 composer: false,
138 },
139 MiddlewareSettingManifest::Integer {
140 id: "max_depth",
141 label: text::SETTING_MAX_DEPTH_LABEL,
142 description: text::SETTING_MAX_DEPTH_DESCRIPTION,
143 min: 1,
144 max: Some(MAX_CONFIGURED_DEPTH as i64),
145 step: text::SETTING_MAX_DEPTH_STEP,
146 default: DEFAULT_MAX_DEPTH as i64,
147 },
148 MiddlewareSettingManifest::Integer {
149 id: "max_concurrency",
150 label: text::SETTING_MAX_CONCURRENCY_LABEL,
151 description: text::SETTING_MAX_CONCURRENCY_DESCRIPTION,
152 min: 2,
153 max: Some(MAX_CONFIGURED_CONCURRENCY as i64),
154 step: text::SETTING_MAX_CONCURRENCY_STEP,
155 default: DEFAULT_MAX_CONCURRENCY as i64,
156 },
157 MiddlewareSettingManifest::Integer {
158 id: "max_agents",
159 label: text::SETTING_MAX_AGENTS_LABEL,
160 description: text::SETTING_MAX_AGENTS_DESCRIPTION,
161 min: 2,
162 max: Some(MAX_CONFIGURED_AGENTS as i64),
163 step: text::SETTING_MAX_AGENTS_STEP,
164 default: DEFAULT_MAX_AGENTS as i64,
165 },
166];
167
168pub const MANIFEST: MiddlewareManifest = MiddlewareManifest {
170 id: "subagents",
171 label: text::MANIFEST_LABEL,
172 description: text::MANIFEST_DESCRIPTION,
173 required: false,
174 default_enabled: true,
175 settings: SETTINGS,
176};
177
178#[derive(Clone)]
180pub struct SubagentLaunch {
181 pub session_id: String,
182 pub model: String,
183 pub reasoning_effort: Option<String>,
184 pub metadata: BTreeMap<String, Value>,
185 pub role: AgentRole,
186}
187
188pub type SubagentLauncher =
190 Arc<dyn Fn(SubagentLaunch) -> BoxFuture<'static, Result<Agent>> + Send + Sync>;
191
192#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
193enum ForkTurns {
194 #[default]
195 None,
196 All,
197 Last(usize),
198}
199
200impl ForkTurns {
201 fn label(self) -> String {
202 match self {
203 Self::None => "No context".into(),
204 Self::All => "Full context".into(),
205 Self::Last(1) => "Last 1 turn".into(),
206 Self::Last(turns) => format!("Last {turns} turns"),
207 }
208 }
209}
210
211#[derive(Clone, Deserialize)]
212#[serde(deny_unknown_fields)]
213struct AgentIdentity {
214 root_session_id: String,
215 agent_path: String,
216 depth: u8,
217}
218
219#[derive(Debug, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221struct PreviewCursor {
222 path: String,
223 before_sequence: u64,
224}
225
226impl AgentIdentity {
227 fn read(session_id: &str, metadata: &BTreeMap<String, Value>) -> Result<Self> {
228 let Some(value) = metadata.get(IDENTITY_KEY) else {
229 return Ok(Self {
230 root_session_id: session_id.into(),
231 agent_path: "/root".into(),
232 depth: 0,
233 });
234 };
235 Ok(serde_json::from_value(value.clone())?)
236 }
237
238 fn metadata(&self, mut metadata: BTreeMap<String, Value>) -> BTreeMap<String, Value> {
239 metadata.insert(
240 IDENTITY_KEY.into(),
241 serde_json::json!({
242 "root_session_id": self.root_session_id,
243 "agent_path": self.agent_path,
244 "depth": self.depth,
245 }),
246 );
247 metadata
248 }
249}
250
251#[derive(Clone)]
252struct AgentScope {
253 checkpoints: Arc<dyn CheckpointStore>,
254 launch_agent: SubagentLauncher,
255 session_id: String,
256 root_session_id: String,
257 agent_path: String,
258 depth: u8,
259 model: String,
260 metadata: BTreeMap<String, Value>,
261}
262
263impl AgentScope {
264 fn new(runtime: &RuntimeContext, launch_agent: SubagentLauncher) -> Result<Self> {
265 let identity = AgentIdentity::read(&runtime.session_id, &runtime.metadata)?;
266 Ok(Self {
267 checkpoints: Arc::clone(&runtime.checkpoints),
268 launch_agent,
269 session_id: runtime.session_id.clone(),
270 root_session_id: identity.root_session_id,
271 agent_path: identity.agent_path,
272 depth: identity.depth,
273 model: runtime.model_route.clone(),
274 metadata: runtime.metadata.clone(),
275 })
276 }
277
278 async fn fork(
279 &self,
280 session_id: String,
281 agent_path: String,
282 model: String,
283 reasoning_effort: Option<String>,
284 turns: ForkTurns,
285 parent_turn_id: String,
286 ) -> Result<Agent> {
287 let parent = self
288 .checkpoints
289 .load(&self.session_id)
290 .await?
291 .ok_or_else(|| Error::Checkpoint("parent checkpoint is missing".into()))?;
292 let parent_sequence = parent.sequence;
293 let pending = parent
294 .pending_tools
295 .iter()
296 .map(|call| call.call_id.clone())
297 .collect::<BTreeSet<_>>();
298 let context = parent
299 .context
300 .into_iter()
301 .filter(|item| {
302 item.get("type").and_then(Value::as_str) != Some("function_call")
303 || item
304 .get("call_id")
305 .and_then(Value::as_str)
306 .is_none_or(|call_id| !pending.contains(call_id))
307 })
308 .collect::<Vec<_>>();
309 let mut checkpoint = Checkpoint::empty(&session_id);
310 checkpoint.catalog_visible = false;
311 checkpoint.context = fork_context(&context, turns);
312 checkpoint.session_context = parent.session_context;
313 let mut metadata = AgentIdentity {
314 root_session_id: self.root_session_id.clone(),
315 agent_path: agent_path.clone(),
316 depth: self.depth + 1,
317 }
318 .metadata(self.metadata.clone());
319 metadata.insert(SPAWN_CONTEXT_KEY.into(), Value::String(turns.label()));
320 checkpoint.metadata.clone_from(&metadata);
321 self.checkpoints
322 .fork(&self.session_id, parent_sequence, &checkpoint)
323 .await?;
324 (self.launch_agent)(SubagentLaunch {
325 role: AgentRole::Subagent {
326 parent_session_id: self.session_id.clone(),
327 parent_turn_id,
328 },
329 session_id,
330 model,
331 reasoning_effort,
332 metadata,
333 })
334 .await
335 }
336
337 async fn resume(
338 &self,
339 session_id: String,
340 agent_path: String,
341 depth: u8,
342 model: String,
343 parent_turn_id: String,
344 ) -> Result<Agent> {
345 let checkpoint = self.checkpoints.load(&session_id).await?.ok_or_else(|| {
346 Error::Checkpoint(format!("checkpoint for `{agent_path}` is missing"))
347 })?;
348 (self.launch_agent)(SubagentLaunch {
349 role: AgentRole::Subagent {
350 parent_session_id: self.session_id.clone(),
351 parent_turn_id,
352 },
353 session_id,
354 model,
355 reasoning_effort: None,
356 metadata: AgentIdentity {
357 root_session_id: self.root_session_id.clone(),
358 agent_path,
359 depth,
360 }
361 .metadata(checkpoint.metadata),
362 })
363 .await
364 }
365}
366
367pub struct Subagents {
369 max_depth: u8,
370 launch_agent: SubagentLauncher,
371 default_model: Option<String>,
372 default_reasoning: Option<String>,
373 prompt: String,
374 shared: Arc<Shared>,
375}
376
377impl Subagents {
378 pub fn new(
383 max_depth: u8,
384 max_concurrency: usize,
385 max_agents: usize,
386 launch_agent: SubagentLauncher,
387 ) -> Result<Self> {
388 if max_depth == 0 || max_depth > MAX_CONFIGURED_DEPTH {
389 return Err(Error::Config(format!(
390 "subagent max depth must be between 1 and {MAX_CONFIGURED_DEPTH}"
391 )));
392 }
393 if max_concurrency > MAX_CONFIGURED_CONCURRENCY {
394 return Err(Error::Config(format!(
395 "subagent max concurrency cannot exceed {MAX_CONFIGURED_CONCURRENCY}"
396 )));
397 }
398 if max_agents > MAX_CONFIGURED_AGENTS {
399 return Err(Error::Config(format!(
400 "subagent max agents cannot exceed {MAX_CONFIGURED_AGENTS}"
401 )));
402 }
403 Ok(Self {
404 max_depth,
405 launch_agent,
406 default_model: None,
407 default_reasoning: None,
408 prompt: text::PROMPT_DEFAULT.into(),
409 shared: Arc::new(Shared::new(max_concurrency, max_agents)?),
410 })
411 }
412
413 pub async fn has_active_children(&self, root_session_id: &str) -> Result<bool> {
415 self.shared.has_active_children(root_session_id).await
416 }
417
418 #[must_use]
420 pub fn default_model(mut self, model: impl Into<String>) -> Self {
421 self.default_model = Some(model.into());
422 self
423 }
424
425 pub fn default_reasoning(mut self, reasoning: impl Into<String>) -> Result<Self> {
427 let reasoning = reasoning.into();
428 if reasoning.trim().is_empty() {
429 return Err(Error::Config(
430 "subagent reasoning effort cannot be empty".into(),
431 ));
432 }
433 self.default_reasoning = Some(reasoning);
434 Ok(self)
435 }
436
437 pub fn prompt(mut self, prompt: impl Into<String>) -> Result<Self> {
439 let prompt = prompt.into();
440 if prompt.trim().is_empty() {
441 return Err(Error::Config("subagent prompt cannot be empty".into()));
442 }
443 self.prompt = prompt;
444 Ok(self)
445 }
446
447 fn section(&self, identity: &AgentIdentity) -> PromptSection {
448 let body = if identity.depth == 0 {
449 text::PROMPT_ROOT.into()
450 } else {
451 format!(
452 "You are `{}`, a child agent.\n{}",
453 identity.agent_path,
454 self.prompt.trim()
455 )
456 };
457 PromptSection::new(body)
458 }
459
460 async fn read_command(
461 &self,
462 session_id: &str,
463 metadata: &BTreeMap<String, Value>,
464 arguments: &str,
465 ) -> Result<MiddlewareCommandOutput> {
466 let path = arguments.trim();
467 if path.starts_with('{') {
468 return self.read_preview_page(session_id, metadata, path).await;
469 }
470 let identity = AgentIdentity::read(session_id, metadata)?;
471 if !path.is_empty() {
472 return self
473 .preview_page(&identity.root_session_id, path, None)
474 .await;
475 }
476 let options = self
477 .shared
478 .resume_options(&identity.root_session_id)
479 .await?;
480 if options.is_empty() {
481 return Ok(MiddlewareCommandOutput::events(vec![
482 FrontendEvent::Picker {
483 title: format!("{} ยท {}", text::RENDER_OPEN, text::RENDER_EMPTY),
484 options,
485 },
486 ]));
487 }
488 Ok(MiddlewareCommandOutput::events(vec![
489 FrontendEvent::Picker {
490 title: text::RENDER_OPEN.into(),
491 options,
492 },
493 ]))
494 }
495
496 async fn read_preview_page(
497 &self,
498 session_id: &str,
499 metadata: &BTreeMap<String, Value>,
500 arguments: &str,
501 ) -> Result<MiddlewareCommandOutput> {
502 let identity = AgentIdentity::read(session_id, metadata)?;
503 let cursor: PreviewCursor = serde_json::from_str(arguments)
504 .map_err(|_| Error::Tool("invalid subagent preview cursor".into()))?;
505 if cursor.path.trim() != cursor.path
506 || cursor.path.is_empty()
507 || cursor.before_sequence == 0
508 {
509 return Err(Error::Tool("invalid subagent preview cursor".into()));
510 }
511 self.preview_page(
512 &identity.root_session_id,
513 &cursor.path,
514 Some(cursor.before_sequence),
515 )
516 .await
517 }
518
519 async fn preview_page(
520 &self,
521 root_session_id: &str,
522 path: &str,
523 before_sequence: Option<u64>,
524 ) -> Result<MiddlewareCommandOutput> {
525 let page = self
526 .shared
527 .preview(root_session_id, path, before_sequence)
528 .await?;
529 let next = page
530 .next
531 .map(|before_sequence| -> Result<Op> {
532 Ok(Op::CapabilityCommand {
533 capability: MANIFEST.id.into(),
534 command: "subagents".into(),
535 arguments: serde_json::to_string(&PreviewCursor {
536 path: path.into(),
537 before_sequence,
538 })?,
539 input: None,
540 target: None,
541 })
542 })
543 .transpose()?;
544 Ok(MiddlewareCommandOutput::events(vec![
545 FrontendEvent::Preview {
546 id: path.into(),
547 title: path.rsplit('/').next().unwrap_or(path).into(),
548 subtitle: page.subtitle,
549 page_id: page.page_id,
550 update: if before_sequence.is_some() {
551 FrontendPreviewUpdate::Prepend
552 } else {
553 FrontendPreviewUpdate::Replace
554 },
555 events: page.events,
556 next,
557 },
558 ]))
559 }
560}
561
562impl Middleware for Subagents {
563 fn name(&self) -> &'static str {
564 MANIFEST.id
565 }
566
567 fn session_start<'a>(
568 &'a self,
569 context: &'a mut SessionStartContext<'_>,
570 ) -> BoxFuture<'a, Result<()>> {
571 if context.source() == SessionStartSource::Compact {
572 return Box::pin(async { Ok(()) });
573 }
574 Box::pin(self.shared.session_start((*context.runtime).clone()))
575 }
576
577 fn register(&self, catalog: &mut Catalog, runtime: &RuntimeContext) -> Result<()> {
578 let scope = Arc::new(AgentScope::new(runtime, Arc::clone(&self.launch_agent))?);
579 if scope.depth < self.max_depth {
580 catalog.register(Arc::new(SpawnAgent {
581 default_model: self.default_model.clone(),
582 default_reasoning: self.default_reasoning.clone(),
583 shared: Arc::clone(&self.shared),
584 scope: Arc::clone(&scope),
585 }))?;
586 }
587 catalog.register(Arc::new(SendMessage {
588 shared: Arc::clone(&self.shared),
589 scope: Arc::clone(&scope),
590 }))?;
591 catalog.register(Arc::new(ListAgents {
592 shared: Arc::clone(&self.shared),
593 scope: Arc::clone(&scope),
594 }))?;
595 catalog.register(Arc::new(InterruptAgent {
596 shared: Arc::clone(&self.shared),
597 scope: Arc::clone(&scope),
598 }))?;
599 catalog.register(Arc::new(WaitAgent {
600 shared: Arc::clone(&self.shared),
601 scope,
602 }))
603 }
604
605 fn prompt_section(&self, runtime: &RuntimeContext) -> Result<Option<PromptSection>> {
606 let identity = AgentIdentity::read(&runtime.session_id, &runtime.metadata)?;
607 Ok(Some(self.section(&identity)))
608 }
609
610 fn frontend(&self) -> FrontendContribution {
611 FrontendContribution {
612 capability: self.name().into(),
613 accepts_file_attachments: false,
614 count: None,
615 commands: vec![FrontendCommand {
616 name: "subagents".into(),
617 arguments: String::new(),
618 description: text::COMMAND_DESCRIPTION.into(),
619 requires_idle: false,
620 }],
621 widgets: Vec::new(),
622 references: Vec::new(),
623 }
624 }
625
626 fn render(&self, event: &EventMsg, _session_id: &str) -> Option<FrontendBlock> {
627 let mut block = render_tool_event(
628 event,
629 |name| {
630 matches!(
631 name,
632 "spawn_agent"
633 | "send_message"
634 | "list_agents"
635 | "interrupt_agent"
636 | "wait_agent"
637 )
638 },
639 |name, arguments| match name {
640 _ if matches!(event, EventMsg::ToolCallEnd(_)) => name.into(),
641 "spawn_agent" => labeled_tool_heading(text::RENDER_AGENT, "task_name", arguments),
642 "send_message" => labeled_tool_heading(text::RENDER_MESSAGE, "target", arguments),
643 "list_agents" => {
644 labeled_tool_heading(text::RENDER_AGENTS, "path_prefix", arguments)
645 }
646 "interrupt_agent" => {
647 labeled_tool_heading(text::RENDER_INTERRUPT, "target", arguments)
648 }
649 "wait_agent" => labeled_tool_heading(text::RENDER_WAIT, "timeout_ms", arguments),
650 _ => name.to_string().into(),
651 },
652 )?;
653 if let EventMsg::ToolCallBegin(call) = event
654 && call.name == "send_message"
655 && let Some(message) = call.arguments.get("text").and_then(Value::as_str)
656 {
657 FrontendBlockUpdate::Append.apply(&mut block.text, message);
658 }
659 Some(block)
660 }
661
662 fn command<'a>(
663 &'a self,
664 context: MiddlewareCommandContext<'a>,
665 ) -> BoxFuture<'a, Result<MiddlewareCommandOutput>> {
666 Box::pin(async move {
667 match context.command {
668 "subagents" => {
669 self.read_command(
670 context.session_id,
671 &context.checkpoint.metadata,
672 context.arguments,
673 )
674 .await
675 }
676 command => Err(Error::Unknown(format!("subagents command `{command}`"))),
677 }
678 })
679 }
680
681 fn active_command<'a>(
682 &'a self,
683 context: &'a mut ActiveCommandContext<'_>,
684 ) -> BoxFuture<'a, Result<Option<SubmissionResult>>> {
685 Box::pin(async move {
686 let output = match context.command {
687 "subagents" => {
688 self.read_command(context.session_id, context.metadata, context.arguments)
689 .await
690 }
691 _ => return Ok(None),
692 };
693 match output {
694 Ok(output) => {
695 context
696 .events
697 .extend(output.events.into_iter().map(EventMsg::Frontend));
698 Ok(Some(SubmissionResult::Handled))
699 }
700 Err(error) => Ok(Some(SubmissionResult::Rejected(error.to_string()))),
701 }
702 })
703 }
704
705 fn pre_model<'a>(&'a self, context: &'a mut ModelContext<'_>) -> BoxFuture<'a, Result<()>> {
706 Box::pin(async move {
707 let identity = AgentIdentity::read(context.session_id, context.metadata)?;
708 let acknowledged = context
709 .input()
710 .iter()
711 .filter_map(internal_message_kind)
712 .filter_map(|kind| kind.strip_prefix("subagent_update:"))
713 .map(str::to_owned)
714 .collect();
715 let delivered_message_ids = context
716 .input()
717 .iter()
718 .filter_map(message_metadata)
719 .filter_map(|message| match message.author {
720 MessageAuthor::Peer { message_id, .. } => Some(message_id),
721 MessageAuthor::User => None,
722 })
723 .collect();
724 let updates = self
725 .shared
726 .receive_updates(
727 &identity.root_session_id,
728 &identity.agent_path,
729 &acknowledged,
730 )
731 .await?;
732 for update in updates {
733 context.push_input(internal_user_message(
734 &update.internal_kind(),
735 &update.render(&delivered_message_ids),
736 ))?;
737 }
738 Ok(())
739 })
740 }
741
742 fn session_end<'a>(&'a self, runtime: &'a RuntimeContext) -> BoxFuture<'a, Result<()>> {
743 Box::pin(async move {
744 let identity = AgentIdentity::read(&runtime.session_id, &runtime.metadata)?;
745 if matches!(runtime.role, AgentRole::Main) && identity.depth == 0 {
746 self.shared.remove_root(&identity.root_session_id).await?;
747 } else {
748 self.shared
749 .remove_sender(&identity.root_session_id, &identity.agent_path)
750 .await;
751 }
752 Ok(())
753 })
754 }
755}
756
757#[cfg(test)]
758mod tests;