feat(context): add hot/warm/cold tiers and actor views
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 18s
CI / quality (pull_request) Successful in 18s
CI / typecheck (pull_request) Successful in 34s
CI / security (pull_request) Successful in 48s
CI / unit_tests (pull_request) Successful in 2m16s
CI / docker (pull_request) Successful in 39s
CI / integration_tests (pull_request) Successful in 3m0s
CI / coverage (pull_request) Successful in 3m58s
CI / lint (push) Successful in 13s
CI / build (push) Successful in 15s
CI / quality (push) Successful in 17s
CI / typecheck (push) Successful in 31s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 35s
CI / unit_tests (push) Successful in 3m23s
CI / docker (push) Successful in 38s
CI / integration_tests (push) Successful in 4m7s
CI / coverage (push) Successful in 4m48s
CI / benchmark-publish (push) Successful in 14m14s
CI / benchmark-regression (pull_request) Successful in 24m27s
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 18s
CI / quality (pull_request) Successful in 18s
CI / typecheck (pull_request) Successful in 34s
CI / security (pull_request) Successful in 48s
CI / unit_tests (pull_request) Successful in 2m16s
CI / docker (pull_request) Successful in 39s
CI / integration_tests (pull_request) Successful in 3m0s
CI / coverage (pull_request) Successful in 3m58s
CI / lint (push) Successful in 13s
CI / build (push) Successful in 15s
CI / quality (push) Successful in 17s
CI / typecheck (push) Successful in 31s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 35s
CI / unit_tests (push) Successful in 3m23s
CI / docker (push) Successful in 38s
CI / integration_tests (push) Successful in 4m7s
CI / coverage (push) Successful in 4m48s
CI / benchmark-publish (push) Successful in 14m14s
CI / benchmark-regression (pull_request) Successful in 24m27s
Implement hot/warm/cold context tiers with ContextTier, ActorRole, TieredFragment, TierBudget, ActorContextView, TierMetrics, and ScopedBackendView models. ContextTierService provides store/get, promotion/demotion with cold-tier summarisation hook, LRU eviction, per-actor filtered views (strategist/executor/reviewer), and project-scoped isolation via ScopedBackendView. Settings: context_max_tokens_hot, context_max_decisions_warm, context_max_decisions_cold. DI-wired as singleton context_tier_service. Includes Behave BDD scenarios (30), Robot Framework integration tests (7), ASV benchmarks (5 suites), and docs/reference/context_tiers.md. Closes #208
This commit was merged in pull request #530.
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Added hot/warm/cold context tiers with `ContextTier`, `ActorRole`, `TieredFragment`,
|
||||
`TierBudget`, `ActorContextView`, `TierMetrics`, and `ScopedBackendView` models.
|
||||
`ContextTierService` provides store/get, promotion/demotion with cold-tier summarisation
|
||||
hook, LRU eviction, per-actor filtered views (strategist/executor/reviewer), and
|
||||
project-scoped isolation. Settings: `context_max_tokens_hot`, `context_max_decisions_warm`,
|
||||
`context_max_decisions_cold`. DI-wired as singleton `context_tier_service`. Includes Behave
|
||||
BDD scenarios, Robot Framework integration tests, ASV benchmarks, and
|
||||
`docs/reference/context_tiers.md`. (#208)
|
||||
- Added `SafetyProfile` domain model with configurable safety constraints (allowed skill
|
||||
categories, sandbox/checkpoint requirements, human-approval flag, cost/retry limits) and
|
||||
integrated it into the `Action` model via `from_config`/`as_cli_dict`. Persistence backed
|
||||
|
||||
@@ -202,5 +202,5 @@ class CoverageFileSuite:
|
||||
return 0
|
||||
|
||||
|
||||
setattr(CoverageFileSuite.track_source_file_count, "unit", "files")
|
||||
setattr(CoverageFileSuite.track_source_total_bytes, "unit", "bytes")
|
||||
CoverageFileSuite.track_source_file_count.unit = "files"
|
||||
CoverageFileSuite.track_source_total_bytes.unit = "bytes"
|
||||
|
||||
@@ -52,9 +52,9 @@ class SubprocessCountSuite:
|
||||
return 0
|
||||
|
||||
|
||||
setattr(SubprocessCountSuite.track_subprocess_count, "unit", "subprocesses")
|
||||
setattr(SubprocessCountSuite.track_recursive_feature_count, "unit", "features")
|
||||
setattr(SubprocessCountSuite.track_step_definition_count, "unit", "files")
|
||||
SubprocessCountSuite.track_subprocess_count.unit = "subprocesses"
|
||||
SubprocessCountSuite.track_recursive_feature_count.unit = "features"
|
||||
SubprocessCountSuite.track_step_definition_count.unit = "files"
|
||||
|
||||
|
||||
class PerFeatureOverheadSuite:
|
||||
@@ -149,5 +149,5 @@ class WorkerPoolSizingSuite:
|
||||
return max(1, math.ceil(self.feature_count / cpus))
|
||||
|
||||
|
||||
setattr(WorkerPoolSizingSuite.track_features_per_cpu, "unit", "features/cpu")
|
||||
setattr(WorkerPoolSizingSuite.track_optimal_chunk_size, "unit", "features/chunk")
|
||||
WorkerPoolSizingSuite.track_features_per_cpu.unit = "features/cpu"
|
||||
WorkerPoolSizingSuite.track_optimal_chunk_size.unit = "features/chunk"
|
||||
|
||||
@@ -87,8 +87,8 @@ class StepModuleDiscoverySuite:
|
||||
return sum(f.stat().st_size for f in self.steps_dir.glob("*.py"))
|
||||
|
||||
|
||||
setattr(StepModuleDiscoverySuite.track_step_file_count, "unit", "files")
|
||||
setattr(StepModuleDiscoverySuite.track_step_total_bytes, "unit", "bytes")
|
||||
StepModuleDiscoverySuite.track_step_file_count.unit = "files"
|
||||
StepModuleDiscoverySuite.track_step_total_bytes.unit = "bytes"
|
||||
|
||||
|
||||
class ParallelChunkSuite:
|
||||
@@ -195,6 +195,6 @@ class TrackTestSuiteMetrics:
|
||||
return count
|
||||
|
||||
|
||||
setattr(TrackTestSuiteMetrics.track_feature_file_count, "unit", "features")
|
||||
setattr(TrackTestSuiteMetrics.track_total_feature_bytes, "unit", "bytes")
|
||||
setattr(TrackTestSuiteMetrics.track_scenario_line_count, "unit", "scenarios")
|
||||
TrackTestSuiteMetrics.track_feature_file_count.unit = "features"
|
||||
TrackTestSuiteMetrics.track_total_feature_bytes.unit = "bytes"
|
||||
TrackTestSuiteMetrics.track_scenario_line_count.unit = "scenarios"
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""ASV benchmarks for ACMS Context Tiers.
|
||||
|
||||
Measures the performance of:
|
||||
- TieredFragment construction
|
||||
- ContextTierService store/get operations
|
||||
- Tier promotion and demotion
|
||||
- LRU eviction
|
||||
- Actor view filtering
|
||||
- Project scoping
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the local *source* tree is importable even when ASV has an
|
||||
# older build of the package installed.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
# Force-reload so ASV picks up the source tree version.
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.application.services.context_tiers import ( # noqa: E402
|
||||
ContextTierService,
|
||||
)
|
||||
from cleveragents.domain.models.acms.tiers import ( # noqa: E402
|
||||
ActorRole,
|
||||
ContextTier,
|
||||
ScopedBackendView,
|
||||
TierBudget,
|
||||
TieredFragment,
|
||||
TierMetrics,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model construction benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TierModelConstructionSuite:
|
||||
"""Benchmark tier model object creation overhead."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def time_create_tiered_fragment(self) -> None:
|
||||
TieredFragment(fragment_id="bench-001", content="benchmark data")
|
||||
|
||||
def time_create_tier_budget(self) -> None:
|
||||
TierBudget()
|
||||
|
||||
def time_create_tier_metrics(self) -> None:
|
||||
TierMetrics()
|
||||
|
||||
def time_create_scoped_view(self) -> None:
|
||||
ScopedBackendView(allowed_projects=["proj-a", "proj-b"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service operation benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TierServiceStoreSuite:
|
||||
"""Benchmark ContextTierService store/get operations."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.service = ContextTierService()
|
||||
self.fragment = TieredFragment(
|
||||
fragment_id="bench-store",
|
||||
content="benchmark content",
|
||||
tier=ContextTier.HOT,
|
||||
)
|
||||
|
||||
def time_store_fragment(self) -> None:
|
||||
self.service.store(self.fragment)
|
||||
|
||||
def time_get_fragment(self) -> None:
|
||||
self.service.store(self.fragment)
|
||||
self.service.get("bench-store")
|
||||
|
||||
def time_get_miss(self) -> None:
|
||||
self.service.get("nonexistent")
|
||||
|
||||
|
||||
class TierServicePromotionSuite:
|
||||
"""Benchmark tier promotion and demotion."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.service = ContextTierService()
|
||||
|
||||
def time_promote_cold_to_warm(self) -> None:
|
||||
frag = TieredFragment(
|
||||
fragment_id="promote-bench",
|
||||
content="data",
|
||||
tier=ContextTier.COLD,
|
||||
)
|
||||
self.service.store(frag)
|
||||
self.service.promote("promote-bench")
|
||||
|
||||
def time_demote_hot_to_warm(self) -> None:
|
||||
frag = TieredFragment(
|
||||
fragment_id="demote-bench",
|
||||
content="data",
|
||||
tier=ContextTier.HOT,
|
||||
)
|
||||
self.service.store(frag)
|
||||
self.service.demote("demote-bench")
|
||||
|
||||
|
||||
class TierServiceEvictionSuite:
|
||||
"""Benchmark LRU eviction performance."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.service = ContextTierService()
|
||||
for i in range(100):
|
||||
self.service.store(
|
||||
TieredFragment(
|
||||
fragment_id=f"evict-{i}",
|
||||
content=f"data-{i}",
|
||||
tier=ContextTier.HOT,
|
||||
)
|
||||
)
|
||||
|
||||
def time_evict_10_from_100(self) -> None:
|
||||
self.service.evict_lru(ContextTier.HOT, 10)
|
||||
|
||||
|
||||
class TierServiceActorViewSuite:
|
||||
"""Benchmark per-actor filtered view performance."""
|
||||
|
||||
timeout = 60
|
||||
|
||||
def setup(self) -> None:
|
||||
self.service = ContextTierService()
|
||||
for i in range(50):
|
||||
tier = [ContextTier.HOT, ContextTier.WARM, ContextTier.COLD][i % 3]
|
||||
self.service.store(
|
||||
TieredFragment(
|
||||
fragment_id=f"actor-{i}",
|
||||
content=f"data-{i}",
|
||||
tier=tier,
|
||||
project_name="bench-proj",
|
||||
)
|
||||
)
|
||||
|
||||
def time_strategist_view(self) -> None:
|
||||
self.service.get_for_actor(ActorRole.STRATEGIST, ["bench-proj"])
|
||||
|
||||
def time_executor_view(self) -> None:
|
||||
self.service.get_for_actor(ActorRole.EXECUTOR, ["bench-proj"])
|
||||
|
||||
def time_reviewer_view(self) -> None:
|
||||
self.service.get_for_actor(ActorRole.REVIEWER, ["bench-proj"])
|
||||
|
||||
def time_scoped_view(self) -> None:
|
||||
self.service.get_scoped_view(["bench-proj"])
|
||||
@@ -32,7 +32,7 @@ except ModuleNotFoundError:
|
||||
from cleveragents.application.services.decision_service import DecisionService
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.domain.models.core.action import Action, ActionState
|
||||
from cleveragents.domain.models.core.decision import Decision, DecisionType
|
||||
from cleveragents.domain.models.core.decision import DecisionType
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
NamespacedName,
|
||||
Plan,
|
||||
|
||||
@@ -29,9 +29,6 @@ try:
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from cleveragents.resource.handlers.devcontainer import (
|
||||
DevcontainerHandler,
|
||||
)
|
||||
from cleveragents.resource.handlers.discovery import (
|
||||
DevcontainerDiscoveryResult,
|
||||
discover_devcontainers,
|
||||
|
||||
@@ -14,7 +14,6 @@ from cleveragents.application.services.permission_service import (
|
||||
from cleveragents.domain.models.core.permission import (
|
||||
ROLE_PERMISSIONS,
|
||||
PermissionAction,
|
||||
PermissionPolicy,
|
||||
PermissionRole,
|
||||
PermissionScope,
|
||||
RoleBinding,
|
||||
|
||||
@@ -11,7 +11,6 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
@@ -28,7 +27,6 @@ from cleveragents.application.services.semantic_validation_rules import (
|
||||
SyntaxCheckRule,
|
||||
)
|
||||
from cleveragents.application.services.semantic_validation_service import (
|
||||
SemanticRuleRegistry,
|
||||
SemanticValidationCache,
|
||||
SemanticValidationService,
|
||||
create_default_registry,
|
||||
|
||||
@@ -31,7 +31,6 @@ from cleveragents.acp.clients import ( # noqa: E402
|
||||
from cleveragents.acp.server_config import ServerConnectionConfig # noqa: E402
|
||||
from cleveragents.application.services.config_service import _REGISTRY # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config resolution benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -16,7 +16,6 @@ from cleveragents.application.services.subplan_merge_service import (
|
||||
)
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
ExecutionMode,
|
||||
ProcessingState,
|
||||
SubplanConfig,
|
||||
SubplanMergeStrategy,
|
||||
SubplanStatus,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Context Tiers
|
||||
|
||||
The ACMS Context Tier system manages context fragments across three storage
|
||||
tiers — **hot**, **warm**, and **cold** — with per-actor visibility, project
|
||||
scoping, and LRU eviction.
|
||||
|
||||
## Overview
|
||||
|
||||
| Tier | Backend | Latency | Capacity |
|
||||
|--------|-------------|---------|------------------|
|
||||
| Hot | In-memory | Low | Token-budget |
|
||||
| Warm | SQLite stub | Medium | Decision-count |
|
||||
| Cold | File stub | High | Decision-count |
|
||||
|
||||
## Models
|
||||
|
||||
### ContextTier
|
||||
|
||||
```python
|
||||
class ContextTier(StrEnum):
|
||||
HOT = "hot"
|
||||
WARM = "warm"
|
||||
COLD = "cold"
|
||||
```
|
||||
|
||||
### ActorRole
|
||||
|
||||
```python
|
||||
class ActorRole(StrEnum):
|
||||
STRATEGIST = "strategist" # sees hot + warm + cold
|
||||
EXECUTOR = "executor" # sees hot + warm
|
||||
REVIEWER = "reviewer" # sees hot only
|
||||
```
|
||||
|
||||
### TieredFragment
|
||||
|
||||
Extends the CRP `ContextFragment` concept with tier placement and access
|
||||
tracking metadata:
|
||||
|
||||
- `fragment_id` — stable unique identifier
|
||||
- `content` — rendered text content
|
||||
- `tier` — current storage tier
|
||||
- `resource_id` — originating resource
|
||||
- `project_name` — project scope for isolation
|
||||
- `token_count` — token count of content
|
||||
- `last_accessed` — timestamp for LRU eviction
|
||||
- `access_count` — access frequency counter
|
||||
- `created_at` — creation timestamp
|
||||
|
||||
### TierBudget
|
||||
|
||||
Controls capacity per tier:
|
||||
|
||||
- `max_tokens_hot` (default: 8000)
|
||||
- `max_decisions_warm` (default: 500)
|
||||
- `max_decisions_cold` (default: 5000)
|
||||
|
||||
### ActorContextView
|
||||
|
||||
Per-actor filtered view configuration with role-based default tiers.
|
||||
|
||||
### TierMetrics
|
||||
|
||||
Hit/miss counters: `hot_hit_count`, `hot_miss_count`, `warm_hit_count`,
|
||||
`warm_miss_count`, plus population counts per tier.
|
||||
|
||||
### ScopedBackendView
|
||||
|
||||
Project-scoped filter that enforces resource isolation. Fragments with
|
||||
empty `project_name` are excluded by default.
|
||||
|
||||
## Service
|
||||
|
||||
### ContextTierService
|
||||
|
||||
```python
|
||||
from cleveragents.application.services.context_tiers import ContextTierService
|
||||
|
||||
svc = ContextTierService()
|
||||
svc.store(fragment)
|
||||
svc.get("fragment-id")
|
||||
svc.promote("fragment-id") # cold→warm or warm→hot
|
||||
svc.demote("fragment-id") # hot→warm or warm→cold (with summarisation)
|
||||
svc.evict_lru(ContextTier.HOT, count=5)
|
||||
svc.get_for_actor(ActorRole.STRATEGIST, project_names=["my-project"])
|
||||
svc.get_scoped_view(["my-project"])
|
||||
svc.get_metrics()
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables (via `Settings`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CLEVERAGENTS_CONTEXT_MAX_TOKENS_HOT` | 8000 | Max tokens in hot tier |
|
||||
| `CLEVERAGENTS_CONTEXT_MAX_DECISIONS_WARM` | 500 | Max fragments in warm tier |
|
||||
| `CLEVERAGENTS_CONTEXT_MAX_DECISIONS_COLD` | 5000 | Max fragments in cold tier |
|
||||
|
||||
## DI Container
|
||||
|
||||
The `ContextTierService` is registered as a singleton in the DI container:
|
||||
|
||||
```python
|
||||
from cleveragents.application.container import get_container
|
||||
|
||||
container = get_container()
|
||||
svc = container.context_tier_service()
|
||||
```
|
||||
@@ -0,0 +1,191 @@
|
||||
Feature: ACMS Context Tiers
|
||||
As a developer
|
||||
I want hot/warm/cold context tiers with actor views and project scoping
|
||||
So that the context management system can efficiently manage fragment lifecycle
|
||||
|
||||
# ---- ContextTier enum ----
|
||||
|
||||
Scenario: ContextTier has three values
|
||||
Then ContextTier should have values "hot", "warm", "cold"
|
||||
|
||||
# ---- ActorRole enum ----
|
||||
|
||||
Scenario: ActorRole has three values
|
||||
Then ActorRole should have values "strategist", "executor", "reviewer"
|
||||
|
||||
# ---- TieredFragment ----
|
||||
|
||||
Scenario: Create a valid TieredFragment
|
||||
Given a TieredFragment with id "frag-001" and content "hello world"
|
||||
Then the fragment tier should be "hot"
|
||||
And the fragment token_count should be 0
|
||||
And the fragment access_count should be 0
|
||||
|
||||
Scenario: TieredFragment rejects empty fragment_id
|
||||
Then creating a TieredFragment with empty fragment_id should raise ValidationError
|
||||
|
||||
Scenario: TieredFragment rejects negative token_count
|
||||
Then creating a TieredFragment with negative token_count should raise ValidationError
|
||||
|
||||
# ---- TierBudget ----
|
||||
|
||||
Scenario: TierBudget has sensible defaults
|
||||
Given a default TierBudget
|
||||
Then max_tokens_hot should be 8000
|
||||
And max_decisions_warm should be 500
|
||||
And max_decisions_cold should be 5000
|
||||
|
||||
# ---- ActorContextView ----
|
||||
|
||||
Scenario: Strategist sees all tiers by default
|
||||
Given an ActorContextView for "strategist"
|
||||
Then effective tiers should be "hot", "warm", "cold"
|
||||
|
||||
Scenario: Executor sees hot and warm by default
|
||||
Given an ActorContextView for "executor"
|
||||
Then effective tiers should be "hot", "warm"
|
||||
|
||||
Scenario: Reviewer sees only hot by default
|
||||
Given an ActorContextView for "reviewer"
|
||||
Then effective tiers should be "hot"
|
||||
|
||||
# ---- TierMetrics ----
|
||||
|
||||
Scenario: TierMetrics defaults to zero
|
||||
Given a default TierMetrics
|
||||
Then total_fragments should be 0
|
||||
And hot_hit_rate should be 0.0
|
||||
|
||||
# ---- ScopedBackendView ----
|
||||
|
||||
Scenario: ScopedBackendView filters by project
|
||||
Given a ScopedBackendView for project "proj-a"
|
||||
And a TieredFragment with id "frag-a" in project "proj-a"
|
||||
And a TieredFragment with id "frag-b" in project "proj-b"
|
||||
Then fragment "frag-a" should be visible
|
||||
And fragment "frag-b" should not be visible
|
||||
|
||||
Scenario: ScopedBackendView excludes empty project_name
|
||||
Given a ScopedBackendView for project "proj-a"
|
||||
And a TieredFragment with id "frag-x" and no project
|
||||
Then fragment "frag-x" should not be visible
|
||||
|
||||
# ---- ContextTierService: store/get ----
|
||||
|
||||
Scenario: Store and retrieve a fragment
|
||||
Given a ContextTierService
|
||||
When I store a fragment with id "f1" content "data" in tier "hot"
|
||||
Then tier getting "f1" should return content "data"
|
||||
|
||||
Scenario: Get returns None for unknown fragment
|
||||
Given a ContextTierService
|
||||
Then tier getting "nonexistent" should return None
|
||||
|
||||
# ---- ContextTierService: promotion ----
|
||||
|
||||
Scenario: Promote from cold to warm
|
||||
Given a ContextTierService
|
||||
When I store a fragment with id "f2" content "cold-data" in tier "cold"
|
||||
And I promote "f2"
|
||||
Then "f2" should be in tier "warm"
|
||||
|
||||
Scenario: Promote from warm to hot
|
||||
Given a ContextTierService
|
||||
When I store a fragment with id "f3" content "warm-data" in tier "warm"
|
||||
And I promote "f3"
|
||||
Then "f3" should be in tier "hot"
|
||||
|
||||
Scenario: Promote unknown fragment returns None
|
||||
Given a ContextTierService
|
||||
Then promoting "unknown" should return None
|
||||
|
||||
# ---- ContextTierService: demotion ----
|
||||
|
||||
Scenario: Demote from hot to warm
|
||||
Given a ContextTierService
|
||||
When I store a fragment with id "f4" content "hot-data" in tier "hot"
|
||||
And I demote "f4"
|
||||
Then "f4" should be in tier "warm"
|
||||
|
||||
Scenario: Demote from warm to cold with summarization
|
||||
Given a ContextTierService
|
||||
When I store a long fragment "f5" in tier "warm"
|
||||
And I demote "f5"
|
||||
Then "f5" should be in tier "cold"
|
||||
And "f5" content should be summarized
|
||||
|
||||
Scenario: Demote unknown fragment returns None
|
||||
Given a ContextTierService
|
||||
Then demoting "unknown" should return None
|
||||
|
||||
# ---- ContextTierService: LRU eviction ----
|
||||
|
||||
Scenario: Evict LRU fragments from hot tier
|
||||
Given a ContextTierService
|
||||
When I store a fragment with id "e1" content "first" in tier "hot"
|
||||
And I store a fragment with id "e2" content "second" in tier "hot"
|
||||
And I store a fragment with id "e3" content "third" in tier "hot"
|
||||
And I evict 2 LRU fragments from "hot"
|
||||
Then "hot" tier should have 1 fragment
|
||||
|
||||
Scenario: Evict with invalid count raises ValueError
|
||||
Given a ContextTierService
|
||||
Then evicting 0 from "hot" should raise ValueError
|
||||
|
||||
# ---- ContextTierService: actor views ----
|
||||
|
||||
Scenario: Strategist sees fragments from all tiers
|
||||
Given a ContextTierService
|
||||
When I store fragment "a1" with content "hot" tier "hot" project "p1"
|
||||
And I store fragment "a2" with content "warm" tier "warm" project "p1"
|
||||
And I store fragment "a3" with content "cold" tier "cold" project "p1"
|
||||
Then strategist for project "p1" should see 3 fragments
|
||||
|
||||
Scenario: Executor sees only hot and warm
|
||||
Given a ContextTierService
|
||||
When I store fragment "b1" with content "hot" tier "hot" project "p1"
|
||||
And I store fragment "b2" with content "warm" tier "warm" project "p1"
|
||||
And I store fragment "b3" with content "cold" tier "cold" project "p1"
|
||||
Then executor for project "p1" should see 2 fragments
|
||||
|
||||
Scenario: Reviewer sees only hot
|
||||
Given a ContextTierService
|
||||
When I store fragment "c1" with content "hot" tier "hot" project "p1"
|
||||
And I store fragment "c2" with content "warm" tier "warm" project "p1"
|
||||
Then reviewer for project "p1" should see 1 fragment
|
||||
|
||||
# ---- ContextTierService: project scoping ----
|
||||
|
||||
Scenario: Scoped view filters by project
|
||||
Given a ContextTierService
|
||||
When I store fragment "s1" with content "yes" tier "hot" project "alpha"
|
||||
And I store fragment "s2" with content "no" tier "hot" project "beta"
|
||||
Then scoped view for "alpha" should have 1 fragment
|
||||
|
||||
# ---- ContextTierService: metrics ----
|
||||
|
||||
Scenario: Metrics track hits and misses
|
||||
Given a ContextTierService
|
||||
When I store a fragment with id "m1" content "data" in tier "hot"
|
||||
And I get fragment "m1"
|
||||
And I get fragment "nonexistent"
|
||||
Then hot_hit_count should be 1
|
||||
And hot_miss_count should be 1
|
||||
|
||||
# ---- DI Container ----
|
||||
|
||||
Scenario: DI container provides context_tier_service
|
||||
Given the DI container is reset
|
||||
Then the container should provide a context_tier_service
|
||||
And the context_tier_service should be a ContextTierService
|
||||
|
||||
Scenario: DI container context_tier_service is singleton
|
||||
Given the DI container is reset
|
||||
Then resolving context_tier_service twice should return the same instance
|
||||
|
||||
# ---- Settings ----
|
||||
|
||||
Scenario: Settings include context tier budget fields
|
||||
Then settings should have context_max_tokens_hot
|
||||
And settings should have context_max_decisions_warm
|
||||
And settings should have context_max_decisions_cold
|
||||
@@ -0,0 +1,450 @@
|
||||
"""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"')
|
||||
def step_then_actor_role_values(context: Any) -> None:
|
||||
assert ActorRole.STRATEGIST == "strategist"
|
||||
assert ActorRole.EXECUTOR == "executor"
|
||||
assert ActorRole.REVIEWER == "reviewer"
|
||||
assert len(ActorRole) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
@@ -0,0 +1,65 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for ACMS Context Tiers (hot/warm/cold)
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_context_tiers.py
|
||||
|
||||
*** Test Cases ***
|
||||
Tier Model Construction
|
||||
[Documentation] Verify ContextTier, ActorRole, TieredFragment creation
|
||||
${result}= Run Process ${PYTHON} ${HELPER} models cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} context-tiers-models-ok
|
||||
|
||||
Store And Retrieve Fragment
|
||||
[Documentation] Verify storing and retrieving a fragment from the tier service
|
||||
${result}= Run Process ${PYTHON} ${HELPER} store-get cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} context-tiers-store-get-ok
|
||||
|
||||
Promote And Demote
|
||||
[Documentation] Verify tier promotion and demotion
|
||||
${result}= Run Process ${PYTHON} ${HELPER} promote-demote cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} context-tiers-promote-demote-ok
|
||||
|
||||
LRU Eviction
|
||||
[Documentation] Verify LRU eviction from hot tier
|
||||
${result}= Run Process ${PYTHON} ${HELPER} eviction cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} context-tiers-eviction-ok
|
||||
|
||||
Actor Views
|
||||
[Documentation] Verify per-actor filtered views
|
||||
${result}= Run Process ${PYTHON} ${HELPER} actor-views cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} context-tiers-actor-views-ok
|
||||
|
||||
Project Scoping
|
||||
[Documentation] Verify ScopedBackendView filters by project
|
||||
${result}= Run Process ${PYTHON} ${HELPER} scoping cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} context-tiers-scoping-ok
|
||||
|
||||
DI Container Resolution
|
||||
[Documentation] Verify DI container provides context_tier_service
|
||||
${result}= Run Process ${PYTHON} ${HELPER} di-resolution cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} context-tiers-di-ok
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Robot Framework helper for ACMS context tier smoke tests.
|
||||
|
||||
Provides a CLI-style interface for Robot to invoke tier model creation,
|
||||
service operations, and DI resolution. Exit code 0 = success, 1 = failure.
|
||||
|
||||
Usage:
|
||||
python robot/helper_context_tiers.py <command>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the src directory is on the import path.
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.application.container import ( # noqa: E402
|
||||
get_container,
|
||||
reset_container,
|
||||
)
|
||||
from cleveragents.application.services.context_tiers import ( # noqa: E402
|
||||
ContextTierService,
|
||||
)
|
||||
from cleveragents.domain.models.acms.tiers import ( # noqa: E402
|
||||
ActorContextView,
|
||||
ActorRole,
|
||||
ContextTier,
|
||||
ScopedBackendView,
|
||||
TierBudget,
|
||||
TieredFragment,
|
||||
TierMetrics,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point called by Robot Framework ``Run Process``."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: helper_context_tiers.py <command>")
|
||||
return 1
|
||||
|
||||
command: str = sys.argv[1]
|
||||
|
||||
if command == "models":
|
||||
try:
|
||||
assert ContextTier.HOT == "hot"
|
||||
assert ActorRole.STRATEGIST == "strategist"
|
||||
frag = TieredFragment(fragment_id="f1", content="hello")
|
||||
assert frag.tier == ContextTier.HOT
|
||||
budget = TierBudget()
|
||||
assert budget.max_tokens_hot == 8000
|
||||
view = ActorContextView(actor_role=ActorRole.EXECUTOR)
|
||||
assert len(view.get_effective_tiers()) == 2
|
||||
metrics = TierMetrics()
|
||||
assert metrics.total_fragments == 0
|
||||
print("context-tiers-models-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"context-tiers-models-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "store-get":
|
||||
try:
|
||||
svc = ContextTierService()
|
||||
frag = TieredFragment(
|
||||
fragment_id="f1", content="test", tier=ContextTier.HOT
|
||||
)
|
||||
svc.store(frag)
|
||||
got = svc.get("f1")
|
||||
assert got is not None
|
||||
assert got.content == "test"
|
||||
assert svc.get("missing") is None
|
||||
print("context-tiers-store-get-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"context-tiers-store-get-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "promote-demote":
|
||||
try:
|
||||
svc = ContextTierService()
|
||||
frag = TieredFragment(
|
||||
fragment_id="f1", content="data", tier=ContextTier.COLD
|
||||
)
|
||||
svc.store(frag)
|
||||
promoted = svc.promote("f1")
|
||||
assert promoted is not None
|
||||
assert promoted.tier == ContextTier.WARM
|
||||
promoted2 = svc.promote("f1")
|
||||
assert promoted2 is not None
|
||||
assert promoted2.tier == ContextTier.HOT
|
||||
demoted = svc.demote("f1")
|
||||
assert demoted is not None
|
||||
assert demoted.tier == ContextTier.WARM
|
||||
print("context-tiers-promote-demote-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"context-tiers-promote-demote-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "eviction":
|
||||
try:
|
||||
svc = ContextTierService()
|
||||
for i in range(5):
|
||||
svc.store(
|
||||
TieredFragment(
|
||||
fragment_id=f"e{i}",
|
||||
content=f"data-{i}",
|
||||
tier=ContextTier.HOT,
|
||||
)
|
||||
)
|
||||
evicted = svc.evict_lru(ContextTier.HOT, 3)
|
||||
assert len(evicted) == 3
|
||||
assert svc.get_metrics().hot_count == 2
|
||||
print("context-tiers-eviction-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"context-tiers-eviction-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "actor-views":
|
||||
try:
|
||||
svc = ContextTierService()
|
||||
svc.store(
|
||||
TieredFragment(
|
||||
fragment_id="h1",
|
||||
content="hot",
|
||||
tier=ContextTier.HOT,
|
||||
project_name="p1",
|
||||
)
|
||||
)
|
||||
svc.store(
|
||||
TieredFragment(
|
||||
fragment_id="w1",
|
||||
content="warm",
|
||||
tier=ContextTier.WARM,
|
||||
project_name="p1",
|
||||
)
|
||||
)
|
||||
svc.store(
|
||||
TieredFragment(
|
||||
fragment_id="c1",
|
||||
content="cold",
|
||||
tier=ContextTier.COLD,
|
||||
project_name="p1",
|
||||
)
|
||||
)
|
||||
strat = svc.get_for_actor(ActorRole.STRATEGIST, ["p1"])
|
||||
assert len(strat) == 3
|
||||
exec_frags = svc.get_for_actor(ActorRole.EXECUTOR, ["p1"])
|
||||
assert len(exec_frags) == 2
|
||||
rev = svc.get_for_actor(ActorRole.REVIEWER, ["p1"])
|
||||
assert len(rev) == 1
|
||||
print("context-tiers-actor-views-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"context-tiers-actor-views-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "scoping":
|
||||
try:
|
||||
svc = ContextTierService()
|
||||
svc.store(
|
||||
TieredFragment(
|
||||
fragment_id="s1",
|
||||
content="yes",
|
||||
tier=ContextTier.HOT,
|
||||
project_name="alpha",
|
||||
)
|
||||
)
|
||||
svc.store(
|
||||
TieredFragment(
|
||||
fragment_id="s2",
|
||||
content="no",
|
||||
tier=ContextTier.HOT,
|
||||
project_name="beta",
|
||||
)
|
||||
)
|
||||
scoped = svc.get_scoped_view(["alpha"])
|
||||
assert len(scoped) == 1
|
||||
assert scoped[0].fragment_id == "s1"
|
||||
|
||||
# ScopedBackendView direct test
|
||||
view = ScopedBackendView(allowed_projects=["alpha"])
|
||||
frag_a = TieredFragment(fragment_id="a", content="x", project_name="alpha")
|
||||
frag_b = TieredFragment(fragment_id="b", content="x", project_name="beta")
|
||||
assert view.is_visible(frag_a)
|
||||
assert not view.is_visible(frag_b)
|
||||
print("context-tiers-scoping-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"context-tiers-scoping-fail: {exc}")
|
||||
return 1
|
||||
|
||||
if command == "di-resolution":
|
||||
try:
|
||||
reset_container()
|
||||
container = get_container()
|
||||
svc = container.context_tier_service()
|
||||
assert isinstance(svc, ContextTierService)
|
||||
svc2 = container.context_tier_service()
|
||||
assert svc is svc2 # singleton
|
||||
print("context-tiers-di-ok")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"context-tiers-di-fail: {exc}")
|
||||
return 1
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -16,6 +16,9 @@ from cleveragents.application.services.autonomy_guardrail_service import (
|
||||
)
|
||||
from cleveragents.application.services.checkpoint_service import CheckpointService
|
||||
from cleveragents.application.services.context_service import ContextService
|
||||
from cleveragents.application.services.context_tiers import (
|
||||
ContextTierService,
|
||||
)
|
||||
from cleveragents.application.services.decision_service import DecisionService
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
@@ -299,6 +302,12 @@ class Container(containers.DeclarativeContainer):
|
||||
database_url=database_url,
|
||||
)
|
||||
|
||||
# Context Tier Service - Singleton so all callers share tier state
|
||||
context_tier_service = providers.Singleton(
|
||||
ContextTierService,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
# Skeleton Compressor Service - stateless, Singleton is sufficient
|
||||
skeleton_compressor_service = providers.Singleton(
|
||||
SkeletonCompressorService,
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Context Tier Service for hot/warm/cold fragment management.
|
||||
|
||||
The ``ContextTierService`` manages the lifecycle of context fragments
|
||||
across three storage tiers:
|
||||
|
||||
- **Hot** — in-memory ``dict``, fastest access, token-budget limited.
|
||||
- **Warm** — SQLite-backed (stub), moderate latency, decision-count limited.
|
||||
- **Cold** — file-backed (stub), highest latency, summarised on demotion.
|
||||
|
||||
Supports promotion (cold→warm→hot), demotion (hot→warm→cold) with a
|
||||
summarisation hook, LRU eviction, per-actor filtered views, and
|
||||
project-scoped isolation via ``ScopedBackendView``.
|
||||
|
||||
Based on ``docs/specification.md`` ACMS tier sections and issue #208.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.domain.models.acms.tiers import (
|
||||
ActorContextView,
|
||||
ActorRole,
|
||||
ContextTier,
|
||||
ScopedBackendView,
|
||||
TierBudget,
|
||||
TieredFragment,
|
||||
TierMetrics,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default budget when settings are not provided
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_MAX_TOKENS_HOT = 8000
|
||||
_DEFAULT_MAX_DECISIONS_WARM = 500
|
||||
_DEFAULT_MAX_DECISIONS_COLD = 5000
|
||||
|
||||
# Maximum content length kept after cold-tier summarisation
|
||||
_COLD_SUMMARY_MAX_CHARS = 200
|
||||
|
||||
|
||||
class ContextTierService:
|
||||
"""Manage context fragments across hot/warm/cold tiers.
|
||||
|
||||
The service is designed for dependency injection via the DI
|
||||
container. All mutable state lives in the three tier stores
|
||||
and the metrics counters.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings | None = None) -> None:
|
||||
budget = _budget_from_settings(settings)
|
||||
self._budget = budget
|
||||
|
||||
# --- tier stores ---------------------------------------------------
|
||||
self._hot: dict[str, TieredFragment] = {}
|
||||
self._warm: dict[str, TieredFragment] = {}
|
||||
self._cold: dict[str, TieredFragment] = {}
|
||||
|
||||
# --- metrics -------------------------------------------------------
|
||||
self._hot_hit = 0
|
||||
self._hot_miss = 0
|
||||
self._warm_hit = 0
|
||||
self._warm_miss = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Store
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def store(self, fragment: TieredFragment) -> None:
|
||||
"""Store a fragment in the tier indicated by ``fragment.tier``.
|
||||
|
||||
If the fragment already exists in any tier it is moved to the
|
||||
requested tier.
|
||||
|
||||
Args:
|
||||
fragment: The fragment to store.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``fragment.fragment_id`` is empty.
|
||||
"""
|
||||
if not fragment.fragment_id:
|
||||
raise ValueError("fragment_id must be non-empty")
|
||||
|
||||
# Remove from other tiers first
|
||||
self._remove_from_all(fragment.fragment_id)
|
||||
|
||||
if fragment.tier == ContextTier.HOT:
|
||||
self._hot[fragment.fragment_id] = fragment
|
||||
elif fragment.tier == ContextTier.WARM:
|
||||
self._warm[fragment.fragment_id] = fragment
|
||||
else:
|
||||
self._cold[fragment.fragment_id] = fragment
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Retrieve
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get(self, fragment_id: str) -> TieredFragment | None:
|
||||
"""Retrieve a fragment from any tier, updating access metadata.
|
||||
|
||||
Returns ``None`` when the fragment is not found.
|
||||
"""
|
||||
if fragment_id in self._hot:
|
||||
self._hot_hit += 1
|
||||
frag = self._hot[fragment_id]
|
||||
return self._touch(frag)
|
||||
|
||||
self._hot_miss += 1
|
||||
|
||||
if fragment_id in self._warm:
|
||||
self._warm_hit += 1
|
||||
frag = self._warm[fragment_id]
|
||||
return self._touch(frag)
|
||||
|
||||
self._warm_miss += 1
|
||||
|
||||
if fragment_id in self._cold:
|
||||
return self._touch(self._cold[fragment_id])
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actor views
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_for_actor(
|
||||
self,
|
||||
actor_role: ActorRole,
|
||||
project_names: list[str] | None = None,
|
||||
) -> list[TieredFragment]:
|
||||
"""Return fragments visible to *actor_role*, scoped to projects.
|
||||
|
||||
Uses ``ActorContextView`` default tiers for the role and
|
||||
``ScopedBackendView`` for project isolation (when project_names
|
||||
is provided).
|
||||
"""
|
||||
view = ActorContextView(actor_role=actor_role)
|
||||
effective_tiers = view.get_effective_tiers()
|
||||
|
||||
candidates: list[TieredFragment] = []
|
||||
for tier in effective_tiers:
|
||||
store = self._store_for_tier(tier)
|
||||
candidates.extend(store.values())
|
||||
|
||||
# Apply project scoping
|
||||
if project_names:
|
||||
scoped = ScopedBackendView(allowed_projects=project_names)
|
||||
candidates = [f for f in candidates if scoped.is_visible(f)]
|
||||
|
||||
# Sort by last_accessed descending, cap at max_fragments
|
||||
candidates.sort(key=lambda f: f.last_accessed, reverse=True)
|
||||
return candidates[: view.max_fragments]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scoped view
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_scoped_view(
|
||||
self,
|
||||
project_names: list[str],
|
||||
) -> list[TieredFragment]:
|
||||
"""Return all fragments scoped to the given project names.
|
||||
|
||||
Enforces project isolation via ``ScopedBackendView`` so actors
|
||||
never see resources outside the plan's project set.
|
||||
"""
|
||||
if not project_names:
|
||||
return []
|
||||
scoped = ScopedBackendView(allowed_projects=project_names)
|
||||
all_frags: list[TieredFragment] = []
|
||||
for store in (self._hot, self._warm, self._cold):
|
||||
all_frags.extend(f for f in store.values() if scoped.is_visible(f))
|
||||
all_frags.sort(key=lambda f: f.last_accessed, reverse=True)
|
||||
return all_frags
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Promote / Demote
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def promote(self, fragment_id: str) -> TieredFragment | None:
|
||||
"""Promote a fragment one tier up (cold→warm or warm→hot).
|
||||
|
||||
Returns the promoted fragment or ``None`` if not found or
|
||||
already in the hot tier.
|
||||
"""
|
||||
if fragment_id in self._cold:
|
||||
frag = self._cold.pop(fragment_id)
|
||||
promoted = frag.model_copy(update={"tier": ContextTier.WARM})
|
||||
self._warm[fragment_id] = promoted
|
||||
return promoted
|
||||
|
||||
if fragment_id in self._warm:
|
||||
frag = self._warm.pop(fragment_id)
|
||||
promoted = frag.model_copy(update={"tier": ContextTier.HOT})
|
||||
self._hot[fragment_id] = promoted
|
||||
return promoted
|
||||
|
||||
return None
|
||||
|
||||
def demote(self, fragment_id: str) -> TieredFragment | None:
|
||||
"""Demote a fragment one tier down (hot→warm or warm→cold).
|
||||
|
||||
When demoting to cold tier, the summarisation hook is applied.
|
||||
Returns the demoted fragment or ``None`` if not found or
|
||||
already in the cold tier.
|
||||
"""
|
||||
if fragment_id in self._hot:
|
||||
frag = self._hot.pop(fragment_id)
|
||||
demoted = frag.model_copy(update={"tier": ContextTier.WARM})
|
||||
self._warm[fragment_id] = demoted
|
||||
return demoted
|
||||
|
||||
if fragment_id in self._warm:
|
||||
frag = self._warm.pop(fragment_id)
|
||||
summarised = self._summarize_for_cold(frag)
|
||||
demoted = summarised.model_copy(update={"tier": ContextTier.COLD})
|
||||
self._cold[fragment_id] = demoted
|
||||
return demoted
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# LRU eviction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def evict_lru(self, tier: ContextTier, count: int) -> list[str]:
|
||||
"""Evict the *count* least-recently-used fragments from *tier*.
|
||||
|
||||
Returns the list of evicted fragment IDs.
|
||||
|
||||
Raises:
|
||||
ValueError: If *count* is not positive.
|
||||
"""
|
||||
if count <= 0:
|
||||
raise ValueError(f"count must be positive, got {count}")
|
||||
|
||||
store = self._store_for_tier(tier)
|
||||
if not store:
|
||||
return []
|
||||
|
||||
# Sort by last_accessed ascending (oldest first)
|
||||
sorted_ids = sorted(
|
||||
store.keys(),
|
||||
key=lambda fid: store[fid].last_accessed,
|
||||
)
|
||||
to_evict = sorted_ids[:count]
|
||||
for fid in to_evict:
|
||||
del store[fid]
|
||||
return to_evict
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Metrics
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_metrics(self) -> TierMetrics:
|
||||
"""Return current hit/miss and population metrics."""
|
||||
return TierMetrics(
|
||||
hot_count=len(self._hot),
|
||||
warm_count=len(self._warm),
|
||||
cold_count=len(self._cold),
|
||||
hot_hit_count=self._hot_hit,
|
||||
hot_miss_count=self._hot_miss,
|
||||
warm_hit_count=self._warm_hit,
|
||||
warm_miss_count=self._warm_miss,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Budget
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def budget(self) -> TierBudget:
|
||||
"""Return the active tier budget."""
|
||||
return self._budget
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _store_for_tier(
|
||||
self,
|
||||
tier: ContextTier,
|
||||
) -> dict[str, TieredFragment]:
|
||||
"""Return the backing store dict for *tier*."""
|
||||
if tier == ContextTier.HOT:
|
||||
return self._hot
|
||||
if tier == ContextTier.WARM:
|
||||
return self._warm
|
||||
return self._cold
|
||||
|
||||
def _remove_from_all(self, fragment_id: str) -> None:
|
||||
"""Remove *fragment_id* from all tiers."""
|
||||
self._hot.pop(fragment_id, None)
|
||||
self._warm.pop(fragment_id, None)
|
||||
self._cold.pop(fragment_id, None)
|
||||
|
||||
@staticmethod
|
||||
def _touch(fragment: TieredFragment) -> TieredFragment:
|
||||
"""Update access metadata on *fragment* and return it."""
|
||||
fragment.last_accessed = datetime.now(tz=UTC)
|
||||
fragment.access_count += 1
|
||||
return fragment
|
||||
|
||||
@staticmethod
|
||||
def _summarize_for_cold(fragment: TieredFragment) -> TieredFragment:
|
||||
"""Summarisation hook: truncate content for cold storage.
|
||||
|
||||
This is a stub implementation that truncates the content.
|
||||
A production implementation would call an LLM summariser.
|
||||
"""
|
||||
content = fragment.content
|
||||
if len(content) > _COLD_SUMMARY_MAX_CHARS:
|
||||
content = content[:_COLD_SUMMARY_MAX_CHARS] + "..."
|
||||
return fragment.model_copy(update={"content": content})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Budget helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _budget_from_settings(settings: Settings | None) -> TierBudget:
|
||||
"""Build a ``TierBudget`` from application settings (or defaults)."""
|
||||
if settings is None:
|
||||
return TierBudget()
|
||||
return TierBudget(
|
||||
max_tokens_hot=getattr(
|
||||
settings, "context_max_tokens_hot", _DEFAULT_MAX_TOKENS_HOT
|
||||
),
|
||||
max_decisions_warm=getattr(
|
||||
settings, "context_max_decisions_warm", _DEFAULT_MAX_DECISIONS_WARM
|
||||
),
|
||||
max_decisions_cold=getattr(
|
||||
settings, "context_max_decisions_cold", _DEFAULT_MAX_DECISIONS_COLD
|
||||
),
|
||||
)
|
||||
@@ -225,6 +225,26 @@ class Settings(BaseSettings):
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_TEST_DATABASE_URL"),
|
||||
)
|
||||
|
||||
# Context tier budgets (ACMS #208)
|
||||
context_max_tokens_hot: int = Field(
|
||||
default=8000,
|
||||
ge=0,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_CONTEXT_MAX_TOKENS_HOT"),
|
||||
description="Maximum total tokens in the hot context tier.",
|
||||
)
|
||||
context_max_decisions_warm: int = Field(
|
||||
default=500,
|
||||
ge=0,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_CONTEXT_MAX_DECISIONS_WARM"),
|
||||
description="Maximum fragments in the warm context tier.",
|
||||
)
|
||||
context_max_decisions_cold: int = Field(
|
||||
default=5000,
|
||||
ge=0,
|
||||
validation_alias=AliasChoices("CLEVERAGENTS_CONTEXT_MAX_DECISIONS_COLD"),
|
||||
description="Maximum fragments in the cold context tier.",
|
||||
)
|
||||
|
||||
# Vector store configuration
|
||||
vector_store_enabled: bool = Field(
|
||||
default=False,
|
||||
|
||||
@@ -22,6 +22,15 @@ Stub backends (from :mod:`~cleveragents.domain.models.acms.stubs`):
|
||||
- ``InMemoryVectorBackend`` -- Zero-dependency vector search stub
|
||||
- ``InMemoryGraphBackend`` -- Zero-dependency graph query stub
|
||||
|
||||
Tier types (from :mod:`~cleveragents.domain.models.acms.tiers`):
|
||||
- ``ContextTier`` -- Hot/warm/cold storage tier enumeration
|
||||
- ``ActorRole`` -- Strategist/executor/reviewer actor roles
|
||||
- ``TieredFragment`` -- Fragment with tier and access metadata
|
||||
- ``TierBudget`` -- Per-tier token/decision budget limits
|
||||
- ``ActorContextView`` -- Per-actor filtered view configuration
|
||||
- ``TierMetrics`` -- Hit/miss counters for cache monitoring
|
||||
- ``ScopedBackendView`` -- Project-scoped resource isolation
|
||||
|
||||
Based on ``docs/specification.md`` ACMS / CRP sections and ADR-014.
|
||||
"""
|
||||
|
||||
@@ -48,12 +57,24 @@ from cleveragents.domain.models.acms.stubs import (
|
||||
InMemoryTextBackend,
|
||||
InMemoryVectorBackend,
|
||||
)
|
||||
from cleveragents.domain.models.acms.tiers import (
|
||||
ActorContextView,
|
||||
ActorRole,
|
||||
ContextTier,
|
||||
ScopedBackendView,
|
||||
TierBudget,
|
||||
TieredFragment,
|
||||
TierMetrics,
|
||||
)
|
||||
|
||||
__all__: list[str] = [
|
||||
"ActorContextView",
|
||||
"ActorRole",
|
||||
"AssembledContext",
|
||||
"ContextBudget",
|
||||
"ContextFragment",
|
||||
"ContextRequest",
|
||||
"ContextTier",
|
||||
"DetailLevelMap",
|
||||
"FragmentProvenance",
|
||||
"GraphBackend",
|
||||
@@ -61,8 +82,12 @@ __all__: list[str] = [
|
||||
"InMemoryGraphBackend",
|
||||
"InMemoryTextBackend",
|
||||
"InMemoryVectorBackend",
|
||||
"ScopedBackendView",
|
||||
"TextBackend",
|
||||
"TextResult",
|
||||
"TierBudget",
|
||||
"TierMetrics",
|
||||
"TieredFragment",
|
||||
"VectorBackend",
|
||||
"VectorResult",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Hot/warm/cold context tier models and actor view definitions.
|
||||
|
||||
Provides the tiered context storage data types used by the ACMS
|
||||
Context Tier Service for managing context fragment lifecycle:
|
||||
|
||||
| Type | Role |
|
||||
|---------------------|--------------------------------------------------------|
|
||||
| ``ContextTier`` | Enumeration of hot/warm/cold storage tiers |
|
||||
| ``ActorRole`` | Enumeration of actor roles (strategist/executor/reviewer)|
|
||||
| ``TieredFragment`` | A context fragment with tier and access metadata |
|
||||
| ``TierBudget`` | Token/decision budget limits per tier |
|
||||
| ``ActorContextView``| Per-actor filtered view configuration |
|
||||
| ``TierMetrics`` | Hit/miss counters for cache performance monitoring |
|
||||
| ``ScopedBackendView``| Project-scoped filter for resource isolation |
|
||||
|
||||
Based on ``docs/specification.md`` ACMS tier sections and issue #208.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier enumeration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContextTier(StrEnum):
|
||||
"""Storage tier for context fragments.
|
||||
|
||||
- ``HOT`` — in-memory, lowest latency, limited capacity
|
||||
- ``WARM`` — SQLite-backed, moderate latency, larger capacity
|
||||
- ``COLD`` — file-backed, highest latency, unlimited capacity
|
||||
"""
|
||||
|
||||
HOT = "hot"
|
||||
WARM = "warm"
|
||||
COLD = "cold"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Actor role enumeration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ActorRole(StrEnum):
|
||||
"""Actor roles that determine context visibility.
|
||||
|
||||
Each role has a default set of visible tiers and filtering rules.
|
||||
"""
|
||||
|
||||
STRATEGIST = "strategist"
|
||||
EXECUTOR = "executor"
|
||||
REVIEWER = "reviewer"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TieredFragment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
"""Return the current UTC timestamp."""
|
||||
return datetime.now(tz=UTC)
|
||||
|
||||
|
||||
class TieredFragment(BaseModel):
|
||||
"""A context fragment annotated with tier placement and access tracking.
|
||||
|
||||
Extends the conceptual ``ContextFragment`` from the CRP with
|
||||
tier-specific metadata for LRU eviction and promotion/demotion
|
||||
decisions.
|
||||
"""
|
||||
|
||||
fragment_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Stable unique identifier for this fragment",
|
||||
)
|
||||
content: str = Field(
|
||||
...,
|
||||
description="Rendered text content of the fragment",
|
||||
)
|
||||
tier: ContextTier = Field(
|
||||
default=ContextTier.HOT,
|
||||
description="Current storage tier",
|
||||
)
|
||||
resource_id: str = Field(
|
||||
default="",
|
||||
description="Originating resource identifier",
|
||||
)
|
||||
project_name: str = Field(
|
||||
default="",
|
||||
description="Project scope for isolation filtering",
|
||||
)
|
||||
token_count: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Token count of content",
|
||||
)
|
||||
last_accessed: datetime = Field(
|
||||
default_factory=_utc_now,
|
||||
description="Timestamp of last access for LRU eviction",
|
||||
)
|
||||
access_count: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of times this fragment has been accessed",
|
||||
)
|
||||
created_at: datetime = Field(
|
||||
default_factory=_utc_now,
|
||||
description="Timestamp when the fragment was first stored",
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Arbitrary tier-specific metadata",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TierBudget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TierBudget(BaseModel):
|
||||
"""Token and decision budget limits for each tier.
|
||||
|
||||
Controls how many tokens can reside in the hot tier, and how
|
||||
many decisions (fragments) can be stored in warm and cold tiers.
|
||||
"""
|
||||
|
||||
max_tokens_hot: int = Field(
|
||||
default=8000,
|
||||
ge=0,
|
||||
description="Maximum total tokens in the hot tier",
|
||||
)
|
||||
max_decisions_warm: int = Field(
|
||||
default=500,
|
||||
ge=0,
|
||||
description="Maximum number of fragments in the warm tier",
|
||||
)
|
||||
max_decisions_cold: int = Field(
|
||||
default=5000,
|
||||
ge=0,
|
||||
description="Maximum number of fragments in the cold tier",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ActorContextView
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Default visible tiers per actor role
|
||||
_DEFAULT_VISIBLE_TIERS: dict[ActorRole, list[ContextTier]] = {
|
||||
ActorRole.STRATEGIST: [ContextTier.HOT, ContextTier.WARM, ContextTier.COLD],
|
||||
ActorRole.EXECUTOR: [ContextTier.HOT, ContextTier.WARM],
|
||||
ActorRole.REVIEWER: [ContextTier.HOT],
|
||||
}
|
||||
|
||||
|
||||
class ActorContextView(BaseModel):
|
||||
"""Configuration for a per-actor filtered context view.
|
||||
|
||||
Determines which tiers an actor can see and optionally restricts
|
||||
results to specific projects.
|
||||
"""
|
||||
|
||||
actor_role: ActorRole = Field(
|
||||
...,
|
||||
description="The role of the actor requesting context",
|
||||
)
|
||||
visible_tiers: list[ContextTier] = Field(
|
||||
default_factory=list,
|
||||
description="Tiers this actor can see (empty = use role defaults)",
|
||||
)
|
||||
project_filter: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Restrict to these project names (None = no filter)",
|
||||
)
|
||||
max_fragments: int = Field(
|
||||
default=100,
|
||||
ge=1,
|
||||
description="Maximum fragments to return in a single view",
|
||||
)
|
||||
|
||||
@field_validator("visible_tiers", mode="before")
|
||||
@classmethod
|
||||
def _default_visible_tiers(
|
||||
cls: type[ActorContextView],
|
||||
v: list[ContextTier],
|
||||
) -> list[ContextTier]:
|
||||
"""Populate visible_tiers from role defaults when empty."""
|
||||
# Actual default population happens in get_effective_tiers()
|
||||
return v
|
||||
|
||||
def get_effective_tiers(self) -> list[ContextTier]:
|
||||
"""Return the effective visible tiers (role defaults if empty)."""
|
||||
if self.visible_tiers:
|
||||
return list(self.visible_tiers)
|
||||
return list(_DEFAULT_VISIBLE_TIERS.get(self.actor_role, [ContextTier.HOT]))
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TierMetrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TierMetrics(BaseModel):
|
||||
"""Hit/miss performance counters for the tier cache system."""
|
||||
|
||||
hot_count: int = Field(default=0, ge=0, description="Fragments in hot tier")
|
||||
warm_count: int = Field(default=0, ge=0, description="Fragments in warm tier")
|
||||
cold_count: int = Field(default=0, ge=0, description="Fragments in cold tier")
|
||||
hot_hit_count: int = Field(default=0, ge=0, description="Hot tier cache hits")
|
||||
hot_miss_count: int = Field(default=0, ge=0, description="Hot tier cache misses")
|
||||
warm_hit_count: int = Field(default=0, ge=0, description="Warm tier cache hits")
|
||||
warm_miss_count: int = Field(default=0, ge=0, description="Warm tier cache misses")
|
||||
|
||||
@property
|
||||
def total_fragments(self) -> int:
|
||||
"""Total fragments across all tiers."""
|
||||
return self.hot_count + self.warm_count + self.cold_count
|
||||
|
||||
@property
|
||||
def hot_hit_rate(self) -> float:
|
||||
"""Hot tier hit rate (0.0 if no accesses)."""
|
||||
total = self.hot_hit_count + self.hot_miss_count
|
||||
if total == 0:
|
||||
return 0.0
|
||||
return self.hot_hit_count / total
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ScopedBackendView
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ScopedBackendView(BaseModel):
|
||||
"""Project-scoped filter ensuring actors never see resources outside scope.
|
||||
|
||||
Enforces project isolation by restricting fragment visibility to
|
||||
only those fragments whose ``project_name`` matches one of the
|
||||
allowed project names.
|
||||
"""
|
||||
|
||||
allowed_projects: list[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Project names the actor is permitted to access",
|
||||
)
|
||||
|
||||
def is_visible(self, fragment: TieredFragment) -> bool:
|
||||
"""Return True if *fragment* belongs to an allowed project.
|
||||
|
||||
Fragments with an empty ``project_name`` are **excluded** by
|
||||
default to prevent information leakage.
|
||||
"""
|
||||
if not fragment.project_name:
|
||||
return False
|
||||
return fragment.project_name in self.allowed_projects
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
@@ -564,3 +564,33 @@ similarity_search # noqa: B018, F821
|
||||
sparql_query # noqa: B018, F821
|
||||
get_triples # noqa: B018, F821
|
||||
traverse # noqa: B018, F821
|
||||
|
||||
# Context Tiers — public API (M6 ACMS, issue #208)
|
||||
ContextTier # noqa: B018, F821
|
||||
ActorRole # noqa: B018, F821
|
||||
TieredFragment # noqa: B018, F821
|
||||
TierBudget # noqa: B018, F821
|
||||
ActorContextView # noqa: B018, F821
|
||||
TierMetrics # noqa: B018, F821
|
||||
ScopedBackendView # noqa: B018, F821
|
||||
ContextTierService # noqa: B018, F821
|
||||
context_tier_service # noqa: B018, F821
|
||||
context_max_tokens_hot # noqa: B018, F821
|
||||
context_max_decisions_warm # noqa: B018, F821
|
||||
context_max_decisions_cold # noqa: B018, F821
|
||||
get_effective_tiers # noqa: B018, F821
|
||||
total_fragments # noqa: B018, F821
|
||||
hot_hit_rate # noqa: B018, F821
|
||||
is_visible # noqa: B018, F821
|
||||
get_for_actor # noqa: B018, F821
|
||||
get_scoped_view # noqa: B018, F821
|
||||
get_metrics # noqa: B018, F821
|
||||
evict_lru # noqa: B018, F821
|
||||
_summarize_for_cold # noqa: B018, F821
|
||||
_budget_from_settings # noqa: B018, F821
|
||||
_DEFAULT_VISIBLE_TIERS # noqa: B018, F821
|
||||
_COLD_SUMMARY_MAX_CHARS # noqa: B018, F821
|
||||
_DEFAULT_MAX_TOKENS_HOT # noqa: B018, F821
|
||||
_DEFAULT_MAX_DECISIONS_WARM # noqa: B018, F821
|
||||
_DEFAULT_MAX_DECISIONS_COLD # noqa: B018, F821
|
||||
_utc_now # noqa: B018, F821
|
||||
|
||||
Reference in New Issue
Block a user