添加AOL路由和UI/UX增强组件
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
This commit is contained in:
1009
crates/openfang-kernel/src/aol/executor.rs
Normal file
1009
crates/openfang-kernel/src/aol/executor.rs
Normal file
File diff suppressed because it is too large
Load Diff
96
crates/openfang-kernel/src/aol/mod.rs
Normal file
96
crates/openfang-kernel/src/aol/mod.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
//! Agent Orchestration Language (AOL) module.
|
||||
//!
|
||||
//! This module provides parsing and execution of AOL workflows - a declarative
|
||||
//! DSL for defining multi-agent orchestration workflows.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! - `parser`: TOML → AST parsing
|
||||
//! - `template`: Template variable expansion
|
||||
//! - `validator`: Workflow validation
|
||||
//! - `executor`: Workflow execution engine
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```toml
|
||||
//! [workflow]
|
||||
//! name = "research-pipeline"
|
||||
//! version = "1.0.0"
|
||||
//!
|
||||
//! [workflow.input]
|
||||
//! topic = { type = "string", required = true }
|
||||
//!
|
||||
//! [[workflow.steps.sequential]]
|
||||
//! id = "research"
|
||||
//! agent = { kind = "by_name", name = "researcher" }
|
||||
//! task = "Search for papers about {{input.topic}}"
|
||||
//! output = "papers"
|
||||
//! ```
|
||||
|
||||
pub mod executor;
|
||||
pub mod parser;
|
||||
pub mod template;
|
||||
pub mod validator;
|
||||
|
||||
pub use executor::{
|
||||
AolExecutor, AgentExecutor, ExecutionId, ExecutionResult, ExecutionStatus,
|
||||
MockAgentExecutor, StepExecutionResult,
|
||||
};
|
||||
pub use parser::{parse_aol_workflow, parse_aol_workflow_from_str, AolParseError};
|
||||
pub use template::{expand_template, TemplateContext, TemplateError};
|
||||
pub use validator::{validate_workflow, ValidationError};
|
||||
|
||||
use openfang_types::aol::{AolWorkflow, WorkflowDefId};
|
||||
|
||||
/// Result type for AOL operations.
|
||||
pub type AolResult<T> = Result<T, AolError>;
|
||||
|
||||
/// Error type for AOL operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AolError {
|
||||
/// Parsing error.
|
||||
#[error("Parse error: {0}")]
|
||||
Parse(#[from] AolParseError),
|
||||
|
||||
/// Validation error.
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(#[from] ValidationError),
|
||||
|
||||
/// Template expansion error.
|
||||
#[error("Template error: {0}")]
|
||||
Template(#[from] TemplateError),
|
||||
|
||||
/// Execution error.
|
||||
#[error("Execution error: {0}")]
|
||||
Execution(String),
|
||||
}
|
||||
|
||||
/// Compiled workflow ready for execution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledWorkflow {
|
||||
/// The parsed AST.
|
||||
pub workflow: AolWorkflow,
|
||||
/// Workflow definition ID.
|
||||
pub id: WorkflowDefId,
|
||||
/// Whether the workflow has been validated.
|
||||
pub validated: bool,
|
||||
}
|
||||
|
||||
impl CompiledWorkflow {
|
||||
/// Create a new compiled workflow from a parsed AST.
|
||||
pub fn new(workflow: AolWorkflow) -> Self {
|
||||
let id = workflow.id;
|
||||
Self {
|
||||
workflow,
|
||||
id,
|
||||
validated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the workflow.
|
||||
pub fn validate(&mut self) -> AolResult<()> {
|
||||
validate_workflow(&self.workflow)?;
|
||||
self.validated = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1252
crates/openfang-kernel/src/aol/parser.rs
Normal file
1252
crates/openfang-kernel/src/aol/parser.rs
Normal file
File diff suppressed because it is too large
Load Diff
454
crates/openfang-kernel/src/aol/template.rs
Normal file
454
crates/openfang-kernel/src/aol/template.rs
Normal file
@@ -0,0 +1,454 @@
|
||||
//! Template variable expansion for AOL workflows.
|
||||
//!
|
||||
//! Supports `{{variable}}` syntax for variable interpolation.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error type for template operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TemplateError {
|
||||
/// Variable not found in context.
|
||||
#[error("Variable not found: {0}")]
|
||||
VariableNotFound(String),
|
||||
|
||||
/// Invalid variable syntax.
|
||||
#[error("Invalid variable syntax: {0}")]
|
||||
InvalidSyntax(String),
|
||||
|
||||
/// JSON path evaluation error.
|
||||
#[error("JSON path error: {0}")]
|
||||
JsonPath(String),
|
||||
|
||||
/// Type mismatch.
|
||||
#[error("Type mismatch: expected {expected}, got {actual}")]
|
||||
TypeMismatch { expected: String, actual: String },
|
||||
}
|
||||
|
||||
/// Context for template expansion.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TemplateContext {
|
||||
/// Input variables.
|
||||
pub input: HashMap<String, Value>,
|
||||
/// Step outputs.
|
||||
pub outputs: HashMap<String, Value>,
|
||||
/// Loop variables.
|
||||
pub loop_vars: HashMap<String, Value>,
|
||||
/// Custom variables.
|
||||
pub custom: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl TemplateContext {
|
||||
/// Create a new empty context.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create a context with input variables.
|
||||
pub fn with_inputs(inputs: HashMap<String, Value>) -> Self {
|
||||
Self {
|
||||
input: inputs,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an input variable.
|
||||
pub fn add_input(&mut self, key: impl Into<String>, value: Value) -> &mut Self {
|
||||
self.input.insert(key.into(), value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a step output.
|
||||
pub fn add_output(&mut self, key: impl Into<String>, value: Value) -> &mut Self {
|
||||
self.outputs.insert(key.into(), value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a loop variable.
|
||||
pub fn add_loop_var(&mut self, key: impl Into<String>, value: Value) -> &mut Self {
|
||||
self.loop_vars.insert(key.into(), value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set custom variables.
|
||||
pub fn set_custom(&mut self, custom: HashMap<String, Value>) -> &mut Self {
|
||||
self.custom = custom;
|
||||
self
|
||||
}
|
||||
|
||||
/// Get a variable by path.
|
||||
pub fn get(&self, path: &str) -> Option<&Value> {
|
||||
// Parse path: namespace.key or namespace.key.nested
|
||||
let parts: Vec<&str> = path.splitn(2, '.').collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let namespace = parts[0];
|
||||
let remainder = parts.get(1).copied();
|
||||
|
||||
match namespace {
|
||||
"input" => {
|
||||
if let Some(key) = remainder {
|
||||
self.get_nested(&self.input, key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
"outputs" | "output" => {
|
||||
if let Some(key) = remainder {
|
||||
self.get_nested(&self.outputs, key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
"loop" => {
|
||||
if let Some(key) = remainder {
|
||||
self.get_nested(&self.loop_vars, key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
"custom" => {
|
||||
if let Some(key) = remainder {
|
||||
self.get_nested(&self.custom, key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
// Direct access without namespace - check all namespaces
|
||||
_ => {
|
||||
// First check if it's a direct key in outputs (most common)
|
||||
if let Some(v) = self.outputs.get(namespace) {
|
||||
return Some(v);
|
||||
}
|
||||
// Then check inputs
|
||||
if let Some(v) = self.input.get(namespace) {
|
||||
return Some(v);
|
||||
}
|
||||
// Then loop vars
|
||||
if let Some(v) = self.loop_vars.get(namespace) {
|
||||
return Some(v);
|
||||
}
|
||||
// Finally custom
|
||||
if let Some(v) = self.custom.get(namespace) {
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a nested value from a map.
|
||||
fn get_nested<'a>(&self, map: &'a HashMap<String, Value>, path: &str) -> Option<&'a Value> {
|
||||
let parts: Vec<&str> = path.split('.').collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first = map.get(parts[0])?;
|
||||
if parts.len() == 1 {
|
||||
return Some(first);
|
||||
}
|
||||
|
||||
// Navigate nested path
|
||||
let mut current = first;
|
||||
for part in &parts[1..] {
|
||||
match current {
|
||||
Value::Object(obj) => {
|
||||
current = obj.get(*part)?;
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
if let Ok(idx) = part.parse::<usize>() {
|
||||
current = arr.get(idx)?;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
/// Set a step output.
|
||||
pub fn set_output(&mut self, key: impl Into<String>, value: Value) {
|
||||
self.outputs.insert(key.into(), value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand template variables in a string.
|
||||
///
|
||||
/// Supports:
|
||||
/// - `{{variable}}` - Simple variable
|
||||
/// - `{{input.key}}` - Namespaced variable
|
||||
/// - `{{output.step_name}}` - Step output reference
|
||||
/// - `{{nested.path.to.value}}` - Nested access
|
||||
pub fn expand_template(template: &str, ctx: &TemplateContext) -> Result<String, TemplateError> {
|
||||
let mut result = String::with_capacity(template.len() * 2);
|
||||
let mut chars = template.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '{' && chars.peek() == Some(&'{') {
|
||||
// Skip the second '{'
|
||||
chars.next();
|
||||
|
||||
// Collect variable name until }}
|
||||
let mut var_name = String::new();
|
||||
let mut found_end = false;
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '}' && chars.peek() == Some(&'}') {
|
||||
chars.next(); // Skip second '}'
|
||||
found_end = true;
|
||||
break;
|
||||
}
|
||||
var_name.push(c);
|
||||
}
|
||||
|
||||
if !found_end {
|
||||
return Err(TemplateError::InvalidSyntax(format!(
|
||||
"Unclosed variable: {}",
|
||||
var_name
|
||||
)));
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
let var_name = var_name.trim();
|
||||
|
||||
if var_name.is_empty() {
|
||||
return Err(TemplateError::InvalidSyntax("Empty variable name".to_string()));
|
||||
}
|
||||
|
||||
// Look up variable
|
||||
let value = ctx.get(var_name).ok_or_else(|| {
|
||||
TemplateError::VariableNotFound(var_name.to_string())
|
||||
})?;
|
||||
|
||||
// Convert to string
|
||||
let str_value = value_to_string(value);
|
||||
result.push_str(&str_value);
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Convert a JSON value to a string for template expansion.
|
||||
fn value_to_string(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Null => String::new(),
|
||||
Value::Array(arr) => {
|
||||
// Format array as comma-separated values
|
||||
arr.iter()
|
||||
.map(value_to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
Value::Object(obj) => {
|
||||
// Format object as key=value pairs
|
||||
obj.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, value_to_string(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand all template variables in a map.
|
||||
pub fn expand_templates_in_map(
|
||||
map: &HashMap<String, String>,
|
||||
ctx: &TemplateContext,
|
||||
) -> Result<HashMap<String, String>, TemplateError> {
|
||||
let mut result = HashMap::with_capacity(map.len());
|
||||
for (k, v) in map {
|
||||
result.insert(k.clone(), expand_template(v, ctx)?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn make_context() -> TemplateContext {
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.add_input("topic", json!("AI safety"));
|
||||
ctx.add_input("count", json!(10));
|
||||
ctx.add_input("enabled", json!(true));
|
||||
ctx.add_input(
|
||||
"nested",
|
||||
json!({
|
||||
"level1": {
|
||||
"level2": "deep_value"
|
||||
}
|
||||
}),
|
||||
);
|
||||
ctx.add_output("result", json!("success"));
|
||||
ctx.add_output("data", json!([1, 2, 3]));
|
||||
ctx.add_loop_var("item", json!("current_item"));
|
||||
ctx.add_loop_var("index", json!(5));
|
||||
ctx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_simple_variable() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template("Topic: {{input.topic}}", &ctx).unwrap();
|
||||
assert_eq!(result, "Topic: AI safety");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_multiple_variables() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template(
|
||||
"Topic: {{input.topic}}, Count: {{input.count}}",
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, "Topic: AI safety, Count: 10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_output_variable() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template("Result: {{output.result}}", &ctx).unwrap();
|
||||
assert_eq!(result, "Result: success");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_loop_variable() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template("Item: {{loop.item}}, Index: {{loop.index}}", &ctx).unwrap();
|
||||
assert_eq!(result, "Item: current_item, Index: 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_nested_variable() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template("Deep: {{input.nested.level1.level2}}", &ctx).unwrap();
|
||||
assert_eq!(result, "Deep: deep_value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_array_variable() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template("Data: {{output.data}}", &ctx).unwrap();
|
||||
assert_eq!(result, "Data: 1, 2, 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_boolean_variable() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template("Enabled: {{input.enabled}}", &ctx).unwrap();
|
||||
assert_eq!(result, "Enabled: true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_direct_access() {
|
||||
let ctx = make_context();
|
||||
// Direct access without namespace (checks outputs first)
|
||||
let result = expand_template("{{result}}", &ctx).unwrap();
|
||||
assert_eq!(result, "success");
|
||||
|
||||
// Direct access to input
|
||||
let result = expand_template("{{topic}}", &ctx).unwrap();
|
||||
assert_eq!(result, "AI safety");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_variable_not_found() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template("{{input.unknown}}", &ctx);
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
TemplateError::VariableNotFound(var) => assert_eq!(var, "input.unknown"),
|
||||
_ => panic!("Expected VariableNotFound error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unclosed_variable() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template("{{input.topic", &ctx);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_variable() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template("{{}}", &ctx);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_variables() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template("Plain text without variables", &ctx).unwrap();
|
||||
assert_eq!(result, "Plain text without variables");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adjacent_variables() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template("{{input.topic}}{{input.count}}", &ctx).unwrap();
|
||||
assert_eq!(result, "AI safety10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitespace_in_variable() {
|
||||
let ctx = make_context();
|
||||
let result = expand_template("{{ input.topic }}", &ctx).unwrap();
|
||||
assert_eq!(result, "AI safety");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_templates_in_map() {
|
||||
let ctx = make_context();
|
||||
let map = vec![
|
||||
("key1".to_string(), "{{input.topic}}".to_string()),
|
||||
("key2".to_string(), "Value: {{output.result}}".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let result = expand_templates_in_map(&map, &ctx).unwrap();
|
||||
assert_eq!(result.get("key1"), Some(&"AI safety".to_string()));
|
||||
assert_eq!(result.get("key2"), Some(&"Value: success".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_template() {
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.add_input("name", json!("OpenFang"));
|
||||
ctx.add_input("version", json!("1.0.0"));
|
||||
ctx.add_output("status", json!("ready"));
|
||||
|
||||
let template = "Project {{input.name}} v{{input.version}} is {{output.status}}!";
|
||||
let result = expand_template(template, &ctx).unwrap();
|
||||
assert_eq!(result, "Project OpenFang v1.0.0 is ready!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_object_formatting() {
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.add_input(
|
||||
"config",
|
||||
json!({
|
||||
"host": "localhost",
|
||||
"port": 8080
|
||||
}),
|
||||
);
|
||||
|
||||
let result = expand_template("Config: {{input.config}}", &ctx).unwrap();
|
||||
assert!(result.contains("host=localhost"));
|
||||
assert!(result.contains("port=8080"));
|
||||
}
|
||||
}
|
||||
876
crates/openfang-kernel/src/aol/validator.rs
Normal file
876
crates/openfang-kernel/src/aol/validator.rs
Normal file
@@ -0,0 +1,876 @@
|
||||
//! AOL Workflow validation.
|
||||
//!
|
||||
//! Validates workflow definitions for correctness and consistency.
|
||||
|
||||
use openfang_types::aol::{AolStep, AolWorkflow, AgentRef};
|
||||
use std::collections::HashSet;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error type for validation.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ValidationError {
|
||||
/// Missing required input.
|
||||
#[error("Missing required input: {0}")]
|
||||
MissingInput(String),
|
||||
|
||||
/// Undefined variable reference.
|
||||
#[error("Undefined variable reference: {0}")]
|
||||
UndefinedVariable(String),
|
||||
|
||||
/// Duplicate output variable.
|
||||
#[error("Duplicate output variable: {0}")]
|
||||
DuplicateOutput(String),
|
||||
|
||||
/// Invalid step reference.
|
||||
#[error("Invalid step reference: {0}")]
|
||||
InvalidStepRef(String),
|
||||
|
||||
/// Circular dependency.
|
||||
#[error("Circular dependency detected: {0}")]
|
||||
CircularDependency(String),
|
||||
|
||||
/// Empty workflow.
|
||||
#[error("Workflow has no steps")]
|
||||
EmptyWorkflow,
|
||||
|
||||
/// Invalid condition expression.
|
||||
#[error("Invalid condition expression: {0}")]
|
||||
InvalidCondition(String),
|
||||
|
||||
/// Empty step ID.
|
||||
#[error("Step ID cannot be empty")]
|
||||
EmptyStepId,
|
||||
|
||||
/// Empty task.
|
||||
#[error("Step '{0}' has empty task")]
|
||||
EmptyTask(String),
|
||||
|
||||
/// Invalid agent reference.
|
||||
#[error("Invalid agent reference in step '{step}': {reason}")]
|
||||
InvalidAgent { step: String, reason: String },
|
||||
|
||||
/// Too many steps.
|
||||
#[error("Too many steps: {count} (max: {max})")]
|
||||
TooManySteps { count: usize, max: usize },
|
||||
|
||||
/// Nested too deep.
|
||||
#[error("Workflow nested too deep: {depth} (max: {max})")]
|
||||
NestedTooDeep { depth: usize, max: usize },
|
||||
}
|
||||
|
||||
/// Validation options.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValidationOptions {
|
||||
/// Maximum number of steps allowed.
|
||||
pub max_steps: usize,
|
||||
/// Maximum nesting depth.
|
||||
pub max_depth: usize,
|
||||
/// Whether to check variable references.
|
||||
pub check_references: bool,
|
||||
/// Whether to check for circular dependencies.
|
||||
pub check_circular: bool,
|
||||
}
|
||||
|
||||
impl Default for ValidationOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_steps: 100,
|
||||
max_depth: 10,
|
||||
check_references: true,
|
||||
check_circular: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a workflow definition.
|
||||
pub fn validate_workflow(workflow: &AolWorkflow) -> Result<(), ValidationError> {
|
||||
validate_workflow_with_options(workflow, ValidationOptions::default())
|
||||
}
|
||||
|
||||
/// Validate a workflow with custom options.
|
||||
pub fn validate_workflow_with_options(
|
||||
workflow: &AolWorkflow,
|
||||
options: ValidationOptions,
|
||||
) -> Result<(), ValidationError> {
|
||||
// Check workflow has steps
|
||||
if workflow.steps.is_empty() {
|
||||
return Err(ValidationError::EmptyWorkflow);
|
||||
}
|
||||
|
||||
// Count total steps and check limit
|
||||
let total_steps = count_steps(&workflow.steps);
|
||||
if total_steps > options.max_steps {
|
||||
return Err(ValidationError::TooManySteps {
|
||||
count: total_steps,
|
||||
max: options.max_steps,
|
||||
});
|
||||
}
|
||||
|
||||
// Check nesting depth
|
||||
let depth = max_nesting_depth(&workflow.steps);
|
||||
if depth > options.max_depth {
|
||||
return Err(ValidationError::NestedTooDeep {
|
||||
depth,
|
||||
max: options.max_depth,
|
||||
});
|
||||
}
|
||||
|
||||
// Collect all step IDs and outputs
|
||||
let mut step_ids = HashSet::new();
|
||||
let mut outputs = HashSet::new();
|
||||
collect_step_info(&workflow.steps, &mut step_ids, &mut outputs)?;
|
||||
|
||||
// Validate each step
|
||||
for step in &workflow.steps {
|
||||
validate_step(step, &step_ids, &outputs, &options)?;
|
||||
}
|
||||
|
||||
// Check for circular dependencies
|
||||
if options.check_circular {
|
||||
check_circular_deps(&workflow.steps, &mut HashSet::new())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Count total steps recursively.
|
||||
fn count_steps(steps: &[AolStep]) -> usize {
|
||||
steps.iter().map(count_step_children).sum()
|
||||
}
|
||||
|
||||
/// Count children of a step.
|
||||
fn count_step_children(step: &AolStep) -> usize {
|
||||
match step {
|
||||
AolStep::Parallel(pg) => {
|
||||
1 + pg.steps.len()
|
||||
}
|
||||
AolStep::Sequential(_) => 1,
|
||||
AolStep::Conditional(cs) => {
|
||||
1 + cs
|
||||
.branches
|
||||
.iter()
|
||||
.map(|b| count_steps(&b.steps))
|
||||
.sum::<usize>()
|
||||
+ cs.default.as_ref().map(|d| count_steps(d.as_slice())).unwrap_or(0)
|
||||
}
|
||||
AolStep::Loop(ls) => 1 + count_steps(&ls.steps),
|
||||
AolStep::Collect(_) => 1,
|
||||
AolStep::Subworkflow(_) => 1,
|
||||
AolStep::Fallback(fs) => {
|
||||
1 + count_step_children(&fs.primary)
|
||||
+ fs.fallbacks.iter().map(count_step_children).sum::<usize>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate maximum nesting depth.
|
||||
fn max_nesting_depth(steps: &[AolStep]) -> usize {
|
||||
steps
|
||||
.iter()
|
||||
.map(|step| match step {
|
||||
AolStep::Parallel(pg) => {
|
||||
1 + pg.steps.iter().map(|_| 1).max().unwrap_or(0)
|
||||
}
|
||||
AolStep::Sequential(_) => 1,
|
||||
AolStep::Conditional(cs) => {
|
||||
let branch_depth = cs
|
||||
.branches
|
||||
.iter()
|
||||
.map(|b| max_nesting_depth(&b.steps))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let default_depth = cs
|
||||
.default
|
||||
.as_ref()
|
||||
.map(|d| max_nesting_depth(d.as_slice()))
|
||||
.unwrap_or(0);
|
||||
1 + branch_depth.max(default_depth)
|
||||
}
|
||||
AolStep::Loop(ls) => 1 + max_nesting_depth(&ls.steps),
|
||||
AolStep::Collect(_) => 1,
|
||||
AolStep::Subworkflow(_) => 1,
|
||||
AolStep::Fallback(fs) => {
|
||||
let primary_depth = max_nesting_depth(&[(*fs.primary).clone()]);
|
||||
let fallback_depth = fs
|
||||
.fallbacks
|
||||
.iter()
|
||||
.map(|f| max_nesting_depth(&[f.clone()]))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
1 + primary_depth.max(fallback_depth)
|
||||
}
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Collect step IDs and output variable names.
|
||||
fn collect_step_info(
|
||||
steps: &[AolStep],
|
||||
ids: &mut HashSet<String>,
|
||||
outputs: &mut HashSet<String>,
|
||||
) -> Result<(), ValidationError> {
|
||||
for step in steps {
|
||||
let id = step.id();
|
||||
if id.is_empty() {
|
||||
return Err(ValidationError::EmptyStepId);
|
||||
}
|
||||
if ids.contains(id) {
|
||||
return Err(ValidationError::InvalidStepRef(format!(
|
||||
"Duplicate step ID: {}",
|
||||
id
|
||||
)));
|
||||
}
|
||||
ids.insert(id.to_string());
|
||||
|
||||
if let Some(output) = step.output() {
|
||||
if !output.is_empty() {
|
||||
if outputs.contains(output) {
|
||||
return Err(ValidationError::DuplicateOutput(output.to_string()));
|
||||
}
|
||||
outputs.insert(output.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively collect from nested steps
|
||||
match step {
|
||||
AolStep::Parallel(pg) => {
|
||||
for ps in &pg.steps {
|
||||
if ps.id.is_empty() {
|
||||
return Err(ValidationError::EmptyStepId);
|
||||
}
|
||||
if ids.contains(&ps.id) {
|
||||
return Err(ValidationError::InvalidStepRef(format!(
|
||||
"Duplicate step ID: {}",
|
||||
ps.id
|
||||
)));
|
||||
}
|
||||
ids.insert(ps.id.clone());
|
||||
if let Some(output) = &ps.output {
|
||||
if !output.is_empty() && outputs.contains(output) {
|
||||
return Err(ValidationError::DuplicateOutput(output.clone()));
|
||||
}
|
||||
outputs.insert(output.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
AolStep::Conditional(cs) => {
|
||||
for branch in &cs.branches {
|
||||
collect_step_info(&branch.steps, ids, outputs)?;
|
||||
}
|
||||
if let Some(default) = &cs.default {
|
||||
collect_step_info(default, ids, outputs)?;
|
||||
}
|
||||
}
|
||||
AolStep::Loop(ls) => {
|
||||
collect_step_info(&ls.steps, ids, outputs)?;
|
||||
}
|
||||
AolStep::Fallback(fs) => {
|
||||
collect_step_info(&[(*fs.primary).clone()], ids, outputs)?;
|
||||
collect_step_info(&fs.fallbacks, ids, outputs)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a single step.
|
||||
fn validate_step(
|
||||
step: &AolStep,
|
||||
step_ids: &HashSet<String>,
|
||||
outputs: &HashSet<String>,
|
||||
options: &ValidationOptions,
|
||||
) -> Result<(), ValidationError> {
|
||||
match step {
|
||||
AolStep::Parallel(pg) => {
|
||||
if pg.steps.is_empty() {
|
||||
return Err(ValidationError::InvalidStepRef(format!(
|
||||
"Parallel group '{}' has no steps",
|
||||
pg.id
|
||||
)));
|
||||
}
|
||||
for ps in &pg.steps {
|
||||
validate_task(&ps.id, &ps.task)?;
|
||||
validate_agent_ref(&ps.id, &ps.agent)?;
|
||||
}
|
||||
}
|
||||
AolStep::Sequential(ss) => {
|
||||
validate_task(&ss.id, &ss.task)?;
|
||||
validate_agent_ref(&ss.id, &ss.agent)?;
|
||||
}
|
||||
AolStep::Conditional(cs) => {
|
||||
if cs.branches.is_empty() {
|
||||
return Err(ValidationError::InvalidStepRef(format!(
|
||||
"Conditional '{}' has no branches",
|
||||
cs.id
|
||||
)));
|
||||
}
|
||||
for branch in &cs.branches {
|
||||
if branch.condition.is_empty() {
|
||||
return Err(ValidationError::InvalidCondition(format!(
|
||||
"Branch '{}' has empty condition",
|
||||
branch.id
|
||||
)));
|
||||
}
|
||||
for nested_step in &branch.steps {
|
||||
validate_step(nested_step, step_ids, outputs, options)?;
|
||||
}
|
||||
}
|
||||
if let Some(default) = &cs.default {
|
||||
for nested_step in default {
|
||||
validate_step(nested_step, step_ids, outputs, options)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
AolStep::Loop(ls) => {
|
||||
if ls.item_var.is_empty() {
|
||||
return Err(ValidationError::InvalidStepRef(format!(
|
||||
"Loop '{}' has empty item_var",
|
||||
ls.id
|
||||
)));
|
||||
}
|
||||
if ls.collection.is_empty() {
|
||||
return Err(ValidationError::InvalidStepRef(format!(
|
||||
"Loop '{}' has empty collection",
|
||||
ls.id
|
||||
)));
|
||||
}
|
||||
for nested_step in &ls.steps {
|
||||
validate_step(nested_step, step_ids, outputs, options)?;
|
||||
}
|
||||
}
|
||||
AolStep::Collect(cs) => {
|
||||
if cs.sources.is_empty() {
|
||||
return Err(ValidationError::InvalidStepRef(format!(
|
||||
"Collect '{}' has no sources",
|
||||
cs.id
|
||||
)));
|
||||
}
|
||||
if options.check_references {
|
||||
for source in &cs.sources {
|
||||
if !outputs.contains(source) {
|
||||
return Err(ValidationError::UndefinedVariable(format!(
|
||||
"Collect '{}' references undefined output: {}",
|
||||
cs.id, source
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AolStep::Subworkflow(_ss) => {
|
||||
// Workflow ID is validated during parsing
|
||||
}
|
||||
AolStep::Fallback(fs) => {
|
||||
validate_step(&fs.primary, step_ids, outputs, options)?;
|
||||
for fallback in &fs.fallbacks {
|
||||
validate_step(fallback, step_ids, outputs, options)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a task string.
|
||||
fn validate_task(step_id: &str, task: &str) -> Result<(), ValidationError> {
|
||||
if task.trim().is_empty() {
|
||||
return Err(ValidationError::EmptyTask(step_id.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate an agent reference.
|
||||
fn validate_agent_ref(step_id: &str, agent: &AgentRef) -> Result<(), ValidationError> {
|
||||
match agent {
|
||||
AgentRef::ById { id } => {
|
||||
if id.is_nil() {
|
||||
return Err(ValidationError::InvalidAgent {
|
||||
step: step_id.to_string(),
|
||||
reason: "Agent ID cannot be nil UUID".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
AgentRef::ByName { name } => {
|
||||
if name.trim().is_empty() {
|
||||
return Err(ValidationError::InvalidAgent {
|
||||
step: step_id.to_string(),
|
||||
reason: "Agent name cannot be empty".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
AgentRef::ByRole { role, .. } => {
|
||||
if role.trim().is_empty() {
|
||||
return Err(ValidationError::InvalidAgent {
|
||||
step: step_id.to_string(),
|
||||
reason: "Agent role cannot be empty".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check for circular dependencies.
|
||||
fn check_circular_deps(steps: &[AolStep], visited: &mut HashSet<String>) -> Result<(), ValidationError> {
|
||||
for step in steps {
|
||||
let id = step.id();
|
||||
if visited.contains(id) {
|
||||
return Err(ValidationError::CircularDependency(id.to_string()));
|
||||
}
|
||||
visited.insert(id.to_string());
|
||||
|
||||
// Check nested steps
|
||||
match step {
|
||||
AolStep::Conditional(cs) => {
|
||||
for branch in &cs.branches {
|
||||
check_circular_deps(&branch.steps, visited)?;
|
||||
}
|
||||
if let Some(default) = &cs.default {
|
||||
check_circular_deps(default, visited)?;
|
||||
}
|
||||
}
|
||||
AolStep::Loop(ls) => {
|
||||
check_circular_deps(&ls.steps, visited)?;
|
||||
}
|
||||
AolStep::Fallback(fs) => {
|
||||
check_circular_deps(&[(*fs.primary).clone()], visited)?;
|
||||
check_circular_deps(&fs.fallbacks, visited)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openfang_types::aol::{
|
||||
CollectStrategy, ConditionalBranch, ConditionalStep, InputParam, LoopStep,
|
||||
ParallelStep, ParallelStepGroup, ParamType, SequentialStep, WorkflowConfig,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn make_valid_workflow() -> AolWorkflow {
|
||||
AolWorkflow {
|
||||
id: openfang_types::aol::WorkflowDefId::new(),
|
||||
name: "test".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
description: String::new(),
|
||||
author: String::new(),
|
||||
inputs: vec![],
|
||||
outputs: vec![],
|
||||
config: WorkflowConfig::default(),
|
||||
steps: vec![AolStep::Sequential(SequentialStep {
|
||||
id: "step1".to_string(),
|
||||
agent: AgentRef::by_name("agent"),
|
||||
task: "Do something".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
output: Some("result".to_string()),
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
})],
|
||||
tags: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_valid_workflow() {
|
||||
let wf = make_valid_workflow();
|
||||
assert!(validate_workflow(&wf).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_empty_workflow() {
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_workflow(&wf),
|
||||
Err(ValidationError::EmptyWorkflow)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_empty_step_id() {
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![AolStep::Sequential(SequentialStep {
|
||||
id: String::new(),
|
||||
agent: AgentRef::by_name("agent"),
|
||||
task: "Task".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
output: None,
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
})],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_workflow(&wf),
|
||||
Err(ValidationError::EmptyStepId)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_empty_task() {
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![AolStep::Sequential(SequentialStep {
|
||||
id: "step1".to_string(),
|
||||
agent: AgentRef::by_name("agent"),
|
||||
task: String::new(),
|
||||
inputs: HashMap::new(),
|
||||
output: None,
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
})],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_workflow(&wf),
|
||||
Err(ValidationError::EmptyTask(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_duplicate_output() {
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![
|
||||
AolStep::Sequential(SequentialStep {
|
||||
id: "step1".to_string(),
|
||||
agent: AgentRef::by_name("agent"),
|
||||
task: "Task 1".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
output: Some("result".to_string()),
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
}),
|
||||
AolStep::Sequential(SequentialStep {
|
||||
id: "step2".to_string(),
|
||||
agent: AgentRef::by_name("agent"),
|
||||
task: "Task 2".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
output: Some("result".to_string()),
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
}),
|
||||
],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_workflow(&wf),
|
||||
Err(ValidationError::DuplicateOutput(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_invalid_agent_empty_name() {
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![AolStep::Sequential(SequentialStep {
|
||||
id: "step1".to_string(),
|
||||
agent: AgentRef::by_name(""),
|
||||
task: "Task".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
output: None,
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
})],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_workflow(&wf),
|
||||
Err(ValidationError::InvalidAgent { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_invalid_agent_nil_uuid() {
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![AolStep::Sequential(SequentialStep {
|
||||
id: "step1".to_string(),
|
||||
agent: AgentRef::by_id(Uuid::nil()),
|
||||
task: "Task".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
output: None,
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
})],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_workflow(&wf),
|
||||
Err(ValidationError::InvalidAgent { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_parallel_group_empty_steps() {
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![AolStep::Parallel(ParallelStepGroup {
|
||||
id: "pg1".to_string(),
|
||||
steps: vec![],
|
||||
collect: CollectStrategy::Merge,
|
||||
output: None,
|
||||
max_concurrency: None,
|
||||
})],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_workflow(&wf),
|
||||
Err(ValidationError::InvalidStepRef(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_conditional_empty_branches() {
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![AolStep::Conditional(ConditionalStep {
|
||||
id: "cond1".to_string(),
|
||||
branches: vec![],
|
||||
default: None,
|
||||
output: None,
|
||||
})],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_workflow(&wf),
|
||||
Err(ValidationError::InvalidStepRef(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_conditional_empty_condition() {
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![AolStep::Conditional(ConditionalStep {
|
||||
id: "cond1".to_string(),
|
||||
branches: vec![ConditionalBranch {
|
||||
id: "branch1".to_string(),
|
||||
condition: String::new(),
|
||||
steps: vec![],
|
||||
output: None,
|
||||
}],
|
||||
default: None,
|
||||
output: None,
|
||||
})],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_workflow(&wf),
|
||||
Err(ValidationError::InvalidCondition(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_loop_empty_item_var() {
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![AolStep::Loop(LoopStep {
|
||||
id: "loop1".to_string(),
|
||||
item_var: String::new(),
|
||||
index_var: None,
|
||||
collection: "$.items".to_string(),
|
||||
steps: vec![],
|
||||
collect: CollectStrategy::Merge,
|
||||
output: None,
|
||||
max_concurrency: 0,
|
||||
})],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_workflow(&wf),
|
||||
Err(ValidationError::InvalidStepRef(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_loop_empty_collection() {
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![AolStep::Loop(LoopStep {
|
||||
id: "loop1".to_string(),
|
||||
item_var: "item".to_string(),
|
||||
index_var: None,
|
||||
collection: String::new(),
|
||||
steps: vec![],
|
||||
collect: CollectStrategy::Merge,
|
||||
output: None,
|
||||
max_concurrency: 0,
|
||||
})],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_workflow(&wf),
|
||||
Err(ValidationError::InvalidStepRef(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_collect_undefined_source() {
|
||||
let options = ValidationOptions {
|
||||
check_references: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![
|
||||
AolStep::Sequential(SequentialStep {
|
||||
id: "step1".to_string(),
|
||||
agent: AgentRef::by_name("agent"),
|
||||
task: "Task".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
output: Some("result1".to_string()),
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
}),
|
||||
AolStep::Collect(openfang_types::aol::CollectStep {
|
||||
id: "collect1".to_string(),
|
||||
sources: vec!["undefined_output".to_string()],
|
||||
strategy: CollectStrategy::Merge,
|
||||
aggregate_fn: None,
|
||||
output: "collected".to_string(),
|
||||
}),
|
||||
],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
|
||||
let result = validate_workflow_with_options(&wf, options);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ValidationError::UndefinedVariable(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_collect_valid_source() {
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![
|
||||
AolStep::Sequential(SequentialStep {
|
||||
id: "step1".to_string(),
|
||||
agent: AgentRef::by_name("agent"),
|
||||
task: "Task".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
output: Some("result1".to_string()),
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
}),
|
||||
AolStep::Collect(openfang_types::aol::CollectStep {
|
||||
id: "collect1".to_string(),
|
||||
sources: vec!["result1".to_string()],
|
||||
strategy: CollectStrategy::Merge,
|
||||
aggregate_fn: None,
|
||||
output: "collected".to_string(),
|
||||
}),
|
||||
],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
|
||||
assert!(validate_workflow(&wf).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_too_many_steps() {
|
||||
let options = ValidationOptions {
|
||||
max_steps: 2,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![
|
||||
AolStep::Sequential(SequentialStep {
|
||||
id: "step1".to_string(),
|
||||
agent: AgentRef::by_name("agent"),
|
||||
task: "Task 1".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
output: None,
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
}),
|
||||
AolStep::Sequential(SequentialStep {
|
||||
id: "step2".to_string(),
|
||||
agent: AgentRef::by_name("agent"),
|
||||
task: "Task 2".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
output: None,
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
}),
|
||||
AolStep::Sequential(SequentialStep {
|
||||
id: "step3".to_string(),
|
||||
agent: AgentRef::by_name("agent"),
|
||||
task: "Task 3".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
output: None,
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
}),
|
||||
],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
|
||||
let result = validate_workflow_with_options(&wf, options);
|
||||
assert!(matches!(result, Err(ValidationError::TooManySteps { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_nested_too_deep() {
|
||||
let options = ValidationOptions {
|
||||
max_depth: 2,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Create deeply nested structure: conditional -> conditional -> conditional -> step
|
||||
let inner_step = AolStep::Sequential(SequentialStep {
|
||||
id: "inner".to_string(),
|
||||
agent: AgentRef::by_name("agent"),
|
||||
task: "Task".to_string(),
|
||||
inputs: HashMap::new(),
|
||||
output: None,
|
||||
error_mode: None,
|
||||
timeout_secs: None,
|
||||
condition: None,
|
||||
});
|
||||
|
||||
let level2 = AolStep::Conditional(ConditionalStep {
|
||||
id: "level2".to_string(),
|
||||
branches: vec![ConditionalBranch {
|
||||
id: "branch2".to_string(),
|
||||
condition: "true".to_string(),
|
||||
steps: vec![inner_step],
|
||||
output: None,
|
||||
}],
|
||||
default: None,
|
||||
output: None,
|
||||
});
|
||||
|
||||
let level1 = AolStep::Conditional(ConditionalStep {
|
||||
id: "level1".to_string(),
|
||||
branches: vec![ConditionalBranch {
|
||||
id: "branch1".to_string(),
|
||||
condition: "true".to_string(),
|
||||
steps: vec![level2],
|
||||
output: None,
|
||||
}],
|
||||
default: None,
|
||||
output: None,
|
||||
});
|
||||
|
||||
let wf = AolWorkflow {
|
||||
steps: vec![level1],
|
||||
..make_valid_workflow()
|
||||
};
|
||||
|
||||
let result = validate_workflow_with_options(&wf, options);
|
||||
assert!(matches!(result, Err(ValidationError::NestedTooDeep { .. })));
|
||||
}
|
||||
}
|
||||
@@ -2424,6 +2424,20 @@ impl OpenFangKernel {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's system prompt with persistence.
|
||||
pub fn update_agent_system_prompt(&self, agent_id: AgentId, system_prompt: String) -> KernelResult<()> {
|
||||
self.registry
|
||||
.update_system_prompt(agent_id, system_prompt.clone())
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
|
||||
if let Some(entry) = self.registry.get(agent_id) {
|
||||
let _ = self.memory.save_agent(&entry);
|
||||
}
|
||||
|
||||
info!(agent_id = %agent_id, "Agent system prompt updated and persisted");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get session token usage and estimated cost for an agent.
|
||||
pub fn session_usage_cost(&self, agent_id: AgentId) -> KernelResult<(u64, u64, f64)> {
|
||||
let entry = self.registry.get(agent_id).ok_or_else(|| {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! and inter-agent communication.
|
||||
|
||||
pub mod approval;
|
||||
pub mod aol;
|
||||
pub mod auth;
|
||||
pub mod auto_reply;
|
||||
pub mod background;
|
||||
@@ -17,6 +18,7 @@ pub mod heartbeat;
|
||||
pub mod kernel;
|
||||
pub mod metering;
|
||||
pub mod pairing;
|
||||
pub mod presence;
|
||||
pub mod registry;
|
||||
pub mod scheduler;
|
||||
pub mod supervisor;
|
||||
@@ -27,3 +29,7 @@ pub mod workflow;
|
||||
|
||||
pub use kernel::DeliveryTracker;
|
||||
pub use kernel::OpenFangKernel;
|
||||
pub use presence::{
|
||||
CollabSession, CollabSessionId, ConnectionId, PresenceConfig, PresenceCursor, PresenceError,
|
||||
PresenceManager, PresenceStats, PresenceStatus, PresenceUser,
|
||||
};
|
||||
|
||||
1378
crates/openfang-kernel/src/presence.rs
Normal file
1378
crates/openfang-kernel/src/presence.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -363,9 +363,9 @@ mod tests {
|
||||
let id = entry.id;
|
||||
registry.register(entry).unwrap();
|
||||
|
||||
registry.set_mode(id, AgentMode::Autonomous).unwrap();
|
||||
registry.set_mode(id, AgentMode::Full).unwrap();
|
||||
let updated = registry.get(id).unwrap();
|
||||
assert_eq!(updated.mode, AgentMode::Autonomous);
|
||||
assert_eq!(updated.mode, AgentMode::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user