Files
cleveragents-core/robot/helper_actor_run_signature.py
T
hurui200320 0c5724c2f6
CI / push-validation (pull_request) Successful in 42s
CI / helm (pull_request) Successful in 51s
CI / lint (pull_request) Successful in 1m38s
CI / build (pull_request) Successful in 1m35s
CI / quality (pull_request) Successful in 2m21s
CI / security (pull_request) Successful in 2m26s
CI / typecheck (pull_request) Successful in 2m51s
CI / integration_tests (pull_request) Successful in 4m9s
CI / unit_tests (pull_request) Successful in 6m5s
CI / docker (pull_request) Successful in 1m28s
CI / coverage (pull_request) Successful in 10m52s
CI / status-check (pull_request) Successful in 3s
CI / push-validation (push) Successful in 31s
CI / helm (push) Successful in 38s
CI / build (push) Successful in 1m10s
CI / quality (push) Successful in 1m28s
CI / lint (push) Successful in 1m31s
CI / typecheck (push) Successful in 1m53s
CI / security (push) Successful in 2m4s
CI / benchmark-regression (push) Failing after 39s
CI / integration_tests (push) Successful in 3m39s
CI / e2e_tests (push) Successful in 56s
CI / unit_tests (push) Successful in 5m21s
CI / docker (push) Successful in 1m28s
CI / coverage (push) Successful in 11m3s
CI / status-check (push) Successful in 3s
CI / benchmark-publish (push) Successful in 1h23m13s
feat(actor-run): wire ToolCallingRuntime into actor run for skill-based tool calling
Implements the full tool-calling path for `agents actor run --skill` so that LLM
actors can actually invoke tools through the ToolCallingRuntime loop when a skill
is attached.

Core changes:
- reactive/tool_caller.py (new): ToolCallingLLMCaller implements the LLMCaller protocol;
  binds tool schemas via bind_tools(), threads SystemMessage+HumanMessage on first call
  and AIMessage+ToolMessages on subsequent calls, extracts tool calls from LangChain
  responses following the LangChainSessionCaller pattern.
- reactive/tool_caller.py: bidirectional tool name encoding via uppercase sentinels
  (_encode_tool_name / _decode_tool_name) to make CleverAgents namespaced tool names
  ("builtin/file-read", "server:local/tool") compatible with Anthropic's tool name
  pattern ^[a-zA-Z0-9_-]{1,128}$.  Uses _C_ for ":" and _S_ for "/" — uppercase
  sentinels are safe because valid CleverAgents tool names forbid uppercase letters.
  Encoding applied in _resolve_llm() before bind_tools(); decoding applied in invoke()
  when extracting tool calls from the LLM response.
- reactive/tool_agent.py (new): ToolCallingAgent builds a per-run local ToolRegistry from
  resolved skill tool entries by looking up names in the shared builtin_registry; drives
  ToolCallingRuntime.run_tool_loop(); exposes last_result for tool_calls surfacing.
- reactive/application.py: (ST-1) _make_agent_instance() now always merges skill tools
  instead of silently dropping them when actor has no base tools list; routes
  tools+llm→ToolCallingAgent, tools+non-llm→SimpleToolAgent, no-tools+llm→SimpleLLMAgent;
  (ST-4) _builtin_registry created at startup with register_file_tools/git/subplan;
  (ST-6) _tally_tool_calls() + last_run_tool_calls property.
- reactive/graph_executor.py: (ST-5) ToolCallingAgent added to isinstance check in
  _invoke_agent() so context dict is forwarded for Jinja2 rendering.
- cli/commands/actor_run.py: prints "Tool Calls: {n}" when > 0.

Test fixes:
- features/steps/actor_cli_run_steps.py: _make_app() sets last_run_tool_calls=0 to avoid
  MagicMock>int TypeError in Python 3.13.
- features/steps/actor_run_signature_resolve_steps.py: same fix.
- robot/helper_actor_run_signature.py: same fix.
- features/reactive_application_coverage_boost.feature: updated scenario to verify new
  correct behavior (LLM+skills → ToolCallingAgent, not silently kept as SimpleLLMAgent).

BDD coverage: 34 scenarios in features/actor_run_tool_calling.feature covering
tool call success, multi-turn loop, no-skill regression, silent-drop fix, LLMCaller
internals, _build_tool_registry edge cases, last_run_tool_calls tallying,
tool name encoding/decoding, and LLM response decoding.

ISSUES CLOSED: #11211
2026-05-18 06:05:54 +00:00

269 lines
8.1 KiB
Python

