Files
cleveragents-core/robot/helper_m5_e2e_context.py
HAL9000 2dd920078a fix(tests): add resolve_actor_options to mock lifecycle helpers
The PR added resolve_actor_options() to StrategyActor, LLMStrategizeActor,
and LLMExecuteActor, but the three mock lifecycle SimpleNamespace objects
used by tests did not include this method, causing AttributeError at
runtime:

- features/mocks/mock_strategy_llm.py: make_mock_lifecycle() used by
  robot/helper_strategy_actor.py (strategy_actor.robot llm-json test)
  and features/steps/strategy_actor_llm_steps.py
- features/steps/llm_actors_coverage_steps.py: _make_mock_lifecycle()
  used by llm_actors_coverage.feature scenarios
- robot/helper_m5_e2e_context.py: inline lifecycle SimpleNamespace
  used by m5_e2e_verification.robot Execute Phase LLM Uses ACMS Context

All three now include resolve_actor_options=MagicMock(return_value=None)
matching the None-return contract the actors use when no custom backend
is configured.

ISSUES CLOSED: #11256
2026-05-28 17:35:08 -04:00

511 lines
19 KiB
Python

"""Context-policy verification commands for M5 Robot E2E tests."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock
from helper_m5_e2e_support import _create_project, _fail, _setup_db
from helpers_common import reset_global_state
from cleveragents.application.services.context_phase_analysis import (
analyze_phase_summaries,
)
from cleveragents.application.services.context_tiers import ContextTierService
from cleveragents.application.services.execute_phase_context_assembler import (
ACMSExecutePhaseContextAssembler,
)
from cleveragents.application.services.llm_actors import LLMExecuteActor
from cleveragents.application.services.plan_executor import StrategyDecision
from cleveragents.cli.commands.project_context import _read_policy, _write_policy
from cleveragents.config.settings import Settings
from cleveragents.domain.models.acms.tiers import ContextTier, TieredFragment
from cleveragents.domain.models.core.context_policy import (
VALID_PHASES,
ContextView,
ProjectContextPolicy,
)
from cleveragents.domain.models.core.project import ContextConfig, NamespacedProject
def context_tier_config() -> None:
"""Verify ContextConfig with hot/warm/cold tier management."""
reset_global_state()
default_cfg = ContextConfig()
if default_cfg.hot_max_tokens is not None:
_fail(f"default hot_max_tokens should be None: {default_cfg.hot_max_tokens}")
if default_cfg.warm_max_decisions is not None:
_fail(
f"default warm_max_decisions should be None: "
f"{default_cfg.warm_max_decisions}"
)
if default_cfg.cold_max_decisions is not None:
_fail(
f"default cold_max_decisions should be None: "
f"{default_cfg.cold_max_decisions}"
)
tier_cfg = ContextConfig(
hot_max_tokens=128_000,
warm_max_decisions=50,
cold_max_decisions=200,
max_file_size=2_000_000,
max_total_size=100_000_000,
)
if tier_cfg.hot_max_tokens != 128_000:
_fail(f"hot_max_tokens mismatch: {tier_cfg.hot_max_tokens}")
if tier_cfg.warm_max_decisions != 50:
_fail(f"warm_max_decisions mismatch: {tier_cfg.warm_max_decisions}")
if tier_cfg.cold_max_decisions != 200:
_fail(f"cold_max_decisions mismatch: {tier_cfg.cold_max_decisions}")
if tier_cfg.max_file_size != 2_000_000:
_fail(f"max_file_size mismatch: {tier_cfg.max_file_size}")
if tier_cfg.max_total_size != 100_000_000:
_fail(f"max_total_size mismatch: {tier_cfg.max_total_size}")
proj = NamespacedProject(
name="tier-test",
namespace="local",
context_config=tier_cfg,
)
if proj.context_config.hot_max_tokens != 128_000:
_fail("project did not preserve hot_max_tokens")
if proj.context_config.warm_max_decisions != 50:
_fail("project did not preserve warm_max_decisions")
if proj.context_config.cold_max_decisions != 200:
_fail("project did not preserve cold_max_decisions")
print("m5-context-tier-config-ok")
def context_policy_set_show() -> None:
"""Set a context policy via SQL helpers and read it back."""
reset_global_state()
proj_repo, _link_repo, _svc, sf = _setup_db()
_create_project(proj_repo, "local/policy-test")
policy = ProjectContextPolicy(
default_view=ContextView(
include_resources=["local/repo-*"],
exclude_paths=["*.log", "*.tmp"],
max_file_size=1_048_576,
max_total_size=52_428_800,
),
strategize_view=ContextView(
include_resources=["local/core-*"],
include_paths=["src/**/*.py"],
max_file_size=500_000,
),
)
_write_policy(sf, "local/policy-test", policy)
loaded = _read_policy(sf, "local/policy-test")
if loaded.default_view.include_resources != ["local/repo-*"]:
_fail(
f"default include_resources mismatch: "
f"{loaded.default_view.include_resources}"
)
if loaded.default_view.exclude_paths != ["*.log", "*.tmp"]:
_fail(f"default exclude_paths mismatch: {loaded.default_view.exclude_paths}")
if loaded.default_view.max_file_size != 1_048_576:
_fail(f"default max_file_size mismatch: {loaded.default_view.max_file_size}")
if loaded.strategize_view is None:
_fail("strategize_view is None after round-trip")
if loaded.strategize_view.include_resources != ["local/core-*"]:
_fail(
f"strategize include_resources mismatch: "
f"{loaded.strategize_view.include_resources}"
)
if loaded.strategize_view.include_paths != ["src/**/*.py"]:
_fail(
f"strategize include_paths mismatch: {loaded.strategize_view.include_paths}"
)
if loaded.execute_view is not None:
_fail("execute_view should be None")
if loaded.apply_view is not None:
_fail("apply_view should be None")
print("m5-context-policy-set-show-ok")
def acms_phase_inheritance() -> None:
"""Verify ACMS view inheritance: default -> strategize -> execute -> apply."""
reset_global_state()
policy = ProjectContextPolicy(
default_view=ContextView(
include_resources=["*"],
max_total_size=50_000_000,
),
strategize_view=ContextView(
include_resources=["core-*"],
max_file_size=1_000_000,
),
)
rv_default = policy.resolve_view("default")
if rv_default.include_resources != ["*"]:
_fail(f"default resolution wrong: {rv_default.include_resources}")
if rv_default.max_total_size != 50_000_000:
_fail(f"default max_total_size wrong: {rv_default.max_total_size}")
rv_strat = policy.resolve_view("strategize")
if rv_strat.include_resources != ["core-*"]:
_fail(f"strategize resolution wrong: {rv_strat.include_resources}")
if rv_strat.max_file_size != 1_000_000:
_fail(f"strategize max_file_size wrong: {rv_strat.max_file_size}")
rv_exec = policy.resolve_view("execute")
if rv_exec.include_resources != ["core-*"]:
_fail(f"execute should inherit strategize: {rv_exec.include_resources}")
rv_apply = policy.resolve_view("apply")
if rv_apply.include_resources != ["core-*"]:
_fail(f"apply should inherit via chain: {rv_apply.include_resources}")
policy2 = ProjectContextPolicy(
default_view=ContextView(include_resources=["*"]),
strategize_view=ContextView(include_resources=["core-*"]),
execute_view=ContextView(
include_resources=["exec-*"],
exclude_paths=["tests/**"],
),
)
rv_apply2 = policy2.resolve_view("apply")
if rv_apply2.include_resources != ["exec-*"]:
_fail(f"apply should inherit from execute: {rv_apply2.include_resources}")
if rv_apply2.exclude_paths != ["tests/**"]:
_fail(f"apply exclude_paths wrong: {rv_apply2.exclude_paths}")
print("m5-acms-phase-inheritance-ok")
def acms_scoped_context() -> None:
"""Verify ACMS produces scoped context output per phase."""
reset_global_state()
policy = ProjectContextPolicy(
default_view=ContextView(
include_resources=["*"],
max_file_size=2_000_000,
max_total_size=100_000_000,
),
strategize_view=ContextView(
include_resources=["local/large-repo"],
include_paths=["src/**/*.py", "docs/**/*.md"],
exclude_paths=["**/test_*.py"],
max_file_size=1_000_000,
),
execute_view=ContextView(
include_resources=["local/large-repo"],
include_paths=["src/**/*.py"],
exclude_paths=["**/test_*.py", "**/conftest.py"],
max_file_size=500_000,
max_total_size=1_200_000,
),
apply_view=ContextView(
include_resources=["local/large-repo"],
include_paths=["src/**/*.py"],
exclude_paths=["docs/**", "**/test_*.py", "**/conftest.py"],
max_file_size=250_000,
max_total_size=700_000,
),
)
for phase in ("default", "strategize", "execute", "apply"):
rv = policy.resolve_view(phase)
expected_attr = getattr(policy, f"{phase}_view")
if expected_attr is None:
_fail(f"phase {phase} view should not be None")
if rv.include_resources != expected_attr.include_resources:
_fail(
f"{phase} include_resources mismatch: "
f"{rv.include_resources} != {expected_attr.include_resources}"
)
strat_view = policy.resolve_view("strategize")
exec_view = policy.resolve_view("execute")
apply_view = policy.resolve_view("apply")
if strat_view.max_file_size is None:
_fail("strategize max_file_size is None")
if exec_view.max_file_size is None:
_fail("execute max_file_size is None")
if apply_view.max_file_size is None:
_fail("apply max_file_size is None")
if not (
strat_view.max_file_size > exec_view.max_file_size > apply_view.max_file_size
):
_fail(
f"size limits should narrow: "
f"{strat_view.max_file_size} > {exec_view.max_file_size} > "
f"{apply_view.max_file_size}"
)
sample_fragments = [
TieredFragment(
fragment_id="f-core",
content="x" * 900,
tier=ContextTier.HOT,
resource_id="local/large-repo",
project_name="local/scoped-test",
token_count=900,
metadata={"path": "src/core.py", "byte_size": 900_000},
),
TieredFragment(
fragment_id="f-engine",
content="x" * 500,
tier=ContextTier.HOT,
resource_id="local/large-repo",
project_name="local/scoped-test",
token_count=500,
metadata={"path": "src/engine.py", "byte_size": 450_000},
),
TieredFragment(
fragment_id="f-test",
content="x" * 120,
tier=ContextTier.WARM,
resource_id="local/large-repo",
project_name="local/scoped-test",
token_count=120,
metadata={"path": "src/test_core.py", "byte_size": 100_000},
),
TieredFragment(
fragment_id="f-doc",
content="x" * 180,
tier=ContextTier.WARM,
resource_id="local/large-repo",
project_name="local/scoped-test",
token_count=180,
metadata={"path": "docs/design.md", "byte_size": 200_000},
),
TieredFragment(
fragment_id="f-conftest",
content="x" * 90,
tier=ContextTier.COLD,
resource_id="local/large-repo",
project_name="local/scoped-test",
token_count=90,
metadata={"path": "src/conftest.py", "byte_size": 80_000},
),
TieredFragment(
fragment_id="f-huge",
content="x" * 1500,
tier=ContextTier.COLD,
resource_id="local/large-repo",
project_name="local/scoped-test",
token_count=1500,
metadata={"path": "src/huge.py", "byte_size": 1_500_000},
),
]
analysis = analyze_phase_summaries(policy, sample_fragments, budget_tokens=2_000)
for phase in ("strategize", "execute", "apply"):
phase_row = analysis["phases"][phase]
if not phase_row["summary"]:
_fail(f"{phase} summary should be non-empty")
if "resource_count" not in phase_row or "total_bytes" not in phase_row:
_fail(f"{phase} summary missing resource/size metrics")
narrowing = analysis["narrowing"]
if not narrowing["overall"]:
_fail(f"narrowing should hold across phases: {narrowing}")
proj_repo, _link_repo, _svc, sf = _setup_db()
_create_project(proj_repo, "local/scoped-test")
_write_policy(sf, "local/scoped-test", policy)
loaded = _read_policy(sf, "local/scoped-test")
for phase in ("default", "strategize", "execute", "apply"):
rv_loaded = loaded.resolve_view(phase)
rv_orig = policy.resolve_view(phase)
if rv_loaded.include_resources != rv_orig.include_resources:
_fail(f"{phase} round-trip include_resources mismatch")
if rv_loaded.max_file_size != rv_orig.max_file_size:
_fail(f"{phase} round-trip max_file_size mismatch")
print("m5-acms-scoped-context-ok")
def context_policy_clear() -> None:
"""Verify clearing a phase view causes inheritance fallback."""
reset_global_state()
policy = ProjectContextPolicy(
default_view=ContextView(include_resources=["all-*"]),
strategize_view=ContextView(include_resources=["strat-*"]),
execute_view=ContextView(include_resources=["exec-*"]),
apply_view=ContextView(include_resources=["apply-*"]),
)
rv = policy.resolve_view("execute")
if rv.include_resources != ["exec-*"]:
_fail(f"before clear, execute wrong: {rv.include_resources}")
cleared = policy.model_copy(update={"execute_view": None})
rv_after = cleared.resolve_view("execute")
if rv_after.include_resources != ["strat-*"]:
_fail(
f"after clear, execute should inherit strategize: "
f"{rv_after.include_resources}"
)
rv_apply = cleared.resolve_view("apply")
if rv_apply.include_resources != ["apply-*"]:
_fail(f"apply should still be its own: {rv_apply.include_resources}")
cleared2 = cleared.model_copy(update={"apply_view": None})
rv_apply2 = cleared2.resolve_view("apply")
if rv_apply2.include_resources != ["strat-*"]:
_fail(
f"after clearing apply+execute, apply should inherit strategize: "
f"{rv_apply2.include_resources}"
)
proj_repo, _link_repo, _svc, sf = _setup_db()
_create_project(proj_repo, "local/clear-test")
_write_policy(sf, "local/clear-test", cleared2)
loaded = _read_policy(sf, "local/clear-test")
if loaded.execute_view is not None:
_fail("execute_view should be None after round-trip")
if loaded.apply_view is not None:
_fail("apply_view should be None after round-trip")
rv_loaded = loaded.resolve_view("execute")
if rv_loaded.include_resources != ["strat-*"]:
_fail(f"round-trip execute fallback wrong: {rv_loaded.include_resources}")
print("m5-context-policy-clear-ok")
def context_view_validation() -> None:
"""Verify ContextView validation rules."""
reset_global_state()
cv = ContextView(max_file_size=1, max_total_size=1)
if cv.max_file_size != 1:
_fail(f"max_file_size should be 1: {cv.max_file_size}")
try:
ContextView(max_file_size=0)
_fail("max_file_size=0 should be rejected")
except ValueError:
pass
try:
ContextView(max_total_size=-1)
_fail("max_total_size=-1 should be rejected")
except ValueError:
pass
cv_none = ContextView(max_file_size=None, max_total_size=None)
if cv_none.max_file_size is not None:
_fail("max_file_size should be None")
expected_phases = {"default", "strategize", "execute", "apply"}
if expected_phases != VALID_PHASES:
_fail(f"VALID_PHASES mismatch: {VALID_PHASES} != {expected_phases}")
policy = ProjectContextPolicy()
try:
policy.resolve_view("invalid-phase")
_fail("resolve_view should reject invalid phase")
except ValueError:
pass
print("m5-context-view-validation-ok")
def llm_execute_acms_context() -> None:
"""Verify execute-phase LLM prompt uses ACMS assembled execute context."""
reset_global_state()
proj_repo, _link_repo, _svc, sf = _setup_db()
_create_project(proj_repo, "local/exec-ctx")
policy = ProjectContextPolicy(
default_view=ContextView(include_paths=["**/*"]),
strategize_view=ContextView(include_paths=["docs/**"]),
execute_view=ContextView(
include_paths=["src/**"],
max_file_size=100_000,
max_total_size=100_000,
),
)
_write_policy(sf, "local/exec-ctx", policy)
tier_service = ContextTierService(settings=Settings())
tier_service.store(
TieredFragment(
fragment_id="frag-docs",
content="documentation fragment",
tier=ContextTier.HOT,
resource_id="local/large-repo",
project_name="local/exec-ctx",
token_count=20,
metadata={"path": "docs/readme.md", "relevance_score": "0.6"},
)
)
tier_service.store(
TieredFragment(
fragment_id="frag-src",
content="def run():\n return 'ok'",
tier=ContextTier.HOT,
resource_id="local/large-repo",
project_name="local/exec-ctx",
token_count=16,
metadata={"path": "src/main.py", "relevance_score": "0.9"},
)
)
assembler = ACMSExecutePhaseContextAssembler(
context_tier_service=tier_service,
project_repository=proj_repo,
hot_max_tokens=1024,
)
mock_llm = MagicMock()
mock_llm.invoke.return_value = SimpleNamespace(
content="FILE: src/out.py\n```python\nprint('ok')\n```\n"
)
registry = cast(Any, SimpleNamespace(create_llm=MagicMock(return_value=mock_llm)))
plan = SimpleNamespace(
identity=SimpleNamespace(plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV"),
action_name="local/action",
project_links=[SimpleNamespace(project_name="local/exec-ctx")],
)
action = SimpleNamespace(execution_actor="openai/gpt-4")
lifecycle = SimpleNamespace(
get_plan=MagicMock(return_value=plan),
get_action=MagicMock(return_value=action),
resolve_actor_provider_model=MagicMock(return_value=None),
resolve_actor_options=MagicMock(return_value=None),
)
actor = LLMExecuteActor(
provider_registry=registry,
lifecycle_service=lifecycle,
context_assembler=assembler,
)
actor.execute(
plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
decisions=[
StrategyDecision(
decision_id="d1",
step_text="Implement feature",
sequence=0,
parent_id=None,
)
],
stream_callback=None,
)
call_args = mock_llm.invoke.call_args
if call_args is None:
_fail("LLM invoke was not called")
prompt = call_args.args[0][0].content
if "ACMS Execute-Phase Context" not in prompt:
_fail("prompt missing ACMS execute-phase context section")
if "src/main.py" not in prompt:
_fail("prompt missing execute-view source fragment")
if "docs/readme.md" in prompt:
_fail("prompt should exclude strategize-only docs fragment in execute view")
print("m5-llm-execute-acms-context-ok")