use serde::{Deserialize, Serialize};
use super::task_graph::TaskGraph;
use crate::types::agent::{AgentIsolation, AgentRole, ContextInheritance};
use crate::types::error::{DeepStrikeError, Result};
use crate::types::task::{RuntimeTask, TaskLane};
pub mod run;
pub use run::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum NodeTrust {
#[default]
Trusted,
Quarantined,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClassifyBranch {
pub label: String,
pub nodes: Vec<usize>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum NodeKind {
#[default]
Spawn,
Loop { max_iters: usize },
Classify { branches: Vec<ClassifyBranch> },
Tournament { entrants: Vec<RuntimeTask> },
Reduce { reducer: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowNode {
pub task: RuntimeTask,
pub role: AgentRole,
pub isolation: AgentIsolation,
pub context_inheritance: ContextInheritance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_hint: Option<String>,
#[serde(default, skip_serializing_if = "is_trusted")]
pub trust: NodeTrust,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_schema: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "is_spawn")]
pub kind: NodeKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_budget: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<usize>,
}
fn is_trusted(t: &NodeTrust) -> bool {
matches!(t, NodeTrust::Trusted)
}
fn is_spawn(k: &NodeKind) -> bool {
matches!(k, NodeKind::Spawn)
}
impl WorkflowNode {
pub fn new(task: RuntimeTask, role: AgentRole) -> Self {
let (isolation, context_inheritance) = role_defaults(role);
Self {
task,
role,
isolation,
context_inheritance,
model_hint: None,
trust: NodeTrust::Trusted,
output_schema: None,
kind: NodeKind::Spawn,
token_budget: None,
depends_on: Vec::new(),
}
}
pub fn with_token_budget(mut self, tokens: u64) -> Self {
self.token_budget = Some(tokens);
self
}
pub fn with_loop(mut self, max_iters: usize) -> Self {
self.kind = NodeKind::Loop { max_iters };
self
}
pub fn with_classify(mut self, branches: Vec<ClassifyBranch>) -> Self {
self.kind = NodeKind::Classify { branches };
self
}
pub fn with_tournament(mut self, entrants: Vec<RuntimeTask>) -> Self {
self.kind = NodeKind::Tournament { entrants };
self
}
pub fn with_reduce(mut self, reducer: impl Into<String>) -> Self {
self.kind = NodeKind::Reduce { reducer: reducer.into() };
self
}
pub fn with_depends_on(mut self, depends_on: Vec<usize>) -> Self {
self.depends_on = depends_on;
self
}
pub fn with_isolation(mut self, isolation: AgentIsolation) -> Self {
self.isolation = isolation;
self
}
pub fn with_context_inheritance(mut self, inheritance: ContextInheritance) -> Self {
self.context_inheritance = inheritance;
self
}
pub fn with_model_hint(mut self, hint: impl Into<String>) -> Self {
self.model_hint = Some(hint.into());
self
}
pub fn with_trust(mut self, trust: NodeTrust) -> Self {
self.trust = trust;
self
}
pub fn quarantined(mut self) -> Self {
self.trust = NodeTrust::Quarantined;
self
}
pub fn with_output_schema(mut self, schema: serde_json::Value) -> Self {
self.output_schema = Some(schema);
self
}
}
fn role_defaults(role: AgentRole) -> (AgentIsolation, ContextInheritance) {
match role {
AgentRole::Explore => (AgentIsolation::ReadOnly, ContextInheritance::SystemOnly),
AgentRole::Verify => (AgentIsolation::ReadOnly, ContextInheritance::None),
AgentRole::Plan => (AgentIsolation::Shared, ContextInheritance::Full),
AgentRole::Implement => (AgentIsolation::Worktree, ContextInheritance::Full),
AgentRole::Custom => (AgentIsolation::Shared, ContextInheritance::None),
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkflowSpec {
pub nodes: Vec<WorkflowNode>,
}
impl WorkflowSpec {
pub fn new(nodes: Vec<WorkflowNode>) -> Self {
Self { nodes }
}
pub fn validate(&self) -> Result<()> {
let n = self.nodes.len();
for (i, node) in self.nodes.iter().enumerate() {
if let NodeKind::Loop { max_iters: 0 } = node.kind {
return Err(DeepStrikeError::InvalidConfig(format!(
"node {i} is a loop with max_iters=0 (would never run)"
)));
}
if let NodeKind::Tournament { entrants } = &node.kind {
if entrants.len() < 2 {
return Err(DeepStrikeError::InvalidConfig(format!(
"tournament node {i} needs at least 2 entrants (have {})",
entrants.len()
)));
}
}
if let NodeKind::Classify { branches } = &node.kind {
for branch in branches {
for &bn in &branch.nodes {
if bn >= n {
return Err(DeepStrikeError::InvalidConfig(format!(
"classify node {i} branch '{}' references out-of-range node {bn}",
branch.label
)));
}
if !self.nodes[bn].depends_on.contains(&i) {
return Err(DeepStrikeError::InvalidConfig(format!(
"classify node {i} branch '{}' node {bn} must depends_on {i}",
branch.label
)));
}
}
}
}
for &dep in &node.depends_on {
if dep >= n {
return Err(DeepStrikeError::InvalidConfig(format!(
"node {i} depends on out-of-range node {dep} (have {n})"
)));
}
if dep == i {
return Err(DeepStrikeError::InvalidConfig(format!(
"node {i} depends on itself"
)));
}
}
}
self.to_task_graph()?.topological_sort().map(|_| ())
}
pub fn to_task_graph(&self) -> Result<TaskGraph> {
let n = self.nodes.len();
let mut graph = TaskGraph::new();
for node in &self.nodes {
if let Some(&bad) = node.depends_on.iter().find(|&&d| d >= n) {
return Err(DeepStrikeError::InvalidConfig(format!(
"dependency index {bad} out of range (have {n})"
)));
}
graph.add(node.task.clone(), node.depends_on.clone());
}
Ok(graph)
}
}
pub fn fanout_synthesize(workers: Vec<RuntimeTask>, synthesize: RuntimeTask) -> WorkflowSpec {
let mut nodes: Vec<WorkflowNode> = workers
.into_iter()
.map(|t| WorkflowNode::new(t.with_lane(TaskLane::new(TaskLane::RETRIEVE)), AgentRole::Explore))
.collect();
let worker_ids: Vec<usize> = (0..nodes.len()).collect();
nodes.push(
WorkflowNode::new(synthesize.with_lane(TaskLane::new(TaskLane::ORCHESTRATE)), AgentRole::Plan)
.with_depends_on(worker_ids),
);
WorkflowSpec::new(nodes)
}
pub fn generate_and_filter(generators: Vec<RuntimeTask>, filter: RuntimeTask) -> WorkflowSpec {
let mut nodes: Vec<WorkflowNode> = generators
.into_iter()
.map(|t| WorkflowNode::new(t.with_lane(TaskLane::new(TaskLane::RETRIEVE)), AgentRole::Implement))
.collect();
let gen_ids: Vec<usize> = (0..nodes.len()).collect();
nodes.push(
WorkflowNode::new(filter.with_lane(TaskLane::new(TaskLane::VERIFY)), AgentRole::Verify)
.with_depends_on(gen_ids),
);
WorkflowSpec::new(nodes)
}
pub fn verify_rules(rules: Vec<RuntimeTask>, skeptic: Option<RuntimeTask>) -> WorkflowSpec {
let mut nodes: Vec<WorkflowNode> = rules
.into_iter()
.map(|t| WorkflowNode::new(t.with_lane(TaskLane::new(TaskLane::VERIFY)), AgentRole::Verify))
.collect();
if let Some(skeptic) = skeptic {
let verifier_ids: Vec<usize> = (0..nodes.len()).collect();
nodes.push(
WorkflowNode::new(skeptic.with_lane(TaskLane::new(TaskLane::VERIFY)), AgentRole::Verify)
.with_depends_on(verifier_ids),
);
}
WorkflowSpec::new(nodes)
}
pub fn gen_eval(
worker: RuntimeTask,
eval: RuntimeTask,
max_iters: usize,
extract_skill_on_pass: bool,
) -> WorkflowSpec {
let worker_node = WorkflowNode::new(
worker.with_lane(TaskLane::new(TaskLane::ORCHESTRATE)),
AgentRole::Implement,
)
.with_loop(max_iters.max(1));
let eval_node = WorkflowNode::new(
eval.with_lane(TaskLane::new(TaskLane::VERIFY)),
AgentRole::Verify,
)
.with_depends_on(vec![0])
.with_output_schema(crate::harness::verdict_output_schema(extract_skill_on_pass));
WorkflowSpec::new(vec![worker_node, eval_node])
}
#[derive(Debug, Clone)]
pub struct ClassifyAndAct {
pub classifier: WorkflowNode,
pub branches: Vec<(String, WorkflowNode)>,
}
impl ClassifyAndAct {
pub fn route(&self, label: &str) -> Option<&WorkflowNode> {
self.branches
.iter()
.find(|(l, _)| l == label)
.map(|(_, node)| node)
}
}
pub fn classify_and_act(
classifier: RuntimeTask,
branches: Vec<(String, RuntimeTask)>,
) -> ClassifyAndAct {
ClassifyAndAct {
classifier: WorkflowNode::new(classifier, AgentRole::Plan),
branches: branches
.into_iter()
.map(|(label, task)| (label, WorkflowNode::new(task, AgentRole::Implement)))
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn task(goal: &str) -> RuntimeTask {
RuntimeTask::new(goal)
}
#[test]
fn fanout_synthesize_shape() {
let spec = fanout_synthesize(
vec![task("search A"), task("search B"), task("search C")],
task("merge findings"),
);
assert_eq!(spec.nodes.len(), 4);
assert_eq!(spec.nodes[3].depends_on, vec![0, 1, 2]);
assert_eq!(spec.nodes[3].role, AgentRole::Plan);
assert_eq!(spec.nodes[0].role, AgentRole::Explore);
assert_eq!(spec.nodes[0].isolation, AgentIsolation::ReadOnly);
spec.validate().unwrap();
let graph = spec.to_task_graph().unwrap();
assert_eq!(graph.ready_tasks(), vec![0, 1, 2]);
}
#[test]
fn generate_and_filter_shape() {
let spec = generate_and_filter(vec![task("idea 1"), task("idea 2")], task("dedupe + rank"));
assert_eq!(spec.nodes.len(), 3);
assert_eq!(spec.nodes[2].depends_on, vec![0, 1]);
assert_eq!(spec.nodes[2].role, AgentRole::Verify);
assert_eq!(spec.nodes[2].context_inheritance, ContextInheritance::None);
assert_eq!(spec.nodes[0].role, AgentRole::Implement);
spec.validate().unwrap();
}
#[test]
fn verify_rules_with_skeptic_shape() {
let spec = verify_rules(
vec![task("money is integer cents"), task("errors propagate"), task("utc timestamps")],
Some(task("skeptic: real violation or false positive?")),
);
assert_eq!(spec.nodes.len(), 4);
assert_eq!(spec.nodes[3].depends_on, vec![0, 1, 2]);
assert_eq!(spec.nodes[3].role, AgentRole::Verify);
spec.validate().unwrap();
assert_eq!(spec.to_task_graph().unwrap().ready_tasks(), vec![0, 1, 2]);
}
#[test]
fn verify_rules_verifiers_are_bias_resistant() {
let spec = verify_rules(vec![task("rule a"), task("rule b")], None);
assert_eq!(spec.nodes.len(), 2); for node in &spec.nodes {
assert_eq!(node.role, AgentRole::Verify);
assert_eq!(node.context_inheritance, ContextInheritance::None);
assert_eq!(node.isolation, AgentIsolation::ReadOnly);
assert!(node.depends_on.is_empty()); }
spec.validate().unwrap();
}
#[test]
fn gen_eval_shape() {
let spec = gen_eval(task("implement feature"), task("score against criteria"), 3, true);
assert_eq!(spec.nodes.len(), 2);
let worker = &spec.nodes[0];
assert_eq!(worker.role, AgentRole::Implement);
assert_eq!(worker.kind, NodeKind::Loop { max_iters: 3 });
assert!(worker.depends_on.is_empty());
let eval = &spec.nodes[1];
assert_eq!(eval.role, AgentRole::Verify);
assert_eq!(eval.context_inheritance, ContextInheritance::None);
assert_eq!(eval.isolation, AgentIsolation::ReadOnly);
assert_eq!(eval.depends_on, vec![0]);
let schema = eval.output_schema.as_ref().expect("eval node carries verdict schema");
assert!(schema["properties"]["passed"].is_object());
assert!(schema["properties"]["skill"].is_object());
spec.validate().unwrap();
assert_eq!(spec.to_task_graph().unwrap().ready_tasks(), vec![0]);
}
#[test]
fn gen_eval_max_iters_floor_and_no_skill() {
let spec = gen_eval(task("w"), task("e"), 0, false);
assert_eq!(spec.nodes[0].kind, NodeKind::Loop { max_iters: 1 });
let schema = spec.nodes[1].output_schema.as_ref().unwrap();
assert!(schema["properties"]["skill"].is_null());
spec.validate().unwrap();
}
#[test]
fn verify_rules_empty_with_skeptic_is_just_skeptic() {
let spec = verify_rules(vec![], Some(task("skeptic")));
assert_eq!(spec.nodes.len(), 1);
assert!(spec.nodes[0].depends_on.is_empty());
spec.validate().unwrap();
}
#[test]
fn classify_and_act_routes() {
let c = classify_and_act(
task("classify the ticket"),
vec![
("bug".into(), task("attempt fix")),
("question".into(), task("answer it")),
],
);
assert_eq!(c.classifier.role, AgentRole::Plan);
assert_eq!(c.route("bug").unwrap().task.goal, "attempt fix");
assert_eq!(c.route("question").unwrap().task.goal, "answer it");
assert!(c.route("unknown").is_none());
}
#[test]
fn validate_rejects_out_of_range_dep() {
let spec = WorkflowSpec::new(vec![
WorkflowNode::new(task("a"), AgentRole::Explore),
WorkflowNode::new(task("b"), AgentRole::Plan).with_depends_on(vec![5]),
]);
assert!(spec.validate().is_err());
}
#[test]
fn validate_rejects_self_dependency() {
let spec = WorkflowSpec::new(vec![
WorkflowNode::new(task("a"), AgentRole::Plan).with_depends_on(vec![0]),
]);
assert!(spec.validate().is_err());
}
#[test]
fn validate_rejects_cycle() {
let spec = WorkflowSpec::new(vec![
WorkflowNode::new(task("a"), AgentRole::Plan).with_depends_on(vec![1]),
WorkflowNode::new(task("b"), AgentRole::Plan).with_depends_on(vec![0]),
]);
assert!(spec.validate().is_err());
}
#[test]
fn tournament_node_requires_two_entrants() {
let ok = WorkflowSpec::new(vec![WorkflowNode::new(task("rank"), AgentRole::Plan)
.with_tournament(vec![task("a"), task("b")])]);
ok.validate().unwrap();
let one = WorkflowSpec::new(vec![WorkflowNode::new(task("rank"), AgentRole::Plan)
.with_tournament(vec![task("only")])]);
assert!(one.validate().is_err());
}
#[test]
fn tournament_node_kind_round_trips_and_gates_dependents() {
let spec = WorkflowSpec::new(vec![
WorkflowNode::new(task("pick best"), AgentRole::Plan)
.with_tournament(vec![task("x"), task("y"), task("z")]),
WorkflowNode::new(task("use winner"), AgentRole::Implement).with_depends_on(vec![0]),
]);
spec.validate().unwrap();
assert_eq!(spec.to_task_graph().unwrap().ready_tasks(), vec![0]);
let json = serde_json::to_string(&spec.nodes[0].kind).unwrap();
assert!(json.contains("\"type\":\"tournament\""), "{json}");
let back: NodeKind = serde_json::from_str(&json).unwrap();
assert_eq!(back, spec.nodes[0].kind);
}
#[test]
fn node_builder_overrides_defaults() {
let n = WorkflowNode::new(task("x"), AgentRole::Verify)
.with_isolation(AgentIsolation::Worktree)
.with_model_hint("opus");
assert_eq!(n.isolation, AgentIsolation::Worktree);
assert_eq!(n.model_hint.as_deref(), Some("opus"));
assert_eq!(n.context_inheritance, ContextInheritance::None);
}
}