"""Helper script for actor_run_signature.robot integration tests.
Tests that the ``actor run`` and ``actor-run`` CLI commands accept
positional NAME and PROMPT arguments while preserving --config fallback.
"""
from __future__ import annotations
import os
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
# 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)
import typer # noqa: E402
from typer.testing import CliRunner # noqa: E402
from cleveragents.cli.commands.actor import app as actor_app # noqa: E402
from cleveragents.cli.commands.actor_run import app as actor_run_app # noqa: E402
runner = CliRunner()
_SIMPLE_YAML = """\
name: smoke-actor
type: custom
provider: openai
model: gpt-4
tools:
- operation: identity
"""
def _write_yaml(content: str) -> str:
fd, path = tempfile.mkstemp(suffix=".yaml")
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(content)
return path
def _make_app(*, result: str) -> MagicMock:
app_exec = MagicMock()
app_exec.config = SimpleNamespace(global_context={})
app_exec.run_single_shot = AsyncMock(return_value=result)
# last_run_tool_calls is an int property; set to 0 so comparisons work in tests
app_exec.last_run_tool_calls = 0
return app_exec
def positional_with_config() -> None:
"""Test positional NAME + PROMPT with --config fallback."""
path = _write_yaml(_SIMPLE_YAML)
app_exec = _make_app(result="config response")
try:
with patch(
"cleveragents.cli.commands.actor.ReactiveCleverAgentsApp",
return_value=app_exec,
):
result = runner.invoke(
actor_app,
["run", "--config", path, "my-actor", "hello world"],
)
if result.exit_code != 0:
print(
f"FAIL: expected exit code 0, got {result.exit_code}",
file=sys.stderr,
)
print(result.output, file=sys.stderr)
sys.exit(1)
if "config response" not in result.output:
print(
f"FAIL: expected 'config response' in output, got: {result.output}",
file=sys.stderr,
)
sys.exit(1)
print("positional-with-config-ok")
finally:
Path(path).unlink(missing_ok=True)
def positional_from_registry() -> None:
"""Test positional NAME resolved from the actor registry."""
path = _write_yaml(_SIMPLE_YAML)
app_exec = _make_app(result="registry response")
def _mock_resolve(name: str, config: list[Any]) -> list[Any]:
return [Path(path)]
try:
with (
patch(
"cleveragents.cli.commands.actor_run._resolve_config_files",
side_effect=_mock_resolve,
),
patch(
"cleveragents.cli.commands.actor_run.ReactiveCleverAgentsApp",
return_value=app_exec,
),
):
result = runner.invoke(
actor_run_app,
["local/my-actor", "hello from registry"],
)
if result.exit_code != 0:
print(
f"FAIL: expected exit code 0, got {result.exit_code}",
file=sys.stderr,
)
print(result.output, file=sys.stderr)
sys.exit(1)
if "registry response" not in result.output:
print(
f"FAIL: expected 'registry response' in output, got: {result.output}",
file=sys.stderr,
)
sys.exit(1)
print("positional-from-registry-ok")
finally:
Path(path).unlink(missing_ok=True)
def actor_app_registry_resolution() -> None:
"""Test positional NAME resolved from the actor registry via actor_app."""
path = _write_yaml(_SIMPLE_YAML)
app_exec = _make_app(result="actor-app registry response")
def _mock_resolve(name: str, config: list[Any]) -> list[Any]:
return [Path(path)]
try:
with (
patch(
"cleveragents.cli.commands.actor._resolve_config_files",
side_effect=_mock_resolve,
),
patch(
"cleveragents.cli.commands.actor.ReactiveCleverAgentsApp",
return_value=app_exec,
),
):
result = runner.invoke(
actor_app,
["run", "local/my-actor", "hello from actor_app registry"],
)
if result.exit_code != 0:
print(
f"FAIL: expected exit code 0, got {result.exit_code}",
file=sys.stderr,
)
print(result.output, file=sys.stderr)
sys.exit(1)
if "actor-app registry response" not in result.output:
print(
"FAIL: expected 'actor-app registry response' "
f"in output, got: {result.output}",
file=sys.stderr,
)
sys.exit(1)
print("actor-app-registry-ok")
finally:
Path(path).unlink(missing_ok=True)
def unknown_actor_name() -> None:
"""Test error when actor name is not found in registry."""
def _not_found_resolve(name: str, config: list[Any]) -> list[Any]:
typer.echo(
f"Error: Actor '{name}' not found in registry and no --config provided.",
err=True,
)
raise typer.Exit(code=2)
with patch(
"cleveragents.cli.commands.actor_run._resolve_config_files",
side_effect=_not_found_resolve,
):
result = runner.invoke(
actor_run_app,
["nonexistent/actor", "test prompt"],
)
if result.exit_code != 2:
print(
f"FAIL: expected exit code 2, got {result.exit_code}",
file=sys.stderr,
)
print(result.output, file=sys.stderr)
sys.exit(1)
combined = (result.output or "") + (getattr(result, "stderr", None) or "")
if "not found in registry" not in combined:
print(
"FAIL: expected 'not found in registry' in output",
file=sys.stderr,
)
print(combined, file=sys.stderr)
sys.exit(1)
print("unknown-actor-name-ok")
def actor_app_unknown_name() -> None:
"""Test error when actor name is not found in registry via actor_app."""
def _not_found_resolve(name: str, config: list[Any]) -> list[Any]:
typer.echo(
f"Error: Actor '{name}' not found in registry and no --config provided.",
err=True,
)
raise typer.Exit(code=2)
with patch(
"cleveragents.cli.commands.actor._resolve_config_files",
side_effect=_not_found_resolve,
):
result = runner.invoke(
actor_app,
["run", "nonexistent/actor", "test prompt"],
)
if result.exit_code != 2:
print(
f"FAIL: expected exit code 2, got {result.exit_code}",
file=sys.stderr,
)
print(result.output, file=sys.stderr)
sys.exit(1)
combined = (result.output or "") + (getattr(result, "stderr", None) or "")
if "not found in registry" not in combined:
print(
"FAIL: expected 'not found in registry' in output",
file=sys.stderr,
)
print(combined, file=sys.stderr)
sys.exit(1)
print("actor-app-unknown-name-ok")
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
_COMMANDS: dict[str, Any] = {
"positional-with-config": positional_with_config,
"positional-from-registry": positional_from_registry,
"actor-app-registry": actor_app_registry_resolution,
"unknown-actor-name": unknown_actor_name,
"actor-app-unknown-name": actor_app_unknown_name,
}
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()