Merge branch 'master' into feature/m3-agent-skills-loader
CI / lint (pull_request) Successful in 24s
CI / quality (pull_request) Successful in 33s
CI / benchmark-publish (pull_request) Has been skipped
CI / security (pull_request) Successful in 59s
CI / typecheck (pull_request) Successful in 1m1s
CI / build (pull_request) Successful in 28s
CI / coverage (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / benchmark-regression (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled

This commit is contained in:
2026-02-26 14:43:52 +00:00
17 changed files with 2088 additions and 17 deletions
+9
View File
@@ -11,6 +11,15 @@
Support directories (`scripts/`, `references/`, `assets/`) are discovered automatically and
exposed as read-only `AgentSkillResourceSlot` bindings. Includes `docs/reference/agent_skills.md`,
Behave unit tests, Robot Framework integration tests, and ASV benchmarks. (#160)
### feat(actor): extend hierarchical actor YAML schema and loader
- Extended actor YAML schema with hierarchical graph support: per-node LSP bindings (`lsp_binding`), tool-source references (`tool_sources`), and subgraph `actor_ref`.
- Added graph reachability validation — all nodes must be reachable from `entry_node` via edges or conditional routing targets.
- Improved loader error reporting with YAML line/column positions and Pydantic field-path hints.
- Added `docs/reference/actor_config.md` — practical configuration reference with hierarchical examples and error cases.
- Fixed `examples/actors/graph_workflow.yaml` to use `actor_ref` instead of deprecated `actor_path`.
- Added Robot smoke test for loading hierarchical actor YAML via `ActorLoader.discover()`.
- Added token/cost tracking, budget enforcement (per-plan and per-day), provider fallback
selection with capability filtering, and cost metadata for plan execution. New config keys
`budget_per_plan`, `budget_per_day`, and `fallback_providers` control spending limits and
+142
View File
@@ -0,0 +1,142 @@
"""ASV benchmarks for hierarchical actor YAML parsing performance.
Measures the performance of:
- Parsing actors with per-node LSP bindings
- Parsing actors with tool-source references
- Parsing actors with subgraph actor_ref
- Parsing actors with full hierarchical features combined
"""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
import cleveragents # noqa: E402
importlib.reload(cleveragents)
from cleveragents.actor.schema import ActorConfigSchema # noqa: E402
def _graph_data(nodes: list[dict], *, skills: list[str] | None = None) -> dict:
node_ids = [n["id"] for n in nodes]
edges = []
for i in range(len(node_ids) - 1):
edges.append({"from_node": node_ids[i], "to_node": node_ids[i + 1]})
data: dict = {
"name": "bench/hierarchy",
"type": "graph",
"description": "Benchmark hierarchical actor",
"model": "gpt-4",
"route": {
"nodes": nodes,
"edges": edges,
"entry_node": node_ids[0],
"exit_nodes": [node_ids[-1]],
},
}
if skills:
data["skills"] = skills
return data
_AGENT_NODE = {
"id": "agent",
"type": "agent",
"name": "Agent",
"description": "Agent node",
"config": {"model": "gpt-4"},
}
_LSP_NODE = {
"id": "lsp_agent",
"type": "agent",
"name": "LSP Agent",
"description": "Agent with LSP binding",
"config": {"model": "gpt-4"},
"lsp_binding": {
"server": "local/pyright",
"languages": ["python"],
"capabilities": ["diagnostics", "hover"],
},
}
_TS_NODE = {
"id": "ts_agent",
"type": "agent",
"name": "Tool Source Agent",
"description": "Agent with tool sources",
"config": {},
"tool_sources": [
{"type": "skill", "name": "local/file-ops"},
{"type": "mcp", "name": "local/filesystem"},
{"type": "builtin", "group": "git_operations"},
],
}
_SUBGRAPH_NODE = {
"id": "sub",
"type": "subgraph",
"name": "Sub",
"description": "Subgraph node",
"actor_ref": "local/code-reviewer",
}
class TimeLspBindingParse:
"""Benchmark parsing actors with LSP bindings."""
def setup(self) -> None:
self.data = _graph_data([_LSP_NODE])
def time_parse_lsp_binding(self) -> None:
ActorConfigSchema.model_validate(self.data)
class TimeToolSourcesParse:
"""Benchmark parsing actors with tool-source references."""
def setup(self) -> None:
self.data = _graph_data([_TS_NODE])
def time_parse_tool_sources(self) -> None:
ActorConfigSchema.model_validate(self.data)
class TimeSubgraphRefParse:
"""Benchmark parsing actors with subgraph actor_ref."""
def setup(self) -> None:
self.data = _graph_data(
[_AGENT_NODE, _SUBGRAPH_NODE],
)
def time_parse_subgraph_ref(self) -> None:
ActorConfigSchema.model_validate(self.data)
class TimeFullHierarchyParse:
"""Benchmark parsing a full hierarchical actor with all features."""
def setup(self) -> None:
self.data = _graph_data(
[_LSP_NODE, _TS_NODE, _SUBGRAPH_NODE],
skills=["local/file-ops", "local/git-ops"],
)
self.data["lsp"] = ["local/pyright"]
self.data["lsp_capabilities"] = ["diagnostics"]
def time_parse_full_hierarchy(self) -> None:
ActorConfigSchema.model_validate(self.data)
time_lsp = TimeLspBindingParse()
time_ts = TimeToolSourcesParse()
time_sg = TimeSubgraphRefParse()
time_full = TimeFullHierarchyParse()
+310
View File
@@ -0,0 +1,310 @@
# Actor Configuration Reference
Practical guide to writing actor YAML configuration files with hierarchical
graph examples and common error cases.
**Module:** `cleveragents.actor.schema` / `cleveragents.actor.loader`
Related references:
- [Actor YAML Schema](actors_schema.md) — full field listing
- [Actor Hierarchy Extensions](actor_hierarchy.md) — LSP bindings and tool sources
- [Actor Loader](actors_loading.md) — discovery and caching
---
## Table of Contents
- [Actor Configuration Reference](#actor-configuration-reference)
- [Basic Structure](#basic-structure)
- [Hierarchical Graph Example](#hierarchical-graph-example)
- [Node Types](#node-types)
- [Graph Topology Rules](#graph-topology-rules)
- [Error Cases](#error-cases)
---
## Basic Structure
Every actor YAML file must include `name`, `type`, and `description`.
The `name` must follow the `namespace/identifier` format.
```yaml
name: local/my-actor # Required — namespaced
type: llm # Required — llm | tool | graph
description: What it does # Required
version: "1.0" # Optional
model: gpt-4 # Required for llm and graph types
```
---
## Hierarchical Graph Example
A graph actor defines a multi-node workflow with conditional routing,
per-node LSP bindings, and tool-source references.
```yaml
name: workflows/dev-pipeline
type: graph
description: Multi-agent development pipeline
version: "1.0"
model: gpt-4
# Actor-level skill and LSP declarations
skills:
- local/file-ops
- local/git-ops
lsp:
- local/pyright
route:
nodes:
- id: planner
type: agent
name: Planner
description: Plans the implementation strategy
config:
model: gpt-4
prompt: "Analyze requirements and create a plan."
# Per-node LSP binding (overrides actor-level default)
lsp_binding:
server: local/pyright
languages:
- python
capabilities:
- diagnostics
# Per-node tool sources
tool_sources:
- type: skill
name: local/file-ops
- id: implementer
type: agent
name: Implementer
description: Implements the plan
config:
model: gpt-4
prompt: "Implement the changes per the plan."
lsp_binding:
auto: true
tool_sources:
- type: skill
name: local/file-ops
- type: skill
name: local/git-ops
- type: mcp
name: local/filesystem
- id: reviewer
type: subgraph
name: Code Reviewer
description: Delegates to the code-review actor
actor_ref: local/code-reviewer # namespaced reference to another actor
- id: gate
type: conditional
name: Quality Gate
description: Routes to reviewer or re-plans based on lint result
config:
conditions:
- check: "state.get('lint_ok') == True"
route_to: reviewer
- check: "state.get('lint_ok') == False"
route_to: planner
edges:
- from_node: planner
to_node: implementer
- from_node: implementer
to_node: gate
- from_node: gate
to_node: planner # explicit back-edge for retry path
- from_node: gate
to_node: reviewer
entry_node: planner
exit_nodes:
- reviewer
```
---
## Node Types
| Type | Purpose | Key config fields |
|------|---------|-------------------|
| `agent` | LLM-driven reasoning node | `model`, `prompt`, `tools` |
| `tool` | Runs a specific tool call | `tool_name`, `parameters` |
| `conditional` | Routes to different nodes based on state | `conditions[].check`, `conditions[].route_to` |
| `subgraph` | Delegates to another actor entirely | `actor_ref` |
### Conditional Node Routing
Conditional nodes use Python expressions over `state` to route the workflow.
The `route_to` targets are counted as reachable even without explicit edges.
```yaml
- id: branch
type: conditional
name: Branch
description: Decides next step
config:
conditions:
- check: "state.get('success') == True"
route_to: done
- check: "state.get('success') == False"
route_to: retry
```
### Subgraph Node
Subgraph nodes delegate execution to another actor by namespaced name.
Use `actor_ref` (not `config.actor_path`).
```yaml
- id: review_step
type: subgraph
name: Review
description: Runs the review sub-workflow
actor_ref: local/reviewer # correct: namespaced actor reference
```
---
## Graph Topology Rules
The loader enforces these constraints on every graph actor:
| Rule | Error trigger |
|------|--------------|
| Unique node IDs | Two nodes share the same `id` |
| Entry node exists | `entry_node` references an `id` not in `nodes` |
| Exit nodes exist | Any `exit_nodes` entry references a missing `id` |
| Edge targets exist | `from_node` or `to_node` reference a missing `id` |
| Nodes are reachable | A node has no path from `entry_node` via edges or conditional routing |
| No cycles | A non-conditional cycle exists in the edge graph |
---
## Error Cases
### Naming Errors
**Actor name missing namespace:**
```yaml
name: my-actor # wrong — must be namespace/name
```
Error: `name must be in 'namespace/name' format`
Fix: use a namespaced name like `local/my-actor` or `workflows/my-actor`.
---
### LLM Actor Without Model
```yaml
name: local/my-actor
type: llm
description: Assistant
# model: missing
```
Error: `llm and graph types require 'model'`
---
### GRAPH Actor Without Route
```yaml
name: local/pipeline
type: graph
description: Pipeline
model: gpt-4
# route: missing
```
Error: `graph type requires 'route'`
---
### Duplicate Node IDs
```yaml
nodes:
- id: step_a
...
- id: step_a # duplicate
...
```
Error: `Duplicate node IDs: 'step_a'`
Fix: give each node a unique `id`.
---
### Missing Entry Node
```yaml
route:
nodes:
- id: node_a
...
entry_node: node_x # node_x does not exist
```
Error: `Entry node 'node_x' not found in route.entry_node`
---
### Invalid Edge Target
```yaml
edges:
- from_node: node_a
to_node: node_z # node_z does not exist
```
Error: `Edge to_node 'node_z' not found at route.edges[0]`
---
### Unreachable Node
```yaml
nodes:
- id: node_a
- id: node_b
- id: node_c # no edge or conditional route_to points here
edges:
- from_node: node_a
to_node: node_b
entry_node: node_a
```
Error: `route: nodes 'node_c' are unreachable from entry node 'node_a'`
Fix: add an edge from a reachable node to `node_c`, or remove `node_c` from
`nodes` if it is not needed.
---
### YAML Parse Error
A malformed YAML file produces a parse error with the location:
```
Invalid YAML in bad.yaml at line 3, column 5: mapping values are not allowed here
```
Fix: check the indicated line and correct the YAML syntax.
---
### Schema Validation Error Format
When a YAML file is syntactically valid but fails schema validation, the
loader reports precise field paths:
```
Schema validation failed for actor.yaml:
type: Input should be 'llm', 'tool' or 'graph'
route.nodes.0.id: String should match pattern '^[a-zA-Z0-9_-]+$'
Hint: see docs/reference/actor_config.md for the correct schema format.
```
Each line shows the dotted field path and the validation message.
+165
View File
@@ -0,0 +1,165 @@
# Actor Hierarchy and Advanced YAML Extensions
This document covers the advanced actor YAML schema extensions introduced
in **M2.1.actor-yaml**: per-node LSP bindings, tool-source references,
subgraph actor references, and actor-level skill/LSP fields.
## Overview
Actor YAML configurations can define multi-node graph workflows. The
M2.1 extensions add fine-grained control over:
| Feature | Scope | Purpose |
|---------|-------|---------|
| `lsp_binding` | Per-node | Bind specific LSP servers to individual graph nodes |
| `tool_sources` | Per-node | Declare where a node's tools come from (skills, MCP, builtins) |
| `actor_ref` | Per-node (subgraph) | Reference another actor by namespaced name |
| `skills` | Actor-level | List of skill references available to the actor |
| `lsp` | Actor-level | Default LSP server bindings for the actor |
| `lsp_capabilities` | Actor-level | Filter which LSP capabilities are exposed |
| `lsp_context_enrichment` | Actor-level | Control automatic context injection |
## Per-Node LSP Binding
Different nodes in a graph can have different LSP configurations:
```yaml
nodes:
- id: strategist
type: agent
name: Strategy Planner
description: Plans with read-only LSP
lsp_binding:
server: local/pyright
languages:
- python
capabilities:
- diagnostics
- hover
- id: implementer
type: agent
name: Implementer
description: Full LSP auto-binding
lsp_binding:
auto: true
```
### `NodeLspBinding` Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `server` | `string` | `null` | Namespaced LSP server name (e.g. `local/pyright`) |
| `languages` | `list[string]` | `[]` | Languages for language-based resolution |
| `auto` | `bool` | `false` | Auto-resolve from project resources |
| `capabilities` | `list[string]` | `null` | Capability filter for this node |
## Tool-Source References
Nodes can declare their tool sources explicitly:
```yaml
nodes:
- id: executor
type: agent
name: Executor
description: Has multiple tool sources
tool_sources:
- type: skill
name: local/file-ops
- type: mcp
name: local/filesystem
- type: builtin
group: git_operations
```
### `ToolSourceRef` Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | `string` | Yes | `skill`, `mcp`, `builtin`, or `custom` |
| `name` | `string` | No | Namespaced name (for `skill` and `mcp`) |
| `group` | `string` | No | Group identifier (for `builtin`) |
## Subgraph Actor References
Subgraph nodes can reference other actors by namespaced name:
```yaml
nodes:
- id: review
type: subgraph
name: Code Review
description: Delegates to code-reviewer
actor_ref: local/code-reviewer
```
The `actor_ref` field must be in `namespace/name` format.
## Actor-Level Fields
### `skills`
```yaml
skills:
- local/file-ops
- local/git-ops
```
### `lsp`
Explicit binding (list of server names):
```yaml
lsp:
- local/pyright
- local/clangd
```
Auto binding:
```yaml
lsp:
auto: true
```
### `lsp_capabilities`
```yaml
lsp_capabilities:
- diagnostics
- hover
- definitions
```
Or expose all:
```yaml
lsp_capabilities: all
```
### `lsp_context_enrichment`
```yaml
lsp_context_enrichment:
diagnostics: true
type_annotations: false
max_diagnostics_per_file: 50
```
## Validation Error Messages
Validation errors now include field-path context:
| Error | Example Message |
|-------|-----------------|
| Bad entry node | `Entry node 'missing' not found in route.entry_node. Valid node IDs: [a, b]` |
| Bad exit node | `Exit node 'missing' not found in route.exit_nodes. Valid node IDs: [a, b]` |
| Bad edge ref | `Edge from_node 'ghost' not found at route.edges[0]. Valid node IDs: [a, b]` |
| Duplicate IDs | `route.nodes: Duplicate node IDs found: ['dup']. Each node ID must be unique.` |
| Cycle | `route: graph contains a cycle involving nodes: [a → b]. Hint: remove or redirect edges.` |
## Complete Example
See `examples/actors/hierarchical_workflow.yaml` for a full working example
combining all these features.
+1 -2
View File
@@ -134,8 +134,7 @@ route:
type: subgraph
name: Code Reviewer
description: Runs code review workflow
config:
actor_path: examples/actors/simple_llm.yaml
actor_ref: local/code-reviewer
# Escalate if tests keep failing
- id: escalate
+102
View File
@@ -0,0 +1,102 @@
# Hierarchical Actor - Multi-Agent Development Workflow
# Demonstrates LSP bindings, tool-source references, subgraph nodes,
# and skills integration in a graph actor.
name: workflows/dev-workflow
type: graph
description: >-
Multi-agent development workflow with a strategist, implementer, and
reviewer. Each node has fine-grained LSP and tool-source control.
version: "1.0"
model: gpt-4
# Actor-level skill references
skills:
- local/file-ops
- local/git-ops
# Actor-level LSP binding (default for all nodes)
lsp:
- local/pyright
lsp_capabilities:
- diagnostics
- hover
- definitions
- references
lsp_context_enrichment:
diagnostics: true
type_annotations: false
max_diagnostics_per_file: 50
route:
nodes:
- id: strategist
type: agent
name: Strategy Planner
description: Plans the implementation strategy using read-only LSP
config:
model: gpt-4
prompt: |
Plan the implementation strategy. Use LSP diagnostics to assess
codebase health before proposing changes.
lsp_binding:
server: local/pyright
languages:
- python
capabilities:
- diagnostics
- hover
tool_sources:
- type: skill
name: local/file-ops
- id: implementer
type: agent
name: Code Implementer
description: Implements code changes with full LSP and tools
config:
model: gpt-4
prompt: |
Implement the changes as planned. Use full LSP capabilities
for navigation and refactoring.
lsp_binding:
auto: true
tool_sources:
- type: skill
name: local/file-ops
- type: skill
name: local/git-ops
- type: mcp
name: local/filesystem
- id: reviewer
type: subgraph
name: Code Review
description: Delegates to the code-reviewer actor for review
actor_ref: local/code-reviewer
edges:
- from_node: strategist
to_node: implementer
- from_node: implementer
to_node: reviewer
entry_node: strategist
exit_nodes:
- reviewer
context_view: strategist
memory:
enabled: true
max_messages: 50
max_tokens: 16000
summarize_old: true
context:
include_dirs:
- src/
- tests/
exclude_patterns:
- "**/__pycache__/**"
- "*.pyc"
max_context_tokens: 32000
+2 -1
View File
@@ -653,12 +653,13 @@ Feature: Actor YAML examples validation
Scenario: Verify all example files exist
Given the examples/actors directory
Then there should be exactly 5 example YAML files
Then there should be exactly 6 example YAML files
And the example files should include "simple_llm.yaml"
And the example files should include "llm_with_tools.yaml"
And the example files should include "tool_collection.yaml"
And the example files should include "simple_graph.yaml"
And the example files should include "graph_workflow.yaml"
And the example files should include "hierarchical_workflow.yaml"
Scenario: Verify all examples are documented
Given the documentation file "docs/reference/actors_examples.md"
+178
View File
@@ -0,0 +1,178 @@
Feature: Hierarchical Actor YAML Schema Extensions
As a developer configuring actors
I want to extend actor YAML with LSP bindings, tool-source references,
and improved subgraph support
So that graph nodes have fine-grained control over language servers and tools
# -------------------------------------------------------------------
# LSP Binding on NodeDefinition
# -------------------------------------------------------------------
Scenario: Node with explicit LSP binding
Given a hierarchy actor YAML with node lsp_binding
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy graph node "code_writer" should have lsp_binding
And the hierarchy node "code_writer" lsp_binding server should be "local/pyright"
And the hierarchy node "code_writer" lsp_binding languages should contain "python"
Scenario: Node with auto LSP binding
Given a hierarchy actor YAML with auto lsp_binding
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy graph node "implementer" should have lsp_binding
And the hierarchy node "implementer" lsp_binding auto should be true
Scenario: Node without LSP binding is valid
Given a hierarchy actor YAML with no lsp_binding
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy graph node "agent_node" should not have lsp_binding
Scenario: LSP binding with invalid server name
Given a hierarchy actor YAML with lsp_binding server "no-namespace"
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "lsp_binding"
# -------------------------------------------------------------------
# Tool-Source References on NodeDefinition
# -------------------------------------------------------------------
Scenario: Node with tool_sources referencing skills
Given a hierarchy actor YAML with node tool_sources skills
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy graph node "executor" should have tool_sources
And the hierarchy node "executor" tool_sources should have 1 items
Scenario: Node with tool_sources referencing MCP server
Given a hierarchy actor YAML with node tool_sources mcp
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy node "executor" tool_sources item 0 type should be "mcp"
And the hierarchy node "executor" tool_sources item 0 name should be "local/filesystem"
Scenario: Node with tool_sources referencing builtin group
Given a hierarchy actor YAML with node tool_sources builtin
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy node "executor" tool_sources item 0 type should be "builtin"
And the hierarchy node "executor" tool_sources item 0 group should be "file_operations"
Scenario: Node with mixed tool_sources
Given a hierarchy actor YAML with mixed node tool_sources
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy node "executor" tool_sources should have 3 items
Scenario: Node with invalid tool_sources type
Given a hierarchy actor YAML with invalid tool_sources type
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "tool_sources"
Scenario: Node without tool_sources is valid
Given a hierarchy actor YAML with no lsp_binding
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy graph node "agent_node" should not have tool_sources
# -------------------------------------------------------------------
# Subgraph Node Enhancements
# -------------------------------------------------------------------
Scenario: Subgraph node with actor_ref
Given a hierarchy actor YAML with subgraph actor_ref
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy graph node "review" should have type "subgraph"
And the hierarchy node "review" actor_ref should be "local/code-reviewer"
Scenario: Subgraph node with actor_ref non-namespaced fails
Given a hierarchy actor YAML with subgraph actor_ref "bad-ref"
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "actor_ref"
# -------------------------------------------------------------------
# Skills Field on ActorConfigSchema
# -------------------------------------------------------------------
Scenario: Actor with skills list
Given a hierarchy actor YAML with skills list
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy actor should have 2 skills
Scenario: Actor with empty skills is valid
Given a hierarchy actor YAML with empty skills
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy actor should have 0 skills
# -------------------------------------------------------------------
# LSP Fields on ActorConfigSchema (top-level)
# -------------------------------------------------------------------
Scenario: Actor with lsp list
Given a hierarchy actor YAML with lsp list
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy actor lsp should have 2 items
Scenario: Actor with lsp auto binding
Given a hierarchy actor YAML with lsp auto
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy actor lsp_auto should be true
Scenario: Actor with lsp_capabilities list
Given a hierarchy actor YAML with lsp_capabilities
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy actor lsp_capabilities should contain "diagnostics"
Scenario: Actor with lsp_capabilities all
Given a hierarchy actor YAML with lsp_capabilities all
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy actor lsp_capabilities should be "all"
# -------------------------------------------------------------------
# Precise Validation Error Messages
# -------------------------------------------------------------------
Scenario: Missing entry node gives precise field path
Given a hierarchy actor YAML with bad entry_node "nonexistent"
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "entry_node"
And the hierarchy parse error should mention "nonexistent"
Scenario: Duplicate node IDs gives precise error
Given a hierarchy actor YAML with duplicate node ids
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "Duplicate"
Scenario: Cycle detection gives node path
Given a hierarchy actor YAML with cycle
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "cycle"
Scenario: Edge referencing non-existent node gives precise error
Given a hierarchy actor YAML with bad edge from_node "ghost"
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "ghost"
# -------------------------------------------------------------------
# Loader Integration with Hierarchical Actors
# -------------------------------------------------------------------
Scenario: Loader discovers hierarchical actor YAML
Given a hierarchy temp dir with a hierarchical actor YAML file
When I create a hierarchy loader and run discovery
Then the hierarchy loader should find 1 actors
And the hierarchy loaded actor should have skills
And the hierarchy loaded actor should have lsp
Scenario: Loader rejects actor with invalid lsp_binding
Given a hierarchy temp dir with invalid lsp_binding actor YAML
When I create a hierarchy loader and run discovery expecting error
Then the hierarchy loader error should mention "lsp_binding"
+7
View File
@@ -232,6 +232,13 @@ Feature: Actor YAML schema validation
Then the actor schema validation should fail
And the validation error should contain "cycle"
Scenario: Reject graph with unreachable nodes
Given an actor YAML string with unreachable node
When I validate the actor schema
Then the actor schema validation should fail
And the validation error should contain "unreachable"
And the validation error should contain "entry node"
# ────────────────────────────────────────────────────────────
# Tool Definition Validation scenarios
# ────────────────────────────────────────────────────────────
+635
View File
@@ -0,0 +1,635 @@
"""Step definitions for features/actor_hierarchy.feature."""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Any
import yaml
from behave import given, then, when
from behave.runner import Context
from cleveragents.actor.schema import ActorConfigSchema, NodeType
from cleveragents.core.exceptions import ValidationError
def _base_graph_yaml(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]] | None = None,
entry_node: str = "agent_node",
exit_nodes: list[str] | None = None,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
if edges is None:
ids = [n["id"] for n in nodes]
edges = []
for i in range(len(ids) - 1):
edges.append({"from_node": ids[i], "to_node": ids[i + 1]})
data: dict[str, Any] = {
"name": "local/hierarchy-test",
"type": "graph",
"description": "Test hierarchical actor",
"model": "gpt-4",
"route": {
"nodes": nodes,
"edges": edges,
"entry_node": entry_node,
"exit_nodes": exit_nodes or [nodes[-1]["id"]],
},
}
if extra:
data.update(extra)
return data
def _simple_agent_node(node_id: str = "agent_node", **overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": node_id,
"type": "agent",
"name": f"Agent {node_id}",
"description": f"Agent node {node_id}",
"config": {"model": "gpt-4"},
}
base.update(overrides)
return base
def _get_node(config: ActorConfigSchema, node_id: str) -> Any:
if config.route is None:
return None
for node in config.route.nodes:
if node.id == node_id:
return node
return None
# ---------------------------------------------------------------
# LSP Binding on NodeDefinition
# ---------------------------------------------------------------
@given("a hierarchy actor YAML with node lsp_binding")
def step_hierarchy_yaml_node_lsp(context: Context) -> None:
node = _simple_agent_node(
"code_writer",
lsp_binding={
"server": "local/pyright",
"languages": ["python"],
},
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="code_writer")
@given("a hierarchy actor YAML with auto lsp_binding")
def step_hierarchy_yaml_auto_lsp(context: Context) -> None:
node = _simple_agent_node(
"implementer",
lsp_binding={"auto": True},
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="implementer")
@given("a hierarchy actor YAML with no lsp_binding")
def step_hierarchy_yaml_no_lsp(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml([node])
@given('a hierarchy actor YAML with lsp_binding server "{server}"')
def step_hierarchy_yaml_lsp_bad_server(context: Context, server: str) -> None:
node = _simple_agent_node(
"code_writer",
lsp_binding={"server": server, "languages": ["python"]},
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="code_writer")
# ---------------------------------------------------------------
# Tool-Source References on NodeDefinition
# ---------------------------------------------------------------
@given("a hierarchy actor YAML with node tool_sources skills")
def step_hierarchy_yaml_tool_sources_skills(context: Context) -> None:
node = _simple_agent_node(
"executor",
tool_sources=[{"type": "skill", "name": "local/file-ops"}],
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="executor")
@given("a hierarchy actor YAML with node tool_sources mcp")
def step_hierarchy_yaml_tool_sources_mcp(context: Context) -> None:
node = _simple_agent_node(
"executor",
tool_sources=[{"type": "mcp", "name": "local/filesystem"}],
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="executor")
@given("a hierarchy actor YAML with node tool_sources builtin")
def step_hierarchy_yaml_tool_sources_builtin(context: Context) -> None:
node = _simple_agent_node(
"executor",
tool_sources=[
{"type": "builtin", "group": "file_operations"},
],
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="executor")
@given("a hierarchy actor YAML with mixed node tool_sources")
def step_hierarchy_yaml_tool_sources_mixed(context: Context) -> None:
node = _simple_agent_node(
"executor",
tool_sources=[
{"type": "skill", "name": "local/file-ops"},
{"type": "mcp", "name": "local/filesystem"},
{"type": "builtin", "group": "git_operations"},
],
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="executor")
@given("a hierarchy actor YAML with invalid tool_sources type")
def step_hierarchy_yaml_tool_sources_invalid(context: Context) -> None:
node = _simple_agent_node(
"executor",
tool_sources=[{"type": "invalid_type", "name": "x/y"}],
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="executor")
# ---------------------------------------------------------------
# Subgraph Node Enhancements
# ---------------------------------------------------------------
@given("a hierarchy actor YAML with subgraph actor_ref")
def step_hierarchy_yaml_subgraph_ref(context: Context) -> None:
planner = _simple_agent_node("planner")
review: dict[str, Any] = {
"id": "review",
"type": "subgraph",
"name": "Review",
"description": "Code review subgraph",
"actor_ref": "local/code-reviewer",
}
context.hierarchy_yaml = _base_graph_yaml(
[planner, review],
edges=[{"from_node": "planner", "to_node": "review"}],
entry_node="planner",
exit_nodes=["review"],
)
@given('a hierarchy actor YAML with subgraph actor_ref "{bad_ref}"')
def step_hierarchy_yaml_subgraph_bad_ref(context: Context, bad_ref: str) -> None:
planner = _simple_agent_node("planner")
review: dict[str, Any] = {
"id": "review",
"type": "subgraph",
"name": "Review",
"description": "Code review subgraph",
"actor_ref": bad_ref,
}
context.hierarchy_yaml = _base_graph_yaml(
[planner, review],
edges=[{"from_node": "planner", "to_node": "review"}],
entry_node="planner",
exit_nodes=["review"],
)
# ---------------------------------------------------------------
# Skills Field on ActorConfigSchema
# ---------------------------------------------------------------
@given("a hierarchy actor YAML with skills list")
def step_hierarchy_yaml_skills(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml(
[node],
extra={"skills": ["local/file-ops", "local/git-ops"]},
)
@given("a hierarchy actor YAML with empty skills")
def step_hierarchy_yaml_empty_skills(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml([node], extra={"skills": []})
# ---------------------------------------------------------------
# LSP Fields on ActorConfigSchema (top-level)
# ---------------------------------------------------------------
@given("a hierarchy actor YAML with lsp list")
def step_hierarchy_yaml_lsp_list(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml(
[node],
extra={"lsp": ["local/pyright", "local/clangd"]},
)
@given("a hierarchy actor YAML with lsp auto")
def step_hierarchy_yaml_lsp_auto(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml([node], extra={"lsp": {"auto": True}})
@given("a hierarchy actor YAML with lsp_capabilities")
def step_hierarchy_yaml_lsp_caps(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml(
[node],
extra={
"lsp": ["local/pyright"],
"lsp_capabilities": ["diagnostics", "hover"],
},
)
@given("a hierarchy actor YAML with lsp_capabilities all")
def step_hierarchy_yaml_lsp_caps_all(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml(
[node],
extra={
"lsp": ["local/pyright"],
"lsp_capabilities": "all",
},
)
# ---------------------------------------------------------------
# Precise Validation Error Messages
# ---------------------------------------------------------------
@given('a hierarchy actor YAML with bad entry_node "{bad_node}"')
def step_hierarchy_yaml_bad_entry(context: Context, bad_node: str) -> None:
node = _simple_agent_node("agent_node")
data = _base_graph_yaml([node])
data["route"]["entry_node"] = bad_node
context.hierarchy_yaml = data
@given("a hierarchy actor YAML with duplicate node ids")
def step_hierarchy_yaml_dup_ids(context: Context) -> None:
n1 = _simple_agent_node("dup_node")
n2 = _simple_agent_node("dup_node")
n2["name"] = "Second Dup"
context.hierarchy_yaml = _base_graph_yaml([n1, n2], entry_node="dup_node")
@given("a hierarchy actor YAML with cycle")
def step_hierarchy_yaml_cycle(context: Context) -> None:
n1 = _simple_agent_node("a")
n2 = _simple_agent_node("b")
context.hierarchy_yaml = _base_graph_yaml(
[n1, n2],
edges=[
{"from_node": "a", "to_node": "b"},
{"from_node": "b", "to_node": "a"},
],
entry_node="a",
exit_nodes=["b"],
)
@given('a hierarchy actor YAML with bad edge from_node "{bad_node}"')
def step_hierarchy_yaml_bad_edge(context: Context, bad_node: str) -> None:
n1 = _simple_agent_node("real_node")
context.hierarchy_yaml = _base_graph_yaml(
[n1],
edges=[{"from_node": bad_node, "to_node": "real_node"}],
entry_node="real_node",
)
# ---------------------------------------------------------------
# When Steps
# ---------------------------------------------------------------
@when("I parse the hierarchy actor YAML")
def step_parse_hierarchy_yaml(context: Context) -> None:
context.hierarchy_error = None
try:
context.hierarchy_config = ActorConfigSchema.model_validate(
context.hierarchy_yaml
)
except Exception as exc:
context.hierarchy_error = str(exc)
context.hierarchy_config = None
@when("I parse the hierarchy actor YAML expecting failure")
def step_parse_hierarchy_yaml_fail(context: Context) -> None:
context.hierarchy_error = None
try:
context.hierarchy_config = ActorConfigSchema.model_validate(
context.hierarchy_yaml
)
context.hierarchy_error = None
except Exception as exc:
context.hierarchy_error = str(exc)
context.hierarchy_config = None
# ---------------------------------------------------------------
# Then Steps - Validation
# ---------------------------------------------------------------
@then("the hierarchy actor should be valid")
def step_hierarchy_valid(context: Context) -> None:
assert context.hierarchy_config is not None, (
f"Expected valid config, got error: {context.hierarchy_error}"
)
@then('the hierarchy graph node "{node_id}" should have lsp_binding')
def step_hierarchy_node_has_lsp(context: Context, node_id: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None, f"Node '{node_id}' not found"
assert node.lsp_binding is not None, f"Node '{node_id}' has no lsp_binding"
@then('the hierarchy node "{node_id}" lsp_binding server should be "{server}"')
def step_hierarchy_lsp_server(context: Context, node_id: str, server: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.lsp_binding is not None
assert node.lsp_binding.server == server
@then('the hierarchy node "{node_id}" lsp_binding languages should contain "{lang}"')
def step_hierarchy_lsp_lang(context: Context, node_id: str, lang: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.lsp_binding is not None
assert lang in node.lsp_binding.languages
@then('the hierarchy node "{node_id}" lsp_binding auto should be true')
def step_hierarchy_lsp_auto(context: Context, node_id: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.lsp_binding is not None
assert node.lsp_binding.auto is True
@then('the hierarchy graph node "{node_id}" should not have lsp_binding')
def step_hierarchy_node_no_lsp(context: Context, node_id: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None, f"Node '{node_id}' not found"
assert node.lsp_binding is None
@then('the hierarchy graph node "{node_id}" should have tool_sources')
def step_hierarchy_node_has_tool_sources(context: Context, node_id: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None, f"Node '{node_id}' not found"
assert node.tool_sources, f"Node '{node_id}' has no tool_sources"
@then('the hierarchy node "{node_id}" tool_sources should have {count:d} items')
def step_hierarchy_tool_sources_count(
context: Context, node_id: str, count: int
) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert len(node.tool_sources) == count
@then(
'the hierarchy node "{node_id}" tool_sources item {idx:d} '
'type should be "{ts_type}"'
)
def step_hierarchy_tool_source_type(
context: Context, node_id: str, idx: int, ts_type: str
) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.tool_sources[idx].type == ts_type
@then(
'the hierarchy node "{node_id}" tool_sources item {idx:d} '
'name should be "{ts_name}"'
)
def step_hierarchy_tool_source_name(
context: Context, node_id: str, idx: int, ts_name: str
) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.tool_sources[idx].name == ts_name
@then(
'the hierarchy node "{node_id}" tool_sources item {idx:d} '
'group should be "{ts_group}"'
)
def step_hierarchy_tool_source_group(
context: Context, node_id: str, idx: int, ts_group: str
) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.tool_sources[idx].group == ts_group
@then('the hierarchy graph node "{node_id}" should not have tool_sources')
def step_hierarchy_node_no_tool_sources(context: Context, node_id: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert not node.tool_sources
@then('the hierarchy graph node "{node_id}" should have type "{ntype}"')
def step_hierarchy_node_type(context: Context, node_id: str, ntype: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.type == NodeType(ntype)
@then('the hierarchy node "{node_id}" actor_ref should be "{ref}"')
def step_hierarchy_node_actor_ref(context: Context, node_id: str, ref: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.actor_ref == ref
@then("the hierarchy actor should have {count:d} skills")
def step_hierarchy_actor_skills_count(context: Context, count: int) -> None:
assert len(context.hierarchy_config.skills) == count
@then("the hierarchy actor lsp should have {count:d} items")
def step_hierarchy_actor_lsp_count(context: Context, count: int) -> None:
assert isinstance(context.hierarchy_config.lsp, list)
assert len(context.hierarchy_config.lsp) == count
@then("the hierarchy actor lsp_auto should be true")
def step_hierarchy_actor_lsp_auto(context: Context) -> None:
assert isinstance(context.hierarchy_config.lsp, dict)
assert context.hierarchy_config.lsp.get("auto") is True
@then('the hierarchy actor lsp_capabilities should contain "{cap}"')
def step_hierarchy_actor_lsp_caps_contain(context: Context, cap: str) -> None:
assert isinstance(context.hierarchy_config.lsp_capabilities, list)
assert cap in context.hierarchy_config.lsp_capabilities
@then('the hierarchy actor lsp_capabilities should be "{value}"')
def step_hierarchy_actor_lsp_caps_str(context: Context, value: str) -> None:
assert context.hierarchy_config.lsp_capabilities == value
@then('the hierarchy parse error should mention "{text}"')
def step_hierarchy_error_mention(context: Context, text: str) -> None:
assert context.hierarchy_error is not None, "Expected error but none occurred"
assert text.lower() in context.hierarchy_error.lower(), (
f"Error '{context.hierarchy_error}' does not mention '{text}'"
)
# ---------------------------------------------------------------
# Loader Integration
# ---------------------------------------------------------------
def _hierarchy_tmpdir() -> Path:
return Path(tempfile.mkdtemp(prefix="hierarchy_actor_"))
@given("a hierarchy temp dir with a hierarchical actor YAML file")
def step_hierarchy_loader_setup(context: Context) -> None:
from cleveragents.actor.loader import ActorLoader
tmpdir = _hierarchy_tmpdir()
actor_data: dict[str, Any] = {
"name": "local/hierarchy-demo",
"type": "graph",
"description": "Hierarchical demo",
"model": "gpt-4",
"skills": ["local/file-ops"],
"lsp": ["local/pyright"],
"lsp_capabilities": ["diagnostics"],
"route": {
"nodes": [
{
"id": "planner",
"type": "agent",
"name": "Planner",
"description": "Plans tasks",
"config": {"model": "gpt-4"},
"lsp_binding": {
"server": "local/pyright",
"languages": ["python"],
},
"tool_sources": [
{"type": "skill", "name": "local/file-ops"},
],
},
],
"edges": [],
"entry_node": "planner",
"exit_nodes": ["planner"],
},
}
yaml_path = tmpdir / "hierarchy_demo.yaml"
with yaml_path.open("w") as f:
yaml.safe_dump(actor_data, f)
context.hierarchy_tmpdir = tmpdir
context.hierarchy_loader_cls = ActorLoader
@given("a hierarchy temp dir with invalid lsp_binding actor YAML")
def step_hierarchy_loader_invalid(context: Context) -> None:
from cleveragents.actor.loader import ActorLoader
tmpdir = _hierarchy_tmpdir()
actor_data: dict[str, Any] = {
"name": "local/bad-lsp",
"type": "graph",
"description": "Bad LSP binding",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "code_node",
"type": "agent",
"name": "Coder",
"description": "Codes stuff",
"config": {},
"lsp_binding": {
"server": "no-namespace",
"languages": ["python"],
},
},
],
"edges": [],
"entry_node": "code_node",
"exit_nodes": ["code_node"],
},
}
yaml_path = tmpdir / "bad_lsp.yaml"
with yaml_path.open("w") as f:
yaml.safe_dump(actor_data, f)
context.hierarchy_tmpdir = tmpdir
context.hierarchy_loader_cls = ActorLoader
@when("I create a hierarchy loader and run discovery")
def step_hierarchy_loader_discover(context: Context) -> None:
loader = context.hierarchy_loader_cls(search_roots=[context.hierarchy_tmpdir])
context.hierarchy_loaded_actors = loader.discover()
context.hierarchy_loader_error = None
@when("I create a hierarchy loader and run discovery expecting error")
def step_hierarchy_loader_discover_error(context: Context) -> None:
loader = context.hierarchy_loader_cls(search_roots=[context.hierarchy_tmpdir])
try:
context.hierarchy_loaded_actors = loader.discover()
context.hierarchy_loader_error = None
except (ValidationError, Exception) as exc:
context.hierarchy_loaded_actors = []
context.hierarchy_loader_error = str(exc)
@then("the hierarchy loader should find {count:d} actors")
def step_hierarchy_loader_count(context: Context, count: int) -> None:
assert len(context.hierarchy_loaded_actors) == count, (
f"Expected {count} actors, got {len(context.hierarchy_loaded_actors)}"
)
@then("the hierarchy loaded actor should have skills")
def step_hierarchy_loaded_has_skills(context: Context) -> None:
actor = context.hierarchy_loaded_actors[0]
assert actor.skills, "Loaded actor should have skills"
@then("the hierarchy loaded actor should have lsp")
def step_hierarchy_loaded_has_lsp(context: Context) -> None:
actor = context.hierarchy_loaded_actors[0]
assert actor.lsp is not None, "Loaded actor should have lsp"
@then('the hierarchy loader error should mention "{text}"')
def step_hierarchy_loader_error_mention(context: Context, text: str) -> None:
assert context.hierarchy_loader_error is not None, (
"Expected loader error but none occurred"
)
assert text.lower() in context.hierarchy_loader_error.lower(), (
f"Error '{context.hierarchy_loader_error}' does not mention '{text}'"
)
+37
View File
@@ -539,6 +539,43 @@ route:
"""
@given("an actor YAML string with unreachable node")
def step_given_unreachable_node(context: Context) -> None:
"""Provide GRAPH where a node has no path from the entry node."""
context.actor_yaml_string = """\
name: workflows/unreachable
type: graph
description: Graph with an isolated node
model: gpt-4
route:
nodes:
- id: node_a
type: agent
name: Node A
description: Entry node
config:
prompt: "A"
- id: node_b
type: agent
name: Node B
description: Reachable from entry
config:
prompt: "B"
- id: node_c
type: agent
name: Node C
description: Isolated no edge points here from entry path
config:
prompt: "C"
edges:
- from_node: node_a
to_node: node_b
entry_node: node_a
exit_nodes:
- node_b
"""
@given('an actor YAML string with inline tool name "{name}"')
def step_given_inline_tool_name(context: Context, name: str) -> None:
"""Provide actor with inline tool having specific name."""
+48
View File
@@ -0,0 +1,48 @@
*** Settings ***
Documentation Smoke tests for hierarchical actor YAML extensions
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_actor_hierarchy.py
*** Test Cases ***
Discover Hierarchical Actor Example
[Documentation] Load hierarchical workflow example and verify extensions
${result}= Run Process ${PYTHON} ${HELPER} discover-hierarchical cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-loaded: workflows/dev-workflow
Should Contain ${result.stdout} skills-count: 2
Should Contain ${result.stdout} lsp-configured: True
Parse Actor With LSP Binding
[Documentation] Validate that per-node LSP bindings parse correctly
${result}= Run Process ${PYTHON} ${HELPER} parse-lsp-binding cwd=${WORKSPACE}
Log ${result.stdout}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lsp-binding-server: local/pyright
Should Contain ${result.stdout} lsp-binding-langs: python
Parse Actor With Tool Sources
[Documentation] Validate that per-node tool-source references parse correctly
${result}= Run Process ${PYTHON} ${HELPER} parse-tool-sources cwd=${WORKSPACE}
Log ${result.stdout}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tool-sources-count: 2
Parse Actor With Subgraph Actor Ref
[Documentation] Validate subgraph node with actor_ref
${result}= Run Process ${PYTHON} ${HELPER} parse-subgraph-ref cwd=${WORKSPACE}
Log ${result.stdout}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-ref: local/code-reviewer
Reject Invalid LSP Binding
[Documentation] Reject actor with non-namespaced LSP server
${result}= Run Process ${PYTHON} ${HELPER} reject-bad-lsp cwd=${WORKSPACE}
Log ${result.stdout}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} validation-error: lsp_binding
+11
View File
@@ -45,3 +45,14 @@ Default Namespace Applied
Log ${result.stdout}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} namespace-default-ok: local/bare-name
Load Hierarchical Actor YAML Via Loader
[Documentation] Discover hierarchical_workflow.yaml via ActorLoader and verify graph properties
${result}= Run Process ${PYTHON} ${HELPER} discover-hierarchical cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-loaded: workflows/dev-workflow
Should Contain ${result.stdout} actor-type: graph
Should Contain ${result.stdout} node-count: 3
Should Contain ${result.stdout} edge-count: 2
+193
View File
@@ -0,0 +1,193 @@
"""Robot Framework helper for hierarchical actor YAML smoke tests.
Usage:
python robot/helper_actor_hierarchy.py discover-hierarchical
python robot/helper_actor_hierarchy.py parse-lsp-binding
python robot/helper_actor_hierarchy.py parse-tool-sources
python robot/helper_actor_hierarchy.py parse-subgraph-ref
python robot/helper_actor_hierarchy.py reject-bad-lsp
"""
from __future__ import annotations
import sys
from pathlib import Path
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.actor.loader import ActorLoader # noqa: E402
from cleveragents.actor.schema import ActorConfigSchema # noqa: E402
def main() -> int:
if len(sys.argv) < 2:
print("Usage: helper_actor_hierarchy.py <command>")
return 1
cmd = sys.argv[1]
dispatch = {
"discover-hierarchical": _discover_hierarchical,
"parse-lsp-binding": _parse_lsp_binding,
"parse-tool-sources": _parse_tool_sources,
"parse-subgraph-ref": _parse_subgraph_ref,
"reject-bad-lsp": _reject_bad_lsp,
}
handler = dispatch.get(cmd)
if handler is None:
print(f"Unknown command: {cmd}")
return 1
return handler()
def _discover_hierarchical() -> int:
project_root = Path(__file__).resolve().parents[1]
examples_dir = project_root / "examples" / "actors"
loader = ActorLoader(search_roots=[examples_dir])
actors = loader.discover()
for actor in actors:
if actor.name == "workflows/dev-workflow":
print(f"actor-loaded: {actor.name}")
print(f"skills-count: {len(actor.skills)}")
print(f"lsp-configured: {actor.lsp is not None}")
return 0
print("actor-not-found: workflows/dev-workflow")
return 1
def _parse_lsp_binding() -> int:
data = {
"name": "local/lsp-test",
"type": "graph",
"description": "LSP binding test",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "coder",
"type": "agent",
"name": "Coder",
"description": "Codes with LSP",
"config": {"model": "gpt-4"},
"lsp_binding": {
"server": "local/pyright",
"languages": ["python"],
},
},
],
"edges": [],
"entry_node": "coder",
"exit_nodes": ["coder"],
},
}
config = ActorConfigSchema.model_validate(data)
node = config.route.nodes[0] # type: ignore[union-attr]
if node.lsp_binding:
print(f"lsp-binding-server: {node.lsp_binding.server}")
print(f"lsp-binding-langs: {', '.join(node.lsp_binding.languages)}")
return 0
def _parse_tool_sources() -> int:
data = {
"name": "local/ts-test",
"type": "graph",
"description": "Tool sources test",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "exec",
"type": "agent",
"name": "Executor",
"description": "Executor with tool sources",
"config": {},
"tool_sources": [
{"type": "skill", "name": "local/file-ops"},
{"type": "mcp", "name": "local/filesystem"},
],
},
],
"edges": [],
"entry_node": "exec",
"exit_nodes": ["exec"],
},
}
config = ActorConfigSchema.model_validate(data)
node = config.route.nodes[0] # type: ignore[union-attr]
print(f"tool-sources-count: {len(node.tool_sources)}")
return 0
def _parse_subgraph_ref() -> int:
data = {
"name": "local/sg-test",
"type": "graph",
"description": "Subgraph ref test",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "main",
"type": "agent",
"name": "Main",
"description": "Main agent",
"config": {},
},
{
"id": "review",
"type": "subgraph",
"name": "Review",
"description": "Subgraph review",
"actor_ref": "local/code-reviewer",
},
],
"edges": [{"from_node": "main", "to_node": "review"}],
"entry_node": "main",
"exit_nodes": ["review"],
},
}
config = ActorConfigSchema.model_validate(data)
node = config.route.nodes[1] # type: ignore[union-attr]
print(f"actor-ref: {node.actor_ref}")
return 0
def _reject_bad_lsp() -> int:
data = {
"name": "local/bad-lsp-test",
"type": "graph",
"description": "Bad LSP test",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "n",
"type": "agent",
"name": "N",
"description": "Bad LSP",
"config": {},
"lsp_binding": {"server": "no-slash"},
},
],
"edges": [],
"entry_node": "n",
"exit_nodes": ["n"],
},
}
try:
ActorConfigSchema.model_validate(data)
print("validation-unexpected-success")
return 1
except Exception as exc:
err = str(exc).lower()
if "lsp_binding" in err:
print("validation-error: lsp_binding")
else:
print(f"validation-error-other: {exc}")
return 0
if __name__ == "__main__":
sys.exit(main())
+24
View File
@@ -6,6 +6,7 @@ and loading. Exit code 0 = success, 1 = failure.
Usage:
python robot/helper_actor_loading.py discover <directory>
python robot/helper_actor_loading.py discover-examples
python robot/helper_actor_loading.py discover-hierarchical
python robot/helper_actor_loading.py discover-invalid <directory>
python robot/helper_actor_loading.py namespace-default <directory>
"""
@@ -38,6 +39,10 @@ def main() -> int:
project_root = Path(__file__).resolve().parents[1]
return _discover(project_root / "examples" / "actors")
if command == "discover-hierarchical":
project_root = Path(__file__).resolve().parents[1]
return _discover_hierarchical(project_root / "examples" / "actors")
if command == "discover-invalid":
directory = sys.argv[2]
return _discover_invalid(Path(directory))
@@ -58,6 +63,25 @@ def _discover(directory: Path) -> int:
return 0
def _discover_hierarchical(examples_dir: Path) -> int:
"""Load the hierarchical workflow example via ActorLoader and print key stats."""
loader = ActorLoader(search_roots=[examples_dir])
actors = loader.discover()
target = next((a for a in actors if a.name == "workflows/dev-workflow"), None)
if target is None:
print("hierarchical-actor-not-found")
return 1
print(f"actor-loaded: {target.name}")
print(f"actor-type: {target.type.value}")
route = target.route
if route is not None:
print(f"node-count: {len(route.nodes)}")
print(f"edge-count: {len(route.edges)}")
lsp_configured = bool(getattr(target, "lsp", None))
print(f"lsp-configured: {lsp_configured}")
return 0
def _discover_invalid(directory: Path) -> int:
loader = ActorLoader(search_roots=[directory])
try:
+28 -2
View File
@@ -127,7 +127,14 @@ class ActorLoader:
try:
raw = yaml.safe_load(content)
except yaml.YAMLError as exc:
errors.append(f"Invalid YAML in {path.name}: {exc}")
mark = getattr(exc, "problem_mark", None)
location = (
f" at line {mark.line + 1}, column {mark.column + 1}"
if mark is not None
else ""
)
problem = getattr(exc, "problem", str(exc))
errors.append(f"Invalid YAML in {path.name}{location}: {problem}")
continue
if not isinstance(raw, dict):
@@ -141,7 +148,26 @@ class ActorLoader:
try:
config = ActorConfigSchema.model_validate(raw)
except Exception as exc:
errors.append(f"Schema validation failed for {path.name}: {exc}")
from pydantic import ValidationError as PydanticValidationError
if isinstance(exc, PydanticValidationError):
field_errors = []
for err in exc.errors():
field_path = ".".join(str(loc) for loc in err["loc"])
field_errors.append(f" {field_path}: {err['msg']}")
detail = "\n".join(field_errors)
hint = (
" Hint: see docs/reference/actor_config.md "
"for the correct schema format."
)
errors.append(
f"Schema validation failed for {path.name}:"
f"\n{detail}\n{hint}"
)
else:
errors.append(
f"Schema validation failed for {path.name}: {exc}"
)
continue
name = config.name
+196 -12
View File
@@ -272,6 +272,89 @@ class ContextConfigSchema(BaseModel):
)
# ============================================================================
# LSP and Tool-Source Models (for per-node bindings)
# ============================================================================
class NodeLspBinding(BaseModel):
"""Per-node LSP server binding configuration.
Allows individual graph nodes to override or specify their own LSP
server bindings independently of the actor-level ``lsp`` setting.
Three binding modes:
1. Explicit: set ``server`` to a namespaced server name.
2. Language-based: set ``languages`` to match servers by language.
3. Auto: set ``auto=True`` to let the runtime resolve automatically.
Attributes:
server: Namespaced LSP server name (e.g. ``local/pyright``).
languages: Languages for language-based binding resolution.
auto: If ``True``, runtime resolves servers from project resources.
capabilities: Optional capability filter for this node.
"""
server: str | None = Field(default=None, description="LSP server name")
languages: list[str] = Field(
default_factory=list, description="Languages for binding"
)
auto: bool = Field(default=False, description="Auto-resolve binding")
capabilities: list[str] | None = Field(
default=None, description="LSP capability filter"
)
@field_validator("server")
@classmethod
def validate_server_namespace(cls, v: str | None) -> str | None:
if v is not None and "/" not in v:
msg = f"lsp_binding.server must be namespaced (namespace/name): '{v}'"
raise ValueError(msg)
return v
class ToolSourceRef(BaseModel):
"""Reference to a tool source available to a graph node.
Allows nodes to declare where their tools come from skills,
MCP servers, or builtin tool groups.
Attributes:
type: Source type (``skill``, ``mcp``, ``builtin``, ``custom``).
name: Namespaced name for skill/mcp sources.
group: Group identifier for builtin sources.
"""
type: str = Field(..., description="Source type: skill, mcp, builtin, custom")
name: str | None = Field(default=None, description="Namespaced source name")
group: str | None = Field(default=None, description="Builtin group name")
@field_validator("type")
@classmethod
def validate_source_type(cls, v: str) -> str:
allowed = {"skill", "mcp", "builtin", "custom"}
if v not in allowed:
msg = f"tool_sources type must be one of {sorted(allowed)}, got '{v}'"
raise ValueError(msg)
return v
class LspContextEnrichment(BaseModel):
"""Controls automatic LSP context enrichment injected into ACMS.
Attributes:
diagnostics: Inject file diagnostics into context (default True).
type_annotations: Inject type info into context (default False).
max_diagnostics_per_file: Cap on diagnostics per file (default 50).
"""
diagnostics: bool = Field(default=True, description="Inject diagnostics")
type_annotations: bool = Field(default=False, description="Inject type annotations")
max_diagnostics_per_file: int = Field(
default=50, description="Max diagnostics per file"
)
# ============================================================================
# Graph Models (for ActorType.GRAPH - multi-node workflows)
# ============================================================================
@@ -357,6 +440,16 @@ class NodeDefinition(BaseModel):
name: str = Field(..., description="Node name")
description: str = Field(..., description="Node description")
config: dict[str, Any] = Field(default_factory=dict, description="Node config")
lsp_binding: NodeLspBinding | None = Field(
default=None, description="Per-node LSP binding"
)
tool_sources: list[ToolSourceRef] = Field(
default_factory=list, description="Tool source references"
)
actor_ref: str | None = Field(
default=None,
description="Namespaced actor reference for subgraph nodes",
)
@field_validator("id")
@classmethod
@@ -367,6 +460,15 @@ class NodeDefinition(BaseModel):
raise ValueError(msg)
return v
@field_validator("actor_ref")
@classmethod
def validate_actor_ref(cls, v: str | None) -> str | None:
"""Ensure actor_ref follows namespace/name format when set."""
if v is not None and "/" not in v:
msg = f"actor_ref must be namespaced (namespace/name): '{v}'"
raise ValueError(msg)
return v
class RouteDefinition(BaseModel):
"""
@@ -427,8 +529,16 @@ class RouteDefinition(BaseModel):
"""Ensure all node IDs are unique."""
ids = [node.id for node in v]
if len(ids) != len(set(ids)):
duplicates = [id for id in ids if ids.count(id) > 1]
msg = f"Duplicate node IDs found: {set(duplicates)}"
seen: set[str] = set()
duplicates: list[str] = []
for nid in ids:
if nid in seen and nid not in duplicates:
duplicates.append(nid)
seen.add(nid)
msg = (
f"route.nodes: Duplicate node IDs found: "
f"{duplicates}. Each node ID must be unique."
)
raise ValueError(msg)
return v
@@ -437,30 +547,75 @@ class RouteDefinition(BaseModel):
Validate all node references in edges and entry/exit points.
Raises:
ValueError: If any reference points to non-existent node
ValueError: If any reference points to non-existent node.
Messages include field paths for precise debugging.
"""
node_ids = {node.id for node in self.nodes}
valid_list = ", ".join(sorted(node_ids)) if node_ids else "(none)"
# Validate entry node
if self.entry_node not in node_ids:
msg = f"Entry node '{self.entry_node}' not found in nodes"
msg = (
f"Entry node '{self.entry_node}' not found in "
f"route.entry_node. Valid node IDs: [{valid_list}]"
)
raise ValueError(msg)
# Validate exit nodes
for exit_node in self.exit_nodes:
if exit_node not in node_ids:
msg = f"Exit node '{exit_node}' not found in nodes"
msg = (
f"Exit node '{exit_node}' not found in "
f"route.exit_nodes. Valid node IDs: [{valid_list}]"
)
raise ValueError(msg)
# Validate edge references
for edge in self.edges:
for idx, edge in enumerate(self.edges):
if edge.from_node not in node_ids:
msg = f"Edge from_node '{edge.from_node}' not found in nodes"
msg = (
f"Edge from_node '{edge.from_node}' not found at "
f"route.edges[{idx}]. Valid node IDs: [{valid_list}]"
)
raise ValueError(msg)
if edge.to_node not in node_ids:
msg = f"Edge to_node '{edge.to_node}' not found in nodes"
msg = (
f"Edge to_node '{edge.to_node}' not found at "
f"route.edges[{idx}]. Valid node IDs: [{valid_list}]"
)
raise ValueError(msg)
# Check all nodes are reachable from entry_node via BFS.
# Adjacency is built from both explicit edges and implicit routing
# targets embedded in conditional node configs (route_to keys).
adj: dict[str, list[str]] = {node.id: [] for node in self.nodes}
for edge in self.edges:
adj[edge.from_node].append(edge.to_node)
for node in self.nodes:
if node.type == NodeType.CONDITIONAL and isinstance(node.config, dict):
for cond in node.config.get("conditions", []):
if isinstance(cond, dict) and "route_to" in cond:
target = str(cond["route_to"])
if target in node_ids:
adj[node.id].append(target)
reachable: set[str] = set()
queue: list[str] = [self.entry_node]
while queue:
current = queue.pop()
if current in reachable:
continue
reachable.add(current)
queue.extend(adj.get(current, []))
unreachable = sorted(node_ids - reachable)
if unreachable:
nodes_str = ", ".join(f"'{n}'" for n in unreachable)
msg = (
f"route: nodes {nodes_str} are unreachable from entry node "
f"'{self.entry_node}' at route.nodes. "
"Hint: add edges connecting these nodes to the graph "
"or remove them."
)
raise ValueError(msg)
def detect_cycles(self) -> list[str]:
"""
Detect cycles in the graph using DFS.
@@ -582,6 +737,27 @@ class ActorConfigSchema(BaseModel):
default=None, description="Graph topology (for GRAPH type)"
)
# Skills
skills: list[str] = Field(
default_factory=list,
description="Namespaced skill references (e.g. local/file-ops)",
)
# LSP configuration
lsp: list[str] | dict[str, Any] | None = Field(
default=None,
description="LSP server bindings: list of server names, "
"language-based object, or {auto: true}",
)
lsp_capabilities: list[str] | str | None = Field(
default=None,
description="LSP capability filter: list or 'all'",
)
lsp_context_enrichment: LspContextEnrichment | None = Field(
default=None,
description="Automatic LSP context enrichment settings",
)
# Environment variables
env_vars: dict[str, str] = Field(
default_factory=dict, description="Environment variable mappings"
@@ -639,7 +815,12 @@ class ActorConfigSchema(BaseModel):
self.route.validate_references()
cycles = self.route.detect_cycles()
if cycles:
msg = f"Graph contains cycles involving nodes: {cycles}"
nodes_str = "".join(cycles)
msg = (
f"route: graph contains a cycle involving "
f"nodes: [{nodes_str}]. "
f"Hint: remove or redirect edges to break the cycle."
)
raise ValueError(msg)
return self
@@ -702,10 +883,13 @@ __all__ = [
"ContextConfigSchema",
"ContextView",
"EdgeDefinition",
"LspContextEnrichment",
"MemoryConfig",
"NodeDefinition",
"NodeLspBinding",
"NodeType",
"RouteDefinition",
"ToolDefinition",
"ToolParameter",
"ToolSourceRef",
]