Files
cleveragents-core/features/steps/skill_discovery_steps.py
freemo c47e6445d0
CI / quality (pull_request) Successful in 19s
CI / lint (pull_request) Successful in 22s
CI / benchmark-publish (pull_request) Has been skipped
CI / security (pull_request) Successful in 33s
CI / build (pull_request) Successful in 26s
CI / typecheck (pull_request) Successful in 1m0s
CI / integration_tests (pull_request) Successful in 4m56s
CI / unit_tests (pull_request) Successful in 15m11s
CI / docker (pull_request) Successful in 1m33s
CI / benchmark-regression (pull_request) Successful in 20m55s
CI / coverage (pull_request) Successful in 33m42s
CI / lint (push) Successful in 13s
CI / build (push) Successful in 15s
CI / quality (push) Successful in 17s
CI / security (push) Successful in 28s
CI / typecheck (push) Successful in 32s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 3m3s
CI / benchmark-publish (push) Successful in 10m8s
CI / unit_tests (push) Successful in 12m42s
CI / docker (push) Successful in 39s
CI / coverage (push) Has been cancelled
feat(actor): compile hierarchical actor configs to LangGraph
Add ActorCompiler module that translates GRAPH-type ActorConfigSchema
definitions into LangGraph NodeConfig/Edge structures with LSP binding
metadata. Includes subgraph resolution with cross-actor cycle detection,
entry/exit validation, and CompilationMetadata for diagnostics.

New files:
- src/cleveragents/actor/compiler.py: Core compiler with compile_actor()
- features/actor_compiler.feature: 13 Behave scenarios
- features/steps/actor_compiler_steps.py: Step definitions
- robot/actor_compiler.robot: 4 Robot smoke tests
- benchmarks/actor_compiler_bench.py: ASV performance benchmarks
- docs/reference/actor_compiler.md: Compilation pipeline reference

Modified:
- src/cleveragents/actor/__init__.py: Export compiler types
- vulture_whitelist.py: Whitelist new public API

ISSUES CLOSED: #158
2026-02-24 17:57:18 +00:00

574 lines
20 KiB
Python

