forked from cleveragents/cleveragents-core
c47e6445d0
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
507 lines
18 KiB
Python
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]}")
|