1use std::future::Future;
12use std::pin::Pin;
13use std::sync::Arc;
14
15use serde_json::{Value, json};
16
17use super::flow::{WorkflowCtx, agent, parallel, thunk};
18use crate::error::Error;
19use crate::llm::BoxedProvider;
20use crate::llm::types::ToolDefinition;
21use crate::tool::{Tool, ToolOutput};
22
23pub type RecipeRun = Arc<
26 dyn Fn(WorkflowCtx, Value) -> Pin<Box<dyn Future<Output = Result<String, Error>> + Send>>
27 + Send
28 + Sync,
29>;
30
31#[derive(Clone)]
33pub struct WorkflowRecipe {
34 pub name: String,
36 pub description: String,
38 pub args_schema: Value,
40 pub run: RecipeRun,
42}
43
44#[derive(Clone, Default)]
46pub struct WorkflowRegistry {
47 recipes: Vec<WorkflowRecipe>,
48}
49
50impl WorkflowRegistry {
51 pub fn new() -> Self {
53 Self::default()
54 }
55
56 pub fn register(mut self, recipe: WorkflowRecipe) -> Self {
58 self.recipes.push(recipe);
59 self
60 }
61
62 pub fn get(&self, name: &str) -> Option<&WorkflowRecipe> {
64 self.recipes.iter().find(|r| r.name == name)
65 }
66
67 pub fn is_empty(&self) -> bool {
69 self.recipes.is_empty()
70 }
71
72 pub fn meta(&self) -> Vec<(String, String)> {
74 self.recipes
75 .iter()
76 .map(|r| (r.name.clone(), r.description.clone()))
77 .collect()
78 }
79}
80
81pub struct RunWorkflowTool {
83 registry: WorkflowRegistry,
84 provider: Arc<BoxedProvider>,
85 agent_events: Option<Arc<crate::agent::events::OnEvent>>,
86 provider_factory: Option<Arc<super::flow::ProviderFactory>>,
87 journal_dir: Option<std::path::PathBuf>,
88 workspace: Option<std::path::PathBuf>,
89}
90
91impl RunWorkflowTool {
92 pub fn new(registry: WorkflowRegistry, provider: Arc<BoxedProvider>) -> Self {
95 Self {
96 registry,
97 provider,
98 agent_events: None,
99 provider_factory: None,
100 journal_dir: None,
101 workspace: None,
102 }
103 }
104
105 pub fn with_agent_events(mut self, sink: Arc<crate::agent::events::OnEvent>) -> Self {
109 self.agent_events = Some(sink);
110 self
111 }
112
113 pub fn with_provider_factory(mut self, factory: Arc<super::flow::ProviderFactory>) -> Self {
116 self.provider_factory = Some(factory);
117 self
118 }
119
120 pub fn with_workspace(mut self, root: std::path::PathBuf) -> Self {
123 self.workspace = Some(root);
124 self
125 }
126
127 pub fn with_journal_dir(mut self, dir: std::path::PathBuf) -> Self {
133 self.journal_dir = Some(dir);
134 self
135 }
136}
137
138fn journal_file_name(recipe: &str, args: &Value) -> String {
141 use sha2::{Digest, Sha256};
142 let canonical = super::flow::journal::canonical_json(args);
143 let mut h = Sha256::new();
144 h.update(canonical.to_string().as_bytes());
145 let digest = h.finalize();
146 let hex: String = digest.iter().take(6).map(|b| format!("{b:02x}")).collect();
147 format!("wf-{recipe}-{hex}.jsonl")
148}
149
150impl Tool for RunWorkflowTool {
151 fn definition(&self) -> ToolDefinition {
152 let names: Vec<String> = self
153 .registry
154 .recipes
155 .iter()
156 .map(|r| r.name.clone())
157 .collect();
158 let list = self
159 .registry
160 .recipes
161 .iter()
162 .map(|r| format!("- {}: {}", r.name, r.description))
163 .collect::<Vec<_>>()
164 .join("\n");
165 ToolDefinition {
166 name: "run_workflow".into(),
167 description: format!(
168 "Launch a named multi-step workflow recipe for structured, repeatable fan-out \
169 (parallel/staged sub-agents with a shared budget). Pick one by name and pass \
170 its args. Recipes:\n{list}"
171 ),
172 input_schema: json!({
173 "type": "object",
174 "properties": {
175 "recipe": {
176 "type": "string",
177 "enum": names,
178 "description": "Which recipe to run."
179 },
180 "args": {
181 "type": "object",
182 "description": "Recipe-specific arguments (see the recipe description)."
183 },
184 "budget": {
185 "type": "integer",
186 "minimum": 1,
187 "description": "Optional hard token budget for the whole workflow \
188 (cost-weighted token-equivalents). Use when the user \
189 asks to cap spend, e.g. 'use 10k tokens' => 10000."
190 }
191 },
192 "required": ["recipe"]
193 }),
194 }
195 }
196
197 fn execute(
198 &self,
199 _ctx: &crate::ExecutionContext,
200 input: Value,
201 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
202 let recipe_name = input
203 .get("recipe")
204 .and_then(|v| v.as_str())
205 .unwrap_or_default()
206 .to_string();
207 let args = input.get("args").cloned().unwrap_or_else(|| json!({}));
208
209 let budget = input.get("budget").and_then(|v| v.as_u64());
210 let recipe = self.registry.get(&recipe_name).cloned();
211 let provider = self.provider.clone();
212 let agent_events = self.agent_events.clone();
213 let provider_factory = self.provider_factory.clone();
214 let journal_dir = self.journal_dir.clone();
215 let workspace = self.workspace.clone();
216 Box::pin(async move {
217 let Some(recipe) = recipe else {
218 return Ok(ToolOutput::error(format!(
219 "unknown workflow recipe '{recipe_name}'"
220 )));
221 };
222 let mut builder = WorkflowCtx::builder(provider);
223 if let Some(sink) = agent_events {
224 builder = builder.on_agent_event(sink);
225 }
226 if let Some(n) = budget {
227 builder = builder.budget(n);
228 }
229 if let Some(factory) = provider_factory {
230 builder = builder.provider_factory(factory);
231 }
232 if let Some(root) = workspace {
233 builder = builder.workspace(root);
234 }
235 if let Some(dir) = journal_dir {
236 if let Err(e) = std::fs::create_dir_all(&dir) {
237 return Ok(ToolOutput::error(format!("workflow journal dir: {e}")));
238 }
239 let path = dir.join(journal_file_name(&recipe_name, &args));
240 builder = match builder.journal(&path, super::flow::journal::ResumeMode::Resume) {
241 Ok(b) => b,
242 Err(e) => {
243 return Ok(ToolOutput::error(format!("workflow journal: {e}")));
244 }
245 };
246 }
247 let ctx = match builder.build() {
248 Ok(c) => c,
249 Err(e) => return Ok(ToolOutput::error(format!("workflow setup failed: {e}"))),
250 };
251 let result = (recipe.run)(ctx.clone(), args).await;
252 if let Some(breach) = ctx.control_breach() {
255 return Ok(ToolOutput::error(format!(
256 "workflow '{recipe_name}' halted by a run-level limit: {breach:?}"
257 )));
258 }
259 match result {
260 Ok(text) => Ok(ToolOutput::success(text)),
261 Err(e) => Ok(ToolOutput::error(format!(
262 "workflow '{recipe_name}' failed: {e}"
263 ))),
264 }
265 })
266 }
267}
268
269pub mod recipes {
271 use super::*;
272
273 const DEFAULT_LENSES: &[&str] = &["correctness", "security", "clarity"];
275
276 pub fn parallel_review() -> WorkflowRecipe {
282 WorkflowRecipe {
283 name: "parallel_review".into(),
284 description: "Review a target (file, diff, or described change) from multiple \
285 independent lenses (correctness/security/clarity) in parallel, then \
286 synthesize the findings."
287 .into(),
288 args_schema: json!({
289 "type": "object",
290 "properties": {
291 "target": {"type": "string", "description": "What to review (path, diff, or description)."},
292 "lenses": {"type": "array", "items": {"type": "string"}, "description": "Review perspectives (default: correctness, security, clarity)."}
293 },
294 "required": ["target"]
295 }),
296 run: Arc::new(|ctx, args| {
297 Box::pin(async move {
298 let target = args
299 .get("target")
300 .and_then(|v| v.as_str())
301 .unwrap_or_default()
302 .to_string();
303 if target.trim().is_empty() {
304 return Err(Error::Agent("parallel_review: 'target' is required".into()));
305 }
306 let lenses: Vec<String> = args
307 .get("lenses")
308 .and_then(|v| v.as_array())
309 .map(|a| {
310 a.iter()
311 .filter_map(|x| x.as_str().map(String::from))
312 .collect::<Vec<_>>()
313 })
314 .filter(|v| !v.is_empty())
315 .unwrap_or_else(|| DEFAULT_LENSES.iter().map(|s| s.to_string()).collect());
316
317 let thunks: Vec<_> = lenses
318 .iter()
319 .cloned()
320 .map(|lens| {
321 let ctx = ctx.clone();
322 let target = target.clone();
323 thunk(move || async move {
324 agent(
325 &ctx,
326 format!(
327 "Review the following from the **{lens}** perspective \
328 only. Be concise and concrete — list specific issues or \
329 confirm it is sound.\n\n{target}"
330 ),
331 )
332 .label(format!("review:{lens}"))
333 .run()
334 .await
335 })
336 })
337 .collect();
338
339 let results = parallel(&ctx, thunks).await;
340
341 let findings = lenses
342 .iter()
343 .zip(results)
344 .map(|(lens, slot)| {
345 let body = slot.flatten().unwrap_or_else(|| "(no output)".to_string());
346 format!("### {lens}\n{body}")
347 })
348 .collect::<Vec<_>>()
349 .join("\n\n");
350
351 Ok(format!("# Parallel review of: {target}\n\n{findings}"))
352 })
353 }),
354 }
355 }
356}
357
358pub fn default_registry() -> WorkflowRegistry {
360 WorkflowRegistry::new()
361 .register(recipes::parallel_review())
362 .register(crate::agent::deep_research::recipe())
363 .register(crate::agent::intake::recipe())
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::ExecutionContext;
370 use crate::llm::LlmProvider;
371 use crate::llm::types::{
372 CompletionRequest, CompletionResponse, ContentBlock, StopReason, TokenUsage,
373 };
374
375 #[test]
376 fn registry_get_and_meta() {
377 let reg = default_registry();
378 assert!(!reg.is_empty());
379 assert!(reg.get("parallel_review").is_some());
380 assert!(reg.get("deep_research").is_some());
381 assert!(reg.get("nope").is_none());
382 let meta = reg.meta();
383 assert!(meta.iter().any(|(n, _)| n == "parallel_review"));
384 }
385
386 struct AlwaysText(String);
388 impl LlmProvider for AlwaysText {
389 async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
390 Ok(CompletionResponse {
391 content: vec![ContentBlock::Text {
392 text: self.0.clone(),
393 }],
394 stop_reason: StopReason::EndTurn,
395 reasoning: None,
396 usage: TokenUsage::default(),
397 model: None,
398 })
399 }
400 }
401
402 fn provider() -> Arc<BoxedProvider> {
403 Arc::new(BoxedProvider::new(AlwaysText("LENS-OK".into())))
404 }
405
406 #[test]
407 fn tool_definition_lists_recipe_names() {
408 let tool = RunWorkflowTool::new(default_registry(), provider());
409 let def = tool.definition();
410 assert_eq!(def.name, "run_workflow");
411 let enum_names = def.input_schema["properties"]["recipe"]["enum"]
412 .as_array()
413 .unwrap();
414 assert!(enum_names.iter().any(|v| v == "parallel_review"));
415 }
416
417 #[tokio::test]
418 async fn unknown_recipe_is_an_error_output() {
419 let tool = RunWorkflowTool::new(default_registry(), provider());
420 let out = tool
421 .execute(
422 &ExecutionContext::default(),
423 json!({"recipe": "does_not_exist"}),
424 )
425 .await
426 .unwrap();
427 assert!(out.is_error);
428 }
429
430 #[tokio::test]
431 async fn parallel_review_fans_out_lenses() {
432 let tool = RunWorkflowTool::new(default_registry(), provider());
433 let out = tool
434 .execute(
435 &ExecutionContext::default(),
436 json!({"recipe": "parallel_review", "args": {"target": "fn foo() {}", "lenses": ["a", "b"]}}),
437 )
438 .await
439 .unwrap();
440 assert!(!out.is_error, "got: {}", out.content);
441 assert!(out.content.contains("### a"), "{}", out.content);
443 assert!(out.content.contains("### b"), "{}", out.content);
444 assert!(out.content.contains("LENS-OK"), "{}", out.content);
445 }
446
447 #[tokio::test]
448 async fn parallel_review_requires_target() {
449 let tool = RunWorkflowTool::new(default_registry(), provider());
450 let out = tool
451 .execute(
452 &ExecutionContext::default(),
453 json!({"recipe": "parallel_review", "args": {}}),
454 )
455 .await
456 .unwrap();
457 assert!(out.is_error, "missing target must error");
458 }
459
460 #[tokio::test(flavor = "multi_thread")]
461 async fn agent_event_sink_observes_recipe_internal_agents() {
462 use crate::agent::events::{AgentEvent, OnEvent};
463 use std::sync::Mutex;
464
465 let captured: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
466 let sink: Arc<OnEvent> = {
467 let captured = Arc::clone(&captured);
468 Arc::new(move |ev| captured.lock().expect("lock").push(ev))
469 };
470 let tool = RunWorkflowTool::new(default_registry(), provider()).with_agent_events(sink);
471 let out = tool
472 .execute(
473 &ExecutionContext::default(),
474 json!({"recipe": "parallel_review", "args": {"target": "fn foo() {}", "lenses": ["a", "b"]}}),
475 )
476 .await
477 .unwrap();
478 assert!(!out.is_error, "got: {}", out.content);
479
480 let events = captured.lock().expect("lock");
481 for lens in ["a", "b"] {
484 let label = format!("review:{lens}");
485 assert!(
486 events.iter().any(
487 |e| matches!(e, AgentEvent::RunCompleted { agent, .. } if *agent == label)
488 ),
489 "missing RunCompleted for {label}"
490 );
491 }
492 }
493
494 fn two_step_recipe() -> WorkflowRecipe {
497 WorkflowRecipe {
498 name: "two_step".into(),
499 description: "test recipe".into(),
500 args_schema: json!({"type": "object"}),
501 run: Arc::new(|ctx, _args| {
502 Box::pin(async move {
503 let a = agent(&ctx, "step one").run().await?.unwrap_or_default();
504 let b = agent(&ctx, "step two").run().await?.unwrap_or_default();
505 Ok(format!("{a}+{b}"))
506 })
507 }),
508 }
509 }
510
511 struct CostlyText;
513 impl LlmProvider for CostlyText {
514 async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
515 Ok(CompletionResponse {
516 content: vec![ContentBlock::Text { text: "x".into() }],
517 stop_reason: StopReason::EndTurn,
518 reasoning: None,
519 usage: TokenUsage {
520 input_tokens: 800,
521 output_tokens: 200,
522 ..Default::default()
523 },
524 model: None,
525 })
526 }
527 }
528
529 #[tokio::test]
530 async fn budget_arg_breaches_and_surfaces_as_tool_error() {
531 let registry = WorkflowRegistry::new().register(two_step_recipe());
532 let tool = RunWorkflowTool::new(registry, Arc::new(BoxedProvider::new(CostlyText)));
533 let out = tool
534 .execute(
535 &ExecutionContext::default(),
536 json!({"recipe": "two_step", "budget": 100}),
537 )
538 .await
539 .unwrap();
540 assert!(out.is_error, "breach must surface: {}", out.content);
541 assert!(
542 out.content.to_lowercase().contains("budget"),
543 "{}",
544 out.content
545 );
546 }
547
548 #[tokio::test]
549 async fn without_budget_the_same_recipe_completes() {
550 let registry = WorkflowRegistry::new().register(two_step_recipe());
551 let tool = RunWorkflowTool::new(registry, Arc::new(BoxedProvider::new(CostlyText)));
552 let out = tool
553 .execute(&ExecutionContext::default(), json!({"recipe": "two_step"}))
554 .await
555 .unwrap();
556 assert!(!out.is_error, "{}", out.content);
557 assert_eq!(out.content, "x+x");
558 }
559
560 struct CountingText(Arc<std::sync::atomic::AtomicUsize>);
563 impl LlmProvider for CountingText {
564 async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
565 self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
566 Ok(CompletionResponse {
567 content: vec![ContentBlock::Text { text: "out".into() }],
568 stop_reason: StopReason::EndTurn,
569 reasoning: None,
570 usage: TokenUsage::default(),
571 model: None,
572 })
573 }
574 }
575
576 #[tokio::test(flavor = "multi_thread")]
577 async fn rerunning_the_same_call_replays_from_the_journal() {
578 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
579 let dir = tempfile::tempdir().unwrap();
580 let registry = WorkflowRegistry::new().register(two_step_recipe());
581 let tool = RunWorkflowTool::new(
582 registry,
583 Arc::new(BoxedProvider::from_arc(Arc::new(CountingText(
584 calls.clone(),
585 )))),
586 )
587 .with_journal_dir(dir.path().to_path_buf());
588
589 let input = json!({"recipe": "two_step", "args": {"k": "v"}});
590 let first = tool
591 .execute(&ExecutionContext::default(), input.clone())
592 .await
593 .unwrap();
594 assert!(!first.is_error, "{}", first.content);
595 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
596
597 let second = tool
599 .execute(&ExecutionContext::default(), input)
600 .await
601 .unwrap();
602 assert!(!second.is_error, "{}", second.content);
603 assert_eq!(second.content, first.content, "identical replayed output");
604 assert_eq!(
605 calls.load(std::sync::atomic::Ordering::SeqCst),
606 2,
607 "the resumed run must make ZERO new provider calls"
608 );
609
610 let third = tool
612 .execute(
613 &ExecutionContext::default(),
614 json!({"recipe": "two_step", "args": {"k": "other"}}),
615 )
616 .await
617 .unwrap();
618 assert!(!third.is_error);
619 assert_eq!(
620 calls.load(std::sync::atomic::Ordering::SeqCst),
621 4,
622 "changed args must NOT replay the old journal"
623 );
624 }
625
626 fn isolated_recipe() -> WorkflowRecipe {
628 WorkflowRecipe {
629 name: "isolated".into(),
630 description: "test".into(),
631 args_schema: json!({"type": "object"}),
632 run: Arc::new(|ctx, _args| {
633 Box::pin(async move {
634 let out = agent(&ctx, "mutate")
635 .isolation(crate::Isolation::Worktree)
636 .run()
637 .await?
638 .unwrap_or_default();
639 Ok(out)
640 })
641 }),
642 }
643 }
644
645 #[tokio::test(flavor = "multi_thread")]
646 async fn worktree_recipes_are_reachable_through_the_tool() {
647 let dir = tempfile::tempdir().unwrap();
649 for args in [
650 vec!["init", "-q"],
651 vec!["config", "user.name", "t"],
652 vec!["config", "user.email", "t@t"],
653 vec!["commit", "--allow-empty", "-q", "-m", "init"],
654 ] {
655 assert!(
656 std::process::Command::new("git")
657 .current_dir(dir.path())
658 .args(&args)
659 .status()
660 .unwrap()
661 .success()
662 );
663 }
664 let registry = WorkflowRegistry::new().register(isolated_recipe());
665 let tool = RunWorkflowTool::new(registry.clone(), provider())
667 .with_workspace(dir.path().to_path_buf());
668 let out = tool
669 .execute(&ExecutionContext::default(), json!({"recipe": "isolated"}))
670 .await
671 .unwrap();
672 assert!(!out.is_error, "{}", out.content);
673 let bare = RunWorkflowTool::new(registry, provider());
675 let out = bare
676 .execute(&ExecutionContext::default(), json!({"recipe": "isolated"}))
677 .await
678 .unwrap();
679 assert!(out.is_error);
680 assert!(out.content.contains("workspace"), "{}", out.content);
681 }
682
683 #[tokio::test]
684 async fn without_sink_recipes_still_run() {
685 let tool = RunWorkflowTool::new(default_registry(), provider());
686 let out = tool
687 .execute(
688 &ExecutionContext::default(),
689 json!({"recipe": "parallel_review", "args": {"target": "x", "lenses": ["a"]}}),
690 )
691 .await
692 .unwrap();
693 assert!(!out.is_error);
694 }
695}