Files
cleveragents-core/robot/helper_m2_e2e_verification.py
HAL9000 80bc9c552d
CI / benchmark-publish (pull_request) Has been skipped
CI / push-validation (pull_request) Successful in 33s
CI / helm (pull_request) Successful in 45s
CI / build (pull_request) Successful in 1m4s
CI / lint (pull_request) Successful in 1m13s
CI / typecheck (pull_request) Successful in 1m31s
CI / quality (pull_request) Successful in 1m37s
CI / security (pull_request) Successful in 1m49s
CI / integration_tests (pull_request) Successful in 3m34s
CI / e2e_tests (pull_request) Successful in 5m6s
CI / unit_tests (pull_request) Successful in 6m14s
CI / docker (pull_request) Successful in 1m36s
CI / coverage (pull_request) Successful in 12m27s
CI / status-check (push) Blocked by required conditions
CI / coverage (push) Blocked by required conditions
CI / docker (push) Blocked by required conditions
CI / unit_tests (push) Has started running
CI / status-check (pull_request) Successful in 4s
CI / helm (push) Successful in 31s
CI / build (push) Successful in 53s
CI / lint (push) Successful in 1m3s
CI / quality (push) Successful in 1m13s
CI / typecheck (push) Successful in 1m46s
CI / security (push) Successful in 1m46s
CI / push-validation (push) Successful in 20s
CI / benchmark-publish (push) Failing after 43s
CI / e2e_tests (push) Successful in 4m9s
CI / integration_tests (push) Successful in 6m57s
test: restore and enhance e2e test coverage
Fix malformed imports in helper_m1_e2e_verification.py and
helper_m4_e2e_cli.py where 'from helpers_common import reset_global_state'
was incorrectly inserted inside a parenthesized import block, causing
Python syntax errors and breaking the lint and unit_tests CI gates.

Also fix import ordering in helper_m2_e2e_verification.py,
helper_m5_e2e_context.py, helper_m5_e2e_support.py, and
helper_m5_e2e_verification.py to satisfy ruff I001 import-sort rules
by placing helpers_common imports in the correct group alongside other
local robot/ helper imports.

ISSUES CLOSED: #8459
2026-05-01 00:59:02 +00:00

618 lines
20 KiB
Python

