feat: llm graph nodes support skills

This commit is contained in:
2026-06-02 12:39:43 -06:00
parent a564085449
commit 2acff31213
4 changed files with 194 additions and 0 deletions
+131
View File
@@ -119,6 +119,7 @@ impl GraphValidator {
self.validate_approval_routes(graph, &mut result);
self.validate_rag_nodes(graph, &mut result);
self.validate_llm_nodes(graph, &mut result);
self.validate_llm_skills(graph, &mut result);
self.validate_max_concurrency(graph, &mut result);
self.validate_map_branches(graph, &mut result);
self.validate_parallel_user_interaction(graph, &mut result);
@@ -189,6 +190,39 @@ impl GraphValidator {
}
}
fn validate_llm_skills(&self, graph: &Graph, result: &mut ValidationResult) {
for (node_id, node) in &graph.nodes {
let NodeType::Llm(llm) = &node.node_type else {
continue;
};
let Some(node_skills) = &llm.enabled_skills else {
continue;
};
for name in node_skills {
if name.trim().is_empty() {
result.error(ValidationError::with_node(
node_id,
"llm node 'enabled_skills' contains an empty skill name",
));
continue;
}
if let Some(graph_skills) = &graph.enabled_skills
&& !graph_skills.iter().any(|g| g == name)
{
result.error(ValidationError::with_node(
node_id,
format!(
"llm node 'enabled_skills' references '{name}' which is not in \
graph-level 'enabled_skills' ({})",
graph_skills.join(", ")
),
));
}
}
}
}
fn validate_node_references(&self, graph: &Graph, result: &mut ValidationResult) {
for (node_id, node) in &graph.nodes {
for (target, label) in declared_targets(node) {
@@ -847,6 +881,8 @@ mod tests {
top_p: None,
global_tools: Vec::new(),
mcp_servers: Vec::new(),
skills_enabled: None,
enabled_skills: None,
conversation_starters: Vec::new(),
variables: Vec::new(),
settings: GraphSettings::default(),
@@ -946,6 +982,8 @@ mod tests {
state_updates: None,
output_schema: None,
timeout: None,
skills_enabled: None,
enabled_skills: None,
}),
next: next.map(NextTargets::from),
}
@@ -967,6 +1005,99 @@ mod tests {
assert!(result.errors.iter().any(|e| e.message.contains("ghost")));
}
#[test]
fn llm_node_skill_in_graph_set_passes() {
let mut graph = graph_with(
vec![("l", llm_node("l", None, Some("end"))), ("end", end_node("end"))],
"l",
);
graph.enabled_skills = Some(vec!["code-review".into(), "git-master".into()]);
if let NodeType::Llm(ref mut n) = graph.nodes.get_mut("l").unwrap().node_type {
n.enabled_skills = Some(vec!["code-review".into()]);
}
let result = validator().validate(&graph);
assert!(
!result
.errors
.iter()
.any(|e| e.message.contains("enabled_skills")),
"unexpected enabled_skills error: {:?}",
result.errors
);
}
#[test]
fn llm_node_skill_not_in_graph_set_errors() {
let mut graph = graph_with(
vec![("l", llm_node("l", None, Some("end"))), ("end", end_node("end"))],
"l",
);
graph.enabled_skills = Some(vec!["code-review".into()]);
if let NodeType::Llm(ref mut n) = graph.nodes.get_mut("l").unwrap().node_type {
n.enabled_skills = Some(vec!["git-master".into()]);
}
let result = validator().validate(&graph);
assert!(!result.is_valid());
assert!(
result.errors.iter().any(|e| e
.message
.contains("'git-master'")
&& e.message.contains("graph-level")),
"expected git-master subset error, got: {:?}",
result.errors
);
}
#[test]
fn llm_node_empty_skill_name_errors() {
let mut graph = graph_with(
vec![("l", llm_node("l", None, Some("end"))), ("end", end_node("end"))],
"l",
);
graph.enabled_skills = Some(vec!["code-review".into()]);
if let NodeType::Llm(ref mut n) = graph.nodes.get_mut("l").unwrap().node_type {
n.enabled_skills = Some(vec!["".into()]);
}
let result = validator().validate(&graph);
assert!(!result.is_valid());
assert!(
result
.errors
.iter()
.any(|e| e.message.contains("empty skill name")),
"expected empty-skill-name error, got: {:?}",
result.errors
);
}
#[test]
fn llm_node_skill_when_no_graph_set_is_permitted_by_validator() {
let mut graph = graph_with(
vec![("l", llm_node("l", None, Some("end"))), ("end", end_node("end"))],
"l",
);
if let NodeType::Llm(ref mut n) = graph.nodes.get_mut("l").unwrap().node_type {
n.enabled_skills = Some(vec!["anything".into()]);
}
let result = validator().validate(&graph);
assert!(
!result
.errors
.iter()
.any(|e| e.message.contains("enabled_skills")),
"validator should not block when graph.enabled_skills is None: {:?}",
result.errors
);
}
fn agent_ctx(tools: &[&str], mcp: &[&str]) -> AgentValidationContext {
AgentValidationContext {
tool_names: tools.iter().map(|s| s.to_string()).collect(),