Skip to main content

aivcs_core/
orchestration.rs

1//! Agent role orchestration primitives.
2//!
3//! This module provides deterministic role contracts for multi-agent workflows:
4//! - role templates and allowed handoffs
5//! - handoff validation
6//! - deterministic merge with conflict surfacing
7//! - state-safe parallel role scheduling guards
8
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11
12/// Canonical orchestration roles for plan -> code -> review -> test -> fix flows.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum AgentRole {
16    Planner,
17    Coder,
18    Reviewer,
19    Tester,
20    Fixer,
21}
22
23impl AgentRole {
24    fn merge_priority(self) -> u8 {
25        match self {
26            AgentRole::Fixer => 0,
27            AgentRole::Reviewer => 1,
28            AgentRole::Tester => 2,
29            AgentRole::Coder => 3,
30            AgentRole::Planner => 4,
31        }
32    }
33}
34
35/// Role contract used for validation and deterministic orchestration.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct RoleTemplate {
38    pub role: AgentRole,
39    pub required_input_keys: BTreeSet<String>,
40    pub produced_output_keys: BTreeSet<String>,
41    pub allowed_handoffs: BTreeSet<AgentRole>,
42}
43
44/// Handoff payload between two roles.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct RoleHandoff {
47    pub task_id: String,
48    pub from: AgentRole,
49    pub to: AgentRole,
50    pub payload: BTreeMap<String, String>,
51}
52
53/// A single role output fragment for merge.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct RoleOutput {
56    pub role: AgentRole,
57    pub step: u32,
58    pub values: BTreeMap<String, String>,
59}
60
61/// Merge conflict with explicit remediation context.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct MergeConflict {
64    pub key: String,
65    pub existing_role: AgentRole,
66    pub incoming_role: AgentRole,
67    pub existing_value: String,
68    pub incoming_value: String,
69}
70
71/// Strategy for conflicting role outputs.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub enum MergeConflictStrategy {
74    /// Keep original value and surface conflict.
75    FailOnConflict,
76    /// Resolve conflict by static role priority while surfacing conflict.
77    PreferRolePriority,
78}
79
80/// Merge result with deterministic output and surfaced conflicts.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct MergeOutcome {
83    pub values: BTreeMap<String, String>,
84    pub conflicts: Vec<MergeConflict>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum HandoffValidationError {
89    MissingTemplate(AgentRole),
90    ForbiddenRoute { from: AgentRole, to: AgentRole },
91    MissingRequiredKeys { role: AgentRole, keys: Vec<String> },
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum ParallelPlanError {
96    DuplicateRole(AgentRole),
97}
98
99/// Default role templates for the canonical software delivery loop.
100pub fn default_role_templates() -> BTreeMap<AgentRole, RoleTemplate> {
101    let mut map = BTreeMap::new();
102
103    map.insert(
104        AgentRole::Planner,
105        RoleTemplate {
106            role: AgentRole::Planner,
107            required_input_keys: BTreeSet::new(),
108            produced_output_keys: ["task_plan".to_string()].into_iter().collect(),
109            allowed_handoffs: [AgentRole::Coder, AgentRole::Tester].into_iter().collect(),
110        },
111    );
112    map.insert(
113        AgentRole::Coder,
114        RoleTemplate {
115            role: AgentRole::Coder,
116            required_input_keys: ["task_plan".to_string()].into_iter().collect(),
117            produced_output_keys: ["code_patch".to_string()].into_iter().collect(),
118            allowed_handoffs: [AgentRole::Reviewer, AgentRole::Tester]
119                .into_iter()
120                .collect(),
121        },
122    );
123    map.insert(
124        AgentRole::Reviewer,
125        RoleTemplate {
126            role: AgentRole::Reviewer,
127            required_input_keys: ["code_patch".to_string()].into_iter().collect(),
128            produced_output_keys: ["review_notes".to_string()].into_iter().collect(),
129            allowed_handoffs: [AgentRole::Fixer, AgentRole::Tester].into_iter().collect(),
130        },
131    );
132    map.insert(
133        AgentRole::Tester,
134        RoleTemplate {
135            role: AgentRole::Tester,
136            required_input_keys: ["code_patch".to_string()].into_iter().collect(),
137            produced_output_keys: ["test_report".to_string()].into_iter().collect(),
138            allowed_handoffs: [AgentRole::Fixer, AgentRole::Reviewer]
139                .into_iter()
140                .collect(),
141        },
142    );
143    map.insert(
144        AgentRole::Fixer,
145        RoleTemplate {
146            role: AgentRole::Fixer,
147            required_input_keys: ["code_patch".to_string()].into_iter().collect(),
148            produced_output_keys: ["code_patch".to_string(), "fix_notes".to_string()]
149                .into_iter()
150                .collect(),
151            allowed_handoffs: BTreeSet::new(),
152        },
153    );
154
155    map
156}
157
158/// Validate a role handoff against template contracts.
159pub fn validate_handoff(
160    templates: &BTreeMap<AgentRole, RoleTemplate>,
161    handoff: &RoleHandoff,
162) -> Result<(), HandoffValidationError> {
163    let from_template = templates
164        .get(&handoff.from)
165        .ok_or(HandoffValidationError::MissingTemplate(handoff.from))?;
166    let to_template = templates
167        .get(&handoff.to)
168        .ok_or(HandoffValidationError::MissingTemplate(handoff.to))?;
169
170    if !from_template.allowed_handoffs.contains(&handoff.to) {
171        return Err(HandoffValidationError::ForbiddenRoute {
172            from: handoff.from,
173            to: handoff.to,
174        });
175    }
176
177    let mut missing = Vec::new();
178    for key in &to_template.required_input_keys {
179        if !handoff.payload.contains_key(key) {
180            missing.push(key.clone());
181        }
182    }
183    if !missing.is_empty() {
184        return Err(HandoffValidationError::MissingRequiredKeys {
185            role: handoff.to,
186            keys: missing,
187        });
188    }
189
190    Ok(())
191}
192
193/// Merge role outputs deterministically and surface conflicts.
194pub fn merge_role_outputs(outputs: &[RoleOutput], strategy: MergeConflictStrategy) -> MergeOutcome {
195    let mut ordered = outputs.to_vec();
196    ordered.sort_by_key(|o| (o.step, o.role));
197
198    let mut values = BTreeMap::<String, String>::new();
199    let mut owners = BTreeMap::<String, AgentRole>::new();
200    let mut conflicts = Vec::<MergeConflict>::new();
201
202    for output in ordered {
203        for (key, incoming_value) in output.values {
204            match values.get(&key) {
205                None => {
206                    values.insert(key.clone(), incoming_value.clone());
207                    owners.insert(key, output.role);
208                }
209                Some(existing_value) if existing_value == &incoming_value => {
210                    // Preserve the original owner on equal-value writes for stable tie-breaking.
211                }
212                Some(existing_value) => {
213                    let existing_role = *owners.get(&key).unwrap_or(&output.role);
214                    conflicts.push(MergeConflict {
215                        key: key.clone(),
216                        existing_role,
217                        incoming_role: output.role,
218                        existing_value: existing_value.clone(),
219                        incoming_value: incoming_value.clone(),
220                    });
221
222                    if matches!(strategy, MergeConflictStrategy::PreferRolePriority)
223                        && output.role.merge_priority() < existing_role.merge_priority()
224                    {
225                        values.insert(key.clone(), incoming_value);
226                        owners.insert(key, output.role);
227                    }
228                }
229            }
230        }
231    }
232
233    MergeOutcome { values, conflicts }
234}
235
236/// Validate that a parallel role plan is state-safe (no duplicate role writers).
237pub fn validate_parallel_roles(roles: &[AgentRole]) -> Result<(), ParallelPlanError> {
238    let mut seen = BTreeSet::new();
239    for role in roles {
240        if !seen.insert(*role) {
241            return Err(ParallelPlanError::DuplicateRole(*role));
242        }
243    }
244    Ok(())
245}
246
247/// Deterministic role ordering for reproducible parallel scheduling.
248pub fn deterministic_role_order(roles: &[AgentRole]) -> Vec<AgentRole> {
249    let mut ordered = roles.to_vec();
250    ordered.sort_by_key(|r| (r.merge_priority(), *r));
251    ordered
252}