use std::time::Duration;
use async_trait::async_trait;
use clawft_types::config::CostBudgetConfig;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpawnBackend {
#[default]
Conversation,
Native,
Wasm {
module: String,
},
Container {
image: String,
},
Tee {
enclave_type: String,
},
Remote {
node_id: String,
},
}
impl SpawnBackend {
pub fn kind(&self) -> &'static str {
match self {
Self::Conversation => "conversation",
Self::Native => "native",
Self::Wasm { .. } => "wasm",
Self::Container { .. } => "container",
Self::Tee { .. } => "tee",
Self::Remote { .. } => "remote",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SpawnBudget {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usd: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub iterations: Option<u32>,
}
impl SpawnBudget {
pub fn resolve_against(&self, base: &CostBudgetConfig) -> CostBudgetConfig {
CostBudgetConfig {
max_tokens_per_conv: self.tokens.unwrap_or(base.max_tokens_per_conv),
max_usd_per_conv: self.usd.unwrap_or(base.max_usd_per_conv),
max_iterations_per_conv: self.iterations.unwrap_or(base.max_iterations_per_conv),
}
}
pub fn has_any(&self) -> bool {
self.tokens.is_some() || self.usd.is_some() || self.iterations.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnSpec {
pub goal: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<String>,
#[serde(rename = "await", default)]
pub await_completion: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget: Option<SpawnBudget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_id: Option<String>,
#[serde(default)]
pub notify_on_complete: bool,
#[serde(default)]
pub backend: SpawnBackend,
#[serde(default)]
pub depth: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_conv_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_turn_uid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_agent_id: Option<String>,
}
impl SpawnSpec {
pub fn new(goal: impl Into<String>) -> Self {
Self {
goal: goal.into(),
persona: None,
skills: Vec::new(),
await_completion: false,
budget: None,
group_id: None,
notify_on_complete: false,
backend: SpawnBackend::default(),
depth: 0,
parent_conv_id: None,
parent_turn_uid: None,
parent_agent_id: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskStatus {
Running,
Completed,
Failed,
Exhausted,
Cancelled,
Denied,
}
impl TaskStatus {
pub fn is_terminal(&self) -> bool {
!matches!(self, Self::Running)
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Running => "running",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Exhausted => "exhausted",
Self::Cancelled => "cancelled",
Self::Denied => "denied",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnHandle {
pub task_id: String,
pub child_conv_id: String,
pub status: TaskStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<TaskOutcome>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskOutcome {
pub task_id: String,
pub child_conv_id: String,
pub status: TaskStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
#[serde(default)]
pub iterations: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Error)]
pub enum SpawnError {
#[error("spawn backend '{backend}' not available: {reason}")]
BackendNotAvailable {
backend: String,
reason: String,
},
#[error("spawn depth {depth} exceeds max {max}")]
DepthExceeded {
depth: u32,
max: u32,
},
#[error("max concurrent subagents ({max}) reached for conversation")]
ConcurrencyExceeded {
max: u32,
},
#[error("spawn denied by governance: {reason}")]
Denied {
reason: String,
},
#[error("task not found: {task_id}")]
NotFound {
task_id: String,
},
#[error("subagent dispatch failed: {0}")]
DispatchFailed(String),
#[error("subagent spawning is disabled")]
Disabled,
}
#[async_trait]
pub trait SubagentSpawner: Send + Sync {
async fn spawn(&self, spec: SpawnSpec) -> Result<SpawnHandle, SpawnError>;
async fn status(&self, task_id: &str) -> Option<TaskOutcome>;
async fn group_status(&self, group_id: &str) -> Vec<TaskOutcome>;
async fn result(&self, task_id: &str, wait: Option<Duration>) -> Result<TaskOutcome, SpawnError>;
async fn message(&self, task_id: &str, text: &str) -> Result<(), SpawnError>;
async fn cancel(&self, task_id: &str) -> Result<(), SpawnError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spawn_budget_resolve_against_merges_and_keeps_base() {
let base = CostBudgetConfig {
max_tokens_per_conv: 200_000,
max_usd_per_conv: 1.0,
max_iterations_per_conv: 30,
};
let only_iters = SpawnBudget {
tokens: None,
usd: None,
iterations: Some(3),
};
let resolved = only_iters.resolve_against(&base);
assert_eq!(resolved.max_tokens_per_conv, 200_000);
assert!((resolved.max_usd_per_conv - 1.0).abs() < 1e-9);
assert_eq!(resolved.max_iterations_per_conv, 3);
assert!(only_iters.has_any());
assert!(!SpawnBudget::default().has_any());
}
#[test]
fn spawn_budget_resolve_tighter_tokens() {
let base = CostBudgetConfig::default();
let hint = SpawnBudget {
tokens: Some(512),
usd: Some(0.05),
iterations: Some(2),
};
let r = hint.resolve_against(&base);
assert_eq!(r.max_tokens_per_conv, 512);
assert!((r.max_usd_per_conv - 0.05).abs() < 1e-9);
assert_eq!(r.max_iterations_per_conv, 2);
}
}