use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, RwLock};
use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
use crate::error::Error;
use crate::llm::BoxedProvider;
use super::budget::Budget;
use super::event::{OnWorkflowEvent, WorkflowEvent};
use super::journal::{ResumeMode, RunJournal};
pub type ProviderFactory = dyn Fn(&str) -> Result<Arc<BoxedProvider>, Error> + Send + Sync;
const MAX_CONCURRENCY_CAP: usize = 16;
const DEFAULT_MAX_AGENTS: u64 = 1000;
pub(crate) fn default_concurrency() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2)
.saturating_sub(2)
.clamp(1, MAX_CONCURRENCY_CAP)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ControlBreach {
Budget {
used: u64,
limit: u64,
},
AgentBackstop {
limit: u64,
},
}
#[derive(Clone)]
pub struct WorkflowCtx {
inner: Arc<CtxInner>,
}
struct CtxInner {
provider: Arc<BoxedProvider>,
base_tools: Option<Arc<Vec<Arc<dyn crate::tool::Tool>>>>,
sem: Arc<Semaphore>,
spawned: Arc<AtomicU64>,
max_agents: u64,
budget: Budget,
control: Arc<Mutex<Option<ControlBreach>>>,
journal: Option<Arc<RunJournal>>,
events: Option<Arc<OnWorkflowEvent>>,
agent_events: Option<Arc<crate::agent::events::OnEvent>>,
provider_factory: Option<Arc<ProviderFactory>>,
workspace: Option<std::path::PathBuf>,
cancel: CancellationToken,
default_phase: RwLock<Option<Arc<str>>>,
depth: u8,
}
impl std::fmt::Debug for WorkflowCtx {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WorkflowCtx")
.field("max_agents", &self.inner.max_agents)
.field("permits", &self.inner.sem.available_permits())
.field("spawned", &self.inner.spawned.load(Ordering::Relaxed))
.field("budget_spent", &self.inner.budget.spent())
.field("budget_total", &self.inner.budget.total())
.finish()
}
}
impl WorkflowCtx {
pub fn builder(provider: Arc<BoxedProvider>) -> WorkflowCtxBuilder {
WorkflowCtxBuilder {
provider,
base_tools: None,
max_concurrency: None,
max_agents: None,
budget: None,
journal: None,
events: None,
agent_events: None,
provider_factory: None,
workspace: None,
cancel: None,
}
}
pub fn is_cancelled(&self) -> bool {
self.inner.cancel.is_cancelled()
}
pub fn stop(&self) {
self.inner.cancel.cancel();
}
pub fn cancellation_token(&self) -> CancellationToken {
self.inner.cancel.clone()
}
pub fn spawned(&self) -> u64 {
self.inner.spawned.load(Ordering::Relaxed)
}
pub fn max_agents(&self) -> u64 {
self.inner.max_agents
}
pub fn budget(&self) -> &Budget {
&self.inner.budget
}
pub fn remaining(&self) -> u64 {
self.inner.budget.remaining()
}
pub fn control_breach(&self) -> Option<ControlBreach> {
self.inner.control.lock().ok().and_then(|g| *g)
}
pub(crate) fn provider(&self) -> Arc<BoxedProvider> {
Arc::clone(&self.inner.provider)
}
pub(crate) fn agent_events(&self) -> Option<Arc<crate::agent::events::OnEvent>> {
self.inner.agent_events.clone()
}
pub(crate) fn provider_factory(&self) -> Option<Arc<ProviderFactory>> {
self.inner.provider_factory.clone()
}
pub(crate) fn workspace(&self) -> Option<std::path::PathBuf> {
self.inner.workspace.clone()
}
pub(crate) fn base_tools(&self) -> Option<Vec<Arc<dyn crate::tool::Tool>>> {
self.inner.base_tools.as_ref().map(|t| t.as_ref().clone())
}
pub(crate) fn semaphore(&self) -> Arc<Semaphore> {
Arc::clone(&self.inner.sem)
}
pub(crate) fn cancel_token(&self) -> &CancellationToken {
&self.inner.cancel
}
pub(crate) fn register_agent(&self) -> Result<(), Error> {
let prior = self.inner.spawned.fetch_add(1, Ordering::Relaxed);
if prior >= self.inner.max_agents {
let limit = self.inner.max_agents;
self.record_breach(ControlBreach::AgentBackstop { limit });
return Err(Error::AgentBudgetExceeded { limit });
}
Ok(())
}
pub(crate) fn admit_budget(&self) -> Result<(), Error> {
match self.inner.budget.check_admit() {
Ok(()) => Ok(()),
Err(err) => {
if let Error::BudgetExceeded { used, limit } = err {
self.record_breach(ControlBreach::Budget { used, limit });
}
Err(err)
}
}
}
pub(crate) fn record_spend(&self, usage: &crate::llm::types::TokenUsage) {
self.inner.budget.record(usage);
}
pub(crate) fn journal_arc(&self) -> Option<Arc<RunJournal>> {
self.inner.journal.clone()
}
pub(crate) fn nested(&self) -> Result<WorkflowCtx, Error> {
if self.inner.depth >= 1 {
return Err(Error::Config(
"workflow() may nest only one level deep".into(),
));
}
Ok(WorkflowCtx {
inner: Arc::new(CtxInner {
provider: Arc::clone(&self.inner.provider),
base_tools: self.inner.base_tools.clone(),
sem: Arc::clone(&self.inner.sem),
spawned: Arc::clone(&self.inner.spawned),
max_agents: self.inner.max_agents,
budget: self.inner.budget.clone(),
control: Arc::clone(&self.inner.control),
journal: self.inner.journal.clone(),
events: self.inner.events.clone(),
agent_events: self.inner.agent_events.clone(),
provider_factory: self.inner.provider_factory.clone(),
workspace: self.inner.workspace.clone(),
cancel: self.inner.cancel.clone(),
default_phase: RwLock::new(None),
depth: self.inner.depth + 1,
}),
})
}
pub(crate) fn record_breach(&self, breach: ControlBreach) {
if let Ok(mut guard) = self.inner.control.lock()
&& guard.is_none()
{
*guard = Some(breach);
}
self.inner.cancel.cancel();
}
pub(crate) fn emit(&self, event: WorkflowEvent) {
if let Some(cb) = &self.inner.events {
cb(event);
}
}
pub(crate) fn current_phase(&self) -> Option<Arc<str>> {
self.inner.default_phase.read().ok().and_then(|g| g.clone())
}
pub(crate) fn swap_default_phase(&self, next: Option<Arc<str>>) -> Option<Arc<str>> {
match self.inner.default_phase.write() {
Ok(mut g) => std::mem::replace(&mut *g, next),
Err(_) => None,
}
}
}
pub struct WorkflowCtxBuilder {
provider: Arc<BoxedProvider>,
base_tools: Option<Arc<Vec<Arc<dyn crate::tool::Tool>>>>,
max_concurrency: Option<usize>,
max_agents: Option<u64>,
budget: Option<Budget>,
journal: Option<Arc<RunJournal>>,
events: Option<Arc<OnWorkflowEvent>>,
agent_events: Option<Arc<crate::agent::events::OnEvent>>,
provider_factory: Option<Arc<ProviderFactory>>,
workspace: Option<std::path::PathBuf>,
cancel: Option<CancellationToken>,
}
impl WorkflowCtxBuilder {
pub fn base_tools(mut self, tools: Vec<Arc<dyn crate::tool::Tool>>) -> Self {
self.base_tools = Some(Arc::new(tools));
self
}
pub fn max_concurrency(mut self, n: usize) -> Self {
self.max_concurrency = Some(n);
self
}
pub fn max_agents(mut self, n: u64) -> Self {
self.max_agents = Some(n);
self
}
pub fn budget(mut self, total: u64) -> Self {
self.budget = Some(Budget::with_total(total));
self
}
pub fn budget_pool(mut self, budget: Budget) -> Self {
self.budget = Some(budget);
self
}
pub fn journal(
mut self,
path: impl AsRef<std::path::Path>,
mode: ResumeMode,
) -> Result<Self, Error> {
self.journal = Some(Arc::new(RunJournal::open(path.as_ref(), mode)?));
Ok(self)
}
pub fn journal_handle(mut self, journal: Arc<RunJournal>) -> Self {
self.journal = Some(journal);
self
}
pub fn on_event(mut self, callback: Arc<OnWorkflowEvent>) -> Self {
self.events = Some(callback);
self
}
pub fn on_agent_event(mut self, callback: Arc<crate::agent::events::OnEvent>) -> Self {
self.agent_events = Some(callback);
self
}
pub fn provider_factory(mut self, factory: Arc<ProviderFactory>) -> Self {
self.provider_factory = Some(factory);
self
}
pub fn workspace(mut self, root: impl Into<std::path::PathBuf>) -> Self {
self.workspace = Some(root.into());
self
}
pub fn cancellation_token(mut self, token: CancellationToken) -> Self {
self.cancel = Some(token);
self
}
pub fn build(self) -> Result<WorkflowCtx, Error> {
let max_concurrency = self.max_concurrency.unwrap_or_else(default_concurrency);
if max_concurrency == 0 {
return Err(Error::Config(
"WorkflowCtx max_concurrency must be at least 1".into(),
));
}
let max_agents = self.max_agents.unwrap_or(DEFAULT_MAX_AGENTS);
if max_agents == 0 {
return Err(Error::Config(
"WorkflowCtx max_agents must be at least 1".into(),
));
}
Ok(WorkflowCtx {
inner: Arc::new(CtxInner {
provider: self.provider,
base_tools: self.base_tools,
sem: Arc::new(Semaphore::new(max_concurrency)),
spawned: Arc::new(AtomicU64::new(0)),
max_agents,
budget: self.budget.unwrap_or_default(),
control: Arc::new(Mutex::new(None)),
journal: self.journal,
events: self.events,
agent_events: self.agent_events,
provider_factory: self.provider_factory,
workspace: self.workspace,
cancel: self.cancel.unwrap_or_default(),
default_phase: RwLock::new(None),
depth: 0,
}),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::test_helpers::MockProvider;
fn provider() -> Arc<BoxedProvider> {
Arc::new(BoxedProvider::new(MockProvider::new(vec![])))
}
#[test]
fn default_concurrency_in_range() {
let c = default_concurrency();
assert!((1..=MAX_CONCURRENCY_CAP).contains(&c), "got {c}");
}
#[test]
fn builder_rejects_zero_concurrency() {
let result = WorkflowCtx::builder(provider()).max_concurrency(0).build();
assert!(result.is_err());
}
#[test]
fn builder_rejects_zero_max_agents() {
let result = WorkflowCtx::builder(provider()).max_agents(0).build();
assert!(result.is_err());
}
#[test]
fn builder_accepts_defaults() {
let ctx = WorkflowCtx::builder(provider()).build().expect("build");
assert_eq!(ctx.max_agents(), DEFAULT_MAX_AGENTS);
assert!(!ctx.is_cancelled());
}
fn budgeted(total: u64) -> WorkflowCtx {
WorkflowCtx::builder(provider())
.budget(total)
.build()
.expect("build")
}
#[test]
fn nested_shares_budget_pool() {
let parent = budgeted(1000);
let child = parent.nested().expect("nest");
child.record_spend(&crate::llm::types::TokenUsage {
input_tokens: 30,
..Default::default()
});
assert_eq!(parent.budget().spent(), 30);
assert_eq!(child.budget().spent(), 30);
}
#[test]
fn nested_shares_runaway_backstop() {
let parent = WorkflowCtx::builder(provider())
.max_agents(2)
.build()
.expect("build");
let child = parent.nested().expect("nest");
assert!(parent.register_agent().is_ok()); assert!(child.register_agent().is_ok()); assert!(child.register_agent().is_err()); assert_eq!(parent.spawned(), 3);
assert_eq!(child.spawned(), 3);
}
#[test]
fn child_breach_cancels_parent() {
let parent = budgeted(1000);
let child = parent.nested().expect("nest");
child.record_breach(ControlBreach::AgentBackstop { limit: 5 });
assert!(parent.is_cancelled());
assert!(matches!(
parent.control_breach(),
Some(ControlBreach::AgentBackstop { limit: 5 })
));
}
#[test]
fn nesting_is_one_level_only() {
let parent = budgeted(1000);
let child = parent.nested().expect("first level ok");
let grandchild = child.nested();
assert!(
grandchild.is_err(),
"a second nesting level must be rejected"
);
}
#[test]
fn child_has_independent_default_phase() {
let parent = budgeted(1000);
parent.swap_default_phase(Some(std::sync::Arc::from("parent-phase")));
let child = parent.nested().expect("nest");
assert!(child.current_phase().is_none());
child.swap_default_phase(Some(std::sync::Arc::from("child-phase")));
assert_eq!(parent.current_phase().as_deref(), Some("parent-phase"));
assert_eq!(child.current_phase().as_deref(), Some("child-phase"));
}
#[test]
fn stop_sets_cancelled() {
let ctx = budgeted(1000);
assert!(!ctx.is_cancelled());
ctx.stop();
assert!(ctx.is_cancelled());
}
}