Skip to main content

atman_runtime/
executor.rs

1use std::collections::HashMap;
2
3use atman_dsl::ast::{File, FlowDecl};
4
5use crate::error::RuntimeError;
6use crate::event::{Event, EventSink, FlowRunId, FlowStatus, TurnId};
7use crate::exec::exec_flow_with_siblings;
8use crate::invocation_env::InvocationEnv;
9use crate::provider::ProviderRegistry;
10use crate::provider_lifecycle::ProviderLifecycle;
11use crate::session::Session;
12use crate::tool::{ToolCtx, ToolRegistry};
13use crate::value::Value;
14
15#[derive(Clone, Default)]
16pub struct RootInvocation {
17    pub turn_id: Option<TurnId>,
18    pub session: Option<std::sync::Arc<Session>>,
19    pub first_run_id: Option<FlowRunId>,
20    pub env: InvocationEnv,
21}
22
23#[derive(Clone)]
24pub struct Executor {
25    pub tools: ToolRegistry,
26    pub providers: ProviderRegistry,
27    pub events: EventSink,
28    pub tool_ctx: ToolCtx,
29    pub safety: Option<crate::safety::SafetyConfig>,
30    /// When set, relative `@` paths are resolved against this directory.
31    pub source_dir: Option<std::path::PathBuf>,
32}
33
34#[derive(Debug, thiserror::Error)]
35#[error("executor already has a provider lifecycle")]
36pub struct ProviderLifecycleAlreadyAttached;
37
38impl Executor {
39    pub fn new() -> Self {
40        let tools = ToolRegistry::new();
41        crate::tools::register_tier_zero(&tools);
42        Self {
43            tools,
44            providers: ProviderRegistry::new(),
45            events: EventSink::new(),
46            tool_ctx: ToolCtx::new(),
47            safety: None,
48            source_dir: None,
49        }
50    }
51
52    pub fn with_events(events: EventSink) -> Self {
53        let tools = ToolRegistry::new();
54        crate::tools::register_tier_zero(&tools);
55        Self {
56            tools,
57            providers: ProviderRegistry::new(),
58            events,
59            tool_ctx: ToolCtx::new(),
60            safety: None,
61            source_dir: None,
62        }
63    }
64
65    pub fn attach_provider_lifecycle(
66        &mut self,
67        hub: crate::config_hub::ConfigHub,
68    ) -> Result<ProviderLifecycle, ProviderLifecycleAlreadyAttached> {
69        self.providers
70            .attach_provider_lifecycle(hub)
71            .ok_or(ProviderLifecycleAlreadyAttached)
72    }
73
74    pub fn provider_lifecycle(&self) -> Option<ProviderLifecycle> {
75        self.providers.provider_lifecycle()
76    }
77
78    pub fn with_safety(mut self, safety: crate::safety::SafetyConfig) -> Self {
79        self.safety = Some(safety);
80        self
81    }
82
83    pub async fn run(
84        &self,
85        file: &File,
86        flow_name: &str,
87        args: Vec<(String, Value)>,
88    ) -> Result<Value, RuntimeError> {
89        self.run_in_turn(file, flow_name, args, None, None).await
90    }
91
92    pub async fn run_in_turn(
93        &self,
94        file: &File,
95        flow_name: &str,
96        args: Vec<(String, Value)>,
97        turn_id: Option<TurnId>,
98        session: Option<std::sync::Arc<Session>>,
99    ) -> Result<Value, RuntimeError> {
100        self.run_with_invocation(
101            file,
102            flow_name,
103            args,
104            RootInvocation {
105                turn_id,
106                session,
107                ..RootInvocation::default()
108            },
109        )
110        .await
111    }
112
113    pub async fn run_in_turn_with_env(
114        &self,
115        file: &File,
116        flow_name: &str,
117        args: Vec<(String, Value)>,
118        turn_id: Option<TurnId>,
119        session: Option<std::sync::Arc<Session>>,
120        invocation_env: InvocationEnv,
121    ) -> Result<Value, RuntimeError> {
122        self.run_with_invocation(
123            file,
124            flow_name,
125            args,
126            RootInvocation {
127                turn_id,
128                session,
129                env: invocation_env,
130                ..RootInvocation::default()
131            },
132        )
133        .await
134    }
135
136    pub async fn run_in_turn_with_run_id(
137        &self,
138        file: &File,
139        flow_name: &str,
140        args: Vec<(String, Value)>,
141        turn_id: Option<TurnId>,
142        session: Option<std::sync::Arc<Session>>,
143        first_run_id: Option<FlowRunId>,
144    ) -> Result<Value, RuntimeError> {
145        self.run_with_invocation(
146            file,
147            flow_name,
148            args,
149            RootInvocation {
150                turn_id,
151                session,
152                first_run_id,
153                ..RootInvocation::default()
154            },
155        )
156        .await
157    }
158
159    pub async fn run_with_invocation(
160        &self,
161        file: &File,
162        flow_name: &str,
163        args: Vec<(String, Value)>,
164        invocation: RootInvocation,
165    ) -> Result<Value, RuntimeError> {
166        let flows: HashMap<_, _> = file
167            .flows
168            .iter()
169            .map(|f| (f.name.name.clone(), f.clone()))
170            .collect();
171        let mut current = flow_name.to_string();
172        let mut current_args = args;
173        let mut next_run_id = invocation.first_run_id.clone();
174        for _ in 0..5 {
175            let flow = flows
176                .get(&current)
177                .ok_or_else(|| RuntimeError::UndefinedTool(format!("flow `{current}`")))?;
178            match self
179                .run_flow(flow, current_args, &flows, &invocation, next_run_id.take())
180                .await
181            {
182                Err(RuntimeError::Redirect(target)) => {
183                    current = target;
184                    current_args = Vec::new();
185                    continue;
186                }
187                other => return other,
188            }
189        }
190        Err(RuntimeError::ToolFailed(
191            "redirect chain exceeded max depth (5)".into(),
192        ))
193    }
194
195    async fn run_flow(
196        &self,
197        flow: &FlowDecl,
198        args: Vec<(String, Value)>,
199        flows: &HashMap<String, FlowDecl>,
200        invocation: &RootInvocation,
201        run_id: Option<FlowRunId>,
202    ) -> Result<Value, RuntimeError> {
203        let turn_id = invocation.turn_id.clone();
204        let session = invocation.session.clone();
205        if let Some(session) = session.as_ref() {
206            session.set_tool_output_budget(self.tool_ctx.tool_output_budget);
207        }
208        let run_id = run_id.unwrap_or_else(FlowRunId::now);
209        let task_label = root_task_label(
210            &flow.name.name,
211            &args,
212            session.as_ref().and_then(|session| session.goal()),
213        );
214        let flow_cancel = session
215            .as_ref()
216            .map(|s| s.flow_cancel_token())
217            .unwrap_or_default();
218        let flow_registry = session
219            .as_ref()
220            .map(|session| std::sync::Arc::clone(&session.flow_registry))
221            .or_else(|| self.tool_ctx.flow_registry.clone())
222            .unwrap_or_else(|| std::sync::Arc::new(crate::tools::agent_ctrl::FlowRegistry::new()));
223        // The broker authenticates against a specific registry, so an inherited
224        // broker is only reusable when it is bound to the registry resolved above.
225        // Otherwise mint one for this registry, keeping standalone runs (no session)
226        // on the same permission pipeline as session-backed runs.
227        let permission_broker = session
228            .as_ref()
229            .map(|session| session.permission_broker())
230            .or_else(|| {
231                self.tool_ctx
232                    .permission_broker
233                    .clone()
234                    .filter(|broker| broker.is_for_registry(&flow_registry))
235            })
236            .unwrap_or_else(|| {
237                crate::permission::PermissionBroker::shared(std::sync::Arc::clone(&flow_registry))
238            });
239        let session_id = session
240            .as_ref()
241            .map(|session| session.id().to_string())
242            .or_else(|| self.tool_ctx.session_id.clone())
243            .unwrap_or_else(|| format!("standalone-{}", uuid::Uuid::now_v7()));
244        let trust = session
245            .as_ref()
246            .map(|session| session.trust_config())
247            .or_else(|| self.tool_ctx.trust.clone())
248            .unwrap_or_default();
249        let workspace_root = self
250            .tool_ctx
251            .workspace
252            .as_ref()
253            .map(|binding| binding.path.clone())
254            .or_else(|| self.tool_ctx.fs_access.workspace.clone());
255        let identity = flow_registry.register_root(
256            session_id.clone(),
257            run_id.clone(),
258            crate::flow_authority::EffectiveAuthority::root(
259                &trust,
260                crate::flow_authority::contract_allows_shell(flow.contract.as_ref()),
261                workspace_root,
262            ),
263        )?;
264        let task_id = self.tool_ctx.task_registry.as_ref().map(|tr| {
265            tr.register_flow_with_run_id(
266                task_label,
267                run_id.0.to_string(),
268                self.tool_ctx
269                    .session_id
270                    .clone()
271                    .unwrap_or_else(|| "anon".into()),
272                flow_cancel.clone(),
273                None,
274                run_id.clone(),
275            )
276        });
277        let _lifecycle_guard = flow_registry.lifecycle_guard(&run_id);
278        self.events.emit(Event::FlowStart {
279            run_id: run_id.clone(),
280            flow_name: flow.name.name.clone(),
281            parent_run_id: None,
282            parent_node_id: None,
283            spawned: false,
284        });
285        if let Some(sess) = session.as_ref() {
286            let _ = sess
287                .stream_tx()
288                .send(crate::stream::StreamFrame::FlowStart {
289                    run_id: run_id.0.to_string(),
290                    flow_name: flow.name.name.clone(),
291                    parent_run_id: None,
292                    parent_node_id: None,
293                });
294        }
295        let graph = crate::nodegraph::extract_graph(flow);
296        self.events.emit(Event::FlowGraph {
297            run_id: run_id.clone(),
298            graph: graph.clone(),
299        });
300        if let Some(sess) = session.as_ref() {
301            let _ = sess
302                .stream_tx()
303                .send(crate::stream::StreamFrame::FlowGraph {
304                    run_id: run_id.0.to_string(),
305                    graph,
306                });
307        }
308        // Root's tool_ctx carries session stream_tx so emit sites use
309        // tool_ctx.stream_tx uniformly.
310        let mut tool_ctx = self
311            .tool_ctx
312            .clone()
313            .with_invocation_env(invocation.env.clone());
314        tool_ctx.model_tool_exposures = Some(Default::default());
315        tool_ctx.flow_registry = Some(std::sync::Arc::clone(&flow_registry));
316        tool_ctx.permission_broker = Some(std::sync::Arc::clone(&permission_broker));
317        tool_ctx.trust = Some(trust);
318        tool_ctx.flow_identity = Some(identity);
319        tool_ctx.flow_run_id = Some(run_id.clone());
320        tool_ctx.session_id = Some(session_id);
321        if let Some(sess) = session.as_ref() {
322            tool_ctx.stream_tx = Some(sess.stream_tx());
323            tool_ctx.session_messages_handle = Some(sess.messages_handle());
324            // Register root so flow.output/interject("root") work. Root's llm
325            // context stays on session MessageStream; entry is for output +
326            // interjection addressing.
327            let root_entry = sess.flow_registry.create_entry(
328                "root".to_string(),
329                sess.goal().unwrap_or_else(|| flow.name.name.clone()),
330                String::new(),
331                run_id.clone(),
332            );
333            tool_ctx.agent_entry = Some(std::sync::Arc::clone(&root_entry));
334            sess.set_current_root("root".to_string());
335        }
336        let exec_fut = exec_flow_with_siblings(
337            flow,
338            args,
339            &self.tools,
340            &tool_ctx,
341            &self.providers,
342            flows,
343            Some(&self.events),
344            turn_id,
345            Some(run_id.clone()),
346            session.clone(),
347            flow_cancel.clone(),
348            self.safety.as_ref(),
349            self.source_dir.clone(),
350        );
351        let result = tokio::select! {
352            biased;
353            _ = flow_cancel.cancelled() => Err(RuntimeError::Cancelled("flow cancelled by user".into())),
354            r = exec_fut => r,
355        };
356        let result = if let Err(RuntimeError::Cancelled(_)) = &result {
357            let suicide = task_id.as_ref().and_then(|id| {
358                self.tool_ctx
359                    .task_registry
360                    .as_ref()
361                    .and_then(|tr| tr.lookup(id))
362                    .and_then(|snap| snap.termination)
363            }) == Some(crate::task_registry::TaskTermination::Suicide);
364            if suicide {
365                Err(RuntimeError::Cancelled("flow terminated by suicide".into()))
366            } else {
367                result
368            }
369        } else {
370            result
371        };
372        let status = match &result {
373            Ok(v) => {
374                if let Value::Err(e) = v
375                    && matches!(e, RuntimeError::Cancelled(_))
376                {
377                    FlowStatus::Cancelled
378                } else {
379                    FlowStatus::Ok
380                }
381            }
382            Err(e) => {
383                if matches!(e, RuntimeError::Cancelled(_)) {
384                    FlowStatus::Cancelled
385                } else {
386                    FlowStatus::Errored {
387                        message: e.to_string(),
388                    }
389                }
390            }
391        };
392        let cancelled = matches!(status, FlowStatus::Cancelled);
393        let suicide = task_id.as_ref().and_then(|id| {
394            self.tool_ctx
395                .task_registry
396                .as_ref()
397                .and_then(|tr| tr.lookup(id))
398                .and_then(|snap| snap.termination)
399        }) == Some(crate::task_registry::TaskTermination::Suicide);
400        if let (Some(tr), Some(tid)) = (self.tool_ctx.task_registry.as_ref(), &task_id) {
401            let ts = match &status {
402                FlowStatus::Ok => crate::task_registry::TaskStatus::Ok,
403                FlowStatus::Cancelled => crate::task_registry::TaskStatus::Killed,
404                FlowStatus::Errored { .. } => crate::task_registry::TaskStatus::Err,
405            };
406            tr.finish(tid, ts);
407        }
408        drop(_lifecycle_guard);
409        self.events.emit(Event::FlowEnd {
410            run_id: run_id.clone(),
411            flow_name: flow.name.name.clone(),
412            status: status.clone(),
413        });
414        if let Some(sess) = session.as_ref() {
415            let _ = sess.stream_tx().send(crate::stream::StreamFrame::FlowDone {
416                run_id: run_id.0.to_string(),
417                flow_name: flow.name.name.clone(),
418                ok: matches!(status, FlowStatus::Ok),
419                cancelled,
420                suicide,
421            });
422        }
423        result
424    }
425}
426
427impl Default for Executor {
428    fn default() -> Self {
429        Self::new()
430    }
431}
432
433fn root_task_label(
434    flow_name: &str,
435    args: &[(String, Value)],
436    session_goal: Option<String>,
437) -> String {
438    const USER_TEXT_KEYS: &[&str] = &["input", "prompt", "goal", "task", "message", "query"];
439
440    let named_user_text = USER_TEXT_KEYS.iter().find_map(|wanted| {
441        args.iter().find_map(|(name, value)| {
442            (name == wanted)
443                .then_some(value)
444                .and_then(|value| match value {
445                    Value::Str(value) => crate::task_registry::normalize_task_label(value),
446                    _ => None,
447                })
448        })
449    });
450    named_user_text
451        .or_else(|| {
452            args.iter().find_map(|(_, value)| match value {
453                Value::Str(value) => crate::task_registry::normalize_task_label(value),
454                _ => None,
455            })
456        })
457        .or_else(|| {
458            session_goal
459                .as_deref()
460                .and_then(crate::task_registry::normalize_task_label)
461        })
462        .unwrap_or_else(|| flow_name.to_owned())
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn attached_provider_lifecycle_uses_and_retains_the_executor_registry() {
471        let dir = tempfile::tempdir().unwrap();
472        let mut executor = Executor::new();
473        let lifecycle = executor
474            .attach_provider_lifecycle(crate::config_hub::ConfigHub::from_config_dir(dir.path()))
475            .unwrap();
476
477        assert!(
478            lifecycle
479                .provider_registry()
480                .shares_storage_with(&executor.providers)
481        );
482        executor.tool_ctx = ToolCtx::new();
483        assert!(executor.provider_lifecycle().is_some());
484        let cloned = executor.clone();
485        drop(lifecycle);
486        drop(executor);
487        assert!(cloned.provider_lifecycle().is_some());
488    }
489
490    #[test]
491    fn provider_lifecycle_rejects_repeated_attachment() {
492        let first = tempfile::tempdir().unwrap();
493        let second = tempfile::tempdir().unwrap();
494        let mut executor = Executor::new();
495        executor
496            .attach_provider_lifecycle(crate::config_hub::ConfigHub::from_config_dir(first.path()))
497            .unwrap();
498
499        assert!(matches!(
500            executor.attach_provider_lifecycle(crate::config_hub::ConfigHub::from_config_dir(
501                second.path()
502            )),
503            Err(ProviderLifecycleAlreadyAttached)
504        ));
505    }
506
507    #[test]
508    fn root_task_title_prefers_named_user_text() {
509        let args = vec![
510            ("model".into(), Value::Str("gpt-example".into())),
511            ("input".into(), Value::Str("  检查   当前项目  ".into())),
512        ];
513
514        assert_eq!(
515            root_task_label("agent", &args, Some("旧目标".into())),
516            "检查 当前项目"
517        );
518    }
519
520    #[test]
521    fn root_task_title_falls_back_to_goal_then_flow_name() {
522        assert_eq!(
523            root_task_label("agent", &[], Some("  完成   审计 ".into())),
524            "完成 审计"
525        );
526        assert_eq!(root_task_label("agent", &[], None), "agent");
527    }
528
529    #[tokio::test]
530    async fn root_invocation_projects_tool_output_budget_into_session() {
531        let budget = crate::tools::tool_output::ToolOutputBudget {
532            max_lines: 7,
533            max_bytes: 777,
534            max_line_bytes: 111,
535        };
536        let mut executor = Executor::new();
537        executor.tool_ctx.tool_output_budget = budget;
538        let session = std::sync::Arc::new(Session::open_ephemeral());
539        let file = atman_dsl::parse::parse_file(
540            r#"flow start() -> string {
541    return "ok"
542}"#,
543        )
544        .unwrap();
545
546        executor
547            .run_in_turn(&file, "start", Vec::new(), None, Some(session.clone()))
548            .await
549            .unwrap();
550
551        assert_eq!(session.tool_output_budget(), budget);
552    }
553}