1use 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
27const DEFAULT_TOOL_TURNS: usize = 10;
31
32pub trait StructuredSchema: serde::de::DeserializeOwned + Send {
43 fn json_schema() -> Value;
45}
46
47pub struct NoSchema;
50
51pub struct RawJson;
55
56#[derive(Clone, Default)]
62#[non_exhaustive]
63pub struct AgentOpts {
64 pub label: Option<String>,
66 pub phase: Option<String>,
69 pub schema: Option<Value>,
73 pub tools: Option<Vec<Arc<dyn crate::tool::Tool>>>,
77 pub goal: Option<crate::agent::goal::GoalCondition>,
82 pub model: Option<String>,
87 pub isolation: Isolation,
92}
93
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
96pub enum Isolation {
97 #[default]
99 None,
100 Worktree,
105}
106
107pub struct AgentCall<T = NoSchema> {
114 ctx: WorkflowCtx,
115 prompt: String,
116 opts: AgentOpts,
117 phase_snapshot: Option<Arc<str>>,
120 _t: PhantomData<fn() -> T>,
121}
122
123pub 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 pub fn label(mut self, label: impl Into<String>) -> Self {
138 self.opts.label = Some(label.into());
139 self
140 }
141
142 pub fn phase(mut self, phase: impl Into<String>) -> Self {
144 self.opts.phase = Some(phase.into());
145 self
146 }
147
148 pub fn model(mut self, model: impl Into<String>) -> Self {
152 self.opts.model = Some(model.into());
153 self
154 }
155
156 pub fn isolation(mut self, isolation: Isolation) -> Self {
158 self.opts.isolation = isolation;
159 self
160 }
161
162 pub fn tools(mut self, tools: Vec<Arc<dyn crate::tool::Tool>>) -> Self {
166 self.opts.tools = Some(tools);
167 self
168 }
169
170 pub fn goal(mut self, goal: crate::agent::goal::GoalCondition) -> Self {
177 self.opts.goal = Some(goal);
178 self
179 }
180
181 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 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 if self.ctx.is_cancelled() {
211 self.ctx.emit(WorkflowEvent::AgentSkipped { label });
212 return Ok(None);
213 }
214
215 if self.opts.isolation == Isolation::None
221 && let Some(journal) = self.ctx.journal_arc()
222 {
223 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 self.run_live(label).await
252 }
253
254 async fn run_live(self, label: String) -> Result<Option<AgentOutput>, Error> {
258 let phase = self.effective_phase();
259
260 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 self.ctx.register_agent()?;
270
271 self.ctx.admit_budget()?;
274
275 self.ctx.emit(WorkflowEvent::AgentStarted {
276 label: label.clone(),
277 phase,
278 });
279
280 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 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 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 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 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 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 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 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 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
417async fn run_one(
424 ctx: &WorkflowCtx,
425 prompt: &str,
426 opts: &AgentOpts,
427 workspace_override: Option<&std::path::Path>,
428) -> Result<AgentOutput, Error> {
429 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 if let Some(ws) = workspace_override {
453 builder = builder.workspace(ws.to_path_buf());
454 }
455 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 if let Some(tools) = opts.tools.clone().or_else(|| ctx.base_tools()) {
467 builder = builder.tools(tools).max_turns(DEFAULT_TOOL_TURNS);
468 }
469 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 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 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 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 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 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 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 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(¤t),
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 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 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(); 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 use super::super::ctx::ControlBreach;
892 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 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 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()); assert!(agent(&ctx, "2").run().await.is_ok()); assert!(agent(&ctx, "3").run().await.is_ok()); let fourth = agent(&ctx, "4").run().await; 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 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() .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 #[tokio::test]
959 async fn budget_breach_in_parallel_is_sticky_and_trips_cancellation() {
960 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 }); 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 assert!(
991 out.iter().all(|slot| !matches!(slot, Some(Some(_)))),
992 "a budget-exhausted run must yield no real output, got {out:?}"
993 );
994 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 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 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 assert!(!ctx.is_cancelled(), "a domain error must NOT fire cancel");
1031 assert!(ctx.control_breach().is_none());
1032 }
1033
1034 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 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 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 use super::{RawJson, StructuredSchema};
1121 use crate::llm::types::RESPOND_TOOL_NAME;
1122
1123 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 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 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 #[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 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 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 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 use super::super::journal::ResumeMode;
1297
1298 use crate::ExecutionContext;
1303 use crate::llm::types::ToolDefinition;
1304 use crate::tool::{Tool, ToolOutput};
1305
1306 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 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 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 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 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 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 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 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) .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 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 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; let f1: Option<Finding> = agent(&ctx1, "audit")
1608 .schema::<Finding>()
1609 .run()
1610 .await
1611 .unwrap();
1612 assert!(f1.is_some());
1613
1614 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 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}