"""Robot Framework helper for skill resolution smoke tests. Provides a CLI-style interface for Robot to invoke skill resolution logic and validate resolver output ordering, de-duplication, and inline tool handling. Usage: python robot/helper_skill_resolution.py resolve-flat python robot/helper_skill_resolution.py resolve-dedup python robot/helper_skill_resolution.py resolve-inline python robot/helper_skill_resolution.py resolve-cycle """ from __future__ import annotations import json import sys from pathlib import Path # Ensure the src directory is on the import path. _SRC = str(Path(__file__).resolve().parents[1] / "src") if _SRC not in sys.path: sys.path.insert(0, _SRC) from cleveragents.domain.models.core.skill import ( # noqa: E402 Skill, SkillInclude, SkillInlineTool, SkillMcpSource, SkillResolver, ) from cleveragents.domain.models.core.tool import ToolSource # noqa: E402 def _resolve_flat() -> int: """Resolve a skill with flat includes and verify output ordering.""" base = Skill( name="local/base-tools", description="Base tools", tool_refs=["local/edit-file", "local/read-file"], ) root = Skill( name="local/code-tools", description="Code editing tools", tool_refs=["local/format"], includes=[SkillInclude(name="local/base-tools")], ) registry = {"local/base-tools": base, "local/code-tools": root} resolver = SkillResolver() resolved = resolver.resolve_tools(root, registry) names = [e.name for e in resolved] # Include tools should come first (depth-first), then own refs expected = ["local/edit-file", "local/read-file", "local/format"] if names != expected: print(f"skill-resolve-fail: expected {expected}, got {names}") return 1 print(f"skill-resolve-flat-ok: {json.dumps(names)}") return 0 def _resolve_dedup() -> int: """Resolve overlapping includes and verify de-duplication.""" skill_a = Skill( name="local/a", description="Skill A", tool_refs=["local/shared-tool", "local/a-only"], ) skill_b = Skill( name="local/b", description="Skill B", tool_refs=["local/shared-tool", "local/b-only"], ) root = Skill( name="local/top", description="Top skill", includes=[ SkillInclude(name="local/a"), SkillInclude(name="local/b"), ], ) registry = { "local/a": skill_a, "local/b": skill_b, "local/top": root, } resolver = SkillResolver() resolved = resolver.resolve_tools(root, registry) names = [e.name for e in resolved] # shared-tool appears once (position from first occurrence in a), # but entry metadata is from b (last-wins) if names.count("local/shared-tool") != 1: print(f"skill-resolve-fail: shared-tool should appear once, got {names}") return 1 # The shared-tool entry should come from skill b (last-wins) shared_entry = next(e for e in resolved if e.name == "local/shared-tool") if shared_entry.source_skill != "local/b": print( f"skill-resolve-fail: shared-tool source should be local/b, " f"got {shared_entry.source_skill}" ) return 1 print(f"skill-resolve-dedup-ok: {json.dumps(names)}") return 0 def _resolve_inline() -> int: """Resolve a skill with inline tools and verify keying.""" root = Skill( name="local/inline-skill", description="Skill with inline tools", tool_refs=["local/ref-tool"], anonymous_tools=[ SkillInlineTool( description="First inline", source=ToolSource.CUSTOM, code="return 1", timeout=300, ), SkillInlineTool( description="Second inline", source=ToolSource.CUSTOM, code="return 2", timeout=300, ), ], mcp_servers=[ SkillMcpSource( server="npx @mcp/test-server", tools=["mcp-read"], ), ], ) resolver = SkillResolver() resolved = resolver.resolve_tools(root, {}) names = [e.name for e in resolved] expected = [ "local/ref-tool", "local/inline-skill/_anon_0", "local/inline-skill/_anon_1", "mcp:npx @mcp/test-server/mcp-read", ] if names != expected: print(f"skill-resolve-fail: expected {expected}, got {names}") return 1 # Check that inline entries are marked correctly anon_entries = [e for e in resolved if e.is_inline] if len(anon_entries) != 2: print(f"skill-resolve-fail: expected 2 inline entries, got {len(anon_entries)}") return 1 print(f"skill-resolve-inline-ok: {json.dumps(names)}") return 0 def _resolve_cycle() -> int: """Attempt cyclic resolution and verify error.""" skill_a = Skill( name="local/cycle-a", description="Cycle A", includes=[SkillInclude(name="local/cycle-b")], ) skill_b = Skill( name="local/cycle-b", description="Cycle B", includes=[SkillInclude(name="local/cycle-a")], ) registry = {"local/cycle-a": skill_a, "local/cycle-b": skill_b} resolver = SkillResolver() try: resolver.resolve_tools(skill_a, registry) print("skill-resolve-fail: expected cycle error but resolution succeeded") return 1 except ValueError as exc: if "ycle" not in str(exc): print(f"skill-resolve-fail: expected cycle error, got: {exc}") return 1 print(f"skill-resolve-cycle-ok: {exc}") return 0 def main() -> int: """Entry point called by Robot Framework ``Run Process``.""" if len(sys.argv) < 2: print( "Usage: helper_skill_resolution.py " "" ) return 1 command = sys.argv[1] commands = { "resolve-flat": _resolve_flat, "resolve-dedup": _resolve_dedup, "resolve-inline": _resolve_inline, "resolve-cycle": _resolve_cycle, } handler = commands.get(command) if handler is None: print(f"Unknown command: {command}") return 1 return handler() if __name__ == "__main__": sys.exit(main())