Skip to main content

a3s_code_core/orchestration/
executor.rs

1//! The `AgentExecutor` seam and its barrier (`parallel`) primitive.
2
3use crate::agent::{AgentEvent, DEFAULT_MAX_PARALLEL_TASKS};
4use crate::ordered_parallel::run_ordered_parallel_with_limit;
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8use tokio::sync::broadcast;
9
10/// A source location that a delegated step actually observed through a
11/// successful built-in research tool call.
12///
13/// The value is deliberately narrow: callers get a normalized URL or
14/// workspace-relative path, never the raw tool output or arguments.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
16pub struct ToolSourceAnchor {
17    /// Built-in tool that observed the source (for example `web_fetch` or
18    /// `read`).
19    pub tool: String,
20    /// Canonical HTTP(S) URL or workspace-relative path.
21    pub url_or_path: String,
22}
23
24/// A single unit of orchestrated agent work — *what* to run, independent of
25/// *where* it runs.
26///
27/// Serializable on purpose: a host may ship it to another node, and
28/// a future workflow checkpoint persists it. The orchestration layer assigns
29/// `task_id`; everything else mirrors a delegated task.
30// `serde_json::Value` (in `output_schema`) is not `Eq`, so this derives
31// `PartialEq` only.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct AgentStepSpec {
34    /// Stable id for this step. Flows into lifecycle events (and, later,
35    /// workflow checkpoints) so a step can be correlated and resumed.
36    pub task_id: String,
37    /// Registry key of the agent to run (e.g. `"explore"`, `"review"`).
38    pub agent: String,
39    /// Short human label for display/tracking.
40    pub description: String,
41    /// The instruction handed to the child agent.
42    pub prompt: String,
43    /// Optional per-step tool-round cap.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub max_steps: Option<usize>,
46    /// Parent session id, for lifecycle-event correlation.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub parent_session_id: Option<String>,
49    /// When set, the step must return a value conforming to this JSON Schema.
50    /// The executor fulfills it (the local default coerces the step's output
51    /// with the structured-output machinery); the validated object lands in
52    /// [`StepOutcome::structured`].
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub output_schema: Option<serde_json::Value>,
55}
56
57impl AgentStepSpec {
58    /// A step that runs `agent` with `prompt`, identified by `task_id`.
59    pub fn new(
60        task_id: impl Into<String>,
61        agent: impl Into<String>,
62        description: impl Into<String>,
63        prompt: impl Into<String>,
64    ) -> Self {
65        Self {
66            task_id: task_id.into(),
67            agent: agent.into(),
68            description: description.into(),
69            prompt: prompt.into(),
70            max_steps: None,
71            parent_session_id: None,
72            output_schema: None,
73        }
74    }
75
76    pub fn with_max_steps(mut self, max_steps: usize) -> Self {
77        self.max_steps = Some(max_steps);
78        self
79    }
80
81    pub fn with_parent_session_id(mut self, parent_session_id: impl Into<String>) -> Self {
82        self.parent_session_id = Some(parent_session_id.into());
83        self
84    }
85
86    /// Require this step to return a value conforming to `schema`.
87    pub fn with_output_schema(mut self, schema: serde_json::Value) -> Self {
88        self.output_schema = Some(schema);
89        self
90    }
91}
92
93/// The result of running one [`AgentStepSpec`] to completion.
94///
95/// `structured` is `Some` only when the spec carried an `output_schema` and
96/// the executor produced a value validated against it. (`serde_json::Value`
97/// is not `Eq`, so this derives `PartialEq` only.)
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99pub struct StepOutcome {
100    pub task_id: String,
101    pub session_id: String,
102    pub agent: String,
103    pub output: String,
104    pub success: bool,
105    /// Schema-validated structured output, when the step requested one.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub structured: Option<serde_json::Value>,
108    /// Source anchors observed by successful child tool calls.
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub source_anchors: Vec<ToolSourceAnchor>,
111}
112
113impl StepOutcome {
114    /// A failed outcome for a step that could not start (e.g. unknown agent)
115    /// or whose fan-out branch panicked. `session_id` mirrors the id the
116    /// local executor would have derived, so failed steps remain addressable.
117    pub fn failed(
118        task_id: impl Into<String>,
119        agent: impl Into<String>,
120        message: impl Into<String>,
121    ) -> Self {
122        let task_id = task_id.into();
123        let session_id = format!("task-run-{task_id}");
124        Self {
125            task_id,
126            session_id,
127            agent: agent.into(),
128            output: message.into(),
129            success: false,
130            structured: None,
131            source_anchors: Vec::new(),
132        }
133    }
134}
135
136/// Runs agent steps — the seam between the framework's orchestration grammar
137/// and the host's placement / transport / scheduling.
138///
139/// The in-box [`TaskExecutor`](crate::tools::TaskExecutor) runs every step
140/// locally (in-process, tokio). A host such as a cluster runtime implements this trait to
141/// place steps on remote nodes; the orchestration combinators are written
142/// purely against the trait and never observe where a step actually ran. The
143/// framework deliberately does **not** own placement, transport, or
144/// cross-node scheduling — those are the host's.
145#[async_trait]
146pub trait AgentExecutor: Send + Sync {
147    /// Run one step to completion.
148    ///
149    /// Failures are reported as `StepOutcome { success: false, .. }` rather
150    /// than a hard error, so a fan-out can continue when one branch fails.
151    /// `event_tx`, when present, receives the step's lifecycle/progress
152    /// [`AgentEvent`]s.
153    async fn execute_step(
154        &self,
155        spec: AgentStepSpec,
156        event_tx: Option<broadcast::Sender<AgentEvent>>,
157    ) -> StepOutcome;
158
159    /// Advisory ceiling on how many steps the orchestration layer should run
160    /// concurrently. The local default returns its `max_parallel_tasks`; a
161    /// scheduler-backed host may return its cluster-wide target. It is a
162    /// *hint*, not a hard local bound — that is what lets orchestration scale
163    /// past a single process.
164    fn concurrency_hint(&self) -> usize {
165        DEFAULT_MAX_PARALLEL_TASKS
166    }
167}
168
169/// Fan `specs` out across the executor, bounded by its
170/// [`concurrency_hint`](AgentExecutor::concurrency_hint), preserving input
171/// order. A panicked branch becomes a failed [`StepOutcome`] without dropping
172/// the others.
173///
174/// This is the barrier (`parallel`) primitive — it awaits every step. Later
175/// combinators (pipeline, phases) build on the same seam.
176pub async fn execute_steps_parallel(
177    executor: Arc<dyn AgentExecutor>,
178    specs: Vec<AgentStepSpec>,
179    event_tx: Option<broadcast::Sender<AgentEvent>>,
180) -> Vec<StepOutcome> {
181    let limit = executor.concurrency_hint();
182    // Keep (task_id, agent) by index so a panicked branch still yields a
183    // correctly-labelled failed outcome (mirrors TaskExecutor's fallback).
184    let labels: Vec<(String, String)> = specs
185        .iter()
186        .map(|s| (s.task_id.clone(), s.agent.clone()))
187        .collect();
188
189    let results = run_ordered_parallel_with_limit(specs, limit, move |_idx, spec| {
190        let executor = Arc::clone(&executor);
191        let event_tx = event_tx.clone();
192        async move { executor.execute_step(spec, event_tx).await }
193    })
194    .await;
195
196    results
197        .into_iter()
198        .map(|result| match result.output {
199            Ok(outcome) => outcome,
200            Err(error) => {
201                let (task_id, agent) = labels
202                    .get(result.index)
203                    .cloned()
204                    .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
205                StepOutcome::failed(task_id, agent, error.to_string())
206            }
207        })
208        .collect()
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use std::sync::atomic::{AtomicUsize, Ordering};
215    use std::time::Duration;
216
217    /// Executor with no LLM — records peak concurrency and synthesizes
218    /// outcomes from the spec, so the combinator can be tested in isolation.
219    struct MockExecutor {
220        hint: usize,
221        active: Arc<AtomicUsize>,
222        max_active: Arc<AtomicUsize>,
223    }
224
225    impl MockExecutor {
226        fn new(hint: usize) -> Self {
227            Self {
228                hint,
229                active: Arc::new(AtomicUsize::new(0)),
230                max_active: Arc::new(AtomicUsize::new(0)),
231            }
232        }
233    }
234
235    #[async_trait]
236    impl AgentExecutor for MockExecutor {
237        async fn execute_step(
238            &self,
239            spec: AgentStepSpec,
240            _event_tx: Option<broadcast::Sender<AgentEvent>>,
241        ) -> StepOutcome {
242            let now = self.active.fetch_add(1, Ordering::SeqCst) + 1;
243            self.max_active.fetch_max(now, Ordering::SeqCst);
244            tokio::time::sleep(Duration::from_millis(20)).await;
245            self.active.fetch_sub(1, Ordering::SeqCst);
246
247            // `boom` panics (exercise branch-panic isolation); `fail`
248            // returns an unsuccessful outcome.
249            assert!(spec.agent != "boom", "boom");
250            StepOutcome {
251                task_id: spec.task_id.clone(),
252                session_id: format!("task-run-{}", spec.task_id),
253                agent: spec.agent.clone(),
254                output: format!("ran: {}", spec.prompt),
255                success: spec.agent != "fail",
256                structured: None,
257                source_anchors: Vec::new(),
258            }
259        }
260        fn concurrency_hint(&self) -> usize {
261            self.hint
262        }
263    }
264
265    fn spec(id: &str, agent: &str) -> AgentStepSpec {
266        AgentStepSpec::new(id, agent, "d", format!("prompt-{id}"))
267    }
268
269    #[tokio::test]
270    async fn fans_out_in_input_order() {
271        let exec: Arc<dyn AgentExecutor> = Arc::new(MockExecutor::new(8));
272        let specs = vec![spec("a", "explore"), spec("b", "review"), spec("c", "plan")];
273        let out = execute_steps_parallel(exec, specs, None).await;
274        assert_eq!(
275            out.iter().map(|o| o.task_id.as_str()).collect::<Vec<_>>(),
276            vec!["a", "b", "c"],
277            "results preserve input order"
278        );
279        assert!(out.iter().all(|o| o.success));
280        assert_eq!(out[0].output, "ran: prompt-a");
281    }
282
283    #[tokio::test]
284    async fn respects_concurrency_hint() {
285        let mock = MockExecutor::new(2);
286        let max_active = Arc::clone(&mock.max_active);
287        let exec: Arc<dyn AgentExecutor> = Arc::new(mock);
288        let specs = (0..6).map(|i| spec(&i.to_string(), "explore")).collect();
289        let _ = execute_steps_parallel(exec, specs, None).await;
290        assert_eq!(
291            max_active.load(Ordering::SeqCst),
292            2,
293            "never more than concurrency_hint steps run at once"
294        );
295    }
296
297    #[tokio::test]
298    async fn isolates_failed_and_panicked_steps() {
299        let exec: Arc<dyn AgentExecutor> = Arc::new(MockExecutor::new(8));
300        let specs = vec![
301            spec("ok", "explore"),
302            spec("bad", "fail"),
303            spec("crash", "boom"),
304            spec("ok2", "review"),
305        ];
306        let out = execute_steps_parallel(exec, specs, None).await;
307        assert_eq!(out.len(), 4, "every step yields a result");
308        assert!(out[0].success);
309        assert!(
310            !out[1].success,
311            "explicit failure surfaces as success=false"
312        );
313        assert!(
314            !out[2].success && out[2].agent == "boom",
315            "a panicked branch becomes a labelled failed outcome, not a drop"
316        );
317        assert!(out[3].success, "later steps unaffected by an earlier panic");
318    }
319
320    #[tokio::test]
321    async fn default_concurrency_hint_is_the_framework_default() {
322        struct Bare;
323        #[async_trait]
324        impl AgentExecutor for Bare {
325            async fn execute_step(
326                &self,
327                spec: AgentStepSpec,
328                _tx: Option<broadcast::Sender<AgentEvent>>,
329            ) -> StepOutcome {
330                StepOutcome::failed(spec.task_id, spec.agent, "unused")
331            }
332        }
333        assert_eq!(Bare.concurrency_hint(), DEFAULT_MAX_PARALLEL_TASKS);
334    }
335
336    #[test]
337    fn spec_and_outcome_round_trip_including_new_optional_fields() {
338        let schema = serde_json::json!({
339            "type": "object",
340            "properties": { "v": { "type": "string" } },
341            "required": ["v"]
342        });
343        let spec = AgentStepSpec::new("t1", "explore", "d", "p")
344            .with_max_steps(3)
345            .with_parent_session_id("parent")
346            .with_output_schema(schema.clone());
347        let back: AgentStepSpec =
348            serde_json::from_str(&serde_json::to_string(&spec).unwrap()).unwrap();
349        assert_eq!(back, spec);
350        assert_eq!(back.output_schema, Some(schema));
351
352        let outcome = StepOutcome {
353            task_id: "t1".into(),
354            session_id: "task-run-t1".into(),
355            agent: "explore".into(),
356            output: "ok".into(),
357            success: true,
358            structured: Some(serde_json::json!({ "v": "x" })),
359            source_anchors: vec![ToolSourceAnchor {
360                tool: "read".into(),
361                url_or_path: "docs/source.md".into(),
362            }],
363        };
364        let back: StepOutcome =
365            serde_json::from_str(&serde_json::to_string(&outcome).unwrap()).unwrap();
366        assert_eq!(back, outcome);
367
368        // Backward-compat: an older payload lacking the new optional fields
369        // still loads with empty defaults.
370        let old_spec: AgentStepSpec =
371            serde_json::from_str(r#"{"task_id":"t","agent":"a","description":"d","prompt":"p"}"#)
372                .unwrap();
373        assert_eq!(old_spec.output_schema, None);
374        let old_outcome: StepOutcome = serde_json::from_str(
375            r#"{"task_id":"t","session_id":"s","agent":"a","output":"o","success":true}"#,
376        )
377        .unwrap();
378        assert_eq!(old_outcome.structured, None);
379        assert!(old_outcome.source_anchors.is_empty());
380    }
381}