"""Step definitions for Agent Skills discovery tests."""
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.config_service import ConfigService
from cleveragents.skills.discovery import (
DiscoveredAgentSkill,
build_tool_spec,
discover_agent_skills,
parse_agent_skills_paths,
register_discovered_skills,
scan_directory,
)
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runtime import ToolSpec
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_skill_md(folder: Path, name: str, description: str) -> None:
"""Create a SKILL.md with YAML front-matter in *folder*."""
folder.mkdir(parents=True, exist_ok=True)
content = (
"---\n"
f"name: {name}\n"
f"description: {description}\n"
"---\n"
f"\n# {name}\n\n"
f"{description}\n"
)
(folder / "SKILL.md").write_text(content, encoding="utf-8")
def _noop_handler(**_kwargs: Any) -> dict[str, Any]:
return {"status": "test"}
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the agent skills discovery module is available")
def step_discovery_module_available(context: Context) -> None:
"""Verify imports are accessible."""
assert parse_agent_skills_paths is not None
assert scan_directory is not None
assert discover_agent_skills is not None
# ---------------------------------------------------------------------------
# Config key
# ---------------------------------------------------------------------------
@when('I check the config registry for "{key}"')
def step_check_config_registry(context: Context, key: str) -> None:
context.config_entry = ConfigService.get_entry(key)
@then("the config entry should exist")
def step_config_entry_exists(context: Context) -> None:
assert context.config_entry is not None, "Config entry not found"
@then('the config entry type should be "{type_name}"')
def step_config_entry_type(context: Context, type_name: str) -> None:
entry = context.config_entry
assert entry is not None
assert entry.python_type.__name__ == type_name
@then('the config entry env var should be "{env_var}"')
def step_config_entry_env_var(context: Context, env_var: str) -> None:
entry = context.config_entry
assert entry is not None
assert entry.env_var == env_var
# ---------------------------------------------------------------------------
# Path parsing
# ---------------------------------------------------------------------------
@when('I parse agent skills paths "{raw_paths}"')
def step_parse_paths(context: Context, raw_paths: str) -> None:
context.parsed_paths = parse_agent_skills_paths(raw_paths)
@when("I parse an empty agent skills path string")
def step_parse_empty_paths(context: Context) -> None:
context.parsed_paths = parse_agent_skills_paths("")
@then("the result should contain {count:d} path")
def step_path_count_singular(context: Context, count: int) -> None:
assert len(context.parsed_paths) == count
@then("the result should contain {count:d} paths")
def step_path_count(context: Context, count: int) -> None:
assert len(context.parsed_paths) == count
# ---------------------------------------------------------------------------
# Discovery from directories
# ---------------------------------------------------------------------------
@given("a temporary directory with agent skill folders")
def step_temp_dir_with_skills(context: Context) -> None:
tmpdir = Path(tempfile.mkdtemp())
context.tmpdir = tmpdir
context.add_cleanup(lambda: shutil.rmtree(str(tmpdir), ignore_errors=True))
assert context.table is not None, "Expected table data"
for row in context.table:
folder_name = row["folder_name"]
skill_name = row["skill_name"]
description = row["description"]
if skill_name == "_none_":
# Write SKILL.md without a name field
folder = tmpdir / folder_name
folder.mkdir(parents=True, exist_ok=True)
content = f"---\ndescription: {description}\n---\n\n# {folder_name}\n"
(folder / "SKILL.md").write_text(content, encoding="utf-8")
else:
_create_skill_md(tmpdir / folder_name, skill_name, description)
@given("a temporary directory with some folders missing SKILL.md")
def step_temp_dir_no_skill_md(context: Context) -> None:
tmpdir = Path(tempfile.mkdtemp())
context.tmpdir = tmpdir
context.add_cleanup(lambda: shutil.rmtree(str(tmpdir), ignore_errors=True))
# Create folder without SKILL.md
(tmpdir / "no-skill-folder").mkdir()
# Create a plain file (not a folder)
(tmpdir / "plain-file.txt").write_text("not a skill", encoding="utf-8")
@given("a temporary directory with a SKILL.md that has no front-matter")
def step_temp_dir_no_front_matter(context: Context) -> None:
tmpdir = Path(tempfile.mkdtemp())
context.tmpdir = tmpdir
context.add_cleanup(lambda: shutil.rmtree(str(tmpdir), ignore_errors=True))
folder = tmpdir / "bad-skill"
folder.mkdir()
(folder / "SKILL.md").write_text("# No front-matter here\n", encoding="utf-8")
@when("I scan the directory for agent skills")
def step_scan_directory(context: Context) -> None:
context.discovered = scan_directory(context.tmpdir)
@then("I should discover {count:d} agent skill")
def step_discover_count_singular(context: Context, count: int) -> None:
assert len(context.discovered) == count, (
f"Expected {count}, got {len(context.discovered)}"
)
@then("I should discover {count:d} agent skills")
def step_discover_count(context: Context, count: int) -> None:
assert len(context.discovered) == count, (
f"Expected {count}, got {len(context.discovered)}"
)
@then('the discovered skill "{name}" should have description "{description}"')
def step_discovered_skill_description(
context: Context, name: str, description: str
) -> None:
skill = next((s for s in context.discovered if s.name == name), None)
assert skill is not None, f"Skill '{name}' not found"
assert skill.description == description
@then("the discovered skill should use folder name as name")
def step_discovered_skill_folder_name(context: Context) -> None:
assert len(context.discovered) == 1
skill = context.discovered[0]
# When no name in front-matter, it falls back to folder name
assert skill.name == "fallback-tool"
# ---------------------------------------------------------------------------
# Non-existent directory
# ---------------------------------------------------------------------------
@when("I run discovery on a non-existent directory")
def step_discover_nonexistent(context: Context) -> None:
context.discovery_result = discover_agent_skills(
[Path("/nonexistent/path/that/does/not/exist")]
)
@then("the discovery result should have {count:d} discovered skills")
def step_discovery_result_count(context: Context, count: int) -> None:
assert len(context.discovery_result.discovered) == count
@then('the discovery result should have errors mentioning "{text}"')
def step_discovery_result_errors(context: Context, text: str) -> None:
errors_joined = " ".join(context.discovery_result.errors)
assert text in errors_joined, f"Expected '{text}' in errors, got: {errors_joined}"
# ---------------------------------------------------------------------------
# ToolSpec building
# ---------------------------------------------------------------------------
@given('a discovered agent skill named "{name}" at "{path}"')
def step_given_discovered_skill(context: Context, name: str, path: str) -> None:
context.discovered_skill = DiscoveredAgentSkill(
name=name,
description=f"Test skill {name}",
path=path,
)
@when("I build a ToolSpec from the discovered skill")
def step_build_tool_spec(context: Context) -> None:
context.tool_spec = build_tool_spec(context.discovered_skill)
@then('the ToolSpec name should be "{expected}"')
def step_tool_spec_name(context: Context, expected: str) -> None:
assert context.tool_spec.name == expected
@then('the ToolSpec source should be "{expected}"')
def step_tool_spec_source(context: Context, expected: str) -> None:
assert context.tool_spec.source == expected
@then('the ToolSpec source_metadata should contain path "{expected}"')
def step_tool_spec_source_metadata_path(context: Context, expected: str) -> None:
assert context.tool_spec.source_metadata.get("path") == expected
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
@given("a ToolRegistry with no existing tools")
def step_empty_tool_registry(context: Context) -> None:
context.tool_registry = ToolRegistry()
@given("a list of {count:d} discovered agent skills")
def step_discovered_skills_list(context: Context, count: int) -> None:
context.discovered_skills = [
DiscoveredAgentSkill(
name=f"tool-{i}",
description=f"Test tool {i}",
path=f"/tmp/skills/tool-{i}",
)
for i in range(count)
]
@given('a ToolRegistry with an existing tool "{name}"')
def step_registry_with_tool(context: Context, name: str) -> None:
context.tool_registry = ToolRegistry()
existing_spec = ToolSpec(
name=name,
description="Existing tool",
handler=_noop_handler,
)
context.tool_registry.register(existing_spec)
@given('a discovered agent skill named "{name}"')
def step_single_discovered_skill(context: Context, name: str) -> None:
context.discovered_skills = [
DiscoveredAgentSkill(
name=name,
description=f"Discovered {name}",
path=f"/tmp/skills/{name}",
)
]
@when('I register discovered skills with "{strategy}" conflict strategy')
def step_register_discovered(context: Context, strategy: str) -> None:
try:
registered, conflicts = register_discovered_skills(
context.discovered_skills,
context.tool_registry,
on_conflict=strategy,
)
context.registered_tools = registered
context.registration_conflicts = conflicts
context.registration_error = None
except ValueError as exc:
context.registration_error = exc
context.registered_tools = []
context.registration_conflicts = []
@then("{count:d} tools should be registered in the ToolRegistry")
def step_registered_tool_count(context: Context, count: int) -> None:
assert len(context.registered_tools) == count, (
f"Expected {count}, got {len(context.registered_tools)}"
)
@then("{count:d} tool should be registered in the ToolRegistry")
def step_registered_tool_count_singular(context: Context, count: int) -> None:
assert len(context.registered_tools) == count, (
f"Expected {count}, got {len(context.registered_tools)}"
)
@then("{count:d} conflicts should be reported")
def step_conflict_count(context: Context, count: int) -> None:
assert len(context.registration_conflicts) == count
@then('{count:d} conflict should be reported with tool name "{name}"')
def step_conflict_with_name(context: Context, count: int, name: str) -> None:
assert len(context.registration_conflicts) == count
if count > 0:
assert context.registration_conflicts[0].tool_name == name
@then('a discovery ValueError should be raised mentioning "{text}"')
def step_value_error_raised_discovery(context: Context, text: str) -> None:
assert context.registration_error is not None, "Expected ValueError not raised"
assert text in str(context.registration_error), (
f"Expected '{text}' in error message, got: {context.registration_error}"
)
@then('the tool "{name}" should have source "{expected_source}"')
def step_tool_has_source(context: Context, name: str, expected_source: str) -> None:
spec = context.tool_registry.get(name)
assert spec is not None, f"Tool '{name}' not found in registry"
assert spec.source == expected_source
# ---------------------------------------------------------------------------
# Refresh
# ---------------------------------------------------------------------------
@given("a SkillRegistryService with a ToolRegistry")
def step_service_with_registry(context: Context) -> None:
from cleveragents.application.services.skill_registry_service import (
SkillRegistryService,
)
context.tool_registry = ToolRegistry()
# Create a mock skill_repo since we only need discovery features
mock_repo = MagicMock()
context.skill_registry_service = SkillRegistryService(
skill_repo=mock_repo,
tool_registry=context.tool_registry,
)
@given("agent skills paths pointing to a directory with {count:d} skills")
def step_paths_with_skills(context: Context, count: int) -> None:
tmpdir = Path(tempfile.mkdtemp())
context.tmpdir = tmpdir
context.add_cleanup(lambda: shutil.rmtree(str(tmpdir), ignore_errors=True))
for i in range(count):
_create_skill_md(tmpdir / f"skill-{i}", f"skill-{i}", f"Skill {i}")
context.agent_skills_raw_paths = str(tmpdir)
@when("I call discover_and_register")
def step_call_discover_and_register(context: Context) -> None:
context.discovery_result = context.skill_registry_service.discover_and_register(
context.agent_skills_raw_paths
)
@then("{count:d} agent skills should be registered")
def step_agent_skills_registered(context: Context, count: int) -> None:
registered = context.skill_registry_service.registered_agent_tools
assert len(registered) == count, f"Expected {count}, got {len(registered)}"
@when("the directory now has {count:d} skills and I call refresh_agent_skills")
def step_add_more_and_refresh(context: Context, count: int) -> None:
# Add more skills to the directory
existing = list(context.tmpdir.iterdir())
existing_count = len([d for d in existing if d.is_dir()])
for i in range(existing_count, count):
_create_skill_md(context.tmpdir / f"skill-{i}", f"skill-{i}", f"Skill {i}")
context.discovery_result = context.skill_registry_service.refresh_agent_skills(
context.agent_skills_raw_paths
)
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
@when("I register an empty discovered skills list")
def step_register_empty_list(context: Context) -> None:
registered, conflicts = register_discovered_skills(
[],
context.tool_registry,
)
context.registered_tools = registered
context.registration_conflicts = conflicts
@then(
"the registration should return {spec_count:d} specs and {conflict_count:d} conflicts"
)
def step_registration_returns(
context: Context, spec_count: int, conflict_count: int
) -> None:
assert len(context.registered_tools) == spec_count
assert len(context.registration_conflicts) == conflict_count
@when("I scan a non-directory path for agent skills")
def step_scan_non_dir(context: Context) -> None:
tmpfile = Path(tempfile.mktemp(suffix=".txt"))
tmpfile.write_text("not a directory", encoding="utf-8")
context.add_cleanup(lambda: tmpfile.unlink(missing_ok=True))
context.discovered = scan_directory(tmpfile)
@when("I run discovery on a path that is a file")
def step_discover_file_path(context: Context) -> None:
tmpfile = Path(tempfile.mktemp(suffix=".txt"))
tmpfile.write_text("not a directory", encoding="utf-8")
context.add_cleanup(lambda: tmpfile.unlink(missing_ok=True))
context.discovery_result = discover_agent_skills([tmpfile])
@given("a temporary directory with a SKILL.md that has invalid YAML")
def step_temp_dir_invalid_yaml(context: Context) -> None:
tmpdir = Path(tempfile.mkdtemp())
context.tmpdir = tmpdir
context.add_cleanup(lambda: shutil.rmtree(str(tmpdir), ignore_errors=True))
folder = tmpdir / "bad-yaml-skill"
folder.mkdir()
(folder / "SKILL.md").write_text(
"---\ninvalid: [yaml: {broken\n---\n",
encoding="utf-8",
)
@given("a temporary directory with a SKILL.md that has empty front-matter")
def step_temp_dir_empty_fm(context: Context) -> None:
tmpdir = Path(tempfile.mkdtemp())
context.tmpdir = tmpdir
context.add_cleanup(lambda: shutil.rmtree(str(tmpdir), ignore_errors=True))
folder = tmpdir / "empty-fm-skill"
folder.mkdir()
(folder / "SKILL.md").write_text(
"---\n\n---\n\n# Empty FM\n",
encoding="utf-8",
)
@given("a temporary directory with a SKILL.md that has non-dict YAML")
def step_temp_dir_non_dict_yaml(context: Context) -> None:
tmpdir = Path(tempfile.mkdtemp())
context.tmpdir = tmpdir
context.add_cleanup(lambda: shutil.rmtree(str(tmpdir), ignore_errors=True))
folder = tmpdir / "list-yaml-skill"
folder.mkdir()
(folder / "SKILL.md").write_text(
"---\n- item1\n- item2\n---\n\n# List YAML\n",
encoding="utf-8",
)
@given("a temporary directory with a SKILL.md that has numeric description")
def step_temp_dir_numeric_desc(context: Context) -> None:
tmpdir = Path(tempfile.mkdtemp())
context.tmpdir = tmpdir
context.add_cleanup(lambda: shutil.rmtree(str(tmpdir), ignore_errors=True))
folder = tmpdir / "numeric-desc-skill"
folder.mkdir()
(folder / "SKILL.md").write_text(
"---\nname: numeric-skill\ndescription: 42\n---\n\n# Numeric\n",
encoding="utf-8",
)
@given("a temporary directory with a SKILL.md that has empty description")
def step_temp_dir_empty_desc(context: Context) -> None:
tmpdir = Path(tempfile.mkdtemp())
context.tmpdir = tmpdir
context.add_cleanup(lambda: shutil.rmtree(str(tmpdir), ignore_errors=True))
folder = tmpdir / "empty-desc-skill"
folder.mkdir()
(folder / "SKILL.md").write_text(
'---\nname: empty-desc\ndescription: ""\n---\n\n# Empty Desc\n',
encoding="utf-8",
)
@then('the first discovered skill description should contain "{text}"')
def step_first_skill_desc_contains(context: Context, text: str) -> None:
assert len(context.discovered) > 0, "No discovered skills"
desc = context.discovered[0].description
assert text in desc, f"Expected '{text}' in description, got: {desc}"
@when("I call the noop handler")
def step_call_noop_handler(context: Context) -> None:
from cleveragents.skills.discovery import _noop_handler
context.noop_result = _noop_handler()
@then('the noop handler should return status "{expected}"')
def step_noop_result(context: Context, expected: str) -> None:
assert context.noop_result["status"] == expected
# ---------------------------------------------------------------------------
# Source metadata
# ---------------------------------------------------------------------------
@given('a registered skill "{name}" with an agent_skill source')
def step_registered_skill_with_agent_source(context: Context, name: str) -> None:
from cleveragents.application.services.skill_service import SkillService
from cleveragents.skills.schema import (
SkillAgentFolderSchema,
SkillConfigSchema,
)
context.skill_service = SkillService()
config = SkillConfigSchema(
name=name,
description="Test skill with agent_skill source",
agent_skill_folders=[
SkillAgentFolderSchema(path="/tmp/agent-skill-folder", name="test-agent")
],
)
context.skill_service.add_skill(config, config_path="/tmp/test.yaml")
context.skill_name = name
@when('I resolve tools for "{name}"')
def step_resolve_tools(context: Context, name: str) -> None:
skill, entries = context.skill_service.resolve_tools(name)
context.resolved_skill = skill
context.resolved_entries = entries
@then("the resolved tools should include agent_skill source entries")
def step_resolved_tools_agent_source(context: Context) -> None:
agent_entries = [e for e in context.resolved_entries if "agent_skill:" in e.name]
assert len(agent_entries) > 0, "Expected at least one agent_skill source entry"