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
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
269 lines
8.1 KiB
Python
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()
|