Files
cleveragents-core/features/steps/skill_flatten_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

507 lines
18 KiB
Python

"""Step definitions for skill flattening and capability summary tests.
All step text patterns are prefixed with ``flatten`` to avoid collisions
with existing step definitions in the Behave step registry.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.domain.models.core.skill import (
ResolvedToolEntry,
Skill,
SkillCapabilitySummary,
SkillInclude,
SkillInlineTool,
SkillResolver,
)
from cleveragents.domain.models.core.tool import ToolCapability, ToolSource
from cleveragents.skills.protocol import SkillDefinition, SkillMetadata
from cleveragents.skills.registry import SkillRegistry
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ensure_flatten_registry(context: Context) -> dict[str, Skill]:
"""Return (and lazily create) the flatten skill lookup dict."""
if not hasattr(context, "flatten_registry"):
context.flatten_registry = {}
return context.flatten_registry # type: ignore[no-any-return]
def _make_skill_definition(skill: Skill) -> SkillDefinition:
"""Build a ``SkillDefinition`` wrapping *skill* with default metadata."""
resolver = SkillResolver()
resolved = resolver.resolve_tools(skill, {})
meta = SkillMetadata.from_skill(skill, resolved=resolved)
return SkillDefinition(
skill=skill,
resolved_tools=resolved,
metadata=meta,
)
# ---------------------------------------------------------------------------
# Given steps (all prefixed with "flatten")
# ---------------------------------------------------------------------------
@given('a flatten registry skill "{name}" with tool refs "{refs}"')
def flatten_given_skill_refs(context: Context, name: str, refs: str) -> None:
"""Register a skill with tool refs in the flatten lookup."""
registry = _ensure_flatten_registry(context)
ref_list = [r.strip() for r in refs.split(",") if r.strip()]
registry[name] = Skill(
name=name,
description=f"Skill {name}",
tool_refs=ref_list,
)
@given('a flatten registry skill "{name}" including "{inc}" with refs "{refs}"')
def flatten_given_skill_include_refs(
context: Context, name: str, inc: str, refs: str
) -> None:
"""Register a skill with one include and own tool refs."""
registry = _ensure_flatten_registry(context)
ref_list = [r.strip() for r in refs.split(",") if r.strip()]
includes = [SkillInclude(name=i.strip()) for i in inc.split(",") if i.strip()]
registry[name] = Skill(
name=name,
description=f"Skill {name}",
tool_refs=ref_list,
includes=includes,
)
@given('a flatten registry skill "{name}" with {count:d} inline tools')
def flatten_given_skill_inline(context: Context, name: str, count: int) -> None:
"""Register a skill with *count* inline tools."""
registry = _ensure_flatten_registry(context)
inlines = [
SkillInlineTool(
description=f"Inline tool {i}",
source=ToolSource.CUSTOM,
code=f"return {i}",
)
for i in range(count)
]
registry[name] = Skill(
name=name,
description=f"Skill {name}",
anonymous_tools=inlines,
)
@given('a flatten registry skill "{name}" including "{inc}" with no refs')
def flatten_given_skill_include_no_refs(context: Context, name: str, inc: str) -> None:
"""Register a skill that includes another with no own refs."""
registry = _ensure_flatten_registry(context)
includes = [SkillInclude(name=i.strip()) for i in inc.split(",") if i.strip()]
registry[name] = Skill(
name=name,
description=f"Skill {name}",
includes=includes,
)
@given(
'a flatten registry skill "{name}" including "{inc}"'
" with overrides timeout {timeout:d}"
)
def flatten_given_skill_include_overrides(
context: Context, name: str, inc: str, timeout: int
) -> None:
"""Register a skill that includes another with per-include overrides."""
registry = _ensure_flatten_registry(context)
includes = [
SkillInclude(
name=inc.strip(),
overrides={"timeout": timeout},
)
]
registry[name] = Skill(
name=name,
description=f"Skill {name}",
includes=includes,
)
@given(
'a flatten registry skill "{name}" including "{inc}"'
' with overrides on non-overridable field "{field}"'
)
def flatten_given_skill_include_bad_override(
context: Context, name: str, inc: str, field: str
) -> None:
"""Register a skill whose include overrides a non-overridable field."""
registry = _ensure_flatten_registry(context)
includes = [
SkillInclude(
name=inc.strip(),
overrides={field: "forbidden-value"},
)
]
registry[name] = Skill(
name=name,
description=f"Skill {name}",
includes=includes,
)
@given('a flatten registry skill "{name}" with mixed capability inline tools')
def flatten_given_skill_capability_mix(context: Context, name: str) -> None:
"""Register a skill with inline tools having diverse capabilities."""
registry = _ensure_flatten_registry(context)
inlines = [
SkillInlineTool(
description="Read-only tool",
source=ToolSource.CUSTOM,
code="pass",
capability=ToolCapability(read_only=True),
),
SkillInlineTool(
description="Write tool with side effects",
source=ToolSource.CUSTOM,
code="pass",
capability=ToolCapability(
writes=True,
side_effects=["filesystem"],
),
),
SkillInlineTool(
description="Checkpointable tool",
source=ToolSource.CUSTOM,
code="pass",
capability=ToolCapability(checkpointable=True),
),
]
registry[name] = Skill(
name=name,
description=f"Skill {name}",
anonymous_tools=inlines,
)
@given('a flatten skill registry with skill "{name}" having {count:d} refs')
def flatten_given_full_registry(context: Context, name: str, count: int) -> None:
"""Create a full SkillRegistry and register a skill with *count* refs."""
refs = [f"local/tool-{i}" for i in range(count)]
skill = Skill(name=name, description=f"Skill {name}", tool_refs=refs)
defn = _make_skill_definition(skill)
reg = SkillRegistry()
reg.register(defn)
context.flatten_skill_registry = reg
@given("an empty flatten skill registry")
def flatten_given_empty_registry(context: Context) -> None:
"""Create an empty SkillRegistry."""
context.flatten_skill_registry = SkillRegistry()
@given("the flatten registry skills are registered in the skill registry")
def flatten_register_all_in_skill_registry(context: Context) -> None:
"""Register all skills from the flatten lookup into a SkillRegistry.
Uses minimal metadata (no resolution) because the skill graph may
contain cycles that would fail resolution.
"""
registry = _ensure_flatten_registry(context)
sr = SkillRegistry()
for skill in registry.values():
meta = SkillMetadata(
name=skill.name,
description=skill.description,
)
defn = SkillDefinition(
skill=skill,
resolved_tools=[],
metadata=meta,
)
sr.register(defn)
context.flatten_skill_registry = sr
@given(
'a flatten registry skill "{name}" including "{inc}"'
" with overrides priority {prio:d} and extra key"
)
def flatten_given_skill_include_merge_overrides(
context: Context, name: str, inc: str, prio: int
) -> None:
"""Register a skill whose include overrides have multiple keys."""
registry = _ensure_flatten_registry(context)
includes = [
SkillInclude(
name=inc.strip(),
overrides={"priority": prio, "extra": "yes"},
)
]
registry[name] = Skill(
name=name,
description=f"Skill {name}",
includes=includes,
)
@given(
'a flatten registry skill "{name}" with tool refs "{refs}"'
" and overrides priority {prio:d}"
)
def flatten_given_skill_refs_with_overrides(
context: Context, name: str, refs: str, prio: int
) -> None:
"""Register a skill with tool refs that have skill-level overrides."""
registry = _ensure_flatten_registry(context)
ref_list = [r.strip() for r in refs.split(",") if r.strip()]
overrides: dict[str, dict[str, Any]] = {}
for ref in ref_list:
overrides[ref] = {"priority": prio}
registry[name] = Skill(
name=name,
description=f"Skill {name}",
tool_refs=ref_list,
overrides=overrides,
)
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when('I flatten the skill "{name}"')
def flatten_when_resolve(context: Context, name: str) -> None:
"""Resolve/flatten a skill from the flatten registry."""
registry = _ensure_flatten_registry(context)
skill = registry[name]
resolver = SkillResolver()
context.flatten_result = resolver.resolve_tools(skill, registry)
context.flatten_error = None
@when('I try to flatten the skill "{name}"')
def flatten_when_try_resolve(context: Context, name: str) -> None:
"""Try to flatten; capture errors."""
registry = _ensure_flatten_registry(context)
skill = registry[name]
resolver = SkillResolver()
context.flatten_result = None
context.flatten_error = None
try:
context.flatten_result = resolver.resolve_tools(skill, registry)
except ValueError as exc:
context.flatten_error = exc
@when('I flatten the skill "{name}" and compute summary')
def flatten_when_resolve_summary(context: Context, name: str) -> None:
"""Flatten a skill and compute the capability summary."""
registry = _ensure_flatten_registry(context)
skill = registry[name]
resolver = SkillResolver()
entries = resolver.resolve_tools(skill, registry)
context.flatten_result = entries
context.flatten_summary = resolver.compute_capability_summary(skill, entries)
@when('I call the flatten tools method for "{name}"')
def flatten_when_tools_method(context: Context, name: str) -> None:
"""Call SkillRegistry.tools() on a registered skill."""
sr: SkillRegistry = context.flatten_skill_registry
entries, summary = sr.tools(name)
context.flatten_tools_entries = entries
context.flatten_tools_summary = summary
@when('I call flatten validate_plan with skills "{skills_csv}"')
def flatten_when_validate_plan(context: Context, skills_csv: str) -> None:
"""Call SkillRegistry.validate_plan()."""
sr: SkillRegistry = context.flatten_skill_registry
skill_list = [s.strip() for s in skills_csv.split(",") if s.strip()]
context.flatten_validation_errors = sr.validate_plan({"skills": skill_list})
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then("the flatten result should have {count:d} entries")
def flatten_then_count(context: Context, count: int) -> None:
"""Check flattened entry count."""
actual = len(context.flatten_result)
assert actual == count, (
f"Expected {count} entries, got {actual}: "
f"{[e.name for e in context.flatten_result]}"
)
@then('the flatten entry at index {idx:d} should be "{expected}"')
def flatten_then_entry_at(context: Context, idx: int, expected: str) -> None:
"""Check entry name at a given index."""
entries: list[ResolvedToolEntry] = context.flatten_result
assert idx < len(entries), f"Index {idx} out of range ({len(entries)} entries)"
actual = entries[idx].name
assert actual == expected, (
f"Expected entry at {idx} to be '{expected}', got '{actual}'"
)
@then("the flatten entry at index {idx:d} should be marked inline")
def flatten_then_entry_inline(context: Context, idx: int) -> None:
"""Check that entry at index is inline."""
entries: list[ResolvedToolEntry] = context.flatten_result
assert entries[idx].is_inline is True, (
f"Expected inline at index {idx}, got is_inline=False"
)
@then("a flatten cycle error should be raised")
def flatten_then_cycle_error(context: Context) -> None:
"""Verify a cycle error was raised."""
assert context.flatten_error is not None, "Expected cycle error"
assert "ycle" in str(context.flatten_error), (
f"Expected cycle error, got: {context.flatten_error}"
)
@then('the flatten error should mention "{text}"')
def flatten_then_error_mention(context: Context, text: str) -> None:
"""Check the error message contains text."""
msg = str(context.flatten_error)
assert text in msg, f"Expected '{text}' in error: {msg}"
@then('the flatten entry "{name}" should have override "{key}" equal to {value:d}')
def flatten_then_override_int(
context: Context, name: str, key: str, value: int
) -> None:
"""Check that a resolved entry has a specific int override."""
entries: list[ResolvedToolEntry] = context.flatten_result
for entry in entries:
if entry.name == name:
assert key in entry.overrides, (
f"Override '{key}' not found on '{name}': {entry.overrides}"
)
assert entry.overrides[key] == value, (
f"Expected override '{key}'={value}, got {entry.overrides[key]}"
)
return
raise AssertionError(f"Entry '{name}' not found: {[e.name for e in entries]}")
@then('the flatten entry "{name}" should have override "{key}" equal to "{value}"')
def flatten_then_override_str(
context: Context, name: str, key: str, value: str
) -> None:
"""Check that a resolved entry has a specific string override."""
entries: list[ResolvedToolEntry] = context.flatten_result
for entry in entries:
if entry.name == name:
assert key in entry.overrides, (
f"Override '{key}' not found on '{name}': {entry.overrides}"
)
assert entry.overrides[key] == value, (
f"Expected override '{key}'='{value}', got {entry.overrides[key]}"
)
return
raise AssertionError(f"Entry '{name}' not found: {[e.name for e in entries]}")
@then("a flatten override error should be raised")
def flatten_then_override_error(context: Context) -> None:
"""Verify a non-overridable field error was raised."""
assert context.flatten_error is not None, (
"Expected override error but no error was raised"
)
@then("the flatten summary total_tools should be {expected:d}")
def flatten_then_summary_total(context: Context, expected: int) -> None:
"""Check summary total_tools."""
summary: SkillCapabilitySummary = context.flatten_summary
assert summary.total_tools == expected, (
f"Expected total_tools={expected}, got {summary.total_tools}"
)
@then("the flatten summary read_only_tools should be {expected:d}")
def flatten_then_summary_readonly(context: Context, expected: int) -> None:
"""Check summary read_only_tools."""
summary: SkillCapabilitySummary = context.flatten_summary
assert summary.read_only_tools == expected
@then("the flatten summary write_tools should be {expected:d}")
def flatten_then_summary_writes(context: Context, expected: int) -> None:
"""Check summary write_tools."""
summary: SkillCapabilitySummary = context.flatten_summary
assert summary.write_tools == expected
@then("the flatten summary has_side_effects should be true")
def flatten_then_summary_side_effects(context: Context) -> None:
"""Check summary has_side_effects."""
summary: SkillCapabilitySummary = context.flatten_summary
assert summary.has_side_effects is True
@then("the flatten summary checkpointable_tools should be {expected:d}")
def flatten_then_summary_checkpoint(context: Context, expected: int) -> None:
"""Check summary checkpointable_tools."""
summary: SkillCapabilitySummary = context.flatten_summary
assert summary.checkpointable_tools == expected
@then("the flatten tools result should contain {count:d} entries")
def flatten_then_tools_count(context: Context, count: int) -> None:
"""Check tools() returned correct entry count."""
actual = len(context.flatten_tools_entries)
assert actual == count, f"Expected {count} entries, got {actual}"
@then("the flatten tools result should include a capability summary")
def flatten_then_tools_summary(context: Context) -> None:
"""Check tools() returned a SkillCapabilitySummary."""
summary = context.flatten_tools_summary
assert isinstance(summary, SkillCapabilitySummary), (
f"Expected SkillCapabilitySummary, got {type(summary)}"
)
@then("the flatten validation result should have {count:d} errors")
def flatten_then_validation_count(context: Context, count: int) -> None:
"""Check validate_plan error count."""
actual = len(context.flatten_validation_errors)
assert actual == count, (
f"Expected {count} errors, got {actual}: {context.flatten_validation_errors}"
)
@then('the flatten validation error should mention "{text}"')
def flatten_then_validation_mention(context: Context, text: str) -> None:
"""Check that at least one validation error mentions text."""
errors: list[str] = context.flatten_validation_errors
assert any(text in e for e in errors), f"Expected '{text}' in errors: {errors}"
@then('the flatten entry "{name}" source_skill should be "{expected}"')
def flatten_then_entry_source(context: Context, name: str, expected: str) -> None:
"""Check which skill contributed a tool entry."""
entries: list[ResolvedToolEntry] = context.flatten_result
for entry in entries:
if entry.name == name:
assert entry.source_skill == expected, (
f"Expected source_skill='{expected}', got '{entry.source_skill}'"
)
return
raise AssertionError(f"Entry '{name}' not found: {[e.name for e in entries]}")