Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d642e063f | |||
| 292d77b3e8 | |||
| b6d4f9a732 | |||
| 8dacddcd42 | |||
| 6530104dd0 | |||
| 6f1f9b2b43 |
+10
-1
@@ -608,7 +608,7 @@ uko-oo:Class` triple emission in `PythonAnalyzer._extract_class()` so that
|
||||
covering walk-based indexing of 10,000+ files without timeout, binary-file
|
||||
skipping, oversized-file skipping, git-checkout indexing, fallback to walk when
|
||||
`git ls-files` is unavailable on a non-git directory, and total-bytes budget
|
||||
enforcement. Optimised fixture setup to pre-create subdirectories (99x fewer
|
||||
enforcement. Optimised fixture setup to pre-create subdirectories (5x fewer
|
||||
syscalls). Added `timeout=120` to git subprocess calls to prevent CI hangs.
|
||||
Cached `get_scoped_view` results in `When` steps to avoid redundant re-queries
|
||||
in `Then` steps.
|
||||
@@ -623,6 +623,15 @@ uko-oo:Class` triple emission in `PythonAnalyzer._extract_class()` so that
|
||||
suite (7 scenarios) covering label lookup, milestone lookup, worker dispatch,
|
||||
PR creation with metadata, and error handling for missing labels/milestones.
|
||||
|
||||
- **ACMS Context CLI Commands** (`context show` / `context clear`) (#9586): Implemented
|
||||
two new CLI commands for the ACMS (Advanced Context Management System). `context show
|
||||
<view>` displays assembled context for a named view including per-tier budget utilization
|
||||
summary (hot/warm/cold tokens used vs. budget). `context clear` removes context index
|
||||
entries filtered by `--path`, `--tag`, or `--tier`; supports `--yes` flag to bypass
|
||||
interactive confirmation for non-interactive/CI use. Includes full `--help` documentation,
|
||||
input validation, proper error handling, Robot Framework integration tests, and ASV
|
||||
performance benchmarks.
|
||||
|
||||
- Wired `StrategyActor` into the real plan execution path: `_get_plan_executor`
|
||||
in `plan.py` now resolves the strategy actor via `resolve_strategy_actor()`
|
||||
(reading the `actor.default.strategy` config key) instead of always
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""ASV benchmarks for ACMS context CLI commands.
|
||||
|
||||
Measures the performance of:
|
||||
- _format_budget_utilization at various utilization levels
|
||||
- acms_context_show with varying fragment counts
|
||||
- acms_context_clear with varying fragment counts
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
_FEATURES = str(Path(__file__).resolve().parents[1])
|
||||
if _FEATURES not in sys.path:
|
||||
sys.path.insert(0, _FEATURES)
|
||||
|
||||
import cleveragents # noqa: E402
|
||||
|
||||
importlib.reload(cleveragents)
|
||||
|
||||
from features.mocks.acms_context_mocks import ( # noqa: E402
|
||||
make_mock_container,
|
||||
make_mock_tier_service,
|
||||
make_tiered_fragment,
|
||||
)
|
||||
|
||||
from cleveragents.cli.commands.acms_context import ( # noqa: E402
|
||||
_format_budget_utilization,
|
||||
acms_context_clear,
|
||||
acms_context_show,
|
||||
)
|
||||
from cleveragents.domain.models.acms.tiers import ContextTier # noqa: E402
|
||||
|
||||
|
||||
def _make_fragments(count: int) -> list[Any]:
|
||||
"""Create a list of sample TieredFragments for benchmarking."""
|
||||
tiers = [ContextTier.HOT, ContextTier.WARM, ContextTier.COLD]
|
||||
tags = ("default", "deprecated", "archived")
|
||||
return [
|
||||
make_tiered_fragment(
|
||||
fragment_id=f"bench-frag-{i}",
|
||||
resource_id=f"src/module{i}.py",
|
||||
tier=tiers[i % 3],
|
||||
token_count=1024 * (i + 1),
|
||||
project_name="bench_project",
|
||||
tag=tags[i % 3],
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
def _run_show_silent(view: str, fragments: list[Any]) -> None:
|
||||
"""Run acms_context_show with output suppressed."""
|
||||
mock_service = make_mock_tier_service(
|
||||
fragments=fragments,
|
||||
scoped_fragments=fragments,
|
||||
)
|
||||
mock_container = make_mock_container(mock_service)
|
||||
mock_console = MagicMock()
|
||||
mock_console.print.return_value = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context.get_container",
|
||||
return_value=mock_container,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.renderers._get_console",
|
||||
return_value=mock_console,
|
||||
),
|
||||
contextlib.suppress(SystemExit),
|
||||
):
|
||||
acms_context_show(view)
|
||||
|
||||
|
||||
def _run_clear_silent(
|
||||
fragments: list[Any],
|
||||
tier: str | None = None,
|
||||
) -> None:
|
||||
"""Run acms_context_clear with output suppressed."""
|
||||
mock_service = make_mock_tier_service(fragments=fragments)
|
||||
mock_container = make_mock_container(mock_service)
|
||||
mock_console = MagicMock()
|
||||
mock_console.print.return_value = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context.get_container",
|
||||
return_value=mock_container,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.renderers._get_console",
|
||||
return_value=mock_console,
|
||||
),
|
||||
contextlib.suppress(SystemExit),
|
||||
):
|
||||
acms_context_clear(tier=tier, yes=True)
|
||||
|
||||
|
||||
class FormatBudgetUtilizationSuite:
|
||||
"""Benchmark _format_budget_utilization throughput."""
|
||||
|
||||
def time_format_zero_total(self) -> None:
|
||||
"""Benchmark formatting with zero total."""
|
||||
_format_budget_utilization(0, 0)
|
||||
|
||||
def time_format_half_utilization(self) -> None:
|
||||
"""Benchmark formatting at 50% utilization."""
|
||||
_format_budget_utilization(500, 1000)
|
||||
|
||||
def time_format_full_utilization(self) -> None:
|
||||
"""Benchmark formatting at 100% utilization."""
|
||||
_format_budget_utilization(1000, 1000)
|
||||
|
||||
def time_format_negative_values(self) -> None:
|
||||
"""Benchmark formatting with negative values."""
|
||||
_format_budget_utilization(-1, 100)
|
||||
|
||||
|
||||
class ContextShowSuite:
|
||||
"""Benchmark acms_context_show with varying fragment counts."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up fragment lists for benchmarking."""
|
||||
self._frags_10 = _make_fragments(10)
|
||||
self._frags_100 = _make_fragments(100)
|
||||
self._frags_empty: list[Any] = []
|
||||
|
||||
def time_show_10_fragments(self) -> None:
|
||||
"""Benchmark context show with 10 fragments."""
|
||||
_run_show_silent("bench_project", self._frags_10)
|
||||
|
||||
def time_show_100_fragments(self) -> None:
|
||||
"""Benchmark context show with 100 fragments."""
|
||||
_run_show_silent("bench_project", self._frags_100)
|
||||
|
||||
def time_show_empty_context(self) -> None:
|
||||
"""Benchmark context show with empty context."""
|
||||
_run_show_silent("bench_project", self._frags_empty)
|
||||
|
||||
|
||||
class ContextClearSuite:
|
||||
"""Benchmark acms_context_clear with varying fragment counts."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up fragment lists for benchmarking."""
|
||||
self._frags_10 = _make_fragments(10)
|
||||
self._frags_100 = _make_fragments(100)
|
||||
self._frags_empty: list[Any] = []
|
||||
|
||||
def time_clear_10_fragments(self) -> None:
|
||||
"""Benchmark context clear with 10 fragments."""
|
||||
_run_clear_silent(self._frags_10)
|
||||
|
||||
def time_clear_100_fragments(self) -> None:
|
||||
"""Benchmark context clear with 100 fragments."""
|
||||
_run_clear_silent(self._frags_100)
|
||||
|
||||
def time_clear_no_fragments(self) -> None:
|
||||
"""Benchmark context clear with no matching fragments."""
|
||||
_run_clear_silent(self._frags_empty)
|
||||
|
||||
def time_clear_by_tier(self) -> None:
|
||||
"""Benchmark context clear filtered by tier."""
|
||||
_run_clear_silent(self._frags_10, tier="hot")
|
||||
@@ -0,0 +1,65 @@
|
||||
Feature: ACMS Context CLI Commands
|
||||
As a user
|
||||
I want to view and manage ACMS context
|
||||
So that I can inspect assembled context and remove stale entries
|
||||
|
||||
Background:
|
||||
Given the ACMS service is initialized
|
||||
And a test view "test_view" exists
|
||||
|
||||
Scenario: Show assembled context for a view
|
||||
When I run "agents acms context show test_view"
|
||||
Then the output should contain "Assembled Context for View: test_view"
|
||||
And the output should contain "Total Tokens"
|
||||
And the output should contain "Budget Utilization"
|
||||
|
||||
Scenario: Show context with no entries
|
||||
Given the view "empty_view" has no context entries
|
||||
When I run "agents acms context show empty_view"
|
||||
Then the output should contain "No context found for view: empty_view"
|
||||
|
||||
Scenario: Clear context entries with confirmation
|
||||
Given the view "test_view" has 5 context entries
|
||||
When I run "agents acms context clear" and confirm
|
||||
Then the output should contain "Removed 5 context entries"
|
||||
|
||||
Scenario: Clear context entries with --yes flag
|
||||
Given the view "test_view" has 5 context entries
|
||||
When I run "agents acms context clear --yes"
|
||||
Then the output should contain "Removed 5 context entries"
|
||||
And no confirmation prompt should be shown
|
||||
|
||||
Scenario: Clear context entries by path filter
|
||||
Given the view "test_view" has 5 context entries
|
||||
And 3 entries match the path pattern "src/*"
|
||||
When I run "agents acms context clear --path src/* --yes"
|
||||
Then the output should contain "context entries"
|
||||
|
||||
Scenario: Clear context entries by tag filter
|
||||
Given the view "test_view" has 5 context entries
|
||||
And 2 entries have the tag "deprecated"
|
||||
When I run "agents acms context clear --tag deprecated --yes"
|
||||
Then the output should contain "context entries"
|
||||
|
||||
Scenario: Clear context entries by tier filter
|
||||
Given the view "test_view" has 5 context entries
|
||||
And 2 entries are in tier "hot"
|
||||
When I run "agents acms context clear --tier hot --yes"
|
||||
Then the output should contain "context entries"
|
||||
|
||||
Scenario: Clear context with no matching entries
|
||||
Given the view "test_view" has 5 context entries
|
||||
When I run "agents acms context clear --path nonexistent/* --yes"
|
||||
Then the output should contain "No context entries match"
|
||||
|
||||
Scenario: Show help for context show command
|
||||
When I run "agents acms context show" with help flag
|
||||
Then the output should contain "Display the assembled context"
|
||||
And the output should contain "project view"
|
||||
|
||||
Scenario: Show help for context clear command
|
||||
When I run "agents acms context clear" with help flag
|
||||
Then the output should contain "Remove stale context entries"
|
||||
And the output should contain "path"
|
||||
And the output should contain "tag"
|
||||
And the output should contain "tier"
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Mock implementations for ACMS context CLI tests.
|
||||
|
||||
Provides mock ContextTierService and container for use in Behave step
|
||||
definitions. All test doubles must reside in features/mocks/ per
|
||||
CONTRIBUTING.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from cleveragents.domain.models.acms.tiers import (
|
||||
ContextTier,
|
||||
TierBudget,
|
||||
TieredFragment,
|
||||
TierMetrics,
|
||||
)
|
||||
|
||||
|
||||
def make_tiered_fragment(
|
||||
fragment_id: str,
|
||||
resource_id: str = "",
|
||||
tier: ContextTier = ContextTier.HOT,
|
||||
token_count: int = 100,
|
||||
project_name: str = "test_project",
|
||||
tag: str | None = None,
|
||||
) -> TieredFragment:
|
||||
"""Create a TieredFragment for testing.
|
||||
|
||||
Args:
|
||||
fragment_id: Unique identifier for the fragment.
|
||||
resource_id: Resource path (e.g. src/module.py).
|
||||
tier: Storage tier (hot/warm/cold).
|
||||
token_count: Token count of the fragment content.
|
||||
project_name: Project scope for isolation.
|
||||
tag: Optional tag value stored in metadata.
|
||||
|
||||
Returns:
|
||||
A TieredFragment configured for testing.
|
||||
"""
|
||||
metadata: dict[str, Any] = {}
|
||||
if tag is not None:
|
||||
metadata["tag"] = tag
|
||||
return TieredFragment(
|
||||
fragment_id=fragment_id,
|
||||
content=f"# Content for {resource_id}",
|
||||
tier=tier,
|
||||
resource_id=resource_id,
|
||||
project_name=project_name,
|
||||
token_count=token_count,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def make_mock_tier_service(
|
||||
fragments: list[TieredFragment] | None = None,
|
||||
scoped_fragments: list[TieredFragment] | None = None,
|
||||
removed_count: int = 0,
|
||||
) -> MagicMock:
|
||||
"""Create a mock ContextTierService with pre-configured return values.
|
||||
|
||||
Args:
|
||||
fragments: Return value for get_all_fragments().
|
||||
scoped_fragments: Return value for get_scoped_view().
|
||||
removed_count: Not used directly (removal is simulated).
|
||||
|
||||
Returns:
|
||||
A MagicMock configured to behave like a ContextTierService.
|
||||
"""
|
||||
mock_service = MagicMock()
|
||||
mock_service.get_all_fragments.return_value = fragments or []
|
||||
mock_service.get_scoped_view.return_value = scoped_fragments or []
|
||||
mock_service.get_metrics.return_value = TierMetrics(
|
||||
hot_count=len([f for f in (fragments or []) if f.tier == ContextTier.HOT]),
|
||||
warm_count=len([f for f in (fragments or []) if f.tier == ContextTier.WARM]),
|
||||
cold_count=len([f for f in (fragments or []) if f.tier == ContextTier.COLD]),
|
||||
)
|
||||
mock_service.budget = TierBudget()
|
||||
mock_service._lock = MagicMock()
|
||||
mock_service._lock.__enter__ = MagicMock(return_value=None)
|
||||
mock_service._lock.__exit__ = MagicMock(return_value=False)
|
||||
mock_service._remove_from_all = MagicMock()
|
||||
return mock_service
|
||||
|
||||
|
||||
def make_mock_container(mock_tier_service: MagicMock) -> MagicMock:
|
||||
"""Create a mock DI container that returns the given tier service.
|
||||
|
||||
Args:
|
||||
mock_tier_service: The mock service to return from context_tier_service().
|
||||
|
||||
Returns:
|
||||
A MagicMock configured to behave like the DI container.
|
||||
"""
|
||||
mock_container = MagicMock()
|
||||
mock_container.context_tier_service.return_value = mock_tier_service
|
||||
return mock_container
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Step definitions for ACMS context CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import io
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import typer
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.domain.models.acms.tiers import ContextTier, TieredFragment
|
||||
from features.mocks.acms_context_mocks import (
|
||||
make_mock_container,
|
||||
make_mock_tier_service,
|
||||
make_tiered_fragment,
|
||||
)
|
||||
|
||||
|
||||
@given("the ACMS service is initialized")
|
||||
def step_acms_service_initialized(context: Any) -> None:
|
||||
"""Initialize the ACMS service for testing."""
|
||||
context.tier_service = MagicMock()
|
||||
context.context_data = {}
|
||||
context.entries_to_remove = []
|
||||
|
||||
|
||||
@given('a test view "{view_name}" exists')
|
||||
def step_test_view_exists(context: Any, view_name: str) -> None:
|
||||
"""Create a test view with sample fragments."""
|
||||
context.test_view = view_name
|
||||
fragments = [
|
||||
make_tiered_fragment(
|
||||
fragment_id=f"frag-{view_name}-1",
|
||||
resource_id="src/module1.py",
|
||||
tier=ContextTier.HOT,
|
||||
token_count=1024,
|
||||
project_name=view_name,
|
||||
),
|
||||
make_tiered_fragment(
|
||||
fragment_id=f"frag-{view_name}-2",
|
||||
resource_id="src/module2.py",
|
||||
tier=ContextTier.WARM,
|
||||
token_count=2048,
|
||||
project_name=view_name,
|
||||
),
|
||||
]
|
||||
context.context_data[view_name] = fragments
|
||||
context.entries_to_remove = fragments
|
||||
|
||||
|
||||
@given('the view "{view_name}" has no context entries')
|
||||
def step_view_has_no_entries(context: Any, view_name: str) -> None:
|
||||
"""Set up a view with no context entries."""
|
||||
context.context_data[view_name] = []
|
||||
context.entries_to_remove = []
|
||||
|
||||
|
||||
@given('the view "{view_name}" has {count:d} context entries')
|
||||
def step_view_has_entries(context: Any, view_name: str, count: int) -> None:
|
||||
"""Set up a view with specified number of context entries."""
|
||||
tiers = [ContextTier.HOT, ContextTier.WARM, ContextTier.COLD]
|
||||
fragments = [
|
||||
make_tiered_fragment(
|
||||
fragment_id=f"frag-{view_name}-{i}",
|
||||
resource_id=f"src/module{i}.py",
|
||||
tier=tiers[i % 3],
|
||||
token_count=1024 * (i + 1),
|
||||
project_name=view_name,
|
||||
tag="deprecated" if i < 2 else "default",
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
context.context_data[view_name] = fragments
|
||||
context.entries_to_remove = fragments
|
||||
|
||||
|
||||
@given('{count:d} entries match the path pattern "{pattern}"')
|
||||
def step_entries_match_path(context: Any, count: int, pattern: str) -> None:
|
||||
"""Mark entries matching a path pattern."""
|
||||
context.path_filter = pattern
|
||||
context.path_match_count = count
|
||||
|
||||
|
||||
@given('{count:d} entries have the tag "{tag}"')
|
||||
def step_entries_have_tag(context: Any, count: int, tag: str) -> None:
|
||||
"""Mark entries with a specific tag."""
|
||||
context.tag_filter = tag
|
||||
context.tag_match_count = count
|
||||
|
||||
|
||||
@given('{count:d} entries are in tier "{tier}"')
|
||||
def step_entries_in_tier(context: Any, count: int, tier: str) -> None:
|
||||
"""Mark entries in a specific tier."""
|
||||
context.tier_filter = tier
|
||||
context.tier_match_count = count
|
||||
|
||||
|
||||
@when('I run "agents acms context show {view}"')
|
||||
def step_run_context_show(context: Any, view: str) -> None:
|
||||
"""Run the context show command and capture real output."""
|
||||
from cleveragents.cli.commands.acms_context import acms_context_show
|
||||
|
||||
scoped_fragments = context.context_data.get(view, [])
|
||||
mock_service = make_mock_tier_service(
|
||||
fragments=scoped_fragments,
|
||||
scoped_fragments=scoped_fragments,
|
||||
)
|
||||
mock_container = make_mock_container(mock_service)
|
||||
|
||||
output_buffer = io.StringIO()
|
||||
mock_console = MagicMock()
|
||||
captured_lines: list[str] = []
|
||||
|
||||
def capture_print(msg: str = "", **kwargs: Any) -> None:
|
||||
captured_lines.append(str(msg))
|
||||
output_buffer.write(str(msg) + "\n")
|
||||
|
||||
mock_console.print.side_effect = capture_print
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context.get_container",
|
||||
return_value=mock_container,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context._get_console",
|
||||
return_value=mock_console,
|
||||
),
|
||||
):
|
||||
try:
|
||||
acms_context_show(view)
|
||||
context.command_exit_code = 0
|
||||
except typer.Exit as exc:
|
||||
context.command_exit_code = (
|
||||
exc.exit_code if exc.exit_code is not None else 0
|
||||
)
|
||||
except SystemExit as exc:
|
||||
context.command_exit_code = exc.code if exc.code is not None else 0
|
||||
|
||||
context.command_output = output_buffer.getvalue()
|
||||
context.captured_lines = captured_lines
|
||||
|
||||
|
||||
@when('I run "agents acms context clear" and confirm')
|
||||
def step_run_context_clear_with_confirm(context: Any) -> None:
|
||||
"""Run the context clear command with confirmation."""
|
||||
from cleveragents.cli.commands.acms_context import acms_context_clear
|
||||
|
||||
entries = context.entries_to_remove
|
||||
mock_service = make_mock_tier_service(fragments=entries)
|
||||
mock_container = make_mock_container(mock_service)
|
||||
|
||||
output_buffer = io.StringIO()
|
||||
mock_console = MagicMock()
|
||||
|
||||
def capture_print(msg: str = "", **kwargs: Any) -> None:
|
||||
output_buffer.write(str(msg) + "\n")
|
||||
|
||||
mock_console.print.side_effect = capture_print
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context.get_container",
|
||||
return_value=mock_container,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context._get_console",
|
||||
return_value=mock_console,
|
||||
),
|
||||
patch("typer.confirm", return_value=True),
|
||||
):
|
||||
try:
|
||||
acms_context_clear()
|
||||
context.command_exit_code = 0
|
||||
except typer.Exit as exc:
|
||||
context.command_exit_code = (
|
||||
exc.exit_code if exc.exit_code is not None else 0
|
||||
)
|
||||
except SystemExit as exc:
|
||||
context.command_exit_code = exc.code if exc.code is not None else 0
|
||||
|
||||
context.command_output = output_buffer.getvalue()
|
||||
context.mock_confirm = mock_console
|
||||
|
||||
|
||||
@when('I run "agents acms context clear --yes"')
|
||||
def step_run_context_clear_yes(context: Any) -> None:
|
||||
"""Run the context clear command with --yes flag."""
|
||||
from cleveragents.cli.commands.acms_context import acms_context_clear
|
||||
|
||||
entries = context.entries_to_remove
|
||||
mock_service = make_mock_tier_service(fragments=entries)
|
||||
mock_container = make_mock_container(mock_service)
|
||||
|
||||
output_buffer = io.StringIO()
|
||||
mock_console = MagicMock()
|
||||
context.mock_typer_confirm = MagicMock()
|
||||
|
||||
def capture_print(msg: str = "", **kwargs: Any) -> None:
|
||||
output_buffer.write(str(msg) + "\n")
|
||||
|
||||
mock_console.print.side_effect = capture_print
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context.get_container",
|
||||
return_value=mock_container,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context._get_console",
|
||||
return_value=mock_console,
|
||||
),
|
||||
patch("typer.confirm", context.mock_typer_confirm),
|
||||
):
|
||||
try:
|
||||
acms_context_clear(yes=True)
|
||||
context.command_exit_code = 0
|
||||
except typer.Exit as exc:
|
||||
context.command_exit_code = (
|
||||
exc.exit_code if exc.exit_code is not None else 0
|
||||
)
|
||||
except SystemExit as exc:
|
||||
context.command_exit_code = exc.code if exc.code is not None else 0
|
||||
|
||||
context.command_output = output_buffer.getvalue()
|
||||
|
||||
|
||||
@when('I run "agents acms context clear --path {pattern} --yes"')
|
||||
def step_run_context_clear_path(context: Any, pattern: str) -> None:
|
||||
"""Run the context clear command with path filter."""
|
||||
from cleveragents.cli.commands.acms_context import acms_context_clear
|
||||
|
||||
all_entries: list[TieredFragment] = context.entries_to_remove
|
||||
filtered = [f for f in all_entries if fnmatch.fnmatch(f.resource_id, pattern)]
|
||||
mock_service = make_mock_tier_service(fragments=all_entries)
|
||||
mock_container = make_mock_container(mock_service)
|
||||
|
||||
output_buffer = io.StringIO()
|
||||
mock_console = MagicMock()
|
||||
|
||||
def capture_print(msg: str = "", **kwargs: Any) -> None:
|
||||
output_buffer.write(str(msg) + "\n")
|
||||
|
||||
mock_console.print.side_effect = capture_print
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context.get_container",
|
||||
return_value=mock_container,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context._get_console",
|
||||
return_value=mock_console,
|
||||
),
|
||||
):
|
||||
try:
|
||||
acms_context_clear(path=pattern, yes=True)
|
||||
context.command_exit_code = 0
|
||||
except typer.Exit as exc:
|
||||
context.command_exit_code = (
|
||||
exc.exit_code if exc.exit_code is not None else 0
|
||||
)
|
||||
except SystemExit as exc:
|
||||
context.command_exit_code = exc.code if exc.code is not None else 0
|
||||
|
||||
context.command_output = output_buffer.getvalue()
|
||||
context.filtered_count = len(filtered)
|
||||
|
||||
|
||||
@when('I run "agents acms context clear --tag {tag} --yes"')
|
||||
def step_run_context_clear_tag(context: Any, tag: str) -> None:
|
||||
"""Run the context clear command with tag filter."""
|
||||
from cleveragents.cli.commands.acms_context import acms_context_clear
|
||||
|
||||
all_entries: list[TieredFragment] = context.entries_to_remove
|
||||
mock_service = make_mock_tier_service(fragments=all_entries)
|
||||
mock_container = make_mock_container(mock_service)
|
||||
|
||||
output_buffer = io.StringIO()
|
||||
mock_console = MagicMock()
|
||||
|
||||
def capture_print(msg: str = "", **kwargs: Any) -> None:
|
||||
output_buffer.write(str(msg) + "\n")
|
||||
|
||||
mock_console.print.side_effect = capture_print
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context.get_container",
|
||||
return_value=mock_container,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context._get_console",
|
||||
return_value=mock_console,
|
||||
),
|
||||
):
|
||||
try:
|
||||
acms_context_clear(tag=tag, yes=True)
|
||||
context.command_exit_code = 0
|
||||
except typer.Exit as exc:
|
||||
context.command_exit_code = (
|
||||
exc.exit_code if exc.exit_code is not None else 0
|
||||
)
|
||||
except SystemExit as exc:
|
||||
context.command_exit_code = exc.code if exc.code is not None else 0
|
||||
|
||||
context.command_output = output_buffer.getvalue()
|
||||
|
||||
|
||||
@when('I run "agents acms context clear --tier {tier} --yes"')
|
||||
def step_run_context_clear_tier(context: Any, tier: str) -> None:
|
||||
"""Run the context clear command with tier filter."""
|
||||
from cleveragents.cli.commands.acms_context import acms_context_clear
|
||||
|
||||
all_entries: list[TieredFragment] = context.entries_to_remove
|
||||
mock_service = make_mock_tier_service(fragments=all_entries)
|
||||
mock_container = make_mock_container(mock_service)
|
||||
|
||||
output_buffer = io.StringIO()
|
||||
mock_console = MagicMock()
|
||||
|
||||
def capture_print(msg: str = "", **kwargs: Any) -> None:
|
||||
output_buffer.write(str(msg) + "\n")
|
||||
|
||||
mock_console.print.side_effect = capture_print
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context.get_container",
|
||||
return_value=mock_container,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context._get_console",
|
||||
return_value=mock_console,
|
||||
),
|
||||
):
|
||||
try:
|
||||
acms_context_clear(tier=tier, yes=True)
|
||||
context.command_exit_code = 0
|
||||
except typer.Exit as exc:
|
||||
context.command_exit_code = (
|
||||
exc.exit_code if exc.exit_code is not None else 0
|
||||
)
|
||||
except SystemExit as exc:
|
||||
context.command_exit_code = exc.code if exc.code is not None else 0
|
||||
|
||||
context.command_output = output_buffer.getvalue()
|
||||
|
||||
|
||||
@when('I run "agents acms context show" with help flag')
|
||||
def step_run_context_show_help(context: Any) -> None:
|
||||
"""Run the context show help command."""
|
||||
from cleveragents.cli.commands.acms_context import acms_context_show
|
||||
|
||||
context.command_output = (
|
||||
acms_context_show.__doc__
|
||||
or "Display the assembled context for a specific ACMS view"
|
||||
)
|
||||
context.command_exit_code = 0
|
||||
|
||||
|
||||
@when('I run "agents acms context clear" with help flag')
|
||||
def step_run_context_clear_help(context: Any) -> None:
|
||||
"""Run the context clear help command."""
|
||||
from cleveragents.cli.commands.acms_context import acms_context_clear
|
||||
|
||||
context.command_output = (
|
||||
acms_context_clear.__doc__ or "Remove stale context entries from the ACMS index"
|
||||
)
|
||||
context.command_exit_code = 0
|
||||
|
||||
|
||||
@then("no confirmation prompt should be shown")
|
||||
def step_no_confirmation_prompt(context: Any) -> None:
|
||||
"""Verify no confirmation prompt was shown (--yes flag bypassed it)."""
|
||||
mock_confirm = getattr(context, "mock_typer_confirm", None)
|
||||
if mock_confirm is not None:
|
||||
mock_confirm.assert_not_called()
|
||||
@@ -0,0 +1,81 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for ACMS context CLI commands (context show and context clear)
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment With Database Isolation
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_acms_context_cli.py
|
||||
|
||||
*** Test Cases ***
|
||||
Context Show Command Returns Assembled Context
|
||||
[Documentation] Verify context show command returns assembled context data
|
||||
${result}= Run Process ${PYTHON} ${HELPER} context-show cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-context-show-ok
|
||||
|
||||
Context Show Command Handles Empty View
|
||||
[Documentation] Verify context show command handles view with no entries
|
||||
${result}= Run Process ${PYTHON} ${HELPER} context-show-empty cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-context-show-empty-ok
|
||||
|
||||
Context Clear Command Removes Entries With Yes Flag
|
||||
[Documentation] Verify context clear command removes entries when --yes flag is used
|
||||
${result}= Run Process ${PYTHON} ${HELPER} context-clear-yes cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-context-clear-yes-ok
|
||||
|
||||
Context Clear Command Filters By Path
|
||||
[Documentation] Verify context clear command filters entries by path pattern
|
||||
${result}= Run Process ${PYTHON} ${HELPER} context-clear-path cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-context-clear-path-ok
|
||||
|
||||
Context Clear Command Filters By Tag
|
||||
[Documentation] Verify context clear command filters entries by tag
|
||||
${result}= Run Process ${PYTHON} ${HELPER} context-clear-tag cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-context-clear-tag-ok
|
||||
|
||||
Context Clear Command Filters By Tier
|
||||
[Documentation] Verify context clear command filters entries by tier
|
||||
${result}= Run Process ${PYTHON} ${HELPER} context-clear-tier cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-context-clear-tier-ok
|
||||
|
||||
Context Clear Command Handles No Matching Entries
|
||||
[Documentation] Verify context clear command handles case with no matching entries
|
||||
${result}= Run Process ${PYTHON} ${HELPER} context-clear-no-match cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-context-clear-no-match-ok
|
||||
|
||||
Context Show Validates Empty View Name
|
||||
[Documentation] Verify context show command rejects empty view name
|
||||
${result}= Run Process ${PYTHON} ${HELPER} context-show-empty-view cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-context-show-empty-view-ok
|
||||
|
||||
Budget Utilization Formats Correctly
|
||||
[Documentation] Verify budget utilization formatting helper
|
||||
${result}= Run Process ${PYTHON} ${HELPER} budget-format cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} acms-budget-format-ok
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Robot Framework helper for ACMS context CLI integration tests.
|
||||
|
||||
Provides a CLI-style interface for Robot to invoke ACMS context CLI
|
||||
operations and verify the results. Exit code 0 = success, 1 = failure.
|
||||
|
||||
Usage:
|
||||
python robot/helper_acms_context_cli.py context-show
|
||||
python robot/helper_acms_context_cli.py context-show-empty
|
||||
python robot/helper_acms_context_cli.py context-clear-yes
|
||||
python robot/helper_acms_context_cli.py context-clear-path
|
||||
python robot/helper_acms_context_cli.py context-clear-tag
|
||||
python robot/helper_acms_context_cli.py context-clear-tier
|
||||
python robot/helper_acms_context_cli.py context-clear-no-match
|
||||
python robot/helper_acms_context_cli.py context-show-empty-view
|
||||
python robot/helper_acms_context_cli.py budget-format
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
# Always insert at position 0 to ensure the clone's source takes precedence
|
||||
# over any other cleveragents installation (e.g., /app/src from the environment)
|
||||
if _SRC in sys.path:
|
||||
sys.path.remove(_SRC)
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
_FEATURES = str(Path(__file__).resolve().parents[1])
|
||||
if _FEATURES in sys.path:
|
||||
sys.path.remove(_FEATURES)
|
||||
sys.path.insert(0, _FEATURES)
|
||||
|
||||
import typer # noqa: E402
|
||||
from features.mocks.acms_context_mocks import ( # noqa: E402
|
||||
make_mock_container,
|
||||
make_mock_tier_service,
|
||||
make_tiered_fragment,
|
||||
)
|
||||
|
||||
from cleveragents.cli.commands.acms_context import ( # noqa: E402
|
||||
_format_budget_utilization,
|
||||
acms_context_clear,
|
||||
acms_context_show,
|
||||
)
|
||||
from cleveragents.domain.models.acms.tiers import ContextTier # noqa: E402
|
||||
|
||||
_SAMPLE_FRAGMENTS = [
|
||||
make_tiered_fragment(
|
||||
fragment_id=f"frag-{i}",
|
||||
resource_id=f"src/module{i}.py",
|
||||
tier=ContextTier.HOT if i % 2 == 0 else ContextTier.WARM,
|
||||
token_count=1024 * (i + 1),
|
||||
project_name="test_project",
|
||||
tag="deprecated" if i < 2 else "default",
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
|
||||
def _run_show(
|
||||
view: str,
|
||||
scoped_fragments: list[Any],
|
||||
) -> tuple[str, int]:
|
||||
"""Run acms_context_show and capture output."""
|
||||
mock_service = make_mock_tier_service(
|
||||
fragments=scoped_fragments,
|
||||
scoped_fragments=scoped_fragments,
|
||||
)
|
||||
mock_container = make_mock_container(mock_service)
|
||||
output_lines: list[str] = []
|
||||
mock_console = MagicMock()
|
||||
|
||||
def capture_print(msg: str = "", **kwargs: Any) -> None:
|
||||
output_lines.append(str(msg))
|
||||
|
||||
mock_console.print.side_effect = capture_print
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context.get_container",
|
||||
return_value=mock_container,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context._get_console",
|
||||
return_value=mock_console,
|
||||
),
|
||||
):
|
||||
exit_code = 0
|
||||
try:
|
||||
acms_context_show(view)
|
||||
except typer.Exit as exc:
|
||||
exit_code = exc.exit_code if exc.exit_code is not None else 0
|
||||
except SystemExit as exc:
|
||||
exit_code = exc.code if exc.code is not None else 0
|
||||
|
||||
return "\n".join(output_lines), exit_code
|
||||
|
||||
|
||||
def _run_clear(
|
||||
all_fragments: list[Any],
|
||||
path: str | None = None,
|
||||
tag: str | None = None,
|
||||
tier: str | None = None,
|
||||
yes: bool = True,
|
||||
) -> tuple[str, int]:
|
||||
"""Run acms_context_clear and capture output."""
|
||||
mock_service = make_mock_tier_service(fragments=all_fragments)
|
||||
mock_container = make_mock_container(mock_service)
|
||||
output_lines: list[str] = []
|
||||
mock_console = MagicMock()
|
||||
|
||||
def capture_print(msg: str = "", **kwargs: Any) -> None:
|
||||
output_lines.append(str(msg))
|
||||
|
||||
mock_console.print.side_effect = capture_print
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context.get_container",
|
||||
return_value=mock_container,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.acms_context._get_console",
|
||||
return_value=mock_console,
|
||||
),
|
||||
):
|
||||
exit_code = 0
|
||||
try:
|
||||
acms_context_clear(path=path, tag=tag, tier=tier, yes=yes)
|
||||
except typer.Exit as exc:
|
||||
exit_code = exc.exit_code if exc.exit_code is not None else 0
|
||||
except SystemExit as exc:
|
||||
exit_code = exc.code if exc.code is not None else 0
|
||||
|
||||
return "\n".join(output_lines), exit_code
|
||||
|
||||
|
||||
def _cmd_context_show() -> int:
|
||||
"""Verify context show command returns assembled context data."""
|
||||
output, exit_code = _run_show("test_project", _SAMPLE_FRAGMENTS)
|
||||
if exit_code != 0:
|
||||
print(f"acms-fail: context show exited with code {exit_code}")
|
||||
return 1
|
||||
expected = "Assembled Context for View: test_project"
|
||||
if expected not in output:
|
||||
print(f"acms-fail: expected {expected!r} in output\n{output}")
|
||||
return 1
|
||||
# Verify per-tier budget utilization breakdown is present
|
||||
for tier_label in ("Hot tier", "Warm tier", "Cold tier"):
|
||||
if tier_label not in output:
|
||||
print(
|
||||
f"acms-fail: expected per-tier breakdown"
|
||||
f" '{tier_label}' in output\n{output}"
|
||||
)
|
||||
return 1
|
||||
print("acms-context-show-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_context_show_empty() -> int:
|
||||
"""Verify context show command handles view with no entries."""
|
||||
output, exit_code = _run_show("empty_view", [])
|
||||
if exit_code != 0:
|
||||
print(f"acms-fail: context show empty exited with code {exit_code}")
|
||||
return 1
|
||||
if "No context found for view: empty_view" not in output:
|
||||
print(f"acms-fail: expected 'No context found for view' in output\n{output}")
|
||||
return 1
|
||||
print("acms-context-show-empty-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_context_clear_yes() -> int:
|
||||
"""Verify context clear command removes entries when --yes flag is used."""
|
||||
output, exit_code = _run_clear(
|
||||
all_fragments=_SAMPLE_FRAGMENTS,
|
||||
yes=True,
|
||||
)
|
||||
if exit_code != 0:
|
||||
print(f"acms-fail: context clear --yes exited with code {exit_code}")
|
||||
return 1
|
||||
if "Removed" not in output or "context entries" not in output:
|
||||
print(f"acms-fail: expected 'Removed N context entries' in output\n{output}")
|
||||
return 1
|
||||
print("acms-context-clear-yes-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_context_clear_path() -> int:
|
||||
"""Verify context clear command filters entries by path pattern."""
|
||||
output, exit_code = _run_clear(
|
||||
all_fragments=_SAMPLE_FRAGMENTS,
|
||||
path="src/module0.py",
|
||||
yes=True,
|
||||
)
|
||||
if exit_code != 0:
|
||||
print(f"acms-fail: context clear --path exited with code {exit_code}")
|
||||
return 1
|
||||
if "context entries" not in output:
|
||||
print(f"acms-fail: expected 'context entries' in output\n{output}")
|
||||
return 1
|
||||
print("acms-context-clear-path-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_context_clear_tag() -> int:
|
||||
"""Verify context clear command filters entries by tag."""
|
||||
output, exit_code = _run_clear(
|
||||
all_fragments=_SAMPLE_FRAGMENTS,
|
||||
tag="deprecated",
|
||||
yes=True,
|
||||
)
|
||||
if exit_code != 0:
|
||||
print(f"acms-fail: context clear --tag exited with code {exit_code}")
|
||||
return 1
|
||||
if "context entries" not in output:
|
||||
print(f"acms-fail: expected 'context entries' in output\n{output}")
|
||||
return 1
|
||||
print("acms-context-clear-tag-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_context_clear_tier() -> int:
|
||||
"""Verify context clear command filters entries by tier."""
|
||||
output, exit_code = _run_clear(
|
||||
all_fragments=_SAMPLE_FRAGMENTS,
|
||||
tier="hot",
|
||||
yes=True,
|
||||
)
|
||||
if exit_code != 0:
|
||||
print(f"acms-fail: context clear --tier exited with code {exit_code}")
|
||||
return 1
|
||||
if "context entries" not in output:
|
||||
print(f"acms-fail: expected 'context entries' in output\n{output}")
|
||||
return 1
|
||||
print("acms-context-clear-tier-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_context_clear_no_match() -> int:
|
||||
"""Verify context clear command handles case with no matching entries."""
|
||||
output, exit_code = _run_clear(
|
||||
all_fragments=_SAMPLE_FRAGMENTS,
|
||||
path="nonexistent/*",
|
||||
yes=True,
|
||||
)
|
||||
if exit_code != 0:
|
||||
print(f"acms-fail: context clear no-match exited with code {exit_code}")
|
||||
return 1
|
||||
if "No context entries match the specified filters" not in output:
|
||||
print(f"acms-fail: expected 'No context entries match' in output\n{output}")
|
||||
return 1
|
||||
print("acms-context-clear-no-match-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_context_show_empty_view() -> int:
|
||||
"""Verify context show command rejects empty view name."""
|
||||
_output, exit_code = _run_show("", [])
|
||||
if exit_code == 0:
|
||||
print("acms-fail: expected non-zero exit code for empty view name")
|
||||
return 1
|
||||
print("acms-context-show-empty-view-ok")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_budget_format() -> int:
|
||||
"""Verify budget utilization formatting helper."""
|
||||
cases = [
|
||||
(0, 0, "0%"),
|
||||
(500, 1000, "50.0%"),
|
||||
(1000, 1000, "100.0%"),
|
||||
(-1, 100, "N/A"),
|
||||
(100, -1, "N/A"),
|
||||
]
|
||||
for used, total, expected in cases:
|
||||
result = _format_budget_utilization(used, total)
|
||||
if result != expected:
|
||||
print(
|
||||
f"acms-fail: _format_budget_utilization({used}, {total}) "
|
||||
f"= {result!r}, expected {expected!r}"
|
||||
)
|
||||
return 1
|
||||
print("acms-budget-format-ok")
|
||||
return 0
|
||||
|
||||
|
||||
_COMMANDS: dict[str, Callable[[], int]] = {
|
||||
"context-show": _cmd_context_show,
|
||||
"context-show-empty": _cmd_context_show_empty,
|
||||
"context-clear-yes": _cmd_context_clear_yes,
|
||||
"context-clear-path": _cmd_context_clear_path,
|
||||
"context-clear-tag": _cmd_context_clear_tag,
|
||||
"context-clear-tier": _cmd_context_clear_tier,
|
||||
"context-clear-no-match": _cmd_context_clear_no_match,
|
||||
"context-show-empty-view": _cmd_context_show_empty_view,
|
||||
"budget-format": _cmd_budget_format,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point called by Robot Framework ``Run Process``."""
|
||||
if len(sys.argv) < 2:
|
||||
print(
|
||||
"Usage: helper_acms_context_cli.py "
|
||||
"<context-show|context-show-empty|context-clear-yes"
|
||||
"|context-clear-path|context-clear-tag|context-clear-tier"
|
||||
"|context-clear-no-match|context-show-empty-view|budget-format>"
|
||||
)
|
||||
return 1
|
||||
|
||||
command = sys.argv[1]
|
||||
handler = _COMMANDS.get(command)
|
||||
if handler is None:
|
||||
print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
return handler()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,367 @@
|
||||
"""ACMS context management commands for CleverAgents CLI.
|
||||
|
||||
This module implements ACMS (Advanced Context Management System) context-related
|
||||
commands for viewing and managing assembled context for views.
|
||||
|
||||
Canonical path: ``agents acms context <subcommand>``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Annotated, Protocol, runtime_checkable
|
||||
|
||||
import typer
|
||||
from rich.table import Table
|
||||
|
||||
from cleveragents.application.container import get_container
|
||||
from cleveragents.cli.renderers import _get_console
|
||||
from cleveragents.core.exceptions import CleverAgentsError
|
||||
from cleveragents.domain.models.acms.tiers import ContextTier, TieredFragment
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_BROAD_PATTERN_THRESHOLD = 50
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _TierServiceProtocol(Protocol):
|
||||
"""Protocol for ContextTierService used by _remove_fragments."""
|
||||
|
||||
def _remove_from_all(self, fragment_id: str) -> None:
|
||||
"""Remove a fragment from all tiers by its ID."""
|
||||
...
|
||||
|
||||
@property
|
||||
def _lock(self) -> object:
|
||||
"""Thread lock for safe concurrent access."""
|
||||
...
|
||||
|
||||
|
||||
# Create sub-app for ACMS context commands
|
||||
app = typer.Typer(help="ACMS context management commands (agents acms context)")
|
||||
|
||||
|
||||
def _format_budget_utilization(
|
||||
used: int | float,
|
||||
total: int | float,
|
||||
) -> str:
|
||||
"""Format budget utilization as a percentage string."""
|
||||
if total == 0:
|
||||
return "0%"
|
||||
if used < 0 or total < 0:
|
||||
return "N/A"
|
||||
percentage = (used / total) * 100
|
||||
return f"{percentage:.1f}%"
|
||||
|
||||
|
||||
def _format_fragment_row(
|
||||
fragment: TieredFragment,
|
||||
) -> tuple[str, str, str, str]:
|
||||
"""Extract display-ready string values from a TieredFragment.
|
||||
|
||||
Returns:
|
||||
A tuple of (resource_id, tier, token_count, project_name) for display.
|
||||
"""
|
||||
resource_id = fragment.resource_id or "(no resource)"
|
||||
tier_label = str(fragment.tier.value) if fragment.tier else "unknown"
|
||||
token_str = f"{fragment.token_count:,}"
|
||||
project = fragment.project_name or "(global)"
|
||||
return resource_id, tier_label, token_str, project
|
||||
|
||||
|
||||
@app.command("show")
|
||||
def acms_context_show(
|
||||
view: Annotated[
|
||||
str,
|
||||
typer.Argument(help="View name/project to show assembled context for"),
|
||||
],
|
||||
) -> None:
|
||||
"""Display the assembled context for a specific ACMS view.
|
||||
|
||||
Shows the context fragments stored in the hot/warm/cold tiers for
|
||||
the given project view, including token budget utilization summary.
|
||||
"""
|
||||
console = _get_console()
|
||||
|
||||
if not view or not view.strip():
|
||||
console.print("[red]Error:[/red] View name must not be empty.")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
try:
|
||||
container = get_container()
|
||||
tier_service = container.context_tier_service()
|
||||
|
||||
# Get fragments scoped to the view (project name)
|
||||
fragments = tier_service.get_scoped_view([view.strip()])
|
||||
|
||||
if not fragments:
|
||||
console.print(f"[yellow]No context found for view: {view}[/yellow]")
|
||||
return
|
||||
|
||||
# Display assembled context
|
||||
console.print(f"\n[bold]Assembled Context for View: {view}[/bold]")
|
||||
|
||||
table = Table(title=f"Context Entries ({len(fragments)} total)")
|
||||
table.add_column("Resource", style="cyan")
|
||||
table.add_column("Tier", style="green")
|
||||
table.add_column("Tokens", style="magenta")
|
||||
table.add_column("Project", style="yellow")
|
||||
|
||||
total_tokens = 0
|
||||
hot_tokens = 0
|
||||
warm_tokens = 0
|
||||
cold_tokens = 0
|
||||
for fragment in fragments:
|
||||
resource_id, tier_label, token_str, project = _format_fragment_row(fragment)
|
||||
total_tokens += fragment.token_count
|
||||
if fragment.tier == ContextTier.HOT:
|
||||
hot_tokens += fragment.token_count
|
||||
elif fragment.tier == ContextTier.WARM:
|
||||
warm_tokens += fragment.token_count
|
||||
elif fragment.tier == ContextTier.COLD:
|
||||
cold_tokens += fragment.token_count
|
||||
table.add_row(resource_id, tier_label, token_str, project)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\n[bold]Total Tokens:[/bold] {total_tokens:,}")
|
||||
|
||||
# Display tier metrics using actual token counts per tier
|
||||
metrics = tier_service.get_metrics()
|
||||
budget = tier_service.budget
|
||||
hot_utilization = _format_budget_utilization(
|
||||
hot_tokens, budget.max_tokens_hot or 1
|
||||
)
|
||||
warm_utilization = _format_budget_utilization(
|
||||
metrics.warm_count, budget.max_decisions_warm or 1
|
||||
)
|
||||
cold_utilization = _format_budget_utilization(
|
||||
metrics.cold_count, budget.max_decisions_cold or 1
|
||||
)
|
||||
console.print("\n[bold]Budget Utilization:[/bold]")
|
||||
console.print(
|
||||
f" Hot tier: {metrics.hot_count} fragments"
|
||||
f" ({hot_tokens:,} tokens)"
|
||||
f" — {hot_utilization} of token budget"
|
||||
)
|
||||
console.print(
|
||||
f" Warm tier: {metrics.warm_count} fragments"
|
||||
f" ({warm_tokens:,} tokens)"
|
||||
f" — {warm_utilization} of decision budget"
|
||||
)
|
||||
console.print(
|
||||
f" Cold tier: {metrics.cold_count} fragments"
|
||||
f" ({cold_tokens:,} tokens)"
|
||||
f" — {cold_utilization} of decision budget"
|
||||
)
|
||||
|
||||
except CleverAgentsError as e:
|
||||
_logger.exception("ACMS context show failed for view %r", view)
|
||||
console = _get_console()
|
||||
console.print(
|
||||
"[red]Error:[/red] Failed to retrieve context."
|
||||
" Please check logs for details."
|
||||
)
|
||||
raise typer.Exit(code=1) from e
|
||||
except Exception as e:
|
||||
_logger.exception("Unexpected error in acms_context_show for view %r", view)
|
||||
console = _get_console()
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to retrieve context for view '{view}'."
|
||||
)
|
||||
raise typer.Exit(code=1) from e
|
||||
|
||||
|
||||
@app.command("clear")
|
||||
def acms_context_clear(
|
||||
path: Annotated[
|
||||
str | None,
|
||||
typer.Option("--path", help="Filter by resource path pattern (glob)"),
|
||||
] = None,
|
||||
tag: Annotated[
|
||||
str | None,
|
||||
typer.Option("--tag", help="Filter by metadata tag value"),
|
||||
] = None,
|
||||
tier: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--tier",
|
||||
help="Filter by tier (hot, warm, cold)",
|
||||
),
|
||||
] = None,
|
||||
yes: Annotated[
|
||||
bool, typer.Option("--yes", "-y", help="Skip confirmation prompt")
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Remove stale context entries from the ACMS index.
|
||||
|
||||
Removes context fragments by path pattern, metadata tag, or tier.
|
||||
Requires confirmation unless --yes flag is provided.
|
||||
"""
|
||||
console = _get_console()
|
||||
|
||||
try:
|
||||
container = get_container()
|
||||
tier_service = container.context_tier_service()
|
||||
|
||||
# Get all fragments and apply filters
|
||||
all_fragments = tier_service.get_all_fragments()
|
||||
|
||||
# Warn if no filters specified (clear ALL)
|
||||
if not path and not tag and not tier:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] No filters specified. "
|
||||
"This will remove ALL context entries."
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
entries_to_remove = _filter_fragments(all_fragments, path, tag, tier)
|
||||
|
||||
if not entries_to_remove:
|
||||
console.print(
|
||||
"[yellow]No context entries match the specified filters.[/yellow]"
|
||||
)
|
||||
return
|
||||
|
||||
# Warn on overly broad glob patterns that match many entries
|
||||
if path and len(entries_to_remove) > _BROAD_PATTERN_THRESHOLD:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] The path pattern '{path}' matches "
|
||||
f"{len(entries_to_remove)} entries."
|
||||
" Consider using a more specific pattern."
|
||||
)
|
||||
|
||||
# Show what will be removed
|
||||
console.print(
|
||||
f"\n[bold]Context entries to remove "
|
||||
f"({len(entries_to_remove)} total):[/bold]"
|
||||
)
|
||||
|
||||
table = Table()
|
||||
table.add_column("Resource", style="cyan")
|
||||
table.add_column("Tier", style="green")
|
||||
table.add_column("Project", style="yellow")
|
||||
|
||||
for fragment in entries_to_remove[:10]: # Show first 10
|
||||
resource_id, tier_label, _, project = _format_fragment_row(fragment)
|
||||
table.add_row(resource_id, tier_label, project)
|
||||
|
||||
console.print(table)
|
||||
|
||||
if len(entries_to_remove) > 10:
|
||||
console.print(f" ... and {len(entries_to_remove) - 10} more entries")
|
||||
|
||||
# Confirm if needed — handle cancellation before the main try/except
|
||||
# to avoid catching typer.Exit from the confirmation prompt
|
||||
if not yes:
|
||||
confirmed = typer.confirm(
|
||||
f"\nRemove {len(entries_to_remove)} context entries?"
|
||||
)
|
||||
if not confirmed:
|
||||
console.print("[yellow]Cancelled.[/yellow]")
|
||||
return
|
||||
|
||||
# Remove entries by evicting from their respective tiers
|
||||
removed_count = _remove_fragments(tier_service, entries_to_remove)
|
||||
console.print(f"\n[green]✓[/green] Removed {removed_count} context entries.")
|
||||
|
||||
except CleverAgentsError as e:
|
||||
_logger.exception("ACMS context clear failed")
|
||||
console = _get_console()
|
||||
console.print(
|
||||
"[red]Error:[/red] Failed to clear context entries."
|
||||
" Please check logs for details."
|
||||
)
|
||||
raise typer.Exit(code=1) from e
|
||||
except Exception as e:
|
||||
_logger.exception("Unexpected error in acms_context_clear")
|
||||
console = _get_console()
|
||||
console.print("[red]Error:[/red] Failed to clear context entries.")
|
||||
raise typer.Exit(code=1) from e
|
||||
|
||||
|
||||
def _filter_fragments(
|
||||
fragments: list[TieredFragment],
|
||||
path: str | None,
|
||||
tag: str | None,
|
||||
tier_filter: str | None,
|
||||
) -> list[TieredFragment]:
|
||||
"""Filter fragments by path pattern, tag, and/or tier.
|
||||
|
||||
Args:
|
||||
fragments: All fragments to filter.
|
||||
path: Glob pattern to match against resource_id.
|
||||
tag: Tag value to match in fragment metadata.
|
||||
tier_filter: Tier name to filter by (hot, warm, cold).
|
||||
|
||||
Returns:
|
||||
Filtered list of fragments matching all specified criteria.
|
||||
"""
|
||||
result = fragments
|
||||
|
||||
if path:
|
||||
result = [f for f in result if fnmatch.fnmatch(f.resource_id, path)]
|
||||
|
||||
if tag:
|
||||
result = [
|
||||
f
|
||||
for f in result
|
||||
if f.metadata.get("tag") == tag or f.metadata.get("tags") == tag
|
||||
]
|
||||
|
||||
if tier_filter:
|
||||
try:
|
||||
target_tier = ContextTier(tier_filter.lower())
|
||||
result = [f for f in result if f.tier == target_tier]
|
||||
except ValueError:
|
||||
# Invalid tier name — return empty (no matches)
|
||||
return []
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _remove_fragments(
|
||||
tier_service: _TierServiceProtocol,
|
||||
fragments: list[TieredFragment],
|
||||
) -> int:
|
||||
"""Remove the given fragments from the tier service.
|
||||
|
||||
Removes fragments by calling _remove_from_all on the tier service.
|
||||
|
||||
Args:
|
||||
tier_service: The ContextTierService instance (or compatible implementation).
|
||||
fragments: Fragments to remove.
|
||||
|
||||
Returns:
|
||||
Number of fragments successfully removed.
|
||||
"""
|
||||
lock = getattr(tier_service, "_lock", None)
|
||||
|
||||
removed = 0
|
||||
for fragment in fragments:
|
||||
if lock is not None:
|
||||
with lock:
|
||||
tier_service._remove_from_all(fragment.fragment_id)
|
||||
else:
|
||||
tier_service._remove_from_all(fragment.fragment_id)
|
||||
removed += 1
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def acms_context_default(ctx: typer.Context) -> None:
|
||||
"""Show ACMS context information when no subcommand is provided."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
console = _get_console()
|
||||
console.print("[bold]ACMS Context Management[/bold]")
|
||||
console.print("\nAvailable commands:")
|
||||
console.print(" show <view> - Display assembled context for a view")
|
||||
console.print(" clear - Remove context entries with filtering")
|
||||
console.print(
|
||||
"\nUse 'agents acms context <command> --help' for more information."
|
||||
)
|
||||
@@ -80,6 +80,7 @@ def _register_subcommands() -> None:
|
||||
|
||||
try:
|
||||
from cleveragents.cli.commands import (
|
||||
acms_context,
|
||||
action,
|
||||
actor,
|
||||
audit,
|
||||
@@ -113,6 +114,13 @@ def _register_subcommands() -> None:
|
||||
|
||||
app.add_typer(project.app, name="project", help="Project management")
|
||||
|
||||
# Register acms context as a sub-app (canonical: agents acms context)
|
||||
app.add_typer(
|
||||
acms_context.app,
|
||||
name="acms",
|
||||
help="ACMS (Advanced Context Management System) operations.",
|
||||
)
|
||||
|
||||
# Register context as a sub-app of actor (canonical: agents actor context)
|
||||
actor.app.add_typer(
|
||||
context.app,
|
||||
@@ -260,6 +268,7 @@ def _print_basic_help() -> None:
|
||||
typer.echo("\nCommon commands:")
|
||||
typer.echo(" project Project management")
|
||||
typer.echo(" actor context Actor context management")
|
||||
typer.echo(" acms context ACMS context management (show, clear)")
|
||||
typer.echo(" plan Plan operations (actor required)")
|
||||
typer.echo(" actor Actor management and defaults")
|
||||
typer.echo(" init Initialize a project")
|
||||
@@ -765,6 +774,7 @@ def main(args: list[str] | None = None) -> int:
|
||||
"diagnostics",
|
||||
"init",
|
||||
"project",
|
||||
"acms",
|
||||
"context",
|
||||
"plan",
|
||||
"actor",
|
||||
|
||||
Reference in New Issue
Block a user