forked from cleveragents/cleveragents-core
554d6889cc
## Summary Add the missing `--skill` repeatable flag to `actor run` and `actor-run` CLI commands, aligning the implementation with the specification (CLI Synopsis line 277). The flag enables ad-hoc skill injection at runtime without modifying YAML configuration. Closes #887 ## Changes ### DI Container - **`container.py`**: Added `_build_skill_service()` factory and `skill_service` Singleton provider, following the established `_build_*` pattern. Falls back to in-memory `SkillService()` when the database is unavailable. Exception handling narrowed to `(ImportError, OperationalError, DatabaseError, OSError)` with `exc_info=True` for traceability. ### CLI Layer - **`actor.py`**: Added `--skill` Typer option (`list[str] | None`, repeatable, `metavar="NAME"`). Help text notes that skills only augment tool-bearing agents. Wrapped constructor in the existing `try/except` block so `CleverAgentsException` from skill resolution is properly caught. - **`actor_run.py`**: Same `--skill` option with `metavar="NAME"`. Exception handler catches `CleverAgentsException` (matching master — not broadened to `CleverAgentsError`). - **`skill.py`**: Removed module-level `_service` cache. `_get_skill_service()` now always delegates to `get_container().skill_service()` so that `reset_container()` correctly invalidates the cached instance. `_reset_skill_service()` now overrides the container's provider via `providers.Object()`. Removed dead `validate_skill_names()` function. ### Runtime Layer - **`application.py`** (438 lines, down from 625): `ReactiveCleverAgentsApp` gains `skill_names` parameter with automatic deduplication via `dict.fromkeys`. `_resolve_skills()` obtains `SkillService` from the DI container (no CLI layer import). Separate `except KeyError` and `except ValueError` produce distinct error messages (`"not found in registry"` vs `"resolution failed: {exc}"`). Skill tools are only injected into agents that already have tools (`if self._resolved_skill_tools and tools:`), preventing LLM agents from being converted to pass-through `SimpleToolAgent` instances. When skill tools are skipped for tool-less agents, `logger.debug` emits a diagnostic message. `_sanitize_skill_name()` validates skill name format with tightened regex: `^[\w.-]{1,127}/[\w.-]{1,127}$` with `re.ASCII` flag. Zero-tool skill warning now uses `logger.warning` (not `print(stderr)`), ensuring structured log output and proper log-level filtering. - **`graph_executor.py`** (334 lines): Extracted graph execution logic. Type annotations improved. ### Tests - 24+ Behave scenarios across feature files covering: single/multiple/unknown skill flags, skill+context combined, duplicate deduplication, skill resolution, ValueError path, zero-tool resolution, error handling, tool merging, default behavior, overrides, LLM agent guard, `_sanitize_skill_name` edge cases (empty string, too-long name, ANSI escape codes, disallowed characters), `_build_skill_service` happy+fallback paths, `_get_skill_service` container delegation. - CLI "unknown skill" tests for **both** `actor.py` and `actor_run.py` exercise the real error chain (mock only `get_container()`, not the entire `ReactiveCleverAgentsApp`), testing `_resolve_skills()` → `CleverAgentsException` → `except CleverAgentsException` → exit code 2 end-to-end. - Combined skill+context tests assert `ContextManager` was instantiated and `exists()` was called in dedicated **Then** steps. - `@coverage` tags added to all new scenarios. - **Robot Framework smoke tests** added (`robot/skill_actor_run.robot` + `robot/helper_skill_actor_run.py`): unknown-skill error path and valid-skill acceptance path. ### Changelog - Added entry under `## Unreleased` in `CHANGELOG.md`. ## Review Fixes Applied (Brent Edwards, Rounds 1 & 2) | # | Finding | Resolution | |---|---------|------------| | **P1-1** | `print(stderr)` for zero-tool skill warning | **Fixed** — replaced with `logger.warning("Skill '%s' resolved to zero tools", name)`, removed unused `import sys` | | **P2-2** | Skill tools silently skipped for tool-less agents | **Fixed** — added `logger.debug` when skipped; updated `--skill` help text to note "only augments tool-bearing agents" | | **P2-3** | `container.py` at 739 lines | **Acknowledged** — pre-existing growth (+59 lines for `_build_skill_service`); extracting factories is a separate refactoring task | | **P2-4↑** | `CleverAgentsException` → `CleverAgentsError` broadens catch scope | **Fixed** — reverted `actor_run.py` to `except CleverAgentsException` matching master | | **P3-5** | No Robot Framework smoke test for `--skill` | **Fixed** — added `skill_actor_run.robot` with 2 test cases (unknown-skill error, valid-skill acceptance) | | **P3-6** | `GraphExecutor._follow_chained_edges` static-calling-static | **Acknowledged** — cosmetic pattern that doesn't affect correctness; can address in a follow-up | ## Known Limitations / Deferred Items | Item | Reason | |------|--------| | `actor.py` at 679 lines (500-line guideline) | Pre-existing (670 on master), +9 lines for `--skill`. Refactoring the shared `_execute()` closure is a separate task. | | `container.py` at 739 lines (500-line guideline) | Was 680 lines on master, +59 lines for `_build_skill_service()` and `skill_service` provider. Refactoring into sub-modules is a separate task. | | Code duplication between `actor.py` and `actor_run.py` `run()` | ~47 lines identical code. Coupled with the line-count issue above — both require extracting shared execution logic into a helper module. | | `SimpleToolAgent` only executes `tools[0]` | Deferred to #974. Pre-existing architectural limitation, not introduced by this PR. | | `GraphExecutor._follow_chained_edges` static-calling-static pattern | Cosmetic, doesn't affect behavior. | ## Quality Gates - `nox -s lint`: ✅ PASS - `nox -s typecheck`: ✅ PASS (0 errors) - `nox -s unit_tests`: ✅ PASS (11,130 scenarios, 0 failures) - `nox -s integration_tests`: ✅ PASS (1,559 tests, 0 failures) - `nox -s coverage_report`: ✅ 97% (meets threshold) - Branch rebased onto latest `master` (`ab1fd19b`) Reviewed-on: cleveragents/cleveragents-core#971 Co-authored-by: Rui Hu <rui.hu@cleverthis.com> Co-committed-by: Rui Hu <rui.hu@cleverthis.com>
190 lines
5.5 KiB
Python
190 lines
5.5 KiB
Python
"""ASV benchmarks for Skill CLI command throughput.
|
|
|
|
Measures the performance of:
|
|
- Config-only add (YAML load + validate + Skill registration)
|
|
- List command rendering
|
|
- Show command rendering
|
|
- Tools command (resolver flattening)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
# Ensure the local *source* tree is importable even when ASV has an
|
|
# older build of the package installed.
|
|
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
|
if _SRC not in sys.path:
|
|
sys.path.insert(0, _SRC)
|
|
|
|
import cleveragents # noqa: E402
|
|
|
|
importlib.reload(cleveragents)
|
|
|
|
from typer.testing import CliRunner # noqa: E402
|
|
|
|
from cleveragents.application.services.skill_service import SkillService # noqa: E402
|
|
from cleveragents.cli.commands.skill import ( # noqa: E402
|
|
_reset_skill_service,
|
|
app as skill_app,
|
|
)
|
|
from cleveragents.domain.models.core.skill import Skill # noqa: E402
|
|
|
|
_VALID_YAML = """\
|
|
name: local/bench-skill
|
|
description: "Benchmark skill"
|
|
tools:
|
|
- name: builtin/read_file
|
|
- name: builtin/write_file
|
|
- name: builtin/list_directory
|
|
"""
|
|
|
|
_runner = CliRunner()
|
|
|
|
|
|
def _fresh_service() -> SkillService:
|
|
"""Create and install a fresh service for benchmarking."""
|
|
svc = SkillService()
|
|
_reset_skill_service(svc)
|
|
return svc
|
|
|
|
|
|
class SkillCLIAddSuite:
|
|
"""Benchmark skill add --config throughput."""
|
|
|
|
def setup(self) -> None:
|
|
"""Write a temporary YAML file and set up fresh service."""
|
|
fd, self._path = tempfile.mkstemp(suffix=".yaml")
|
|
with os.fdopen(fd, "w") as fh:
|
|
fh.write(_VALID_YAML)
|
|
|
|
def teardown(self) -> None:
|
|
"""Clean up."""
|
|
Path(self._path).unlink(missing_ok=True)
|
|
|
|
def time_add_from_config(self) -> None:
|
|
"""Benchmark add --config end-to-end."""
|
|
_fresh_service()
|
|
_runner.invoke(skill_app, ["add", "--config", self._path])
|
|
|
|
|
|
class SkillCLIListSuite:
|
|
"""Benchmark skill list throughput."""
|
|
|
|
def setup(self) -> None:
|
|
"""Set up service with skills."""
|
|
svc = _fresh_service()
|
|
for i in range(50):
|
|
name = f"local/skill-{i}"
|
|
skill = Skill(
|
|
name=name,
|
|
description=f"Benchmark skill {i}",
|
|
tool_refs=["builtin/read_file", "builtin/write_file"],
|
|
)
|
|
svc._skills[name] = skill
|
|
svc._created_at[name] = datetime.now()
|
|
svc._updated_at[name] = datetime.now()
|
|
|
|
def teardown(self) -> None:
|
|
_fresh_service()
|
|
|
|
def time_list_all(self) -> None:
|
|
"""Benchmark listing all skills."""
|
|
_runner.invoke(skill_app, ["list"])
|
|
|
|
def time_list_with_namespace(self) -> None:
|
|
"""Benchmark listing with namespace filter."""
|
|
_runner.invoke(skill_app, ["list", "--namespace", "local"])
|
|
|
|
|
|
class SkillCLIShowSuite:
|
|
"""Benchmark skill show throughput."""
|
|
|
|
def setup(self) -> None:
|
|
svc = _fresh_service()
|
|
skill = Skill(
|
|
name="local/bench-show",
|
|
description="Benchmark skill for show",
|
|
tool_refs=[
|
|
"builtin/read_file",
|
|
"builtin/write_file",
|
|
"builtin/list_directory",
|
|
],
|
|
)
|
|
svc._skills["local/bench-show"] = skill
|
|
svc._created_at["local/bench-show"] = datetime.now()
|
|
svc._updated_at["local/bench-show"] = datetime.now()
|
|
|
|
def teardown(self) -> None:
|
|
_fresh_service()
|
|
|
|
def time_show(self) -> None:
|
|
"""Benchmark showing a skill."""
|
|
_runner.invoke(skill_app, ["show", "local/bench-show"])
|
|
|
|
|
|
class SkillCLIToolsSuite:
|
|
"""Benchmark skill tools (resolver) throughput."""
|
|
|
|
def setup(self) -> None:
|
|
svc = _fresh_service()
|
|
skill = Skill(
|
|
name="local/bench-tools",
|
|
description="Benchmark skill for tools resolution",
|
|
tool_refs=[
|
|
"builtin/read_file",
|
|
"builtin/write_file",
|
|
"builtin/list_directory",
|
|
"builtin/search_files",
|
|
"builtin/delete_file",
|
|
],
|
|
)
|
|
svc._skills["local/bench-tools"] = skill
|
|
svc._created_at["local/bench-tools"] = datetime.now()
|
|
svc._updated_at["local/bench-tools"] = datetime.now()
|
|
|
|
def teardown(self) -> None:
|
|
_fresh_service()
|
|
|
|
def time_tools(self) -> None:
|
|
"""Benchmark resolving tools for a skill."""
|
|
_runner.invoke(skill_app, ["tools", "local/bench-tools"])
|
|
|
|
|
|
class SkillCLIRefreshSuite:
|
|
"""Benchmark skill refresh command throughput."""
|
|
|
|
def setup(self) -> None:
|
|
"""Set up service with multiple skills."""
|
|
svc = _fresh_service()
|
|
for i in range(20):
|
|
name = f"local/refresh-skill-{i}"
|
|
skill = Skill(
|
|
name=name,
|
|
description=f"Refresh benchmark skill {i}",
|
|
tool_refs=[
|
|
"builtin/read_file",
|
|
"builtin/write_file",
|
|
"builtin/list_directory",
|
|
],
|
|
)
|
|
svc._skills[name] = skill
|
|
svc._created_at[name] = datetime.now()
|
|
svc._updated_at[name] = datetime.now()
|
|
|
|
def teardown(self) -> None:
|
|
_fresh_service()
|
|
|
|
def time_refresh_single(self) -> None:
|
|
"""Benchmark refreshing a single skill."""
|
|
_runner.invoke(skill_app, ["refresh", "local/refresh-skill-0"])
|
|
|
|
def time_refresh_all(self) -> None:
|
|
"""Benchmark refreshing all skills."""
|
|
_runner.invoke(skill_app, ["refresh", "--all"])
|