Files
cleveragents-core/features/steps/actor_hierarchy_steps.py
hurui200320 8953449dc2
CI / lint (pull_request) Successful in 30s
CI / typecheck (pull_request) Successful in 1m15s
CI / security (pull_request) Successful in 1m18s
CI / quality (pull_request) Successful in 30s
CI / build (pull_request) Successful in 37s
CI / helm (pull_request) Successful in 29s
CI / push-validation (pull_request) Successful in 26s
CI / e2e_tests (pull_request) Successful in 3m58s
CI / integration_tests (pull_request) Successful in 6m49s
CI / unit_tests (pull_request) Successful in 8m39s
CI / docker (pull_request) Successful in 1m23s
CI / coverage (pull_request) Successful in 13m32s
CI / benchmark-regression (push) Failing after 0s
CI / benchmark-publish (push) Failing after 0s
CI / status-check (pull_request) Successful in 1s
CI / lint (push) Successful in 25s
CI / typecheck (push) Successful in 1m1s
CI / quality (push) Successful in 55s
CI / security (push) Successful in 1m9s
CI / build (push) Successful in 24s
CI / helm (push) Successful in 30s
CI / push-validation (push) Successful in 20s
CI / e2e_tests (push) Successful in 5m13s
CI / integration_tests (push) Successful in 7m17s
CI / unit_tests (push) Successful in 8m50s
CI / docker (push) Successful in 1m30s
CI / coverage (push) Successful in 11m57s
CI / status-check (push) Successful in 2s
fix(actor): validate v3 YAML via ActorConfigSchema in agents actor add CLI
- schema.py: provider field changed to Optional[str] with model validator
  validate_provider_required_for_llm_graph() that requires it only for LLM
  and GRAPH actor types; TOOL actors do not require provider
- schema.py: is_v3_yaml() uses version_str == "3" or version_str.startswith("3.")
  to avoid false positives from "30" or "300" version strings
- schema.py: tool namespace validation uses strict 2-part split to reject
  empty namespace or empty name (e.g. "/tool", "ns/", "a/b/c")
- cli/commands/actor.py: schema_version extraction uses raw_version pattern
  (no # type: ignore[assignment]) for clean static typing
- actor/__init__.py: is_v3_yaml removed from __all__ and _LAZY_IMPORTS
  since it is a module-private helper, not a public API
- robot/actor_add_v3_schema_validation.robot: YAML fixtures for 'Reject v3
  LLM Actor Without Model Field' and 'Reject v3 TOOL Actor Without Tools
  Field' now include required provider field (and model for TOOL fixture)
- robot/helper_actor_add_v3_schema_validation.py: except clauses unified to
  catch (subprocess.TimeoutExpired, FileNotFoundError) in both add_actor()
  and update_actor() functions
- features/actor_add_v3_schema_validation.feature: 'Update a v3 actor with
  valid YAML succeeds' scenario now includes 'And the actor should be
  validated via ActorConfigSchema'; error assertions tightened to exact
  messages (e.g. "Input should be 'llm', 'tool' or 'graph'", "Node ID must
  be alphanumeric", "must be namespaced")
- features/steps/actor_add_v3_schema_validation_steps.py: step_run_actor_update
  now spies on ActorConfigSchema.model_validate; failure paths assert
  isinstance(result.exception, SystemExit)

ISSUES CLOSED: #5869
2026-04-17 16:28:45 +08:00

639 lines
22 KiB
Python

"""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",
"provider": "openai",
"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",
"provider": "openai",
"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",
"provider": "openai",
"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}'"
)