Skip to main content

atman_runtime/tools/
agent_ctrl.rs

1use crate::error::RuntimeError;
2use crate::event::{Event, FlowRunId, FlowStatus};
3use crate::git_workspace::{
4    WorkspaceBinding, WorkspaceFinalizeOutcome, WorkspacePolicy, WorkspaceState,
5};
6use crate::message::Message;
7use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
8use crate::value::Value;
9use std::path::PathBuf;
10use std::sync::{Arc, Mutex};
11use std::time::{Duration, Instant};
12
13const SPAWN_PERMIT_TTL: Duration = Duration::from_secs(300);
14
15pub struct AgentSpawn;
16
17#[derive(Debug, Clone)]
18pub enum FlowRunStatus {
19    Running {
20        started_at: chrono::DateTime<chrono::Utc>,
21    },
22    Ok {
23        ended_at: chrono::DateTime<chrono::Utc>,
24        final_text: String,
25    },
26    Err {
27        ended_at: chrono::DateTime<chrono::Utc>,
28        message: String,
29    },
30    Killed {
31        ended_at: chrono::DateTime<chrono::Utc>,
32    },
33}
34
35impl FlowRunStatus {
36    pub fn is_running(&self) -> bool {
37        matches!(self, Self::Running { .. })
38    }
39
40    pub fn kind_str(&self) -> &'static str {
41        match self {
42            Self::Running { .. } => "running",
43            Self::Ok { .. } => "ok",
44            Self::Err { .. } => "err",
45            Self::Killed { .. } => "killed",
46        }
47    }
48}
49
50#[derive(Debug, Clone)]
51pub enum FlowEvent {
52    AssistantDone { text: String },
53    Exited { status: FlowRunStatus },
54}
55
56pub struct FlowEntry {
57    pub handle: String,
58    pub goal: String,
59    pub display_label: String,
60    pub status: Arc<Mutex<FlowRunStatus>>,
61    pub output: Arc<Mutex<String>>,
62    pub cancel: tokio_util::sync::CancellationToken,
63    pub stream_tx: tokio::sync::broadcast::Sender<FlowEvent>,
64    pub messages: Arc<Mutex<Vec<Message>>>,
65    pub iteration: Arc<std::sync::atomic::AtomicU64>,
66    pub child_run_id: FlowRunId,
67    pub model: String,
68    pub started_at: chrono::DateTime<chrono::Utc>,
69    pub compact_lock: Arc<tokio::sync::Mutex<()>>,
70    pub interjection_tx: tokio::sync::broadcast::Sender<crate::injection::Injection>,
71    pub pending_injections: Arc<std::sync::Mutex<Vec<crate::injection::Injection>>>,
72    pub injection_notify: Arc<tokio::sync::Notify>,
73    pub frame_tx: tokio::sync::broadcast::Sender<crate::stream::StreamFrame>,
74    pub workspace: Option<WorkspaceBinding>,
75    pub workspace_state: Arc<Mutex<Option<WorkspaceState>>>,
76    pub cleanup_error: Arc<Mutex<Option<String>>>,
77}
78
79impl crate::watch::Watchable for FlowEntry {
80    fn watch_output(
81        self: Arc<Self>,
82        pattern: String,
83        cancel: tokio_util::sync::CancellationToken,
84    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::watch::WatchResult> + Send>>
85    {
86        let stream_tx = self.stream_tx.clone();
87        let output = self.output.clone();
88        let status = self.status.clone();
89        Box::pin(async move {
90            {
91                let existing = output.lock().unwrap().clone();
92                if existing.find(&pattern).is_some() {
93                    return crate::watch::WatchResult::Matched {
94                        row: None,
95                        col: None,
96                        text: existing,
97                    };
98                }
99            }
100            // Subscribe BEFORE checking status — if the source exits between
101            // subscribe and the status check, the broadcast event arrives via rx.
102            // If it exits before subscribe, the status check catches it.
103            let mut rx = stream_tx.subscribe();
104            {
105                let st = status.lock().unwrap().clone();
106                if !st.is_running() {
107                    if let FlowRunStatus::Ok { final_text, .. } = &st {
108                        if final_text.find(&pattern).is_some() {
109                            return crate::watch::WatchResult::Matched {
110                                row: None,
111                                col: None,
112                                text: final_text.clone(),
113                            };
114                        }
115                    }
116                    return crate::watch::WatchResult::SourceExited;
117                }
118            }
119            loop {
120                tokio::select! {
121                    _ = cancel.cancelled() => return crate::watch::WatchResult::Cancelled,
122                    result = rx.recv() => match result {
123                        Ok(FlowEvent::AssistantDone { text }) => {
124                            if text.find(&pattern).is_some() {
125                                return crate::watch::WatchResult::Matched {
126                                    row: None,
127                                    col: None,
128                                    text,
129                                };
130                            }
131                        }
132                        Ok(FlowEvent::Exited { status }) => {
133                            if let FlowRunStatus::Ok { final_text, .. } = &status {
134                                if final_text.find(&pattern).is_some() {
135                                    return crate::watch::WatchResult::Matched {
136                                        row: None,
137                                        col: None,
138                                        text: final_text.clone(),
139                                    };
140                                }
141                            }
142                            return crate::watch::WatchResult::SourceExited;
143                        }
144                        Err(_) => return crate::watch::WatchResult::SourceExited,
145                    }
146                }
147            }
148        })
149    }
150}
151
152/// Observes flow terminal transitions while the lifecycle arbitration is held, so
153/// subsystems keyed by run liveness can be updated inside the same linearization point.
154pub(crate) trait FlowTerminalObserver: Send + Sync {
155    fn flow_became_terminal(
156        &self,
157        session_id: &str,
158        run_id: &FlowRunId,
159    ) -> Option<Box<dyn FnOnce() + Send>>;
160}
161
162#[derive(Default)]
163pub struct FlowRegistry {
164    entries: Mutex<std::collections::HashMap<String, Arc<FlowEntry>>>,
165    runs: Mutex<std::collections::HashMap<FlowRunId, Arc<crate::flow_authority::FlowIdentity>>>,
166    /// Serializes every identity/execution-state transition. Held around observer
167    /// notification so a run cannot go terminal between a liveness check and a
168    /// decision commit in another subsystem. Lock order: lifecycle -> runs -> identity.
169    lifecycle: Mutex<()>,
170    terminal_observers: Mutex<Vec<std::sync::Weak<dyn FlowTerminalObserver>>>,
171    spawn_admission: Mutex<SpawnAdmission>,
172}
173
174#[derive(Default)]
175struct SpawnAdmission {
176    revisions: std::collections::HashMap<String, u64>,
177    permits: std::collections::HashMap<String, SpawnPermit>,
178}
179
180struct SpawnPermit {
181    session_id: String,
182    parent_run_id: FlowRunId,
183    revision: u64,
184    expires_at: Instant,
185}
186
187#[derive(Debug, Clone)]
188pub struct FlowInstanceInfo {
189    pub handle: String,
190    pub goal: String,
191    pub model: String,
192    pub status: String,
193    pub child_run_id: FlowRunId,
194    pub started_at: chrono::DateTime<chrono::Utc>,
195}
196
197pub struct DescendantBlockGuard {
198    registry: Arc<FlowRegistry>,
199    parent_run_id: FlowRunId,
200    child_run_id: FlowRunId,
201}
202
203pub struct FlowLifecycleGuard {
204    registry: Arc<FlowRegistry>,
205    run_id: FlowRunId,
206}
207
208impl Drop for DescendantBlockGuard {
209    fn drop(&mut self) {
210        self.registry
211            .unblock_descendant(&self.parent_run_id, &self.child_run_id);
212    }
213}
214
215impl Drop for FlowLifecycleGuard {
216    fn drop(&mut self) {
217        self.registry.mark_terminal(&self.run_id);
218    }
219}
220
221impl FlowRegistry {
222    pub fn new() -> Self {
223        Self::default()
224    }
225
226    fn bump_spawn_inventory(&self, session_id: &str) {
227        let mut admission = self.spawn_admission.lock().unwrap();
228        let revision = admission
229            .revisions
230            .entry(session_id.to_owned())
231            .or_default();
232        *revision = revision.wrapping_add(1);
233        admission
234            .permits
235            .retain(|_, permit| permit.session_id != session_id);
236    }
237
238    pub fn issue_spawn_permit(&self, identity: &crate::flow_authority::FlowIdentity) -> String {
239        let mut admission = self.spawn_admission.lock().unwrap();
240        Self::issue_spawn_permit_locked(&mut admission, identity)
241    }
242
243    fn issue_spawn_permit_locked(
244        admission: &mut SpawnAdmission,
245        identity: &crate::flow_authority::FlowIdentity,
246    ) -> String {
247        let now = Instant::now();
248        admission
249            .permits
250            .retain(|_, permit| permit.expires_at > now);
251        let revision = admission
252            .revisions
253            .get(&identity.session_id)
254            .copied()
255            .unwrap_or_default();
256        let token = format!("spawn_{}", uuid::Uuid::now_v7().simple());
257        admission.permits.insert(
258            token.clone(),
259            SpawnPermit {
260                session_id: identity.session_id.clone(),
261                parent_run_id: identity.run_id.clone(),
262                revision,
263                expires_at: now + SPAWN_PERMIT_TTL,
264            },
265        );
266        token
267    }
268
269    pub fn inspect_for_spawn(
270        &self,
271        identity: &crate::flow_authority::FlowIdentity,
272    ) -> (Vec<FlowInstanceInfo>, String) {
273        let mut admission = self.spawn_admission.lock().unwrap();
274        let instances = self.instances_for_session(&identity.session_id);
275        let token = Self::issue_spawn_permit_locked(&mut admission, identity);
276        (instances, token)
277    }
278
279    pub fn consume_spawn_permit(
280        &self,
281        token: &str,
282        identity: &crate::flow_authority::FlowIdentity,
283    ) -> Result<(), RuntimeError> {
284        let mut admission = self.spawn_admission.lock().unwrap();
285        let Some(permit) = admission.permits.remove(token) else {
286            return Err(RuntimeError::ToolFailed(
287                "flow.spawn: missing, expired, or already used spawn_token; call flow.instances, inspect existing flows, reuse suitable work, and kill obsolete flows before spawning"
288                    .into(),
289            ));
290        };
291        let current_revision = admission
292            .revisions
293            .get(&identity.session_id)
294            .copied()
295            .unwrap_or_default();
296        if permit.expires_at <= Instant::now()
297            || permit.session_id != identity.session_id
298            || permit.parent_run_id != identity.run_id
299        {
300            return Err(RuntimeError::ToolFailed(
301                "flow.spawn: spawn_token does not belong to this caller or has expired; call flow.instances again"
302                    .into(),
303            ));
304        }
305        if permit.revision != current_revision {
306            return Err(RuntimeError::ToolFailed(
307                "flow.spawn: flow inventory changed after inspection; call flow.instances again before spawning"
308                    .into(),
309            ));
310        }
311        let revision = admission
312            .revisions
313            .entry(identity.session_id.clone())
314            .or_default();
315        *revision = revision.wrapping_add(1);
316        admission
317            .permits
318            .retain(|_, permit| permit.session_id != identity.session_id);
319        Ok(())
320    }
321
322    pub fn instances_for_session(&self, session_id: &str) -> Vec<FlowInstanceInfo> {
323        let runs = self.runs.lock().unwrap();
324        let entries = self.entries.lock().unwrap();
325        let mut instances = entries
326            .values()
327            .filter(|entry| {
328                runs.get(&entry.child_run_id)
329                    .is_some_and(|identity| identity.session_id == session_id)
330            })
331            .map(|entry| FlowInstanceInfo {
332                handle: entry.handle.clone(),
333                goal: entry.display_label.clone(),
334                model: entry.model.clone(),
335                status: entry.status.lock().unwrap().kind_str().to_owned(),
336                child_run_id: entry.child_run_id.clone(),
337                started_at: entry.started_at,
338            })
339            .collect::<Vec<_>>();
340        instances.sort_by_key(|entry| entry.started_at);
341        instances
342    }
343
344    /// Runs `f` under the lifecycle arbitration. Callers must not already hold it,
345    /// and must not acquire it again from inside `f`.
346    pub(crate) fn with_lifecycle_arbitration<T>(&self, f: impl FnOnce() -> T) -> T {
347        let _lifecycle = self.lifecycle.lock().unwrap_or_else(|e| e.into_inner());
348        f()
349    }
350
351    pub(crate) fn register_terminal_observer(
352        &self,
353        observer: std::sync::Weak<dyn FlowTerminalObserver>,
354    ) {
355        let mut observers = self.terminal_observers.lock().unwrap();
356        observers.retain(|existing| existing.strong_count() > 0);
357        observers.push(observer);
358    }
359
360    pub fn register_root(
361        &self,
362        session_id: String,
363        run_id: FlowRunId,
364        authority: crate::flow_authority::EffectiveAuthority,
365    ) -> Result<Arc<crate::flow_authority::FlowIdentity>, RuntimeError> {
366        self.with_lifecycle_arbitration(|| self.register_root_locked(session_id, run_id, authority))
367    }
368
369    fn register_root_locked(
370        &self,
371        session_id: String,
372        run_id: FlowRunId,
373        authority: crate::flow_authority::EffectiveAuthority,
374    ) -> Result<Arc<crate::flow_authority::FlowIdentity>, RuntimeError> {
375        let identity = Arc::new(crate::flow_authority::FlowIdentity {
376            session_id,
377            run_id: run_id.clone(),
378            parent_run_id: None,
379            root_run_id: run_id.clone(),
380            invocation: crate::flow_authority::InvocationKind::Root,
381            effective_authority: authority,
382            execution_state: Mutex::new(crate::flow_authority::FlowExecutionState::Running),
383        });
384        let mut runs = self.runs.lock().unwrap();
385        if runs.contains_key(&run_id) {
386            return Err(RuntimeError::ToolFailed(format!(
387                "flow identity '{run_id}' is already registered"
388            )));
389        }
390        runs.insert(run_id, Arc::clone(&identity));
391        Ok(identity)
392    }
393
394    pub fn register_child(
395        &self,
396        parent_run_id: &FlowRunId,
397        child_run_id: FlowRunId,
398        invocation: crate::flow_authority::InvocationKind,
399        contract_allows_shell: bool,
400        workspace: crate::flow_authority::ChildWorkspaceAuthority,
401    ) -> Result<Arc<crate::flow_authority::FlowIdentity>, RuntimeError> {
402        self.with_lifecycle_arbitration(|| {
403            self.register_child_locked(
404                parent_run_id,
405                child_run_id,
406                invocation,
407                contract_allows_shell,
408                workspace,
409            )
410        })
411    }
412
413    fn register_child_locked(
414        &self,
415        parent_run_id: &FlowRunId,
416        child_run_id: FlowRunId,
417        invocation: crate::flow_authority::InvocationKind,
418        contract_allows_shell: bool,
419        workspace: crate::flow_authority::ChildWorkspaceAuthority,
420    ) -> Result<Arc<crate::flow_authority::FlowIdentity>, RuntimeError> {
421        if invocation == crate::flow_authority::InvocationKind::Root {
422            return Err(RuntimeError::ToolFailed(
423                "child flow identity cannot use root invocation".into(),
424            ));
425        }
426        let mut runs = self.runs.lock().unwrap();
427        if runs.contains_key(&child_run_id) {
428            return Err(RuntimeError::ToolFailed(format!(
429                "flow identity '{child_run_id}' is already registered"
430            )));
431        }
432        let parent = runs.get(parent_run_id).cloned().ok_or_else(|| {
433            RuntimeError::ToolFailed(format!(
434                "parent flow identity '{parent_run_id}' is not registered"
435            ))
436        })?;
437        if matches!(
438            parent.execution_state(),
439            crate::flow_authority::FlowExecutionState::Terminal
440        ) {
441            return Err(RuntimeError::ToolFailed(format!(
442                "parent flow identity '{parent_run_id}' is terminal"
443            )));
444        }
445        let identity = Arc::new(crate::flow_authority::FlowIdentity {
446            session_id: parent.session_id.clone(),
447            run_id: child_run_id.clone(),
448            parent_run_id: Some(parent_run_id.clone()),
449            root_run_id: parent.root_run_id.clone(),
450            invocation,
451            effective_authority: parent
452                .effective_authority
453                .inherited_child(contract_allows_shell, workspace)
454                .map_err(|error| RuntimeError::ToolFailed(format!("child authority: {error}")))?,
455            execution_state: Mutex::new(crate::flow_authority::FlowExecutionState::Running),
456        });
457        runs.insert(child_run_id, Arc::clone(&identity));
458        Ok(identity)
459    }
460
461    pub fn lookup_run(
462        &self,
463        run_id: &FlowRunId,
464    ) -> Option<Arc<crate::flow_authority::FlowIdentity>> {
465        self.runs.lock().unwrap().get(run_id).cloned()
466    }
467
468    pub fn is_strict_ancestor(&self, ancestor: &FlowRunId, descendant: &FlowRunId) -> bool {
469        if ancestor == descendant {
470            return false;
471        }
472        let runs = self.runs.lock().unwrap();
473        let Some(ancestor_identity) = runs.get(ancestor) else {
474            return false;
475        };
476        let Some(mut current) = runs.get(descendant).cloned() else {
477            return false;
478        };
479        if ancestor_identity.session_id != current.session_id {
480            return false;
481        }
482        let mut visited = std::collections::HashSet::new();
483        while let Some(parent_run_id) = current.parent_run_id.as_ref() {
484            if !visited.insert(current.run_id.clone()) {
485                return false;
486            }
487            if parent_run_id == ancestor {
488                return true;
489            }
490            let Some(parent) = runs.get(parent_run_id) else {
491                return false;
492            };
493            if parent.session_id != ancestor_identity.session_id {
494                return false;
495            }
496            current = Arc::clone(parent);
497        }
498        false
499    }
500
501    pub fn strict_ancestors(
502        &self,
503        run_id: &FlowRunId,
504    ) -> Vec<Arc<crate::flow_authority::FlowIdentity>> {
505        let runs = self.runs.lock().unwrap();
506        let Some(start) = runs.get(run_id) else {
507            return Vec::new();
508        };
509        let session_id = start.session_id.clone();
510        let mut current = Arc::clone(start);
511        let mut ancestors = Vec::new();
512        let mut visited = std::collections::HashSet::new();
513        while let Some(parent_run_id) = current.parent_run_id.as_ref() {
514            if !visited.insert(current.run_id.clone()) {
515                return Vec::new();
516            }
517            let Some(parent) = runs.get(parent_run_id) else {
518                return Vec::new();
519            };
520            if parent.session_id != session_id {
521                return Vec::new();
522            }
523            ancestors.push(Arc::clone(parent));
524            current = Arc::clone(parent);
525        }
526        ancestors
527    }
528
529    pub fn execution_state(
530        &self,
531        run_id: &FlowRunId,
532    ) -> Option<crate::flow_authority::FlowExecutionState> {
533        self.lookup_run(run_id)
534            .map(|identity| identity.execution_state())
535    }
536
537    pub fn mark_terminal(&self, run_id: &FlowRunId) {
538        let completions = self.with_lifecycle_arbitration(|| self.mark_terminal_locked(run_id));
539        for completion in completions {
540            completion();
541        }
542    }
543
544    fn mark_terminal_locked(&self, run_id: &FlowRunId) -> Vec<Box<dyn FnOnce() + Send>> {
545        let Some(identity) = self.lookup_run(run_id) else {
546            return Vec::new();
547        };
548        *identity.execution_state.lock().unwrap() =
549            crate::flow_authority::FlowExecutionState::Terminal;
550        self.bump_spawn_inventory(&identity.session_id);
551        let observers: Vec<_> = {
552            let mut observers = self.terminal_observers.lock().unwrap();
553            observers.retain(|existing| existing.strong_count() > 0);
554            observers
555                .iter()
556                .filter_map(std::sync::Weak::upgrade)
557                .collect()
558        };
559        observers
560            .into_iter()
561            .filter_map(|observer| observer.flow_became_terminal(&identity.session_id, run_id))
562            .collect()
563    }
564
565    pub fn lifecycle_guard(self: &Arc<Self>, run_id: &FlowRunId) -> FlowLifecycleGuard {
566        FlowLifecycleGuard {
567            registry: Arc::clone(self),
568            run_id: run_id.clone(),
569        }
570    }
571
572    pub fn block_on_descendant(
573        self: &Arc<Self>,
574        parent_run_id: &FlowRunId,
575        child_run_id: &FlowRunId,
576    ) -> Result<DescendantBlockGuard, RuntimeError> {
577        self.with_lifecycle_arbitration(|| {
578            self.block_on_descendant_locked(parent_run_id, child_run_id)
579        })
580    }
581
582    fn block_on_descendant_locked(
583        self: &Arc<Self>,
584        parent_run_id: &FlowRunId,
585        child_run_id: &FlowRunId,
586    ) -> Result<DescendantBlockGuard, RuntimeError> {
587        if !self.is_strict_ancestor(parent_run_id, child_run_id) {
588            return Err(RuntimeError::ToolFailed(format!(
589                "flow '{parent_run_id}' is not a strict ancestor of '{child_run_id}'"
590            )));
591        }
592        let parent = self.lookup_run(parent_run_id).ok_or_else(|| {
593            RuntimeError::ToolFailed(format!("flow identity '{parent_run_id}' is not registered"))
594        })?;
595        let mut state = parent.execution_state.lock().unwrap();
596        match &mut *state {
597            crate::flow_authority::FlowExecutionState::Running => {
598                *state = crate::flow_authority::FlowExecutionState::BlockedOnDescendants {
599                    child_run_counts: std::collections::HashMap::from([(child_run_id.clone(), 1)]),
600                };
601            }
602            crate::flow_authority::FlowExecutionState::BlockedOnDescendants {
603                child_run_counts,
604            } => {
605                *child_run_counts.entry(child_run_id.clone()).or_default() += 1;
606            }
607            crate::flow_authority::FlowExecutionState::Terminal => {
608                return Err(RuntimeError::ToolFailed(format!(
609                    "flow identity '{parent_run_id}' is terminal"
610                )));
611            }
612        }
613        drop(state);
614        Ok(DescendantBlockGuard {
615            registry: Arc::clone(self),
616            parent_run_id: parent_run_id.clone(),
617            child_run_id: child_run_id.clone(),
618        })
619    }
620
621    fn unblock_descendant(&self, parent_run_id: &FlowRunId, child_run_id: &FlowRunId) {
622        self.with_lifecycle_arbitration(|| {
623            self.unblock_descendant_locked(parent_run_id, child_run_id)
624        });
625    }
626
627    fn unblock_descendant_locked(&self, parent_run_id: &FlowRunId, child_run_id: &FlowRunId) {
628        let Some(parent) = self.lookup_run(parent_run_id) else {
629            return;
630        };
631        let mut state = parent.execution_state.lock().unwrap();
632        let crate::flow_authority::FlowExecutionState::BlockedOnDescendants { child_run_counts } =
633            &mut *state
634        else {
635            return;
636        };
637        if let Some(count) = child_run_counts.get_mut(child_run_id) {
638            *count -= 1;
639            if *count == 0 {
640                child_run_counts.remove(child_run_id);
641            }
642        }
643        if child_run_counts.is_empty() {
644            *state = crate::flow_authority::FlowExecutionState::Running;
645        }
646    }
647
648    pub fn create_entry(
649        &self,
650        handle: String,
651        goal: String,
652        model: String,
653        child_run_id: FlowRunId,
654    ) -> Arc<FlowEntry> {
655        self.create_entry_with_workspace(handle, goal, model, child_run_id, None)
656    }
657
658    pub fn create_entry_with_workspace(
659        &self,
660        handle: String,
661        goal: String,
662        model: String,
663        child_run_id: FlowRunId,
664        workspace: Option<WorkspaceBinding>,
665    ) -> Arc<FlowEntry> {
666        let display_label = goal.clone();
667        self.create_entry_with_workspace_label(
668            handle,
669            goal,
670            display_label,
671            model,
672            child_run_id,
673            workspace,
674        )
675    }
676
677    fn create_entry_with_workspace_label(
678        &self,
679        handle: String,
680        goal: String,
681        display_label: String,
682        model: String,
683        child_run_id: FlowRunId,
684        workspace: Option<WorkspaceBinding>,
685    ) -> Arc<FlowEntry> {
686        let (stream_tx, _) = tokio::sync::broadcast::channel(64);
687        let entry = Arc::new(FlowEntry {
688            handle: handle.clone(),
689            goal,
690            display_label,
691            status: Arc::new(Mutex::new(FlowRunStatus::Running {
692                started_at: chrono::Utc::now(),
693            })),
694            output: Arc::new(Mutex::new(String::new())),
695            cancel: tokio_util::sync::CancellationToken::new(),
696            stream_tx,
697            messages: Arc::new(Mutex::new(Vec::new())),
698            iteration: Arc::new(std::sync::atomic::AtomicU64::new(0)),
699            child_run_id,
700            model,
701            started_at: chrono::Utc::now(),
702            compact_lock: Arc::new(tokio::sync::Mutex::new(())),
703            interjection_tx: tokio::sync::broadcast::channel(32).0,
704            pending_injections: Arc::new(std::sync::Mutex::new(Vec::new())),
705            injection_notify: Arc::new(tokio::sync::Notify::new()),
706            frame_tx: tokio::sync::broadcast::channel(2048).0,
707            workspace_state: Arc::new(Mutex::new(
708                workspace.as_ref().map(|_| WorkspaceState::Active),
709            )),
710            workspace,
711            cleanup_error: Arc::new(Mutex::new(None)),
712        });
713        self.entries
714            .lock()
715            .unwrap()
716            .insert(handle, Arc::clone(&entry));
717        if let Some(identity) = self.lookup_run(&entry.child_run_id) {
718            self.bump_spawn_inventory(&identity.session_id);
719        }
720        entry
721    }
722
723    pub fn lookup(&self, handle: &str) -> Result<Arc<FlowEntry>, RuntimeError> {
724        self.entries
725            .lock()
726            .unwrap()
727            .get(handle)
728            .map(Arc::clone)
729            .ok_or_else(|| RuntimeError::ToolFailed(format!("agent: handle '{handle}' not found")))
730    }
731
732    pub fn remove(&self, handle: &str) {
733        let removed = self.entries.lock().unwrap().remove(handle);
734        if let Some(entry) = removed
735            && let Some(identity) = self.lookup_run(&entry.child_run_id)
736        {
737            self.bump_spawn_inventory(&identity.session_id);
738        }
739    }
740
741    pub fn is_empty(&self) -> bool {
742        self.entries.lock().unwrap().is_empty()
743    }
744}
745
746impl Tool for AgentSpawn {
747    fn name(&self) -> &str {
748        "flow.spawn"
749    }
750
751    fn tier(&self) -> Tier {
752        Tier::Two
753    }
754
755    fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
756        ApprovalLevel::Approve
757    }
758
759    fn description(&self) -> Option<&str> {
760        Some(
761            "Spawn a DSL flow as an independent sub-agent with its own message history and \
762             iteration counter. Put target flow parameters in `arguments`. Use flow.search for \
763             bounded discovery and flow.describe for the selected parameter contract.\n\n\
764             Flow reference syntax: `file@flow_name`\n\
765             - \"subagent.at@subagent\" — run the `subagent` flow in subagent.at\n\
766             - \"subagent@research_loop\" — .at suffix optional\n\
767             - \"subagent.at\" — no @, takes first non-describe flow\n\
768             - \"/abs/path/my.at@main\" — absolute path\n\n\
769             Default flow is `subagent.at` (research/verify/implement/review roles). \
770             Required: `flow` and a single-use `spawn_token` from flow.instances. `version` rejects source changes after discovery. `async` is optional (default true). \
771             Target flow parameters must be passed through `arguments`. \
772             A flow may declare `contract { invocation { user_message: param } }` to seed its child message context. \
773             Use flow.status/flow.output/flow.kill to manage async sub-agents by handle. \
774             Pass only parameters declared by flow.describe. Missing params use flow-defined defaults.",
775        )
776    }
777
778    fn input_schema(&self) -> serde_json::Value {
779        serde_json::json!({
780            "type": "object",
781            "properties": {
782                "flow": {"type": "string", "description": "Flow reference (e.g. \"subagent.at@subagent\")."},
783                "spawn_token": {"type": "string", "minLength": 1, "description": "Single-use admission token returned by the latest flow.instances call. Inspect and clean up existing flow instances before spawning."},
784                "version": {"type": "string", "minLength": 1, "description": "Staleness guard. Pass the non-empty source fingerprint returned by the current flow.search or flow.describe result verbatim. Omit this field when no fingerprint is available; never send an empty or invented value."},
785                "arguments": {
786                    "type": "object",
787                    "additionalProperties": true,
788                    "description": "Target flow parameters as key-value pairs. Use flow.search then flow.describe when the parameter contract is unknown. Example: arguments={\"goal\":\"read Cargo.toml\",\"role\":\"research\"}"
789                },
790                "async": {"type": "boolean", "default": true, "description": "If true (default), run in background and return a handle. If false, block until done."},
791                "inherit_context": {"type": "boolean", "default": false, "description": "If true, seed the sub-agent's context with the parent snapshot through its last complete tool transaction. Every child also receives an explicit handoff.parent context record."},
792                "workspace": {"type": "string", "enum": ["none", "auto", "retain"], "default": "none", "description": "Workspace policy for the child flow."}
793            },
794            "required": ["flow", "spawn_token"],
795            "additionalProperties": false
796        })
797    }
798
799    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
800        Box::pin(async move {
801            let token = extract_spawn_token(&args)?;
802            let flow_registry = ctx.flow_registry.as_ref().ok_or_else(|| {
803                RuntimeError::ToolFailed("flow.spawn: trusted flow registry is unavailable".into())
804            })?;
805            let identity = ctx.flow_identity.as_ref().ok_or_else(|| {
806                RuntimeError::ToolFailed(
807                    "flow.spawn: trusted caller flow identity is unavailable".into(),
808                )
809            })?;
810            flow_registry.consume_spawn_permit(&token, identity)?;
811            let is_async = args
812                .named("async")
813                .and_then(|v| {
814                    if let Value::Bool(b) = v {
815                        Some(*b)
816                    } else {
817                        None
818                    }
819                })
820                .unwrap_or(true);
821            if is_async {
822                run_sub_agent_async(args, ctx).await
823            } else {
824                run_sub_agent(args, ctx).await
825            }
826        })
827    }
828}
829
830fn workspace_policy(args: &ToolArgs) -> Result<WorkspacePolicy, RuntimeError> {
831    match args.named("workspace") {
832        None => Ok(WorkspacePolicy::None),
833        Some(Value::Str(value)) => value.parse().map_err(|error| {
834            RuntimeError::ToolFailed(format!("flow.spawn: invalid workspace policy: {error}"))
835        }),
836        Some(_) => Err(RuntimeError::ToolFailed(
837            "flow.spawn: workspace must be one of none, auto, or retain".into(),
838        )),
839    }
840}
841
842fn workspace_session(
843    ctx: &ToolCtx,
844    policy: WorkspacePolicy,
845) -> Result<Option<String>, RuntimeError> {
846    if policy == WorkspacePolicy::None {
847        return Ok(ctx.session_id.clone().filter(|id| !id.is_empty()));
848    }
849    ctx.session_id
850        .clone()
851        .filter(|id| !id.is_empty())
852        .map(Some)
853        .ok_or_else(|| {
854            RuntimeError::ToolFailed(
855                "flow.spawn: workspace auto/retain requires a non-empty session id".into(),
856            )
857        })
858}
859
860fn allocate_workspace(
861    ctx: &ToolCtx,
862    policy: WorkspacePolicy,
863    session_id: Option<&str>,
864    run_id: &FlowRunId,
865) -> Result<Option<WorkspaceBinding>, RuntimeError> {
866    if policy == WorkspacePolicy::None {
867        return Ok(None);
868    }
869    let service = ctx.flow_workspace_service.as_ref().ok_or_else(|| {
870        RuntimeError::ToolFailed("flow.spawn: workspace service is unavailable".into())
871    })?;
872    service
873        .allocate(
874            policy,
875            session_id.expect("validated managed workspace session"),
876            &run_id.0.to_string(),
877            ctx.workspace.as_ref().map(|binding| binding.path.as_path()),
878        )
879        .map_err(|error| RuntimeError::ToolFailed(format!("flow.spawn: {error}")))
880}
881
882struct WorkspaceFinalizeGuard {
883    ctx: ToolCtx,
884    binding: Option<WorkspaceBinding>,
885    session_id: Option<String>,
886    run_id: FlowRunId,
887    state_projection: Option<Arc<Mutex<Option<WorkspaceState>>>>,
888    error_projection: Option<Arc<Mutex<Option<String>>>>,
889}
890
891impl WorkspaceFinalizeGuard {
892    fn new(
893        ctx: &ToolCtx,
894        binding: Option<WorkspaceBinding>,
895        session_id: Option<String>,
896        run_id: FlowRunId,
897    ) -> Self {
898        Self {
899            ctx: ctx.clone(),
900            binding,
901            session_id,
902            run_id,
903            state_projection: None,
904            error_projection: None,
905        }
906    }
907
908    fn with_projections(
909        mut self,
910        state_projection: Arc<Mutex<Option<WorkspaceState>>>,
911        error_projection: Arc<Mutex<Option<String>>>,
912    ) -> Self {
913        self.state_projection = Some(state_projection);
914        self.error_projection = Some(error_projection);
915        self
916    }
917
918    fn binding(&self) -> Option<&WorkspaceBinding> {
919        self.binding.as_ref()
920    }
921}
922
923impl Drop for WorkspaceFinalizeGuard {
924    fn drop(&mut self) {
925        finalize_workspace(
926            &self.ctx,
927            self.binding.as_ref(),
928            self.session_id.as_deref(),
929            &self.run_id,
930            self.state_projection.as_ref(),
931            self.error_projection.as_ref(),
932        );
933    }
934}
935
936fn finalize_workspace(
937    ctx: &ToolCtx,
938    binding: Option<&WorkspaceBinding>,
939    session_id: Option<&str>,
940    run_id: &FlowRunId,
941    state_projection: Option<&Arc<Mutex<Option<WorkspaceState>>>>,
942    error_projection: Option<&Arc<Mutex<Option<String>>>>,
943) {
944    let Some(binding) = binding else {
945        return;
946    };
947    let service = ctx.flow_workspace_service.as_ref();
948    let result = service
949        .ok_or_else(|| "workspace service is unavailable".to_string())
950        .and_then(|service| {
951            service
952                .finalize(
953                    binding,
954                    session_id.unwrap_or_default(),
955                    &run_id.0.to_string(),
956                )
957                .map_err(|error| error.to_string())
958        });
959    let (state, cleanup_error) = match result {
960        Ok(outcome) => {
961            let record = match outcome {
962                WorkspaceFinalizeOutcome::Released(record)
963                | WorkspaceFinalizeOutcome::Dirty(record)
964                | WorkspaceFinalizeOutcome::Retained(record)
965                | WorkspaceFinalizeOutcome::AlreadyReleased(record) => record,
966            };
967            (record.lifecycle_state(), None)
968        }
969        Err(error) => (
970            service
971                .and_then(|service| service.persisted_state(binding))
972                .unwrap_or_else(|| WorkspaceState::Unknown("unknown".into())),
973            Some(error),
974        ),
975    };
976    if let Some(projection) = state_projection {
977        *projection.lock().unwrap() = Some(state.clone());
978    }
979    if let Some(projection) = error_projection {
980        *projection.lock().unwrap() = cleanup_error.clone();
981    }
982    if let Some(sink) = &ctx.events {
983        sink.emit(Event::WorkspaceLifecycle {
984            run_id: run_id.clone(),
985            workspace_id: binding.workspace_id.clone(),
986            path: binding.path.display().to_string(),
987            state: state.as_str().into(),
988            cleanup_error,
989        });
990    }
991}
992
993struct PreparedFlowAgent {
994    path: PathBuf,
995    flow: atman_dsl::ast::FlowDecl,
996    flows: std::collections::HashMap<String, atman_dsl::ast::FlowDecl>,
997}
998
999async fn prepare_flow_agent(
1000    flow_ref: &str,
1001    expected_version: Option<&str>,
1002    ctx: &ToolCtx,
1003) -> Result<PreparedFlowAgent, RuntimeError> {
1004    let (file_part, flow_name) = match flow_ref.split_once('@') {
1005        Some((file, name)) => (file, Some(name)),
1006        None => (flow_ref, None),
1007    };
1008    let (path, source) = read_flow_source(file_part, ctx).await?;
1009    let actual_version = format!("blake3:{}", blake3::hash(source.as_bytes()).to_hex());
1010    if expected_version.is_some_and(|version| version != actual_version) {
1011        return Err(RuntimeError::ToolFailed(format!(
1012            "flow.spawn: stale version for `{flow_ref}`; search again"
1013        )));
1014    }
1015    let file = atman_dsl::parse::parse_file(&source).map_err(|error| {
1016        RuntimeError::ToolFailed(format!("flow.spawn: parse {}: {error}", path.display()))
1017    })?;
1018    let flow = match flow_name {
1019        Some(name) => file.flows.iter().find(|flow| flow.name.name == name),
1020        None => file.flows.iter().find(|flow| flow.name.name != "describe"),
1021    }
1022    .cloned()
1023    .ok_or_else(|| {
1024        RuntimeError::ToolFailed(format!(
1025            "flow.spawn: target flow not found in {}",
1026            path.display()
1027        ))
1028    })?;
1029    let flows = file
1030        .flows
1031        .into_iter()
1032        .map(|flow| (flow.name.name.clone(), flow))
1033        .collect();
1034    Ok(PreparedFlowAgent { path, flow, flows })
1035}
1036
1037fn register_prepared_identity(
1038    prepared: &PreparedFlowAgent,
1039    ctx: &ToolCtx,
1040    child_run_id: &FlowRunId,
1041    invocation: crate::flow_authority::InvocationKind,
1042    workspace: crate::flow_authority::ChildWorkspaceAuthority,
1043) -> Result<Arc<crate::flow_authority::FlowIdentity>, RuntimeError> {
1044    let registry = ctx.flow_registry.as_ref().ok_or_else(|| {
1045        RuntimeError::ToolFailed("flow.spawn: trusted flow registry is unavailable".into())
1046    })?;
1047    let parent = ctx.flow_identity.as_ref().ok_or_else(|| {
1048        RuntimeError::ToolFailed("flow.spawn: trusted parent flow identity is unavailable".into())
1049    })?;
1050    registry.register_child(
1051        &parent.run_id,
1052        child_run_id.clone(),
1053        invocation,
1054        crate::flow_authority::contract_allows_shell(prepared.flow.contract.as_ref()),
1055        workspace,
1056    )
1057}
1058
1059fn spawned_workspace_authority(
1060    binding: Option<&WorkspaceBinding>,
1061) -> crate::flow_authority::ChildWorkspaceAuthority {
1062    match binding {
1063        Some(binding) => {
1064            crate::flow_authority::ChildWorkspaceAuthority::TrustedDelegation(binding.path.clone())
1065        }
1066        None => crate::flow_authority::ChildWorkspaceAuthority::Inherit,
1067    }
1068}
1069
1070async fn run_sub_agent(args: ToolArgs, ctx: &ToolCtx) -> ToolResult {
1071    let flow = extract_flow(&args)?.unwrap_or_else(|| "subagent.at".to_string());
1072    let version = extract_flow_version(&args)?;
1073    let prepared = prepare_flow_agent(&flow, version.as_deref(), ctx).await?;
1074    let flow_args = resolve_flow_arguments(&prepared.flow, &args)?;
1075    let inherit_context = should_inherit_context(&args);
1076    let run_id = FlowRunId::now();
1077    let policy = workspace_policy(&args)?;
1078    let session_id = workspace_session(ctx, policy)?;
1079    let workspace_guard = WorkspaceFinalizeGuard::new(
1080        ctx,
1081        allocate_workspace(ctx, policy, session_id.as_deref(), &run_id)?,
1082        session_id,
1083        run_id.clone(),
1084    );
1085    let child_identity = register_prepared_identity(
1086        &prepared,
1087        ctx,
1088        &run_id,
1089        crate::flow_authority::InvocationKind::SpawnSync,
1090        spawned_workspace_authority(workspace_guard.binding()),
1091    )?;
1092    let flow_registry = ctx.flow_registry.as_ref().expect("validated flow registry");
1093    let _lifecycle_guard = flow_registry.lifecycle_guard(&run_id);
1094    let parent_run_id = ctx
1095        .flow_identity
1096        .as_ref()
1097        .expect("validated parent flow identity")
1098        .run_id
1099        .clone();
1100    let _block_guard = flow_registry.block_on_descendant(&parent_run_id, &run_id)?;
1101    let mut child_ctx = match workspace_guard.binding().cloned() {
1102        Some(binding) => ctx.clone().with_workspace(binding),
1103        None => ctx.clone(),
1104    };
1105    child_ctx.flow_run_id = Some(run_id.clone());
1106    child_ctx.flow_identity = Some(child_identity);
1107    child_ctx.call_intent = None;
1108    let child_messages = Arc::new(Mutex::new(Vec::new()));
1109    if inherit_context && let Some(parent) = &ctx.session_messages_handle {
1110        *child_messages.lock().unwrap() = inherited_context_snapshot(parent);
1111    }
1112    run_prepared_flow_agent(
1113        prepared,
1114        flow_args,
1115        &child_ctx,
1116        run_id,
1117        child_messages,
1118        Arc::new(tokio::sync::Mutex::new(())),
1119        inherit_context,
1120    )
1121    .await
1122}
1123
1124async fn run_sub_agent_async(args: ToolArgs, ctx: &ToolCtx) -> ToolResult {
1125    // Use the first string-typed argument as a display label for the FlowEntry.
1126    let goal = match args.named("arguments") {
1127        Some(Value::Struct(fields)) => fields.iter().find_map(|(_, value)| match value {
1128            Value::Str(value) => Some(value.clone()),
1129            _ => None,
1130        }),
1131        _ => None,
1132    }
1133    .unwrap_or_default();
1134    let display_label = ctx
1135        .call_intent
1136        .as_ref()
1137        .map(|intent| intent.as_str().to_string())
1138        .unwrap_or_else(|| goal.clone());
1139    let inherit_context = should_inherit_context(&args);
1140    let flow_registry = ctx.flow_registry.clone().ok_or_else(|| {
1141        RuntimeError::ToolFailed("flow.spawn: no agent registry available on ctx".into())
1142    })?;
1143
1144    let flow_ref = extract_flow(&args)?.unwrap_or_else(|| "subagent.at".to_string());
1145    let version = extract_flow_version(&args)?;
1146    let prepared = prepare_flow_agent(&flow_ref, version.as_deref(), ctx).await?;
1147    let flow_args = resolve_flow_arguments(&prepared.flow, &args)?;
1148    let model = flow_args
1149        .iter()
1150        .find_map(|(name, value)| match (name.as_str(), value) {
1151            ("model", Value::Str(model)) => Some(model.clone()),
1152            _ => None,
1153        })
1154        .or_else(|| {
1155            prepared
1156                .flow
1157                .params
1158                .iter()
1159                .find(|parameter| parameter.name.name == "model")
1160                .and_then(|parameter| match parameter.default.as_ref() {
1161                    Some(atman_dsl::ast::Expr::Literal(atman_dsl::ast::Literal::Str(model))) => {
1162                        Some(model.clone())
1163                    }
1164                    _ => None,
1165                })
1166        })
1167        .unwrap_or_default();
1168
1169    let handle = format!("agent_{}", uuid::Uuid::now_v7().simple());
1170    let child_run_id = FlowRunId::now();
1171    let policy = workspace_policy(&args)?;
1172    let session_id = workspace_session(ctx, policy)?;
1173    let mut workspace_guard = WorkspaceFinalizeGuard::new(
1174        ctx,
1175        allocate_workspace(ctx, policy, session_id.as_deref(), &child_run_id)?,
1176        session_id.clone(),
1177        child_run_id.clone(),
1178    );
1179    let child_identity = register_prepared_identity(
1180        &prepared,
1181        ctx,
1182        &child_run_id,
1183        crate::flow_authority::InvocationKind::SpawnAsync,
1184        spawned_workspace_authority(workspace_guard.binding()),
1185    )?;
1186    let lifecycle_guard = flow_registry.lifecycle_guard(&child_run_id);
1187    let workspace = workspace_guard.binding().cloned();
1188    let entry = flow_registry.create_entry_with_workspace_label(
1189        handle.clone(),
1190        goal,
1191        display_label,
1192        model,
1193        child_run_id.clone(),
1194        workspace.clone(),
1195    );
1196    workspace_guard = workspace_guard.with_projections(
1197        Arc::clone(&entry.workspace_state),
1198        Arc::clone(&entry.cleanup_error),
1199    );
1200    if inherit_context {
1201        if let Some(parent) = &ctx.session_messages_handle {
1202            let snapshot = inherited_context_snapshot(parent);
1203            *entry.messages.lock().unwrap() = snapshot;
1204        }
1205    }
1206
1207    let task_registry = ctx.task_registry.clone();
1208    let task_id = task_registry.as_ref().map(|tr| {
1209        tr.register_flow_with_run_id(
1210            entry.display_label.clone(),
1211            handle.clone(),
1212            session_id.clone().unwrap_or_default(),
1213            entry.cancel.clone(),
1214            workspace
1215                .as_ref()
1216                .map(|binding| binding.workspace_id.clone()),
1217            child_run_id.clone(),
1218        )
1219    });
1220
1221    let entry_clone = Arc::clone(&entry);
1222    let ctx_clone = ctx.clone();
1223    let parent_stream_tx = ctx.stream_tx.clone();
1224    let child_run_id_str = child_run_id.0.to_string();
1225    let tool_use_id = ctx.tool_use_id.clone();
1226    tokio::spawn(async move {
1227        let _lifecycle_guard = lifecycle_guard;
1228        let _workspace_guard = workspace_guard;
1229        // Send SubAgentStarted so the TUI creates a SubAgentActivity item and
1230        // registers child_run_id in sub_agent_run_ids for frame routing.
1231        if let Some(tx) = &parent_stream_tx {
1232            let _ = tx.send(crate::stream::StreamFrame::SubAgentStarted {
1233                handle: entry_clone.handle.clone(),
1234                tool_use_id,
1235                goal: entry_clone.display_label.clone(),
1236                child_run_id: child_run_id_str.clone(),
1237                model: entry_clone.model.clone(),
1238            });
1239        }
1240
1241        // Replace ctx.cancel with entry.cancel so flow.kill can actually cancel
1242        // the sub-agent's flow execution. Pass entry into ctx so the DSL runtime
1243        // can write output/messages/iteration synchronously during LLM calls.
1244        // Bind the sub-agent's own compact_lock so it can compact its segment
1245        // independently.
1246        let mut ctx_for_flow = ctx_clone;
1247        ctx_for_flow.cancel = entry_clone.cancel.clone();
1248        ctx_for_flow.call_intent = None;
1249        ctx_for_flow.agent_entry = Some(Arc::clone(&entry_clone));
1250        ctx_for_flow.flow_run_id = Some(child_run_id.clone());
1251        ctx_for_flow.flow_identity = Some(child_identity);
1252        ctx_for_flow.compact_lock_handle = Some(Arc::clone(&entry_clone.compact_lock));
1253        if let Some(binding) = entry_clone.workspace.clone() {
1254            ctx_for_flow = ctx_for_flow.with_workspace(binding);
1255        }
1256
1257        let result = run_prepared_flow_agent(
1258            prepared,
1259            flow_args,
1260            &ctx_for_flow,
1261            child_run_id.clone(),
1262            Arc::clone(&entry_clone.messages),
1263            Arc::clone(&entry_clone.compact_lock),
1264            inherit_context,
1265        )
1266        .await;
1267        let killed = entry_clone.cancel.is_cancelled();
1268        let status = match &result {
1269            _ if killed => FlowRunStatus::Killed {
1270                ended_at: chrono::Utc::now(),
1271            },
1272            Ok(Value::Str(s)) => FlowRunStatus::Ok {
1273                ended_at: chrono::Utc::now(),
1274                final_text: s.clone(),
1275            },
1276            Ok(_) => FlowRunStatus::Ok {
1277                ended_at: chrono::Utc::now(),
1278                final_text: String::new(),
1279            },
1280            Err(e) => FlowRunStatus::Err {
1281                ended_at: chrono::Utc::now(),
1282                message: e.to_string(),
1283            },
1284        };
1285
1286        *entry_clone.status.lock().unwrap() = status.clone();
1287
1288        // Send SubAgentDone so the TUI updates the SubAgentActivity item.
1289        if let Some(tx) = &parent_stream_tx {
1290            let final_text = match &status {
1291                FlowRunStatus::Ok { final_text, .. } => final_text.clone(),
1292                _ => String::new(),
1293            };
1294            let _ = tx.send(crate::stream::StreamFrame::SubAgentDone {
1295                handle: entry_clone.handle.clone(),
1296                status: status.kind_str().to_string(),
1297                final_text,
1298            });
1299        }
1300
1301        let _ = entry_clone.stream_tx.send(FlowEvent::Exited { status });
1302        if let (Some(tr), Some(tid)) = (&task_registry, &task_id) {
1303            let ts = if killed {
1304                crate::task_registry::TaskStatus::Killed
1305            } else {
1306                match result {
1307                    Ok(_) => crate::task_registry::TaskStatus::Ok,
1308                    Err(_) => crate::task_registry::TaskStatus::Err,
1309                }
1310            };
1311            tr.finish(tid, ts);
1312        }
1313    });
1314
1315    let mut fields = vec![
1316        ("handle".into(), Value::Str(handle)),
1317        ("status".into(), Value::Str("running".into())),
1318    ];
1319    if let Some(binding) = workspace {
1320        fields.push(("workspace_id".into(), Value::Str(binding.workspace_id)));
1321        fields.push((
1322            "workspace_path".into(),
1323            Value::Str(binding.path.display().to_string()),
1324        ));
1325        fields.push((
1326            "workspace_state".into(),
1327            Value::Str(WorkspaceState::Active.as_str().into()),
1328        ));
1329    }
1330    Ok(Value::Struct(fields))
1331}
1332
1333pub struct AgentStatus;
1334impl Tool for AgentStatus {
1335    fn name(&self) -> &str {
1336        "flow.status"
1337    }
1338    fn tier(&self) -> Tier {
1339        Tier::Zero
1340    }
1341    fn description(&self) -> Option<&str> {
1342        Some("Check the status of an async sub-agent. Returns handle, status, goal, and timing.")
1343    }
1344    fn input_schema(&self) -> serde_json::Value {
1345        serde_json::json!({
1346            "type": "object",
1347            "properties": {"handle": {"type": "string"}},
1348            "required": ["handle"]
1349        })
1350    }
1351    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1352        Box::pin(async move {
1353            let handle = extract_string(&args, "handle", 0)?;
1354            let reg = ctx
1355                .flow_registry
1356                .clone()
1357                .ok_or_else(|| RuntimeError::ToolFailed("flow.status: no agent registry".into()))?;
1358            let entry = reg.lookup(&handle)?;
1359            let st = entry.status.lock().unwrap().clone();
1360            let goal = entry.goal.clone();
1361            let mut fields = vec![
1362                ("handle".into(), Value::Str(handle)),
1363                ("status".into(), Value::Str(st.kind_str().into())),
1364                ("goal".into(), Value::Str(goal)),
1365            ];
1366            if let FlowRunStatus::Err { message, .. } = &st {
1367                fields.push(("error".into(), Value::Str(message.clone())));
1368            }
1369            if let Some(binding) = &entry.workspace {
1370                fields.push((
1371                    "workspace_id".into(),
1372                    Value::Str(binding.workspace_id.clone()),
1373                ));
1374                fields.push((
1375                    "workspace_path".into(),
1376                    Value::Str(binding.path.display().to_string()),
1377                ));
1378                if let Some(state) = entry.workspace_state.lock().unwrap().clone() {
1379                    fields.push(("workspace_state".into(), Value::Str(state.as_str().into())));
1380                }
1381                if let Some(error) = entry.cleanup_error.lock().unwrap().clone() {
1382                    fields.push(("cleanup_error".into(), Value::Str(error)));
1383                }
1384            }
1385            Ok(Value::Struct(fields))
1386        })
1387    }
1388}
1389
1390pub struct AgentOutput;
1391impl Tool for AgentOutput {
1392    fn name(&self) -> &str {
1393        "flow.output"
1394    }
1395    fn tier(&self) -> Tier {
1396        Tier::Zero
1397    }
1398    fn description(&self) -> Option<&str> {
1399        Some("Read accumulated assistant text from an async sub-agent.")
1400    }
1401    fn input_schema(&self) -> serde_json::Value {
1402        serde_json::json!({
1403            "type": "object",
1404            "properties": {
1405                "handle": {"type": "string"},
1406                "cursor": {"type": "integer", "default": 0},
1407                "limit": {"type": "integer", "default": 4096}
1408            },
1409            "required": ["handle"]
1410        })
1411    }
1412    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1413        Box::pin(async move {
1414            let handle = extract_string(&args, "handle", 0)?;
1415            let cursor = args
1416                .named("cursor")
1417                .and_then(|v| {
1418                    if let Value::Int(n) = v {
1419                        Some(*n)
1420                    } else {
1421                        None
1422                    }
1423                })
1424                .unwrap_or(0)
1425                .max(0) as usize;
1426            let limit = args
1427                .named("limit")
1428                .and_then(|v| {
1429                    if let Value::Int(n) = v {
1430                        Some(*n)
1431                    } else {
1432                        None
1433                    }
1434                })
1435                .unwrap_or(4096)
1436                .max(1) as usize;
1437            let reg = ctx
1438                .flow_registry
1439                .clone()
1440                .ok_or_else(|| RuntimeError::ToolFailed("flow.output: no agent registry".into()))?;
1441            let entry = reg.lookup(&handle)?;
1442            let output = entry.output.lock().unwrap().clone();
1443            let chunk = output.chars().skip(cursor).take(limit).collect::<String>();
1444            let next_cursor = cursor + chunk.chars().count();
1445            let eof = next_cursor >= output.chars().count();
1446            Ok(Value::Struct(vec![
1447                ("handle".into(), Value::Str(handle)),
1448                ("chunk".into(), Value::Str(chunk)),
1449                ("cursor".into(), Value::Int(cursor as i64)),
1450                ("next_cursor".into(), Value::Int(next_cursor as i64)),
1451                ("eof".into(), Value::Bool(eof)),
1452            ]))
1453        })
1454    }
1455}
1456
1457pub struct AgentKill;
1458impl Tool for AgentKill {
1459    fn name(&self) -> &str {
1460        "flow.kill"
1461    }
1462    fn tier(&self) -> Tier {
1463        Tier::Four
1464    }
1465    fn description(&self) -> Option<&str> {
1466        Some("Cancel a running async sub-agent by handle.")
1467    }
1468    fn input_schema(&self) -> serde_json::Value {
1469        serde_json::json!({
1470            "type": "object",
1471            "properties": {"handle": {"type": "string"}},
1472            "required": ["handle"]
1473        })
1474    }
1475    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1476        Box::pin(async move {
1477            let handle = extract_string(&args, "handle", 0)?;
1478            let reg = ctx
1479                .flow_registry
1480                .clone()
1481                .ok_or_else(|| RuntimeError::ToolFailed("flow.kill: no agent registry".into()))?;
1482            let entry = reg.lookup(&handle)?;
1483            entry.cancel.cancel();
1484            Ok(Value::Unit)
1485        })
1486    }
1487}
1488
1489pub struct FlowInterject;
1490impl Tool for FlowInterject {
1491    fn name(&self) -> &str {
1492        "flow.interject"
1493    }
1494    fn tier(&self) -> Tier {
1495        Tier::Two
1496    }
1497    fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
1498        ApprovalLevel::Approve
1499    }
1500    fn description(&self) -> Option<&str> {
1501        Some(
1502            "Interject a text message into a running FlowRun (root or sub-agent) by handle. \
1503             L1 (nudge): inject text into context, flow continues. \
1504             L2 (course_correct): inject text, cancel current LLM call, flow continues with correction. \
1505             L3 (redirect): cancel current LLM call, redirect to a different flow. \
1506             L4 (hard_stop): kill the FlowRun immediately. \
1507             Use handle \"root\" to interject into the main agent.",
1508        )
1509    }
1510    fn input_schema(&self) -> serde_json::Value {
1511        serde_json::json!({
1512            "type": "object",
1513            "properties": {
1514                "handle": {"type": "string", "description": "Target FlowRun handle (e.g. from flow.spawn return, or \"root\")."},
1515                "text": {"type": "string", "description": "Interjection text."},
1516                "level": {"type": "string", "enum": ["l1_nudge", "l2_course_correct", "l3_redirect", "l4_hard_stop"], "default": "l1_nudge", "description": "Interjection level."},
1517                "redirect_target": {"type": "string", "description": "Required for L3 redirect: the flow to redirect to."}
1518            },
1519            "required": ["handle", "text"]
1520        })
1521    }
1522    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1523        Box::pin(async move {
1524            let handle = extract_string(&args, "handle", 0)?;
1525            let text = extract_string(&args, "text", 1)?;
1526            let level_str = args
1527                .named("level")
1528                .and_then(|v| {
1529                    if let Value::Str(s) = v {
1530                        Some(s.clone())
1531                    } else {
1532                        None
1533                    }
1534                })
1535                .unwrap_or_else(|| "l1_nudge".to_string());
1536            let level = match level_str.as_str() {
1537                "l2_course_correct" => crate::injection::InjectionLevel::L2CourseCorrect,
1538                "l3_redirect" => crate::injection::InjectionLevel::L3Redirect,
1539                "l4_hard_stop" => crate::injection::InjectionLevel::L4HardStop,
1540                _ => crate::injection::InjectionLevel::L1Nudge,
1541            };
1542            let redirect_target = args.named("redirect_target").and_then(|v| {
1543                if let Value::Str(s) = v {
1544                    Some(s.clone())
1545                } else {
1546                    None
1547                }
1548            });
1549            let reg = ctx.flow_registry.clone().ok_or_else(|| {
1550                RuntimeError::ToolFailed("flow.interject: no agent registry".into())
1551            })?;
1552            let entry = reg.lookup(&handle)?;
1553            let inj = crate::injection::Injection::with_level(
1554                crate::event::TurnId::now(),
1555                text,
1556                level,
1557                redirect_target,
1558            );
1559            entry.pending_injections.lock().unwrap().push(inj);
1560            entry.injection_notify.notify_one();
1561            Ok(Value::Unit)
1562        })
1563    }
1564}
1565
1566fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
1567    let value = match args.named(name) {
1568        Some(v) => v,
1569        None => args.positional(pos)?,
1570    };
1571    match value {
1572        Value::Str(s) => Ok(s.clone()),
1573        other => Err(RuntimeError::TypeMismatch {
1574            expected: "string".into(),
1575            actual: other.kind_name().into(),
1576        }),
1577    }
1578}
1579
1580async fn run_prepared_flow_agent(
1581    prepared: PreparedFlowAgent,
1582    flow_args: Vec<(String, Value)>,
1583    ctx: &ToolCtx,
1584    run_id: FlowRunId,
1585    child_messages: Arc<Mutex<Vec<Message>>>,
1586    child_compact_lock: Arc<tokio::sync::Mutex<()>>,
1587    inherited_parent_context: bool,
1588) -> ToolResult {
1589    let Some(registry) = ctx.registry.as_ref() else {
1590        return Err(RuntimeError::ToolFailed(
1591            "flow.spawn: no tool registry available on ctx".into(),
1592        ));
1593    };
1594    let Some(providers) = ctx.providers.as_ref() else {
1595        return Err(RuntimeError::ToolFailed(
1596            "flow.spawn: no provider registry available on ctx".into(),
1597        ));
1598    };
1599    let PreparedFlowAgent { path, flow, flows } = prepared;
1600    let initial_prompt = invocation_user_message(&flow, &flow_args)?;
1601    emit_flow_agent_start(ctx, &run_id, &flow.name.name);
1602    let mut child_ctx = sanitize_child_ctx(ctx);
1603    child_ctx.session_messages_handle = Some(child_messages);
1604    child_ctx.compact_lock_handle = Some(child_compact_lock);
1605    child_ctx.context_epoch_handle = Some(Arc::new(std::sync::atomic::AtomicU64::new(0)));
1606    child_ctx.context_prefix_tracker = Some(Arc::new(std::sync::Mutex::new(
1607        crate::context_plan::ContextPrefixTracker::default(),
1608    )));
1609    seed_parent_handoff_context(
1610        &child_ctx,
1611        &flow,
1612        &run_id,
1613        initial_prompt.as_deref(),
1614        inherited_parent_context,
1615    )?;
1616    if let Some(prompt) = initial_prompt {
1617        seed_child_message_context(&child_ctx, prompt)?;
1618    }
1619    let out = crate::exec::exec_flow_with_siblings(
1620        &flow,
1621        flow_args,
1622        registry.as_ref(),
1623        &child_ctx,
1624        providers.as_ref(),
1625        &flows,
1626        child_ctx.events.as_ref(),
1627        child_ctx.turn_id.clone(),
1628        Some(run_id.clone()),
1629        None,
1630        child_ctx.cancel.clone(),
1631        None,
1632        path.parent().map(|p| p.to_path_buf()),
1633    )
1634    .await;
1635    let status = match &out {
1636        Ok(_) => FlowStatus::Ok,
1637        Err(e) => FlowStatus::Errored {
1638            message: e.to_string(),
1639        },
1640    };
1641    mark_terminal_and_emit_child_flow_end(ctx, &run_id, &status);
1642    out
1643}
1644
1645fn resolve_flow_arguments(
1646    flow: &atman_dsl::ast::FlowDecl,
1647    args: &ToolArgs,
1648) -> Result<Vec<(String, Value)>, RuntimeError> {
1649    let mut flow_args = Vec::new();
1650    let mut unknown = Vec::new();
1651    // Extract the structured flow parameters first.
1652    match args.named("arguments") {
1653        Some(Value::Struct(fields)) => {
1654            for (key, value) in fields {
1655                if flow
1656                    .params
1657                    .iter()
1658                    .any(|parameter| parameter.name.name == *key)
1659                {
1660                    flow_args.push((key.clone(), value.clone()));
1661                } else {
1662                    unknown.push(key.clone());
1663                }
1664            }
1665        }
1666        Some(Value::Unit) | None => {}
1667        Some(other) => {
1668            return Err(RuntimeError::ToolFailed(format!(
1669                "flow.spawn: `arguments` must be a struct, got {}",
1670                other.kind_name()
1671            )));
1672        }
1673    }
1674    // Backward compat: top-level named args (pre-arguments schema).
1675    for (key, value) in &args.named {
1676        if key == "flow"
1677            || key == "version"
1678            || key == "spawn_token"
1679            || key == "async"
1680            || key == "inherit_context"
1681            || key == "arguments"
1682            || key == "workspace"
1683        {
1684            continue;
1685        }
1686        if !flow
1687            .params
1688            .iter()
1689            .any(|parameter| parameter.name.name == *key)
1690        {
1691            unknown.push(key.clone());
1692        } else if flow_args.iter().any(|(name, _)| name == key) {
1693            return Err(RuntimeError::ToolFailed(format!(
1694                "flow.spawn: argument `{key}` was provided twice"
1695            )));
1696        } else {
1697            flow_args.push((key.clone(), value.clone()));
1698        }
1699    }
1700    if !unknown.is_empty() {
1701        unknown.sort();
1702        unknown.dedup();
1703        return Err(RuntimeError::ToolFailed(format!(
1704            "flow.spawn: unknown argument(s) for `{}`: {}",
1705            flow.name.name,
1706            unknown.join(", ")
1707        )));
1708    }
1709    Ok(flow_args)
1710}
1711
1712fn should_inherit_context(args: &ToolArgs) -> bool {
1713    matches!(args.named("inherit_context"), Some(Value::Bool(true)))
1714}
1715
1716fn inherited_context_snapshot(parent: &Arc<Mutex<Vec<Message>>>) -> Vec<Message> {
1717    let mut snapshot = parent.lock().unwrap().clone();
1718    crate::message::retain_complete_tool_pairs(&mut snapshot);
1719    snapshot
1720}
1721
1722fn invocation_user_message(
1723    flow: &atman_dsl::ast::FlowDecl,
1724    flow_args: &[(String, Value)],
1725) -> Result<Option<String>, RuntimeError> {
1726    let Some((_, parameter_value)) = flow.contract.as_ref().and_then(|contract| {
1727        contract
1728            .blocks
1729            .iter()
1730            .find(|block| block.name.name == "invocation")
1731            .and_then(|block| {
1732                block
1733                    .kwargs
1734                    .iter()
1735                    .find(|(name, _)| name.name == "user_message")
1736            })
1737    }) else {
1738        return Ok(None);
1739    };
1740    let atman_dsl::ast::Expr::Ident(parameter_name) = parameter_value else {
1741        return Err(RuntimeError::ToolFailed(
1742            "flow.spawn: invocation user_message must reference a string flow parameter".into(),
1743        ));
1744    };
1745    let parameter_name = parameter_name.name.as_str();
1746    let Some(parameter) = flow
1747        .params
1748        .iter()
1749        .find(|parameter| parameter.name.name == parameter_name)
1750    else {
1751        return Err(RuntimeError::ToolFailed(format!(
1752            "flow.spawn: invocation user_message references unknown parameter `{parameter_name}`"
1753        )));
1754    };
1755    if !matches!(
1756        &parameter.ty,
1757        atman_dsl::ast::TypeExpr::Named(name) if name.name == "string"
1758    ) {
1759        return Err(RuntimeError::ToolFailed(format!(
1760            "flow.spawn: invocation user_message parameter `{parameter_name}` must be a string"
1761        )));
1762    }
1763    match flow_args
1764        .iter()
1765        .find(|(name, _)| name == parameter_name)
1766        .map(|(_, value)| value)
1767    {
1768        Some(Value::Str(value)) => Ok(Some(value.clone())),
1769        Some(value) => Err(RuntimeError::TypeMismatch {
1770            expected: "string invocation user_message".into(),
1771            actual: value.kind_name().into(),
1772        }),
1773        None => match parameter.default.as_ref() {
1774            Some(atman_dsl::ast::Expr::Literal(atman_dsl::ast::Literal::Str(value))) => {
1775                Ok(Some(value.clone()))
1776            }
1777            Some(_) => Err(RuntimeError::ToolFailed(format!(
1778                "flow.spawn: invocation user_message parameter `{parameter_name}` requires a literal string default"
1779            ))),
1780            None => Err(RuntimeError::ToolFailed(format!(
1781                "flow.spawn: invocation user_message parameter `{parameter_name}` was not provided"
1782            ))),
1783        },
1784    }
1785}
1786
1787fn seed_child_message_context(ctx: &ToolCtx, prompt: String) -> Result<(), RuntimeError> {
1788    let turn_id = ctx
1789        .turn_id
1790        .clone()
1791        .unwrap_or_else(crate::event::TurnId::now);
1792    crate::tools::session::append_message_to_context(
1793        ctx,
1794        crate::message::Message::user_text(turn_id, prompt),
1795    )
1796}
1797
1798fn seed_parent_handoff_context(
1799    ctx: &ToolCtx,
1800    flow: &atman_dsl::ast::FlowDecl,
1801    child_run_id: &FlowRunId,
1802    invocation_prompt: Option<&str>,
1803    inherited_parent_context: bool,
1804) -> Result<(), RuntimeError> {
1805    let parent_run_id = ctx
1806        .flow_identity
1807        .as_ref()
1808        .and_then(|identity| identity.parent_run_id.as_ref())
1809        .map(ToString::to_string);
1810    let expected_result = flow
1811        .ret
1812        .as_ref()
1813        .map(super::flow_list::render_type)
1814        .unwrap_or_else(|| "unit".into());
1815    let task_digest = invocation_prompt
1816        .map(|prompt| format!("blake3:{}", blake3::hash(prompt.as_bytes()).to_hex()));
1817    let handoff = serde_json::json!({
1818        "parent_run_id": parent_run_id,
1819        "child_run_id": child_run_id.to_string(),
1820        "flow": flow.name.name,
1821        "task_source": if invocation_prompt.is_some() { "following_user_message" } else { "flow_parameters" },
1822        "task_digest": task_digest,
1823        "parent_context": if inherited_parent_context {
1824            "materialized_window_through_last_complete_tool_transaction"
1825        } else {
1826            "not_inherited"
1827        },
1828        "capability_boundary": "Only tools exposed by the current child model request are callable.",
1829        "task_boundary": "Execute only this delegated flow invocation and return its declared result to the parent.",
1830        "expected_result": expected_result,
1831    });
1832    let spec = crate::context_plan::ContextRecordSpec::new(
1833        "handoff.parent",
1834        crate::context_plan::ContextRecordAuthority::Runtime,
1835        crate::context_plan::ContextRecordRetention::Latest,
1836        crate::context_plan::ContextRecordBody::text(handoff.to_string()),
1837    );
1838    let turn_id = ctx
1839        .turn_id
1840        .clone()
1841        .unwrap_or_else(crate::event::TurnId::now);
1842    let record = {
1843        let messages = ctx.session_messages_handle.as_ref().ok_or_else(|| {
1844            RuntimeError::ToolFailed("flow.spawn: child message context is unavailable".into())
1845        })?;
1846        let messages = messages.lock().unwrap();
1847        crate::context_plan::compile_context_records(&messages, [spec])
1848            .into_iter()
1849            .next()
1850            .ok_or_else(|| {
1851                RuntimeError::ToolFailed(
1852                    "flow.spawn: child handoff record was not materialized".into(),
1853                )
1854            })?
1855    };
1856    crate::tools::session::append_message_to_context(
1857        ctx,
1858        crate::message::Message::context_record(turn_id, record),
1859    )
1860}
1861
1862fn mark_terminal_and_emit_child_flow_end(ctx: &ToolCtx, run_id: &FlowRunId, status: &FlowStatus) {
1863    terminal_then_emit(
1864        || {
1865            if let Some(flow_registry) = &ctx.flow_registry {
1866                flow_registry.mark_terminal(run_id);
1867            }
1868        },
1869        || emit_child_flow_end(ctx, run_id, status),
1870    );
1871}
1872
1873fn terminal_then_emit(mark_terminal: impl FnOnce(), emit: impl FnOnce()) {
1874    mark_terminal();
1875    emit();
1876}
1877
1878fn extract_flow(args: &ToolArgs) -> Result<Option<String>, RuntimeError> {
1879    match args.named("flow") {
1880        Some(Value::Str(s)) if !s.trim().is_empty() => Ok(Some(s.clone())),
1881        Some(Value::Unit) | None => Ok(None),
1882        Some(other) => Err(RuntimeError::TypeMismatch {
1883            expected: "flow string".into(),
1884            actual: other.kind_name().into(),
1885        }),
1886    }
1887}
1888
1889fn extract_flow_version(args: &ToolArgs) -> Result<Option<String>, RuntimeError> {
1890    match args.named("version") {
1891        Some(Value::Str(version)) if !version.trim().is_empty() => Ok(Some(version.clone())),
1892        Some(Value::Str(_)) => Ok(None),
1893        Some(Value::Unit) | None => Ok(None),
1894        Some(other) => Err(RuntimeError::TypeMismatch {
1895            expected: "version string".into(),
1896            actual: other.kind_name().into(),
1897        }),
1898    }
1899}
1900
1901fn extract_spawn_token(args: &ToolArgs) -> Result<String, RuntimeError> {
1902    match args.named("spawn_token") {
1903        Some(Value::Str(token)) if !token.trim().is_empty() => Ok(token.clone()),
1904        _ => Err(RuntimeError::ToolFailed(
1905            "flow.spawn: spawn_token is required; call flow.instances, inspect existing flows, reuse suitable work, and kill obsolete flows before spawning"
1906                .into(),
1907        )),
1908    }
1909}
1910
1911async fn read_flow_source(
1912    flow_ref: &str,
1913    ctx: &ToolCtx,
1914) -> Result<(PathBuf, String), RuntimeError> {
1915    for path in super::flow_source::candidates(flow_ref, ctx) {
1916        match tokio::fs::read_to_string(&path).await {
1917            Ok(src) => return Ok((path, src)),
1918            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1919            Err(e) => {
1920                return Err(RuntimeError::ToolFailed(format!(
1921                    "flow.spawn: read {}: {e}",
1922                    path.display()
1923                )));
1924            }
1925        }
1926    }
1927    Err(RuntimeError::ToolFailed(format!(
1928        "flow.spawn: flow `{flow_ref}` not found"
1929    )))
1930}
1931
1932fn emit_flow_agent_start(ctx: &ToolCtx, run_id: &FlowRunId, flow_name: &str) {
1933    let parent_run_id = ctx
1934        .flow_identity
1935        .as_ref()
1936        .and_then(|identity| identity.parent_run_id.clone())
1937        .or_else(|| {
1938            ctx.flow_run_id
1939                .clone()
1940                .filter(|candidate| candidate != run_id)
1941        });
1942    let parent_node_id = ctx.current_node_id.clone();
1943    if let Some(sink) = &ctx.events {
1944        sink.emit(Event::FlowStart {
1945            run_id: run_id.clone(),
1946            flow_name: flow_name.into(),
1947            parent_run_id: parent_run_id.clone(),
1948            parent_node_id: parent_node_id.clone(),
1949            spawned: true,
1950        });
1951    }
1952    if let Some(tx) = &ctx.stream_tx {
1953        let _ = tx.send(crate::stream::StreamFrame::FlowStart {
1954            run_id: run_id.0.to_string(),
1955            flow_name: flow_name.into(),
1956            parent_run_id: parent_run_id.as_ref().map(|r| r.0.to_string()),
1957            parent_node_id,
1958        });
1959    }
1960}
1961
1962fn emit_child_flow_end(ctx: &ToolCtx, run_id: &FlowRunId, status: &FlowStatus) {
1963    let suicide = ctx.task_registry.as_ref().is_some_and(|registry| {
1964        registry
1965            .list(&crate::task_registry::TaskFilter::default())
1966            .iter()
1967            .any(|snapshot| {
1968                snapshot.flow_run_id.as_ref() == Some(run_id)
1969                    && snapshot.termination == Some(crate::task_registry::TaskTermination::Suicide)
1970            })
1971    });
1972    if let Some(sink) = &ctx.events {
1973        sink.emit(Event::FlowEnd {
1974            run_id: run_id.clone(),
1975            flow_name: "agent.sub".into(),
1976            status: status.clone(),
1977        });
1978    }
1979    if let Some(tx) = &ctx.stream_tx {
1980        let _ = tx.send(crate::stream::StreamFrame::FlowDone {
1981            run_id: run_id.0.to_string(),
1982            flow_name: "agent.sub".into(),
1983            ok: matches!(status, FlowStatus::Ok),
1984            cancelled: matches!(status, FlowStatus::Cancelled),
1985            suicide,
1986        });
1987    }
1988}
1989
1990fn sanitize_child_ctx(parent: &ToolCtx) -> ToolCtx {
1991    let mut c = parent.clone();
1992    c.session_runtime = None;
1993    c.history_segment = crate::tool::HistorySegment::Spawned;
1994    c.session_messages_handle = None;
1995    c.compact_lock_handle = None;
1996    c.context_epoch_handle = None;
1997    c.context_prefix_tracker = None;
1998    c.forms = None;
1999    c.on_memory_recent = None;
2000    c
2001}
2002
2003#[cfg(test)]
2004mod tests {
2005    use super::{
2006        AgentSpawn, FlowRegistry, FlowRunStatus, extract_flow_version, inherited_context_snapshot,
2007        prepare_flow_agent, resolve_flow_arguments, terminal_then_emit,
2008    };
2009    use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
2010    use crate::permission::PermissionBroker;
2011    use crate::provider::ProviderRegistry;
2012    use crate::tool::{Tier, Tool, ToolArgs, ToolCtx, ToolRegistry};
2013    use crate::{InvocationEnv, Value};
2014    use std::cell::Cell;
2015    use std::sync::Arc;
2016
2017    struct SandboxProbe;
2018
2019    struct SessionTextProbe;
2020
2021    fn root_identity(
2022        registry: &FlowRegistry,
2023        session_id: &str,
2024    ) -> Arc<crate::flow_authority::FlowIdentity> {
2025        let run_id = crate::event::FlowRunId::now();
2026        registry
2027            .register_root(
2028                session_id.to_owned(),
2029                run_id,
2030                crate::flow_authority::EffectiveAuthority::root(&Default::default(), false, None),
2031            )
2032            .unwrap()
2033    }
2034
2035    fn admitted_args(registry: &FlowRegistry, ctx: &ToolCtx, mut args: ToolArgs) -> ToolArgs {
2036        args.named.push((
2037            "spawn_token".into(),
2038            Value::Str(
2039                registry.issue_spawn_permit(ctx.flow_identity.as_ref().expect("flow identity")),
2040            ),
2041        ));
2042        args
2043    }
2044
2045    #[test]
2046    fn spawn_permits_are_single_use_and_bound_to_the_caller() {
2047        let registry = FlowRegistry::new();
2048        let owner = root_identity(&registry, "session");
2049        let sibling = root_identity(&registry, "session");
2050        let foreign = root_identity(&registry, "other-session");
2051
2052        let token = registry.issue_spawn_permit(&owner);
2053        assert!(registry.consume_spawn_permit(&token, &foreign).is_err());
2054
2055        let token = registry.issue_spawn_permit(&owner);
2056        assert!(registry.consume_spawn_permit(&token, &sibling).is_err());
2057
2058        let token = registry.issue_spawn_permit(&owner);
2059        registry.consume_spawn_permit(&token, &owner).unwrap();
2060        assert!(registry.consume_spawn_permit(&token, &owner).is_err());
2061    }
2062
2063    #[test]
2064    fn spawn_inventory_changes_invalidate_issued_permits() {
2065        let registry = FlowRegistry::new();
2066        let owner = root_identity(&registry, "session");
2067        let token = registry.issue_spawn_permit(&owner);
2068
2069        registry.bump_spawn_inventory("session");
2070
2071        assert!(registry.consume_spawn_permit(&token, &owner).is_err());
2072    }
2073
2074    #[test]
2075    fn empty_spawn_version_is_treated_as_omitted() {
2076        for value in ["", " ", "\t\n"] {
2077            let args = ToolArgs {
2078                named: vec![("version".into(), Value::Str(value.into()))],
2079                ..Default::default()
2080            };
2081            assert_eq!(extract_flow_version(&args).unwrap(), None);
2082        }
2083    }
2084
2085    #[tokio::test]
2086    async fn prepared_flow_rejects_a_stale_discovery_version() {
2087        let dir = tempfile::tempdir().unwrap();
2088        let path = dir.path().join("child.at");
2089        let source = "flow child(goal: string) -> string { return goal }";
2090        std::fs::write(&path, source).unwrap();
2091        let flow_ref = format!("{}@child", path.display());
2092        let version = format!("blake3:{}", blake3::hash(source.as_bytes()).to_hex());
2093        let ctx = ToolCtx::new();
2094
2095        prepare_flow_agent(&flow_ref, Some(&version), &ctx)
2096            .await
2097            .unwrap();
2098        let error = match prepare_flow_agent(&flow_ref, Some("blake3:stale"), &ctx).await {
2099            Ok(_) => panic!("stale version should fail"),
2100            Err(error) => error,
2101        };
2102        assert!(error.to_string().contains("stale version"));
2103    }
2104
2105    #[test]
2106    fn flow_arguments_reject_unknown_and_duplicate_fields() {
2107        let file = atman_dsl::parse::parse_file(
2108            "flow child(goal: string, retries: int = 1) -> string { return goal }",
2109        )
2110        .unwrap();
2111        let flow = &file.flows[0];
2112        let unknown = ToolArgs {
2113            named: vec![(
2114                "arguments".into(),
2115                Value::Struct(vec![
2116                    ("goal".into(), Value::Str("work".into())),
2117                    ("typo".into(), Value::Bool(true)),
2118                ]),
2119            )],
2120            ..Default::default()
2121        };
2122        let error = resolve_flow_arguments(flow, &unknown).unwrap_err();
2123        assert!(error.to_string().contains("unknown argument(s)"));
2124        assert!(error.to_string().contains("typo"));
2125
2126        let duplicate = ToolArgs {
2127            named: vec![
2128                (
2129                    "arguments".into(),
2130                    Value::Struct(vec![("goal".into(), Value::Str("nested".into()))]),
2131                ),
2132                ("goal".into(), Value::Str("top-level".into())),
2133            ],
2134            ..Default::default()
2135        };
2136        assert!(
2137            resolve_flow_arguments(flow, &duplicate)
2138                .unwrap_err()
2139                .to_string()
2140                .contains("provided twice")
2141        );
2142    }
2143
2144    #[test]
2145    fn flow_entry_keeps_execution_goal_separate_from_display_label() {
2146        let registry = FlowRegistry::new();
2147        let entry = registry.create_entry_with_workspace_label(
2148            "agent-test".into(),
2149            "Audit the full provider chain".into(),
2150            "Review provider routing".into(),
2151            "smart".into(),
2152            crate::event::FlowRunId::now(),
2153            None,
2154        );
2155        assert_eq!(entry.goal, "Audit the full provider chain");
2156        assert_eq!(entry.display_label, "Review provider routing");
2157    }
2158
2159    #[test]
2160    fn inherited_context_excludes_incomplete_parent_tool_transactions() {
2161        let turn_id = crate::event::TurnId::now();
2162        let parent = Arc::new(std::sync::Mutex::new(vec![
2163            Message::user_text(turn_id.clone(), "delegate work"),
2164            Message {
2165                role: MessageRole::Assistant,
2166                parts: vec![
2167                    MessagePart::Text {
2168                        text: "starting tools".into(),
2169                    },
2170                    MessagePart::ToolUse {
2171                        id: "active-spawn".into(),
2172                        name: "flow.spawn".into(),
2173                        input: serde_json::json!({}),
2174                        intent: None,
2175                    },
2176                    MessagePart::ToolUse {
2177                        id: "complete-read".into(),
2178                        name: "fs.read".into(),
2179                        input: serde_json::json!({"path": "README.md"}),
2180                        intent: None,
2181                    },
2182                ],
2183                turn_id: turn_id.clone(),
2184                origin: MessageOrigin::User,
2185            },
2186            Message {
2187                role: MessageRole::Tool,
2188                parts: vec![MessagePart::ToolResult {
2189                    tool_use_id: "complete-read".into(),
2190                    content: "contents".into(),
2191                    is_error: false,
2192                }],
2193                turn_id,
2194                origin: MessageOrigin::User,
2195            },
2196        ]));
2197
2198        let snapshot = inherited_context_snapshot(&parent);
2199
2200        assert!(snapshot.iter().any(|message| {
2201            message
2202                .parts
2203                .iter()
2204                .any(|part| matches!(part, MessagePart::Text { text } if text == "starting tools"))
2205        }));
2206        assert!(snapshot.iter().any(|message| {
2207            message.parts.iter().any(
2208                |part| matches!(part, MessagePart::ToolUse { id, .. } if id == "complete-read"),
2209            )
2210        }));
2211        assert!(!snapshot.iter().any(|message| {
2212            message
2213                .parts
2214                .iter()
2215                .any(|part| matches!(part, MessagePart::ToolUse { id, .. } if id == "active-spawn"))
2216        }));
2217        assert!(parent.lock().unwrap().iter().any(|message| {
2218            message
2219                .parts
2220                .iter()
2221                .any(|part| matches!(part, MessagePart::ToolUse { id, .. } if id == "active-spawn"))
2222        }));
2223    }
2224
2225    impl Tool for SandboxProbe {
2226        fn name(&self) -> &str {
2227            "sandbox.probe"
2228        }
2229
2230        fn tier(&self) -> Tier {
2231            Tier::Zero
2232        }
2233
2234        fn call<'a>(
2235            &'a self,
2236            _args: ToolArgs,
2237            ctx: &'a ToolCtx,
2238        ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
2239            Box::pin(async move { Ok(Value::Bool(ctx.sandbox.is_some())) })
2240        }
2241    }
2242
2243    impl Tool for SessionTextProbe {
2244        fn name(&self) -> &str {
2245            "session.text"
2246        }
2247
2248        fn tier(&self) -> Tier {
2249            Tier::Zero
2250        }
2251
2252        fn call<'a>(
2253            &'a self,
2254            _args: ToolArgs,
2255            ctx: &'a ToolCtx,
2256        ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
2257            Box::pin(async move {
2258                let text = ctx
2259                    .session_messages_handle
2260                    .as_ref()
2261                    .map(|handle| {
2262                        handle
2263                            .lock()
2264                            .unwrap()
2265                            .iter()
2266                            .filter(|message| {
2267                                !message
2268                                    .parts
2269                                    .iter()
2270                                    .any(|part| matches!(part, MessagePart::ContextRecord(_)))
2271                            })
2272                            .map(crate::message::Message::text_concat)
2273                            .collect::<Vec<_>>()
2274                            .join("|")
2275                    })
2276                    .unwrap_or_default();
2277                Ok(Value::Str(text))
2278            })
2279        }
2280    }
2281
2282    #[test]
2283    fn terminal_transition_happens_before_flow_end_emit() {
2284        let terminal = Cell::new(false);
2285
2286        terminal_then_emit(
2287            || terminal.set(true),
2288            || assert!(terminal.get(), "FlowEnd emitted before terminal transition"),
2289        );
2290    }
2291
2292    #[tokio::test]
2293    async fn sync_and_async_spawned_flows_inherit_invocation_snapshot() {
2294        for is_async in [false, true] {
2295            let dir = tempfile::tempdir().unwrap();
2296            let path = dir.path().join("child.at");
2297            std::fs::write(&path, r#"flow child() -> string { return env("effort") }"#).unwrap();
2298
2299            let registry = Arc::new(FlowRegistry::new());
2300            let root_run_id = crate::event::FlowRunId::now();
2301            let root_identity = registry
2302                .register_root(
2303                    "test-session".into(),
2304                    root_run_id.clone(),
2305                    crate::flow_authority::EffectiveAuthority::root(
2306                        &Default::default(),
2307                        false,
2308                        None,
2309                    ),
2310                )
2311                .unwrap();
2312            let mut ctx = ToolCtx::new()
2313                .with_registry(Arc::new(ToolRegistry::new()))
2314                .with_providers(Arc::new(ProviderRegistry::new()))
2315                .with_flow_registry(Arc::clone(&registry))
2316                .with_invocation_env(InvocationEnv::single("effort", Value::Str("high".into())));
2317            ctx.flow_run_id = Some(root_run_id);
2318            ctx.flow_identity = Some(root_identity);
2319
2320            let result = AgentSpawn
2321                .call(
2322                    admitted_args(
2323                        &registry,
2324                        &ctx,
2325                        ToolArgs {
2326                            positional: Vec::new(),
2327                            named: vec![
2328                                (
2329                                    "flow".into(),
2330                                    Value::Str(format!("{}@child", path.display())),
2331                                ),
2332                                ("async".into(), Value::Bool(is_async)),
2333                            ],
2334                        },
2335                    ),
2336                    &ctx,
2337                )
2338                .await
2339                .unwrap();
2340
2341            if !is_async {
2342                assert!(matches!(result, Value::Str(value) if value == "high"));
2343                continue;
2344            }
2345
2346            let Value::Struct(fields) = result else {
2347                panic!("expected async spawn handle");
2348            };
2349            let handle = fields
2350                .into_iter()
2351                .find_map(|(name, value)| {
2352                    (name == "handle")
2353                        .then_some(value)
2354                        .and_then(|value| match value {
2355                            Value::Str(handle) => Some(handle),
2356                            _ => None,
2357                        })
2358                })
2359                .expect("async spawn handle");
2360            let status = tokio::time::timeout(std::time::Duration::from_secs(2), async {
2361                loop {
2362                    let status = registry
2363                        .lookup(&handle)
2364                        .unwrap()
2365                        .status
2366                        .lock()
2367                        .unwrap()
2368                        .clone();
2369                    if !status.is_running() {
2370                        break status;
2371                    }
2372                    tokio::task::yield_now().await;
2373                }
2374            })
2375            .await
2376            .unwrap();
2377            assert!(matches!(
2378                status,
2379                FlowRunStatus::Ok { final_text, .. } if final_text == "high"
2380            ));
2381        }
2382    }
2383
2384    #[tokio::test]
2385    async fn async_spawn_announces_declared_model() {
2386        for (arguments, expected) in [
2387            (Vec::new(), "smart"),
2388            (
2389                vec![("model".into(), Value::Str("vendor/model".into()))],
2390                "vendor/model",
2391            ),
2392        ] {
2393            let dir = tempfile::tempdir().unwrap();
2394            let path = dir.path().join("child.at");
2395            std::fs::write(
2396                &path,
2397                r#"flow child(model: string = "smart") -> string { return model }"#,
2398            )
2399            .unwrap();
2400
2401            let registry = Arc::new(FlowRegistry::new());
2402            let root_run_id = crate::event::FlowRunId::now();
2403            let root_identity = registry
2404                .register_root(
2405                    "test-session".into(),
2406                    root_run_id.clone(),
2407                    crate::flow_authority::EffectiveAuthority::root(
2408                        &Default::default(),
2409                        false,
2410                        None,
2411                    ),
2412                )
2413                .unwrap();
2414            let (stream_tx, mut stream_rx) = tokio::sync::broadcast::channel(16);
2415            let mut ctx = ToolCtx::new()
2416                .with_registry(Arc::new(ToolRegistry::new()))
2417                .with_providers(Arc::new(ProviderRegistry::new()))
2418                .with_flow_registry(Arc::clone(&registry))
2419                .with_stream_tx(stream_tx);
2420            ctx.flow_run_id = Some(root_run_id);
2421            ctx.flow_identity = Some(root_identity);
2422
2423            AgentSpawn
2424                .call(
2425                    admitted_args(
2426                        &registry,
2427                        &ctx,
2428                        ToolArgs {
2429                            positional: Vec::new(),
2430                            named: vec![
2431                                (
2432                                    "flow".into(),
2433                                    Value::Str(format!("{}@child", path.display())),
2434                                ),
2435                                ("async".into(), Value::Bool(true)),
2436                                ("arguments".into(), Value::Struct(arguments)),
2437                            ],
2438                        },
2439                    ),
2440                    &ctx,
2441                )
2442                .await
2443                .unwrap();
2444
2445            let announced_model = tokio::time::timeout(std::time::Duration::from_secs(2), async {
2446                loop {
2447                    if let crate::stream::StreamFrame::SubAgentStarted { model, .. } =
2448                        stream_rx.recv().await.unwrap()
2449                    {
2450                        break model;
2451                    }
2452                }
2453            })
2454            .await
2455            .unwrap();
2456            assert_eq!(announced_model, expected);
2457        }
2458    }
2459
2460    #[tokio::test]
2461    async fn flow_spawn_retains_sandbox_for_child_tool_invocations() {
2462        let dir = tempfile::tempdir().unwrap();
2463        let path = dir.path().join("child.at");
2464        std::fs::write(&path, r#"flow child() -> bool { return sandbox.probe() }"#).unwrap();
2465
2466        let tools = Arc::new(ToolRegistry::new());
2467        tools.register(Arc::new(SandboxProbe));
2468        let flows = Arc::new(FlowRegistry::new());
2469        let broker = PermissionBroker::shared(Arc::clone(&flows));
2470        let root_run_id = crate::event::FlowRunId::now();
2471        let trust = crate::trust::TrustConfig::default();
2472        let root_identity = flows
2473            .register_root(
2474                "test-session".into(),
2475                root_run_id.clone(),
2476                crate::flow_authority::EffectiveAuthority::root(&trust, false, None),
2477            )
2478            .unwrap();
2479        let sandbox: Arc<dyn crate::sandbox::Sandbox> =
2480            Arc::new(crate::sandbox::SandboxExec::new(dir.path()));
2481        let mut ctx = ToolCtx::new()
2482            .with_registry(tools)
2483            .with_providers(Arc::new(ProviderRegistry::new()))
2484            .with_flow_registry(Arc::clone(&flows))
2485            .with_permission_broker(broker)
2486            .with_session_id("test-session")
2487            .with_trust(trust)
2488            .with_sandbox(sandbox)
2489            .for_tool_invocation(Tier::Two);
2490        ctx.flow_run_id = Some(root_run_id);
2491        ctx.flow_identity = Some(root_identity);
2492
2493        let result = AgentSpawn
2494            .call(
2495                admitted_args(
2496                    &flows,
2497                    &ctx,
2498                    ToolArgs {
2499                        positional: Vec::new(),
2500                        named: vec![
2501                            (
2502                                "flow".into(),
2503                                Value::Str(format!("{}@child", path.display())),
2504                            ),
2505                            ("async".into(), Value::Bool(false)),
2506                        ],
2507                    },
2508                ),
2509                &ctx,
2510            )
2511            .await
2512            .unwrap();
2513
2514        assert!(matches!(result, Value::Bool(true)));
2515    }
2516
2517    #[tokio::test]
2518    async fn flow_spawn_owns_one_isolated_invocation_message() {
2519        let dir = tempfile::tempdir().unwrap();
2520        let path = dir.path().join("child.at");
2521        std::fs::write(
2522            &path,
2523            r#"flow child(user_prompt: string) -> string {
2524    contract { invocation { user_message: user_prompt } }
2525    return session.text()
2526}
2527
2528flow plain(user_prompt: string) -> string {
2529    return session.text()
2530}"#,
2531        )
2532        .unwrap();
2533
2534        let tools = Arc::new(ToolRegistry::new());
2535        tools.register(Arc::new(SessionTextProbe));
2536        let flows = Arc::new(FlowRegistry::new());
2537        let broker = PermissionBroker::shared(Arc::clone(&flows));
2538        let event_session = Arc::new(crate::session::Session::open_ephemeral());
2539        let root_run_id = crate::event::FlowRunId::now();
2540        let trust = crate::trust::TrustConfig::default();
2541        let root_identity = flows
2542            .register_root(
2543                "test-session".into(),
2544                root_run_id.clone(),
2545                crate::flow_authority::EffectiveAuthority::root(&trust, false, None),
2546            )
2547            .unwrap();
2548        let root_entry = flows.create_entry(
2549            "root".into(),
2550            "parent prompt".into(),
2551            String::new(),
2552            root_run_id.clone(),
2553        );
2554        let parent_messages = Arc::new(std::sync::Mutex::new(vec![
2555            crate::message::Message::user_text(crate::event::TurnId::now(), "parent prompt"),
2556        ]));
2557        let mut ctx = ToolCtx::new()
2558            .with_registry(tools)
2559            .with_providers(Arc::new(ProviderRegistry::new()))
2560            .with_flow_registry(Arc::clone(&flows))
2561            .with_permission_broker(broker)
2562            .with_events(event_session.sink().clone())
2563            .with_session_id("test-session")
2564            .with_trust(trust)
2565            .with_agent_entry(Arc::clone(&root_entry))
2566            .with_session_messages_handle(Arc::clone(&parent_messages));
2567        ctx.flow_run_id = Some(root_run_id);
2568        ctx.flow_identity = Some(root_identity);
2569
2570        let result = AgentSpawn
2571            .call(
2572                admitted_args(
2573                    &flows,
2574                    &ctx,
2575                    ToolArgs {
2576                        positional: Vec::new(),
2577                        named: vec![
2578                            (
2579                                "flow".into(),
2580                                Value::Str(format!("{}@child", path.display())),
2581                            ),
2582                            ("async".into(), Value::Bool(false)),
2583                            (
2584                                "arguments".into(),
2585                                Value::Struct(vec![(
2586                                    "user_prompt".into(),
2587                                    Value::Str("child prompt".into()),
2588                                )]),
2589                            ),
2590                        ],
2591                    },
2592                ),
2593                &ctx,
2594            )
2595            .await
2596            .unwrap();
2597
2598        assert!(matches!(result, Value::Str(text) if text == "child prompt"));
2599        assert_eq!(parent_messages.lock().unwrap().len(), 1);
2600        assert!(
2601            root_entry.messages.lock().unwrap().is_empty(),
2602            "sync child must not reuse the parent FlowEntry message segment"
2603        );
2604
2605        let plain_result = AgentSpawn
2606            .call(
2607                admitted_args(
2608                    &flows,
2609                    &ctx,
2610                    ToolArgs {
2611                        positional: Vec::new(),
2612                        named: vec![
2613                            (
2614                                "flow".into(),
2615                                Value::Str(format!("{}@plain", path.display())),
2616                            ),
2617                            ("async".into(), Value::Bool(false)),
2618                            (
2619                                "arguments".into(),
2620                                Value::Struct(vec![(
2621                                    "user_prompt".into(),
2622                                    Value::Str("not implicit".into()),
2623                                )]),
2624                            ),
2625                        ],
2626                    },
2627                ),
2628                &ctx,
2629            )
2630            .await
2631            .unwrap();
2632
2633        assert!(matches!(plain_result, Value::Str(text) if text.is_empty()));
2634
2635        let async_result = AgentSpawn
2636            .call(
2637                admitted_args(
2638                    &flows,
2639                    &ctx,
2640                    ToolArgs {
2641                        positional: Vec::new(),
2642                        named: vec![
2643                            (
2644                                "flow".into(),
2645                                Value::Str(format!("{}@child", path.display())),
2646                            ),
2647                            ("async".into(), Value::Bool(true)),
2648                            (
2649                                "arguments".into(),
2650                                Value::Struct(vec![(
2651                                    "user_prompt".into(),
2652                                    Value::Str("async child prompt".into()),
2653                                )]),
2654                            ),
2655                        ],
2656                    },
2657                ),
2658                &ctx,
2659            )
2660            .await
2661            .unwrap();
2662        let Value::Struct(fields) = async_result else {
2663            panic!("expected async spawn handle");
2664        };
2665        let handle = fields
2666            .into_iter()
2667            .find_map(|(name, value)| match (name.as_str(), value) {
2668                ("handle", Value::Str(handle)) => Some(handle),
2669                _ => None,
2670            })
2671            .expect("async spawn handle");
2672        let entry = flows.lookup(&handle).unwrap();
2673        let status = tokio::time::timeout(std::time::Duration::from_secs(2), async {
2674            loop {
2675                let status = entry.status.lock().unwrap().clone();
2676                if !status.is_running() {
2677                    break status;
2678                }
2679                tokio::task::yield_now().await;
2680            }
2681        })
2682        .await
2683        .unwrap();
2684        assert!(matches!(
2685            status,
2686            FlowRunStatus::Ok { final_text, .. } if final_text == "async child prompt"
2687        ));
2688        let entry_messages = entry.messages.lock().unwrap();
2689        assert_eq!(entry_messages.len(), 2);
2690        assert!(matches!(
2691            entry_messages[0].parts.as_slice(),
2692            [MessagePart::ContextRecord(record)] if record.key() == "handoff.parent"
2693        ));
2694        let MessagePart::ContextRecord(handoff) = &entry_messages[0].parts[0] else {
2695            unreachable!("validated handoff record")
2696        };
2697        let rendered_handoff = handoff.render_for_model();
2698        assert!(rendered_handoff.contains("following_user_message"));
2699        assert!(rendered_handoff.contains("expected_result"));
2700        assert!(!rendered_handoff.contains("async child prompt"));
2701        assert_eq!(entry_messages[1].text_concat(), "async child prompt");
2702        drop(entry_messages);
2703        assert_eq!(parent_messages.lock().unwrap().len(), 1);
2704        assert!(root_entry.messages.lock().unwrap().is_empty());
2705        assert!(
2706            event_session.messages_full().is_empty(),
2707            "spawned handoff records must not project into root history"
2708        );
2709        let invocation_events = event_session
2710            .sink()
2711            .snapshot()
2712            .into_iter()
2713            .filter_map(|event| match event {
2714                crate::event::Event::UserMsg {
2715                    flow_run_id,
2716                    message,
2717                    ..
2718                } => Some((flow_run_id, message.text_concat())),
2719                _ => None,
2720            })
2721            .collect::<Vec<_>>();
2722        assert_eq!(invocation_events.len(), 2);
2723        assert!(
2724            invocation_events
2725                .iter()
2726                .all(|(flow_run_id, _)| flow_run_id.is_some()),
2727            "child invocation events must not project into root history"
2728        );
2729        let handoff_events = event_session
2730            .sink()
2731            .snapshot()
2732            .into_iter()
2733            .filter_map(|event| match event {
2734                crate::event::Event::SystemMsg {
2735                    flow_run_id,
2736                    message,
2737                    ..
2738                } if message.parts.iter().any(
2739                    |part| matches!(part, MessagePart::ContextRecord(record) if record.key() == "handoff.parent"),
2740                ) => Some(flow_run_id),
2741                _ => None,
2742            })
2743            .collect::<Vec<_>>();
2744        assert_eq!(handoff_events.len(), 3);
2745        assert!(handoff_events.iter().all(Option::is_some));
2746    }
2747}