use crate::execution::{AgentRequest, Execute};
use crate::workflows_config::WorkflowConfig;
use ares_types::types::{AgentContext, AppError, Result};
use chrono::Utc;
use cordis::{Context, Plugin, Service};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowOutput {
pub final_response: String,
pub steps_executed: usize,
pub agents_used: Vec<String>,
pub reasoning_path: Vec<WorkflowStep>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowStep {
pub agent_name: String,
pub input: String,
pub output: String,
pub timestamp: i64,
pub duration_ms: u64,
}
#[derive(Clone, Default)]
pub struct WorkflowSet {
pub workflows: HashMap<String, WorkflowConfig>,
}
impl Service for WorkflowSet {
fn name(&self) -> &'static str {
"workflows"
}
}
const VALID_AGENTS: &[&str] = &[
"product",
"invoice",
"sales",
"finance",
"hr",
"orchestrator",
"research",
"router",
];
pub struct WorkflowEngine {
pub ctx: Arc<Context>,
workflows: HashMap<String, WorkflowConfig>,
}
impl Service for WorkflowEngine {
fn name(&self) -> &'static str {
"workflow"
}
fn check(&self) -> bool {
true
}
}
impl WorkflowEngine {
pub fn new(ctx: Arc<Context>) -> Self {
let workflows = ctx
.get::<WorkflowSet>()
.map(|s| s.workflows.clone())
.unwrap_or_default();
Self { ctx, workflows }
}
pub fn with_config(ctx: Arc<Context>, workflows: HashMap<String, WorkflowConfig>) -> Self {
Self { ctx, workflows }
}
#[allow(dead_code)]
pub fn from_ctx(ctx: Arc<Context>) -> Self {
Self::new(ctx)
}
fn parse_routing_decision(output: &str) -> Option<String> {
let trimmed = output.trim().to_lowercase();
if VALID_AGENTS.contains(&trimmed.as_str()) {
return Some(trimmed);
}
for word in trimmed.split(|c: char| c.is_whitespace() || c == ':' || c == ',' || c == '.') {
let word = word.trim();
if VALID_AGENTS.contains(&word) {
return Some(word.to_string());
}
}
for agent in VALID_AGENTS {
if trimmed.contains(agent) {
return Some(agent.to_string());
}
}
None
}
pub async fn execute_workflow(
&self,
workflow_name: &str,
user_input: &str,
context: &AgentContext,
) -> Result<WorkflowOutput> {
let workflow = self
.workflows
.get(workflow_name)
.cloned()
.ok_or_else(|| {
AppError::Configuration(format!(
"Workflow '{workflow_name}' not found in configuration"
))
})?;
let exec = self.ctx.get::<Execute>().ok_or_else(|| {
AppError::Configuration("Execute is not on context".into())
})?;
let scoped = crate::tenant_scope(&self.ctx, &context.user_id);
let mut steps = Vec::new();
let mut agents_used = Vec::new();
let current_input = user_input.to_string();
let mut current_agent_name = workflow.entry_agent.clone();
let mut depth = 0;
while depth < workflow.max_depth {
let step_start = std::time::Instant::now();
let timestamp = Utc::now().timestamp();
let req = AgentRequest {
agent_name: current_agent_name.clone(),
message: current_input.clone(),
history: Vec::new(),
ctx_provider: None,
};
let run = match exec.run(&req, &scoped).await {
Ok(r) => r,
Err(e) => {
if let Some(fallback) = &workflow.fallback_agent {
tracing::warn!(
"Failed to run agent '{}', using fallback '{}'",
current_agent_name,
fallback
);
current_agent_name = fallback.clone();
let req = AgentRequest {
agent_name: current_agent_name.clone(),
message: current_input.clone(),
history: Vec::new(),
ctx_provider: None,
};
exec.run(&req, &scoped).await?
} else {
return Err(e);
}
}
};
let output = run.response.content;
let duration_ms = step_start.elapsed().as_millis() as u64;
steps.push(WorkflowStep {
agent_name: current_agent_name.clone(),
input: current_input.clone(),
output: output.clone(),
timestamp,
duration_ms,
});
if !agents_used.contains(¤t_agent_name) {
agents_used.push(current_agent_name.clone());
}
if current_agent_name == "router" {
let next_agent = Self::parse_routing_decision(&output);
if let Some(agent_name) = &next_agent {
current_agent_name = agent_name.clone();
depth += 1;
continue;
}
if let Some(fallback) = &workflow.fallback_agent {
tracing::warn!(
"Routed agent '{:?}' not found or invalid, using fallback '{}'",
next_agent,
fallback
);
current_agent_name = fallback.clone();
depth += 1;
continue;
}
break;
}
break;
}
let final_response = steps
.last()
.map(|s| s.output.clone())
.unwrap_or_else(|| "No response generated".to_string());
Ok(WorkflowOutput {
final_response,
steps_executed: steps.len(),
agents_used,
reasoning_path: steps,
})
}
pub fn available_workflows(&self) -> Vec<String> {
let mut names: Vec<String> = self.workflows.keys().cloned().collect();
names.sort();
names
}
pub fn has_workflow(&self, name: &str) -> bool {
self.workflows.contains_key(name)
}
pub fn get_workflow_config(&self, name: &str) -> Option<WorkflowConfig> {
self.workflows.get(name).cloned()
}
}
pub struct WorkflowPlugin;
impl Plugin for WorkflowPlugin {
type Config = HashMap<String, WorkflowConfig>;
type Provides = WorkflowEngine;
fn apply(
&self,
ctx: &Arc<Context>,
config: Self::Config,
) -> std::result::Result<Arc<Self::Provides>, cordis::CordisError> {
ctx.provide(WorkflowSet {
workflows: config.clone(),
});
Ok(Arc::new(WorkflowEngine::with_config(ctx.clone(), config)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_routing_decision_exact_match() {
assert_eq!(
WorkflowEngine::parse_routing_decision("product").as_deref(),
Some("product")
);
assert_eq!(
WorkflowEngine::parse_routing_decision(" SALES ").as_deref(),
Some("sales")
);
}
#[test]
fn test_parse_routing_decision_word_split() {
assert_eq!(
WorkflowEngine::parse_routing_decision("route to: product.").as_deref(),
Some("product")
);
assert_eq!(
WorkflowEngine::parse_routing_decision("invoice agent").as_deref(),
Some("invoice")
);
}
#[test]
fn test_parse_routing_decision_substring_match() {
assert_eq!(
WorkflowEngine::parse_routing_decision("I would route this to finance").as_deref(),
Some("finance")
);
}
#[test]
fn test_parse_routing_decision_none_for_unknown() {
assert!(WorkflowEngine::parse_routing_decision("unknown-agent").is_none());
assert!(WorkflowEngine::parse_routing_decision("").is_none());
}
#[test]
fn workflow_engine_reads_workflow_set() {
let ctx = Context::new_root();
let mut workflows = HashMap::new();
workflows.insert(
"default".into(),
WorkflowConfig {
entry_agent: "router".into(),
fallback_agent: Some("orchestrator".into()),
max_depth: 3,
max_iterations: 5,
parallel_subagents: false,
},
);
ctx.provide(WorkflowSet {
workflows: workflows.clone(),
});
let engine = WorkflowEngine::new(ctx);
assert!(engine.has_workflow("default"));
assert!(!engine.has_workflow("missing"));
assert_eq!(engine.available_workflows(), vec!["default".to_string()]);
assert_eq!(
engine.get_workflow_config("default").unwrap().entry_agent,
"router"
);
}
}