1use crate::approval::{ApprovalOutcome, request_approval};
2use crate::error::RuntimeError;
3use crate::event::{Event, FlowRunId, FlowStatus, NodeEvent};
4use crate::message::{Message, MessagePart, MessageRole};
5use crate::provider::LlmRequest;
6use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult, ToolSpec};
7use crate::value::Value;
8use std::path::PathBuf;
9
10pub struct AgentSpawn;
11
12const DEFAULT_MAX_ITER: u64 = 20;
13const MAX_ITER_HARD_CAP: u64 = 200;
14
15impl Tool for AgentSpawn {
16 fn name(&self) -> &str {
17 "agent.spawn"
18 }
19
20 fn tier(&self) -> Tier {
21 Tier::Two
22 }
23
24 fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
25 ApprovalLevel::Approve
26 }
27
28 fn description(&self) -> Option<&str> {
29 Some(
30 "Spawn an independent sub-agent to handle a focused sub-goal. The sub-agent runs its \
31 own message history and iteration counter, uses the same tool registry (or a subset \
32 you pick), and returns its final assistant text as this tool's result. Prefer this \
33 over doing large exploratory work directly when it would otherwise flood the main \
34 conversation with search output or scratch reasoning. Parameters: \
35 `goal` (required string), `tools` (optional list of tool-name strings — defaults \
36 to all tools available to you), `max_iterations` (optional int, default 20, capped \
37 at 200), `model` (optional model name — defaults to the last model this session \
38 used, then configured models, then claude-opus-4.7), `flow` (optional .at file path \
39 or command name; goal is passed as the first flow argument).",
40 )
41 }
42
43 fn input_schema(&self) -> serde_json::Value {
44 serde_json::json!({
45 "type": "object",
46 "properties": {
47 "goal": {"type": "string"},
48 "tools": {"type": "array", "items": {"type": "string"}},
49 "max_iterations": {"type": "integer"},
50 "model": {"type": "string"},
51 "flow": {"type": "string"}
52 },
53 "required": ["goal"]
54 })
55 }
56
57 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
58 Box::pin(async move { run_sub_agent(args, ctx).await })
59 }
60}
61
62async fn run_sub_agent(args: ToolArgs, ctx: &ToolCtx) -> ToolResult {
63 let goal = extract_goal(&args)?;
64 if let Some(flow) = extract_flow(&args)? {
65 return run_flow_agent(&flow, goal, ctx).await;
66 }
67 let max_iter = extract_max_iter(&args);
68 let tool_filter = extract_tool_filter(&args)?;
69 let model = pick_model(&args, ctx);
70 let Some(providers) = ctx.providers.as_ref() else {
71 return Err(RuntimeError::ToolFailed(
72 "agent.spawn: no provider registry available on ctx".into(),
73 ));
74 };
75 let Some(provider) = providers.resolve(&model) else {
76 return Ok(Value::Str(format!(
77 "[sub-agent failed: no provider for model `{model}`]"
78 )));
79 };
80 let Some(registry) = ctx.registry.as_ref() else {
81 return Err(RuntimeError::ToolFailed(
82 "agent.spawn: no tool registry available on ctx".into(),
83 ));
84 };
85 let tool_specs = build_tool_specs(registry.as_ref(), tool_filter.as_deref());
86 let child_run_id = FlowRunId::now();
87 emit_child_flow_start(ctx, &child_run_id, &goal);
88 let turn = ctx
89 .turn_id
90 .clone()
91 .unwrap_or_else(crate::event::TurnId::now);
92 let mut messages: Vec<Message> = vec![Message::user_text(turn.clone(), goal.clone())];
93 let mut final_text: Option<String> = None;
94 let mut failure_reason: Option<String> = None;
95 for iter in 0..max_iter {
96 if ctx.cancel.is_cancelled() {
97 failure_reason = Some("cancelled by parent".into());
98 break;
99 }
100 let req = LlmRequest {
101 model: model.clone(),
102 messages: messages.clone(),
103 system: None,
104 input: Value::Unit,
105 schema: None,
106 cache_prompt: false,
107 tools: tool_specs.clone(),
108 thinking_enabled: false,
109 stall_timeout_secs: 120,
110 };
111 let outcome = call_streaming_sub_agent(provider.as_ref(), req, ctx).await;
112 match outcome {
113 Ok(am) => {
114 emit_child_llm_call(ctx, &child_run_id, &model, &am);
115 let uses = extract_tool_uses(&am.message);
116 messages.push(am.message.clone());
117 emit_assistant_msg(ctx, &child_run_id, &am.message);
118 if uses.is_empty() {
119 final_text = Some(am.text_concat());
120 break;
121 }
122 let mut child_ctx = sanitize_child_ctx(ctx);
123 child_ctx.session_messages = Some(std::sync::Arc::new(messages.clone()));
124 let tool_results = dispatch_child_tools(&uses, registry.as_ref(), &child_ctx).await;
125 let turn_for_results = am.message.turn_id.clone();
126 let combined = Message {
127 turn_id: turn_for_results,
128 role: MessageRole::Tool,
129 parts: tool_results,
130 };
131 emit_tool_result_msg(ctx, &child_run_id, &combined);
132 messages.push(combined);
133 }
134 Err(e) => {
135 failure_reason = Some(format!("provider error at iter {iter}: {e}"));
136 break;
137 }
138 }
139 }
140 let status = if final_text.is_some() {
141 FlowStatus::Ok
142 } else {
143 FlowStatus::Errored {
144 message: failure_reason
145 .clone()
146 .unwrap_or_else(|| format!("hit max iterations {max_iter} without a final answer")),
147 }
148 };
149 emit_child_flow_end(ctx, &child_run_id, &status);
150 if let Some(text) = final_text {
151 Ok(Value::Str(text))
152 } else {
153 let reason = failure_reason
154 .unwrap_or_else(|| format!("hit max iterations {max_iter} without a final answer"));
155 let last = messages
156 .iter()
157 .rev()
158 .find(|m| matches!(m.role, MessageRole::Assistant))
159 .map(|m| m.text_concat())
160 .unwrap_or_default();
161 let partial = if last.is_empty() {
162 String::new()
163 } else {
164 format!("\n[partial output: {}]", truncate(&last, 400))
165 };
166 Ok(Value::Str(format!("[sub-agent failed: {reason}]{partial}")))
167 }
168}
169
170async fn run_flow_agent(flow_ref: &str, goal: String, ctx: &ToolCtx) -> ToolResult {
171 let Some(registry) = ctx.registry.as_ref() else {
172 return Err(RuntimeError::ToolFailed(
173 "agent.spawn: no tool registry available on ctx".into(),
174 ));
175 };
176 let Some(providers) = ctx.providers.as_ref() else {
177 return Err(RuntimeError::ToolFailed(
178 "agent.spawn: no provider registry available on ctx".into(),
179 ));
180 };
181 let (path, src) = read_flow_source(flow_ref).await?;
182 let file = atman_dsl::parse::parse_file(&src).map_err(|e| {
183 RuntimeError::ToolFailed(format!("agent.spawn: parse {}: {e}", path.display()))
184 })?;
185 let Some(flow) = file.flows.first() else {
186 return Err(RuntimeError::ToolFailed(format!(
187 "agent.spawn: no flow in {}",
188 path.display()
189 )));
190 };
191 let args = flow
192 .params
193 .first()
194 .map(|(ident, _)| vec![(ident.name.clone(), Value::Str(goal))])
195 .unwrap_or_default();
196 let flows = file
197 .flows
198 .iter()
199 .map(|flow| (flow.name.name.clone(), flow.clone()))
200 .collect();
201 let run_id = FlowRunId::now();
202 emit_flow_agent_start(ctx, &run_id, &flow.name.name);
203 let mut child_ctx = sanitize_child_ctx(ctx);
204 child_ctx.session_messages = Some(std::sync::Arc::new(Vec::new()));
205 let out = crate::exec::exec_flow_with_siblings(
206 flow,
207 args,
208 registry.as_ref(),
209 &child_ctx,
210 providers.as_ref(),
211 &flows,
212 child_ctx.events.as_ref(),
213 child_ctx.turn_id.clone(),
214 Some(run_id.clone()),
215 None,
216 child_ctx.cancel.clone(),
217 None,
218 path.parent().map(|p| p.to_path_buf()),
219 )
220 .await;
221 let status = match &out {
222 Ok(_) => FlowStatus::Ok,
223 Err(e) => FlowStatus::Errored {
224 message: e.to_string(),
225 },
226 };
227 emit_child_flow_end(ctx, &run_id, &status);
228 out
229}
230
231fn extract_goal(args: &ToolArgs) -> Result<String, RuntimeError> {
232 match args.named("goal").or_else(|| args.positional.first()) {
233 Some(Value::Str(s)) if !s.trim().is_empty() => Ok(s.clone()),
234 Some(other) => Err(RuntimeError::TypeMismatch {
235 expected: "non-empty goal string".into(),
236 actual: other.kind_name().into(),
237 }),
238 None => Err(RuntimeError::MissingArg("agent.spawn.goal".into())),
239 }
240}
241
242fn extract_max_iter(args: &ToolArgs) -> u64 {
243 match args.named("max_iterations") {
244 Some(Value::Int(n)) if *n > 0 => (*n as u64).min(MAX_ITER_HARD_CAP),
245 _ => DEFAULT_MAX_ITER,
246 }
247}
248
249fn extract_flow(args: &ToolArgs) -> Result<Option<String>, RuntimeError> {
250 match args.named("flow") {
251 Some(Value::Str(s)) if !s.trim().is_empty() => Ok(Some(s.clone())),
252 Some(Value::Unit) | None => Ok(None),
253 Some(other) => Err(RuntimeError::TypeMismatch {
254 expected: "flow string".into(),
255 actual: other.kind_name().into(),
256 }),
257 }
258}
259
260fn extract_tool_filter(args: &ToolArgs) -> Result<Option<Vec<String>>, RuntimeError> {
261 match args.named("tools") {
262 Some(Value::List(items)) => {
263 let mut out = Vec::with_capacity(items.len());
264 for it in items {
265 match it {
266 Value::Str(s) => out.push(s.clone()),
267 other => {
268 return Err(RuntimeError::TypeMismatch {
269 expected: "string tool name".into(),
270 actual: other.kind_name().into(),
271 });
272 }
273 }
274 }
275 Ok(Some(out))
276 }
277 Some(Value::Unit) | None => Ok(None),
278 Some(other) => Err(RuntimeError::TypeMismatch {
279 expected: "list of tool names".into(),
280 actual: other.kind_name().into(),
281 }),
282 }
283}
284
285fn pick_model(args: &ToolArgs, ctx: &ToolCtx) -> String {
286 if let Some(Value::Str(s)) = args.named("model")
287 && !s.is_empty()
288 {
289 return crate::model_registry::resolve_alias(s);
290 }
291 if let Some(model) = &ctx.current_model {
292 return model.clone();
293 }
294 if let Some((name, _)) = crate::model_registry::all_model_entries().first() {
295 return name.clone();
296 }
297 "claude-opus-4.7".into()
298}
299
300async fn read_flow_source(flow_ref: &str) -> Result<(PathBuf, String), RuntimeError> {
301 for path in flow_candidates(flow_ref) {
302 match tokio::fs::read_to_string(&path).await {
303 Ok(src) => return Ok((path, src)),
304 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
305 Err(e) => {
306 return Err(RuntimeError::ToolFailed(format!(
307 "agent.spawn: read {}: {e}",
308 path.display()
309 )));
310 }
311 }
312 }
313 Err(RuntimeError::ToolFailed(format!(
314 "agent.spawn: flow `{flow_ref}` not found"
315 )))
316}
317
318fn flow_candidates(flow_ref: &str) -> Vec<PathBuf> {
319 let path = PathBuf::from(flow_ref);
320 if path.is_absolute() {
321 return vec![path];
322 }
323 let file_name = if flow_ref.ends_with(".at") {
324 flow_ref.to_string()
325 } else {
326 format!("{flow_ref}.at")
327 };
328 let mut out = Vec::new();
329 if let Some(home) = std::env::var_os("HOME") {
330 out.push(
331 PathBuf::from(home)
332 .join(".config")
333 .join("atman")
334 .join("commands")
335 .join(&file_name),
336 );
337 }
338 out.push(PathBuf::from(file_name));
339 out
340}
341
342async fn call_streaming_sub_agent(
343 provider: &dyn crate::provider::Provider,
344 req: LlmRequest,
345 ctx: &ToolCtx,
346) -> Result<crate::provider::AssistantMessage, RuntimeError> {
347 let model_name = req.model.clone();
348 let obs = provider.call_streaming(req);
349 let mut events = obs.events;
350 let output = obs.output;
351 tokio::pin!(output);
352 let result = loop {
353 tokio::select! {
354 ev = events.recv() => {
355 match ev {
356 Ok(event) => forward_stream_event(event, ctx, &model_name),
357 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
358 break output.await;
360 }
361 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
362 }
364 }
365 }
366 result = &mut output => break result,
367 }
368 };
369 while let Ok(ev) = events.try_recv() {
370 forward_stream_event(ev, ctx, &model_name);
371 }
372 result
373}
374
375fn forward_stream_event(ev: NodeEvent, ctx: &ToolCtx, model: &str) {
376 let Some(tx) = &ctx.stream_tx else {
377 return;
378 };
379 match ev {
380 NodeEvent::LlmChunk { text, .. } => {
381 let _ = tx.send(crate::stream::StreamFrame::LlmChunk {
382 text,
383 model: model.to_string(),
384 });
385 }
386 NodeEvent::ThinkingChunk { text } => {
387 let _ = tx.send(crate::stream::StreamFrame::ThinkingChunk { text });
388 }
389 NodeEvent::LlmDone { total_tokens } => {
390 let _ = tx.send(crate::stream::StreamFrame::LlmDone { total_tokens });
391 }
392 _ => {}
393 }
394}
395
396fn build_tool_specs(
397 registry: &crate::tool::ToolRegistry,
398 filter: Option<&[String]>,
399) -> Vec<ToolSpec> {
400 let mut specs = Vec::new();
401 for (name, tool) in registry.iter() {
402 if let Some(allow) = filter
403 && !allow.iter().any(|n| n == &name)
404 {
405 continue;
406 }
407 specs.push(crate::tool::tool_spec(tool.as_ref()));
408 }
409 specs
410}
411
412fn extract_tool_uses(msg: &Message) -> Vec<(String, String, Value)> {
413 let mut out = Vec::new();
414 for part in &msg.parts {
415 if let MessagePart::ToolUse { id, name, input } = part {
416 let value = Value::from_json(input.clone());
417 out.push((id.clone(), name.clone(), value));
418 }
419 }
420 out
421}
422
423async fn dispatch_child_tools(
424 uses: &[(String, String, Value)],
425 registry: &crate::tool::ToolRegistry,
426 ctx: &ToolCtx,
427) -> Vec<MessagePart> {
428 struct Ready {
429 idx: usize,
430 id: String,
431 name: String,
432 tool: std::sync::Arc<dyn crate::tool::Tool>,
433 call_args: ToolArgs,
434 }
435 let mut out: Vec<Option<MessagePart>> = vec![None; uses.len()];
436 let mut ready: Vec<Ready> = Vec::new();
437 for (idx, (id, name, input)) in uses.iter().enumerate() {
438 let Some(tool) = registry.get(name) else {
439 out[idx] = Some(MessagePart::ToolResult {
440 tool_use_id: id.clone(),
441 content: format!("sub-agent: unknown tool `{name}`"),
442 is_error: true,
443 });
444 continue;
445 };
446 let named = match input {
447 Value::Struct(fields) => fields.clone(),
448 Value::Unit => Vec::new(),
449 _ => Vec::new(),
450 };
451 ready.push(Ready {
452 idx,
453 id: id.clone(),
454 name: name.clone(),
455 tool,
456 call_args: ToolArgs {
457 positional: Vec::new(),
458 named,
459 },
460 });
461 }
462 for r in &ready {
464 emit_tool_use_start(ctx, &r.name, &r.id, &r.call_args);
465 }
466 let gates = ready.iter().map(|r| {
467 let level = r.tool.approval_level(&r.call_args, ctx);
468 request_approval(
469 ctx,
470 &r.id,
471 &r.name,
472 &r.call_args,
473 level,
474 Some(r.tool.as_ref()),
475 )
476 });
477 let outcomes = futures::future::join_all(gates).await;
478 for (r, gate) in ready.into_iter().zip(outcomes) {
479 let part = match gate {
480 ApprovalOutcome::Deny { reason } => MessagePart::ToolResult {
481 tool_use_id: r.id.clone(),
482 content: format!("sub-agent: tool `{}` denied — {reason}", r.name),
483 is_error: true,
484 },
485 ApprovalOutcome::Approve => match r.tool.call(r.call_args, ctx).await {
486 Ok(v) => MessagePart::ToolResult {
487 tool_use_id: r.id.clone(),
488 content: format_value(&v),
489 is_error: false,
490 },
491 Err(e) => MessagePart::ToolResult {
492 tool_use_id: r.id.clone(),
493 content: format!("{e}"),
494 is_error: true,
495 },
496 },
497 };
498 emit_tool_use_done(ctx, &r.name, &r.id, &part);
499 out[r.idx] = Some(part);
500 }
501 out.into_iter().flatten().collect()
502}
503
504fn emit_tool_use_start(ctx: &ToolCtx, name: &str, id: &str, args: &ToolArgs) {
505 if let Some(tx) = &ctx.stream_tx {
506 let _ = tx.send(crate::stream::StreamFrame::ToolUseStart {
507 tool: name.to_string(),
508 args_preview: preview_tool_args(args),
509 id: id.to_string(),
510 });
511 }
512}
513
514fn emit_tool_use_done(ctx: &ToolCtx, name: &str, id: &str, part: &MessagePart) {
515 if let Some(tx) = &ctx.stream_tx {
516 let (ok, preview) = match part {
517 MessagePart::ToolResult {
518 content, is_error, ..
519 } => (!is_error, truncate(content, 400)),
520 _ => (false, String::new()),
521 };
522 let _ = tx.send(crate::stream::StreamFrame::ToolUseDone {
523 tool: name.to_string(),
524 ok,
525 preview,
526 id: id.to_string(),
527 });
528 }
529}
530
531fn preview_tool_args(args: &ToolArgs) -> String {
532 let mut parts: Vec<String> = args.positional.iter().map(preview_value).collect();
533 for (k, v) in &args.named {
534 parts.push(format!("{k}={}", preview_value(v)));
535 }
536 truncate(&parts.join(", "), 4000)
537}
538
539fn preview_value(v: &Value) -> String {
540 match v {
541 Value::Str(s) => format!("{s:?}"),
542 Value::Int(n) => n.to_string(),
543 Value::Bool(b) => b.to_string(),
544 Value::Float(f) => f.to_string(),
545 Value::Unit => "()".into(),
546 Value::List(items) => format!("list[{}]", items.len()),
547 Value::Struct(items) => format!("struct[{}]", items.len()),
548 Value::Message(_) => "<message>".into(),
549 Value::Path(p) => p.display().to_string(),
550 Value::EditProposal(_) => "<edit proposal>".into(),
551 Value::Err(e) => format!("error({e})"),
552 }
553}
554
555fn format_value(v: &Value) -> String {
556 match v {
557 Value::Str(s) => s.clone(),
558 other => format!("{other:?}"),
559 }
560}
561
562fn emit_child_flow_start(ctx: &ToolCtx, run_id: &FlowRunId, goal: &str) {
563 let parent_run_id = ctx.flow_run_id.clone();
564 let parent_node_id = ctx.current_node_id.clone();
565 if let Some(sink) = &ctx.events {
566 sink.emit(Event::FlowStart {
567 run_id: run_id.clone(),
568 flow_name: "agent.sub".into(),
569 parent_run_id: parent_run_id.clone(),
570 parent_node_id: parent_node_id.clone(),
571 });
572 }
573 if let Some(tx) = &ctx.stream_tx {
574 let _ = tx.send(crate::stream::StreamFrame::FlowStart {
575 run_id: run_id.0.to_string(),
576 flow_name: format!("agent.sub · {}", truncate(goal, 60)),
577 parent_run_id: parent_run_id.as_ref().map(|r| r.0.to_string()),
578 parent_node_id,
579 });
580 }
581}
582
583fn emit_flow_agent_start(ctx: &ToolCtx, run_id: &FlowRunId, flow_name: &str) {
584 let parent_run_id = ctx.flow_run_id.clone();
585 let parent_node_id = ctx.current_node_id.clone();
586 if let Some(sink) = &ctx.events {
587 sink.emit(Event::FlowStart {
588 run_id: run_id.clone(),
589 flow_name: flow_name.into(),
590 parent_run_id: parent_run_id.clone(),
591 parent_node_id: parent_node_id.clone(),
592 });
593 }
594 if let Some(tx) = &ctx.stream_tx {
595 let _ = tx.send(crate::stream::StreamFrame::FlowStart {
596 run_id: run_id.0.to_string(),
597 flow_name: flow_name.into(),
598 parent_run_id: parent_run_id.as_ref().map(|r| r.0.to_string()),
599 parent_node_id,
600 });
601 }
602}
603
604fn emit_child_flow_end(ctx: &ToolCtx, run_id: &FlowRunId, status: &FlowStatus) {
605 if let Some(sink) = &ctx.events {
606 sink.emit(Event::FlowEnd {
607 run_id: run_id.clone(),
608 flow_name: "agent.sub".into(),
609 status: status.clone(),
610 });
611 }
612 if let Some(tx) = &ctx.stream_tx {
613 let _ = tx.send(crate::stream::StreamFrame::FlowDone {
614 run_id: run_id.0.to_string(),
615 flow_name: "agent.sub".into(),
616 ok: matches!(status, FlowStatus::Ok),
617 cancelled: false,
618 });
619 }
620}
621
622fn emit_child_llm_call(
623 ctx: &ToolCtx,
624 _run_id: &FlowRunId,
625 model: &str,
626 am: &crate::provider::AssistantMessage,
627) {
628 if let Some(sink) = &ctx.events {
629 sink.emit(Event::LlmCall {
630 model: model.into(),
631 provider: "sub".into(),
632 usage: am.token_usage.clone(),
633 wallclock_ms: 0,
634 ttft_ms: am.timing.ttft_ms,
635 tokens_per_second: am.timing.tokens_per_second(am.token_usage.output),
636 status: crate::event::LlmCallStatus::Ok,
637 run_id: None,
638 node_id: None,
639 });
640 }
641}
642
643fn emit_assistant_msg(ctx: &ToolCtx, run_id: &FlowRunId, message: &Message) {
644 let turn_id = message.turn_id.clone();
645 if let Some(sink) = &ctx.events {
646 sink.emit(Event::AssistantMsg {
647 turn_id: turn_id.clone(),
648 flow_run_id: Some(run_id.clone()),
649 message: message.clone(),
650 });
651 }
652 if let Some(tx) = &ctx.stream_tx {
653 let _ = tx.send(crate::stream::StreamFrame::AssistantMsg {
654 flow_run_id: Some(run_id.0.to_string()),
655 message: message.clone(),
656 });
657 }
658}
659
660fn emit_tool_result_msg(ctx: &ToolCtx, run_id: &FlowRunId, message: &Message) {
661 let turn_id = message.turn_id.clone();
662 if let Some(sink) = &ctx.events {
663 sink.emit(Event::ToolResultMsg {
664 turn_id: turn_id.clone(),
665 flow_run_id: Some(run_id.clone()),
666 message: message.clone(),
667 });
668 }
669 if let Some(tx) = &ctx.stream_tx {
670 let _ = tx.send(crate::stream::StreamFrame::ToolResultMsg {
671 flow_run_id: Some(run_id.0.to_string()),
672 message: message.clone(),
673 });
674 }
675}
676
677fn sanitize_child_ctx(parent: &ToolCtx) -> ToolCtx {
678 let mut c = parent.clone();
679 c.session_runtime = None;
680 c.session_messages_handle = None;
681 c.compact_lock_handle = None;
682 c.forms = None;
683 c.on_memory_recent = None;
684 c
685}
686
687fn truncate(s: &str, n: usize) -> String {
688 let chars: Vec<char> = s.chars().collect();
689 if chars.len() <= n {
690 s.to_string()
691 } else {
692 chars.iter().take(n).collect::<String>() + "…"
693 }
694}