添加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

This commit is contained in:
iven
2026-03-01 17:59:03 +08:00
parent 92e5def702
commit 810e32077e
23 changed files with 8420 additions and 29 deletions

File diff suppressed because it is too large Load Diff

View 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(())
}
}

File diff suppressed because it is too large Load Diff

View 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"));
}
}

View 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 { .. })));
}
}

View File

@@ -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(|| {

View File

@@ -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,
};

File diff suppressed because it is too large Load Diff

View File

@@ -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]