feat(acms): add context strategy registry with plugin discovery #565
@@ -33,6 +33,16 @@
|
||||
Integrates with all 8 built-in automation profiles. DI-wired as singleton
|
||||
`autonomy_controller`. Includes 48 Behave BDD scenarios, 10 Robot Framework
|
||||
integration tests, and ASV benchmarks. (#546)
|
||||
- Added context strategy registry with `ContextStrategy` protocol,
|
||||
`StrategyCapabilities`, `BackendSet`, `PlanContext`, `StrategyConfig`,
|
||||
`ContextStrategyResult`, and `StrategyRegistryEntry` domain models.
|
||||
Six built-in stub strategies (simple-keyword, semantic-embedding,
|
||||
breadth-depth-navigator, arce, temporal-archaeology, plan-decision-context)
|
||||
with spec-mandated quality scores and backend requirements.
|
||||
`StrategyRegistry` supports config-driven registration, per-strategy
|
||||
timeout/max-fragment limits, per-project enable/disable overrides,
|
||||
plugin discovery from `"module:ClassName"` strings, and validation
|
||||
that strategies declare supported resource types. (#191)
|
||||
- Fixed `context inspect` to display project-scoped tier fragment counts instead of global
|
||||
counts. Added `ContextTierService.get_scoped_metrics()` which returns fragment population
|
||||
counts filtered to the target project while keeping hit/miss counters as global service
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""ASV benchmarks for context strategy registry.
|
||||
|
||||
Measures registry lookup, registration, and can_handle performance
|
||||
against spec targets (can_handle < 20ms per strategy,
|
||||
specification.md:43245-43261).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure src is on sys.path for ASV
|
||||
_SRC = Path(__file__).resolve().parents[1] / "src"
|
||||
if str(_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC))
|
||||
importlib.invalidate_caches()
|
||||
import cleveragents
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from cleveragents.application.services.strategy_registry import ( # noqa: E402
|
||||
StrategyRegistry,
|
||||
)
|
||||
from cleveragents.domain.models.acms.crp import ContextRequest # noqa: E402
|
||||
from cleveragents.domain.models.acms.strategy import ( # noqa: E402
|
||||
BackendSet,
|
||||
StrategyConfig,
|
||||
)
|
||||
from cleveragents.domain.models.acms.strategy_stubs import ( # noqa: E402
|
||||
BUILTIN_STRATEGY_CLASSES,
|
||||
DEFAULT_ENABLED_STRATEGIES,
|
||||
SimpleKeywordStrategy,
|
||||
)
|
||||
from cleveragents.domain.models.acms.stubs import ( # noqa: E402
|
||||
InMemoryGraphBackend,
|
||||
InMemoryTextBackend,
|
||||
InMemoryVectorBackend,
|
||||
)
|
||||
|
||||
|
||||
class TimeRegistration:
|
||||
"""Benchmark strategy registration performance."""
|
||||
|
||||
timeout = 10
|
||||
repeat = 3
|
||||
number = 100
|
||||
|
||||
def time_register_single(self) -> None:
|
||||
"""Register a single strategy."""
|
||||
registry = StrategyRegistry()
|
||||
registry.register(SimpleKeywordStrategy(), is_builtin=True)
|
||||
|
||||
def time_register_all_builtins(self) -> None:
|
||||
"""Register all 6 built-in strategies."""
|
||||
registry = StrategyRegistry()
|
||||
for cls in BUILTIN_STRATEGY_CLASSES:
|
||||
inst = cls()
|
||||
registry.register(inst, is_builtin=True)
|
||||
|
||||
|
||||
class TimeLookup:
|
||||
"""Benchmark registry lookup performance."""
|
||||
|
||||
timeout = 10
|
||||
repeat = 3
|
||||
number = 1000
|
||||
|
||||
def setup(self) -> None:
|
||||
self.registry = StrategyRegistry()
|
||||
for cls in BUILTIN_STRATEGY_CLASSES:
|
||||
inst = cls()
|
||||
enabled = inst.name in DEFAULT_ENABLED_STRATEGIES
|
||||
self.registry.register(
|
||||
inst,
|
||||
config=StrategyConfig(enabled=enabled),
|
||||
is_builtin=True,
|
||||
)
|
||||
|
||||
def time_get_by_name(self) -> None:
|
||||
"""Look up a strategy by name."""
|
||||
self.registry.get("simple-keyword")
|
||||
|
||||
def time_list_enabled(self) -> None:
|
||||
"""List enabled strategies."""
|
||||
self.registry.list_enabled()
|
||||
|
||||
def time_list_all(self) -> None:
|
||||
"""List all strategies."""
|
||||
self.registry.list_all()
|
||||
|
||||
|
||||
class TimeCanHandle:
|
||||
"""Benchmark can_handle performance (spec target: < 20ms).
|
||||
|
||||
Spec: specification.md:43245-43261.
|
||||
"""
|
||||
|
||||
timeout = 10
|
||||
repeat = 3
|
||||
number = 1000
|
||||
|
||||
def setup(self) -> None:
|
||||
self.request = ContextRequest(query="test query")
|
||||
self.backends_all = BackendSet(
|
||||
text=InMemoryTextBackend(),
|
||||
vector=InMemoryVectorBackend(),
|
||||
graph=InMemoryGraphBackend(),
|
||||
)
|
||||
self.strategies = [cls() for cls in BUILTIN_STRATEGY_CLASSES]
|
||||
|
||||
def time_can_handle_all_strategies(self) -> None:
|
||||
"""Poll can_handle on all 6 built-in strategies."""
|
||||
for strategy in self.strategies:
|
||||
strategy.can_handle(self.request, self.backends_all)
|
||||
@@ -0,0 +1,130 @@
|
||||
# Context Strategy Registry
|
||||
|
||||
The Context Strategy Registry manages pluggable retrieval strategies for the
|
||||
ACMS Context Assembly Pipeline. Strategies implement the `ContextStrategy`
|
||||
protocol and are registered via TOML configuration or programmatic API.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ContextRequest
|
||||
┌─── StrategySelector ───┐
|
||||
│ can_handle() polling │
|
||||
└──────────┬──────────────┘
|
||||
v
|
||||
┌─── BudgetAllocator ────┐
|
||||
│ proportional alloc │
|
||||
└──────────┬──────────────┘
|
||||
v
|
||||
┌─── StrategyExecutor ───┐
|
||||
│ parallel w/ timeout │
|
||||
│ circuit breaker │
|
||||
└──────────┬──────────────┘
|
||||
v
|
||||
ContextFragment[]
|
||||
```
|
||||
|
||||
## ContextStrategy Protocol
|
||||
|
||||
Every strategy implements:
|
||||
|
||||
| Member | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `name` | `property -> str` | Unique strategy name |
|
||||
| `capabilities` | `property -> StrategyCapabilities` | Backend requirements and quality score |
|
||||
| `can_handle(request, backends)` | `-> float` | 0.0-1.0 confidence for this request |
|
||||
| `assemble(request, backends, budget, plan_context)` | `-> list[ContextFragment]` | Execute and return fragments; must respect budget |
|
||||
| `explain()` | `-> str` | Human-readable description |
|
||||
|
||||
## StrategyCapabilities
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `uses_text` | `bool` | `False` | Requires TextBackend |
|
||||
| `uses_vector` | `bool` | `False` | Requires VectorBackend |
|
||||
| `uses_graph` | `bool` | `False` | Requires GraphBackend |
|
||||
| `uses_temporal` | `bool` | `False` | Requires temporal/cold-tier data |
|
||||
| `resource_types` | `tuple[str, ...]` | `()` | Supported resource types (empty = all) |
|
||||
| `quality_score` | `float` | `0.5` | Intrinsic quality score (0.0-1.0) |
|
||||
| `supports_depth_breadth` | `bool` | `False` | Supports depth/breadth projection |
|
||||
| `supports_plan_hierarchy` | `bool` | `False` | Supports plan hierarchy traversal |
|
||||
| `supports_temporal` | `bool` | `False` | Supports temporal archaeology queries |
|
||||
|
||||
## Built-in Strategies
|
||||
|
||||
| Strategy | Quality | Backends | Description |
|
||||
|----------|---------|----------|-------------|
|
||||
| `simple-keyword` | 0.3 | Text | Basic keyword/regex text search. Universal fallback. |
|
||||
| `semantic-embedding` | 0.6 | Vector | Vector similarity search. |
|
||||
| `breadth-depth-navigator` | 0.85 | Graph | Graph-aware UKO traversal. Primary code-aware strategy. |
|
||||
| `arce` | 0.95 | All | Multi-modal ARCE pipeline. Highest quality. |
|
||||
| `temporal-archaeology` | 0.5 | Graph + Cold | Historical pattern discovery. |
|
||||
| `plan-decision-context` | 0.7 | Warm/Cold | Parent/ancestor plan context retrieval. |
|
||||
|
||||
Default enabled (spec `context.strategies.enabled`):
|
||||
`["simple-keyword", "semantic-embedding", "breadth-depth-navigator"]`
|
||||
|
||||
## Configuration
|
||||
|
||||
### Global (config.toml)
|
||||
|
||||
```toml
|
||||
[context.strategies]
|
||||
enabled = ["simple-keyword", "semantic-embedding", "breadth-depth-navigator"]
|
||||
|
||||
[context.strategies.custom]
|
||||
"my-strategy" = "my_package.strategies:MyStrategy"
|
||||
```
|
||||
|
||||
### Per-strategy settings
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `timeout_seconds` | int | 30 | Per-strategy assembly timeout |
|
||||
| `max_fragments` | int | 100 | Maximum fragments per call |
|
||||
| `max_workers` | int | 4 | Parallel workers for executor |
|
||||
| `circuit_breaker_threshold` | int | 3 | Failures before circuit opens |
|
||||
| `enabled` | bool | `true` | Whether the strategy is active |
|
||||
|
||||
### Per-project overrides
|
||||
|
||||
Projects can override the global enabled list using:
|
||||
|
||||
```
|
||||
agents project context set --strategy simple-keyword --strategy semantic-embedding
|
||||
```
|
||||
|
||||
This replaces the global `context.strategies.enabled` list for that project.
|
||||
|
||||
## ContextStrategyResult
|
||||
|
||||
Output of a single strategy execution:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `strategy_name` | `str` | Name of the producing strategy |
|
||||
| `fragments` | `tuple[ContextFragment, ...]` | Deterministically ordered fragments |
|
||||
| `total_fragments` | `int` | Total fragments before limits |
|
||||
| `tokens_used` | `int` | Total tokens across fragments |
|
||||
| `execution_time_ms` | `float` | Execution wall-clock time (ms) |
|
||||
| `errors` | `tuple[str, ...]` | Error messages |
|
||||
| `stats` | `dict[str, Any]` | Strategy-specific statistics |
|
||||
|
||||
Fragment ordering: `(relevance_score DESC, uko_node ASC)`.
|
||||
|
||||
## Registry Validation
|
||||
|
||||
The registry validates that:
|
||||
|
||||
1. Every enabled strategy is actually registered.
|
||||
2. Every strategy declares at least one backend capability.
|
||||
3. Every strategy declares supported resource types.
|
||||
|
||||
## Fallback Degradation Path
|
||||
|
||||
When backends are unavailable, the system degrades gracefully:
|
||||
|
||||
1. `arce` (requires all) → 2. `breadth-depth-navigator` (graph) →
|
||||
3. `semantic-embedding` (vector) → 4. `simple-keyword` (text only)
|
||||
|
||||
This ensures context is always produced.
|
||||
@@ -0,0 +1,531 @@
|
||||
@phase2 @acms @strategy
|
||||
Feature: Context Strategy Registry
|
||||
As a CleverAgents developer
|
||||
I want to register and configure context strategies
|
||||
So that the ACMS pipeline can select and execute them
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategy protocol conformance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_protocol
|
||||
Scenario: All built-in strategies satisfy the ContextStrategy protocol
|
||||
Given all built-in strategies are instantiated
|
||||
Then every strategy should satisfy the ContextStrategy protocol
|
||||
And every strategy should have a non-empty name
|
||||
And every strategy should have a capabilities object
|
||||
|
||||
@strategy_protocol
|
||||
Scenario: Built-in strategy quality scores match specification
|
||||
Given all built-in strategies are instantiated
|
||||
Then the strategy "simple-keyword" should have quality score 0.3
|
||||
And the strategy "semantic-embedding" should have quality score 0.6
|
||||
And the strategy "breadth-depth-navigator" should have quality score 0.85
|
||||
And the strategy "arce" should have quality score 0.95
|
||||
And the strategy "temporal-archaeology" should have quality score 0.5
|
||||
And the strategy "plan-decision-context" should have quality score 0.7
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_registry
|
||||
Scenario: Register a single strategy
|
||||
Given an empty strategy registry
|
||||
When I register the "simple-keyword" built-in strategy
|
||||
Then the strategy registry should contain "simple-keyword"
|
||||
And the registry size should be 1
|
||||
|
||||
@strategy_registry
|
||||
Scenario: Register all built-in strategies
|
||||
Given an empty strategy registry
|
||||
When I register all 6 built-in strategies
|
||||
Then the registry should contain 6 strategies
|
||||
And the strategy registry should contain "simple-keyword"
|
||||
And the strategy registry should contain "semantic-embedding"
|
||||
And the strategy registry should contain "breadth-depth-navigator"
|
||||
And the strategy registry should contain "arce"
|
||||
And the strategy registry should contain "temporal-archaeology"
|
||||
And the strategy registry should contain "plan-decision-context"
|
||||
|
||||
@strategy_registry @error_handling
|
||||
Scenario: Reject duplicate strategy registration
|
||||
Given an empty strategy registry
|
||||
And I register the "simple-keyword" built-in strategy
|
||||
When I attempt to register "simple-keyword" again
|
||||
Then a StrategyRegistrationError should be raised
|
||||
|
||||
@strategy_registry @error_handling
|
||||
Scenario: Reject strategy with empty name
|
||||
Given an empty strategy registry
|
||||
When I attempt to register a strategy with an empty name
|
||||
Then a StrategyRegistrationError should be raised
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry query
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_query
|
||||
Scenario: Get a registered strategy by name
|
||||
Given a registry with all built-in strategies
|
||||
When I get the strategy "simple-keyword"
|
||||
Then the strategy name should be "simple-keyword"
|
||||
|
||||
@strategy_query @error_handling
|
||||
Scenario: Get a non-existent strategy raises error
|
||||
Given a registry with all built-in strategies
|
||||
When I attempt to get the strategy "nonexistent"
|
||||
Then a StrategyNotFoundError should be raised
|
||||
|
||||
@strategy_query
|
||||
Scenario: List all registered strategy names
|
||||
Given a registry with all built-in strategies
|
||||
Then the registry should list 6 strategies
|
||||
And the list should be sorted alphabetically
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enable / disable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_config
|
||||
Scenario: Default enabled list matches spec
|
||||
Given a registry with all built-in strategies using default enabled list
|
||||
Then the enabled strategies should be "simple-keyword", "semantic-embedding", "breadth-depth-navigator"
|
||||
|
||||
@strategy_config
|
||||
Scenario: Override enabled list per project
|
||||
Given a registry with all built-in strategies using default enabled list
|
||||
When I set the enabled list to "simple-keyword", "arce"
|
||||
Then the enabled strategies should be "simple-keyword", "arce"
|
||||
|
||||
@strategy_config
|
||||
Scenario: Set enabled list deduplicates duplicate names
|
||||
Given a registry with all built-in strategies using default enabled list
|
||||
When I set the enabled list to "simple-keyword", "arce", "simple-keyword"
|
||||
Then the enabled strategies should be "simple-keyword", "arce"
|
||||
|
||||
@strategy_config @error_handling
|
||||
Scenario: Set enabled list with unknown strategy raises error
|
||||
Given a registry with all built-in strategies using default enabled list
|
||||
When I attempt to set the enabled list to "nonexistent"
|
||||
Then a StrategyNotFoundError should be raised
|
||||
|
||||
@strategy_config
|
||||
Scenario: Disabled strategy excluded from enabled list
|
||||
Given a registry with all built-in strategies using default enabled list
|
||||
When I disable the strategy "semantic-embedding"
|
||||
Then the enabled strategies should not include "semantic-embedding"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-strategy configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_config
|
||||
Scenario: Default strategy config values
|
||||
Given a registry with all built-in strategies
|
||||
When I get the config for "simple-keyword"
|
||||
Then the timeout_seconds should be 30
|
||||
And the max_fragments should be 100
|
||||
And the circuit_breaker_threshold should be 3
|
||||
|
||||
@strategy_config
|
||||
Scenario: Update per-strategy timeout
|
||||
Given a registry with all built-in strategies
|
||||
When I update config for "simple-keyword" with timeout_seconds 10
|
||||
Then the config for "simple-keyword" should have timeout_seconds 10
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# can_handle with backends
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_can_handle
|
||||
Scenario: simple-keyword returns quality score with text backend
|
||||
Given a BackendSet with text backend only
|
||||
And a default ContextRequest
|
||||
Then "simple-keyword" can_handle should return 0.3
|
||||
|
||||
@strategy_can_handle
|
||||
Scenario: simple-keyword returns 0 without text backend
|
||||
Given a BackendSet with no backends
|
||||
And a default ContextRequest
|
||||
Then "simple-keyword" can_handle should return 0.0
|
||||
|
||||
@strategy_can_handle
|
||||
Scenario: semantic-embedding returns quality score with vector backend
|
||||
Given a BackendSet with vector backend only
|
||||
And a default ContextRequest
|
||||
Then "semantic-embedding" can_handle should return 0.6
|
||||
|
||||
@strategy_can_handle
|
||||
Scenario: arce requires all backends
|
||||
Given a BackendSet with all backends
|
||||
And a default ContextRequest
|
||||
Then "arce" can_handle should return 0.95
|
||||
|
||||
@strategy_can_handle
|
||||
Scenario: arce returns 0 when missing a backend
|
||||
Given a BackendSet with text backend only
|
||||
And a default ContextRequest
|
||||
Then "arce" can_handle should return 0.0
|
||||
|
||||
@strategy_can_handle
|
||||
Scenario: plan-decision-context returns quality score with temporal backend
|
||||
Given a BackendSet with temporal backend only
|
||||
And a default ContextRequest
|
||||
Then "plan-decision-context" can_handle should return 0.7
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub assemble (no-op)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_assemble
|
||||
Scenario: Stub strategies return empty fragment lists
|
||||
Given all built-in strategies are instantiated
|
||||
And a BackendSet with all backends
|
||||
And a default ContextRequest
|
||||
And a default PlanContext
|
||||
When each strategy assembles with budget 1000
|
||||
Then every strategy should return an empty fragment list
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ContextStrategyResult deterministic ordering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_result
|
||||
Scenario: ContextStrategyResult sorts fragments deterministically
|
||||
Given a ContextStrategyResult with unordered fragments
|
||||
Then the fragments should be sorted by relevance_score DESC then uko_node ASC
|
||||
|
||||
@strategy_result
|
||||
Scenario: ContextStrategyResult with no fragments
|
||||
Given a ContextStrategyResult with no fragments
|
||||
Then the total_fragments should be 0
|
||||
And the tokens_used should be 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_validation
|
||||
Scenario: Validate registry warns about missing resource types
|
||||
Given a registry with a strategy that declares no resource types
|
||||
When I validate the registry
|
||||
Then validation warnings should mention "resource types"
|
||||
|
||||
@strategy_validation
|
||||
Scenario: Validate registry warns about missing capabilities
|
||||
Given a registry with a strategy that declares no backend capabilities
|
||||
When I validate the registry
|
||||
Then validation warnings should mention "backend capabilities"
|
||||
|
||||
@strategy_validation
|
||||
Scenario: Valid registry returns no warnings for built-in strategies
|
||||
Given a registry with all built-in strategies
|
||||
When I validate the registry
|
||||
Then there should be no validation warnings
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unregister
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin discovery (register_from_module)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_registry @plugin
|
||||
Scenario: Register a strategy from a module path
|
||||
Given an empty strategy registry
|
||||
When I register a strategy from module "cleveragents.domain.models.acms.strategy_stubs:SimpleKeywordStrategy"
|
||||
Then the strategy registry should contain "simple-keyword"
|
||||
And the registry size should be 1
|
||||
|
||||
@strategy_registry @plugin @error_handling
|
||||
Scenario: Reject module path without colon separator
|
||||
Given an empty strategy registry
|
||||
When I attempt to register a strategy from module "no_colon_here"
|
||||
Then a StrategyRegistrationError should be raised
|
||||
|
||||
@strategy_registry @plugin @error_handling
|
||||
Scenario: Reject module path with missing module
|
||||
Given an empty strategy registry
|
||||
When I attempt to register a strategy from module "nonexistent.module:FakeClass"
|
||||
Then a StrategyRegistrationError should be raised
|
||||
|
||||
@strategy_registry @plugin @error_handling
|
||||
Scenario: Reject module path with missing class
|
||||
Given an empty strategy registry
|
||||
When I attempt to register a strategy from module "cleveragents.domain.models.acms.strategy_stubs:NonExistentClass"
|
||||
Then a StrategyRegistrationError should be raised
|
||||
|
||||
@strategy_registry @plugin @error_handling
|
||||
Scenario: Reject module path when instantiation fails
|
||||
Given an empty strategy registry
|
||||
When I attempt to register a strategy from module "cleveragents.domain.models.acms.strategy_stubs:BUILTIN_STRATEGY_CLASSES"
|
||||
Then a StrategyRegistrationError should be raised
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional query coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_query
|
||||
Scenario: Get entry for a registered strategy
|
||||
Given a registry with all built-in strategies
|
||||
When I get the entry for "simple-keyword"
|
||||
Then the entry name should be "simple-keyword"
|
||||
And the entry should be marked as builtin
|
||||
|
||||
@strategy_query @error_handling
|
||||
Scenario: Get entry for non-existent strategy raises error
|
||||
Given a registry with all built-in strategies
|
||||
When I attempt to get the entry for "nonexistent"
|
||||
Then a StrategyNotFoundError should be raised
|
||||
|
||||
@strategy_query
|
||||
Scenario: List built-in strategies
|
||||
Given a registry with all built-in strategies
|
||||
Then the builtin list should contain 6 strategies
|
||||
|
||||
@strategy_query
|
||||
Scenario: Registry supports the in operator
|
||||
Given a registry with all built-in strategies
|
||||
Then "simple-keyword" should be in the registry via contains
|
||||
And "nonexistent" should not be in the registry via contains
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation edge case: enabled-but-unregistered
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_validation
|
||||
Scenario: Validate warns when enabled list references unregistered strategy
|
||||
Given a registry with a stale enabled list entry
|
||||
When I validate the registry
|
||||
Then validation warnings should mention "not registered"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unregister error path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_registry @error_handling
|
||||
Scenario: Unregister non-existent strategy raises error
|
||||
Given a registry with all built-in strategies
|
||||
When I attempt to unregister "nonexistent"
|
||||
Then a StrategyNotFoundError should be raised
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unregister
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_registry
|
||||
Scenario: Unregister a strategy
|
||||
Given a registry with all built-in strategies
|
||||
When I unregister "temporal-archaeology"
|
||||
Then the registry should contain 5 strategies
|
||||
And the strategy registry should not contain "temporal-archaeology"
|
||||
|
||||
@strategy_registry
|
||||
Scenario: Clear the registry
|
||||
Given a registry with all built-in strategies
|
||||
When I clear the registry
|
||||
Then the registry should contain 0 strategies
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boundary tests for Pydantic-validated config fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_config @boundary
|
||||
Scenario Outline: Reject invalid StrategyConfig numeric values
|
||||
When I attempt to create a StrategyConfig with <field> set to <value>
|
||||
Then a Pydantic ValidationError should be raised
|
||||
|
||||
Examples:
|
||||
| field | value |
|
||||
| timeout_seconds | 0 |
|
||||
| timeout_seconds | -1 |
|
||||
| max_fragments | 0 |
|
||||
| max_fragments | -1 |
|
||||
| max_workers | 0 |
|
||||
| circuit_breaker_threshold | 0 |
|
||||
|
||||
@strategy_config @boundary
|
||||
Scenario Outline: Reject invalid quality_score values
|
||||
When I attempt to create a StrategyCapabilities with quality_score <value>
|
||||
Then a Pydantic ValidationError should be raised
|
||||
|
||||
Examples:
|
||||
| value |
|
||||
| -0.1 |
|
||||
| 1.1 |
|
||||
|
||||
@strategy_config @boundary
|
||||
Scenario: Accept quality_score at lower bound 0.0
|
||||
When I create a StrategyCapabilities with quality_score 0.0
|
||||
Then the quality_score should be 0.0
|
||||
|
||||
@strategy_config @boundary
|
||||
Scenario: Accept quality_score at upper bound 1.0
|
||||
When I create a StrategyCapabilities with quality_score 1.0
|
||||
Then the quality_score should be 1.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug regression tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_config @regression
|
||||
Scenario: update_config enabled=True adds strategy to enabled list
|
||||
Given an empty strategy registry
|
||||
When I register the "simple-keyword" built-in strategy with enabled False
|
||||
Then the enabled strategies should be ""
|
||||
When I update config for "simple-keyword" with enabled True
|
||||
Then the enabled strategies should include "simple-keyword"
|
||||
|
||||
@strategy_config @regression
|
||||
Scenario: update_config enabled=False removes strategy from enabled list
|
||||
Given a registry with all built-in strategies using default enabled list
|
||||
When I update config for "simple-keyword" with enabled False
|
||||
Then the enabled strategies should not include "simple-keyword"
|
||||
|
||||
@strategy_registry @plugin @regression
|
||||
Scenario: register_from_module uses the provided name parameter
|
||||
Given an empty strategy registry
|
||||
When I register a strategy named "custom-search" from module "cleveragents.domain.models.acms.strategy_stubs:SimpleKeywordStrategy"
|
||||
Then the strategy registry should contain "custom-search"
|
||||
And the registry size should be 1
|
||||
|
||||
@strategy_config @regression
|
||||
Scenario: update_config with max_workers and circuit_breaker_threshold
|
||||
Given a registry with all built-in strategies
|
||||
When I update config for "simple-keyword" with max_workers 8 and circuit_breaker_threshold 5
|
||||
Then the config for "simple-keyword" should have max_workers 8
|
||||
And the config for "simple-keyword" should have circuit_breaker_threshold 5
|
||||
|
||||
@strategy_registry @error_handling
|
||||
Scenario: Reject object that does not satisfy ContextStrategy protocol
|
||||
Given an empty strategy registry
|
||||
When I attempt to register a non-protocol object
|
||||
Then a StrategyRegistrationError should be raised
|
||||
|
||||
@strategy_registry @error_handling
|
||||
Scenario: Reject non-protocol object without explicit name
|
||||
Given an empty strategy registry
|
||||
When I attempt to register a non-protocol object without a name
|
||||
Then a StrategyRegistrationError should be raised
|
||||
And the error message should contain "does not satisfy"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# explain() coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_protocol
|
||||
Scenario: Every built-in strategy returns a non-empty explain string
|
||||
Given all built-in strategies are instantiated
|
||||
Then every strategy should return a non-empty explain string
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# can_handle coverage for strategies not tested elsewhere
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_can_handle
|
||||
Scenario: breadth-depth-navigator returns quality score with graph backend
|
||||
Given a BackendSet with graph backend only
|
||||
And a default ContextRequest
|
||||
Then "breadth-depth-navigator" can_handle should return 0.85
|
||||
|
||||
@strategy_can_handle
|
||||
Scenario: breadth-depth-navigator returns 0 without graph backend
|
||||
Given a BackendSet with no backends
|
||||
And a default ContextRequest
|
||||
Then "breadth-depth-navigator" can_handle should return 0.0
|
||||
|
||||
@strategy_can_handle
|
||||
Scenario: temporal-archaeology returns quality score with graph and temporal backends
|
||||
Given a BackendSet with graph and temporal backends
|
||||
And a default ContextRequest
|
||||
Then "temporal-archaeology" can_handle should return 0.5
|
||||
|
||||
@strategy_can_handle
|
||||
Scenario: temporal-archaeology returns 0 without graph backend
|
||||
Given a BackendSet with no backends
|
||||
And a default ContextRequest
|
||||
Then "temporal-archaeology" can_handle should return 0.0
|
||||
|
||||
@strategy_can_handle
|
||||
Scenario: semantic-embedding returns 0 without vector backend
|
||||
Given a BackendSet with no backends
|
||||
And a default ContextRequest
|
||||
Then "semantic-embedding" can_handle should return 0.0
|
||||
|
||||
@strategy_config @regression
|
||||
Scenario: update_config rejects invalid timeout via Pydantic validation
|
||||
Given a registry with all built-in strategies
|
||||
When I attempt to update config for "simple-keyword" with timeout_seconds 0
|
||||
Then a Pydantic ValidationError should be raised
|
||||
|
||||
@strategy_config @regression
|
||||
Scenario: update_config rejects negative max_fragments via Pydantic validation
|
||||
Given a registry with all built-in strategies
|
||||
When I attempt to update config for "simple-keyword" with max_fragments -1
|
||||
Then a Pydantic ValidationError should be raised
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_config: resource_types and extra parameters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_config @regression
|
||||
Scenario: update_config with resource_types
|
||||
Given a registry with all built-in strategies
|
||||
When I update config for "simple-keyword" with resource_types "code", "doc"
|
||||
Then the config for "simple-keyword" should have resource_types "code", "doc"
|
||||
|
||||
@strategy_config @regression
|
||||
Scenario: update_config with extra dict
|
||||
Given a registry with all built-in strategies
|
||||
When I update config for "simple-keyword" with extra key "boost" value "2"
|
||||
Then the config for "simple-keyword" extra should contain key "boost"
|
||||
And the config for "simple-keyword" extra should be immutable
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MappingProxyType coercion validators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_config @boundary
|
||||
Scenario: StrategyConfig accepts plain dict for extra field
|
||||
When I create a StrategyConfig with extra as a plain dict
|
||||
Then the extra field should be a MappingProxyType
|
||||
|
||||
@strategy_config @boundary
|
||||
Scenario: StrategyConfig rejects non-dict non-MappingProxyType extra
|
||||
When I attempt to create a StrategyConfig with extra as an integer
|
||||
Then a Pydantic ValidationError should be raised
|
||||
|
||||
@strategy_result @boundary
|
||||
Scenario: ContextStrategyResult accepts plain dict for stats field
|
||||
When I create a ContextStrategyResult with stats as a plain dict
|
||||
Then the stats field should be a MappingProxyType
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_registry @thread_safety
|
||||
Scenario: Concurrent register and list_enabled do not raise
|
||||
Given a registry with all built-in strategies
|
||||
When 10 threads concurrently register and query the registry
|
||||
Then no thread should have raised an exception
|
||||
And the registry should contain at least 10 strategies
|
||||
And every thread-registered strategy should be present
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# temporal/cold-tier can_handle regression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@strategy_can_handle @regression
|
||||
Scenario: temporal-archaeology returns 0 without temporal backend
|
||||
Given a BackendSet with graph backend only
|
||||
And a default ContextRequest
|
||||
Then "temporal-archaeology" can_handle should return 0.0
|
||||
|
||||
@strategy_can_handle @regression
|
||||
Scenario: plan-decision-context returns 0 without temporal backend
|
||||
Given a BackendSet with no backends
|
||||
And a default ContextRequest
|
||||
Then "plan-decision-context" can_handle should return 0.0
|
||||
@@ -0,0 +1,35 @@
|
||||
*** Settings ***
|
||||
Documentation Integration smoke tests for context strategy registry
|
||||
Library Process
|
||||
Suite Setup Log Context Strategy Registry Robot Tests
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_context_strategy_registry.py
|
||||
|
||||
*** Test Cases ***
|
||||
Register All Built-in Strategies
|
||||
[Documentation] Register 6 built-in strategies with default enabled list
|
||||
${result}= Run Process python3 ${HELPER} register-builtins
|
||||
Log ${result.stdout}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} strategy-ok
|
||||
|
||||
Verify Protocol Conformance
|
||||
[Documentation] All built-in strategies satisfy ContextStrategy protocol
|
||||
${result}= Run Process python3 ${HELPER} protocol-check
|
||||
Log ${result.stdout}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} strategy-ok
|
||||
|
||||
Test Can Handle With Backends
|
||||
[Documentation] Verify can_handle returns correct scores per backend availability
|
||||
${result}= Run Process python3 ${HELPER} can-handle
|
||||
Log ${result.stdout}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
|
||||
Validate Registry
|
||||
[Documentation] Validate registry with all built-in strategies passes
|
||||
${result}= Run Process python3 ${HELPER} validate
|
||||
Log ${result.stdout}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} validation passed
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Robot Framework helper for context strategy registry smoke tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure src is on sys.path
|
||||
_SRC = Path(__file__).resolve().parents[1] / "src"
|
||||
if str(_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC))
|
||||
|
||||
from cleveragents.application.services.strategy_registry import ( # noqa: E402
|
||||
StrategyRegistry,
|
||||
)
|
||||
from cleveragents.domain.models.acms.crp import ContextRequest # noqa: E402
|
||||
from cleveragents.domain.models.acms.strategy import ( # noqa: E402
|
||||
BackendSet,
|
||||
ContextStrategy,
|
||||
StrategyConfig,
|
||||
)
|
||||
from cleveragents.domain.models.acms.strategy_stubs import ( # noqa: E402
|
||||
BUILTIN_STRATEGY_CLASSES,
|
||||
DEFAULT_ENABLED_STRATEGIES,
|
||||
)
|
||||
from cleveragents.domain.models.acms.stubs import ( # noqa: E402
|
||||
InMemoryGraphBackend,
|
||||
InMemoryTextBackend,
|
||||
InMemoryVectorBackend,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cmd_register_builtins() -> int:
|
||||
"""Register all built-in strategies and verify count."""
|
||||
registry = StrategyRegistry()
|
||||
for cls in BUILTIN_STRATEGY_CLASSES:
|
||||
inst = cls()
|
||||
enabled = inst.name in DEFAULT_ENABLED_STRATEGIES
|
||||
registry.register(inst, config=StrategyConfig(enabled=enabled), is_builtin=True)
|
||||
registry.set_enabled(list(DEFAULT_ENABLED_STRATEGIES))
|
||||
|
||||
if len(registry) != 6:
|
||||
print(f"strategy-fail: expected 6 strategies, got {len(registry)}")
|
||||
return 1
|
||||
print(f"strategy-ok: registered {len(registry)} strategies")
|
||||
|
||||
enabled = registry.list_enabled()
|
||||
if len(enabled) != 3:
|
||||
print(f"strategy-fail: expected 3 enabled, got {len(enabled)}")
|
||||
return 1
|
||||
print(f"strategy-enabled: {enabled}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_protocol_check() -> int:
|
||||
"""Verify all built-in strategies satisfy ContextStrategy protocol."""
|
||||
for cls in BUILTIN_STRATEGY_CLASSES:
|
||||
inst = cls()
|
||||
if not isinstance(inst, ContextStrategy):
|
||||
print(f"strategy-fail: {cls.__name__} doesn't satisfy protocol")
|
||||
return 1
|
||||
if not inst.name:
|
||||
print(f"strategy-fail: {cls.__name__} has empty name")
|
||||
return 1
|
||||
if inst.capabilities.quality_score <= 0:
|
||||
print(f"strategy-fail: {cls.__name__} has invalid quality score")
|
||||
return 1
|
||||
print(f"strategy-ok: {inst.name} (quality={inst.capabilities.quality_score})")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_can_handle() -> int:
|
||||
"""Test can_handle with various backend configurations."""
|
||||
from cleveragents.domain.models.acms.strategy_stubs import (
|
||||
ARCEStrategy,
|
||||
SimpleKeywordStrategy,
|
||||
)
|
||||
|
||||
request = ContextRequest(query="test")
|
||||
|
||||
# simple-keyword with text backend
|
||||
sk = SimpleKeywordStrategy()
|
||||
bs_text = BackendSet(text=InMemoryTextBackend())
|
||||
score = sk.can_handle(request, bs_text)
|
||||
if abs(score - 0.3) > 1e-6:
|
||||
print(f"strategy-fail: simple-keyword expected 0.3, got {score}")
|
||||
return 1
|
||||
print(f"strategy-ok: simple-keyword can_handle with text = {score}")
|
||||
|
||||
# simple-keyword without backend
|
||||
bs_none = BackendSet()
|
||||
score = sk.can_handle(request, bs_none)
|
||||
if score != 0.0:
|
||||
print(
|
||||
f"strategy-fail: simple-keyword expected 0.0 without backend, got {score}"
|
||||
)
|
||||
return 1
|
||||
print(f"strategy-ok: simple-keyword can_handle without backend = {score}")
|
||||
|
||||
# arce with all backends
|
||||
arce = ARCEStrategy()
|
||||
bs_all = BackendSet(
|
||||
text=InMemoryTextBackend(),
|
||||
vector=InMemoryVectorBackend(),
|
||||
graph=InMemoryGraphBackend(),
|
||||
)
|
||||
score = arce.can_handle(request, bs_all)
|
||||
if abs(score - 0.95) > 1e-6:
|
||||
print(f"strategy-fail: arce expected 0.95, got {score}")
|
||||
return 1
|
||||
print(f"strategy-ok: arce can_handle with all = {score}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_validate() -> int:
|
||||
"""Validate registry with all built-in strategies."""
|
||||
registry = StrategyRegistry()
|
||||
for cls in BUILTIN_STRATEGY_CLASSES:
|
||||
registry.register(cls(), is_builtin=True)
|
||||
|
||||
warnings = registry.validate_registry()
|
||||
if warnings:
|
||||
print(f"strategy-fail: unexpected warnings: {warnings}")
|
||||
return 1
|
||||
print("strategy-ok: registry validation passed with no warnings")
|
||||
return 0
|
||||
|
||||
|
||||
_COMMANDS: dict[str, Callable[[], int]] = {
|
||||
"register-builtins": _cmd_register_builtins,
|
||||
"protocol-check": _cmd_protocol_check,
|
||||
"can-handle": _cmd_can_handle,
|
||||
"validate": _cmd_validate,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the specified command."""
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
||||
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>")
|
||||
return 2
|
||||
return _COMMANDS[sys.argv[1]]()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -111,6 +111,11 @@ from cleveragents.application.services.skeleton_compressor import (
|
||||
from cleveragents.application.services.skill_registry_service import (
|
||||
SkillRegistryService,
|
||||
)
|
||||
from cleveragents.application.services.strategy_registry import (
|
||||
StrategyNotFoundError,
|
||||
StrategyRegistrationError,
|
||||
StrategyRegistry,
|
||||
)
|
||||
from cleveragents.application.services.subplan_execution_service import (
|
||||
SubplanExecutionOutput,
|
||||
SubplanExecutionResult,
|
||||
@@ -226,6 +231,9 @@ __all__ = [
|
||||
"SpawnResult",
|
||||
"SpawnValidationError",
|
||||
"SpawnValidationResult",
|
||||
"StrategyNotFoundError",
|
||||
"StrategyRegistrationError",
|
||||
"StrategyRegistry",
|
||||
"SubplanExecutionOutput",
|
||||
"SubplanExecutionResult",
|
||||
"SubplanExecutionService",
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
"""Context strategy registry for the ACMS.
|
||||
|
||||
Provides ``StrategyRegistry`` — the central registry for context
|
||||
strategies. Supports:
|
||||
|
||||
- Registration of built-in and custom strategies
|
||||
- Configuration-driven enable/disable (spec §25218-25233)
|
||||
- Per-strategy timeout and max-fragment limits (spec §28706-28708)
|
||||
- Validation that strategies declare supported resource types
|
||||
- Per-project strategy overrides via enabled list
|
||||
- Plugin discovery from ``"module:ClassName"`` strings
|
||||
|
||||
Based on ``docs/specification.md`` §§ 25218-25233, 28682-28708,
|
||||
42947-42980, and issue #191.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import threading
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from cleveragents.domain.models.acms.strategy import (
|
||||
ContextStrategy,
|
||||
StrategyConfig,
|
||||
StrategyRegistryEntry,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StrategyRegistrationError(Exception):
|
||||
"""Raised when strategy registration fails."""
|
||||
|
||||
|
||||
class StrategyNotFoundError(KeyError):
|
||||
"""Raised when a requested strategy is not in the registry."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StrategyRegistry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StrategyRegistry:
|
||||
"""Central registry for context strategies.
|
||||
|
||||
Manages built-in and custom strategies with per-strategy
|
||||
configuration. The registry is the single source of truth for
|
||||
which strategies are available and how they are configured.
|
||||
|
||||
Strategies are registered via :meth:`register` and discovered
|
||||
via :meth:`get`, :meth:`list_enabled`, or :meth:`list_all`.
|
||||
|
||||
Configuration-driven registration follows spec §25218-25233:
|
||||
|
||||
- ``[context.strategies] enabled = [...]`` controls the global
|
||||
enabled list.
|
||||
- ``[context.strategies.custom] "name" = "module:Class"``
|
||||
registers custom strategies from Python modules.
|
||||
|
||||
Per-project overrides replace the global enabled list.
|
||||
|
||||
Lifecycle::
|
||||
|
||||
registry = StrategyRegistry()
|
||||
registry.register(my_strategy, config=StrategyConfig(timeout_seconds=10))
|
||||
enabled = registry.list_enabled()
|
||||
result = registry.get("simple-keyword")
|
||||
"""
|
||||
|
||||
|
|
||||
# INVARIANTS (must hold after every public method returns):
|
||||
# 1. _strategies.keys() == _entries.keys()
|
||||
# 2. set(_enabled_order) is a subset of _strategies.keys()
|
||||
# 3. For all n in _enabled_order: _entries[n].config.enabled is True
|
||||
# 4. For all n not in _enabled_order: _entries[n].config.enabled is False
|
||||
|
||||
# Default module prefix allowlist for register_from_module().
|
||||
# Only modules under these prefixes may be dynamically imported.
|
||||
# Override via the ``allowed_module_prefixes`` constructor parameter.
|
||||
DEFAULT_ALLOWED_MODULE_PREFIXES: tuple[str, ...] = ("cleveragents.",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
allowed_module_prefixes: tuple[str, ...] | None = None,
|
||||
) -> None:
|
||||
"""Initialize an empty registry.
|
||||
|
||||
Args:
|
||||
allowed_module_prefixes: Module prefixes permitted for
|
||||
dynamic import via :meth:`register_from_module`.
|
||||
Defaults to ``("cleveragents.",)``. Pass an empty
|
||||
tuple to disable the allowlist (not recommended).
|
||||
"""
|
||||
self._lock = threading.RLock()
|
||||
self._strategies: dict[str, ContextStrategy] = {}
|
||||
self._entries: dict[str, StrategyRegistryEntry] = {}
|
||||
self._enabled_order: list[str] = []
|
||||
self._allowed_module_prefixes = (
|
||||
allowed_module_prefixes
|
||||
if allowed_module_prefixes is not None
|
||||
else self.DEFAULT_ALLOWED_MODULE_PREFIXES
|
||||
)
|
||||
|
||||
|
CoreRasurae
commented
[BUG - HIGH] When Fix: Move the **[BUG - HIGH]** When `name=None` (default) and the passed object is not a `ContextStrategy`, this line accesses `strategy.name` before the protocol check at line 121. If the object lacks a `.name` attribute, this raises `AttributeError` instead of `StrategyRegistrationError`.
**Fix:** Move the `isinstance(strategy, ContextStrategy)` check before this line, or wrap the `strategy.name` access in a try/except that converts to `StrategyRegistrationError`.
|
||||
# ------------------------------------------------------------------
|
||||
# Registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def register(
|
||||
self,
|
||||
strategy: ContextStrategy,
|
||||
*,
|
||||
name: str | None = None,
|
||||
config: StrategyConfig | None = None,
|
||||
module_path: str = "",
|
||||
is_builtin: bool = False,
|
||||
) -> None:
|
||||
"""Register a strategy with the registry.
|
||||
|
||||
Args:
|
||||
strategy: Strategy instance implementing ``ContextStrategy``.
|
||||
name: Override name for the registry key. If ``None``,
|
||||
``strategy.name`` is used. Used by
|
||||
:meth:`register_from_module` to honour the TOML key.
|
||||
config: Per-strategy configuration. Defaults to
|
||||
``StrategyConfig()`` if not provided.
|
||||
module_path: Python module path (for custom strategies).
|
||||
is_builtin: Whether this is a built-in strategy.
|
||||
|
||||
Raises:
|
||||
StrategyRegistrationError: If the strategy name is empty,
|
||||
already registered, or the strategy doesn't satisfy
|
||||
the ``ContextStrategy`` protocol.
|
||||
"""
|
||||
# Validate protocol conformance FIRST — before accessing any
|
||||
# attributes (e.g., strategy.name) that a non-protocol object
|
||||
# may not have.
|
||||
if not isinstance(strategy, ContextStrategy):
|
||||
label = name or type(strategy).__name__
|
||||
raise StrategyRegistrationError(
|
||||
f"Strategy '{label}' does not satisfy the ContextStrategy protocol"
|
||||
)
|
||||
|
||||
name = name or strategy.name
|
||||
if not name:
|
||||
raise StrategyRegistrationError("Strategy name must not be empty")
|
||||
|
||||
with self._lock:
|
||||
if name in self._strategies:
|
||||
raise StrategyRegistrationError(
|
||||
f"Strategy '{name}' is already registered"
|
||||
)
|
||||
|
||||
resolved_config = config or StrategyConfig()
|
||||
entry = StrategyRegistryEntry(
|
||||
name=name,
|
||||
config=resolved_config,
|
||||
module_path=module_path,
|
||||
is_builtin=is_builtin,
|
||||
)
|
||||
|
||||
self._strategies[name] = strategy
|
||||
self._entries[name] = entry
|
||||
|
||||
if resolved_config.enabled:
|
||||
self._enabled_order.append(name)
|
||||
|
||||
logger.debug(
|
||||
"strategy.registered",
|
||||
name=name,
|
||||
builtin=is_builtin,
|
||||
enabled=resolved_config.enabled,
|
||||
)
|
||||
|
||||
def register_from_module(
|
||||
self,
|
||||
name: str,
|
||||
|
CoreRasurae
commented
[SECURITY - MEDIUM] CWE-706: Consider adding a configurable allowlist (e.g., only **[SECURITY - MEDIUM] CWE-706:** `importlib.import_module()` with a runtime-determined string executes arbitrary module-level code on import. The docstring warning is good, but there is no enforcement — no allowlist of permitted module prefixes or namespace restriction.
Consider adding a configurable allowlist (e.g., only `cleveragents.*` modules or explicitly configured trusted namespaces) to prevent loading untrusted code from user-supplied TOML configs.
|
||||
module_path: str,
|
||||
*,
|
||||
config: StrategyConfig | None = None,
|
||||
) -> None:
|
||||
"""Register a custom strategy from a ``"module:ClassName"`` string.
|
||||
|
||||
Performs plugin discovery per spec §25225-25226:
|
||||
``[context.strategies.custom] "name" = "module:Class"``.
|
||||
|
||||
Note:
|
||||
Spec §25226 uses dot notation
|
||||
(``my_package.strategies.DomainStrategy``) while §25232
|
||||
uses colon notation
|
||||
(``my_extensions.scorers:DomainAwareScorer``).
|
||||
We use **colon notation** (``module:ClassName``) because
|
||||
it unambiguously separates the importable module from the
|
||||
class name, matching Python entry-point conventions.
|
||||
The spec is internally inconsistent here.
|
||||
|
||||
Security:
|
||||
Module paths should come from trusted configuration only
|
||||
(e.g., project TOML files). Importing a module executes its
|
||||
module-level code, and instantiating the class runs its
|
||||
``__init__``. Never pass untrusted or user-supplied strings
|
||||
as ``module_path``.
|
||||
|
||||
Args:
|
||||
name: Unique name for the strategy.
|
||||
module_path: Python path in ``"module:ClassName"`` or
|
||||
``"module.path:ClassName"`` format.
|
||||
config: Per-strategy configuration.
|
||||
|
||||
Raises:
|
||||
StrategyRegistrationError: If the module prefix is not in
|
||||
the allowlist, import fails, the class doesn't exist,
|
||||
or the instance doesn't satisfy the ``ContextStrategy``
|
||||
protocol.
|
||||
"""
|
||||
if ":" not in module_path:
|
||||
raise StrategyRegistrationError(
|
||||
f"Invalid module_path '{module_path}': "
|
||||
f"expected 'module:ClassName' format"
|
||||
)
|
||||
|
||||
module_name, class_name = module_path.rsplit(":", 1)
|
||||
|
||||
# CWE-706: Restrict dynamic imports to an allowlist of trusted
|
||||
# module prefixes to prevent arbitrary code execution.
|
||||
if self._allowed_module_prefixes and not any(
|
||||
module_name.startswith(prefix) for prefix in self._allowed_module_prefixes
|
||||
):
|
||||
raise StrategyRegistrationError(
|
||||
f"Module '{module_name}' is not in the allowed prefix list: "
|
||||
f"{self._allowed_module_prefixes}. Only modules under these "
|
||||
|
brent.edwards
commented
P1:must-fix —
Fix: Either **P1:must-fix** — `set_enabled()` does not validate for duplicate names in the input list.
`enabled_set = set(names)` correctly deduplicates for the config-update loop, but `self._enabled_order = list(names)` preserves any duplicates from the caller. This violates Invariant 2 (`set(_enabled_order)` should behave like a proper set) and would cause `list_enabled()` to return duplicate entries, leading to duplicate strategy execution.
**Fix**: Either `self._enabled_order = list(dict.fromkeys(names))` to deduplicate while preserving order, or raise `ValueError` on duplicate input. Deduplicate-with-warning is probably safest for config-driven callers.
|
||||
f"prefixes may be dynamically imported."
|
||||
)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except ImportError as exc:
|
||||
raise StrategyRegistrationError(
|
||||
f"Cannot import module '{module_name}' for strategy '{name}': {exc}"
|
||||
) from exc
|
||||
|
||||
cls = getattr(module, class_name, None)
|
||||
if cls is None:
|
||||
raise StrategyRegistrationError(
|
||||
f"Class '{class_name}' not found in module '{module_name}'"
|
||||
)
|
||||
|
||||
try:
|
||||
instance = cls()
|
||||
except Exception as exc:
|
||||
raise StrategyRegistrationError(
|
||||
f"Cannot instantiate '{class_name}' for strategy '{name}': {exc}"
|
||||
) from exc
|
||||
|
||||
self.register(
|
||||
instance,
|
||||
name=name,
|
||||
config=config,
|
||||
module_path=module_path,
|
||||
is_builtin=False,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get(self, name: str) -> ContextStrategy:
|
||||
"""Return the strategy with the given name.
|
||||
|
||||
Args:
|
||||
name: Strategy name.
|
||||
|
||||
Returns:
|
||||
The registered ``ContextStrategy`` instance.
|
||||
|
||||
Raises:
|
||||
StrategyNotFoundError: If the name is not registered.
|
||||
"""
|
||||
with self._lock:
|
||||
if name not in self._strategies:
|
||||
raise StrategyNotFoundError(
|
||||
f"Strategy '{name}' not found in registry. "
|
||||
f"Available: {sorted(self._strategies)}"
|
||||
)
|
||||
return self._strategies[name]
|
||||
|
||||
def get_entry(self, name: str) -> StrategyRegistryEntry:
|
||||
"""Return the registry entry (metadata + config) for a strategy.
|
||||
|
||||
Raises:
|
||||
StrategyNotFoundError: If the name is not registered.
|
||||
"""
|
||||
with self._lock:
|
||||
if name not in self._entries:
|
||||
raise StrategyNotFoundError(f"Strategy '{name}' not found in registry")
|
||||
return self._entries[name]
|
||||
|
||||
def get_config(self, name: str) -> StrategyConfig:
|
||||
"""Return the configuration for a registered strategy.
|
||||
|
||||
Raises:
|
||||
StrategyNotFoundError: If the name is not registered.
|
||||
"""
|
||||
return self.get_entry(name).config
|
||||
|
||||
def list_all(self) -> list[str]:
|
||||
"""Return names of all registered strategies."""
|
||||
with self._lock:
|
||||
return sorted(self._strategies)
|
||||
|
||||
def list_enabled(self) -> list[str]:
|
||||
"""Return names of enabled strategies in registration order.
|
||||
|
||||
The order matches the ``[context.strategies] enabled`` list,
|
||||
which controls priority during selection (spec §28682).
|
||||
"""
|
||||
with self._lock:
|
||||
return [
|
||||
n
|
||||
for n in self._enabled_order
|
||||
if n in self._entries and self._entries[n].config.enabled
|
||||
]
|
||||
|
||||
def list_builtin(self) -> list[str]:
|
||||
"""Return names of built-in strategies."""
|
||||
with self._lock:
|
||||
return sorted(n for n, e in self._entries.items() if e.is_builtin)
|
||||
|
||||
def is_registered(self, name: str) -> bool:
|
||||
"""Check whether a strategy is registered."""
|
||||
with self._lock:
|
||||
return name in self._strategies
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of registered strategies."""
|
||||
with self._lock:
|
||||
return len(self._strategies)
|
||||
|
||||
def __contains__(self, name: object) -> bool:
|
||||
"""Check whether a strategy name is registered."""
|
||||
with self._lock:
|
||||
return name in self._strategies
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_enabled(self, names: list[str]) -> None:
|
||||
"""Replace the enabled strategy list.
|
||||
|
||||
Supports per-project overrides: the ``--strategy`` CLI flag or
|
||||
project YAML replaces the global ``context.strategies.enabled``
|
||||
list (spec §3782).
|
||||
|
||||
Also updates each strategy's ``config.enabled`` flag to match:
|
||||
strategies in *names* are enabled, all others are disabled.
|
||||
|
||||
Args:
|
||||
names: Ordered list of strategy names to enable.
|
||||
|
||||
Raises:
|
||||
StrategyNotFoundError: If any name is not registered.
|
||||
"""
|
||||
with self._lock:
|
||||
for name in names:
|
||||
if name not in self._strategies:
|
||||
raise StrategyNotFoundError(
|
||||
f"Cannot enable unknown strategy '{name}'"
|
||||
)
|
||||
|
||||
# Update config.enabled for all strategies
|
||||
enabled_set = set(names)
|
||||
for sname, entry in self._entries.items():
|
||||
should_enable = sname in enabled_set
|
||||
if entry.config.enabled != should_enable:
|
||||
new_config = entry.config.model_copy(
|
||||
update={"enabled": should_enable},
|
||||
)
|
||||
self._entries[sname] = entry.model_copy(
|
||||
update={"config": new_config},
|
||||
)
|
||||
|
||||
self._enabled_order = list(dict.fromkeys(names))
|
||||
|
||||
def update_config(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
enabled: bool | None = None,
|
||||
timeout_seconds: int | None = None,
|
||||
max_fragments: int | None = None,
|
||||
max_workers: int | None = None,
|
||||
circuit_breaker_threshold: int | None = None,
|
||||
resource_types: tuple[str, ...] | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Update configuration for a registered strategy.
|
||||
|
||||
Only provided (non-``None``) fields are updated; the rest
|
||||
retain their current values.
|
||||
|
||||
Args:
|
||||
name: Strategy name.
|
||||
enabled: Whether the strategy is enabled.
|
||||
timeout_seconds: Assembly timeout in seconds (>=1).
|
||||
max_fragments: Max fragments per call (>=1).
|
||||
max_workers: Max parallel workers (>=1).
|
||||
circuit_breaker_threshold: Failures before circuit opens (>=1).
|
||||
resource_types: Resource types this strategy is limited to.
|
||||
extra: Strategy-specific extra configuration.
|
||||
|
||||
Raises:
|
||||
StrategyNotFoundError: If the name is not registered.
|
||||
pydantic.ValidationError: If the updated values violate
|
||||
``StrategyConfig`` constraints (e.g., ``timeout_seconds < 1``).
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self.get_entry(name)
|
||||
updates: dict[str, Any] = {}
|
||||
if enabled is not None:
|
||||
updates["enabled"] = enabled
|
||||
if timeout_seconds is not None:
|
||||
updates["timeout_seconds"] = timeout_seconds
|
||||
if max_fragments is not None:
|
||||
updates["max_fragments"] = max_fragments
|
||||
if max_workers is not None:
|
||||
updates["max_workers"] = max_workers
|
||||
if circuit_breaker_threshold is not None:
|
||||
updates["circuit_breaker_threshold"] = circuit_breaker_threshold
|
||||
if resource_types is not None:
|
||||
updates["resource_types"] = resource_types
|
||||
if extra is not None:
|
||||
updates["extra"] = MappingProxyType(extra)
|
||||
merged = entry.config.model_dump()
|
||||
merged.update(updates)
|
||||
new_config = StrategyConfig.model_validate(merged)
|
||||
new_entry = entry.model_copy(update={"config": new_config})
|
||||
self._entries[name] = new_entry
|
||||
|
||||
# Keep _enabled_order in sync with the enabled flag
|
||||
if enabled is True and name not in self._enabled_order:
|
||||
self._enabled_order.append(name)
|
||||
elif enabled is False and name in self._enabled_order:
|
||||
self._enabled_order = [n for n in self._enabled_order if n != name]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def validate_registry(self) -> list[str]:
|
||||
"""Validate the registry and return a list of warnings.
|
||||
|
||||
Checks:
|
||||
- Every enabled strategy is actually registered.
|
||||
- Every strategy declares at least one capability.
|
||||
- Every strategy declares supported resource types
|
||||
(per issue #191 subtask).
|
||||
|
||||
Returns:
|
||||
List of warning messages (empty = valid).
|
||||
"""
|
||||
with self._lock:
|
||||
warnings: list[str] = []
|
||||
|
||||
for name in self._enabled_order:
|
||||
if name not in self._strategies:
|
||||
warnings.append(f"Enabled strategy '{name}' is not registered")
|
||||
|
||||
for name, strategy in self._strategies.items():
|
||||
caps = strategy.capabilities
|
||||
|
||||
has_any_backend = (
|
||||
caps.uses_text
|
||||
or caps.uses_vector
|
||||
or caps.uses_graph
|
||||
or caps.uses_temporal
|
||||
)
|
||||
if not has_any_backend:
|
||||
warnings.append(
|
||||
f"Strategy '{name}' declares no backend capabilities"
|
||||
)
|
||||
|
||||
if not caps.resource_types:
|
||||
warnings.append(
|
||||
f"Strategy '{name}' does not declare supported "
|
||||
f"resource types (capabilities.resource_types is empty)"
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Removal (for testing / reconfiguration)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Remove a strategy from the registry.
|
||||
|
||||
Args:
|
||||
name: Strategy name.
|
||||
|
||||
Raises:
|
||||
StrategyNotFoundError: If the name is not registered.
|
||||
"""
|
||||
with self._lock:
|
||||
if name not in self._strategies:
|
||||
raise StrategyNotFoundError(
|
||||
f"Cannot unregister unknown strategy '{name}'"
|
||||
)
|
||||
|
||||
del self._strategies[name]
|
||||
del self._entries[name]
|
||||
self._enabled_order = [n for n in self._enabled_order if n != name]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all registered strategies."""
|
||||
with self._lock:
|
||||
self._strategies.clear()
|
||||
self._entries.clear()
|
||||
self._enabled_order.clear()
|
||||
|
||||
def inject_stale_enabled_entry(self, name: str) -> None:
|
||||
"""Inject a stale name into the enabled list for testing.
|
||||
|
||||
This is a test helper that creates a deliberately inconsistent
|
||||
state where the enabled list references a name that is not
|
||||
registered. Used to verify that :meth:`validate_registry` and
|
||||
:meth:`list_enabled` handle stale entries correctly.
|
||||
|
||||
Args:
|
||||
name: A strategy name that is NOT currently registered.
|
||||
"""
|
||||
with self._lock:
|
||||
self._enabled_order.append(name)
|
||||
@@ -17,6 +17,23 @@ BAL types (from :mod:`~cleveragents.domain.models.acms.backends`):
|
||||
- ``VectorBackend`` / ``VectorResult`` -- Embedding similarity search
|
||||
- ``GraphBackend`` / ``GraphResult`` -- SPARQL queries and traversals
|
||||
|
||||
Strategy types (from :mod:`~cleveragents.domain.models.acms.strategy`):
|
||||
- ``ContextStrategy`` -- Pluggable context assembly strategy protocol
|
||||
- ``StrategyCapabilities`` -- Declares what a strategy can do
|
||||
- ``BackendSet`` -- Container for available data backends
|
||||
- ``PlanContext`` -- Lightweight plan hierarchy reference
|
||||
- ``StrategyConfig`` -- Per-strategy configuration
|
||||
- ``ContextStrategyResult`` -- Output of a strategy execution
|
||||
- ``StrategyRegistryEntry`` -- Metadata for a registered strategy
|
||||
|
||||
Strategy stubs (from :mod:`~cleveragents.domain.models.acms.strategy_stubs`):
|
||||
- ``SimpleKeywordStrategy`` -- Basic keyword/regex text search (quality 0.3)
|
||||
- ``SemanticEmbeddingStrategy`` -- Vector similarity search (quality 0.6)
|
||||
- ``BreadthDepthNavigatorStrategy`` -- Graph-aware UKO traversal (quality 0.85)
|
||||
- ``ARCEStrategy`` -- Multi-modal ARCE pipeline (quality 0.95)
|
||||
- ``TemporalArchaeologyStrategy`` -- Historical pattern discovery (quality 0.5)
|
||||
- ``PlanDecisionContextStrategy`` -- Parent/ancestor plan context (quality 0.7)
|
||||
|
||||
Stub backends (from :mod:`~cleveragents.domain.models.acms.stubs`):
|
||||
- ``InMemoryTextBackend`` -- Zero-dependency text search stub
|
||||
- ``InMemoryVectorBackend`` -- Zero-dependency vector search stub
|
||||
@@ -68,6 +85,23 @@ from cleveragents.domain.models.acms.crp import (
|
||||
)
|
||||
from cleveragents.domain.models.acms.markdown_analyzer import MarkdownAnalyzer
|
||||
from cleveragents.domain.models.acms.python_analyzer import PythonAnalyzer
|
||||
from cleveragents.domain.models.acms.strategy import (
|
||||
BackendSet,
|
||||
ContextStrategy,
|
||||
ContextStrategyResult,
|
||||
PlanContext,
|
||||
StrategyCapabilities,
|
||||
StrategyConfig,
|
||||
StrategyRegistryEntry,
|
||||
)
|
||||
from cleveragents.domain.models.acms.strategy_stubs import (
|
||||
ARCEStrategy,
|
||||
BreadthDepthNavigatorStrategy,
|
||||
PlanDecisionContextStrategy,
|
||||
SemanticEmbeddingStrategy,
|
||||
SimpleKeywordStrategy,
|
||||
TemporalArchaeologyStrategy,
|
||||
)
|
||||
from cleveragents.domain.models.acms.stubs import (
|
||||
InMemoryGraphBackend,
|
||||
InMemoryTextBackend,
|
||||
@@ -84,14 +118,19 @@ from cleveragents.domain.models.acms.tiers import (
|
||||
)
|
||||
|
||||
__all__: list[str] = [
|
||||
"ARCEStrategy",
|
||||
"ActorContextView",
|
||||
"ActorRole",
|
||||
"AnalyzerProtocol",
|
||||
"AnalyzerRegistry",
|
||||
"AssembledContext",
|
||||
"BackendSet",
|
||||
"BreadthDepthNavigatorStrategy",
|
||||
"ContextBudget",
|
||||
"ContextFragment",
|
||||
"ContextRequest",
|
||||
"ContextStrategy",
|
||||
"ContextStrategyResult",
|
||||
"ContextTier",
|
||||
"DetailLevelMap",
|
||||
"FragmentProvenance",
|
||||
@@ -101,8 +140,16 @@ __all__: list[str] = [
|
||||
"InMemoryTextBackend",
|
||||
"InMemoryVectorBackend",
|
||||
"MarkdownAnalyzer",
|
||||
"PlanContext",
|
||||
"PlanDecisionContextStrategy",
|
||||
"PythonAnalyzer",
|
||||
"ScopedBackendView",
|
||||
"SemanticEmbeddingStrategy",
|
||||
"SimpleKeywordStrategy",
|
||||
"StrategyCapabilities",
|
||||
"StrategyConfig",
|
||||
"StrategyRegistryEntry",
|
||||
"TemporalArchaeologyStrategy",
|
||||
"TextBackend",
|
||||
"TextResult",
|
||||
"TierBudget",
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
"""Context strategy domain models for the ACMS.
|
||||
|
||||
Defines the ``ContextStrategy`` protocol, supporting value objects, and
|
||||
the ``ContextStrategyResult`` output model used by strategy stubs and
|
||||
the Context Assembly Pipeline.
|
||||
|
||||
Based on ``docs/specification.md`` §§ 25162-25233 (Context Strategy
|
||||
Protocol, StrategyCapabilities, Built-in Strategies) and
|
||||
§§ 28682-28708 (strategy configuration keys).
|
||||
|
||||
All frozen Pydantic models follow ADR-004 immutability rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from cleveragents.domain.models.acms.backends import (
|
||||
GraphBackend,
|
||||
TextBackend,
|
||||
VectorBackend,
|
||||
)
|
||||
from cleveragents.domain.models.acms.crp import (
|
||||
ContextFragment,
|
||||
ContextRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BackendSet",
|
||||
"ContextStrategy",
|
||||
"ContextStrategyResult",
|
||||
"PlanContext",
|
||||
|
CoreRasurae
commented
[DESIGN - MEDIUM] **[DESIGN - MEDIUM]** `BackendSet` only models `text`, `vector`, and `graph`. There is no field for temporal/cold-tier backends. Two of six strategies (`temporal-archaeology`, `plan-decision-context`) declare `uses_temporal=True` in capabilities but have no way to verify temporal backend availability via this model. This is the root cause of the `can_handle` bugs in those two strategies. Consider adding a `temporal` or `cold` backend field.
|
||||
"StrategyCapabilities",
|
||||
"StrategyConfig",
|
||||
"StrategyRegistryEntry",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BackendSet — container for available data backends
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BackendSet(BaseModel, frozen=True):
|
||||
"""Container holding the set of available data backends.
|
||||
|
||||
Strategies inspect which backends are present (not ``None``) to
|
||||
determine their ``can_handle`` confidence.
|
||||
|
||||
Based on spec §42851 — ``ContextAssemblyPipeline`` passes a
|
||||
``BackendSet`` to strategies during assembly.
|
||||
"""
|
||||
|
||||
text: TextBackend | None = Field(
|
||||
default=None,
|
||||
description="Full-text search backend (e.g., Tantivy, SQLite FTS)",
|
||||
)
|
||||
vector: VectorBackend | None = Field(
|
||||
default=None,
|
||||
description="Vector similarity search backend (e.g., FAISS, Qdrant)",
|
||||
)
|
||||
graph: GraphBackend | None = Field(
|
||||
default=None,
|
||||
description="Knowledge graph backend (e.g., Blazegraph, Neo4j)",
|
||||
)
|
||||
temporal: object | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Temporal/cold-tier backend for historical pattern discovery. "
|
||||
"Typed as ``object`` until a formal TemporalBackend protocol "
|
||||
"is defined; presence (not ``None``) indicates availability."
|
||||
),
|
||||
)
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PlanContext — lightweight plan hierarchy reference
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PlanContext(BaseModel, frozen=True):
|
||||
"""Lightweight reference to the current plan's hierarchy.
|
||||
|
||||
Passed to ``ContextStrategy.assemble()`` so strategies like
|
||||
``plan-decision-context`` can traverse parent/ancestor decisions.
|
||||
|
||||
Based on spec §25182-25186 — ``assemble()`` receives
|
||||
``plan_context: PlanContext``.
|
||||
"""
|
||||
|
||||
plan_id: str = Field(
|
||||
default="",
|
||||
description="ULID of the current plan",
|
||||
)
|
||||
parent_plan_id: str = Field(
|
||||
default="",
|
||||
description="ULID of the parent plan (empty if root)",
|
||||
)
|
||||
ancestor_plan_ids: tuple[str, ...] = Field(
|
||||
default=(),
|
||||
description="Ordered ancestor plan ULIDs (root first)",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StrategyCapabilities — declares what a strategy can do
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StrategyCapabilities(BaseModel, frozen=True):
|
||||
"""Declares what a strategy is capable of.
|
||||
|
||||
Based on spec §25192-25205. Strategies expose this to the
|
||||
``StrategySelector`` so it can match strategies to requests.
|
||||
"""
|
||||
|
||||
uses_text: bool = Field(
|
||||
default=False,
|
||||
description="Requires a TextBackend",
|
||||
)
|
||||
uses_vector: bool = Field(
|
||||
default=False,
|
||||
description="Requires a VectorBackend",
|
||||
)
|
||||
uses_graph: bool = Field(
|
||||
default=False,
|
||||
description="Requires a GraphBackend",
|
||||
)
|
||||
uses_temporal: bool = Field(
|
||||
default=False,
|
||||
description="Requires temporal/cold-tier data",
|
||||
)
|
||||
uko_levels: tuple[str, ...] = Field(
|
||||
default=(),
|
||||
description="UKO levels this strategy operates on",
|
||||
)
|
||||
resource_types: tuple[str, ...] = Field(
|
||||
default=(),
|
||||
description="Resource types this strategy supports (empty = all)",
|
||||
)
|
||||
supports_depth_breadth: bool = Field(
|
||||
default=False,
|
||||
description="Supports depth/breadth projection",
|
||||
)
|
||||
supports_plan_hierarchy: bool = Field(
|
||||
default=False,
|
||||
description="Supports plan hierarchy traversal",
|
||||
)
|
||||
supports_temporal: bool = Field(
|
||||
default=False,
|
||||
description="Supports temporal archaeology queries",
|
||||
)
|
||||
quality_score: float = Field(
|
||||
default=0.5,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Intrinsic quality score (0.0-1.0)",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ContextStrategy — the pluggable strategy protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ContextStrategy(Protocol):
|
||||
"""A pluggable context assembly strategy.
|
||||
|
||||
Spec ref: ``specification.md:25166-25189``.
|
||||
|
||||
Strategies are registered via configuration (TOML ``[context.strategies]``
|
||||
section) and invoked by the ``StrategyExecutor`` pipeline component
|
||||
in parallel.
|
||||
|
||||
``can_handle`` returns 0.0-1.0 confidence that this strategy can
|
||||
usefully contribute to the given request. ``assemble`` executes the
|
||||
strategy and **must** respect the ``budget`` parameter.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Strategy's unique name (e.g., 'simple-keyword')."""
|
||||
...
|
||||
|
||||
@property
|
||||
def capabilities(self) -> StrategyCapabilities:
|
||||
"""Declares what backends and features this strategy uses."""
|
||||
...
|
||||
|
||||
def can_handle(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
) -> float:
|
||||
"""Return 0.0-1.0 confidence for this request.
|
||||
|
||||
Returns 0.0 if required backends are missing or the request
|
||||
is outside this strategy's scope.
|
||||
"""
|
||||
...
|
||||
|
||||
def assemble(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
budget: int,
|
||||
plan_context: PlanContext,
|
||||
) -> list[ContextFragment]:
|
||||
"""Execute the strategy. Must respect the budget."""
|
||||
...
|
||||
|
||||
def explain(self) -> str:
|
||||
"""Return a human-readable explanation of this strategy."""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StrategyConfig — per-strategy configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StrategyConfig(BaseModel, frozen=True):
|
||||
"""Per-strategy configuration.
|
||||
|
||||
Controls whether a strategy is enabled, its timeout, and fragment
|
||||
limits. Based on spec §28706-28708 (pipeline executor config) and
|
||||
issue #191 acceptance criteria.
|
||||
"""
|
||||
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Whether this strategy is enabled",
|
||||
)
|
||||
timeout_seconds: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
description="Timeout for this strategy's assembly call (spec §28706)",
|
||||
)
|
||||
max_fragments: int = Field(
|
||||
|
CoreRasurae
commented
[SECURITY - LOW / ADR-004] **[SECURITY - LOW / ADR-004]** `extra: dict[str, Any]` on a `frozen=True` model can still be mutated via dict operations (`config.extra["key"] = "value"` succeeds). Pydantic's `frozen` only prevents attribute reassignment, not deep mutation. Same issue exists for `ContextStrategyResult.stats` at line 293. If ADR-004 immutability is a hard requirement, consider `types.MappingProxyType` or a frozen representation.
|
||||
default=100,
|
||||
ge=1,
|
||||
description="Maximum fragments this strategy may return per call",
|
||||
)
|
||||
max_workers: int = Field(
|
||||
default=4,
|
||||
ge=1,
|
||||
description="Max parallel workers (spec §28707)",
|
||||
)
|
||||
circuit_breaker_threshold: int = Field(
|
||||
default=3,
|
||||
ge=1,
|
||||
description="Consecutive failures before circuit opens (spec §28708)",
|
||||
)
|
||||
resource_types: tuple[str, ...] = Field(
|
||||
default=(),
|
||||
description="Resource types this strategy is limited to (empty = all)",
|
||||
)
|
||||
extra: MappingProxyType[str, Any] = Field(
|
||||
default_factory=lambda: MappingProxyType({}),
|
||||
description="Strategy-specific extra configuration (immutable)",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
@field_validator("extra", mode="before")
|
||||
@classmethod
|
||||
def _coerce_extra(
|
||||
cls: type[StrategyConfig],
|
||||
v: Any,
|
||||
) -> MappingProxyType[str, Any]:
|
||||
"""Wrap plain dicts in MappingProxyType for immutability (ADR-004)."""
|
||||
if isinstance(v, MappingProxyType):
|
||||
return v
|
||||
if isinstance(v, dict):
|
||||
return MappingProxyType(v)
|
||||
msg = f"expected dict or MappingProxyType, got {type(v).__name__}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ContextStrategyResult — output of a strategy execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ContextStrategyResult(BaseModel, frozen=True):
|
||||
"""Result of a single strategy's execution.
|
||||
|
||||
Provides fragments, execution statistics, and any errors
|
||||
encountered. Fragments are stored in deterministic order:
|
||||
``(relevance_score DESC, uko_node ASC)``.
|
||||
|
||||
Required by issue #191 acceptance criteria.
|
||||
"""
|
||||
|
||||
strategy_name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Name of the strategy that produced this result",
|
||||
)
|
||||
fragments: tuple[ContextFragment, ...] = Field(
|
||||
default=(),
|
||||
description="Fragments in deterministic order",
|
||||
)
|
||||
total_fragments: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Total fragments produced (before any limit)",
|
||||
)
|
||||
tokens_used: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Total tokens across all fragments",
|
||||
)
|
||||
execution_time_ms: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description="Wall-clock execution time in milliseconds",
|
||||
)
|
||||
errors: tuple[str, ...] = Field(
|
||||
default=(),
|
||||
description="Error messages encountered during execution",
|
||||
)
|
||||
stats: MappingProxyType[str, Any] = Field(
|
||||
default_factory=lambda: MappingProxyType({}),
|
||||
description="Strategy-specific statistics (immutable)",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
@field_validator("fragments")
|
||||
@classmethod
|
||||
def _sort_fragments(
|
||||
cls: type[ContextStrategyResult],
|
||||
v: tuple[ContextFragment, ...],
|
||||
) -> tuple[ContextFragment, ...]:
|
||||
"""Ensure deterministic ordering: relevance DESC, uko_node ASC."""
|
||||
return tuple(
|
||||
sorted(v, key=lambda f: (-f.relevance_score, f.uko_node)),
|
||||
)
|
||||
|
||||
@field_validator("stats", mode="before")
|
||||
@classmethod
|
||||
def _coerce_stats(
|
||||
cls: type[ContextStrategyResult],
|
||||
v: Any,
|
||||
) -> MappingProxyType[str, Any]:
|
||||
"""Wrap plain dicts in MappingProxyType for immutability (ADR-004)."""
|
||||
if isinstance(v, MappingProxyType):
|
||||
return v
|
||||
if isinstance(v, dict):
|
||||
return MappingProxyType(v)
|
||||
msg = f"expected dict or MappingProxyType, got {type(v).__name__}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StrategyRegistryEntry — metadata wrapper for registered strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StrategyRegistryEntry(BaseModel, frozen=True):
|
||||
"""Metadata wrapper for a strategy in the registry.
|
||||
|
||||
Combines a strategy name with its resolved configuration.
|
||||
The actual ``ContextStrategy`` instance is not stored here
|
||||
(it's a protocol, not a Pydantic model); the registry holds
|
||||
both this entry and the strategy instance separately.
|
||||
"""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Unique strategy name",
|
||||
)
|
||||
config: StrategyConfig = Field(
|
||||
default_factory=StrategyConfig,
|
||||
description="Per-strategy configuration",
|
||||
)
|
||||
module_path: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Python module path for custom strategies "
|
||||
"(e.g., 'my_package.strategies:MyStrategy')"
|
||||
),
|
||||
)
|
||||
is_builtin: bool = Field(
|
||||
default=False,
|
||||
description="Whether this is a built-in strategy",
|
||||
)
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Built-in stub context strategies for the ACMS.
|
||||
|
||||
Each strategy implements the ``ContextStrategy`` protocol with no-op
|
||||
defaults: ``can_handle`` returns the quality score when required backends
|
||||
are present, and ``assemble`` returns an empty fragment list.
|
||||
|
||||
These stubs are scaffolding for future real implementations.
|
||||
|
||||
Based on ``docs/specification.md`` §25207-25216 (Built-in Strategies
|
||||
table) and §43167-43199 (Built-in Strategy Catalogue).
|
||||
|
||||
Six built-in strategies per spec:
|
||||
|
||||
| Name | Quality | Backends Required |
|
||||
|----------------------------|---------|---------------------------|
|
||||
| ``simple-keyword`` | 0.3 | Text only |
|
||||
| ``semantic-embedding`` | 0.6 | Vector |
|
||||
| ``breadth-depth-navigator``| 0.85 | Graph |
|
||||
| ``arce`` | 0.95 | All |
|
||||
| ``temporal-archaeology`` | 0.5 | Graph + temporal/cold |
|
||||
| ``plan-decision-context`` | 0.7 | Warm/cold tiers |
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
|
||||
from cleveragents.domain.models.acms.crp import (
|
||||
ContextFragment,
|
||||
ContextRequest,
|
||||
)
|
||||
from cleveragents.domain.models.acms.strategy import (
|
||||
BackendSet,
|
||||
PlanContext,
|
||||
StrategyCapabilities,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BUILTIN_STRATEGY_CLASSES",
|
||||
"DEFAULT_ENABLED_STRATEGIES",
|
||||
"ARCEStrategy",
|
||||
"BreadthDepthNavigatorStrategy",
|
||||
"PlanDecisionContextStrategy",
|
||||
"SemanticEmbeddingStrategy",
|
||||
"SimpleKeywordStrategy",
|
||||
"TemporalArchaeologyStrategy",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# simple-keyword (spec §25211, §43171-43173)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SimpleKeywordStrategy:
|
||||
"""Basic keyword/regex text search. Universal fallback.
|
||||
|
CoreRasurae
commented
[PERF - MEDIUM] This constructs a new Fix: Use This applies to all 6 stub strategy classes. **[PERF - MEDIUM]** This constructs a new `StrategyCapabilities` Pydantic model on every property access. Since `can_handle()` reads `self.capabilities.quality_score`, each `can_handle` call allocates and validates a Pydantic instance. With 6 strategies and a spec target of < 20ms total for all `can_handle` checks (§43253), this is wasteful.
**Fix:** Use `functools.cached_property` to compute once and cache:
```python
@functools.cached_property
def capabilities(self) -> StrategyCapabilities:
return StrategyCapabilities(...)
```
This applies to all 6 stub strategy classes.
|
||||
|
||||
Works with any backend, any resource type. No graph or vector
|
||||
required. Quality score: 0.3.
|
||||
|
||||
Spec: ``specification.md:25211``, ``specification.md:43171-43173``.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "simple-keyword"
|
||||
|
||||
@functools.cached_property
|
||||
def capabilities(self) -> StrategyCapabilities:
|
||||
return StrategyCapabilities(
|
||||
uses_text=True,
|
||||
uses_vector=False,
|
||||
uses_graph=False,
|
||||
uses_temporal=False,
|
||||
resource_types=("*",),
|
||||
quality_score=0.3,
|
||||
)
|
||||
|
||||
def can_handle(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
) -> float:
|
||||
"""Returns quality score if text backend is available."""
|
||||
if backends.text is not None:
|
||||
return self.capabilities.quality_score
|
||||
return 0.0
|
||||
|
||||
def assemble(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
budget: int,
|
||||
plan_context: PlanContext,
|
||||
) -> list[ContextFragment]:
|
||||
"""No-op stub — returns empty list."""
|
||||
return []
|
||||
|
||||
def explain(self) -> str:
|
||||
return (
|
||||
"Basic keyword/regex text search. Universal fallback. "
|
||||
"Works with any text backend."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# semantic-embedding (spec §25212, §43175-43177)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SemanticEmbeddingStrategy:
|
||||
"""Vector similarity search for semantically related content.
|
||||
|
||||
Requires a vector backend. Quality score: 0.6.
|
||||
|
||||
Spec: ``specification.md:25212``, ``specification.md:43175-43177``.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "semantic-embedding"
|
||||
|
||||
@functools.cached_property
|
||||
def capabilities(self) -> StrategyCapabilities:
|
||||
return StrategyCapabilities(
|
||||
uses_text=False,
|
||||
uses_vector=True,
|
||||
uses_graph=False,
|
||||
uses_temporal=False,
|
||||
resource_types=("*",),
|
||||
quality_score=0.6,
|
||||
)
|
||||
|
||||
def can_handle(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
) -> float:
|
||||
if backends.vector is not None:
|
||||
return self.capabilities.quality_score
|
||||
return 0.0
|
||||
|
||||
def assemble(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
budget: int,
|
||||
plan_context: PlanContext,
|
||||
) -> list[ContextFragment]:
|
||||
return []
|
||||
|
||||
def explain(self) -> str:
|
||||
return (
|
||||
"Vector similarity search. Finds semantically related "
|
||||
"content even without exact keyword matches. "
|
||||
"Requires a vector backend."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# breadth-depth-navigator (spec §25213, §43179-43181)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BreadthDepthNavigatorStrategy:
|
||||
"""Graph-aware UKO traversal with depth/breadth projection.
|
||||
|
||||
Primary strategy for code-aware context. Requires a graph backend.
|
||||
Quality score: 0.85.
|
||||
|
||||
Spec: ``specification.md:25213``, ``specification.md:43179-43181``.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "breadth-depth-navigator"
|
||||
|
||||
@functools.cached_property
|
||||
def capabilities(self) -> StrategyCapabilities:
|
||||
return StrategyCapabilities(
|
||||
uses_text=False,
|
||||
uses_vector=False,
|
||||
uses_graph=True,
|
||||
uses_temporal=False,
|
||||
resource_types=("*",),
|
||||
supports_depth_breadth=True,
|
||||
quality_score=0.85,
|
||||
)
|
||||
|
||||
def can_handle(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
) -> float:
|
||||
if backends.graph is not None:
|
||||
return self.capabilities.quality_score
|
||||
return 0.0
|
||||
|
||||
def assemble(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
budget: int,
|
||||
plan_context: PlanContext,
|
||||
) -> list[ContextFragment]:
|
||||
return []
|
||||
|
||||
def explain(self) -> str:
|
||||
return (
|
||||
"Graph-aware UKO traversal with depth/breadth projection. "
|
||||
"Primary strategy for code-aware context. "
|
||||
"Supports focus items, hop traversal, and detail depth gradients."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# arce (spec §25214, §43183-43191)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ARCEStrategy:
|
||||
"""Multi-modal pipeline combining text, vector, and graph.
|
||||
|
||||
Autonomous Reasoning Context Extraction. Requires all backends.
|
||||
Highest quality. Quality score: 0.95.
|
||||
|
||||
Spec: ``specification.md:25214``, ``specification.md:43183-43191``.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "arce"
|
||||
|
||||
@functools.cached_property
|
||||
def capabilities(self) -> StrategyCapabilities:
|
||||
return StrategyCapabilities(
|
||||
uses_text=True,
|
||||
uses_vector=True,
|
||||
uses_graph=True,
|
||||
uses_temporal=False,
|
||||
resource_types=("*",),
|
||||
supports_depth_breadth=True,
|
||||
quality_score=0.95,
|
||||
)
|
||||
|
||||
def can_handle(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
) -> float:
|
||||
if (
|
||||
backends.text is not None
|
||||
and backends.vector is not None
|
||||
and backends.graph is not None
|
||||
):
|
||||
return self.capabilities.quality_score
|
||||
return 0.0
|
||||
|
||||
def assemble(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
budget: int,
|
||||
plan_context: PlanContext,
|
||||
) -> list[ContextFragment]:
|
||||
return []
|
||||
|
||||
def explain(self) -> str:
|
||||
return (
|
||||
"Multi-modal pipeline combining text, vector, and graph "
|
||||
"with intent analysis. Autonomous Reasoning Context "
|
||||
"Extraction (ARCE). Highest quality."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# temporal-archaeology (spec §25215, §43193-43195)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TemporalArchaeologyStrategy:
|
||||
"""Historical pattern discovery from past decisions and archived context.
|
||||
|
||||
Requires graph backend and cold-tier access. Quality score: 0.5.
|
||||
|
||||
Spec: ``specification.md:25215``, ``specification.md:43193-43195``.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "temporal-archaeology"
|
||||
|
||||
@functools.cached_property
|
||||
def capabilities(self) -> StrategyCapabilities:
|
||||
return StrategyCapabilities(
|
||||
uses_text=False,
|
||||
uses_vector=False,
|
||||
|
CoreRasurae
commented
[BUG - MEDIUM] Spec §25215 / §43193-43195 says **[BUG - MEDIUM]** Spec §25215 / §43193-43195 says `temporal-archaeology` requires **Graph + Cold tier**. Capabilities correctly declare `uses_temporal=True`, but `can_handle` only checks `backends.graph`. The root cause is that `BackendSet` has no temporal/cold field — but this means the strategy reports 0.5 confidence even when cold-tier data is unavailable, which could lead to it being selected and then failing at assembly time.
|
||||
uses_graph=True,
|
||||
uses_temporal=True,
|
||||
resource_types=("*",),
|
||||
supports_temporal=True,
|
||||
quality_score=0.5,
|
||||
)
|
||||
|
||||
def can_handle(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
) -> float:
|
||||
# Requires both graph AND temporal/cold-tier (spec §43193-43195).
|
||||
if backends.graph is not None and backends.temporal is not None:
|
||||
return self.capabilities.quality_score
|
||||
return 0.0
|
||||
|
||||
def assemble(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
budget: int,
|
||||
plan_context: PlanContext,
|
||||
) -> list[ContextFragment]:
|
||||
return []
|
||||
|
||||
def explain(self) -> str:
|
||||
return (
|
||||
"Historical pattern discovery from past decisions and "
|
||||
"archived context. Searches the cold tier for temporal "
|
||||
"patterns."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# plan-decision-context (spec §25216, §43197-43199)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PlanDecisionContextStrategy:
|
||||
"""Retrieves context from parent/ancestor plan decisions.
|
||||
|
||||
How child plans 'remember' what their parent decided and why.
|
||||
Operates on warm/cold tiers. Quality score: 0.7.
|
||||
|
||||
Spec: ``specification.md:25216``, ``specification.md:43197-43199``.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "plan-decision-context"
|
||||
|
||||
@functools.cached_property
|
||||
def capabilities(self) -> StrategyCapabilities:
|
||||
return StrategyCapabilities(
|
||||
uses_text=False,
|
||||
uses_vector=False,
|
||||
|
CoreRasurae
commented
[BUG - MEDIUM] This unconditionally returns 0.7 regardless of backend/tier availability. Spec §43197-43199 says this strategy "operates on warm/cold tiers", yet it never checks whether those tiers are available. Every other strategy degrades to 0.0 when its required backends are missing; this one does not. This could cause the strategy to be selected when it has no data source to read from. **[BUG - MEDIUM]** This unconditionally returns 0.7 regardless of backend/tier availability. Spec §43197-43199 says this strategy "operates on warm/cold tiers", yet it never checks whether those tiers are available. Every other strategy degrades to 0.0 when its required backends are missing; this one does not. This could cause the strategy to be selected when it has no data source to read from.
|
||||
uses_graph=False,
|
||||
uses_temporal=True,
|
||||
resource_types=("*",),
|
||||
supports_plan_hierarchy=True,
|
||||
quality_score=0.7,
|
||||
)
|
||||
|
||||
def can_handle(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
) -> float:
|
||||
# Requires temporal/cold-tier backend (spec §43197-43199).
|
||||
if backends.temporal is not None:
|
||||
return self.capabilities.quality_score
|
||||
return 0.0
|
||||
|
||||
def assemble(
|
||||
self,
|
||||
request: ContextRequest,
|
||||
backends: BackendSet,
|
||||
budget: int,
|
||||
plan_context: PlanContext,
|
||||
) -> list[ContextFragment]:
|
||||
return []
|
||||
|
||||
def explain(self) -> str:
|
||||
return (
|
||||
"Retrieves context from parent and ancestor plan "
|
||||
"decisions. This is how child plans 'remember' what "
|
||||
"their parent decided and why."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry helper — register all built-in strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Canonical ordered list per spec §28682 default:
|
||||
# ["simple-keyword", "semantic-embedding", "breadth-depth-navigator"]
|
||||
# Plus the remaining 3 registered but not enabled by default.
|
||||
#
|
||||
# NOTE: Spec §25223 lists 4 strategies (including "arce") in the default
|
||||
# enabled config, but §28682 lists only 3 (without "arce"). This is a
|
||||
# spec internal contradiction. We follow §28682 (the config key table)
|
||||
# because it is the more specific/operational reference. This decision
|
||||
# should be confirmed with the spec author.
|
||||
|
||||
BUILTIN_STRATEGY_CLASSES: tuple[type, ...] = (
|
||||
SimpleKeywordStrategy,
|
||||
SemanticEmbeddingStrategy,
|
||||
BreadthDepthNavigatorStrategy,
|
||||
ARCEStrategy,
|
||||
TemporalArchaeologyStrategy,
|
||||
PlanDecisionContextStrategy,
|
||||
)
|
||||
|
||||
# Spec §28682 default enabled list
|
||||
DEFAULT_ENABLED_STRATEGIES: tuple[str, ...] = (
|
||||
"simple-keyword",
|
||||
"semantic-embedding",
|
||||
"breadth-depth-navigator",
|
||||
)
|
||||
@@ -620,6 +620,10 @@ source_project # noqa: B018, F821
|
||||
target_project # noqa: B018, F821
|
||||
dependency_type # noqa: B018, F821
|
||||
|
||||
# Context Strategy Registry — protocol parameter required by ContextStrategy.assemble()
|
||||
plan_context # noqa: B018, F821
|
||||
inject_stale_enabled_entry # noqa: B018, F821
|
||||
|
||||
# ACMS Backend Abstraction Layer — public API (issue #498)
|
||||
TextBackend # noqa: B018, F821
|
||||
VectorBackend # noqa: B018, F821
|
||||
|
||||
[THREAD SAFETY - MEDIUM] These three plain
dict/listfields have no synchronization. Other services in the same package (e.g.,autonomy_guardrail_service.py,semantic_validation_service.py) usethreading.Lockorthreading.RLock. Since the spec describes parallel strategy execution (§42636), this registry will likely be a shared singleton accessed from multiple threads. Consider adding athreading.RLockto protect mutation methods (register,unregister,set_enabled,update_config,clear).