Files
hurui200320 554d6889cc
CI / lint (push) Successful in 17s
CI / build (push) Successful in 17s
CI / quality (push) Successful in 28s
CI / security (push) Successful in 43s
CI / typecheck (push) Successful in 46s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m34s
CI / integration_tests (push) Successful in 3m37s
CI / docker (push) Successful in 56s
CI / e2e_tests (push) Successful in 5m19s
CI / coverage (push) Successful in 7m4s
CI / benchmark-publish (push) Successful in 20m58s
fix(cli): add --skill flag to actor run command (#971)
## 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: #971
Co-authored-by: Rui Hu <rui.hu@cleverthis.com>
Co-committed-by: Rui Hu <rui.hu@cleverthis.com>
2026-03-19 09:30:35 +00:00

298 lines
9.3 KiB
Python

"""Helper script for skill_cli.robot smoke tests.
Each subcommand is a self-contained check that prints a sentinel on success.
"""
from __future__ import annotations
import os
import sys
import tempfile
from datetime import datetime
from pathlib import Path
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
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,
)
from cleveragents.cli.commands.skill import ( # noqa: E402
app as skill_app,
)
from cleveragents.domain.models.core.skill import Skill # noqa: E402
runner = CliRunner()
_VALID_YAML = """\
name: local/smoke-skill
description: "Smoke test skill"
tools:
- name: builtin/read_file
- name: builtin/list_directory
"""
_COMPOSED_YAML = """\
name: local/composed-smoke
description: "Composed smoke test skill"
tools:
- name: builtin/shell_execute
includes:
- name: local/smoke-skill
"""
def _write_yaml(content: str) -> str:
fd, path = tempfile.mkstemp(suffix=".yaml")
with os.fdopen(fd, "w") as fh:
fh.write(content)
return path
def _fresh_service() -> SkillService:
"""Create and install a fresh SkillService."""
svc = SkillService()
_reset_skill_service(svc)
return svc
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def add_config() -> None:
"""Verify skill add --config works."""
_fresh_service()
path = _write_yaml(_VALID_YAML)
try:
result = runner.invoke(skill_app, ["add", "--config", path])
if result.exit_code == 0 and "Skill Registered" in result.output:
print("skill-cli-add-config-ok")
else:
print(f"FAIL: add returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
finally:
Path(path).unlink(missing_ok=True)
def add_duplicate_fails() -> None:
"""Verify that adding a duplicate skill fails without --update."""
svc = _fresh_service()
# Pre-register the skill
skill = Skill(
name="local/smoke-skill",
description="Smoke test skill",
tool_refs=["builtin/read_file"],
)
svc._skills["local/smoke-skill"] = skill
svc._created_at["local/smoke-skill"] = datetime.now()
svc._updated_at["local/smoke-skill"] = datetime.now()
path = _write_yaml(_VALID_YAML)
try:
result = runner.invoke(skill_app, ["add", "--config", path])
if result.exit_code != 0 and "already registered" in result.output:
print("skill-cli-add-duplicate-fails-ok")
else:
print(f"FAIL: expected error, got {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
finally:
Path(path).unlink(missing_ok=True)
def add_update() -> None:
"""Verify skill add --update works."""
svc = _fresh_service()
# Pre-register
skill = Skill(
name="local/smoke-skill",
description="Old description",
tool_refs=["builtin/read_file"],
)
svc._skills["local/smoke-skill"] = skill
svc._created_at["local/smoke-skill"] = datetime.now()
svc._updated_at["local/smoke-skill"] = datetime.now()
path = _write_yaml(_VALID_YAML)
try:
result = runner.invoke(skill_app, ["add", "--config", path, "--update"])
if result.exit_code == 0 and "Skill Updated" in result.output:
print("skill-cli-add-update-ok")
else:
print(f"FAIL: update returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
finally:
Path(path).unlink(missing_ok=True)
def show_skill() -> None:
"""Verify skill show works."""
svc = _fresh_service()
skill = Skill(
name="local/smoke-skill",
description="Smoke test skill",
tool_refs=["builtin/read_file", "builtin/list_directory"],
)
svc._skills["local/smoke-skill"] = skill
svc._created_at["local/smoke-skill"] = datetime.now()
svc._updated_at["local/smoke-skill"] = datetime.now()
result = runner.invoke(skill_app, ["show", "local/smoke-skill"])
if result.exit_code == 0 and "Skill Details" in result.output:
print("skill-cli-show-ok")
else:
print(f"FAIL: show returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def tools_skill() -> None:
"""Verify skill tools works."""
svc = _fresh_service()
skill = Skill(
name="local/smoke-skill",
description="Smoke test skill",
tool_refs=["builtin/read_file", "builtin/list_directory"],
)
svc._skills["local/smoke-skill"] = skill
svc._created_at["local/smoke-skill"] = datetime.now()
svc._updated_at["local/smoke-skill"] = datetime.now()
result = runner.invoke(skill_app, ["tools", "local/smoke-skill"])
if result.exit_code == 0 and "Tools for" in result.output:
print("skill-cli-tools-ok")
else:
print(f"FAIL: tools returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def list_skills() -> None:
"""Verify skill list works."""
svc = _fresh_service()
for name in ["local/skill-a", "local/skill-b"]:
skill = Skill(
name=name,
description=f"Test skill {name}",
tool_refs=["builtin/read_file"],
)
svc._skills[name] = skill
svc._created_at[name] = datetime.now()
svc._updated_at[name] = datetime.now()
result = runner.invoke(skill_app, ["list"])
if result.exit_code == 0 and "Summary" in result.output:
print("skill-cli-list-ok")
else:
print(f"FAIL: list returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def remove_skill() -> None:
"""Verify skill remove --yes works."""
svc = _fresh_service()
skill = Skill(
name="local/smoke-skill",
description="Smoke test skill",
tool_refs=["builtin/read_file"],
)
svc._skills["local/smoke-skill"] = skill
svc._created_at["local/smoke-skill"] = datetime.now()
svc._updated_at["local/smoke-skill"] = datetime.now()
result = runner.invoke(skill_app, ["remove", "local/smoke-skill", "--yes"])
if result.exit_code == 0 and "Skill Removed" in result.output:
print("skill-cli-remove-ok")
else:
print(f"FAIL: remove returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def refresh_skill() -> None:
"""Verify skill refresh <name> works."""
svc = _fresh_service()
skill = Skill(
name="local/smoke-skill",
description="Smoke test skill",
tool_refs=["builtin/read_file", "builtin/list_directory"],
)
svc._skills["local/smoke-skill"] = skill
svc._created_at["local/smoke-skill"] = datetime.now()
svc._updated_at["local/smoke-skill"] = datetime.now()
result = runner.invoke(skill_app, ["refresh", "local/smoke-skill"])
if result.exit_code == 0 and "Skill Refreshed" in result.output:
print("skill-cli-refresh-ok")
else:
print(f"FAIL: refresh returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
def refresh_all() -> None:
"""Verify skill refresh --all works."""
svc = _fresh_service()
skill1 = Skill(
name="local/smoke-skill",
description="Smoke test skill",
tool_refs=["builtin/read_file"],
)
skill2 = Skill(
name="local/another-skill",
description="Another skill",
tool_refs=["builtin/shell_execute"],
)
svc._skills["local/smoke-skill"] = skill1
svc._created_at["local/smoke-skill"] = datetime.now()
svc._updated_at["local/smoke-skill"] = datetime.now()
svc._skills["local/another-skill"] = skill2
svc._created_at["local/another-skill"] = datetime.now()
svc._updated_at["local/another-skill"] = datetime.now()
result = runner.invoke(skill_app, ["refresh", "--all"])
if result.exit_code == 0 and "Skills Refreshed" in result.output:
print("skill-cli-refresh-all-ok")
else:
print(f"FAIL: refresh --all returned {result.exit_code}", file=sys.stderr)
print(result.output, file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
_COMMANDS = {
"add-config": add_config,
"add-duplicate-fails": add_duplicate_fails,
"add-update": add_update,
"show-skill": show_skill,
"tools-skill": tools_skill,
"list-skills": list_skills,
"remove-skill": remove_skill,
"refresh-skill": refresh_skill,
"refresh-all": refresh_all,
}
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
sys.exit(2)
_COMMANDS[sys.argv[1]]()
if __name__ == "__main__":
main()