Skip to main content

heartbit_core/agent/
workflow_tool.rs

1//! Named workflow registry + the `run_workflow` tool (option C, tier 3).
2//!
3//! The `flow/` combinators are developer-authored Rust — the LLM cannot write a
4//! workflow at runtime. So the agent reaches workflows the Claude-Code way:
5//! a developer pre-registers named **recipes**, and the agent picks one by name
6//! plus args via the `run_workflow` tool. A recipe drives the `flow/`
7//! combinators (agent, parallel, pipeline) and returns a text result — the
8//! structured, repeatable fan-out that plain `delegate_task` lacks (multi-stage
9//! pipeline, shared budget, journal/resume).
10
11use 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
23/// The async body of a recipe: given a built [`WorkflowCtx`] and the
24/// LLM-supplied `args`, drive `flow/` combinators and return a text result.
25pub type RecipeRun = Arc<
26    dyn Fn(WorkflowCtx, Value) -> Pin<Box<dyn Future<Output = Result<String, Error>> + Send>>
27        + Send
28        + Sync,
29>;
30
31/// A named, agent-invocable workflow recipe.
32#[derive(Clone)]
33pub struct WorkflowRecipe {
34    /// Stable identifier the agent passes as `recipe`.
35    pub name: String,
36    /// One-line description shown to the agent (how/when to use it).
37    pub description: String,
38    /// JSON Schema for the recipe's `args` (advisory — shown to the agent).
39    pub args_schema: Value,
40    /// The recipe body.
41    pub run: RecipeRun,
42}
43
44/// Registry of recipes the [`RunWorkflowTool`] exposes.
45#[derive(Clone, Default)]
46pub struct WorkflowRegistry {
47    recipes: Vec<WorkflowRecipe>,
48}
49
50impl WorkflowRegistry {
51    /// An empty registry.
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Register a recipe (builder-style).
57    pub fn register(mut self, recipe: WorkflowRecipe) -> Self {
58        self.recipes.push(recipe);
59        self
60    }
61
62    /// Look up a recipe by name.
63    pub fn get(&self, name: &str) -> Option<&WorkflowRecipe> {
64        self.recipes.iter().find(|r| r.name == name)
65    }
66
67    /// True when no recipes are registered (→ the tool is not registered).
68    pub fn is_empty(&self) -> bool {
69        self.recipes.is_empty()
70    }
71
72    /// `(name, description)` pairs to advertise in the entry-agent prompt.
73    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
81/// The `run_workflow` tool: the agent picks a recipe by name + args.
82pub 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    /// Build the tool over a registry and the shared provider used to run the
93    /// recipe's `flow/` agents.
94    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    /// Forward every recipe-internal agent's event stream (tool calls, LLM
106    /// responses, run lifecycle) to `sink` — e.g. the TUI trace. Without this,
107    /// a recipe run is a single opaque tool call to any observer.
108    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    /// Install the per-call model resolver threaded into every recipe ctx —
114    /// lets recipes run stages on role-named models (`.model("fast")`).
115    pub fn with_provider_factory(mut self, factory: Arc<super::flow::ProviderFactory>) -> Self {
116        self.provider_factory = Some(factory);
117        self
118    }
119
120    /// Workspace (git repo root) threaded into every recipe ctx — required
121    /// for recipes whose agents ask for `Isolation::Worktree`.
122    pub fn with_workspace(mut self, root: std::path::PathBuf) -> Self {
123        self.workspace = Some(root);
124        self
125    }
126
127    /// Enable resume: each run journals its agent outputs under `dir`, keyed
128    /// by recipe + canonicalized args. Re-invoking the SAME call replays the
129    /// completed agents at zero cost and continues the rest — an interrupted
130    /// workflow picks up where it left off (scope the dir per session to avoid
131    /// stale cross-session replays).
132    pub fn with_journal_dir(mut self, dir: std::path::PathBuf) -> Self {
133        self.journal_dir = Some(dir);
134        self
135    }
136}
137
138/// `wf-{recipe}-{sha256(canonical args)[..12]}.jsonl` — content-addressed so a
139/// re-ask with identical inputs resumes, while ANY args change runs fresh.
140fn 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            // A run-level breach (budget / agent backstop) must surface even
253            // when the recipe collapsed the failed slots into degraded text.
254            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
269/// Built-in recipes.
270pub mod recipes {
271    use super::*;
272
273    /// Default lenses applied by `parallel_review` when the caller gives none.
274    const DEFAULT_LENSES: &[&str] = &["correctness", "security", "clarity"];
275
276    /// Review a target (a file path, a diff, or a described change) from several
277    /// INDEPENDENT lenses in parallel, then concatenate the findings. Exercises
278    /// the `flow::parallel` combinator under the shared budget.
279    ///
280    /// Args: `{ "target": string (required), "lenses"?: [string] }`.
281    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
358/// A default registry for the TUI entry agent: the built-in recipes.
359pub 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    /// Always answers with a fixed text — order-independent for concurrent calls.
387    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        // Both lenses appear as sections, each carrying the agent's output.
442        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        // Each lens agent's full runner lifecycle must reach the sink, keyed by
482        // its flow label (the trace's `agent` field).
483        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    /// A recipe with two SEQUENTIAL agents — deterministic budget breach: the
495    /// first call records its spend, the second is refused admission.
496    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    /// Fixed text with REAL token usage (so the budget pool actually fills).
512    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    /// Counts provider calls — the resume test's whole point is that the
561    /// SECOND invocation makes ZERO new ones.
562    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        // Re-invocation: same recipe+args → both agents replay, zero new calls.
598        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        // DIFFERENT args → different journal → fresh run.
611        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    /// A recipe whose single agent asks for worktree isolation.
627    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        // Fixture repo (worktrees need a HEAD).
648        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        // WITH workspace: the isolated agent runs.
666        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        // WITHOUT workspace: the leaf's Config error surfaces honestly.
674        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}