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 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
152pub(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 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 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 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 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 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 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 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 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 ¶meter.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(®istry, "session");
2049 let sibling = root_identity(®istry, "session");
2050 let foreign = root_identity(®istry, "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(®istry, "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(®istry))
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 ®istry,
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(®istry))
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 ®istry,
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}