"""Robot Framework helper for M2 E2E verification tests.
Exercises the complete M2 success criteria sequence:
1. Actor YAML creation and loading
2. Actor compilation to LangGraph StateGraphs
3. Tool router external tool resolution
4. Validation runner with required/informational modes
5. Multi-file generation producing a correct ChangeSet
CLI-facing tests (actor-add-config-cli, action-create, plan-use-execute)
invoke the real ``agents`` CLI via subprocess without mocking.
Domain-level tests exercise real application code directly.
Each subcommand prints a sentinel string on success and exits 0.
On failure it prints a diagnostic and exits 1.
Usage:
python robot/helper_m2_e2e_verification.py <command> [args...]
"""
from __future__ import annotations
import json
import os
import re
import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any, NoReturn
# Ensure src is importable when run from workspace root
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
# Ensure robot/ is on the import path for helper_e2e_common.
_ROBOT = str(Path(__file__).resolve().parent)
if _ROBOT not in sys.path:
sys.path.insert(0, _ROBOT)
from helper_e2e_common import ( # noqa: E402
cleanup_workspace,
is_expected_provider_unavailable,
run_cli,
setup_workspace,
write_yaml,
)
from helpers_common import reset_global_state # noqa: E402
from cleveragents.actor.compiler import ( # noqa: E402
ActorCompilationError,
CompiledActor,
compile_actor,
)
from cleveragents.actor.loader import ActorLoader # noqa: E402
from cleveragents.actor.schema import ( # noqa: E402
ActorConfigSchema,
ActorType,
)
from cleveragents.domain.models.core.tool import ( # noqa: E402
Validation,
ValidationMode,
)
from cleveragents.tool.builtins.changeset import ( # noqa: E402
ChangeSet,
ChangeSetCapture,
)
from cleveragents.tool.builtins.file_tools import FILE_WRITE_SPEC # noqa: E402
from cleveragents.tool.registry import ToolRegistry # noqa: E402
from cleveragents.tool.router import ( # noqa: E402
ProviderFormat,
ToolCallRouter,
detect_provider_format,
normalize_tool_call,
)
from cleveragents.tool.runner import ToolRunner # noqa: E402
from cleveragents.tool.runtime import ToolSpec # noqa: E402
def _fail(msg: str) -> NoReturn:
"""Print failure message and exit."""
print(f"FAIL: {msg}", file=sys.stderr)
raise SystemExit(1)
def _extract_plan_id(output: str) -> str | None:
"""Extract a ULID plan_id from plain CLI output."""
match = re.search(r"\b([0-9A-Z]{26})\b", output)
return match.group(1) if match else None
# -----------------------------------------------------------------------
# Helpers for domain-level tests (unchanged)
# -----------------------------------------------------------------------
def _echo(inputs: dict[str, Any]) -> dict[str, Any]:
return dict(inputs)
def _validation_pass(inputs: dict[str, Any]) -> dict[str, Any]:
return {"passed": True, "message": "validation passed"}
def _validation_fail(inputs: dict[str, Any]) -> dict[str, Any]:
return {"passed": False, "message": "validation failed"}
# -----------------------------------------------------------------------
# Subcommand: actor-yaml-create-load (domain-level, no CLI)
# -----------------------------------------------------------------------
def actor_yaml_create_load() -> None:
"""Create a temporary actor YAML and load it via ActorLoader."""
reset_global_state()
tmp = Path(tempfile.mkdtemp(prefix="m2_actor_"))
yaml_content = (
"name: local/m2-test-actor\n"
"type: graph\n"
"description: M2 E2E test actor\n"
'version: "1.0"\n'
"provider: openai\n"
"model: gpt-4\n"
"route:\n"
" nodes:\n"
" - id: planner\n"
" type: agent\n"
" name: Planner\n"
" description: Plans the task\n"
" config:\n"
" model: gpt-4\n"
" prompt: Plan the task\n"
" - id: executor\n"
" type: tool\n"
" name: Executor\n"
" description: Executes the plan\n"
" config:\n"
" tool_name: builtins/echo\n"
" edges:\n"
" - from_node: planner\n"
" to_node: executor\n"
" entry_node: planner\n"
" exit_nodes:\n"
" - executor\n"
)
yaml_path = tmp / "m2_actor.yaml"
yaml_path.write_text(yaml_content)
loader = ActorLoader(search_roots=[tmp])
actors = loader.discover()
assert len(actors) == 1, f"Expected 1 actor, got {len(actors)}"
actor = actors[0]
assert actor.name == "local/m2-test-actor", f"Wrong name: {actor.name}"
assert actor.type == ActorType.GRAPH, f"Wrong type: {actor.type}"
print("m2-actor-yaml-create-load-ok")
# -----------------------------------------------------------------------
# Subcommand: actor-add-config-cli (CLI via subprocess)
# -----------------------------------------------------------------------
def actor_add_config_cli() -> None:
"""Add an actor via ``agents actor add --config`` using subprocess."""
reset_global_state()
workspace = setup_workspace(prefix="m2_actor_cli_")
config_dir = tempfile.mkdtemp(prefix="m2_actor_cfg_")
config_path = os.path.join(config_dir, "actor.json")
with open(config_path, "w") as f:
json.dump(
{
"name": "local/m2-cli-actor",
"provider": "openai",
"model": "gpt-4",
"options": {"temperature": 0.5},
},
f,
)
try:
result = run_cli(
"actor",
"add",
"local/m2-cli-actor",
"--config",
config_path,
"--format",
"plain",
workspace=workspace,
)
if result.returncode != 0:
_fail(
f"actor add rc={result.returncode}\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
print("m2-actor-add-config-cli-ok")
finally:
shutil.rmtree(config_dir, ignore_errors=True)
cleanup_workspace(workspace)
# -----------------------------------------------------------------------
# Subcommand: action-create (CLI via subprocess)
# -----------------------------------------------------------------------
_ACTION_YAML = """\
name: local/m2-test-action
description: M2 test action
definition_of_done: All tests pass
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
"""
def action_create() -> None:
"""Create an action via real CLI subprocess."""
reset_global_state()
workspace = setup_workspace(prefix="m2_action_")
yaml_path = write_yaml(_ACTION_YAML)
try:
result = run_cli("action", "create", "--config", yaml_path, workspace=workspace)
if result.returncode != 0:
_fail(
f"action create rc={result.returncode}\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
# Verify persistence: action should be retrievable
show = run_cli(
"action",
"show",
"local/m2-test-action",
"--format",
"plain",
workspace=workspace,
)
if show.returncode != 0:
_fail(f"action show rc={show.returncode}\nstderr: {show.stderr}")
if "local/m2-test-action" not in show.stdout:
_fail(f"action not found in show output:\n{show.stdout}")
print("m2-action-create-ok")
finally:
os.unlink(yaml_path)
cleanup_workspace(workspace)
# -----------------------------------------------------------------------
# Subcommand: plan-use-execute (CLI via subprocess)
# -----------------------------------------------------------------------
def plan_use_execute() -> None:
"""Run plan use and plan execute via real CLI subprocesses."""
reset_global_state()
workspace = setup_workspace(prefix="m2_plan_")
yaml_path = write_yaml(_ACTION_YAML)
try:
# Step 1: Create action
r1 = run_cli("action", "create", "--config", yaml_path, workspace=workspace)
if r1.returncode != 0:
_fail(f"action create: {r1.stderr}")
# Step 2: Plan use
r2 = run_cli(
"plan",
"use",
"local/m2-test-action",
"--format",
"plain",
workspace=workspace,
)
if r2.returncode != 0:
_fail(f"plan use: {r2.stderr}")
plan_id = _extract_plan_id(r2.stdout)
if not plan_id:
_fail(f"could not extract plan_id from plan use output:\n{r2.stdout}")
# Step 3: Plan execute — plan is strategize/queued so it
# correctly rejects with "not ready" (controlled abort).
r3 = run_cli("plan", "execute", plan_id, workspace=workspace)
combined = r3.stdout + r3.stderr
if "Traceback" in combined:
_fail(f"plan execute crashed:\n{combined}")
if "INTERNAL" in combined and not is_expected_provider_unavailable(combined):
_fail(f"plan execute crashed:\n{combined}")
print("m2-plan-use-execute-ok")
finally:
os.unlink(yaml_path)
cleanup_workspace(workspace)
# -----------------------------------------------------------------------
# Subcommand: actor-yaml-parse-validate (domain-level, no CLI)
# -----------------------------------------------------------------------
def actor_yaml_parse_validate() -> None:
"""Verify actor YAML files parse and validate correctly."""
reset_global_state()
# Test valid graph YAML
tmp = Path(tempfile.mkdtemp(prefix="m2_parse_"))
valid_yaml = (
"name: local/valid-actor\n"
"type: graph\n"
"description: Valid actor\n"
'version: "1.0"\n'
"provider: openai\n"
"model: gpt-4\n"
"route:\n"
" nodes:\n"
" - id: start\n"
" type: agent\n"
" name: Start\n"
" description: Start node\n"
" config:\n"
" model: gpt-4\n"
" prompt: Begin\n"
" edges: []\n"
" entry_node: start\n"
" exit_nodes:\n"
" - start\n"
)
(tmp / "valid.yaml").write_text(valid_yaml)
config = ActorConfigSchema.from_yaml_file(str(tmp / "valid.yaml"))
assert config.name == "local/valid-actor"
assert config.type == ActorType.GRAPH
assert config.route is not None
assert len(config.route.nodes) == 1
# Test valid LLM YAML
llm_yaml = (
"name: local/llm-actor\n"
"type: llm\n"
"description: LLM actor\n"
'version: "1.0"\n'
"provider: openai\n"
"model: gpt-4\n"
)
(tmp / "llm.yaml").write_text(llm_yaml)
llm_config = ActorConfigSchema.from_yaml_file(str(tmp / "llm.yaml"))
assert llm_config.type == ActorType.LLM
# Test invalid YAML is rejected
bad_yaml = "name: [\ninvalid yaml\n"
(tmp / "bad.yaml").write_text(bad_yaml)
try:
ActorConfigSchema.from_yaml_file(str(tmp / "bad.yaml"))
print("FAIL: bad YAML not rejected")
sys.exit(1)
except Exception:
pass # Expected
print("m2-actor-yaml-parse-validate-ok")
# -----------------------------------------------------------------------
# Subcommand: actor-compile-stategraph (domain-level, no CLI)
# -----------------------------------------------------------------------
def actor_compile_stategraph() -> None:
"""Verify actors compile to LangGraph StateGraphs."""
reset_global_state()
project_root = Path(__file__).resolve().parents[1]
yaml_path = project_root / "examples" / "actors" / "simple_graph.yaml"
config = ActorConfigSchema.from_yaml_file(str(yaml_path))
compiled = compile_actor(config)
assert isinstance(compiled, CompiledActor)
assert compiled.name == config.name
assert len(compiled.nodes) > 0, "Expected at least one node"
assert len(compiled.edges) > 0, "Expected at least one edge"
assert compiled.entry_point != "", "Entry point must not be empty"
assert len(compiled.metadata.node_ids) > 0
assert compiled.metadata.entry_node != ""
# Verify LLM actors are rejected
tmp = Path(tempfile.mkdtemp(prefix="m2_compile_"))
llm_yaml = (
"name: local/llm-only\n"
"type: llm\n"
"description: LLM only actor\n"
'version: "1.0"\n'
"provider: openai\n"
"model: gpt-4\n"
)
(tmp / "llm.yaml").write_text(llm_yaml)
llm_config = ActorConfigSchema.from_yaml_file(str(tmp / "llm.yaml"))
try:
compile_actor(llm_config)
print("FAIL: LLM actor should not compile")
sys.exit(1)
except ActorCompilationError:
pass # Expected
print("m2-actor-compile-stategraph-ok")
# -----------------------------------------------------------------------
# Subcommand: tool-router-resolve-external (domain-level, no CLI)
# -----------------------------------------------------------------------
def tool_router_resolve_external() -> None:
"""Verify tool router can resolve external tools."""
reset_global_state()
registry = ToolRegistry()
ext_spec = ToolSpec(
name="external/custom-tool",
description="External custom tool",
handler=_echo,
source="custom",
)
registry.register(ext_spec)
tool_runner_inst = ToolRunner(registry)
router = ToolCallRouter(
registry=registry, runner=tool_runner_inst, plan_id="m2-plan-1"
)
# Route an OpenAI-format call to the external tool
result = router.route(
{
"name": "external/custom-tool",
"arguments": json.dumps({"key": "value"}),
}
)
assert result.result.success, f"Expected success, got error: {result.result.error}"
assert result.provider_format == ProviderFormat.OPENAI
# Route an Anthropic-format call
result2 = router.route(
{
"name": "external/custom-tool",
"input": {"key": "value"},
}
)
assert result2.result.success
assert result2.provider_format == ProviderFormat.ANTHROPIC
# Verify detection and normalization
fmt = detect_provider_format({"name": "x/y", "arguments": '{"a": 1}'})
assert fmt == ProviderFormat.OPENAI
req = normalize_tool_call(
{"name": "external/custom-tool", "arguments": '{"k":"v"}'}
)
assert req.tool_name == "external/custom-tool"
print("m2-tool-router-resolve-external-ok")
# -----------------------------------------------------------------------
# Subcommand: validation-runner-execute (domain-level, no CLI)
# -----------------------------------------------------------------------
def validation_runner_execute() -> None:
"""Verify validation runner executes required/informational validations."""
reset_global_state()
# Create a required validation
required_val = Validation.from_config(
{
"name": "local/coverage-check",
"description": "Check code coverage",
"source": "custom",
"mode": "required",
"code": (
"def run(inputs): return {'passed': inputs.get('coverage', 0) >= 80}"
),
}
)
assert required_val.mode == ValidationMode.REQUIRED
# Create an informational validation
info_val = Validation.from_config(
{
"name": "local/lint-check",
"description": "Lint check",
"source": "custom",
"mode": "informational",
"code": (
"def run(inputs): return {'passed': False, 'message': 'lint warnings'}"
),
}
)
assert info_val.mode == ValidationMode.INFORMATIONAL
# Register validation tools in registry and execute through runner
registry = ToolRegistry()
required_spec = ToolSpec(
name="local/coverage-check",
description="Coverage validation",
handler=_validation_pass,
)
info_spec = ToolSpec(
name="local/lint-check",
description="Lint validation",
handler=_validation_fail,
)
registry.register(required_spec)
registry.register(info_spec)
tool_runner_inst = ToolRunner(registry)
# Execute required validation (pass)
req_result = tool_runner_inst.execute("local/coverage-check", {"coverage": 95})
assert req_result.success, f"Required validation failed: {req_result.error}"
assert req_result.output.get("passed") is True
# Execute informational validation (fail -- should not block)
info_result = tool_runner_inst.execute("local/lint-check", {"code": "x"})
assert info_result.success, (
f"Informational validation execution failed: {info_result.error}"
)
assert info_result.output.get("passed") is False
# Verify as_cli_dict renders mode
cli_dict = required_val.as_cli_dict()
assert cli_dict["mode"] == "required"
info_cli = info_val.as_cli_dict()
assert info_cli["mode"] == "informational"
print("m2-validation-runner-execute-ok")
# -----------------------------------------------------------------------
# Subcommand: changeset-multifile (domain-level, no CLI)
# -----------------------------------------------------------------------
def changeset_multifile() -> None:
"""Verify multi-file generation produces correct ChangeSet."""
reset_global_state()
tmpdir = tempfile.mkdtemp(prefix="m2_changeset_")
capture = ChangeSetCapture(
plan_id="m2-plan-cs",
resource_id="res-m2",
sandbox_root=tmpdir,
)
wrapped = capture.wrap_tool(FILE_WRITE_SPEC)
# Write multiple files
wrapped.handler(
{
"path": "file_a.py",
"content": "# File A\nprint('hello')\n",
"sandbox_root": tmpdir,
}
)
wrapped.handler(
{
"path": "file_b.py",
"content": "# File B\nprint('world')\n",
"sandbox_root": tmpdir,
}
)
wrapped.handler(
{
"path": "README.md",
"content": "# Readme\nSample project\n",
"sandbox_root": tmpdir,
}
)
cs = capture.get_changeset()
assert isinstance(cs, ChangeSet)
assert len(cs.entries) == 3, f"Expected 3 entries, got {len(cs.entries)}"
# Verify each entry
paths = [e.path for e in cs.entries]
assert "file_a.py" in paths
assert "file_b.py" in paths
assert "README.md" in paths
# All operations should be 'create'
for entry in cs.entries:
assert entry.operation == "create", (
f"Expected 'create', got '{entry.operation}' for {entry.path}"
)
# Verify summary is a non-empty string describing 3 changes
summary_str = cs.summary()
assert "3" in summary_str, f"Expected '3' in summary: {summary_str}"
assert "create" in summary_str, f"Expected 'create' in summary: {summary_str}"
# Also verify via the domain-level SpecChangeSet summary (returns dict)
spec_cs = capture.to_spec_changeset()
spec_summary = spec_cs.summary()
assert spec_summary["total"] == 3
assert spec_summary["creates"] == 3
print("m2-changeset-multifile-ok")
# -----------------------------------------------------------------------
# Dispatcher
# -----------------------------------------------------------------------
_COMMANDS: dict[str, object] = {
"actor-yaml-create-load": actor_yaml_create_load,
"actor-add-config-cli": actor_add_config_cli,
"action-create": action_create,
"plan-use-execute": plan_use_execute,
"actor-yaml-parse-validate": actor_yaml_parse_validate,
"actor-compile-stategraph": actor_compile_stategraph,
"tool-router-resolve-external": tool_router_resolve_external,
"validation-runner-execute": validation_runner_execute,
"changeset-multifile": changeset_multifile,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>")
sys.exit(1)
fn = _COMMANDS[sys.argv[1]]
fn() # type: ignore[operator]