1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
16pub struct ToolSourceAnchor {
17 pub tool: String,
20 pub url_or_path: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct AgentStepSpec {
34 pub task_id: String,
37 pub agent: String,
39 pub description: String,
41 pub prompt: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub max_steps: Option<usize>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub parent_session_id: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub output_schema: Option<serde_json::Value>,
55}
56
57impl AgentStepSpec {
58 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 pub fn with_output_schema(mut self, schema: serde_json::Value) -> Self {
88 self.output_schema = Some(schema);
89 self
90 }
91}
92
93#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub structured: Option<serde_json::Value>,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub source_anchors: Vec<ToolSourceAnchor>,
111}
112
113impl StepOutcome {
114 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#[async_trait]
146pub trait AgentExecutor: Send + Sync {
147 async fn execute_step(
154 &self,
155 spec: AgentStepSpec,
156 event_tx: Option<broadcast::Sender<AgentEvent>>,
157 ) -> StepOutcome;
158
159 fn concurrency_hint(&self) -> usize {
165 DEFAULT_MAX_PARALLEL_TASKS
166 }
167}
168
169pub 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 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 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 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 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}