diff --git a/features/steps/acms_context_cli_steps.py b/features/steps/acms_context_cli_steps.py index 27de8ada2..227b54e12 100644 --- a/features/steps/acms_context_cli_steps.py +++ b/features/steps/acms_context_cli_steps.py @@ -7,6 +7,7 @@ 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 @@ -131,6 +132,10 @@ def step_run_context_show(context: Any, view: str) -> None: 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 @@ -169,6 +174,10 @@ def step_run_context_clear_with_confirm(context: Any) -> None: 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 @@ -208,6 +217,10 @@ def step_run_context_clear_yes(context: Any) -> None: 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 @@ -245,6 +258,10 @@ def step_run_context_clear_path(context: Any, pattern: str) -> None: 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 @@ -282,6 +299,10 @@ def step_run_context_clear_tag(context: Any, tag: str) -> None: 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 @@ -318,6 +339,10 @@ def step_run_context_clear_tier(context: Any, tier: str) -> None: 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 diff --git a/robot/helper_acms_context_cli.py b/robot/helper_acms_context_cli.py index 923121d01..dafd7168e 100644 --- a/robot/helper_acms_context_cli.py +++ b/robot/helper_acms_context_cli.py @@ -24,13 +24,18 @@ 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) +# 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 not in sys.path: - sys.path.insert(0, _FEATURES) +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, @@ -88,6 +93,8 @@ def _run_show( 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 @@ -125,6 +132,8 @@ def _run_clear( 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 diff --git a/src/cleveragents/cli/commands/acms_context.py b/src/cleveragents/cli/commands/acms_context.py index c458dee19..7dfb28cf2 100644 --- a/src/cleveragents/cli/commands/acms_context.py +++ b/src/cleveragents/cli/commands/acms_context.py @@ -9,7 +9,8 @@ Canonical path: ``agents acms context `` from __future__ import annotations import fnmatch -from typing import TYPE_CHECKING, Annotated +import logging +from typing import TYPE_CHECKING, Annotated, Protocol, runtime_checkable import typer from rich.table import Table @@ -22,6 +23,25 @@ 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)") @@ -93,31 +113,49 @@ def acms_context_show( 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 + # Display tier metrics using actual token counts per tier metrics = tier_service.get_metrics() budget = tier_service.budget - utilization = _format_budget_utilization( - metrics.hot_count * 100, budget.max_tokens_hot or 1 - ) + utilization = _format_budget_utilization(hot_tokens, 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 tier: {metrics.hot_count} fragments ({hot_tokens:,} tokens)" + ) + console.print( + f" Warm tier: {metrics.warm_count} fragments ({warm_tokens:,} tokens)" + ) + console.print( + f" Cold tier: {metrics.cold_count} fragments ({cold_tokens:,} tokens)" + ) console.print(f" Hot utilization: {utilization}") except CleverAgentsError as e: + _logger.exception("ACMS context show failed for view %r", view) console = _get_console() - console.print(f"[red]Error:[/red] {e!s}") + 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}'." @@ -176,6 +214,14 @@ def acms_context_clear( ) 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 " @@ -196,24 +242,30 @@ def acms_context_clear( if len(entries_to_remove) > 10: console.print(f" ... and {len(entries_to_remove) - 10} more entries") - # Confirm if needed + # 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]") - raise typer.Exit(0) + 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(f"[red]Error:[/red] {e!s}") + 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 @@ -260,7 +312,7 @@ def _filter_fragments( def _remove_fragments( - tier_service: object, + tier_service: _TierServiceProtocol, fragments: list[TieredFragment], ) -> int: """Remove the given fragments from the tier service. @@ -268,24 +320,21 @@ def _remove_fragments( Removes fragments by calling _remove_from_all on the tier service. Args: - tier_service: The ContextTierService instance (or compatible mock). + tier_service: The ContextTierService instance (or compatible implementation). 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) + tier_service._remove_from_all(fragment.fragment_id) else: - remove_fn(fragment.fragment_id) + tier_service._remove_from_all(fragment.fragment_id) removed += 1 return removed