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
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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}'"
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user