Skip to main content

heartbit_core/agent/flow/
mod.rs

1//! Dynamic-workflow combinator core (P1).
2//!
3//! A pure-Rust async combinator layer that reproduces the *semantics* of
4//! Claude Code "dynamic workflows" — [`parallel`] (barrier, fail-soft),
5//! [`pipeline`] (no-barrier per-item streaming), [`phase`]/[`log`]
6//! observability, and a run-wide concurrency cap — on top of the existing
7//! [`AgentRunner`](crate::AgentRunner). A "workflow" is ordinary developer-written
8//! async Rust against these free functions; we do **not** embed a script
9//! engine, IR, or macro DSL.
10//!
11//! These primitives are the deliberate *duals* of the compile-time workflow
12//! agents in [`crate::agent::workflow`]: [`parallel`] is fail-SOFT where
13//! [`ParallelAgent`](crate::ParallelAgent) is fail-FAST, and [`pipeline`] has
14//! NO inter-item barrier where [`SequentialAgent`](crate::SequentialAgent)
15//! advances stage-by-stage. Both layers coexist; nothing existing is changed.
16//!
17//! # Load-bearing invariant
18//!
19//! The `Err -> None` collapse lives **only** in [`parallel`]/[`pipeline`] at the
20//! join/stage boundary, **never** inside [`agent`]. If `agent()` swallowed
21//! errors, the concurrency backstop (and, in later phases, the token budget)
22//! would stop propagating and silently break. Control errors must reach the
23//! caller; only agent-domain failures collapse to `None`.
24
25pub mod agent;
26pub mod budget;
27pub mod ctx;
28pub mod event;
29pub mod journal;
30pub mod parallel;
31pub mod pipeline;
32pub mod progress;
33pub mod worktree;
34
35#[cfg(test)]
36mod audit_gap_tests;
37
38// Re-export the free functions at the `flow` level so callers write
39// `flow::agent(..)`, `flow::parallel(..)`, etc. A module and a same-named
40// function coexist (type vs value namespace), so `flow::agent` is the module in
41// path position and the function in call position.
42pub use agent::agent;
43pub use ctx::{ProviderFactory, WorkflowCtx};
44pub use event::{log, phase};
45pub use parallel::{parallel, thunk};
46pub use pipeline::pipeline;
47
48/// Run `body` as a nested sub-workflow sharing this run's budget, journal,
49/// concurrency cap, runaway backstop, and cancellation. Exactly **one** level of
50/// nesting is allowed: calling `workflow()` from inside a `body` returns
51/// [`Error::Config`](crate::Error::Config).
52///
53/// The child receives a fresh [`WorkflowCtx`] whose default-phase is independent
54/// of the parent's, but whose limits and journal are the *same shared state*, so
55/// a child's spend counts against the parent budget and a child's breach halts
56/// the whole run.
57///
58/// ```ignore
59/// let summary = workflow(&ctx, "research", |child| async move {
60///     let findings = parallel(&child, finders).await;
61///     agent(&child, summarize(findings)).run().await
62/// }).await?;
63/// ```
64pub async fn workflow<R, F, Fut>(
65    ctx: &WorkflowCtx,
66    _name: impl Into<String>,
67    body: F,
68) -> Result<R, crate::error::Error>
69where
70    F: FnOnce(WorkflowCtx) -> Fut,
71    Fut: std::future::Future<Output = Result<R, crate::error::Error>>,
72{
73    let child = ctx.nested()?;
74    body(child).await
75}
76
77#[cfg(test)]
78mod smoke_tests {
79    //! End-to-end smoke test exercising the whole P1 surface through the
80    //! **public crate-root re-exports** (`crate::WorkflowCtx`, `crate::agent`,
81    //! `crate::parallel`, `crate::pipeline`, `crate::phase`), proving the wiring
82    //! in `lib.rs` resolves and the combinators compose.
83
84    use std::sync::Arc;
85
86    use serde_json::Value;
87
88    use crate::agent::test_helpers::MockProvider;
89    use crate::llm::BoxedProvider;
90
91    #[tokio::test]
92    async fn p1_surface_composes_through_public_paths() {
93        // Mock provider with plenty of responses (single + 2 parallel + 2 pipeline).
94        let responses: Vec<_> = (0..6)
95            .map(|i| MockProvider::text_response(&format!("r{i}"), 5, 3))
96            .collect();
97        let provider = Arc::new(BoxedProvider::new(MockProvider::new(responses)));
98        // Root-level type re-export.
99        let ctx = crate::WorkflowCtx::builder(provider)
100            .max_concurrency(4)
101            .build()
102            .expect("build ctx");
103
104        // Free functions via the `crate::flow::` namespace.
105        // phase() returns an RAII guard; agents issued under it adopt the phase.
106        let _phase = crate::flow::phase(&ctx, "smoke");
107
108        // Single agent leaf.
109        let single = crate::flow::agent(&ctx, "solo")
110            .label("solo")
111            .run()
112            .await
113            .expect("single ok");
114        assert!(single.is_some());
115
116        // Fail-soft barrier fan-out over a homogeneous work-list (no boxing).
117        let thunks: Vec<_> = (0..2)
118            .map(|i| {
119                let ctx = ctx.clone();
120                move || async move { crate::flow::agent(&ctx, format!("p{i}")).run().await }
121            })
122            .collect();
123        let fanned = crate::flow::parallel(&ctx, thunks).await;
124        assert_eq!(fanned.iter().filter(|o| o.is_some()).count(), 2);
125
126        // No-barrier pipeline whose stage calls an agent leaf.
127        let piped = crate::flow::pipeline(&ctx, vec![10usize, 20usize])
128            .stage(move |_prev, item, _idx| {
129                let ctx = ctx.clone();
130                async move {
131                    let text = crate::flow::agent(&ctx, format!("item {item}"))
132                        .run()
133                        .await?
134                        .unwrap_or_default();
135                    Ok(Value::String(text))
136                }
137            })
138            .run()
139            .await;
140        assert_eq!(piped.iter().filter(|o| o.is_some()).count(), 2);
141    }
142}