Compare commits
3 Commits
temp
...
develop-aditya-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
80a56846fe
|
|||
|
a6e56fe96e
|
|||
|
7013d4462d
|
@@ -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,153 @@
|
||||
"""ASV benchmarks for MCP Tool Adapter performance.
|
||||
|
||||
Measures the performance of:
|
||||
- MCPServerConfig creation and validation
|
||||
- Tool discovery from a mock transport
|
||||
- Tool invocation with schema validation
|
||||
- Tool registration into ToolRegistry
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_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.mcp.adapter import ( # noqa: E402
|
||||
MCPServerConfig,
|
||||
MCPToolAdapter,
|
||||
MCPTransport,
|
||||
)
|
||||
from cleveragents.tool.registry import ToolRegistry # noqa: E402
|
||||
|
||||
|
||||
class _BenchTransport(MCPTransport):
|
||||
"""Minimal transport for benchmarks — returns canned data."""
|
||||
|
||||
def __init__(self, tools: list[dict[str, Any]]) -> None:
|
||||
self._tools = tools
|
||||
|
||||
def connect(self, config: MCPServerConfig) -> dict[str, Any]:
|
||||
return {"capabilities": {"tools": True}}
|
||||
|
||||
def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
if method == "tools/list":
|
||||
return {"tools": self._tools}
|
||||
if method == "tools/call":
|
||||
return {"content": {"status": "ok"}}
|
||||
return {}
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _mock_tool(name: str, schema: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"description": f"Bench tool {name}",
|
||||
"inputSchema": schema or {},
|
||||
}
|
||||
|
||||
|
||||
_TOOLS_3 = [_mock_tool(f"tool_{i}") for i in range(3)]
|
||||
_TOOLS_50 = [_mock_tool(f"tool_{i}") for i in range(50)]
|
||||
_SCHEMA_TOOL = _mock_tool(
|
||||
"validated",
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"x": {"type": "integer"}, "y": {"type": "string"}},
|
||||
"required": ["x"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TimeConfigCreation:
|
||||
"""Benchmark MCPServerConfig instantiation."""
|
||||
|
||||
def time_create_stdio_config(self) -> None:
|
||||
MCPServerConfig(name="bench", transport="stdio", command="echo")
|
||||
|
||||
def time_create_sse_config(self) -> None:
|
||||
MCPServerConfig(
|
||||
name="bench",
|
||||
transport="sse",
|
||||
url="https://example.com/mcp",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
|
||||
|
||||
class TimeToolDiscovery:
|
||||
"""Benchmark tool discovery with varying tool counts."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._config = MCPServerConfig(name="bench", transport="stdio", command="echo")
|
||||
|
||||
def time_discover_3_tools(self) -> None:
|
||||
t = _BenchTransport(_TOOLS_3)
|
||||
adapter = MCPToolAdapter(config=self._config, transport=t)
|
||||
adapter.connect()
|
||||
adapter.discover_tools()
|
||||
|
||||
def time_discover_50_tools(self) -> None:
|
||||
t = _BenchTransport(_TOOLS_50)
|
||||
adapter = MCPToolAdapter(config=self._config, transport=t)
|
||||
adapter.connect()
|
||||
adapter.discover_tools()
|
||||
|
||||
|
||||
class TimeToolInvocation:
|
||||
"""Benchmark tool invocation with and without schema validation."""
|
||||
|
||||
def setup(self) -> None:
|
||||
config = MCPServerConfig(name="bench", transport="stdio", command="echo")
|
||||
self._transport = _BenchTransport([_SCHEMA_TOOL])
|
||||
self._adapter = MCPToolAdapter(config=config, transport=self._transport)
|
||||
self._adapter.connect()
|
||||
self._adapter.discover_tools()
|
||||
|
||||
def time_invoke_with_validation(self) -> None:
|
||||
self._adapter.invoke("validated", {"x": 42, "y": "hello"})
|
||||
|
||||
def time_invoke_no_schema(self) -> None:
|
||||
no_schema = _BenchTransport([_mock_tool("plain")])
|
||||
config = MCPServerConfig(name="bench", transport="stdio", command="echo")
|
||||
adapter = MCPToolAdapter(config=config, transport=no_schema)
|
||||
adapter.connect()
|
||||
adapter.discover_tools()
|
||||
adapter.invoke("plain", {"any": "data"})
|
||||
|
||||
|
||||
class TimeToolRegistration:
|
||||
"""Benchmark registering discovered tools into ToolRegistry."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self._config = MCPServerConfig(name="bench", transport="stdio", command="echo")
|
||||
|
||||
def time_register_3_tools(self) -> None:
|
||||
t = _BenchTransport(_TOOLS_3)
|
||||
adapter = MCPToolAdapter(config=self._config, transport=t)
|
||||
adapter.connect()
|
||||
registry = ToolRegistry()
|
||||
adapter.register_tools(registry, namespace="bench")
|
||||
|
||||
def time_register_50_tools(self) -> None:
|
||||
t = _BenchTransport(_TOOLS_50)
|
||||
adapter = MCPToolAdapter(config=self._config, transport=t)
|
||||
adapter.connect()
|
||||
registry = ToolRegistry()
|
||||
adapter.register_tools(registry, namespace="bench")
|
||||
|
||||
|
||||
time_config = TimeConfigCreation()
|
||||
time_disc = TimeToolDiscovery()
|
||||
time_invoke = TimeToolInvocation()
|
||||
time_reg = TimeToolRegistration()
|
||||
@@ -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.
|
||||
@@ -0,0 +1,119 @@
|
||||
# MCP Tool Adapter
|
||||
|
||||
This document covers the MCP (Model Context Protocol) adapter introduced in
|
||||
**M2.3.mcp-runtime**: connecting to external MCP servers, discovering tools,
|
||||
invoking them with schema validation, and registering them in the ToolRegistry.
|
||||
|
||||
## Overview
|
||||
|
||||
The `MCPToolAdapter` bridges any MCP-compatible tool server into the
|
||||
CleverAgents runtime. It manages the full lifecycle:
|
||||
|
||||
| Operation | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| Connect | `connect()` | Start the server process or HTTP session, perform handshake |
|
||||
| Disconnect | `disconnect()` | Clean shutdown of the server connection |
|
||||
| Discover | `discover_tools(filter?)` | Enumerate available tools from the server |
|
||||
| Invoke | `invoke(name, args)` | Call a tool with automatic input validation |
|
||||
| Register | `register_tools(registry, namespace)` | Bulk-register into a ToolRegistry |
|
||||
|
||||
## Server Configuration
|
||||
|
||||
### `MCPServerConfig` Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Server identifier |
|
||||
| `transport` | `string` | Yes | `stdio`, `sse`, or `streamable-http` |
|
||||
| `command` | `string` | stdio only | Command to spawn the server process |
|
||||
| `args` | `list[string]` | No | Command-line arguments |
|
||||
| `env` | `dict` | No | Environment variables for the server process |
|
||||
| `url` | `string` | sse/http only | Server URL for remote connections |
|
||||
| `headers` | `dict` | No | HTTP headers for remote connections |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
- `stdio` transport **requires** `command`
|
||||
- `sse` / `streamable-http` transport **requires** `url`
|
||||
|
||||
## Tool Discovery
|
||||
|
||||
After connecting, call `discover_tools()` to enumerate all tools exposed by
|
||||
the MCP server. Each returned `MCPToolDescriptor` includes the tool name,
|
||||
description, and JSON Schema for inputs.
|
||||
|
||||
### Filtering
|
||||
|
||||
Use `MCPToolFilter` with `include` or `exclude` lists to control which
|
||||
tools are exposed.
|
||||
|
||||
### `MCPToolDescriptor` Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `name` | `string` | Tool name as exposed by the server |
|
||||
| `description` | `string` | Human-readable description |
|
||||
| `input_schema` | `dict` | JSON Schema for tool inputs |
|
||||
|
||||
## Tool Invocation
|
||||
|
||||
Call `invoke(name, arguments)` to execute a discovered tool. Input arguments
|
||||
are validated against the tool's JSON Schema before the call is made. If
|
||||
validation fails, the result is returned immediately with `success=False`.
|
||||
|
||||
### `MCPToolResult` Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `success` | `bool` | Whether the invocation succeeded |
|
||||
| `data` | `dict` | Result payload from the server |
|
||||
| `error` | `string` or `null` | Error message on failure |
|
||||
| `duration_ms` | `float` | Wall-clock execution time in milliseconds |
|
||||
|
||||
## ToolRegistry Integration
|
||||
|
||||
Discovered MCP tools can be bulk-registered into any `ToolRegistry` via
|
||||
`register_tools(registry, namespace)`. Each tool is registered with the
|
||||
name `{namespace}/{tool_name}`. Re-registration after tool list changes
|
||||
replaces stale entries automatically.
|
||||
|
||||
## Capability Inference
|
||||
|
||||
MCP tools expose limited metadata (name, description, inputSchema). The adapter
|
||||
automatically infers extended `ToolCapability` metadata using name-based heuristics:
|
||||
|
||||
| Tool name keywords | Inferred capability |
|
||||
|---|---|
|
||||
| `read`, `get`, `list`, `search`, `find` | `read_only: true` |
|
||||
| `write`, `create`, `update`, `delete`, `set` | `writes: true` |
|
||||
| Both read and write keywords | `writes: true` (write takes precedence) |
|
||||
| Neither | `read_only: false`, `writes: false` |
|
||||
|
||||
Inferences are applied automatically when registering tools via
|
||||
`register_tools()`. They can be overridden via the `overrides` block in
|
||||
tool or skill YAML.
|
||||
|
||||
Use `MCPToolAdapter.infer_capabilities(tool_name)` directly to check
|
||||
what would be inferred for a given tool name.
|
||||
|
||||
## Error Classification
|
||||
|
||||
| Error Type | When | Exception / Field |
|
||||
|------------|------|-------------------|
|
||||
| Connection failure | `connect()` with unreachable server | `ConnectionError` |
|
||||
| Not connected | `invoke()` / `discover_tools()` before `connect()` | `RuntimeError` |
|
||||
| Tool not found | `invoke()` with unknown tool name | `result.error` contains "not found" |
|
||||
| Validation failure | `invoke()` with invalid input | `result.error` contains "validation" |
|
||||
| Timeout | Server call exceeds deadline | `result.error` contains "timeout" |
|
||||
| Server error | Server returns `isError: true` | `result.error` contains "server error" |
|
||||
|
||||
## Transport Layer
|
||||
|
||||
The `MCPTransport` base class defines the abstract interface. Concrete
|
||||
implementations handle stdio (child process) and HTTP (SSE / streamable-http)
|
||||
protocols. For testing, provide a mock transport to the adapter constructor.
|
||||
|
||||
## Thread Safety
|
||||
|
||||
`MCPToolAdapter` uses `threading.RLock` internally. All public methods are
|
||||
safe to call from multiple threads concurrently.
|
||||
@@ -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"
|
||||
@@ -0,0 +1,239 @@
|
||||
Feature: MCP Tool Adapter
|
||||
As an actor runtime
|
||||
I want an adapter that connects to MCP servers, discovers tools,
|
||||
and invokes them with schema validation
|
||||
So that external MCP tools are usable like any registered tool
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Adapter Lifecycle
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
Scenario: Create adapter from server config
|
||||
Given an MCP server config for "github" with stdio transport
|
||||
When I create an MCP adapter from the config
|
||||
Then the adapter server name should be "github"
|
||||
And the adapter transport should be "stdio"
|
||||
And the adapter should not be connected
|
||||
|
||||
Scenario: Create adapter from SSE server config
|
||||
Given an MCP server config for "remote-api" with sse transport and url "https://api.example.com/mcp"
|
||||
When I create an MCP adapter from the config
|
||||
Then the adapter server name should be "remote-api"
|
||||
And the adapter transport should be "sse"
|
||||
|
||||
Scenario: Connect adapter starts handshake
|
||||
Given an MCP adapter with a mock transport
|
||||
When I connect the adapter
|
||||
Then the adapter should be connected
|
||||
And the adapter server capabilities should be set
|
||||
|
||||
Scenario: Disconnect adapter cleans up
|
||||
Given a connected MCP adapter with a mock transport
|
||||
When I disconnect the adapter
|
||||
Then the adapter should not be connected
|
||||
|
||||
Scenario: Double connect is idempotent
|
||||
Given a connected MCP adapter with a mock transport
|
||||
When I connect the adapter
|
||||
Then the adapter should be connected
|
||||
|
||||
Scenario: Disconnect when not connected is safe
|
||||
Given an MCP adapter with a mock transport
|
||||
When I disconnect the adapter
|
||||
Then the adapter should not be connected
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Tool Discovery
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
Scenario: Discover tools from MCP server
|
||||
Given a connected MCP adapter with 3 mock tools
|
||||
When I discover tools from the adapter
|
||||
Then the adapter should have 3 discovered tools
|
||||
And discovered tool 0 should have a name
|
||||
And discovered tool 0 should have an input schema
|
||||
|
||||
Scenario: Discover tools with include filter
|
||||
Given a connected MCP adapter with 3 mock tools
|
||||
And a tool filter including only "tool_0"
|
||||
When I discover tools from the adapter with filter
|
||||
Then the adapter should have 1 discovered tools
|
||||
|
||||
Scenario: Discover tools with exclude filter
|
||||
Given a connected MCP adapter with 3 mock tools
|
||||
And a tool filter excluding "tool_0"
|
||||
When I discover tools from the adapter with filter
|
||||
Then the adapter should have 2 discovered tools
|
||||
|
||||
Scenario: Discover returns empty when server has no tools
|
||||
Given a connected MCP adapter with 0 mock tools
|
||||
When I discover tools from the adapter
|
||||
Then the adapter should have 0 discovered tools
|
||||
|
||||
Scenario: Discover when not connected raises error
|
||||
Given an MCP adapter with a mock transport
|
||||
When I discover tools expecting an error
|
||||
Then the adapter error should mention "not connected"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Tool Invocation
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
Scenario: Invoke a discovered tool successfully
|
||||
Given a connected MCP adapter with a callable mock tool "create_issue"
|
||||
When I invoke "create_issue" with arguments {"title": "Bug", "body": "Fix it"}
|
||||
Then the invocation should succeed
|
||||
And the invocation result should contain key "id"
|
||||
|
||||
Scenario: Invoke with valid input schema passes validation
|
||||
Given a connected MCP adapter with a schema-validated mock tool "create_issue"
|
||||
When I invoke "create_issue" with arguments {"title": "Bug", "body": "Fix it"}
|
||||
Then the invocation should succeed
|
||||
|
||||
Scenario: Invoke with invalid input fails validation
|
||||
Given a connected MCP adapter with a schema-validated mock tool "create_issue"
|
||||
When I invoke "create_issue" with arguments {"wrong_field": 123}
|
||||
Then the invocation should fail
|
||||
And the invocation error should mention "validation"
|
||||
|
||||
Scenario: Invoke unknown tool raises error
|
||||
Given a connected MCP adapter with a callable mock tool "create_issue"
|
||||
When I invoke "nonexistent_tool" with arguments {}
|
||||
Then the invocation should fail
|
||||
And the invocation error should mention "not found"
|
||||
|
||||
Scenario: Invoke when not connected raises error
|
||||
Given an MCP adapter with a mock transport
|
||||
When I invoke MCP tool "create_issue" while disconnected
|
||||
Then the adapter error should mention "not connected"
|
||||
|
||||
Scenario: Invoke tool that returns error from server
|
||||
Given a connected MCP adapter with a failing mock tool "bad_tool"
|
||||
When I invoke "bad_tool" with arguments {}
|
||||
Then the invocation should fail
|
||||
And the invocation error should mention "server error"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Tool Registration in ToolRegistry
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
Scenario: Register discovered MCP tools in ToolRegistry
|
||||
Given a connected MCP adapter with 2 mock tools
|
||||
And an empty MCP tool registry
|
||||
When I register MCP tools in the registry with namespace "mcp-github"
|
||||
Then the registry should have 2 tools
|
||||
And registry tool 0 name should start with "mcp-github/"
|
||||
|
||||
Scenario: Registered MCP tool is callable via registry
|
||||
Given a connected MCP adapter with a callable mock tool "list_repos"
|
||||
And an empty MCP tool registry
|
||||
When I register MCP tools in the registry with namespace "mcp-github"
|
||||
Then calling registry tool "mcp-github/list_repos" should succeed
|
||||
|
||||
Scenario: Re-registration after tool list changed
|
||||
Given a connected MCP adapter with 2 mock tools
|
||||
And an empty MCP tool registry
|
||||
When I register MCP tools in the registry with namespace "mcp-github"
|
||||
And the MCP server adds a new tool "deploy"
|
||||
And I re-register MCP tools in the registry with namespace "mcp-github"
|
||||
Then the registry should have 3 tools
|
||||
|
||||
Scenario: Registered tool handler returns error on failed invoke
|
||||
Given a connected MCP adapter with a failing mock tool "bad_tool"
|
||||
And an empty MCP tool registry
|
||||
When I register MCP tools in the registry with namespace "mcp-fail"
|
||||
Then calling registry tool "mcp-fail/bad_tool" should return an error
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Capability Inference
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
Scenario: Infer read_only for tool named list_repos
|
||||
When I infer capabilities for tool name "list_repos"
|
||||
Then the inferred capabilities should have read_only true
|
||||
And the inferred capabilities should have writes false
|
||||
|
||||
Scenario: Infer writes for tool named create_issue
|
||||
When I infer capabilities for tool name "create_issue"
|
||||
Then the inferred capabilities should have read_only false
|
||||
And the inferred capabilities should have writes true
|
||||
|
||||
Scenario: Infer writes for tool named delete_branch
|
||||
When I infer capabilities for tool name "delete_branch"
|
||||
Then the inferred capabilities should have read_only false
|
||||
And the inferred capabilities should have writes true
|
||||
|
||||
Scenario: Infer default for unknown tool name
|
||||
When I infer capabilities for tool name "run_pipeline"
|
||||
Then the inferred capabilities should have read_only false
|
||||
And the inferred capabilities should have writes false
|
||||
|
||||
Scenario: Infer read_only for tool with get prefix
|
||||
When I infer capabilities for tool name "get-user-details"
|
||||
Then the inferred capabilities should have read_only true
|
||||
And the inferred capabilities should have writes false
|
||||
|
||||
Scenario: Write overrides read when both keywords present
|
||||
When I infer capabilities for tool name "search_and_update"
|
||||
Then the inferred capabilities should have writes true
|
||||
|
||||
Scenario: Registered MCP tool has inferred capabilities
|
||||
Given a connected MCP adapter with a callable mock tool "list_repos"
|
||||
And an empty MCP tool registry
|
||||
When I register MCP tools in the registry with namespace "mcp-github"
|
||||
Then registry tool "mcp-github/list_repos" should have read_only capability
|
||||
|
||||
Scenario: Registered write tool has writes capability
|
||||
Given a connected MCP adapter with a callable mock tool "create_issue"
|
||||
And an empty MCP tool registry
|
||||
When I register MCP tools in the registry with namespace "mcp-github"
|
||||
Then registry tool "mcp-github/create_issue" should have writes capability
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Edge Cases
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
Scenario: Access discovered_tools property after discovery
|
||||
Given a connected MCP adapter with 2 mock tools
|
||||
When I discover tools from the adapter
|
||||
Then the adapter discovered_tools property should have 2 items
|
||||
|
||||
Scenario: Disconnect handles close errors gracefully
|
||||
Given a connected MCP adapter with a transport that errors on close
|
||||
When I disconnect the adapter
|
||||
Then the adapter should not be connected
|
||||
|
||||
Scenario: Invoke tool that raises generic exception
|
||||
Given a connected MCP adapter with a transport-error mock tool "crash_tool"
|
||||
When I invoke "crash_tool" with arguments {}
|
||||
Then the invocation should fail
|
||||
And the invocation error should mention "server error"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Error Classification
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
Scenario: Classify connection failure
|
||||
Given an MCP adapter with a transport that fails to connect
|
||||
When I connect the adapter expecting error
|
||||
Then the adapter error should mention "connection"
|
||||
|
||||
Scenario: Classify timeout error
|
||||
Given a connected MCP adapter with a tool that times out
|
||||
When I invoke "slow_tool" with arguments {}
|
||||
Then the invocation should fail
|
||||
And the invocation error should mention "timeout"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Server Config Validation
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
Scenario: Stdio transport requires command
|
||||
Given an MCP server config for "bad" with stdio transport but no command
|
||||
When I create an MCP adapter from the config expecting validation error
|
||||
Then the adapter error should mention "command"
|
||||
|
||||
Scenario: SSE transport requires url
|
||||
Given an MCP server config for "bad" with sse transport but no url
|
||||
When I create an MCP adapter from the config expecting validation error
|
||||
Then the adapter error should mention "url"
|
||||
@@ -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}'"
|
||||
)
|
||||
@@ -0,0 +1,506 @@
|
||||
"""Step definitions for features/mcp_adapter.feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.mcp.adapter import (
|
||||
MCPServerConfig,
|
||||
MCPToolAdapter,
|
||||
MCPToolFilter,
|
||||
MCPTransport,
|
||||
)
|
||||
from cleveragents.tool.registry import ToolRegistry
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Mock transport for testing
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
|
||||
class MockMCPTransport(MCPTransport):
|
||||
"""In-memory mock transport simulating an MCP server."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
fail_connect: bool = False,
|
||||
invoke_results: dict[str, dict[str, Any]] | None = None,
|
||||
invoke_errors: dict[str, str] | None = None,
|
||||
timeout_tools: set[str] | None = None,
|
||||
) -> None:
|
||||
self._tools = tools or []
|
||||
self._fail_connect = fail_connect
|
||||
self._invoke_results = invoke_results or {}
|
||||
self._invoke_errors = invoke_errors or {}
|
||||
self._timeout_tools = timeout_tools or set()
|
||||
self._connected = False
|
||||
|
||||
def connect(self, config: MCPServerConfig) -> dict[str, Any]:
|
||||
if self._fail_connect:
|
||||
msg = "Mock connection refused"
|
||||
raise ConnectionRefusedError(msg)
|
||||
self._connected = True
|
||||
return {"capabilities": {"tools": True}}
|
||||
|
||||
def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
if method == "tools/list":
|
||||
return {"tools": list(self._tools)}
|
||||
|
||||
if method == "tools/call":
|
||||
tool_name = params.get("name", "")
|
||||
|
||||
if tool_name in self._timeout_tools:
|
||||
msg = f"Tool '{tool_name}' exceeded timeout"
|
||||
raise TimeoutError(msg)
|
||||
|
||||
if tool_name in self._invoke_errors:
|
||||
return {
|
||||
"isError": True,
|
||||
"error": self._invoke_errors[tool_name],
|
||||
}
|
||||
|
||||
if tool_name in self._invoke_results:
|
||||
return {"content": self._invoke_results[tool_name]}
|
||||
|
||||
return {"content": {"result": "ok"}}
|
||||
|
||||
return {}
|
||||
|
||||
def close(self) -> None:
|
||||
self._connected = False
|
||||
|
||||
def add_tool(self, tool: dict[str, Any]) -> None:
|
||||
self._tools.append(tool)
|
||||
|
||||
|
||||
def _mock_tool(name: str, desc: str = "", schema: dict | None = None) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"description": desc or f"Mock tool {name}",
|
||||
"inputSchema": schema or {},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Adapter Lifecycle
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
|
||||
@given('an MCP server config for "{name}" with stdio transport')
|
||||
def step_mcp_config_stdio(context: Context, name: str) -> None:
|
||||
context.mcp_config = MCPServerConfig(
|
||||
name=name,
|
||||
transport="stdio",
|
||||
command="echo",
|
||||
args=["hello"],
|
||||
)
|
||||
|
||||
|
||||
@given('an MCP server config for "{name}" with sse transport and url "{url}"')
|
||||
def step_mcp_config_sse(context: Context, name: str, url: str) -> None:
|
||||
context.mcp_config = MCPServerConfig(name=name, transport="sse", url=url)
|
||||
|
||||
|
||||
@given("an MCP adapter with a mock transport")
|
||||
def step_mcp_adapter_mock(context: Context) -> None:
|
||||
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
|
||||
context.mcp_transport = MockMCPTransport()
|
||||
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
|
||||
|
||||
|
||||
@given("a connected MCP adapter with a mock transport")
|
||||
def step_mcp_adapter_connected(context: Context) -> None:
|
||||
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
|
||||
context.mcp_transport = MockMCPTransport()
|
||||
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
|
||||
context.mcp_adapter.connect()
|
||||
|
||||
|
||||
@given("a connected MCP adapter with {count:d} mock tools")
|
||||
def step_mcp_adapter_with_tools(context: Context, count: int) -> None:
|
||||
tools = [_mock_tool(f"tool_{i}") for i in range(count)]
|
||||
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
|
||||
context.mcp_transport = MockMCPTransport(tools=tools)
|
||||
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
|
||||
context.mcp_adapter.connect()
|
||||
|
||||
|
||||
@given('a connected MCP adapter with a callable mock tool "{tool_name}"')
|
||||
def step_mcp_adapter_callable_tool(context: Context, tool_name: str) -> None:
|
||||
tools = [_mock_tool(tool_name)]
|
||||
results = {tool_name: {"id": 42, "status": "created"}}
|
||||
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
|
||||
context.mcp_transport = MockMCPTransport(tools=tools, invoke_results=results)
|
||||
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
|
||||
context.mcp_adapter.connect()
|
||||
context.mcp_adapter.discover_tools()
|
||||
|
||||
|
||||
@given('a connected MCP adapter with a schema-validated mock tool "{tool_name}"')
|
||||
def step_mcp_adapter_schema_tool(context: Context, tool_name: str) -> None:
|
||||
schema: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"body": {"type": "string"},
|
||||
},
|
||||
"required": ["title", "body"],
|
||||
}
|
||||
tools = [_mock_tool(tool_name, schema=schema)]
|
||||
results = {tool_name: {"id": 1}}
|
||||
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
|
||||
context.mcp_transport = MockMCPTransport(tools=tools, invoke_results=results)
|
||||
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
|
||||
context.mcp_adapter.connect()
|
||||
context.mcp_adapter.discover_tools()
|
||||
|
||||
|
||||
@given('a connected MCP adapter with a failing mock tool "{tool_name}"')
|
||||
def step_mcp_adapter_failing_tool(context: Context, tool_name: str) -> None:
|
||||
tools = [_mock_tool(tool_name)]
|
||||
errors = {tool_name: "Internal server error"}
|
||||
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
|
||||
context.mcp_transport = MockMCPTransport(tools=tools, invoke_errors=errors)
|
||||
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
|
||||
context.mcp_adapter.connect()
|
||||
context.mcp_adapter.discover_tools()
|
||||
|
||||
|
||||
@given('a tool filter including only "{tool_name}"')
|
||||
def step_tool_filter_include(context: Context, tool_name: str) -> None:
|
||||
context.mcp_tool_filter = MCPToolFilter(include=[tool_name])
|
||||
|
||||
|
||||
@given('a tool filter excluding "{tool_name}"')
|
||||
def step_tool_filter_exclude(context: Context, tool_name: str) -> None:
|
||||
context.mcp_tool_filter = MCPToolFilter(exclude=[tool_name])
|
||||
|
||||
|
||||
@given("an empty MCP tool registry")
|
||||
def step_empty_mcp_registry(context: Context) -> None:
|
||||
context.mcp_registry = ToolRegistry()
|
||||
|
||||
|
||||
@given("an MCP adapter with a transport that fails to connect")
|
||||
def step_mcp_adapter_fail_connect(context: Context) -> None:
|
||||
config = MCPServerConfig(name="bad-server", transport="stdio", command="echo")
|
||||
context.mcp_transport = MockMCPTransport(fail_connect=True)
|
||||
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
|
||||
|
||||
|
||||
@given("a connected MCP adapter with a tool that times out")
|
||||
def step_mcp_adapter_timeout_tool(context: Context) -> None:
|
||||
tools = [_mock_tool("slow_tool")]
|
||||
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
|
||||
context.mcp_transport = MockMCPTransport(tools=tools, timeout_tools={"slow_tool"})
|
||||
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
|
||||
context.mcp_adapter.connect()
|
||||
context.mcp_adapter.discover_tools()
|
||||
|
||||
|
||||
@given("a connected MCP adapter with a transport that errors on close")
|
||||
def step_mcp_adapter_close_error(context: Context) -> None:
|
||||
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
|
||||
|
||||
class _ErrorCloseTransport(MockMCPTransport):
|
||||
def close(self) -> None:
|
||||
msg = "Close failed"
|
||||
raise OSError(msg)
|
||||
|
||||
context.mcp_transport = _ErrorCloseTransport()
|
||||
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
|
||||
context.mcp_adapter.connect()
|
||||
|
||||
|
||||
@given('a connected MCP adapter with a transport-error mock tool "{tool_name}"')
|
||||
def step_mcp_adapter_transport_error_tool(context: Context, tool_name: str) -> None:
|
||||
tools = [_mock_tool(tool_name)]
|
||||
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
|
||||
|
||||
class _CrashTransport(MockMCPTransport):
|
||||
def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
if method == "tools/call":
|
||||
msg = "Transport crashed unexpectedly"
|
||||
raise RuntimeError(msg)
|
||||
return super().call(method, params)
|
||||
|
||||
context.mcp_transport = _CrashTransport(tools=tools)
|
||||
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
|
||||
context.mcp_adapter.connect()
|
||||
context.mcp_adapter.discover_tools()
|
||||
|
||||
|
||||
@given('an MCP server config for "{name}" with stdio transport but no command')
|
||||
def step_mcp_config_stdio_no_cmd(context: Context, name: str) -> None:
|
||||
context.mcp_config = MCPServerConfig(name=name, transport="stdio")
|
||||
|
||||
|
||||
@given('an MCP server config for "{name}" with sse transport but no url')
|
||||
def step_mcp_config_sse_no_url(context: Context, name: str) -> None:
|
||||
context.mcp_config = MCPServerConfig(name=name, transport="sse")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# When Steps
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I create an MCP adapter from the config")
|
||||
def step_create_adapter(context: Context) -> None:
|
||||
context.mcp_error = None
|
||||
try:
|
||||
context.mcp_adapter = MCPToolAdapter.from_server_config(context.mcp_config)
|
||||
except Exception as exc:
|
||||
context.mcp_error = str(exc)
|
||||
|
||||
|
||||
@when("I create an MCP adapter from the config expecting validation error")
|
||||
def step_create_adapter_fail(context: Context) -> None:
|
||||
context.mcp_error = None
|
||||
try:
|
||||
context.mcp_adapter = MCPToolAdapter.from_server_config(context.mcp_config)
|
||||
except Exception as exc:
|
||||
context.mcp_error = str(exc)
|
||||
|
||||
|
||||
@when("I connect the adapter")
|
||||
def step_connect_adapter(context: Context) -> None:
|
||||
context.mcp_error = None
|
||||
context.mcp_adapter.connect()
|
||||
|
||||
|
||||
@when("I connect the adapter expecting error")
|
||||
def step_connect_adapter_fail(context: Context) -> None:
|
||||
context.mcp_error = None
|
||||
try:
|
||||
context.mcp_adapter.connect()
|
||||
except Exception as exc:
|
||||
context.mcp_error = str(exc)
|
||||
|
||||
|
||||
@when("I disconnect the adapter")
|
||||
def step_disconnect_adapter(context: Context) -> None:
|
||||
context.mcp_adapter.disconnect()
|
||||
|
||||
|
||||
@when("I discover tools from the adapter")
|
||||
def step_discover_tools(context: Context) -> None:
|
||||
context.mcp_discovered = context.mcp_adapter.discover_tools()
|
||||
|
||||
|
||||
@when("I discover tools from the adapter with filter")
|
||||
def step_discover_tools_filtered(context: Context) -> None:
|
||||
context.mcp_discovered = context.mcp_adapter.discover_tools(
|
||||
tool_filter=context.mcp_tool_filter
|
||||
)
|
||||
|
||||
|
||||
@when("I discover tools expecting an error")
|
||||
def step_discover_tools_error(context: Context) -> None:
|
||||
context.mcp_error = None
|
||||
try:
|
||||
context.mcp_adapter.discover_tools()
|
||||
except Exception as exc:
|
||||
context.mcp_error = str(exc)
|
||||
|
||||
|
||||
@when('I invoke "{tool_name}" with arguments {args_json}')
|
||||
def step_invoke_tool(context: Context, tool_name: str, args_json: str) -> None:
|
||||
arguments = json.loads(args_json)
|
||||
context.mcp_invoke_result = context.mcp_adapter.invoke(tool_name, arguments)
|
||||
|
||||
|
||||
@when('I invoke MCP tool "{tool_name}" while disconnected')
|
||||
def step_invoke_not_connected(context: Context, tool_name: str) -> None:
|
||||
context.mcp_error = None
|
||||
try:
|
||||
context.mcp_adapter.invoke(tool_name, {})
|
||||
except Exception as exc:
|
||||
context.mcp_error = str(exc)
|
||||
|
||||
|
||||
@when('I register MCP tools in the registry with namespace "{namespace}"')
|
||||
def step_register_tools(context: Context, namespace: str) -> None:
|
||||
context.mcp_registered = context.mcp_adapter.register_tools(
|
||||
registry=context.mcp_registry,
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
|
||||
@when('the MCP server adds a new tool "{tool_name}"')
|
||||
def step_server_adds_tool(context: Context, tool_name: str) -> None:
|
||||
context.mcp_transport.add_tool(_mock_tool(tool_name))
|
||||
|
||||
|
||||
@when('I re-register MCP tools in the registry with namespace "{namespace}"')
|
||||
def step_reregister_tools(context: Context, namespace: str) -> None:
|
||||
context.mcp_registered = context.mcp_adapter.register_tools(
|
||||
registry=context.mcp_registry,
|
||||
namespace=namespace,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Then Steps
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
|
||||
@then('the adapter server name should be "{name}"')
|
||||
def step_adapter_name(context: Context, name: str) -> None:
|
||||
assert context.mcp_adapter.server_name == name
|
||||
|
||||
|
||||
@then('the adapter transport should be "{transport}"')
|
||||
def step_adapter_transport(context: Context, transport: str) -> None:
|
||||
assert context.mcp_adapter.transport_type == transport
|
||||
|
||||
|
||||
@then("the adapter should not be connected")
|
||||
def step_adapter_not_connected(context: Context) -> None:
|
||||
assert not context.mcp_adapter.is_connected
|
||||
|
||||
|
||||
@then("the adapter should be connected")
|
||||
def step_adapter_connected(context: Context) -> None:
|
||||
assert context.mcp_adapter.is_connected
|
||||
|
||||
|
||||
@then("the adapter server capabilities should be set")
|
||||
def step_adapter_capabilities(context: Context) -> None:
|
||||
assert context.mcp_adapter.capabilities
|
||||
|
||||
|
||||
@then("the adapter should have {count:d} discovered tools")
|
||||
def step_adapter_tool_count(context: Context, count: int) -> None:
|
||||
assert len(context.mcp_discovered) == count, (
|
||||
f"Expected {count} tools, got {len(context.mcp_discovered)}"
|
||||
)
|
||||
|
||||
|
||||
@then("discovered tool {idx:d} should have a name")
|
||||
def step_discovered_tool_name(context: Context, idx: int) -> None:
|
||||
assert context.mcp_discovered[idx].name
|
||||
|
||||
|
||||
@then("discovered tool {idx:d} should have an input schema")
|
||||
def step_discovered_tool_schema(context: Context, idx: int) -> None:
|
||||
assert context.mcp_discovered[idx].input_schema is not None
|
||||
|
||||
|
||||
@then("the invocation should succeed")
|
||||
def step_invoke_success(context: Context) -> None:
|
||||
assert context.mcp_invoke_result.success, (
|
||||
f"Expected success, got error: {context.mcp_invoke_result.error}"
|
||||
)
|
||||
|
||||
|
||||
@then("the invocation should fail")
|
||||
def step_invoke_fail(context: Context) -> None:
|
||||
assert not context.mcp_invoke_result.success
|
||||
|
||||
|
||||
@then('the invocation result should contain key "{key}"')
|
||||
def step_invoke_result_key(context: Context, key: str) -> None:
|
||||
assert key in context.mcp_invoke_result.data, (
|
||||
f"Key '{key}' not in result: {context.mcp_invoke_result.data}"
|
||||
)
|
||||
|
||||
|
||||
@then('the invocation error should mention "{text}"')
|
||||
def step_invoke_error_mention(context: Context, text: str) -> None:
|
||||
assert context.mcp_invoke_result.error is not None
|
||||
assert text.lower() in context.mcp_invoke_result.error.lower(), (
|
||||
f"Error '{context.mcp_invoke_result.error}' does not mention '{text}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the adapter error should mention "{text}"')
|
||||
def step_adapter_error_mention(context: Context, text: str) -> None:
|
||||
assert context.mcp_error is not None, "Expected error but none occurred"
|
||||
assert text.lower() in context.mcp_error.lower(), (
|
||||
f"Error '{context.mcp_error}' does not mention '{text}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the registry should have {count:d} tools")
|
||||
def step_registry_count(context: Context, count: int) -> None:
|
||||
all_tools = context.mcp_registry.list_tools()
|
||||
assert len(all_tools) == count, f"Expected {count} tools, got {len(all_tools)}"
|
||||
|
||||
|
||||
@then('registry tool {idx:d} name should start with "{prefix}"')
|
||||
def step_registry_tool_prefix(context: Context, idx: int, prefix: str) -> None:
|
||||
all_tools = context.mcp_registry.list_tools()
|
||||
assert all_tools[idx].name.startswith(prefix)
|
||||
|
||||
|
||||
@then('calling registry tool "{tool_name}" should succeed')
|
||||
def step_registry_tool_callable(context: Context, tool_name: str) -> None:
|
||||
spec = context.mcp_registry.get(tool_name)
|
||||
assert spec is not None, f"Tool '{tool_name}' not in registry"
|
||||
result = spec.handler()
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
@then('calling registry tool "{tool_name}" should return an error')
|
||||
def step_registry_tool_error(context: Context, tool_name: str) -> None:
|
||||
spec = context.mcp_registry.get(tool_name)
|
||||
assert spec is not None, f"Tool '{tool_name}' not in registry"
|
||||
result = spec.handler()
|
||||
assert isinstance(result, dict)
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@then("the adapter discovered_tools property should have {count:d} items")
|
||||
def step_adapter_discovered_tools_property(context: Context, count: int) -> None:
|
||||
dt = context.mcp_adapter.discovered_tools
|
||||
assert len(dt) == count, f"Expected {count}, got {len(dt)}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Capability Inference Steps
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I infer capabilities for tool name "{tool_name}"')
|
||||
def step_infer_capabilities(context: Context, tool_name: str) -> None:
|
||||
context.inferred_caps = MCPToolAdapter.infer_capabilities(tool_name)
|
||||
|
||||
|
||||
@then("the inferred capabilities should have read_only true")
|
||||
def step_inferred_read_only_true(context: Context) -> None:
|
||||
assert context.inferred_caps["read_only"] is True
|
||||
|
||||
|
||||
@then("the inferred capabilities should have read_only false")
|
||||
def step_inferred_read_only_false(context: Context) -> None:
|
||||
assert context.inferred_caps["read_only"] is False
|
||||
|
||||
|
||||
@then("the inferred capabilities should have writes true")
|
||||
def step_inferred_writes_true(context: Context) -> None:
|
||||
assert context.inferred_caps["writes"] is True
|
||||
|
||||
|
||||
@then("the inferred capabilities should have writes false")
|
||||
def step_inferred_writes_false(context: Context) -> None:
|
||||
assert context.inferred_caps["writes"] is False
|
||||
|
||||
|
||||
@then('registry tool "{tool_name}" should have read_only capability')
|
||||
def step_registry_tool_read_only(context: Context, tool_name: str) -> None:
|
||||
spec = context.mcp_registry.get(tool_name)
|
||||
assert spec is not None, f"Tool '{tool_name}' not in registry"
|
||||
assert spec.capabilities.read_only is True
|
||||
|
||||
|
||||
@then('registry tool "{tool_name}" should have writes capability')
|
||||
def step_registry_tool_writes(context: Context, tool_name: str) -> None:
|
||||
spec = context.mcp_registry.get(tool_name)
|
||||
assert spec is not None, f"Tool '{tool_name}' not in registry"
|
||||
assert spec.capabilities.writes is True
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Robot Framework helper for MCP Tool Adapter smoke tests.
|
||||
|
||||
Usage:
|
||||
python robot/helper_mcp_adapter.py create-adapter
|
||||
python robot/helper_mcp_adapter.py discover-tools
|
||||
python robot/helper_mcp_adapter.py invoke-tool
|
||||
python robot/helper_mcp_adapter.py register-tools
|
||||
python robot/helper_mcp_adapter.py reject-no-command
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.mcp.adapter import ( # noqa: E402
|
||||
MCPServerConfig,
|
||||
MCPToolAdapter,
|
||||
MCPTransport,
|
||||
)
|
||||
from cleveragents.tool.registry import ToolRegistry # noqa: E402
|
||||
|
||||
|
||||
class _MockTransport(MCPTransport):
|
||||
"""Minimal mock transport for smoke tests."""
|
||||
|
||||
def __init__(self, tools: list[dict[str, Any]] | None = None) -> None:
|
||||
self._tools = tools or []
|
||||
self._results: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def connect(self, config: MCPServerConfig) -> dict[str, Any]:
|
||||
return {"capabilities": {"tools": True}}
|
||||
|
||||
def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
if method == "tools/list":
|
||||
return {"tools": list(self._tools)}
|
||||
if method == "tools/call":
|
||||
name = params.get("name", "")
|
||||
if name in self._results:
|
||||
return {"content": self._results[name]}
|
||||
return {"content": {"result": "ok"}}
|
||||
return {}
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _mock_tool(name: str) -> dict[str, Any]:
|
||||
return {"name": name, "description": f"Mock {name}", "inputSchema": {}}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: helper_mcp_adapter.py <command>")
|
||||
return 1
|
||||
|
||||
dispatch = {
|
||||
"create-adapter": _create_adapter,
|
||||
"discover-tools": _discover_tools,
|
||||
"invoke-tool": _invoke_tool,
|
||||
"register-tools": _register_tools,
|
||||
"reject-no-command": _reject_no_command,
|
||||
}
|
||||
handler = dispatch.get(sys.argv[1])
|
||||
if handler is None:
|
||||
print(f"Unknown command: {sys.argv[1]}")
|
||||
return 1
|
||||
return handler()
|
||||
|
||||
|
||||
def _create_adapter() -> int:
|
||||
config = MCPServerConfig(name="github", transport="stdio", command="echo")
|
||||
adapter = MCPToolAdapter(config=config)
|
||||
print(f"server-name: {adapter.server_name}")
|
||||
print(f"transport: {adapter.transport_type}")
|
||||
print(f"connected: {adapter.is_connected}")
|
||||
return 0
|
||||
|
||||
|
||||
def _discover_tools() -> int:
|
||||
tools = [
|
||||
_mock_tool("create_issue"),
|
||||
_mock_tool("list_repos"),
|
||||
_mock_tool("search"),
|
||||
]
|
||||
config = MCPServerConfig(name="test", transport="stdio", command="echo")
|
||||
transport = _MockTransport(tools=tools)
|
||||
adapter = MCPToolAdapter(config=config, transport=transport)
|
||||
adapter.connect()
|
||||
discovered = adapter.discover_tools()
|
||||
print(f"discovered: {len(discovered)}")
|
||||
for i, t in enumerate(discovered):
|
||||
print(f"tool-{i}: {t.name}")
|
||||
return 0
|
||||
|
||||
|
||||
def _invoke_tool() -> int:
|
||||
tools = [_mock_tool("create_issue")]
|
||||
config = MCPServerConfig(name="test", transport="stdio", command="echo")
|
||||
transport = _MockTransport(tools=tools)
|
||||
transport._results["create_issue"] = {"id": 42}
|
||||
adapter = MCPToolAdapter(config=config, transport=transport)
|
||||
adapter.connect()
|
||||
adapter.discover_tools()
|
||||
result = adapter.invoke("create_issue", {"title": "Bug"})
|
||||
print(f"success: {result.success}")
|
||||
print(f"has-data: {bool(result.data)}")
|
||||
return 0
|
||||
|
||||
|
||||
def _register_tools() -> int:
|
||||
tools = [_mock_tool("tool_a"), _mock_tool("tool_b")]
|
||||
config = MCPServerConfig(name="test", transport="stdio", command="echo")
|
||||
transport = _MockTransport(tools=tools)
|
||||
adapter = MCPToolAdapter(config=config, transport=transport)
|
||||
adapter.connect()
|
||||
registry = ToolRegistry()
|
||||
names = adapter.register_tools(registry, namespace="mcp-test")
|
||||
print(f"registered: {len(names)}")
|
||||
if names and "/" in names[0]:
|
||||
print(f"prefix: {names[0].split('/')[0]}/")
|
||||
return 0
|
||||
|
||||
|
||||
def _reject_no_command() -> int:
|
||||
config = MCPServerConfig(name="bad", transport="stdio")
|
||||
try:
|
||||
MCPToolAdapter.from_server_config(config)
|
||||
print("unexpected-success")
|
||||
return 1
|
||||
except ValueError as exc:
|
||||
err = str(exc).lower()
|
||||
if "command" in err:
|
||||
print("validation-error: command")
|
||||
else:
|
||||
print(f"validation-error-other: {exc}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,50 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for MCP Tool Adapter
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_mcp_adapter.py
|
||||
|
||||
*** Test Cases ***
|
||||
Create MCP Adapter From Config
|
||||
[Documentation] Create an adapter from a server config and verify properties
|
||||
${result}= Run Process ${PYTHON} ${HELPER} create-adapter cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} server-name: github
|
||||
Should Contain ${result.stdout} transport: stdio
|
||||
Should Contain ${result.stdout} connected: False
|
||||
|
||||
Discover MCP Tools
|
||||
[Documentation] Connect and discover tools from a mock MCP server
|
||||
${result}= Run Process ${PYTHON} ${HELPER} discover-tools cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} discovered: 3
|
||||
Should Contain ${result.stdout} tool-0: create_issue
|
||||
|
||||
Invoke MCP Tool
|
||||
[Documentation] Invoke a discovered tool and verify result
|
||||
${result}= Run Process ${PYTHON} ${HELPER} invoke-tool cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} success: True
|
||||
Should Contain ${result.stdout} has-data: True
|
||||
|
||||
Register MCP Tools In Registry
|
||||
[Documentation] Register discovered tools into a ToolRegistry
|
||||
${result}= Run Process ${PYTHON} ${HELPER} register-tools cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} registered: 2
|
||||
Should Contain ${result.stdout} prefix: mcp-test/
|
||||
|
||||
Reject Stdio Config Without Command
|
||||
[Documentation] Validate that stdio transport requires command
|
||||
${result}= Run Process ${PYTHON} ${HELPER} reject-no-command cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} validation-error: command
|
||||
@@ -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,28 +547,39 @@ 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)
|
||||
|
||||
def detect_cycles(self) -> list[str]:
|
||||
@@ -582,6 +703,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 +781,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 +849,13 @@ __all__ = [
|
||||
"ContextConfigSchema",
|
||||
"ContextView",
|
||||
"EdgeDefinition",
|
||||
"LspContextEnrichment",
|
||||
"MemoryConfig",
|
||||
"NodeDefinition",
|
||||
"NodeLspBinding",
|
||||
"NodeType",
|
||||
"RouteDefinition",
|
||||
"ToolDefinition",
|
||||
"ToolParameter",
|
||||
"ToolSourceRef",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""MCP (Model Context Protocol) adapter package.
|
||||
|
||||
Bridges external MCP tool servers into the CleverAgents ToolRegistry
|
||||
via ``MCPToolAdapter``.
|
||||
"""
|
||||
|
||||
from cleveragents.mcp.adapter import (
|
||||
MCPServerConfig,
|
||||
MCPToolAdapter,
|
||||
MCPToolDescriptor,
|
||||
MCPToolResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MCPServerConfig",
|
||||
"MCPToolAdapter",
|
||||
"MCPToolDescriptor",
|
||||
"MCPToolResult",
|
||||
]
|
||||
@@ -0,0 +1,461 @@
|
||||
"""MCP Tool Adapter for bridging MCP servers into the ToolRegistry.
|
||||
|
||||
Manages the lifecycle of an MCP server connection — handshake, tool
|
||||
discovery, invocation with schema validation, and clean shutdown.
|
||||
|
||||
Operations:
|
||||
| Method | Description |
|
||||
|-------------------------------|--------------------------------------------|
|
||||
| ``connect()`` | Start server process / HTTP session |
|
||||
| ``disconnect()`` | Clean shutdown of the server connection |
|
||||
| ``discover_tools(filter)`` | Enumerate tools from the server |
|
||||
| ``invoke(name, arguments)`` | Call a tool with input validation |
|
||||
| ``register_tools(registry)`` | Bulk-register discovered tools |
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import jsonschema
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPServerConfig(BaseModel):
|
||||
"""Configuration for connecting to an MCP server.
|
||||
|
||||
Attributes:
|
||||
name: Server identifier.
|
||||
transport: Protocol type (``stdio``, ``sse``, ``streamable-http``).
|
||||
command: Command to spawn the server (required for stdio).
|
||||
args: Command-line arguments for the server process.
|
||||
env: Environment variables passed to the server process.
|
||||
url: Server URL (required for sse/streamable-http).
|
||||
headers: HTTP headers for remote connections.
|
||||
"""
|
||||
|
||||
name: str = Field(..., min_length=1, description="Server identifier")
|
||||
transport: str = Field(..., description="Transport: stdio | sse | streamable-http")
|
||||
command: str | None = Field(default=None, description="Spawn command (stdio)")
|
||||
args: list[str] = Field(default_factory=list, description="Command arguments")
|
||||
env: dict[str, str] = Field(default_factory=dict, description="Environment vars")
|
||||
url: str | None = Field(default=None, description="Server URL (sse/http)")
|
||||
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers")
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
|
||||
class MCPToolDescriptor(BaseModel):
|
||||
"""Descriptor for a tool discovered from an MCP server.
|
||||
|
||||
Attributes:
|
||||
name: Tool name as exposed by the MCP server.
|
||||
description: Human-readable description of the tool.
|
||||
input_schema: JSON Schema for tool inputs.
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="Tool name")
|
||||
description: str = Field(default="", description="Tool description")
|
||||
input_schema: dict[str, Any] = Field(
|
||||
default_factory=dict, description="JSON Schema for inputs"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
|
||||
class MCPToolResult(BaseModel):
|
||||
"""Result of an MCP tool invocation.
|
||||
|
||||
Attributes:
|
||||
success: Whether the invocation succeeded.
|
||||
data: Result payload from the MCP server.
|
||||
error: Error message on failure.
|
||||
duration_ms: Wall-clock execution time in milliseconds.
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="Whether invocation succeeded")
|
||||
data: dict[str, Any] = Field(default_factory=dict, description="Result payload")
|
||||
error: str | None = Field(default=None, description="Error message")
|
||||
duration_ms: float = Field(default=0.0, description="Execution time (ms)")
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
|
||||
class MCPToolFilter(BaseModel):
|
||||
"""Filter controlling which MCP tools are exposed.
|
||||
|
||||
Attributes:
|
||||
include: Whitelist of tool names (empty = include all).
|
||||
exclude: Blacklist of tool names.
|
||||
"""
|
||||
|
||||
include: list[str] = Field(default_factory=list, description="Include list")
|
||||
exclude: list[str] = Field(default_factory=list, description="Exclude list")
|
||||
|
||||
|
||||
class MCPTransport:
|
||||
"""Abstract transport layer for MCP server communication.
|
||||
|
||||
Subclass to provide real stdio or HTTP transports. The default
|
||||
implementation raises ``NotImplementedError`` for all operations.
|
||||
"""
|
||||
|
||||
def connect(self, config: MCPServerConfig) -> dict[str, Any]:
|
||||
"""Perform handshake and return server capabilities."""
|
||||
raise NotImplementedError
|
||||
|
||||
def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Send a JSON-RPC method call and return the result."""
|
||||
_ = method, params
|
||||
raise NotImplementedError
|
||||
|
||||
def close(self) -> None:
|
||||
"""Shut down the transport connection."""
|
||||
|
||||
|
||||
class MCPToolAdapter:
|
||||
"""Adapter bridging an MCP server into the CleverAgents ToolRegistry.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config:
|
||||
Server connection configuration.
|
||||
transport:
|
||||
Optional transport override (for testing). When ``None``, a
|
||||
real transport is selected based on ``config.transport``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: MCPServerConfig,
|
||||
transport: MCPTransport | None = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._transport = transport or MCPTransport()
|
||||
self._connected = False
|
||||
self._capabilities: dict[str, Any] = {}
|
||||
self._tools: dict[str, MCPToolDescriptor] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@classmethod
|
||||
def from_server_config(
|
||||
cls,
|
||||
config: MCPServerConfig,
|
||||
transport: MCPTransport | None = None,
|
||||
) -> MCPToolAdapter:
|
||||
"""Create an adapter with config validation.
|
||||
|
||||
Raises ``ValueError`` when required transport fields are missing.
|
||||
"""
|
||||
if config.transport == "stdio" and not config.command:
|
||||
msg = (
|
||||
f"MCPServerConfig '{config.name}': stdio transport "
|
||||
f"requires 'command' field"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
if config.transport in ("sse", "streamable-http") and not config.url:
|
||||
msg = (
|
||||
f"MCPServerConfig '{config.name}': {config.transport} "
|
||||
f"transport requires 'url' field"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return cls(config=config, transport=transport)
|
||||
|
||||
@property
|
||||
def server_name(self) -> str:
|
||||
return self._config.name
|
||||
|
||||
@property
|
||||
def transport_type(self) -> str:
|
||||
return self._config.transport
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
with self._lock:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def capabilities(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return dict(self._capabilities)
|
||||
|
||||
@property
|
||||
def discovered_tools(self) -> list[MCPToolDescriptor]:
|
||||
with self._lock:
|
||||
return list(self._tools.values())
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Connect to the MCP server and perform the initialize handshake.
|
||||
|
||||
Raises ``ConnectionError`` if the transport fails to connect.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._connected:
|
||||
return
|
||||
try:
|
||||
caps = self._transport.connect(self._config)
|
||||
except Exception as exc:
|
||||
msg = f"MCP connection to '{self._config.name}' failed: {exc}"
|
||||
raise ConnectionError(msg) from exc
|
||||
self._capabilities = caps or {}
|
||||
self._connected = True
|
||||
logger.info("Connected to MCP server '%s'", self._config.name)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from the MCP server."""
|
||||
with self._lock:
|
||||
if not self._connected:
|
||||
return
|
||||
try:
|
||||
self._transport.close()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Error during MCP disconnect from '%s'",
|
||||
self._config.name,
|
||||
exc_info=True,
|
||||
)
|
||||
self._connected = False
|
||||
self._tools.clear()
|
||||
logger.info("Disconnected from MCP server '%s'", self._config.name)
|
||||
|
||||
def discover_tools(
|
||||
self,
|
||||
tool_filter: MCPToolFilter | None = None,
|
||||
) -> list[MCPToolDescriptor]:
|
||||
"""Enumerate tools from the connected MCP server.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tool_filter:
|
||||
Optional include/exclude filter.
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError
|
||||
If the adapter is not connected.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._connected:
|
||||
msg = (
|
||||
f"MCP adapter '{self._config.name}' is not connected. "
|
||||
f"Call connect() first."
|
||||
)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
result = self._transport.call("tools/list", {})
|
||||
raw_tools = result.get("tools", [])
|
||||
|
||||
descriptors: list[MCPToolDescriptor] = []
|
||||
for raw in raw_tools:
|
||||
desc = MCPToolDescriptor(
|
||||
name=raw.get("name", ""),
|
||||
description=raw.get("description", ""),
|
||||
input_schema=raw.get("inputSchema", {}),
|
||||
)
|
||||
descriptors.append(desc)
|
||||
|
||||
if tool_filter:
|
||||
descriptors = self._apply_filter(descriptors, tool_filter)
|
||||
|
||||
self._tools = {d.name: d for d in descriptors}
|
||||
return descriptors
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
) -> MCPToolResult:
|
||||
"""Invoke a discovered MCP tool by name.
|
||||
|
||||
Validates inputs against the tool's JSON Schema before calling
|
||||
the server.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tool_name:
|
||||
Name of the tool as discovered from the server.
|
||||
arguments:
|
||||
Tool input arguments.
|
||||
|
||||
Returns
|
||||
-------
|
||||
MCPToolResult
|
||||
Invocation outcome with success flag, data, and timing.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._connected:
|
||||
msg = (
|
||||
f"MCP adapter '{self._config.name}' is not connected. "
|
||||
f"Call connect() first."
|
||||
)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
descriptor = self._tools.get(tool_name)
|
||||
if descriptor is None:
|
||||
return MCPToolResult(
|
||||
success=False,
|
||||
error=f"Tool '{tool_name}' not found in MCP server "
|
||||
f"'{self._config.name}'",
|
||||
)
|
||||
|
||||
validation_error = self._validate_input(descriptor, arguments)
|
||||
if validation_error:
|
||||
return MCPToolResult(
|
||||
success=False,
|
||||
error=f"Input validation failed for '{tool_name}': "
|
||||
f"{validation_error}",
|
||||
)
|
||||
|
||||
start = time.monotonic()
|
||||
try:
|
||||
result = self._transport.call(
|
||||
"tools/call",
|
||||
{"name": tool_name, "arguments": arguments},
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
elapsed = (time.monotonic() - start) * 1000
|
||||
return MCPToolResult(
|
||||
success=False,
|
||||
error=f"Tool '{tool_name}' timeout: {exc}",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
except Exception as exc:
|
||||
elapsed = (time.monotonic() - start) * 1000
|
||||
return MCPToolResult(
|
||||
success=False,
|
||||
error=f"MCP server error invoking '{tool_name}': {exc}",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
elapsed = (time.monotonic() - start) * 1000
|
||||
|
||||
if result.get("isError"):
|
||||
return MCPToolResult(
|
||||
success=False,
|
||||
error=f"MCP server error: {result.get('error', 'unknown error')}",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
|
||||
return MCPToolResult(
|
||||
success=True,
|
||||
data=result.get("content", result),
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
|
||||
def register_tools(
|
||||
self,
|
||||
registry: Any,
|
||||
namespace: str,
|
||||
tool_filter: MCPToolFilter | None = None,
|
||||
) -> list[str]:
|
||||
"""Discover and register MCP tools into a ToolRegistry.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
registry:
|
||||
A ``ToolRegistry`` instance.
|
||||
namespace:
|
||||
Namespace prefix for registered tool names.
|
||||
tool_filter:
|
||||
Optional include/exclude filter.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]
|
||||
Names of successfully registered tools.
|
||||
"""
|
||||
from cleveragents.domain.models.core.tool import ToolCapability
|
||||
from cleveragents.tool.runtime import ToolSpec
|
||||
|
||||
descriptors = self.discover_tools(tool_filter)
|
||||
registered: list[str] = []
|
||||
|
||||
for desc in descriptors:
|
||||
tool_name = f"{namespace}/{desc.name}"
|
||||
existing = registry.get(tool_name)
|
||||
if existing is not None:
|
||||
registry.remove(tool_name)
|
||||
|
||||
adapter_ref = self
|
||||
|
||||
def _make_handler(tn: str, ar: MCPToolAdapter) -> Any:
|
||||
def handler(**kwargs: Any) -> dict[str, Any]:
|
||||
result = ar.invoke(tn, kwargs)
|
||||
if not result.success:
|
||||
return {"error": result.error}
|
||||
return result.data
|
||||
|
||||
return handler
|
||||
|
||||
inferred = self.infer_capabilities(desc.name)
|
||||
capabilities = ToolCapability(
|
||||
read_only=inferred["read_only"],
|
||||
writes=inferred["writes"],
|
||||
)
|
||||
|
||||
spec = ToolSpec(
|
||||
name=tool_name,
|
||||
description=desc.description or f"MCP tool: {desc.name}",
|
||||
input_schema=desc.input_schema,
|
||||
capabilities=capabilities,
|
||||
handler=_make_handler(desc.name, adapter_ref),
|
||||
)
|
||||
registry.register(spec)
|
||||
registered.append(tool_name)
|
||||
|
||||
return registered
|
||||
|
||||
@staticmethod
|
||||
def infer_capabilities(tool_name: str) -> dict[str, bool]:
|
||||
"""Infer ToolCapability metadata from an MCP tool name.
|
||||
|
||||
Applies heuristics from the specification:
|
||||
- Names containing read/get/list/search/find -> read_only
|
||||
- Names containing write/create/update/delete/set -> writes
|
||||
"""
|
||||
lower = tool_name.lower()
|
||||
read_keywords = {"read", "get", "list", "search", "find"}
|
||||
write_keywords = {"write", "create", "update", "delete", "set"}
|
||||
|
||||
parts = set(lower.replace("-", "_").split("_"))
|
||||
|
||||
is_read = bool(parts & read_keywords)
|
||||
is_write = bool(parts & write_keywords)
|
||||
|
||||
if is_read and not is_write:
|
||||
return {"read_only": True, "writes": False}
|
||||
if is_write:
|
||||
return {"read_only": False, "writes": True}
|
||||
return {"read_only": False, "writes": False}
|
||||
|
||||
@staticmethod
|
||||
def _apply_filter(
|
||||
descriptors: list[MCPToolDescriptor],
|
||||
tool_filter: MCPToolFilter,
|
||||
) -> list[MCPToolDescriptor]:
|
||||
result = descriptors
|
||||
if tool_filter.include:
|
||||
include_set = set(tool_filter.include)
|
||||
result = [d for d in result if d.name in include_set]
|
||||
if tool_filter.exclude:
|
||||
exclude_set = set(tool_filter.exclude)
|
||||
result = [d for d in result if d.name not in exclude_set]
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _validate_input(
|
||||
descriptor: MCPToolDescriptor,
|
||||
arguments: dict[str, Any],
|
||||
) -> str | None:
|
||||
schema = descriptor.input_schema
|
||||
if not schema:
|
||||
return None
|
||||
try:
|
||||
jsonschema.validate(instance=arguments, schema=schema)
|
||||
except jsonschema.ValidationError as exc:
|
||||
return str(exc.message)
|
||||
return None
|
||||
Reference in New Issue
Block a user