Skip to main content

atman_runtime/tools/
agent_ctrl.rs

1use crate::error::RuntimeError;
2use crate::event::{Event, FlowRunId, FlowStatus};
3use crate::message::Message;
4use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
5use crate::value::Value;
6use std::path::PathBuf;
7use std::sync::{Arc, Mutex};
8
9pub struct AgentSpawn;
10
11#[derive(Debug, Clone)]
12pub enum FlowRunStatus {
13    Running {
14        started_at: chrono::DateTime<chrono::Utc>,
15    },
16    Ok {
17        ended_at: chrono::DateTime<chrono::Utc>,
18        final_text: String,
19    },
20    Err {
21        ended_at: chrono::DateTime<chrono::Utc>,
22        message: String,
23    },
24    Killed {
25        ended_at: chrono::DateTime<chrono::Utc>,
26    },
27}
28
29impl FlowRunStatus {
30    pub fn is_running(&self) -> bool {
31        matches!(self, Self::Running { .. })
32    }
33
34    pub fn kind_str(&self) -> &'static str {
35        match self {
36            Self::Running { .. } => "running",
37            Self::Ok { .. } => "ok",
38            Self::Err { .. } => "err",
39            Self::Killed { .. } => "killed",
40        }
41    }
42}
43
44#[derive(Debug, Clone)]
45pub enum FlowEvent {
46    AssistantDone { text: String },
47    Exited { status: FlowRunStatus },
48}
49
50pub struct FlowEntry {
51    pub handle: String,
52    pub goal: String,
53    pub status: Arc<Mutex<FlowRunStatus>>,
54    pub output: Arc<Mutex<String>>,
55    pub cancel: tokio_util::sync::CancellationToken,
56    pub stream_tx: tokio::sync::broadcast::Sender<FlowEvent>,
57    pub messages: Arc<Mutex<Vec<Message>>>,
58    pub iteration: Arc<std::sync::atomic::AtomicU64>,
59    pub child_run_id: FlowRunId,
60    pub model: String,
61    pub started_at: chrono::DateTime<chrono::Utc>,
62    pub compact_lock: Arc<tokio::sync::Mutex<()>>,
63    pub interjection_tx: tokio::sync::broadcast::Sender<crate::injection::Injection>,
64    pub pending_injections: Arc<std::sync::Mutex<Vec<crate::injection::Injection>>>,
65    pub injection_notify: Arc<tokio::sync::Notify>,
66    pub frame_tx: tokio::sync::broadcast::Sender<crate::stream::StreamFrame>,
67}
68
69impl crate::watch::Watchable for FlowEntry {
70    fn watch_output(
71        self: Arc<Self>,
72        pattern: String,
73        cancel: tokio_util::sync::CancellationToken,
74    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::watch::WatchResult> + Send>>
75    {
76        let stream_tx = self.stream_tx.clone();
77        let output = self.output.clone();
78        let status = self.status.clone();
79        Box::pin(async move {
80            {
81                let existing = output.lock().unwrap().clone();
82                if existing.find(&pattern).is_some() {
83                    return crate::watch::WatchResult::Matched {
84                        row: None,
85                        col: None,
86                        text: existing,
87                    };
88                }
89            }
90            // Subscribe BEFORE checking status — if the source exits between
91            // subscribe and the status check, the broadcast event arrives via rx.
92            // If it exits before subscribe, the status check catches it.
93            let mut rx = stream_tx.subscribe();
94            {
95                let st = status.lock().unwrap().clone();
96                if !st.is_running() {
97                    if let FlowRunStatus::Ok { final_text, .. } = &st {
98                        if final_text.find(&pattern).is_some() {
99                            return crate::watch::WatchResult::Matched {
100                                row: None,
101                                col: None,
102                                text: final_text.clone(),
103                            };
104                        }
105                    }
106                    return crate::watch::WatchResult::SourceExited;
107                }
108            }
109            loop {
110                tokio::select! {
111                    _ = cancel.cancelled() => return crate::watch::WatchResult::Cancelled,
112                    result = rx.recv() => match result {
113                        Ok(FlowEvent::AssistantDone { text }) => {
114                            if text.find(&pattern).is_some() {
115                                return crate::watch::WatchResult::Matched {
116                                    row: None,
117                                    col: None,
118                                    text,
119                                };
120                            }
121                        }
122                        Ok(FlowEvent::Exited { status }) => {
123                            if let FlowRunStatus::Ok { final_text, .. } = &status {
124                                if final_text.find(&pattern).is_some() {
125                                    return crate::watch::WatchResult::Matched {
126                                        row: None,
127                                        col: None,
128                                        text: final_text.clone(),
129                                    };
130                                }
131                            }
132                            return crate::watch::WatchResult::SourceExited;
133                        }
134                        Err(_) => return crate::watch::WatchResult::SourceExited,
135                    }
136                }
137            }
138        })
139    }
140}
141
142#[derive(Default)]
143pub struct FlowRegistry {
144    entries: Mutex<std::collections::HashMap<String, Arc<FlowEntry>>>,
145}
146
147impl FlowRegistry {
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    pub fn create_entry(
153        &self,
154        handle: String,
155        goal: String,
156        model: String,
157        child_run_id: FlowRunId,
158    ) -> Arc<FlowEntry> {
159        let (stream_tx, _) = tokio::sync::broadcast::channel(64);
160        let entry = Arc::new(FlowEntry {
161            handle: handle.clone(),
162            goal,
163            status: Arc::new(Mutex::new(FlowRunStatus::Running {
164                started_at: chrono::Utc::now(),
165            })),
166            output: Arc::new(Mutex::new(String::new())),
167            cancel: tokio_util::sync::CancellationToken::new(),
168            stream_tx,
169            messages: Arc::new(Mutex::new(Vec::new())),
170            iteration: Arc::new(std::sync::atomic::AtomicU64::new(0)),
171            child_run_id,
172            model,
173            started_at: chrono::Utc::now(),
174            compact_lock: Arc::new(tokio::sync::Mutex::new(())),
175            interjection_tx: tokio::sync::broadcast::channel(32).0,
176            pending_injections: Arc::new(std::sync::Mutex::new(Vec::new())),
177            injection_notify: Arc::new(tokio::sync::Notify::new()),
178            frame_tx: tokio::sync::broadcast::channel(256).0,
179        });
180        self.entries
181            .lock()
182            .unwrap()
183            .insert(handle, Arc::clone(&entry));
184        entry
185    }
186
187    pub fn lookup(&self, handle: &str) -> Result<Arc<FlowEntry>, RuntimeError> {
188        self.entries
189            .lock()
190            .unwrap()
191            .get(handle)
192            .map(Arc::clone)
193            .ok_or_else(|| RuntimeError::ToolFailed(format!("agent: handle '{handle}' not found")))
194    }
195
196    pub fn remove(&self, handle: &str) {
197        self.entries.lock().unwrap().remove(handle);
198    }
199}
200
201impl Tool for AgentSpawn {
202    fn name(&self) -> &str {
203        "flow.spawn"
204    }
205
206    fn tier(&self) -> Tier {
207        Tier::Two
208    }
209
210    fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
211        ApprovalLevel::Approve
212    }
213
214    fn description(&self) -> Option<&str> {
215        Some(
216            "Spawn a DSL flow as an independent sub-agent with its own message history and \
217             iteration counter. All named args except `flow` and `async` pass through to the \
218             flow as parameters — call flow.list first to discover available flows and their \
219             parameter signatures.\n\n\
220             Flow reference syntax: `file@flow_name`\n\
221             - \"subagent.at@subagent\" — run the `subagent` flow in subagent.at\n\
222             - \"subagent@research_loop\" — .at suffix optional\n\
223             - \"subagent.at\" — no @, takes first non-describe flow\n\
224             - \"/abs/path/my.at@main\" — absolute path\n\n\
225             Default flow is `subagent.at` (research/verify/implement/review roles). \
226             Required: `flow`. `async` is optional (default true). Other named args pass through to the flow. \
227             Use flow.status/flow.output/flow.kill to manage async sub-agents by handle. \
228             Best practice: call flow.list to see available flows and params, then pass \
229             matching named args. Missing params use flow-defined defaults.",
230        )
231    }
232
233    fn input_schema(&self) -> serde_json::Value {
234        serde_json::json!({
235            "type": "object",
236            "properties": {
237                "flow": {"type": "string", "description": "Flow reference (e.g. \"subagent.at@subagent\")."},
238                "arguments": {
239                    "type": "object",
240                    "additionalProperties": true,
241                    "description": "Target flow parameters as key-value pairs. You MUST call flow.list first to discover the flow's description and parameter signatures (names, types, required/optional), then construct this object accordingly. Example: arguments={\"goal\":\"read Cargo.toml\",\"role\":\"research\"}"
242                },
243                "async": {"type": "boolean", "default": true, "description": "If true (default), run in background and return a handle. If false, block until done."},
244                "inherit_context": {"type": "boolean", "default": false, "description": "If true, seed the sub-agent's context with a snapshot of the parent's messages."}
245            },
246            "required": ["flow"]
247        })
248    }
249
250    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
251        Box::pin(async move {
252            let is_async = args
253                .named("async")
254                .and_then(|v| {
255                    if let Value::Bool(b) = v {
256                        Some(*b)
257                    } else {
258                        None
259                    }
260                })
261                .unwrap_or(true);
262            if is_async {
263                run_sub_agent_async(args, ctx).await
264            } else {
265                run_sub_agent(args, ctx).await
266            }
267        })
268    }
269}
270
271async fn run_sub_agent(args: ToolArgs, ctx: &ToolCtx) -> ToolResult {
272    let flow = extract_flow(&args)?.unwrap_or_else(|| "subagent.at".to_string());
273    run_flow_agent(&flow, &args, ctx, FlowRunId::now()).await
274}
275
276async fn run_sub_agent_async(args: ToolArgs, ctx: &ToolCtx) -> ToolResult {
277    // Extract flow params from the `arguments` object (generic, no hardcoded param names).
278    let arg_fields: Vec<(String, Value)> = match args.named("arguments") {
279        Some(Value::Struct(fields)) => fields.clone(),
280        _ => Vec::new(),
281    };
282    // Use the first string-typed argument as a display label for the FlowEntry.
283    let display_label = arg_fields
284        .iter()
285        .find_map(|(_, v)| {
286            if let Value::Str(s) = v {
287                Some(s.clone())
288            } else {
289                None
290            }
291        })
292        .unwrap_or_default();
293    let inherit_context = args
294        .named("inherit_context")
295        .map(|v| matches!(v, Value::Bool(true)))
296        .unwrap_or(false);
297    let flow_registry = ctx.flow_registry.clone().ok_or_else(|| {
298        RuntimeError::ToolFailed("flow.spawn: no agent registry available on ctx".into())
299    })?;
300
301    let flow_ref = extract_flow(&args)?.unwrap_or_else(|| "subagent.at".to_string());
302
303    let handle = format!("agent_{}", uuid::Uuid::now_v7().simple());
304    let child_run_id = FlowRunId::now();
305    let entry = flow_registry.create_entry(
306        handle.clone(),
307        display_label,
308        String::new(),
309        child_run_id.clone(),
310    );
311    if inherit_context {
312        if let Some(parent) = &ctx.session_messages_handle {
313            let snapshot = parent.lock().unwrap().clone();
314            *entry.messages.lock().unwrap() = snapshot;
315        }
316    }
317
318    let task_registry = ctx.task_registry.clone();
319    let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
320    let task_id = task_registry.as_ref().map(|tr| {
321        tr.register(
322            crate::task_registry::TaskKind::Flow,
323            entry.goal.clone(),
324            handle.clone(),
325            session_id,
326            entry.cancel.clone(),
327        )
328    });
329
330    let entry_clone = Arc::clone(&entry);
331    let ctx_clone = ctx.clone();
332    let parent_stream_tx = ctx.stream_tx.clone();
333    let child_run_id_str = child_run_id.0.to_string();
334    tokio::spawn(async move {
335        // Send SubAgentStarted so the TUI creates a SubAgentActivity item and
336        // registers child_run_id in sub_agent_run_ids for frame routing.
337        if let Some(tx) = &parent_stream_tx {
338            let _ = tx.send(crate::stream::StreamFrame::SubAgentStarted {
339                handle: entry_clone.handle.clone(),
340                goal: entry_clone.goal.clone(),
341                child_run_id: child_run_id_str.clone(),
342                model: entry_clone.model.clone(),
343            });
344        }
345
346        // Replace ctx.cancel with entry.cancel so flow.kill can actually cancel
347        // the sub-agent's flow execution. Pass entry into ctx so the DSL runtime
348        // can write output/messages/iteration synchronously during LLM calls.
349        // Bind the sub-agent's own compact_lock so it can compact its segment
350        // independently.
351        let mut ctx_for_flow = ctx_clone;
352        ctx_for_flow.cancel = entry_clone.cancel.clone();
353        ctx_for_flow.agent_entry = Some(Arc::clone(&entry_clone));
354        ctx_for_flow.compact_lock_handle = Some(Arc::clone(&entry_clone.compact_lock));
355
356        let result = run_flow_agent(&flow_ref, &args, &ctx_for_flow, child_run_id.clone()).await;
357
358        let status = match &result {
359            Ok(Value::Str(s)) => FlowRunStatus::Ok {
360                ended_at: chrono::Utc::now(),
361                final_text: s.clone(),
362            },
363            Ok(_) => FlowRunStatus::Ok {
364                ended_at: chrono::Utc::now(),
365                final_text: String::new(),
366            },
367            Err(e) => FlowRunStatus::Err {
368                ended_at: chrono::Utc::now(),
369                message: e.to_string(),
370            },
371        };
372        *entry_clone.status.lock().unwrap() = status.clone();
373
374        // Send SubAgentDone so the TUI updates the SubAgentActivity item.
375        if let Some(tx) = &parent_stream_tx {
376            let final_text = match &status {
377                FlowRunStatus::Ok { final_text, .. } => final_text.clone(),
378                _ => String::new(),
379            };
380            let _ = tx.send(crate::stream::StreamFrame::SubAgentDone {
381                handle: entry_clone.handle.clone(),
382                status: status.kind_str().to_string(),
383                final_text,
384            });
385        }
386
387        let _ = entry_clone.stream_tx.send(FlowEvent::Exited { status });
388        if let (Some(tr), Some(tid)) = (&task_registry, &task_id) {
389            let ts = match result {
390                Ok(_) => crate::task_registry::TaskStatus::Ok,
391                Err(_) => crate::task_registry::TaskStatus::Err,
392            };
393            tr.finish(tid, ts);
394        }
395    });
396
397    Ok(Value::Struct(vec![
398        ("handle".into(), Value::Str(handle)),
399        ("status".into(), Value::Str("running".into())),
400    ]))
401}
402
403pub struct AgentStatus;
404impl Tool for AgentStatus {
405    fn name(&self) -> &str {
406        "flow.status"
407    }
408    fn tier(&self) -> Tier {
409        Tier::Zero
410    }
411    fn description(&self) -> Option<&str> {
412        Some("Check the status of an async sub-agent. Returns handle, status, goal, and timing.")
413    }
414    fn input_schema(&self) -> serde_json::Value {
415        serde_json::json!({
416            "type": "object",
417            "properties": {"handle": {"type": "string"}},
418            "required": ["handle"]
419        })
420    }
421    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
422        Box::pin(async move {
423            let handle = extract_string(&args, "handle", 0)?;
424            let reg = ctx
425                .flow_registry
426                .clone()
427                .ok_or_else(|| RuntimeError::ToolFailed("flow.status: no agent registry".into()))?;
428            let entry = reg.lookup(&handle)?;
429            let st = entry.status.lock().unwrap().clone();
430            let goal = entry.goal.clone();
431            let mut fields = vec![
432                ("handle".into(), Value::Str(handle)),
433                ("status".into(), Value::Str(st.kind_str().into())),
434                ("goal".into(), Value::Str(goal)),
435            ];
436            if let FlowRunStatus::Err { message, .. } = &st {
437                fields.push(("error".into(), Value::Str(message.clone())));
438            }
439            Ok(Value::Struct(fields))
440        })
441    }
442}
443
444pub struct AgentOutput;
445impl Tool for AgentOutput {
446    fn name(&self) -> &str {
447        "flow.output"
448    }
449    fn tier(&self) -> Tier {
450        Tier::Zero
451    }
452    fn description(&self) -> Option<&str> {
453        Some("Read accumulated assistant text from an async sub-agent.")
454    }
455    fn input_schema(&self) -> serde_json::Value {
456        serde_json::json!({
457            "type": "object",
458            "properties": {
459                "handle": {"type": "string"},
460                "cursor": {"type": "integer", "default": 0},
461                "limit": {"type": "integer", "default": 4096}
462            },
463            "required": ["handle"]
464        })
465    }
466    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
467        Box::pin(async move {
468            let handle = extract_string(&args, "handle", 0)?;
469            let cursor = args
470                .named("cursor")
471                .and_then(|v| {
472                    if let Value::Int(n) = v {
473                        Some(*n)
474                    } else {
475                        None
476                    }
477                })
478                .unwrap_or(0)
479                .max(0) as usize;
480            let limit = args
481                .named("limit")
482                .and_then(|v| {
483                    if let Value::Int(n) = v {
484                        Some(*n)
485                    } else {
486                        None
487                    }
488                })
489                .unwrap_or(4096)
490                .max(1) as usize;
491            let reg = ctx
492                .flow_registry
493                .clone()
494                .ok_or_else(|| RuntimeError::ToolFailed("flow.output: no agent registry".into()))?;
495            let entry = reg.lookup(&handle)?;
496            let output = entry.output.lock().unwrap().clone();
497            let chunk = output.chars().skip(cursor).take(limit).collect::<String>();
498            let next_cursor = cursor + chunk.chars().count();
499            let eof = next_cursor >= output.chars().count();
500            Ok(Value::Struct(vec![
501                ("handle".into(), Value::Str(handle)),
502                ("chunk".into(), Value::Str(chunk)),
503                ("cursor".into(), Value::Int(cursor as i64)),
504                ("next_cursor".into(), Value::Int(next_cursor as i64)),
505                ("eof".into(), Value::Bool(eof)),
506            ]))
507        })
508    }
509}
510
511pub struct AgentKill;
512impl Tool for AgentKill {
513    fn name(&self) -> &str {
514        "flow.kill"
515    }
516    fn tier(&self) -> Tier {
517        Tier::Four
518    }
519    fn description(&self) -> Option<&str> {
520        Some("Cancel a running async sub-agent by handle.")
521    }
522    fn input_schema(&self) -> serde_json::Value {
523        serde_json::json!({
524            "type": "object",
525            "properties": {"handle": {"type": "string"}},
526            "required": ["handle"]
527        })
528    }
529    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
530        Box::pin(async move {
531            let handle = extract_string(&args, "handle", 0)?;
532            let reg = ctx
533                .flow_registry
534                .clone()
535                .ok_or_else(|| RuntimeError::ToolFailed("flow.kill: no agent registry".into()))?;
536            let entry = reg.lookup(&handle)?;
537            entry.cancel.cancel();
538            Ok(Value::Unit)
539        })
540    }
541}
542
543pub struct FlowInterject;
544impl Tool for FlowInterject {
545    fn name(&self) -> &str {
546        "flow.interject"
547    }
548    fn tier(&self) -> Tier {
549        Tier::Two
550    }
551    fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
552        ApprovalLevel::Approve
553    }
554    fn description(&self) -> Option<&str> {
555        Some(
556            "Interject a text message into a running FlowRun (root or sub-agent) by handle. \
557             L1 (nudge): inject text into context, flow continues. \
558             L2 (course_correct): inject text, cancel current LLM call, flow continues with correction. \
559             L3 (redirect): cancel current LLM call, redirect to a different flow. \
560             L4 (hard_stop): kill the FlowRun immediately. \
561             Use handle \"root\" to interject into the main agent.",
562        )
563    }
564    fn input_schema(&self) -> serde_json::Value {
565        serde_json::json!({
566            "type": "object",
567            "properties": {
568                "handle": {"type": "string", "description": "Target FlowRun handle (e.g. from flow.spawn return, or \"root\")."},
569                "text": {"type": "string", "description": "Interjection text."},
570                "level": {"type": "string", "enum": ["l1_nudge", "l2_course_correct", "l3_redirect", "l4_hard_stop"], "default": "l1_nudge", "description": "Interjection level."},
571                "redirect_target": {"type": "string", "description": "Required for L3 redirect: the flow to redirect to."}
572            },
573            "required": ["handle", "text"]
574        })
575    }
576    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
577        Box::pin(async move {
578            let handle = extract_string(&args, "handle", 0)?;
579            let text = extract_string(&args, "text", 1)?;
580            let level_str = args
581                .named("level")
582                .and_then(|v| {
583                    if let Value::Str(s) = v {
584                        Some(s.clone())
585                    } else {
586                        None
587                    }
588                })
589                .unwrap_or_else(|| "l1_nudge".to_string());
590            let level = match level_str.as_str() {
591                "l2_course_correct" => crate::injection::InjectionLevel::L2CourseCorrect,
592                "l3_redirect" => crate::injection::InjectionLevel::L3Redirect,
593                "l4_hard_stop" => crate::injection::InjectionLevel::L4HardStop,
594                _ => crate::injection::InjectionLevel::L1Nudge,
595            };
596            let redirect_target = args.named("redirect_target").and_then(|v| {
597                if let Value::Str(s) = v {
598                    Some(s.clone())
599                } else {
600                    None
601                }
602            });
603            let reg = ctx.flow_registry.clone().ok_or_else(|| {
604                RuntimeError::ToolFailed("flow.interject: no agent registry".into())
605            })?;
606            let entry = reg.lookup(&handle)?;
607            let inj = crate::injection::Injection::with_level(
608                crate::event::TurnId::now(),
609                text,
610                level,
611                redirect_target,
612            );
613            entry.pending_injections.lock().unwrap().push(inj);
614            entry.injection_notify.notify_one();
615            Ok(Value::Unit)
616        })
617    }
618}
619
620fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
621    let value = match args.named(name) {
622        Some(v) => v,
623        None => args.positional(pos)?,
624    };
625    match value {
626        Value::Str(s) => Ok(s.clone()),
627        other => Err(RuntimeError::TypeMismatch {
628            expected: "string".into(),
629            actual: other.kind_name().into(),
630        }),
631    }
632}
633
634async fn run_flow_agent(
635    flow_ref: &str,
636    args: &ToolArgs,
637    ctx: &ToolCtx,
638    run_id: FlowRunId,
639) -> ToolResult {
640    let Some(registry) = ctx.registry.as_ref() else {
641        return Err(RuntimeError::ToolFailed(
642            "flow.spawn: no tool registry available on ctx".into(),
643        ));
644    };
645    let Some(providers) = ctx.providers.as_ref() else {
646        return Err(RuntimeError::ToolFailed(
647            "flow.spawn: no provider registry available on ctx".into(),
648        ));
649    };
650    let (file_part, flow_name) = match flow_ref.split_once('@') {
651        Some((f, n)) => (f, Some(n.to_string())),
652        None => (flow_ref, None),
653    };
654    let (path, src) = read_flow_source(file_part).await?;
655    let file = atman_dsl::parse::parse_file(&src).map_err(|e| {
656        RuntimeError::ToolFailed(format!("flow.spawn: parse {}: {e}", path.display()))
657    })?;
658    let flow = match &flow_name {
659        Some(name) => file
660            .flows
661            .iter()
662            .find(|f| f.name.name == *name)
663            .ok_or_else(|| {
664                let available: Vec<&str> =
665                    file.flows.iter().map(|f| f.name.name.as_str()).collect();
666                RuntimeError::ToolFailed(format!(
667                    "flow.spawn: flow `{name}` not found in {}. available: {}",
668                    path.display(),
669                    available.join(", ")
670                ))
671            })?,
672        None => file
673            .flows
674            .iter()
675            .find(|f| f.name.name != "describe")
676            .ok_or_else(|| {
677                RuntimeError::ToolFailed(format!(
678                    "flow.spawn: no entry flow in {} (all flows are describe())",
679                    path.display()
680                ))
681            })?,
682    };
683    // Extract flow params from the `arguments` object — generic, matches by
684    // param name, no hardcoded param names.
685    let mut flow_args: Vec<(String, Value)> = Vec::new();
686    if let Some(Value::Struct(fields)) = args.named("arguments") {
687        for (key, value) in fields {
688            if key == "flow" || key == "async" || key == "inherit_context" {
689                continue;
690            }
691            if flow.params.iter().any(|p| p.name.name == *key) {
692                flow_args.push((key.clone(), value.clone()));
693            }
694        }
695    }
696    // Backward compat: top-level named args (pre-arguments schema).
697    for (key, value) in &args.named {
698        if key == "flow" || key == "async" || key == "inherit_context" || key == "arguments" {
699            continue;
700        }
701        if flow.params.iter().any(|p| p.name.name == *key)
702            && !flow_args.iter().any(|(k, _)| k == key)
703        {
704            flow_args.push((key.clone(), value.clone()));
705        }
706    }
707    let flows = file
708        .flows
709        .iter()
710        .map(|flow| (flow.name.name.clone(), flow.clone()))
711        .collect();
712    emit_flow_agent_start(ctx, &run_id, &flow.name.name);
713    let mut child_ctx = sanitize_child_ctx(ctx);
714    // One message segment per FlowRun: async shares entry.messages, sync gets
715    // a fresh ephemeral handle.
716    let inherit = args
717        .named("inherit_context")
718        .map(|v| matches!(v, Value::Bool(true)))
719        .unwrap_or(false);
720    child_ctx.session_messages_handle = match &ctx.agent_entry {
721        Some(entry) => Some(std::sync::Arc::clone(&entry.messages)),
722        None => {
723            let handle: std::sync::Arc<std::sync::Mutex<Vec<_>>> =
724                std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
725            if inherit {
726                if let Some(parent) = &ctx.session_messages_handle {
727                    *handle.lock().unwrap() = parent.lock().unwrap().clone();
728                }
729            }
730            Some(handle)
731        }
732    };
733    // Sync path has no entry; fresh compact_lock avoids deadlocking on the
734    // parent's lock.
735    if ctx.agent_entry.is_none() {
736        child_ctx.compact_lock_handle = Some(std::sync::Arc::new(tokio::sync::Mutex::new(())));
737    }
738    let out = crate::exec::exec_flow_with_siblings(
739        flow,
740        flow_args,
741        registry.as_ref(),
742        &child_ctx,
743        providers.as_ref(),
744        &flows,
745        child_ctx.events.as_ref(),
746        child_ctx.turn_id.clone(),
747        Some(run_id.clone()),
748        None,
749        child_ctx.cancel.clone(),
750        None,
751        path.parent().map(|p| p.to_path_buf()),
752    )
753    .await;
754    let status = match &out {
755        Ok(_) => FlowStatus::Ok,
756        Err(e) => FlowStatus::Errored {
757            message: e.to_string(),
758        },
759    };
760    emit_child_flow_end(ctx, &run_id, &status);
761    out
762}
763
764fn extract_flow(args: &ToolArgs) -> Result<Option<String>, RuntimeError> {
765    match args.named("flow") {
766        Some(Value::Str(s)) if !s.trim().is_empty() => Ok(Some(s.clone())),
767        Some(Value::Unit) | None => Ok(None),
768        Some(other) => Err(RuntimeError::TypeMismatch {
769            expected: "flow string".into(),
770            actual: other.kind_name().into(),
771        }),
772    }
773}
774
775async fn read_flow_source(flow_ref: &str) -> Result<(PathBuf, String), RuntimeError> {
776    for path in flow_candidates(flow_ref) {
777        match tokio::fs::read_to_string(&path).await {
778            Ok(src) => return Ok((path, src)),
779            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
780            Err(e) => {
781                return Err(RuntimeError::ToolFailed(format!(
782                    "flow.spawn: read {}: {e}",
783                    path.display()
784                )));
785            }
786        }
787    }
788    Err(RuntimeError::ToolFailed(format!(
789        "flow.spawn: flow `{flow_ref}` not found"
790    )))
791}
792
793fn flow_candidates(flow_ref: &str) -> Vec<PathBuf> {
794    let path = PathBuf::from(flow_ref);
795    if path.is_absolute() {
796        return vec![path];
797    }
798    let file_name = if flow_ref.ends_with(".at") {
799        flow_ref.to_string()
800    } else {
801        format!("{flow_ref}.at")
802    };
803    let mut out = Vec::new();
804    if let Some(home) = std::env::var_os("HOME") {
805        out.push(
806            PathBuf::from(home)
807                .join(".config")
808                .join("atman")
809                .join("commands")
810                .join(&file_name),
811        );
812    }
813    out.push(PathBuf::from(file_name));
814    out
815}
816
817fn emit_flow_agent_start(ctx: &ToolCtx, run_id: &FlowRunId, flow_name: &str) {
818    let parent_run_id = ctx.flow_run_id.clone();
819    let parent_node_id = ctx.current_node_id.clone();
820    if let Some(sink) = &ctx.events {
821        sink.emit(Event::FlowStart {
822            run_id: run_id.clone(),
823            flow_name: flow_name.into(),
824            parent_run_id: parent_run_id.clone(),
825            parent_node_id: parent_node_id.clone(),
826            spawned: true,
827        });
828    }
829    if let Some(tx) = &ctx.stream_tx {
830        let _ = tx.send(crate::stream::StreamFrame::FlowStart {
831            run_id: run_id.0.to_string(),
832            flow_name: flow_name.into(),
833            parent_run_id: parent_run_id.as_ref().map(|r| r.0.to_string()),
834            parent_node_id,
835        });
836    }
837}
838
839fn emit_child_flow_end(ctx: &ToolCtx, run_id: &FlowRunId, status: &FlowStatus) {
840    if let Some(sink) = &ctx.events {
841        sink.emit(Event::FlowEnd {
842            run_id: run_id.clone(),
843            flow_name: "agent.sub".into(),
844            status: status.clone(),
845        });
846    }
847    if let Some(tx) = &ctx.stream_tx {
848        let _ = tx.send(crate::stream::StreamFrame::FlowDone {
849            run_id: run_id.0.to_string(),
850            flow_name: "agent.sub".into(),
851            ok: matches!(status, FlowStatus::Ok),
852            cancelled: false,
853        });
854    }
855}
856
857fn sanitize_child_ctx(parent: &ToolCtx) -> ToolCtx {
858    let mut c = parent.clone();
859    c.session_runtime = None;
860    c.history_segment = crate::tool::HistorySegment::Spawned;
861    c.session_messages_handle = None;
862    c.compact_lock_handle = None;
863    c.forms = None;
864    c.on_memory_recent = None;
865    c
866}