Skip to main content

heartbit_core/agent/flow/
agent.rs

1//! [`agent`] — the atomic unit of a workflow: spawn one sub-agent.
2//!
3//! `agent(&ctx, prompt)` returns a fluent [`AgentCall`] that owns a clone of the
4//! [`WorkflowCtx`] (so the returned `run()` future is `'static` and can be moved
5//! into a [`parallel`](super::parallel)/[`pipeline`](super::pipeline) task). The
6//! leaf is where the run-wide concurrency cap, the runaway backstop, and (in
7//! later phases) the token budget and journal are enforced.
8//!
9//! P1 ships the text terminal only: `run() -> Result<Option<String>, Error>`,
10//! where `Ok(None)` means the call was skipped (e.g. the run was cancelled),
11//! mirroring Claude Code's `null`. The `Err -> None` collapse is the job of the
12//! *combinators*, never of `agent()` — control errors (backstop, cancellation,
13//! and later the budget) must propagate.
14
15use std::marker::PhantomData;
16use std::sync::Arc;
17
18use serde_json::Value;
19
20use crate::agent::{AgentOutput, AgentRunner};
21use crate::error::Error;
22
23use super::ctx::WorkflowCtx;
24use super::event::WorkflowEvent;
25use super::journal;
26
27/// Max ReAct turns for a tool-using flow agent. A tool-less agent finishes in
28/// one turn; a tool user needs at least a ToolUse turn plus a final-answer turn,
29/// so we give it headroom. (Per-call override is a later refinement.)
30const DEFAULT_TOOL_TURNS: usize = 10;
31
32/// A Rust type an [`agent`] can be forced to produce as validated structured
33/// output via [`AgentCall::schema`]. Implementors supply the JSON Schema the
34/// model's `__respond__` payload is validated against; the payload is then
35/// `serde`-deserialized into `Self`, so `serde` enforces everything JSON Schema
36/// cannot express (exact integer widths, enum variants, `deny_unknown_fields`).
37///
38/// Dep-free by design: write [`json_schema`](Self::json_schema) by hand, or
39/// generate it with a future `#[derive(StructuredSchema)]` in `heartbit-macro`.
40/// (We deliberately do *not* pull in `schemars`, whose dialect would have to be
41/// reconciled with the `jsonschema` validator already used by the runner.)
42pub trait StructuredSchema: serde::de::DeserializeOwned + Send {
43    /// The JSON Schema the model output is validated against before deserialization.
44    fn json_schema() -> Value;
45}
46
47/// Marker type for an [`AgentCall`] with no schema: its `run()` yields the
48/// agent's text. This is the default type parameter of [`AgentCall`].
49pub struct NoSchema;
50
51/// Marker type for an [`AgentCall`] given a hand-written `serde_json::Value`
52/// schema via [`AgentCall::schema_value`]: its `run()` yields the raw validated
53/// `Value` (no deserialization into a Rust type).
54pub struct RawJson;
55
56/// Per-call options for an [`agent`] leaf. `#[non_exhaustive]` so later phases
57/// can add fields (schema, model, isolation, …) without breaking callers.
58///
59/// `Debug` is hand-written because `dyn Tool` is not `Debug`; the tools field is
60/// rendered as a count.
61#[derive(Clone, Default)]
62#[non_exhaustive]
63pub struct AgentOpts {
64    /// Display label for events/observability. Defaults to `"agent"`.
65    pub label: Option<String>,
66    /// Explicit phase override. When unset, the leaf adopts the context's
67    /// default phase snapshotted at construction time.
68    pub phase: Option<String>,
69    /// JSON Schema for forced structured output. When set, the underlying
70    /// runner injects `__respond__`, validates the payload, and retries on
71    /// mismatch. Set indirectly via [`AgentCall::schema`] / [`AgentCall::schema_value`].
72    pub schema: Option<Value>,
73    /// Per-call tool set. When `Some`, these tools are wired into the agent
74    /// (overriding the ctx's base tools); when `None`, the ctx base tools (if
75    /// any) are used. Set via [`AgentCall::tools`].
76    pub tools: Option<Vec<Arc<dyn crate::tool::Tool>>>,
77    /// Optional persistent goal: an independent judge gates the leaf's
78    /// completion so it self-continues until the objective is met (bounded by
79    /// the goal's `max_continuations`). Each continuation's spend accrues into
80    /// the leaf's single budget record. Set via [`AgentCall::goal`].
81    pub goal: Option<crate::agent::goal::GoalCondition>,
82    /// Per-call model role or id, resolved by the ctx's
83    /// [`ProviderFactory`](super::ctx::ProviderFactory). No factory installed →
84    /// the leaf degrades to the shared provider (with a log line). Set via
85    /// [`AgentCall::model`].
86    pub model: Option<String>,
87    /// Filesystem isolation for this leaf. [`Isolation::Worktree`] runs it in
88    /// its own git worktree of the ctx workspace (requires
89    /// [`WorkflowCtxBuilder::workspace`](super::ctx::WorkflowCtxBuilder::workspace)).
90    /// Set via [`AgentCall::isolation`].
91    pub isolation: Isolation,
92}
93
94/// Filesystem isolation level for one agent leaf.
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
96pub enum Isolation {
97    /// Share the host filesystem (the default).
98    #[default]
99    None,
100    /// Run in a fresh git worktree of the ctx workspace; clean worktrees are
101    /// pruned afterwards, dirty ones are preserved on an `hb/<name>` branch.
102    /// Isolated calls are NEVER journaled/replayed (side effects are not
103    /// restored by a replay).
104    Worktree,
105}
106
107/// A fluent, owned builder for one [`agent`] leaf. Created by [`agent`].
108///
109/// The type parameter `T` selects the terminal: [`NoSchema`] (default) yields
110/// the agent's text, a [`StructuredSchema`] type `T` yields a validated `T`, and
111/// [`RawJson`] yields a validated `serde_json::Value`. `T` is phantom — it lives
112/// only here, so the underlying runner stays `Value`-only and object-safe.
113pub struct AgentCall<T = NoSchema> {
114    ctx: WorkflowCtx,
115    prompt: String,
116    opts: AgentOpts,
117    /// Default phase captured when this call was *constructed*, so concurrently
118    /// running agents never observe a torn phase if another phase begins later.
119    phase_snapshot: Option<Arc<str>>,
120    _t: PhantomData<fn() -> T>,
121}
122
123/// Begin an [`AgentCall`] against `ctx`. Snapshots the current default phase.
124pub fn agent(ctx: &WorkflowCtx, prompt: impl Into<String>) -> AgentCall<NoSchema> {
125    let phase_snapshot = ctx.current_phase();
126    AgentCall {
127        ctx: ctx.clone(),
128        prompt: prompt.into(),
129        opts: AgentOpts::default(),
130        phase_snapshot,
131        _t: PhantomData,
132    }
133}
134
135impl<T> AgentCall<T> {
136    /// Set the display label (used in events and as the runner name).
137    pub fn label(mut self, label: impl Into<String>) -> Self {
138        self.opts.label = Some(label.into());
139        self
140    }
141
142    /// Override the phase this call is grouped under.
143    pub fn phase(mut self, phase: impl Into<String>) -> Self {
144        self.opts.phase = Some(phase.into());
145        self
146    }
147
148    /// Run this leaf on a different model: a ROLE name ("fast", "frontier")
149    /// or a raw model id, resolved by the ctx's provider factory. Dynamic
150    /// model-by-task, the Claude Code way — the workflow decides per agent.
151    pub fn model(mut self, model: impl Into<String>) -> Self {
152        self.opts.model = Some(model.into());
153        self
154    }
155
156    /// Isolate this leaf's filesystem effects (see [`Isolation`]).
157    pub fn isolation(mut self, isolation: Isolation) -> Self {
158        self.opts.isolation = isolation;
159        self
160    }
161
162    /// Wire a tool set into this agent, overriding the ctx's base tools. The
163    /// agent can then call these tools during its ReAct loop. (Sub-agents
164    /// inherit tools the way Claude Code's subagents inherit the allowlist.)
165    pub fn tools(mut self, tools: Vec<Arc<dyn crate::tool::Tool>>) -> Self {
166        self.opts.tools = Some(tools);
167        self
168    }
169
170    /// Attach a persistent [`GoalCondition`](crate::agent::goal::GoalCondition):
171    /// the leaf self-continues until an independent judge confirms the objective
172    /// (bounded by the goal's `max_continuations`). The continuations all run
173    /// inside the one leaf, so their combined token spend is recorded once
174    /// against the shared flow budget, and a run-wide budget breach or
175    /// cancellation aborts the goal loop at the leaf's cancel race.
176    pub fn goal(mut self, goal: crate::agent::goal::GoalCondition) -> Self {
177        self.opts.goal = Some(goal);
178        self
179    }
180
181    /// The effective phase: explicit override, else the construction snapshot.
182    fn effective_phase(&self) -> Option<String> {
183        self.opts
184            .phase
185            .clone()
186            .or_else(|| self.phase_snapshot.as_deref().map(str::to_owned))
187    }
188
189    /// Run the leaf to completion, returning the full [`AgentOutput`] (or `None`
190    /// if the run was cancelled / skipped). Shared by every typed terminal.
191    ///
192    /// Order: **(0)** cancellation dominates everything — even a journal hit;
193    /// **(1)** if journaling is on, a journal HIT replays the cached output with
194    /// zero work and zero spend (no permit, backstop, or budget — it represents
195    /// work already done in a prior run), and a MISS runs live then appends;
196    /// **(2)** otherwise run live (permit -> backstop -> budget -> race -> record).
197    ///
198    /// `Ok(None)` only on cancellation; agent-domain failures return `Err` so the
199    /// combinators can decide whether to collapse them to `None`. The backstop
200    /// and budget are *control* errors: they record a breach and fire run-wide
201    /// cancellation, so they survive a combinator's `Err -> None` collapse.
202    async fn run_leaf(self) -> Result<Option<AgentOutput>, Error> {
203        let label = self
204            .opts
205            .label
206            .clone()
207            .unwrap_or_else(|| "agent".to_string());
208
209        // 0. Cancellation dominates — a fired cancel beats a cache hit.
210        if self.ctx.is_cancelled() {
211            self.ctx.emit(WorkflowEvent::AgentSkipped { label });
212            return Ok(None);
213        }
214
215        // 1. Resume journal (HIT = 0 work, 0 spend; MISS = run live then append).
216        //    `journal_arc()` clones the Arc so no ctx borrow is held across the
217        //    `self.run_live(..)` move below.
218        //    ISOLATED calls never participate: a replay restores the return
219        //    value but NOT the worktree side effects, so it would lie.
220        if self.opts.isolation == Isolation::None
221            && let Some(journal) = self.ctx.journal_arc()
222        {
223            // Hash the call's INPUTS only (never the output model) — including
224            // the REQUESTED per-call model: the same prompt on a different
225            // model is a different call.
226            let hash = journal::content_hash(
227                &self.prompt,
228                self.opts.model.as_deref(),
229                self.opts.schema.as_ref(),
230            );
231            let occurrence = journal.next_occurrence(&hash);
232            let key = journal::CallKey {
233                content_hash: hash,
234                occurrence,
235            };
236            if let Some(cached) = journal.lookup(&key) {
237                self.ctx.emit(WorkflowEvent::AgentReplayed {
238                    label,
239                    usage: cached.tokens_used,
240                });
241                return Ok(Some(cached));
242            }
243            let result = self.run_live(label).await?;
244            if let Some(ref output) = result {
245                journal.append(&key, output)?;
246            }
247            return Ok(result);
248        }
249
250        // 2. No journal: run live directly.
251        self.run_live(label).await
252    }
253
254    /// The live leaf path: permit -> backstop -> budget -> race(cancel|run) ->
255    /// record spend. Separated from [`run_leaf`](Self::run_leaf) so a journal
256    /// HIT can bypass it entirely (0 work, 0 spend).
257    async fn run_live(self, label: String) -> Result<Option<AgentOutput>, Error> {
258        let phase = self.effective_phase();
259
260        // 1. Concurrency permit (the run-wide cap). Held until this leaf finishes.
261        let _permit = self
262            .ctx
263            .semaphore()
264            .acquire_owned()
265            .await
266            .map_err(|_| Error::Agent("flow concurrency limiter closed".to_string()))?;
267
268        // 2. Runaway backstop (monotonic; a rejected admission still counts).
269        self.ctx.register_agent()?;
270
271        // 3. Budget admission (hard ceiling). On exhaustion this records a
272        //    control breach and fires run-wide cancellation before returning.
273        self.ctx.admit_budget()?;
274
275        self.ctx.emit(WorkflowEvent::AgentStarted {
276            label: label.clone(),
277            phase,
278        });
279
280        // 3b. Worktree isolation: create the leaf's own worktree BEFORE the
281        // cancel race; cleaned up on every exit path below.
282        let guard = match self.opts.isolation {
283            Isolation::None => None,
284            Isolation::Worktree => {
285                let root = self.ctx.workspace().ok_or_else(|| {
286                    Error::Config(
287                        "isolation: Worktree requires WorkflowCtxBuilder::workspace(repo root)"
288                            .into(),
289                    )
290                })?;
291                let name = format!(
292                    "hb-{}-{}",
293                    super::worktree::sanitize_name(&label),
294                    self.ctx.spawned()
295                );
296                Some(super::worktree::WorktreeGuard::create(&root, &name).await?)
297            }
298        };
299        let workspace_override = guard.as_ref().map(|g| g.path().to_path_buf());
300
301        // 4. Race the agent run against cooperative cancellation.
302        let output = tokio::select! {
303            biased;
304            _ = self.ctx.cancel_token().cancelled() => {
305                self.ctx.emit(WorkflowEvent::AgentSkipped { label });
306                if let Some(g) = guard {
307                    let _ = g.cleanup().await;
308                }
309                return Ok(None);
310            }
311            result = run_one(&self.ctx, &self.prompt, &self.opts, workspace_override.as_deref()) => {
312                // Cleanup happens for BOTH Ok and Err results before `?`.
313                if let Some(g) = guard {
314                    match g.cleanup().await {
315                        Ok(super::worktree::Disposition::Kept { branch }) => {
316                            self.ctx.emit(WorkflowEvent::LogLine {
317                                msg: format!(
318                                    "worktree of '{label}' had changes — preserved on branch {branch}"
319                                ),
320                            });
321                        }
322                        Ok(super::worktree::Disposition::Pruned) => {}
323                        Err(e) => {
324                            self.ctx.emit(WorkflowEvent::LogLine {
325                                msg: format!("worktree cleanup for '{label}' failed: {e}"),
326                            });
327                        }
328                    }
329                }
330                result?
331            }
332        };
333
334        // 5. Record the completed cost against the shared budget, then finish.
335        self.ctx.record_spend(&output.tokens_used);
336        self.ctx.emit(WorkflowEvent::AgentFinished {
337            label,
338            usage: output.tokens_used,
339        });
340        Ok(Some(output))
341    }
342}
343
344impl AgentCall<NoSchema> {
345    /// Force validated structured output of type `S`, transforming this into an
346    /// [`AgentCall<S>`] whose `run()` returns `Option<S>`. The schema comes from
347    /// [`StructuredSchema::json_schema`].
348    pub fn schema<S: StructuredSchema>(self) -> AgentCall<S> {
349        let mut opts = self.opts;
350        opts.schema = Some(S::json_schema());
351        AgentCall {
352            ctx: self.ctx,
353            prompt: self.prompt,
354            opts,
355            phase_snapshot: self.phase_snapshot,
356            _t: PhantomData,
357        }
358    }
359
360    /// Force validated structured output against a hand-written JSON Schema,
361    /// transforming this into an [`AgentCall<RawJson>`] whose `run()` returns the
362    /// raw validated `serde_json::Value` (no deserialization).
363    pub fn schema_value(self, schema: Value) -> AgentCall<RawJson> {
364        let mut opts = self.opts;
365        opts.schema = Some(schema);
366        AgentCall {
367            ctx: self.ctx,
368            prompt: self.prompt,
369            opts,
370            phase_snapshot: self.phase_snapshot,
371            _t: PhantomData,
372        }
373    }
374
375    /// Run the agent leaf and return its text. `Ok(None)` only on cancellation.
376    pub async fn run(self) -> Result<Option<String>, Error> {
377        Ok(self.run_leaf().await?.map(|o| o.result))
378    }
379}
380
381impl<T: StructuredSchema> AgentCall<T> {
382    /// Run the agent leaf and deserialize its validated structured output into
383    /// `T`. `Ok(None)` only on cancellation. A run that finishes without
384    /// producing structured output, or whose output fails to deserialize into
385    /// `T`, returns `Err` — never a silent `None`.
386    pub async fn run(self) -> Result<Option<T>, Error> {
387        match self.run_leaf().await? {
388            None => Ok(None),
389            Some(output) => {
390                let value = output.structured.ok_or_else(|| {
391                    Error::Agent("agent finished without structured output".to_string())
392                })?;
393                let typed = serde_json::from_value::<T>(value).map_err(Error::Json)?;
394                Ok(Some(typed))
395            }
396        }
397    }
398}
399
400impl AgentCall<RawJson> {
401    /// Run the agent leaf and return its raw validated `serde_json::Value`.
402    /// `Ok(None)` only on cancellation; a run that finishes without structured
403    /// output returns `Err`.
404    pub async fn run(self) -> Result<Option<Value>, Error> {
405        match self.run_leaf().await? {
406            None => Ok(None),
407            Some(output) => {
408                let value = output.structured.ok_or_else(|| {
409                    Error::Agent("agent finished without structured output".to_string())
410                })?;
411                Ok(Some(value))
412            }
413        }
414    }
415}
416
417/// Build and execute a fresh per-call [`AgentRunner`] over the shared provider.
418///
419/// Seam: later phases attach the per-call model and journal hooks here without
420/// changing the leaf sequence. When `opts.schema` is set, the runner injects the
421/// `__respond__` tool, validates the payload against the schema, and retries on
422/// mismatch — the typed terminal then deserializes `AgentOutput.structured`.
423async fn run_one(
424    ctx: &WorkflowCtx,
425    prompt: &str,
426    opts: &AgentOpts,
427    workspace_override: Option<&std::path::Path>,
428) -> Result<AgentOutput, Error> {
429    // Per-call model: resolve the role/id through the host factory. A missing
430    // factory degrades to the shared provider (same answers, default cost) —
431    // a recipe must not die because the host lacks the seam.
432    let provider = match &opts.model {
433        Some(role) => match ctx.provider_factory() {
434            Some(factory) => factory(role)?,
435            None => {
436                ctx.emit(super::event::WorkflowEvent::LogLine {
437                    msg: format!(
438                        "model '{role}' requested but no provider factory installed — \
439                         using the default provider"
440                    ),
441                });
442                ctx.provider()
443            }
444        },
445        None => ctx.provider(),
446    };
447    let mut builder = AgentRunner::builder(provider);
448    if let Some(label) = &opts.label {
449        builder = builder.name(label.clone());
450    }
451    // Worktree isolation: the leaf's runner works against ITS OWN worktree.
452    if let Some(ws) = workspace_override {
453        builder = builder.workspace(ws.to_path_buf());
454    }
455    // Surface the inner runner's full event stream (tool calls, LLM responses,
456    // run lifecycle) to the host's sink when one is installed on the ctx —
457    // otherwise recipe-internal activity is invisible behind the tool boundary.
458    if let Some(sink) = ctx.agent_events() {
459        builder = builder.on_event(sink);
460    }
461    if let Some(schema) = &opts.schema {
462        builder = builder.structured_schema(schema.clone());
463    }
464    // Per-call tools override the ctx's base tools; otherwise inherit the base
465    // set (if any). A 2-turn agent (ToolUse then text) needs max_turns > 1.
466    if let Some(tools) = opts.tools.clone().or_else(|| ctx.base_tools()) {
467        builder = builder.tools(tools).max_turns(DEFAULT_TOOL_TURNS);
468    }
469    // A goal makes the leaf self-continue, so it needs enough turns for the
470    // initial completion plus its continuations (layered on top of any tool
471    // turns). The goal's own continuation cap is the inner bound.
472    if let Some(goal) = &opts.goal {
473        let needed = (goal.max_continuations() as usize).saturating_add(1);
474        builder = builder
475            .goal(goal.clone())
476            .max_turns(needed.max(DEFAULT_TOOL_TURNS));
477        // Bound a SOLO goal leaf against the shared budget mid-loop: cap the
478        // leaf's cumulative tokens at the budget remaining at leaf start, so a
479        // long goal loop cannot overshoot the pool arbitrarily even when no
480        // sibling leaf is around to fire the run-wide cancel. (The cross-leaf
481        // case is handled by the leaf's biased cancel-race.) Unbounded budget =
482        // no cap. NOTE: the budget is weighted (input+output+reasoning + cache
483        // ratios) while `max_total_tokens` counts raw input+output, so the cap is
484        // exact for input/output-only usage and a slight over-allowance when
485        // cache/reasoning tokens are present — a conservative bound either way.
486        let remaining = ctx.remaining();
487        if remaining != u64::MAX {
488            builder = builder.max_total_tokens(remaining);
489        }
490    }
491    let runner = builder.build()?;
492    runner.execute(prompt).await
493}
494
495#[cfg(test)]
496mod tests {
497    use std::sync::atomic::{AtomicUsize, Ordering};
498    use std::time::Duration;
499
500    use super::*;
501    use crate::agent::test_helpers::MockProvider;
502    use crate::error::Error;
503    use crate::llm::types::TokenUsage;
504    use crate::llm::types::{CompletionRequest, CompletionResponse, ContentBlock, StopReason};
505    use crate::llm::{BoxedProvider, LlmProvider};
506
507    use super::super::parallel::{BoxThunk, parallel, thunk};
508
509    /// Provider that tracks peak concurrency, sleeping to force overlap.
510    struct ConcurrencyTrackingProvider {
511        current: Arc<AtomicUsize>,
512        peak: Arc<AtomicUsize>,
513    }
514
515    impl LlmProvider for ConcurrencyTrackingProvider {
516        async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
517            let now = self.current.fetch_add(1, Ordering::SeqCst) + 1;
518            self.peak.fetch_max(now, Ordering::SeqCst);
519            tokio::time::sleep(Duration::from_millis(50)).await;
520            self.current.fetch_sub(1, Ordering::SeqCst);
521            Ok(CompletionResponse {
522                content: vec![ContentBlock::Text {
523                    text: "done".into(),
524                }],
525                stop_reason: StopReason::EndTurn,
526                reasoning: None,
527                usage: TokenUsage {
528                    input_tokens: 1,
529                    output_tokens: 1,
530                    ..Default::default()
531                },
532                model: None,
533            })
534        }
535        fn model_name(&self) -> Option<&str> {
536            Some("concurrency-mock")
537        }
538    }
539
540    #[tokio::test]
541    async fn text_terminal_returns_agent_output() {
542        let mock = Arc::new(MockProvider::new(vec![MockProvider::text_response(
543            "mock text",
544            10,
545            5,
546        )]));
547        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(Arc::clone(&mock))))
548            .build()
549            .expect("build ctx");
550
551        let out = agent(&ctx, "do the thing").run().await.expect("run ok");
552        assert_eq!(out.as_deref(), Some("mock text"));
553        // The provider was actually invoked exactly once.
554        assert_eq!(mock.captured_requests.lock().expect("lock").len(), 1);
555    }
556
557    #[tokio::test]
558    async fn agent_event_sink_receives_inner_runner_events() {
559        use crate::agent::events::{AgentEvent, OnEvent};
560        use std::sync::Mutex;
561
562        let mock = Arc::new(MockProvider::new(vec![MockProvider::text_response(
563            "hi", 1, 1,
564        )]));
565        let captured: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
566        let sink: Arc<OnEvent> = {
567            let captured = Arc::clone(&captured);
568            Arc::new(move |ev| captured.lock().expect("lock").push(ev))
569        };
570        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(mock)))
571            .on_agent_event(sink)
572            .build()
573            .expect("build ctx");
574
575        let out = agent(&ctx, "do it")
576            .label("lane:1")
577            .run()
578            .await
579            .expect("run ok");
580        assert_eq!(out.as_deref(), Some("hi"));
581
582        let events = captured.lock().expect("lock");
583        assert!(
584            events
585                .iter()
586                .any(|e| matches!(e, AgentEvent::RunStarted { agent, .. } if agent == "lane:1")),
587            "inner runner's RunStarted must reach the sink with the label as agent name"
588        );
589        assert!(
590            events
591                .iter()
592                .any(|e| matches!(e, AgentEvent::RunCompleted { agent, .. } if agent == "lane:1")),
593            "inner runner's RunCompleted must reach the sink"
594        );
595    }
596
597    #[tokio::test]
598    async fn per_call_model_resolves_through_the_factory() {
599        // Two distinct mocks: the ctx default and the factory's "fast" model.
600        let default_mock = Arc::new(MockProvider::new(vec![MockProvider::text_response(
601            "from-default",
602            1,
603            1,
604        )]));
605        let fast_mock = Arc::new(MockProvider::new(vec![MockProvider::text_response(
606            "from-fast",
607            1,
608            1,
609        )]));
610        let fast_for_factory = Arc::clone(&fast_mock);
611        let factory: Arc<crate::agent::flow::ProviderFactory> = Arc::new(move |role: &str| {
612            assert_eq!(role, "fast");
613            Ok(Arc::new(BoxedProvider::from_arc(Arc::clone(
614                &fast_for_factory,
615            ))))
616        });
617        let ctx =
618            WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(Arc::clone(&default_mock))))
619                .provider_factory(factory)
620                .build()
621                .expect("ctx");
622
623        let out = agent(&ctx, "go").model("fast").run().await.expect("run");
624        assert_eq!(out.as_deref(), Some("from-fast"));
625        assert_eq!(
626            fast_mock.captured_requests.lock().expect("lock").len(),
627            1,
628            "the fast provider must take the call"
629        );
630        assert!(
631            default_mock
632                .captured_requests
633                .lock()
634                .expect("lock")
635                .is_empty(),
636            "the default provider must NOT be used"
637        );
638    }
639
640    #[tokio::test]
641    async fn model_without_factory_degrades_to_default_with_a_log() {
642        use std::sync::Mutex;
643        let mock = Arc::new(MockProvider::new(vec![MockProvider::text_response(
644            "ok", 1, 1,
645        )]));
646        let logs: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
647        let sink = {
648            let logs = Arc::clone(&logs);
649            Arc::new(move |ev: super::super::event::WorkflowEvent| {
650                if let super::super::event::WorkflowEvent::LogLine { msg } = ev {
651                    logs.lock().expect("lock").push(msg);
652                }
653            })
654        };
655        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(Arc::clone(&mock))))
656            .on_event(sink)
657            .build()
658            .expect("ctx");
659        let out = agent(&ctx, "go").model("fast").run().await.expect("run");
660        assert_eq!(out.as_deref(), Some("ok"), "degraded, never fatal");
661        let logs = logs.lock().expect("lock");
662        assert!(
663            logs.iter()
664                .any(|l| l.contains("fast") && l.contains("factory")),
665            "must log the degradation: {logs:?}"
666        );
667    }
668
669    /// A throwaway repo with one commit, for isolation tests.
670    async fn fixture_repo() -> (tempfile::TempDir, std::path::PathBuf) {
671        let dir = tempfile::tempdir().unwrap();
672        let root = dir.path().to_path_buf();
673        for args in [
674            vec!["init", "-q"],
675            vec!["config", "user.name", "t"],
676            vec!["config", "user.email", "t@t"],
677            vec!["commit", "--allow-empty", "-q", "-m", "init"],
678        ] {
679            let ok = std::process::Command::new("git")
680                .current_dir(&root)
681                .args(&args)
682                .status()
683                .unwrap()
684                .success();
685            assert!(ok, "fixture git {args:?}");
686        }
687        (dir, root)
688    }
689
690    #[tokio::test(flavor = "multi_thread")]
691    async fn worktree_isolation_runs_the_leaf_in_its_own_worktree() {
692        let (_keep, root) = fixture_repo().await;
693        let mock = Arc::new(MockProvider::new(vec![MockProvider::text_response(
694            "done", 1, 1,
695        )]));
696        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(Arc::clone(&mock))))
697            .workspace(root.clone())
698            .build()
699            .expect("ctx");
700        let out = agent(&ctx, "mutate files")
701            .label("iso-1")
702            .isolation(super::Isolation::Worktree)
703            .run()
704            .await
705            .expect("run ok");
706        assert_eq!(out.as_deref(), Some("done"));
707        // The runner saw the WORKTREE as its workspace (system-prompt hint),
708        // not the repo root.
709        let reqs = mock.captured_requests.lock().expect("lock");
710        let sys = &reqs[0].system;
711        assert!(
712            sys.contains("heartbit-worktrees")
713                && !sys.contains(&root.to_string_lossy().to_string()),
714            "leaf must run against the worktree, got system: {sys}"
715        );
716    }
717
718    #[tokio::test]
719    async fn worktree_without_ctx_workspace_is_a_config_error() {
720        let mock = Arc::new(MockProvider::new(vec![MockProvider::text_response(
721            "x", 1, 1,
722        )]));
723        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(mock)))
724            .build()
725            .expect("ctx");
726        let err = agent(&ctx, "go")
727            .isolation(super::Isolation::Worktree)
728            .run()
729            .await
730            .unwrap_err();
731        assert!(
732            err.to_string().contains("workspace"),
733            "must name the missing seam: {err}"
734        );
735    }
736
737    #[tokio::test(flavor = "multi_thread")]
738    async fn journal_refuses_isolated_calls() {
739        let (_keep, root) = fixture_repo().await;
740        let dir = tempfile::tempdir().unwrap();
741        let jpath = dir.path().join("j.jsonl");
742        let mock = Arc::new(MockProvider::new(vec![
743            MockProvider::text_response("a", 1, 1),
744            MockProvider::text_response("b", 1, 1),
745        ]));
746        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(Arc::clone(&mock))))
747            .workspace(root)
748            .journal(&jpath, super::super::journal::ResumeMode::Resume)
749            .expect("journal")
750            .build()
751            .expect("ctx");
752        for _ in 0..2 {
753            let _ = agent(&ctx, "same prompt")
754                .label("iso-j")
755                .isolation(super::Isolation::Worktree)
756                .run()
757                .await
758                .expect("run");
759        }
760        assert_eq!(
761            mock.captured_requests.lock().expect("lock").len(),
762            2,
763            "isolated calls must NEVER replay from the journal (side effects \
764             are not restored)"
765        );
766    }
767
768    #[tokio::test]
769    async fn journal_hash_includes_the_model() {
770        let dir = tempfile::tempdir().unwrap();
771        let jpath = dir.path().join("j.jsonl");
772        let mock = Arc::new(MockProvider::new(vec![
773            MockProvider::text_response("a", 1, 1),
774            MockProvider::text_response("b", 1, 1),
775        ]));
776        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(Arc::clone(&mock))))
777            .journal(&jpath, super::super::journal::ResumeMode::Resume)
778            .expect("journal")
779            .build()
780            .expect("ctx");
781        // Same prompt, DIFFERENT model → must NOT collide in the journal.
782        let one = agent(&ctx, "same").run().await.expect("run");
783        let two = agent(&ctx, "same")
784            .model("other-model")
785            .run()
786            .await
787            .expect("run");
788        assert_eq!(one.as_deref(), Some("a"));
789        assert_eq!(
790            two.as_deref(),
791            Some("b"),
792            "a different model must be a journal MISS, not a replay of 'a'"
793        );
794    }
795
796    #[tokio::test]
797    async fn no_agent_event_sink_means_no_events_and_no_failure() {
798        let mock = Arc::new(MockProvider::new(vec![MockProvider::text_response(
799            "ok", 1, 1,
800        )]));
801        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(mock)))
802            .build()
803            .expect("build ctx");
804        let out = agent(&ctx, "go").run().await.expect("run ok");
805        assert_eq!(out.as_deref(), Some("ok"));
806    }
807
808    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
809    async fn concurrency_cap_is_respected_and_used() {
810        let current = Arc::new(AtomicUsize::new(0));
811        let peak = Arc::new(AtomicUsize::new(0));
812        let provider = ConcurrencyTrackingProvider {
813            current: Arc::clone(&current),
814            peak: Arc::clone(&peak),
815        };
816        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::new(provider)))
817            .max_concurrency(2)
818            .max_agents(1000)
819            .build()
820            .expect("build ctx");
821
822        // 10 agents through parallel(); the leaf permit caps in-flight at 2.
823        let thunks: Vec<BoxThunk<String>> = (0..10)
824            .map(|i| {
825                let ctx = ctx.clone();
826                thunk(move || async move {
827                    Ok(agent(&ctx, format!("task {i}"))
828                        .run()
829                        .await?
830                        .unwrap_or_default())
831                })
832            })
833            .collect();
834        let out = parallel(&ctx, thunks).await;
835
836        assert_eq!(out.iter().filter(|o| o.is_some()).count(), 10);
837        let observed = peak.load(Ordering::SeqCst);
838        assert!(observed <= 2, "peak {observed} exceeded cap 2");
839        assert_eq!(
840            observed, 2,
841            "cap should actually be reached, peak was {observed}"
842        );
843    }
844
845    #[tokio::test]
846    async fn backstop_rejects_excess_agents() {
847        let mock = Arc::new(MockProvider::new(vec![
848            MockProvider::text_response("a", 1, 1),
849            MockProvider::text_response("b", 1, 1),
850        ]));
851        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(Arc::clone(&mock))))
852            .max_agents(2)
853            .build()
854            .expect("build ctx");
855
856        assert!(agent(&ctx, "1").run().await.is_ok());
857        assert!(agent(&ctx, "2").run().await.is_ok());
858        let third = agent(&ctx, "3").run().await;
859        match third {
860            Err(Error::AgentBudgetExceeded { limit }) => assert_eq!(limit, 2),
861            other => panic!("expected AgentBudgetExceeded {{ limit: 2 }}, got {other:?}"),
862        }
863        // The rejected third agent never reached the provider.
864        assert_eq!(mock.captured_requests.lock().expect("lock").len(), 2);
865    }
866
867    #[tokio::test]
868    async fn cancelled_run_skips_without_invoking_provider() {
869        let mock = Arc::new(MockProvider::new(vec![MockProvider::text_response(
870            "should not run",
871            1,
872            1,
873        )]));
874        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(Arc::clone(&mock))))
875            .build()
876            .expect("build ctx");
877        ctx.cancellation_token().cancel(); // pre-cancel
878
879        let out = agent(&ctx, "task").run().await.expect("run ok");
880        assert!(out.is_none(), "cancelled run must yield Ok(None)");
881        assert!(
882            mock.captured_requests.lock().expect("lock").is_empty(),
883            "provider must not be invoked when cancelled"
884        );
885    }
886
887    // -----------------------------------------------------------------------
888    // P2: shared budget wired through the leaf
889    // -----------------------------------------------------------------------
890
891    use super::super::ctx::ControlBreach;
892    // `BoxThunk` is already imported at the top of this `mod tests`; re-import
893    // only the aliased fns to avoid an E0252 duplicate.
894    use super::super::parallel::{parallel as flow_parallel, thunk as flow_thunk};
895
896    #[tokio::test]
897    async fn leaf_records_weighted_cost_into_budget() {
898        // input 10 + output 5 -> weighted cost 15.
899        let mock = Arc::new(MockProvider::new(vec![MockProvider::text_response(
900            "x", 10, 5,
901        )]));
902        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(mock)))
903            .build()
904            .expect("build ctx");
905
906        assert_eq!(ctx.budget().spent(), 0);
907        agent(&ctx, "task").run().await.expect("run ok");
908        assert_eq!(ctx.budget().spent(), 15);
909    }
910
911    #[tokio::test]
912    async fn sequential_budget_ceiling_rejects_after_exhaustion() {
913        // Each agent costs 10 (input 10, output 0); ceiling 25.
914        let mock = Arc::new(MockProvider::new(vec![
915            MockProvider::text_response("a", 10, 0),
916            MockProvider::text_response("b", 10, 0),
917            MockProvider::text_response("c", 10, 0),
918        ]));
919        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(Arc::clone(&mock))))
920            .budget(25)
921            .build()
922            .expect("build ctx");
923
924        assert!(agent(&ctx, "1").run().await.is_ok()); // spent 0->10
925        assert!(agent(&ctx, "2").run().await.is_ok()); // spent 10->20
926        assert!(agent(&ctx, "3").run().await.is_ok()); // spent 20->30 (admitted at 20<25)
927        let fourth = agent(&ctx, "4").run().await; // admit sees 30>=25
928        match fourth {
929            Err(Error::BudgetExceeded { used, limit }) => {
930                assert_eq!(used, 30);
931                assert_eq!(limit, 25);
932            }
933            other => panic!("expected BudgetExceeded, got {other:?}"),
934        }
935        // The rejected fourth agent never reached the provider.
936        assert_eq!(mock.captured_requests.lock().expect("lock").len(), 3);
937    }
938
939    #[tokio::test]
940    async fn unbounded_budget_leaves_runs_unthrottled() {
941        let mock = Arc::new(MockProvider::new(vec![MockProvider::text_response(
942            "ok", 1_000, 1_000,
943        )]));
944        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(mock)))
945            .build() // default: unbounded
946            .expect("build ctx");
947        assert!(agent(&ctx, "task").run().await.is_ok());
948        assert_eq!(ctx.budget().spent(), 2_000);
949        assert_eq!(ctx.remaining(), u64::MAX);
950        assert!(!ctx.is_cancelled());
951        assert!(ctx.control_breach().is_none());
952    }
953
954    // -----------------------------------------------------------------------
955    // P2: sticky control-error semantics through a combinator
956    // -----------------------------------------------------------------------
957
958    #[tokio::test]
959    async fn budget_breach_in_parallel_is_sticky_and_trips_cancellation() {
960        // Pre-exhaust the budget so every agent's admission fails.
961        let mock = Arc::new(MockProvider::new(vec![
962            MockProvider::text_response("x", 1, 1),
963            MockProvider::text_response("y", 1, 1),
964        ]));
965        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(Arc::clone(&mock))))
966            .budget(1)
967            .build()
968            .expect("build ctx");
969        ctx.budget().record(&crate::llm::types::TokenUsage {
970            input_tokens: 5,
971            ..Default::default()
972        }); // spent 5 >= 1: pool exhausted
973
974        // Preserve the agent's Option (do NOT `unwrap_or_default`, which would
975        // mask a cancelled `Ok(None)` as `Some("")`). With an exhausted budget,
976        // each agent either loses the admission race (`Err(BudgetExceeded)` ->
977        // outer slot `None`) or, once the first breach fires run-wide cancel,
978        // short-circuits at the leaf's step-0 cancellation check (`Ok(None)` ->
979        // `Some(None)`). Which path each takes is a race; *neither* yields real
980        // output, which is the invariant we assert.
981        let thunks: Vec<BoxThunk<Option<String>>> = (0..2)
982            .map(|i| {
983                let ctx = ctx.clone();
984                flow_thunk(move || async move { agent(&ctx, format!("p{i}")).run().await })
985            })
986            .collect();
987        let out = flow_parallel(&ctx, thunks).await;
988
989        // No agent produced real output (no slot is `Some(Some(_))`) ...
990        assert!(
991            out.iter().all(|slot| !matches!(slot, Some(Some(_)))),
992            "a budget-exhausted run must yield no real output, got {out:?}"
993        );
994        // ... but the breach is sticky: cancellation fired and is recorded.
995        assert!(
996            ctx.is_cancelled(),
997            "a control breach must fire run-wide cancel"
998        );
999        assert!(matches!(
1000            ctx.control_breach(),
1001            Some(ControlBreach::Budget { limit: 1, .. })
1002        ));
1003        // No agent ran (admission failed before the provider).
1004        assert!(mock.captured_requests.lock().expect("lock").is_empty());
1005    }
1006
1007    #[tokio::test]
1008    async fn agent_domain_error_in_parallel_is_not_sticky() {
1009        // Empty provider -> each agent errors with an agent-domain error.
1010        let mock = Arc::new(MockProvider::new(vec![]));
1011        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(mock)))
1012            .build()
1013            .expect("build ctx");
1014
1015        let thunks: Vec<BoxThunk<String>> = (0..2)
1016            .map(|i| {
1017                let ctx = ctx.clone();
1018                flow_thunk(move || async move {
1019                    Ok(agent(&ctx, format!("p{i}"))
1020                        .run()
1021                        .await?
1022                        .unwrap_or_default())
1023                })
1024            })
1025            .collect();
1026        let out = flow_parallel(&ctx, thunks).await;
1027
1028        assert!(out.iter().all(Option::is_none));
1029        // Agent-domain errors collapse to None WITHOUT tripping the run.
1030        assert!(!ctx.is_cancelled(), "a domain error must NOT fire cancel");
1031        assert!(ctx.control_breach().is_none());
1032    }
1033
1034    // -----------------------------------------------------------------------
1035    // P2: accounting under concurrency + loop-until-budget
1036    // -----------------------------------------------------------------------
1037
1038    /// Provider that returns a fixed usage on *every* call (never exhausts), so
1039    /// many concurrent agents don't run out of canned responses.
1040    struct FixedCostProvider {
1041        input: u32,
1042        output: u32,
1043    }
1044
1045    impl LlmProvider for FixedCostProvider {
1046        async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
1047            Ok(CompletionResponse {
1048                content: vec![ContentBlock::Text { text: "ok".into() }],
1049                stop_reason: StopReason::EndTurn,
1050                reasoning: None,
1051                usage: TokenUsage {
1052                    input_tokens: self.input,
1053                    output_tokens: self.output,
1054                    ..Default::default()
1055                },
1056                model: None,
1057            })
1058        }
1059        fn model_name(&self) -> Option<&str> {
1060            Some("fixed-cost-mock")
1061        }
1062    }
1063
1064    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1065    async fn concurrent_spend_accounting_loses_nothing() {
1066        // 64 agents, each costing 2 (input 1 + output 1), run concurrently under
1067        // the cap. The atomic accumulator must total exactly 64 × 2 = 128.
1068        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::new(FixedCostProvider {
1069            input: 1,
1070            output: 1,
1071        })))
1072        .max_concurrency(8)
1073        .build()
1074        .expect("build ctx");
1075
1076        let thunks: Vec<BoxThunk<String>> = (0..64)
1077            .map(|i| {
1078                let ctx = ctx.clone();
1079                thunk(move || async move {
1080                    Ok(agent(&ctx, format!("t{i}"))
1081                        .run()
1082                        .await?
1083                        .unwrap_or_default())
1084                })
1085            })
1086            .collect();
1087        let out = parallel(&ctx, thunks).await;
1088
1089        assert_eq!(out.iter().filter(|o| o.is_some()).count(), 64);
1090        assert_eq!(ctx.budget().spent(), 128, "atomic accounting lost a record");
1091    }
1092
1093    #[tokio::test]
1094    async fn loop_until_budget_terminates() {
1095        // Budget 100; each agent costs 10 (input 10). The loop guard
1096        // `remaining() >= 10` admits exactly 10 agents, then stops at remaining 0.
1097        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::new(FixedCostProvider {
1098            input: 10,
1099            output: 0,
1100        })))
1101        .budget(100)
1102        .build()
1103        .expect("build ctx");
1104
1105        let mut ran = 0u32;
1106        while ctx.remaining() >= 10 {
1107            agent(&ctx, "work").run().await.expect("run ok");
1108            ran += 1;
1109            assert!(ran <= 100, "loop-until-budget failed to terminate");
1110        }
1111        assert_eq!(ran, 10);
1112        assert_eq!(ctx.budget().spent(), 100);
1113        assert_eq!(ctx.remaining(), 0);
1114    }
1115
1116    // -----------------------------------------------------------------------
1117    // P3: per-call typed structured output via .schema::<T>()
1118    // -----------------------------------------------------------------------
1119
1120    use super::{RawJson, StructuredSchema};
1121    use crate::llm::types::RESPOND_TOOL_NAME;
1122
1123    /// Provider that always answers by calling the synthetic `__respond__` tool
1124    /// with a fixed payload — mimicking a model producing structured output.
1125    struct RespondProvider {
1126        payload: serde_json::Value,
1127    }
1128
1129    impl LlmProvider for RespondProvider {
1130        async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
1131            Ok(CompletionResponse {
1132                content: vec![ContentBlock::ToolUse {
1133                    id: "resp-1".into(),
1134                    name: RESPOND_TOOL_NAME.into(),
1135                    input: self.payload.clone(),
1136                }],
1137                stop_reason: StopReason::ToolUse,
1138                reasoning: None,
1139                usage: TokenUsage {
1140                    input_tokens: 5,
1141                    output_tokens: 5,
1142                    ..Default::default()
1143                },
1144                model: None,
1145            })
1146        }
1147        fn model_name(&self) -> Option<&str> {
1148            Some("respond-mock")
1149        }
1150    }
1151
1152    #[derive(serde::Deserialize, Debug, PartialEq)]
1153    struct Finding {
1154        title: String,
1155        severity: u8,
1156    }
1157
1158    impl StructuredSchema for Finding {
1159        fn json_schema() -> serde_json::Value {
1160            serde_json::json!({
1161                "type": "object",
1162                "required": ["title", "severity"],
1163                "properties": {
1164                    "title": { "type": "string" },
1165                    "severity": { "type": "integer" }
1166                }
1167            })
1168        }
1169    }
1170
1171    fn respond_ctx(payload: serde_json::Value) -> WorkflowCtx {
1172        WorkflowCtx::builder(Arc::new(BoxedProvider::new(RespondProvider { payload })))
1173            .build()
1174            .expect("build ctx")
1175    }
1176
1177    #[tokio::test]
1178    async fn typed_schema_round_trips() {
1179        let ctx = respond_ctx(serde_json::json!({ "title": "SQLi", "severity": 9 }));
1180        let found: Option<Finding> = agent(&ctx, "audit")
1181            .schema::<Finding>()
1182            .run()
1183            .await
1184            .expect("run ok");
1185        assert_eq!(
1186            found,
1187            Some(Finding {
1188                title: "SQLi".into(),
1189                severity: 9
1190            })
1191        );
1192        // The budget still records through the typed path (input 5 + output 5).
1193        assert_eq!(ctx.budget().spent(), 10);
1194    }
1195
1196    #[tokio::test]
1197    async fn schema_value_returns_raw_json() {
1198        let payload = serde_json::json!({ "anything": [1, 2, 3] });
1199        let ctx = respond_ctx(payload.clone());
1200        let out: Option<serde_json::Value> = agent(&ctx, "task")
1201            .schema_value(serde_json::json!({ "type": "object" }))
1202            .run()
1203            .await
1204            .expect("run ok");
1205        assert_eq!(out, Some(payload));
1206    }
1207
1208    #[tokio::test]
1209    async fn finished_without_respond_is_err_not_none() {
1210        // A text-only provider never calls __respond__. With a schema set, the
1211        // runner treats that as a contract violation. The typed terminal must
1212        // surface an Err — NEVER silently collapse a missing structured output
1213        // to Ok(None) (that is reserved for cancellation).
1214        let mock = Arc::new(MockProvider::new(vec![MockProvider::text_response(
1215            "just prose",
1216            5,
1217            5,
1218        )]));
1219        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::from_arc(mock)))
1220            .build()
1221            .expect("build ctx");
1222        let result = agent(&ctx, "audit").schema::<Finding>().run().await;
1223        assert!(
1224            result.is_err(),
1225            "missing structured output must be Err, got {result:?}"
1226        );
1227    }
1228
1229    // Deserialize-only fixtures: their fields/variants are exercised through
1230    // serde, never read in Rust, so dead-code analysis is intentionally muted.
1231    #[derive(serde::Deserialize, Debug)]
1232    #[allow(dead_code)]
1233    struct Choice {
1234        pick: Pick,
1235    }
1236
1237    #[derive(serde::Deserialize, Debug)]
1238    #[serde(rename_all = "lowercase")]
1239    #[allow(dead_code)]
1240    enum Pick {
1241        Yes,
1242        No,
1243    }
1244
1245    impl StructuredSchema for Choice {
1246        // Deliberately LOOSER than the Rust type: jsonschema only checks that
1247        // `pick` is a string, but serde restricts it to the enum variants.
1248        fn json_schema() -> serde_json::Value {
1249            serde_json::json!({
1250                "type": "object",
1251                "required": ["pick"],
1252                "properties": { "pick": { "type": "string" } }
1253            })
1254        }
1255    }
1256
1257    #[tokio::test]
1258    async fn serde_catches_what_jsonschema_misses() {
1259        // "maybe" passes the loose string schema (so the runner accepts it) but
1260        // is not a valid `Pick` variant — `from_value` must fail with Error::Json.
1261        let ctx = respond_ctx(serde_json::json!({ "pick": "maybe" }));
1262        let result = agent(&ctx, "decide").schema::<Choice>().run().await;
1263        match result {
1264            Err(Error::Json(_)) => {}
1265            other => panic!("expected Error::Json from serde, got {other:?}"),
1266        }
1267    }
1268
1269    #[tokio::test]
1270    async fn cancelled_typed_run_is_none() {
1271        let ctx = respond_ctx(serde_json::json!({ "title": "x", "severity": 1 }));
1272        ctx.cancellation_token().cancel();
1273        let found: Option<Finding> = agent(&ctx, "audit")
1274            .schema::<Finding>()
1275            .run()
1276            .await
1277            .expect("run ok");
1278        assert!(found.is_none(), "cancelled typed run must be Ok(None)");
1279    }
1280
1281    #[tokio::test]
1282    async fn raw_json_marker_is_distinct_from_no_schema() {
1283        // Type-level guard: schema_value yields AgentCall<RawJson>, whose run()
1284        // returns Option<Value>, while the plain path returns Option<String>.
1285        let ctx = respond_ctx(serde_json::json!({ "k": "v" }));
1286        let call = agent(&ctx, "t").schema_value(serde_json::json!({ "type": "object" }));
1287        let _assert_type: fn(AgentCall<RawJson>) = |_c| {};
1288        _assert_type(call);
1289    }
1290
1291    // -----------------------------------------------------------------------
1292    // P4: resume journal wired through the leaf
1293    // -----------------------------------------------------------------------
1294
1295    // AtomicUsize + Ordering are already in scope from this mod's top import.
1296    use super::super::journal::ResumeMode;
1297
1298    // -----------------------------------------------------------------------
1299    // P5a: tools plumbing
1300    // -----------------------------------------------------------------------
1301
1302    use crate::ExecutionContext;
1303    use crate::llm::types::ToolDefinition;
1304    use crate::tool::{Tool, ToolOutput};
1305
1306    /// A tool that records (via an atomic flag) whether it was executed.
1307    struct RecordingTool {
1308        name: String,
1309        invoked: Arc<AtomicUsize>,
1310    }
1311
1312    impl Tool for RecordingTool {
1313        fn definition(&self) -> ToolDefinition {
1314            ToolDefinition {
1315                name: self.name.clone(),
1316                description: "records that it ran".into(),
1317                input_schema: serde_json::json!({ "type": "object" }),
1318            }
1319        }
1320        fn execute(
1321            &self,
1322            _ctx: &ExecutionContext,
1323            _input: serde_json::Value,
1324        ) -> std::pin::Pin<
1325            Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
1326        > {
1327            let invoked = Arc::clone(&self.invoked);
1328            Box::pin(async move {
1329                invoked.fetch_add(1, Ordering::SeqCst);
1330                Ok(ToolOutput::success("recorded"))
1331            })
1332        }
1333    }
1334
1335    /// Provider: first turn calls `tool_name` via __respond__-style ToolUse, then
1336    /// (after the tool result) finishes with plain text. Drives a 2-turn agent so
1337    /// the wired tool actually executes.
1338    struct CallsToolProvider {
1339        tool_name: String,
1340        turn: AtomicUsize,
1341    }
1342
1343    impl LlmProvider for CallsToolProvider {
1344        async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
1345            let turn = self.turn.fetch_add(1, Ordering::SeqCst);
1346            let content = if turn == 0 {
1347                vec![ContentBlock::ToolUse {
1348                    id: "call-1".into(),
1349                    name: self.tool_name.clone(),
1350                    input: serde_json::json!({}),
1351                }]
1352            } else {
1353                vec![ContentBlock::Text {
1354                    text: "done".into(),
1355                }]
1356            };
1357            Ok(CompletionResponse {
1358                content,
1359                stop_reason: if turn == 0 {
1360                    StopReason::ToolUse
1361                } else {
1362                    StopReason::EndTurn
1363                },
1364                usage: TokenUsage {
1365                    input_tokens: 1,
1366                    output_tokens: 1,
1367                    ..Default::default()
1368                },
1369                model: None,
1370                reasoning: None,
1371            })
1372        }
1373        fn model_name(&self) -> Option<&str> {
1374            Some("calls-tool-mock")
1375        }
1376    }
1377
1378    fn tool_ctx(tool_name: &str) -> WorkflowCtx {
1379        // max_turns defaults to 1 in make_agent, but the flow leaf builds its own
1380        // runner; CallsToolProvider needs 2 turns, so use a ctx that allows it.
1381        WorkflowCtx::builder(Arc::new(BoxedProvider::new(CallsToolProvider {
1382            tool_name: tool_name.to_string(),
1383            turn: AtomicUsize::new(0),
1384        })))
1385        .build()
1386        .expect("build ctx")
1387    }
1388
1389    #[tokio::test]
1390    async fn per_call_tools_are_invoked_by_the_agent() {
1391        let invoked = Arc::new(AtomicUsize::new(0));
1392        let tool = Arc::new(RecordingTool {
1393            name: "rec".into(),
1394            invoked: Arc::clone(&invoked),
1395        }) as Arc<dyn Tool>;
1396        let ctx = tool_ctx("rec");
1397
1398        agent(&ctx, "use the tool")
1399            .tools(vec![tool])
1400            .run()
1401            .await
1402            .expect("run ok");
1403        assert_eq!(
1404            invoked.load(Ordering::SeqCst),
1405            1,
1406            "the per-call wired tool must be executed by the agent"
1407        );
1408    }
1409
1410    #[tokio::test]
1411    async fn ctx_base_tools_are_used_when_no_per_call_tools() {
1412        let invoked = Arc::new(AtomicUsize::new(0));
1413        let tool = Arc::new(RecordingTool {
1414            name: "rec".into(),
1415            invoked: Arc::clone(&invoked),
1416        }) as Arc<dyn Tool>;
1417        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::new(CallsToolProvider {
1418            tool_name: "rec".into(),
1419            turn: AtomicUsize::new(0),
1420        })))
1421        .base_tools(vec![tool])
1422        .build()
1423        .expect("build ctx");
1424
1425        agent(&ctx, "use the tool").run().await.expect("run ok");
1426        assert_eq!(
1427            invoked.load(Ordering::SeqCst),
1428            1,
1429            "ctx base_tools must be used when the call sets no tools"
1430        );
1431    }
1432
1433    #[tokio::test]
1434    async fn per_call_tools_override_base_tools() {
1435        // base tool is "base"; per-call tool is "rec"; the LLM calls "rec".
1436        // Only the per-call set should be visible -> base tool never runs.
1437        let base_invoked = Arc::new(AtomicUsize::new(0));
1438        let per_invoked = Arc::new(AtomicUsize::new(0));
1439        let base = Arc::new(RecordingTool {
1440            name: "base".into(),
1441            invoked: Arc::clone(&base_invoked),
1442        }) as Arc<dyn Tool>;
1443        let per = Arc::new(RecordingTool {
1444            name: "rec".into(),
1445            invoked: Arc::clone(&per_invoked),
1446        }) as Arc<dyn Tool>;
1447        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::new(CallsToolProvider {
1448            tool_name: "rec".into(),
1449            turn: AtomicUsize::new(0),
1450        })))
1451        .base_tools(vec![base])
1452        .build()
1453        .expect("build ctx");
1454
1455        agent(&ctx, "use rec")
1456            .tools(vec![per])
1457            .run()
1458            .await
1459            .expect("run ok");
1460        assert_eq!(per_invoked.load(Ordering::SeqCst), 1, "per-call tool runs");
1461        assert_eq!(
1462            base_invoked.load(Ordering::SeqCst),
1463            0,
1464            "base tool must be shadowed by the per-call override"
1465        );
1466    }
1467
1468    /// Provider that counts how many times the model was actually called, so a
1469    /// test can prove a journal replay did NOT hit the provider.
1470    struct CountingProvider {
1471        calls: Arc<AtomicUsize>,
1472    }
1473
1474    impl LlmProvider for CountingProvider {
1475        async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, Error> {
1476            let n = self.calls.fetch_add(1, Ordering::SeqCst);
1477            Ok(CompletionResponse {
1478                content: vec![ContentBlock::Text {
1479                    text: format!("live-{n}"),
1480                }],
1481                stop_reason: StopReason::EndTurn,
1482                reasoning: None,
1483                usage: TokenUsage {
1484                    input_tokens: 10,
1485                    output_tokens: 5,
1486                    ..Default::default()
1487                },
1488                model: None,
1489            })
1490        }
1491        fn model_name(&self) -> Option<&str> {
1492            Some("counting-mock")
1493        }
1494    }
1495
1496    fn counting_ctx(
1497        calls: &Arc<AtomicUsize>,
1498        path: &std::path::Path,
1499        mode: ResumeMode,
1500    ) -> WorkflowCtx {
1501        WorkflowCtx::builder(Arc::new(BoxedProvider::new(CountingProvider {
1502            calls: Arc::clone(calls),
1503        })))
1504        .journal(path, mode)
1505        .expect("open journal")
1506        .build()
1507        .expect("build ctx")
1508    }
1509
1510    #[tokio::test]
1511    async fn resume_replays_without_calling_provider() {
1512        let dir = tempfile::tempdir().unwrap();
1513        let path = dir.path().join("run.jsonl");
1514
1515        // First run (Fresh): the model is called once and the output journaled.
1516        let calls1 = Arc::new(AtomicUsize::new(0));
1517        let ctx1 = counting_ctx(&calls1, &path, ResumeMode::Fresh);
1518        let first = agent(&ctx1, "do the task").run().await.expect("run ok");
1519        assert_eq!(first.as_deref(), Some("live-0"));
1520        assert_eq!(calls1.load(Ordering::SeqCst), 1);
1521
1522        // Second run (Resume) with the SAME prompt: replayed from the journal,
1523        // so the provider is NOT called and the cached text is returned.
1524        let calls2 = Arc::new(AtomicUsize::new(0));
1525        let ctx2 = counting_ctx(&calls2, &path, ResumeMode::Resume);
1526        let replayed = agent(&ctx2, "do the task").run().await.expect("run ok");
1527        assert_eq!(
1528            replayed.as_deref(),
1529            Some("live-0"),
1530            "must replay cached text"
1531        );
1532        assert_eq!(
1533            calls2.load(Ordering::SeqCst),
1534            0,
1535            "replay must NOT call the provider"
1536        );
1537    }
1538
1539    #[tokio::test]
1540    async fn replayed_prefix_costs_zero_budget() {
1541        let dir = tempfile::tempdir().unwrap();
1542        let path = dir.path().join("run.jsonl");
1543
1544        let calls1 = Arc::new(AtomicUsize::new(0));
1545        let ctx1 = counting_ctx(&calls1, &path, ResumeMode::Fresh);
1546        agent(&ctx1, "task").run().await.expect("run ok");
1547
1548        // Resume run with a bounded budget: the replayed call must spend nothing,
1549        // so even a tiny budget is untouched.
1550        let calls2 = Arc::new(AtomicUsize::new(0));
1551        let ctx2 = WorkflowCtx::builder(Arc::new(BoxedProvider::new(CountingProvider {
1552            calls: Arc::clone(&calls2),
1553        })))
1554        .journal(&path, ResumeMode::Resume)
1555        .expect("open journal")
1556        .budget(1) // would reject any live admission (a live call costs 15)
1557        .build()
1558        .expect("build ctx");
1559
1560        let replayed = agent(&ctx2, "task").run().await.expect("run ok");
1561        assert_eq!(replayed.as_deref(), Some("live-0"));
1562        assert_eq!(
1563            ctx2.budget().spent(),
1564            0,
1565            "replayed call must spend 0 budget"
1566        );
1567        assert!(!ctx2.is_cancelled());
1568    }
1569
1570    #[tokio::test]
1571    async fn changed_prompt_reruns_live() {
1572        let dir = tempfile::tempdir().unwrap();
1573        let path = dir.path().join("run.jsonl");
1574
1575        let calls1 = Arc::new(AtomicUsize::new(0));
1576        let ctx1 = counting_ctx(&calls1, &path, ResumeMode::Fresh);
1577        agent(&ctx1, "original").run().await.expect("run ok");
1578
1579        // Resume but with a DIFFERENT prompt -> content hash differs -> MISS ->
1580        // runs live (provider called) and appends a new entry.
1581        let calls2 = Arc::new(AtomicUsize::new(0));
1582        let ctx2 = counting_ctx(&calls2, &path, ResumeMode::Resume);
1583        let out = agent(&ctx2, "changed").run().await.expect("run ok");
1584        assert_eq!(out.as_deref(), Some("live-0"));
1585        assert_eq!(
1586            calls2.load(Ordering::SeqCst),
1587            1,
1588            "a changed prompt must re-run live"
1589        );
1590    }
1591
1592    #[tokio::test]
1593    async fn reordered_schema_keys_still_replay() {
1594        let dir = tempfile::tempdir().unwrap();
1595        let path = dir.path().join("run.jsonl");
1596
1597        // First run with a schema written one way.
1598        let calls1 = Arc::new(AtomicUsize::new(0));
1599        let ctx1 = WorkflowCtx::builder(Arc::new(BoxedProvider::new(RespondProvider {
1600            payload: serde_json::json!({ "title": "x", "severity": 1 }),
1601        })))
1602        .journal(&path, ResumeMode::Fresh)
1603        .expect("open journal")
1604        .build()
1605        .expect("build ctx");
1606        let _ = calls1; // RespondProvider has no counter; correctness is via the replay below
1607        let f1: Option<Finding> = agent(&ctx1, "audit")
1608            .schema::<Finding>()
1609            .run()
1610            .await
1611            .unwrap();
1612        assert!(f1.is_some());
1613
1614        // Resume with the SAME Finding schema (its json_schema() is stable). The
1615        // canonicalized hash matches regardless of key order, so it replays.
1616        let calls2 = Arc::new(AtomicUsize::new(0));
1617        let ctx2 = counting_ctx(&calls2, &path, ResumeMode::Resume);
1618        let f2: Option<Finding> = agent(&ctx2, "audit")
1619            .schema::<Finding>()
1620            .run()
1621            .await
1622            .unwrap();
1623        assert_eq!(
1624            f2,
1625            Some(Finding {
1626                title: "x".into(),
1627                severity: 1
1628            })
1629        );
1630        assert_eq!(
1631            calls2.load(Ordering::SeqCst),
1632            0,
1633            "identical schema+prompt must replay, not re-run"
1634        );
1635    }
1636
1637    #[tokio::test]
1638    async fn no_journal_path_is_unaffected() {
1639        // Sanity: without .journal(), the leaf runs live every time.
1640        let calls = Arc::new(AtomicUsize::new(0));
1641        let ctx = WorkflowCtx::builder(Arc::new(BoxedProvider::new(CountingProvider {
1642            calls: Arc::clone(&calls),
1643        })))
1644        .build()
1645        .expect("build ctx");
1646        agent(&ctx, "a").run().await.expect("run ok");
1647        agent(&ctx, "a").run().await.expect("run ok");
1648        assert_eq!(calls.load(Ordering::SeqCst), 2, "no journal -> always live");
1649    }
1650}