use std::collections::{HashMap, HashSet};
use super::types::{AgentReplyPlanV1, PlanArtifactError, plan_step_id_syntax_ok};
pub fn validate_plan_workflow_node_ids_subset(
plan: &AgentReplyPlanV1,
workflow_node_ids: &[String],
) -> Result<(), PlanArtifactError> {
let set: HashSet<&str> = workflow_node_ids.iter().map(|s| s.as_str()).collect();
for (i, s) in plan.steps.iter().enumerate() {
let Some(ref w) = s.workflow_node_id else {
continue;
};
let w = w.trim();
if !set.contains(w) {
return Err(PlanArtifactError::InvalidStep {
index: i,
reason: "workflow_node_id 不在最近一次工作流工具结果的 nodes 列表中",
});
}
}
Ok(())
}
pub fn validate_plan_covers_all_workflow_node_ids(
plan: &AgentReplyPlanV1,
workflow_node_ids: &[String],
) -> Result<(), PlanArtifactError> {
if workflow_node_ids.is_empty() {
return Ok(());
}
let any_linked = plan.steps.iter().any(|s| s.workflow_node_id.is_some());
if !any_linked {
return Ok(());
}
let mut covered: HashSet<&str> = HashSet::new();
for s in &plan.steps {
if let Some(ref w) = s.workflow_node_id {
covered.insert(w.trim());
}
}
let mut missing = Vec::new();
for id in workflow_node_ids {
let t = id.as_str();
if !covered.contains(t) {
missing.push(t.to_string());
}
}
if missing.is_empty() {
Ok(())
} else {
Err(PlanArtifactError::WorkflowNodesNotFullyCovered { missing })
}
}
pub fn validate_plan_binds_workflow_validate_nodes(
plan: &AgentReplyPlanV1,
validate_only_node_ids: &[String],
) -> Result<(), PlanArtifactError> {
if validate_only_node_ids.is_empty() {
return Ok(());
}
if plan.steps.len() != validate_only_node_ids.len() {
return Err(PlanArtifactError::ValidateOnlyPlanNodeBindingMismatch {
detail: "steps_len_must_equal_nodes_len",
});
}
let mut expected: HashMap<&str, usize> = HashMap::new();
for id in validate_only_node_ids {
*expected.entry(id.as_str()).or_insert(0) += 1;
}
let mut actual: HashMap<&str, usize> = HashMap::new();
for s in &plan.steps {
let Some(ref w) = s.workflow_node_id else {
return Err(PlanArtifactError::ValidateOnlyPlanNodeBindingMismatch {
detail: "each_step_requires_workflow_node_id",
});
};
let t = w.trim();
if t.is_empty() {
return Err(PlanArtifactError::ValidateOnlyPlanNodeBindingMismatch {
detail: "each_step_requires_workflow_node_id",
});
}
*actual.entry(t).or_insert(0) += 1;
}
if actual == expected {
Ok(())
} else {
Err(PlanArtifactError::ValidateOnlyPlanNodeBindingMismatch {
detail: "workflow_node_id_multiset_must_match_nodes",
})
}
}
pub(super) fn validate_agent_reply_plan_v1(p: &AgentReplyPlanV1) -> Result<(), PlanArtifactError> {
validate_agent_reply_plan_v1_with_validate_only_binding_ids(p, None)
}
fn validate_plan_step_transitions(
p: &AgentReplyPlanV1,
step_index: usize,
transitions: &[super::PlanStepControlFlow],
seen_step_ids: &HashSet<String>,
) -> Result<(), PlanArtifactError> {
for t in transitions {
if !seen_step_ids.contains(t.target_step_id.as_str())
&& !p.steps.iter().any(|st| st.id == t.target_step_id)
{
return Err(PlanArtifactError::InvalidStep {
index: step_index,
reason: "transitions 包含不存在的 target_step_id",
});
}
#[allow(clippy::collapsible_if)]
if let Some(max) = t.max_loops {
if max > 20 {
return Err(PlanArtifactError::InvalidStep {
index: step_index,
reason: "transitions 中的 max_loops 不得超过 20",
});
}
}
}
Ok(())
}
fn validate_single_plan_step_v1(
p: &AgentReplyPlanV1,
step_index: usize,
s: &super::PlanStepV1,
seen_step_ids: &mut HashSet<String>,
) -> Result<(), PlanArtifactError> {
let raw_id = s.id.as_str();
if raw_id != raw_id.trim() {
return Err(PlanArtifactError::InvalidStep {
index: step_index,
reason: "id 首尾不得含空白",
});
}
let id = raw_id.trim();
if id.is_empty() {
return Err(PlanArtifactError::InvalidStep {
index: step_index,
reason: "id 为空",
});
}
if !plan_step_id_syntax_ok(id) {
return Err(PlanArtifactError::InvalidStep {
index: step_index,
reason: "id 语法不合法(须 ASCII 字母数字起头,仅含 - _ . /,总长不超过 128)",
});
}
if !seen_step_ids.insert(id.to_string()) {
return Err(PlanArtifactError::InvalidStep {
index: step_index,
reason: "id 重复",
});
}
if s.description.trim().is_empty() {
return Err(PlanArtifactError::InvalidStep {
index: step_index,
reason: "description 为空",
});
}
if let Some(ref w) = s.workflow_node_id {
let raw_w = w.as_str();
if raw_w != raw_w.trim() {
return Err(PlanArtifactError::InvalidStep {
index: step_index,
reason: "workflow_node_id 首尾不得含空白",
});
}
let w = raw_w.trim();
if w.is_empty() {
return Err(PlanArtifactError::InvalidStep {
index: step_index,
reason: "workflow_node_id 若出现须为非空字符串(否则请省略该字段)",
});
}
if !plan_step_id_syntax_ok(w) {
return Err(PlanArtifactError::InvalidStep {
index: step_index,
reason: "workflow_node_id 语法不合法",
});
}
}
if let Some(ref transitions) = s.transitions {
validate_plan_step_transitions(p, step_index, transitions, seen_step_ids)?;
}
Ok(())
}
pub fn validate_agent_reply_plan_v1_with_validate_only_binding_ids(
p: &AgentReplyPlanV1,
validate_only_binding_ids: Option<&[String]>,
) -> Result<(), PlanArtifactError> {
const PLAN_FIXED_MAX_STEPS: usize = 1;
if p.plan_type != "agent_reply_plan" {
return Err(PlanArtifactError::WrongType(p.plan_type.clone()));
}
if p.version != 1 {
return Err(PlanArtifactError::WrongVersion(p.version));
}
if p.no_task {
if !p.steps.is_empty() {
return Err(PlanArtifactError::NoTaskWithNonEmptySteps);
}
return Ok(());
}
if p.steps.is_empty() {
return Err(PlanArtifactError::EmptySteps);
}
let workflow_bound_multi_step = p.steps.len() > PLAN_FIXED_MAX_STEPS
&& p.steps.iter().all(|s| {
s.workflow_node_id
.as_deref()
.map(|w| !w.trim().is_empty())
.unwrap_or(false)
});
let has_validate_only_binding_context =
validate_only_binding_ids.is_some_and(|ids| !ids.is_empty());
let allow_workflow_bound_multi_step =
workflow_bound_multi_step && has_validate_only_binding_context;
if p.steps.len() > PLAN_FIXED_MAX_STEPS && !allow_workflow_bound_multi_step {
return Err(PlanArtifactError::TooManySteps {
max: PLAN_FIXED_MAX_STEPS,
got: p.steps.len(),
});
}
let mut seen_step_ids = HashSet::<String>::new();
for (i, s) in p.steps.iter().enumerate() {
validate_single_plan_step_v1(p, i, s, &mut seen_step_ids)?;
}
Ok(())
}
#[cfg(test)]
mod validate_workflow_binding_tests {
use super::super::types::{AgentReplyPlanV1, PlanArtifactError, PlanStepV1};
use super::{
validate_plan_binds_workflow_validate_nodes, validate_plan_workflow_node_ids_subset,
};
fn step(id: &str, desc: &str, wf: Option<&str>) -> PlanStepV1 {
PlanStepV1 {
id: id.into(),
description: desc.into(),
workflow_node_id: wf.map(str::to_string),
executor_kind: None,
step_kind: None,
acceptance: None,
max_step_retries: None,
transitions: None,
}
}
#[test]
fn validate_subset_trims_workflow_node_id_in_plan() {
let plan = AgentReplyPlanV1 {
plan_type: "agent_reply_plan".into(),
version: 1,
steps: vec![step("s1", "x", Some(" n1 "))],
no_task: false,
};
assert!(validate_plan_workflow_node_ids_subset(&plan, &["n1".into()]).is_ok());
}
#[test]
fn validate_only_binding_multiset_mismatch_detail() {
let plan = AgentReplyPlanV1 {
plan_type: "agent_reply_plan".into(),
version: 1,
steps: vec![step("a", "1", Some("x")), step("b", "2", Some("y"))],
no_task: false,
};
let err = validate_plan_binds_workflow_validate_nodes(&plan, &["x".into(), "x".into()])
.expect_err("multiset mismatch");
assert!(matches!(
err,
PlanArtifactError::ValidateOnlyPlanNodeBindingMismatch {
detail: "workflow_node_id_multiset_must_match_nodes"
}
));
}
}