Files
temp/features/steps/context_tiers_steps.py
brent.edwards 6531440431 feat(actor): implement estimation actor type (#962)
## Summary

Implement the estimation actor as a functional actor type. The estimation actor provides cost, time, and resource estimates for plan operations, running after Strategize completes (before Execute). Estimation is informational only and optional — plans work without an estimation actor configured.

### Changes

**New files:**
- `src/cleveragents/domain/models/core/estimation.py` — `EstimationResult` frozen Pydantic model with fields for cost (USD), tokens, steps, child plans, time, risk level/factors, summary
- `features/estimation_actor.feature` — 12 Behave test scenarios (model validation, serialization, stub actor, plan integration, optional behavior)
- `features/steps/estimation_actor_steps.py` — Step definitions
- `robot/estimation_actor.robot` — 6 Robot integration tests
- `robot/helper_estimation_actor.py` — Robot test helper

**Modified files:**
- `domain/models/acms/tiers.py` — Added `ESTIMATOR` to `ActorRole` enum
- `domain/models/core/plan.py` — Added `estimation_result: EstimationResult | None` field, estimation data in `as_cli_dict()`
- `application/services/plan_executor.py` — Added `EstimationStubActor` class
- `application/services/plan_lifecycle_service.py` — Added `_run_estimation()` method, invoked in `execute_plan()`
- `cli/commands/plan.py` — Display estimation results in plan status
- `vulture_whitelist.py` — Added 12 new public symbols

### Quality Gates

| Session | Result |
|---|---|
| `nox -s lint` | PASS |
| `nox -s typecheck` | PASS (0 errors) |
| `nox -s unit_tests` | PASS (10,818 scenarios) |
| `nox -s integration_tests` | PASS (1,512 tests) |
| `nox -s coverage_report` | 98% (>= 97%) |

Closes #890

Reviewed-on: cleveragents/cleveragents-core#962
Co-authored-by: Brent Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent Edwards <brent.edwards@cleverthis.com>
2026-03-21 01:40:58 +00:00

452 lines
15 KiB
Python

"""Step definitions for the ACMS Context Tiers feature."""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from pydantic import ValidationError
from cleveragents.application.container import get_container, reset_container
from cleveragents.application.services.context_tiers import ContextTierService
from cleveragents.config.settings import Settings
from cleveragents.domain.models.acms.tiers import (
ActorContextView,
ActorRole,
ContextTier,
ScopedBackendView,
TierBudget,
TieredFragment,
TierMetrics,
)
__all__: list[str] = []
# ---------------------------------------------------------------------------
# ContextTier enum
# ---------------------------------------------------------------------------
@then('ContextTier should have values "hot", "warm", "cold"')
def step_then_context_tier_values(context: Any) -> None:
assert ContextTier.HOT == "hot"
assert ContextTier.WARM == "warm"
assert ContextTier.COLD == "cold"
assert len(ContextTier) == 3
# ---------------------------------------------------------------------------
# ActorRole enum
# ---------------------------------------------------------------------------
@then('ActorRole should have values "strategist", "executor", "reviewer", "estimator"')
def step_then_actor_role_values(context: Any) -> None:
assert ActorRole.STRATEGIST == "strategist"
assert ActorRole.EXECUTOR == "executor"
assert ActorRole.REVIEWER == "reviewer"
assert ActorRole.ESTIMATOR == "estimator"
assert "estimator" in [r.value for r in ActorRole]
# ---------------------------------------------------------------------------
# TieredFragment
# ---------------------------------------------------------------------------
@given('a TieredFragment with id "{fid}" and content "{content}"')
def step_given_tiered_fragment(context: Any, fid: str, content: str) -> None:
context.fragment = TieredFragment(fragment_id=fid, content=content)
@then('the fragment tier should be "{tier}"')
def step_then_fragment_tier(context: Any, tier: str) -> None:
assert context.fragment.tier == tier
@then("the fragment token_count should be {count:d}")
def step_then_fragment_token_count(context: Any, count: int) -> None:
assert context.fragment.token_count == count
@then("the fragment access_count should be {count:d}")
def step_then_fragment_access_count(context: Any, count: int) -> None:
assert context.fragment.access_count == count
@then("creating a TieredFragment with empty fragment_id should raise ValidationError")
def step_then_empty_fragment_id(context: Any) -> None:
try:
TieredFragment(fragment_id="", content="x")
raise AssertionError("Expected ValidationError")
except ValidationError:
pass
@then(
"creating a TieredFragment with negative token_count should raise ValidationError"
)
def step_then_negative_token_count(context: Any) -> None:
try:
TieredFragment(fragment_id="f", content="x", token_count=-1)
raise AssertionError("Expected ValidationError")
except ValidationError:
pass
# ---------------------------------------------------------------------------
# TierBudget
# ---------------------------------------------------------------------------
@given("a default TierBudget")
def step_given_default_tier_budget(context: Any) -> None:
context.budget = TierBudget()
@then("max_tokens_hot should be {val:d}")
def step_then_max_tokens_hot(context: Any, val: int) -> None:
assert context.budget.max_tokens_hot == val
@then("max_decisions_warm should be {val:d}")
def step_then_max_decisions_warm(context: Any, val: int) -> None:
assert context.budget.max_decisions_warm == val
@then("max_decisions_cold should be {val:d}")
def step_then_max_decisions_cold(context: Any, val: int) -> None:
assert context.budget.max_decisions_cold == val
# ---------------------------------------------------------------------------
# ActorContextView
# ---------------------------------------------------------------------------
@given('an ActorContextView for "{role}"')
def step_given_actor_context_view(context: Any, role: str) -> None:
context.actor_view = ActorContextView(actor_role=ActorRole(role))
@then('effective tiers should be "hot", "warm", "cold"')
def step_then_all_tiers(context: Any) -> None:
tiers = context.actor_view.get_effective_tiers()
assert tiers == [ContextTier.HOT, ContextTier.WARM, ContextTier.COLD]
@then('effective tiers should be "hot", "warm"')
def step_then_hot_warm(context: Any) -> None:
tiers = context.actor_view.get_effective_tiers()
assert tiers == [ContextTier.HOT, ContextTier.WARM]
@then('effective tiers should be "hot"')
def step_then_hot_only(context: Any) -> None:
tiers = context.actor_view.get_effective_tiers()
assert tiers == [ContextTier.HOT]
# ---------------------------------------------------------------------------
# TierMetrics
# ---------------------------------------------------------------------------
@given("a default TierMetrics")
def step_given_default_metrics(context: Any) -> None:
context.metrics = TierMetrics()
@then("total_fragments should be {val:d}")
def step_then_total_fragments(context: Any, val: int) -> None:
assert context.metrics.total_fragments == val
@then("hot_hit_rate should be {val:g}")
def step_then_hot_hit_rate(context: Any, val: float) -> None:
assert context.metrics.hot_hit_rate == val
# ---------------------------------------------------------------------------
# ScopedBackendView
# ---------------------------------------------------------------------------
@given('a ScopedBackendView for project "{proj}"')
def step_given_scoped_view(context: Any, proj: str) -> None:
context.scoped_view = ScopedBackendView(allowed_projects=[proj])
if not hasattr(context, "view_fragments"):
context.view_fragments = {}
@given('a TieredFragment with id "{fid}" and no project')
def step_given_fragment_no_project(context: Any, fid: str) -> None:
if not hasattr(context, "view_fragments"):
context.view_fragments = {}
context.view_fragments[fid] = TieredFragment(
fragment_id=fid, content="x", project_name=""
)
@given('a TieredFragment with id "{fid}" in project "{proj}"')
def step_given_fragment_in_project(context: Any, fid: str, proj: str) -> None:
if not hasattr(context, "view_fragments"):
context.view_fragments = {}
context.view_fragments[fid] = TieredFragment(
fragment_id=fid, content="x", project_name=proj
)
@then('fragment "{fid}" should be visible')
def step_then_fragment_visible(context: Any, fid: str) -> None:
frag = context.view_fragments[fid]
assert context.scoped_view.is_visible(frag)
@then('fragment "{fid}" should not be visible')
def step_then_fragment_not_visible(context: Any, fid: str) -> None:
frag = context.view_fragments[fid]
assert not context.scoped_view.is_visible(frag)
# ---------------------------------------------------------------------------
# ContextTierService: store/get
# ---------------------------------------------------------------------------
@given("a ContextTierService")
def step_given_tier_service(context: Any) -> None:
context.tier_service = ContextTierService()
@when('I store a fragment with id "{fid}" content "{content}" in tier "{tier}"')
def step_when_store_fragment(context: Any, fid: str, content: str, tier: str) -> None:
frag = TieredFragment(
fragment_id=fid,
content=content,
tier=ContextTier(tier),
)
context.tier_service.store(frag)
@when(
'I store fragment "{fid}" with content "{content}" tier "{tier}" project "{proj}"'
)
def step_when_store_fragment_project(
context: Any, fid: str, content: str, tier: str, proj: str
) -> None:
frag = TieredFragment(
fragment_id=fid,
content=content,
tier=ContextTier(tier),
project_name=proj,
)
context.tier_service.store(frag)
@when('I store a long fragment "{fid}" in tier "{tier}"')
def step_when_store_long_fragment(context: Any, fid: str, tier: str) -> None:
long_content = "x" * 300 # Longer than _COLD_SUMMARY_MAX_CHARS (200)
frag = TieredFragment(
fragment_id=fid,
content=long_content,
tier=ContextTier(tier),
)
context.tier_service.store(frag)
@then('tier getting "{fid}" should return content "{content}"')
def step_then_get_content(context: Any, fid: str, content: str) -> None:
result = context.tier_service.get(fid)
assert result is not None
assert result.content == content
@then('tier getting "{fid}" should return None')
def step_then_get_none(context: Any, fid: str) -> None:
assert context.tier_service.get(fid) is None
# ---------------------------------------------------------------------------
# ContextTierService: promote/demote
# ---------------------------------------------------------------------------
@when('I promote "{fid}"')
def step_when_promote(context: Any, fid: str) -> None:
context.last_promoted = context.tier_service.promote(fid)
@when('I demote "{fid}"')
def step_when_demote(context: Any, fid: str) -> None:
context.last_demoted = context.tier_service.demote(fid)
@then('"{fid}" should be in tier "{tier}"')
def step_then_in_tier(context: Any, fid: str, tier: str) -> None:
result = context.tier_service.get(fid)
assert result is not None
assert result.tier == tier
@then('promoting "{fid}" should return None')
def step_then_promote_none(context: Any, fid: str) -> None:
assert context.tier_service.promote(fid) is None
@then('demoting "{fid}" should return None')
def step_then_demote_none(context: Any, fid: str) -> None:
assert context.tier_service.demote(fid) is None
@then('"{fid}" content should be summarized')
def step_then_content_summarized(context: Any, fid: str) -> None:
result = context.tier_service.get(fid)
assert result is not None
# Summarised content ends with "..." if original was long
assert result.content.endswith("...")
# ---------------------------------------------------------------------------
# ContextTierService: LRU eviction
# ---------------------------------------------------------------------------
@when('I evict {count:d} LRU fragments from "{tier}"')
def step_when_evict(context: Any, count: int, tier: str) -> None:
context.evicted = context.tier_service.evict_lru(ContextTier(tier), count)
@then('"{tier}" tier should have {count:d} fragment')
def step_then_tier_count_singular(context: Any, tier: str, count: int) -> None:
metrics = context.tier_service.get_metrics()
if tier == "hot":
assert metrics.hot_count == count
elif tier == "warm":
assert metrics.warm_count == count
else:
assert metrics.cold_count == count
@then('evicting {count:d} from "{tier}" should raise ValueError')
def step_then_evict_raises(context: Any, count: int, tier: str) -> None:
try:
context.tier_service.evict_lru(ContextTier(tier), count)
raise AssertionError("Expected ValueError")
except ValueError:
pass
# ---------------------------------------------------------------------------
# ContextTierService: actor views
# ---------------------------------------------------------------------------
@then('strategist for project "{proj}" should see {count:d} fragments')
def step_then_strategist_sees(context: Any, proj: str, count: int) -> None:
frags = context.tier_service.get_for_actor(
ActorRole.STRATEGIST, project_names=[proj]
)
assert len(frags) == count
@then('executor for project "{proj}" should see {count:d} fragments')
def step_then_executor_sees(context: Any, proj: str, count: int) -> None:
frags = context.tier_service.get_for_actor(ActorRole.EXECUTOR, project_names=[proj])
assert len(frags) == count
@then('reviewer for project "{proj}" should see {count:d} fragment')
def step_then_reviewer_sees(context: Any, proj: str, count: int) -> None:
frags = context.tier_service.get_for_actor(ActorRole.REVIEWER, project_names=[proj])
assert len(frags) == count
# ---------------------------------------------------------------------------
# ContextTierService: scoped view
# ---------------------------------------------------------------------------
@then('scoped view for "{proj}" should have {count:d} fragment')
def step_then_scoped_count(context: Any, proj: str, count: int) -> None:
frags = context.tier_service.get_scoped_view([proj])
assert len(frags) == count
# ---------------------------------------------------------------------------
# ContextTierService: metrics
# ---------------------------------------------------------------------------
@when('I get fragment "{fid}"')
def step_when_get_fragment(context: Any, fid: str) -> None:
context.tier_service.get(fid)
@then("hot_hit_count should be {val:d}")
def step_then_hot_hit_count(context: Any, val: int) -> None:
metrics = context.tier_service.get_metrics()
assert metrics.hot_hit_count == val
@then("hot_miss_count should be {val:d}")
def step_then_hot_miss_count(context: Any, val: int) -> None:
metrics = context.tier_service.get_metrics()
assert metrics.hot_miss_count == val
# ---------------------------------------------------------------------------
# DI Container
# ---------------------------------------------------------------------------
@given("the DI container is reset")
def step_given_di_reset(context: Any) -> None:
reset_container()
context.container = get_container()
@then("the container should provide a context_tier_service")
def step_then_container_has_tier_service(context: Any) -> None:
context.resolved_tier_svc = context.container.context_tier_service()
assert context.resolved_tier_svc is not None
@then("the context_tier_service should be a ContextTierService")
def step_then_tier_svc_type(context: Any) -> None:
assert isinstance(context.resolved_tier_svc, ContextTierService)
@then("resolving context_tier_service twice should return the same instance")
def step_then_tier_svc_singleton(context: Any) -> None:
a = context.container.context_tier_service()
b = context.container.context_tier_service()
assert a is b
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
@then("settings should have context_max_tokens_hot")
def step_then_settings_hot(context: Any) -> None:
s = Settings()
assert hasattr(s, "context_max_tokens_hot")
assert s.context_max_tokens_hot >= 0
@then("settings should have context_max_decisions_warm")
def step_then_settings_warm(context: Any) -> None:
s = Settings()
assert hasattr(s, "context_max_decisions_warm")
assert s.context_max_decisions_warm >= 0
@then("settings should have context_max_decisions_cold")
def step_then_settings_cold(context: Any) -> None:
s = Settings()
assert hasattr(s, "context_max_decisions_cold")
assert s.context_max_decisions_cold >= 0