diff --git a/CHANGELOG.md b/CHANGELOG.md index 11fa47fb..b8592fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### 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 diff --git a/benchmarks/actor_hierarchy_bench.py b/benchmarks/actor_hierarchy_bench.py new file mode 100644 index 00000000..7892ae9f --- /dev/null +++ b/benchmarks/actor_hierarchy_bench.py @@ -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() diff --git a/docs/reference/actor_config.md b/docs/reference/actor_config.md new file mode 100644 index 00000000..b902dfc9 --- /dev/null +++ b/docs/reference/actor_config.md @@ -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. diff --git a/docs/reference/actor_hierarchy.md b/docs/reference/actor_hierarchy.md new file mode 100644 index 00000000..218dccab --- /dev/null +++ b/docs/reference/actor_hierarchy.md @@ -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. diff --git a/examples/actors/graph_workflow.yaml b/examples/actors/graph_workflow.yaml index 9c50d428..0c7633bc 100644 --- a/examples/actors/graph_workflow.yaml +++ b/examples/actors/graph_workflow.yaml @@ -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 diff --git a/examples/actors/hierarchical_workflow.yaml b/examples/actors/hierarchical_workflow.yaml new file mode 100644 index 00000000..4879bd78 --- /dev/null +++ b/examples/actors/hierarchical_workflow.yaml @@ -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 diff --git a/features/actor_examples.feature b/features/actor_examples.feature index 248d0828..5fc3be01 100644 --- a/features/actor_examples.feature +++ b/features/actor_examples.feature @@ -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" diff --git a/features/actor_hierarchy.feature b/features/actor_hierarchy.feature new file mode 100644 index 00000000..a2cfbc80 --- /dev/null +++ b/features/actor_hierarchy.feature @@ -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" diff --git a/features/actor_schema.feature b/features/actor_schema.feature index f7963ca2..85eddeff 100644 --- a/features/actor_schema.feature +++ b/features/actor_schema.feature @@ -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 # ──────────────────────────────────────────────────────────── diff --git a/features/steps/actor_hierarchy_steps.py b/features/steps/actor_hierarchy_steps.py new file mode 100644 index 00000000..4497a125 --- /dev/null +++ b/features/steps/actor_hierarchy_steps.py @@ -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}'" + ) diff --git a/features/steps/actor_schema_steps.py b/features/steps/actor_schema_steps.py index ae6f675b..003f2dc3 100644 --- a/features/steps/actor_schema_steps.py +++ b/features/steps/actor_schema_steps.py @@ -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.""" diff --git a/robot/actor_hierarchy.robot b/robot/actor_hierarchy.robot new file mode 100644 index 00000000..0dfd8608 --- /dev/null +++ b/robot/actor_hierarchy.robot @@ -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 diff --git a/robot/actor_loading.robot b/robot/actor_loading.robot index a8b775fa..33b2cdc8 100644 --- a/robot/actor_loading.robot +++ b/robot/actor_loading.robot @@ -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 diff --git a/robot/helper_actor_hierarchy.py b/robot/helper_actor_hierarchy.py new file mode 100644 index 00000000..71d1149d --- /dev/null +++ b/robot/helper_actor_hierarchy.py @@ -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 ") + 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()) diff --git a/robot/helper_actor_loading.py b/robot/helper_actor_loading.py index f7587a78..66e44cfb 100644 --- a/robot/helper_actor_loading.py +++ b/robot/helper_actor_loading.py @@ -6,6 +6,7 @@ and loading. Exit code 0 = success, 1 = failure. Usage: python robot/helper_actor_loading.py discover python robot/helper_actor_loading.py discover-examples + python robot/helper_actor_loading.py discover-hierarchical python robot/helper_actor_loading.py discover-invalid python robot/helper_actor_loading.py namespace-default """ @@ -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: diff --git a/src/cleveragents/actor/loader.py b/src/cleveragents/actor/loader.py index 659c54f4..b0f900bb 100644 --- a/src/cleveragents/actor/loader.py +++ b/src/cleveragents/actor/loader.py @@ -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 diff --git a/src/cleveragents/actor/schema.py b/src/cleveragents/actor/schema.py index b136bd2e..b2213221 100644 --- a/src/cleveragents/actor/schema.py +++ b/src/cleveragents/actor/schema.py @@ -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", ]