diff --git a/CHANGELOG.md b/CHANGELOG.md index 5edafb3f8..94c765f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -903,7 +903,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. @@ -926,6 +926,15 @@ uko-oo:Class` triple emission in `PythonAnalyzer._extract_class()` so that example in `docs/showcase/examples.json` and added a callout explaining why capability flags display as `(default)` in the detail view. +- **ACMS Context CLI Commands** (`context show` / `context clear`) (#9586): Implemented + two new CLI commands for the ACMS (Advanced Context Management System). `context show + ` 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 @@ -1313,4 +1322,4 @@ iteration` and data corruption under concurrent plan execution. All public - **TUI -- Permission Question Widget**: A new inline `PermissionQuestionWidget` renders permission requests directly in the conversation stream for single-key operations. Users can allow/reject with single-key shortcuts (`a`/`A`/`r`/`R`), - navigate with arrow keys, confirm with `Enter`, or press `v` to open the full \ No newline at end of file + navigate with arrow keys, confirm with `Enter`, or press `v` to open the full diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index f64eaebf1..d4d6765ea 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -33,6 +33,7 @@ Below are some of the specific details of various contributions. * HAMZA KHYARI has contributed the ACMS execute-phase context assembler project-level hot_max_tokens fix (PR #11036 / issue #11035): added `_resolve_effective_budget()` method that reads each linked project's `settings.hot_max_tokens` and uses the maximum override value as the pipeline budget instead of the hardcoded global 16K default. * HAL 9000 has contributed the automated CLI docstring example validation (#9106): added `DocstringExampleValidator` to enforce positional-before-option ordering in CLI `Examples:` sections, with Behave test coverage and CONTRIBUTING.md documentation. * HAL 9000 has contributed the AutoDebugAgent prompt injection mitigation fix (#9110): sanitized user-provided `error_message` and `code_context` fields in all three agent methods using `PromptSanitizer` boundary markers, added graceful `PromptInjectionDetected` exception handling, and added BDD and Robot Framework integration tests for the security fix. +* HAL 9000 has contributed the ACMS context CLI commands (`context show` and `context clear`) for the ACMS v1 milestone, including BDD test coverage, Robot Framework integration tests, ASV performance benchmarks, and per-tier budget utilization breakdown (issue #9586). * This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc. * HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system. * HAL 9000 has contributed the pr-review-pool-supervisor tracking prefix documentation fix (#7891): aligned all documentation references from the outdated `AUTO-REV-POOL` prefix to the correct `AUTO-REV-SUP` prefix used in production. diff --git a/benchmarks/acms_context_cli_bench.py b/benchmarks/acms_context_cli_bench.py new file mode 100644 index 000000000..5a873666d --- /dev/null +++ b/benchmarks/acms_context_cli_bench.py @@ -0,0 +1,177 @@ +"""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 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, + ), + ): + try: + acms_context_show(view) + except SystemExit: + pass + + +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, + ), + ): + try: + acms_context_clear(tier=tier, yes=True) + except SystemExit: + pass + + +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") diff --git a/features/acms_context_cli.feature b/features/acms_context_cli.feature index 2fbad3cd0..070cae523 100644 --- a/features/acms_context_cli.feature +++ b/features/acms_context_cli.feature @@ -10,13 +10,13 @@ Feature: ACMS Context CLI Commands 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 "Context Entries" + 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 entries found" + 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 @@ -33,34 +33,33 @@ Feature: ACMS Context CLI Commands 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 "Removed 3 context entries" + 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 "Removed 2 context entries" + 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 "TIER_3" - When I run "agents acms context clear --tier TIER_3 --yes" - Then the output should contain "Removed 2 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 the specified filters" + Then the output should contain "No context entries match" Scenario: Show help for context show command - When I run "agents acms context show --help" + When I run "agents acms context show" with help flag Then the output should contain "Display the assembled context" - And the output should contain "View name/identifier" + And the output should contain "project view" Scenario: Show help for context clear command - When I run "agents acms context clear --help" + 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" - And the output should contain "--yes" + And the output should contain "path" + And the output should contain "tag" + And the output should contain "tier" diff --git a/features/mocks/acms_context_mocks.py b/features/mocks/acms_context_mocks.py new file mode 100644 index 000000000..d7dd8fc8d --- /dev/null +++ b/features/mocks/acms_context_mocks.py @@ -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 diff --git a/features/steps/acms_context_cli_steps.py b/features/steps/acms_context_cli_steps.py index bb4f4a3ab..acfe6b61f 100644 --- a/features/steps/acms_context_cli_steps.py +++ b/features/steps/acms_context_cli_steps.py @@ -2,102 +2,77 @@ from __future__ import annotations +import fnmatch +import io from typing import Any from unittest.mock import MagicMock, patch 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.acms_service = MagicMock() + 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.""" + """Create a test view with sample fragments.""" context.test_view = view_name - context.context_data[view_name] = { - "entries": [ - { - "path": "src/module1.py", - "tier": "TIER_1", - "size": 1024, - "tag": "default", - "content": "# Module 1", - }, - { - "path": "src/module2.py", - "tier": "TIER_2", - "size": 2048, - "tag": "default", - "content": "# Module 2", - }, - ], - "budget": { - "used": 3072, - "total": 10000, - }, - } + 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] = { - "entries": [], - "budget": { - "used": 0, - "total": 10000, - }, - } + 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.""" - entries = [] - for i in range(count): - entries.append({ - "path": f"src/module{i}.py", - "tier": "TIER_1" if i % 2 == 0 else "TIER_3", - "size": 1024 * (i + 1), - "tag": "deprecated" if i < 2 else "default", - "content": f"# Module {i}", - }) - - context.context_data[view_name] = { - "entries": entries, - "budget": { - "used": sum(e["size"] for e in entries), - "total": 100000, - }, - } - - -@given('the view "{view_name}" has {count:d} context entries') -def step_view_has_entries_with_filters(context: Any, view_name: str, count: int) -> None: - """Set up a view with specified number of context entries.""" - entries = [] - for i in range(count): - entries.append({ - "path": f"src/module{i}.py", - "tier": "TIER_1" if i % 2 == 0 else "TIER_3", - "size": 1024 * (i + 1), - "tag": "deprecated" if i < 2 else "default", - "content": f"# Module {i}", - }) - - context.context_data[view_name] = { - "entries": entries, - "budget": { - "used": sum(e["size"] for e in entries), - "total": 100000, - }, - } - context.entries_to_remove = 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}"') @@ -114,168 +89,270 @@ def step_entries_have_tag(context: Any, count: int, tag: str) -> None: context.tag_match_count = count -@given('entries are in tier "{tier}"') -def step_entries_in_tier(context: Any, tier: str) -> None: +@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.""" + """Run the context show command and capture real output.""" from cleveragents.cli.commands.acms_context import acms_context_show - from unittest.mock import patch - - with patch('cleveragents.cli.commands.acms_context.get_container') as mock_container: - mock_acms = MagicMock() - mock_acms.get_assembled_context.return_value = context.context_data.get(view) - mock_container.return_value.acms_service.return_value = mock_acms - + + 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_output = "Success" - except SystemExit: - context.command_output = "Command executed" + context.command_exit_code = 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 - from unittest.mock import patch - - with patch('cleveragents.cli.commands.acms_context.get_container') as mock_container: - with patch('typer.confirm', return_value=True): - mock_acms = MagicMock() - mock_acms.get_context_entries.return_value = context.entries_to_remove - mock_acms.clear_context_entries.return_value = len(context.entries_to_remove) - mock_container.return_value.acms_service.return_value = mock_acms - - try: - acms_context_clear() - context.command_output = "Success" - except SystemExit: - context.command_output = "Command executed" + + 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 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 - from unittest.mock import patch - - with patch('cleveragents.cli.commands.acms_context.get_container') as mock_container: - mock_acms = MagicMock() - mock_acms.get_context_entries.return_value = context.entries_to_remove - mock_acms.clear_context_entries.return_value = len(context.entries_to_remove) - mock_container.return_value.acms_service.return_value = mock_acms - + + 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_output = "Success" - except SystemExit: - context.command_output = "Command executed" + context.command_exit_code = 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 - from unittest.mock import patch - - with patch('cleveragents.cli.commands.acms_context.get_container') as mock_container: - mock_acms = MagicMock() - filtered_entries = [e for e in context.entries_to_remove if pattern.replace("*", "") in e["path"]] - mock_acms.get_context_entries.return_value = filtered_entries - mock_acms.clear_context_entries.return_value = len(filtered_entries) - mock_container.return_value.acms_service.return_value = mock_acms - + + 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_output = "Success" - except SystemExit: - context.command_output = "Command executed" + context.command_exit_code = 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 - from unittest.mock import patch - - with patch('cleveragents.cli.commands.acms_context.get_container') as mock_container: - mock_acms = MagicMock() - filtered_entries = [e for e in context.entries_to_remove if e["tag"] == tag] - mock_acms.get_context_entries.return_value = filtered_entries - mock_acms.clear_context_entries.return_value = len(filtered_entries) - mock_container.return_value.acms_service.return_value = mock_acms - + + 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_output = "Success" - except SystemExit: - context.command_output = "Command executed" + context.command_exit_code = 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 - from unittest.mock import patch - - with patch('cleveragents.cli.commands.acms_context.get_container') as mock_container: - mock_acms = MagicMock() - filtered_entries = [e for e in context.entries_to_remove if e["tier"] == tier] - mock_acms.get_context_entries.return_value = filtered_entries - mock_acms.clear_context_entries.return_value = len(filtered_entries) - mock_container.return_value.acms_service.return_value = mock_acms - + + 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_output = "Success" - except SystemExit: - context.command_output = "Command executed" + context.command_exit_code = 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_no_match(context: Any, pattern: str) -> None: - """Run the context clear command with non-matching filter.""" - from cleveragents.cli.commands.acms_context import acms_context_clear - from unittest.mock import patch - - with patch('cleveragents.cli.commands.acms_context.get_container') as mock_container: - mock_acms = MagicMock() - mock_acms.get_context_entries.return_value = [] - mock_acms.clear_context_entries.return_value = 0 - mock_container.return_value.acms_service.return_value = mock_acms - - try: - acms_context_clear(path=pattern, yes=True) - context.command_output = "Success" - except SystemExit: - context.command_output = "Command executed" - - -@when('I run "agents acms context show --help"') +@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.""" - context.command_output = "Display the assembled context for a specific ACMS view" + 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 --help"') +@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.""" - context.command_output = "Remove stale context entries from the ACMS index" + 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('the output should contain "{text}"') -def step_output_contains(context: Any, text: str) -> None: - """Check if output contains text.""" - assert text in context.command_output or text in str(context.command_output) - - -@then('no confirmation prompt should be shown') +@then("no confirmation prompt should be shown") def step_no_confirmation_prompt(context: Any) -> None: - """Verify no confirmation prompt was shown.""" - # This is implicitly verified by the --yes flag being used - pass + """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() diff --git a/robot/acms_context_cli.robot b/robot/acms_context_cli.robot new file mode 100644 index 000000000..f9d4c38b7 --- /dev/null +++ b/robot/acms_context_cli.robot @@ -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 diff --git a/robot/helper_acms_context_cli.py b/robot/helper_acms_context_cli.py new file mode 100644 index 000000000..8b94512df --- /dev/null +++ b/robot/helper_acms_context_cli.py @@ -0,0 +1,314 @@ +"""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") +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) + +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 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 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 + 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 " + "" + ) + 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()) diff --git a/src/cleveragents/cli/commands/acms_context.py b/src/cleveragents/cli/commands/acms_context.py index 3940fcbea..65c3c1c07 100644 --- a/src/cleveragents/cli/commands/acms_context.py +++ b/src/cleveragents/cli/commands/acms_context.py @@ -8,24 +8,24 @@ Canonical path: ``agents acms context `` from __future__ import annotations -from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Any +import fnmatch +from typing import TYPE_CHECKING, Annotated import typer -from rich.panel import Panel 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: - from cleveragents.domain.models.acms import ScopedView + pass # Create sub-app for ACMS context commands app = typer.Typer( help="ACMS context management commands (agents acms context)" ) -console = _get_console() def _format_budget_utilization( @@ -35,176 +35,174 @@ def _format_budget_utilization( """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 _normalize_context_entry( - entry: dict[str, Any], -) -> tuple[str, str, int | str, str, str]: - """Convert a context entry into normalized display values.""" - path_value = entry.get("path") - path_text = str(path_value) if path_value is not None else "" - - tier_value = entry.get("tier") - tier_label = str(tier_value) if tier_value is not None else "UNKNOWN" - - size_value = entry.get("size", 0) - size: int | str = size_value if isinstance(size_value, int) else str(size_value) - - tag_value = entry.get("tag") - tag_text = str(tag_value) if tag_value is not None else "default" - - content_value = entry.get("content") - content_text = str(content_value) if content_value is not None else "" +def _format_fragment_row( + fragment: TieredFragment, +) -> tuple[str, str, str, str]: + """Extract display-ready string values from a TieredFragment. - return path_text, tier_label, size, tag_text, content_text + 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/identifier to show assembled context for"), + typer.Argument(help="View name/project to show assembled context for"), ], ) -> None: """Display the assembled context for a specific ACMS view. - - Shows the context that would be sent to an LLM for the given view, - including budget utilization summary. + + Shows the context fragments stored in the hot/warm/cold tiers for + the given project view, including token budget utilization summary. """ - from cleveragents.application.container import get_container - from cleveragents.application.services.acms_service import ACMSService + 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() - acms_service: ACMSService = container.acms_service() + tier_service = container.context_tier_service() - # Get assembled context for the view - context_data = acms_service.get_assembled_context(view) - - if not context_data: - console.print(f"[yellow]No context found for view: {view}[/yellow]") + # 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 - # Extract context entries and budget info - entries = context_data.get("entries", []) - budget_info = context_data.get("budget", {}) - # Display assembled context console.print(f"\n[bold]Assembled Context for View: {view}[/bold]") - - if entries: - # Create table for context entries - table = Table(title=f"Context Entries ({len(entries)} total)") - table.add_column("Path", style="cyan") - table.add_column("Tier", style="green") - table.add_column("Size", style="magenta") - table.add_column("Tag", style="yellow") - total_size = 0 - for entry in entries: - path_text, tier_label, size, tag_text, _ = _normalize_context_entry(entry) - - if isinstance(size, int): - total_size += size - size_str = f"{size:,} bytes" - else: - size_str = str(size) + 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") - table.add_row( - path_text, - tier_label, - size_str, - tag_text, - ) + total_tokens = 0 + for fragment in fragments: + resource_id, tier_label, token_str, project = ( + _format_fragment_row(fragment) + ) + total_tokens += fragment.token_count + table.add_row(resource_id, tier_label, token_str, project) - console.print(table) - console.print(f"\n[bold]Total Size:[/bold] {total_size:,} bytes") - else: - console.print("[yellow]No context entries found.[/yellow]") + console.print(table) + console.print(f"\n[bold]Total Tokens:[/bold] {total_tokens:,}") - # Display budget utilization - if budget_info: - used = budget_info.get("used", 0) - total = budget_info.get("total", 0) - utilization = _format_budget_utilization(used, total) - - console.print(f"\n[bold]Budget Utilization:[/bold]") - console.print(f" Used: {used:,} tokens") - console.print(f" Total: {total:,} tokens") - console.print(f" Utilization: {utilization}") + # Display tier metrics + metrics = tier_service.get_metrics() + budget = tier_service.budget + utilization = _format_budget_utilization( + metrics.hot_count * 100, budget.max_tokens_hot or 1 + ) + console.print("\n[bold]Budget Utilization:[/bold]") + console.print(f" Hot tier: {metrics.hot_count} fragments") + console.print(f" Warm tier: {metrics.warm_count} fragments") + console.print(f" Cold tier: {metrics.cold_count} fragments") + console.print(f" Hot utilization: {utilization}") except CleverAgentsError as e: - console.print(f"[red]Error:[/red] {e.message}") - raise typer.Abort() from e + console = _get_console() + console.print(f"[red]Error:[/red] {e!s}") + raise typer.Exit(code=1) from e except Exception as e: - console.print(f"[red]Error:[/red] Failed to retrieve context for view '{view}': {str(e)}") - raise typer.Abort() from e + 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 path pattern"), + typer.Option("--path", help="Filter by resource path pattern (glob)"), ] = None, tag: Annotated[ str | None, - typer.Option("--tag", help="Filter by tag"), + typer.Option("--tag", help="Filter by metadata tag value"), ] = None, tier: Annotated[ str | None, - typer.Option("--tier", help="Filter by tier"), + 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 entries by path, tag, or tier filters. Requires confirmation - unless --yes flag is provided. + + Removes context fragments by path pattern, metadata tag, or tier. + Requires confirmation unless --yes flag is provided. """ - from cleveragents.application.container import get_container - from cleveragents.application.services.acms_service import ACMSService + console = _get_console() try: container = get_container() - acms_service: ACMSService = container.acms_service() + tier_service = container.context_tier_service() - # Build filter criteria - filters: dict[str, Any] = {} - if path: - filters["path"] = path - if tag: - filters["tag"] = tag - if tier: - filters["tier"] = tier + # 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) - # Get entries that would be removed - entries_to_remove = acms_service.get_context_entries(filters) - if not entries_to_remove: - console.print("[yellow]No context entries match the specified filters.[/yellow]") + console.print( + "[yellow]No context entries match the specified filters.[/yellow]" + ) return # Show what will be removed - console.print(f"\n[bold]Context entries to remove ({len(entries_to_remove)} total):[/bold]") - - table = Table() - table.add_column("Path", style="cyan") - table.add_column("Tier", style="green") - table.add_column("Tag", style="yellow") + console.print( + f"\n[bold]Context entries to remove " + f"({len(entries_to_remove)} total):[/bold]" + ) - for entry in entries_to_remove[:10]: # Show first 10 - path_text, tier_label, _, tag_text, _ = _normalize_context_entry(entry) - table.add_row(path_text, tier_label, tag_text) + 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") + console.print( + f" ... and {len(entries_to_remove) - 10} more entries" + ) # Confirm if needed if not yes: @@ -215,24 +213,104 @@ def acms_context_clear( console.print("[yellow]Cancelled.[/yellow]") raise typer.Exit(0) - # Remove entries - removed_count = acms_service.clear_context_entries(filters) - console.print(f"\n[green]✓[/green] Removed {removed_count} context entries.") + # 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: - console.print(f"[red]Error:[/red] {e.message}") - raise typer.Abort() from e + console = _get_console() + console.print(f"[red]Error:[/red] {e!s}") + raise typer.Exit(code=1) from e except Exception as e: - console.print(f"[red]Error:[/red] Failed to clear context entries: {str(e)}") - raise typer.Abort() from e + 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: object, + 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 mock). + fragments: Fragments to remove. + + Returns: + Number of fragments successfully removed. + """ + remove_fn = getattr(tier_service, "_remove_from_all", None) + lock = getattr(tier_service, "_lock", None) + if remove_fn is None: + return 0 + + removed = 0 + for fragment in fragments: + if lock is not None: + with lock: + remove_fn(fragment.fragment_id) + else: + remove_fn(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 - Display assembled context for a view") console.print(" clear - Remove context entries with filtering") - console.print("\nUse 'agents acms context --help' for more information.") + console.print( + "\nUse 'agents acms context --help' for more information." + )