diff --git a/CHANGELOG.md b/CHANGELOG.md index 0663943a4..7840cab52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `agents actor context clear` command to reset actor message history and + state while preserving the underlying context directory via `ContextManager` + (#6370). - **Plan checkpoint management CLI commands** (#8683): Added `agents plan checkpoint-list ` and `agents plan checkpoint-delete ` commands. Listing output now highlights checkpoint ID, type, created timestamp, reason, phase, and decision linkage with a concise field summary footer across rich/table/json/yaml formats. Deletion supports batch IDs, interactive confirmation (skip with `--yes`), and structured JSON/YAML responses for automation-friendly scripting. - **Invariant Remove CLI Command** (#8530): Implemented `agents invariant remove ` command that soft-deletes an invariant by ID. The command displays a confirmation prompt before removal (bypassable with `--yes`/`-y`), outputs the removed invariant ID on success, and shows a clear error message when the invariant ID does not exist. Supports `--format` flag for JSON and YAML output. Full BDD test coverage and Robot Framework integration tests included. @@ -223,8 +226,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). parent/child structure) during Execute instead of rebuilding from `definition_of_done`. `StrategizeStubActor.execute` accepts `**kwargs` for forward-compatibility. Added BDD coverage for the stored-JSON path, - corrupt-JSON fallback, resource-passing, and stub extra-kwargs scenarios. - (#828) + corrupt-JSON fallback, resource-passing, and stub extra-kwargs scenarios. (#828) - **TDD Issue-Capture Test Activation** (#7025): Replaced 234 bare `@skip` tags diff --git a/features/actor_context_cmds.feature b/features/actor_context_cmds.feature index 6412514b2..2e716f9bd 100644 --- a/features/actor_context_cmds.feature +++ b/features/actor_context_cmds.feature @@ -1,4 +1,4 @@ -Feature: Actor context remove, export, and import commands +Feature: Actor context clear, remove, export, and import commands As a CleverAgents user I want to manage actor contexts via the CLI So that I can remove, export, and import conversation contexts @@ -6,6 +6,43 @@ Feature: Actor context remove, export, and import commands Background: Given a temporary context directory for actor context tests + # ── context clear ───────────────────────────────────────── + + Scenario: Clear a named actor context + Given an actor context named "docs" exists with messages + When I run actor context clear "docs" with --yes + Then the actor context clear command should succeed + And the context "docs" should exist + And the context "docs" should be empty + + Scenario: Clear all actor contexts + Given an actor context named "docs" exists with messages + And an actor context named "notes" exists with messages + When I run actor context clear --all with --yes + Then the actor context clear command should succeed + And all actor contexts should be empty + + Scenario: Clear non-existent context fails + When I run actor context clear "ghost" with --yes + Then the actor context clear command should fail with exit code 1 + + Scenario: Clear requires NAME or --all + When I run actor context clear without name or all + Then the actor context clear command should fail with exit code 1 + + Scenario: Clear rejects NAME with --all + When I run actor context clear "docs" with --all + Then the actor context clear command should fail with exit code 1 + + Scenario: Clear outputs JSON format + Given an actor context named "docs" exists with messages + When I run actor context clear "docs" with --yes and format "json" + Then the actor context clear command should succeed + And the output should contain valid JSON with key "context_cleared" + And the output JSON key "context_cleared" should contain keys "items, storage" + And the output should contain valid JSON with key "retention" + And the output JSON key "retention" should contain keys "context, files" + # ── context remove ───────────────────────────────────────── Scenario: Remove a named actor context diff --git a/features/steps/actor_context_cmds_steps.py b/features/steps/actor_context_cmds_steps.py index cd831971a..16925aa17 100644 --- a/features/steps/actor_context_cmds_steps.py +++ b/features/steps/actor_context_cmds_steps.py @@ -105,6 +105,59 @@ def step_create_json_file_with_name(context, name): context.import_file.write_text(json.dumps(data, indent=2), encoding="utf-8") +# --------------------------------------------------------------------------- +# When — clear +# --------------------------------------------------------------------------- + + +@when('I run actor context clear "{name}" with --yes') +def step_clear_named_yes(context, name): + context.result = context.runner.invoke( + actor_context_app, + ["clear", name, "--yes", "--context-dir", str(context.context_dir)], + ) + + +@when("I run actor context clear --all with --yes") +def step_clear_all_yes(context): + context.result = context.runner.invoke( + actor_context_app, + ["clear", "--all", "--yes", "--context-dir", str(context.context_dir)], + ) + + +@when('I run actor context clear "{name}" with --yes and format "{fmt}"') +def step_clear_named_format(context, name, fmt): + context.result = context.runner.invoke( + actor_context_app, + [ + "clear", + name, + "--yes", + "--context-dir", + str(context.context_dir), + "--format", + fmt, + ], + ) + + +@when("I run actor context clear without name or all") +def step_clear_no_args(context): + context.result = context.runner.invoke( + actor_context_app, + ["clear", "--context-dir", str(context.context_dir)], + ) + + +@when('I run actor context clear "{name}" with --all') +def step_clear_name_and_all(context, name): + context.result = context.runner.invoke( + actor_context_app, + ["clear", name, "--all", "--context-dir", str(context.context_dir)], + ) + + # --------------------------------------------------------------------------- # When — remove # --------------------------------------------------------------------------- @@ -330,6 +383,15 @@ def step_roundtrip_import(context, name): # --------------------------------------------------------------------------- +@then("the actor context clear command should succeed") +def step_clear_success(context): + assert context.result.exit_code == 0, ( + f"Expected exit 0, got {context.result.exit_code}.\n" + f"stdout: {context.result.output}\n" + f"stderr: {getattr(context.result, 'stderr', '')}" + ) + + @then("the actor context remove command should succeed") def step_remove_success(context): assert context.result.exit_code == 0, ( @@ -357,6 +419,15 @@ def step_import_success(context): ) +@then("the actor context clear command should fail with exit code 1") +def step_clear_fail(context): + assert context.result.exit_code == 1, ( + f"Expected exit 1, got {context.result.exit_code}.\n" + f"stdout: {context.result.output}\n" + f"stderr: {getattr(context.result, 'stderr', '')}" + ) + + @then("the actor context remove command should fail with exit code 1") def step_remove_fail(context): assert context.result.exit_code == 1, ( @@ -409,6 +480,35 @@ def step_context_exists_check(context, name): assert mgr.exists(), f"Context '{name}' does not exist at {mgr.context_dir}" +@then('the context "{name}" should be empty') +def step_context_empty(context, name): + mgr = ContextManager(name, context.context_dir) + assert len(mgr.messages) == 0, ( + f"Context '{name}' still has messages: {mgr.messages}" + ) + assert mgr.state == {}, f"Context '{name}' state not cleared: {mgr.state}" + assert mgr.global_context == {}, ( + f"Context '{name}' global context not cleared: {mgr.global_context}" + ) + + +@then("all actor contexts should be empty") +def step_all_contexts_empty(context): + for ctx_path in context.context_dir.iterdir(): + if not ctx_path.is_dir(): + continue + mgr = ContextManager(ctx_path.name, context.context_dir) + assert len(mgr.messages) == 0, ( + f"Context '{ctx_path.name}' still has messages: {mgr.messages}" + ) + assert mgr.state == {}, ( + f"Context '{ctx_path.name}' state not cleared: {mgr.state}" + ) + assert mgr.global_context == {}, ( + f"Context '{ctx_path.name}' global context not cleared: {mgr.global_context}" + ) + + # --------------------------------------------------------------------------- # Then — output assertions # --------------------------------------------------------------------------- @@ -422,6 +522,21 @@ def step_output_json_key(context, key): assert key in data, f"Key '{key}' not found in JSON output: {data.keys()}" +@then('the output JSON key "{top_key}" should contain keys "{keys}"') +def step_output_json_nested_keys(context, top_key, keys): + parsed = json.loads(context.result.output) + data = _unwrap_envelope(parsed) + assert top_key in data, ( + f"Key '{top_key}' not found in JSON output: {list(data.keys())}" + ) + nested = data[top_key] + expected_keys = [k.strip() for k in keys.split(",")] + missing = [key for key in expected_keys if key and key not in nested] + assert not missing, ( + f"Keys {missing} not found in nested output for '{top_key}': {list(nested.keys())}" + ) + + @then("the exported file should exist and contain valid JSON") def step_exported_json_valid(context): assert context.export_file.exists(), f"Export file {context.export_file} not found" diff --git a/src/cleveragents/cli/commands/actor_context.py b/src/cleveragents/cli/commands/actor_context.py index 948a2ff21..c90d33e49 100644 --- a/src/cleveragents/cli/commands/actor_context.py +++ b/src/cleveragents/cli/commands/actor_context.py @@ -1,16 +1,18 @@ """Actor-scoped context management commands. -Implements ``agents actor context remove``, ``agents actor context export``, -and ``agents actor context import`` per the v3 specification. These commands -manage named conversation contexts stored under ``~/.cleveragents/context/`` -using the :class:`~cleveragents.reactive.context_manager.ContextManager` -persistence layer. +Implements ``agents actor context clear``, ``agents actor context remove``, +``agents actor context export``, and ``agents actor context import`` per the +v3 specification. These commands manage named conversation contexts stored +under ``~/.cleveragents/context/`` using the +:class:`~cleveragents.reactive.context_manager.ContextManager` persistence +layer. """ from __future__ import annotations import hashlib import json +import shutil from pathlib import Path from typing import Annotated, Any @@ -58,6 +60,14 @@ def _context_size_kb(ctx_mgr: ContextManager) -> float: return round(total / 1024, 1) +def _format_storage_value(kilobytes: float) -> str: + """Return a human-readable storage string like ``"48 KB freed"``.""" + rounded = round(kilobytes, 1) + if rounded.is_integer(): + return f"{int(rounded)} KB freed" + return f"{rounded:.1f} KB freed" + + def _file_checksum(path: Path) -> str: """Return ``sha256:`` checksum for a file.""" h = hashlib.sha256() @@ -133,8 +143,6 @@ def context_remove( agents actor context remove docs agents actor context remove --all --yes """ - import shutil - if name and all_contexts: typer.echo("Error: Cannot specify NAME when using --all", err=True) raise typer.Exit(code=1) @@ -231,6 +239,147 @@ def context_remove( _render_output(data, fmt, rich_panels=panels, ok_message="Context updated") +@app.command("clear") +def context_clear( + name: Annotated[ + str | None, + typer.Argument(help="Context name to clear"), + ] = None, + all_contexts: Annotated[ + bool, + typer.Option("--all", "-a", help="Clear all contexts"), + ] = False, + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip confirmation prompt"), + ] = False, + context_dir: Annotated[ + Path | None, + typer.Option( + "--context-dir", + help="Directory where contexts are stored", + resolve_path=True, + ), + ] = None, + fmt: Annotated[ + str, + typer.Option("--format", "-f", help=_FORMAT_HELP), + ] = "rich", +) -> None: + """Clear message history for a named actor context or all contexts. + + The context directory is preserved while messages, metadata, and state are + reset to their initial empty values. + + Examples:: + + agents actor context clear docs + agents actor context clear --all --yes + """ + + if name and all_contexts: + typer.echo("Error: Cannot specify NAME when using --all", err=True) + raise typer.Exit(code=1) + + if not name and not all_contexts: + typer.echo("Error: Must specify NAME or use --all", err=True) + raise typer.Exit(code=1) + + base = _default_context_base(context_dir) + + contexts: list[str] + if all_contexts: + contexts = _list_context_names(base) + if not contexts: + typer.echo("No contexts found to clear.") + return + + if not yes: + typer.echo(f"Found {len(contexts)} context(s) to clear:") + for cname in contexts: + typer.echo(f" - {cname}") + if not typer.confirm("Clear all?"): + typer.echo("Clear cancelled.") + return + else: + assert name is not None + target = base / name + if not target.exists(): + typer.echo(f"Error: Context '{name}' does not exist.", err=True) + raise typer.Exit(code=1) + + if not yes and not typer.confirm(f"Clear context '{name}'?"): + typer.echo("Clear cancelled.") + return + + contexts = [name] + + cleared_stats: dict[str, tuple[int, float]] = {} + total_messages_removed = 0 + total_storage_freed = 0.0 + + for cname in contexts: + manager = ContextManager(cname, context_dir) + messages_before = len(manager.messages) + size_before = _context_size_kb(manager) + manager.clear() + size_after = _context_size_kb(manager) + freed = max(size_before - size_after, 0.0) + + cleared_stats[cname] = (messages_before, freed) + total_messages_removed += messages_before + total_storage_freed += freed + + cleared_context_count = len(contexts) + context_label = "all" if all_contexts else contexts[0] + + if all_contexts: + items_value = f"{total_messages_removed} removed" + storage_value = _format_storage_value(total_storage_freed) + else: + messages_removed, storage_freed = cleared_stats[context_label] + items_value = f"{messages_removed} removed" + storage_value = _format_storage_value(storage_freed) + + data: dict[str, Any] = { + "context_cleared": { + "context": context_label, + "items": items_value, + "storage": storage_value, + }, + "retention": { + "context": "preserved", + "files": "cleared", + }, + } + + if all_contexts: + context_body = ( + f"[bold]Context:[/bold] all ({cleared_context_count} cleared)\n" + f"[bold]Items:[/bold] {items_value}\n" + f"[bold]Storage:[/bold] {storage_value}" + ) + else: + context_body = ( + f"[bold]Context:[/bold] {context_label}\n" + f"[bold]Items:[/bold] {items_value}\n" + f"[bold]Storage:[/bold] {storage_value}" + ) + + panels = [ + ( + "Context Cleared", + context_body, + ), + ( + "Retention", + "[bold]Context:[/bold] preserved\n[bold]Files:[/bold] cleared", + ), + ] + + _render_output(data, fmt, rich_panels=panels, ok_message="Context cleared") + + @app.command("export") def context_export( name: Annotated[ @@ -238,14 +387,14 @@ def context_export( typer.Argument(help="Context name to export"), ], output: Annotated[ - Path, + Path | None, typer.Option( "--output", "-o", help="Output file path (JSON or YAML)", resolve_path=True, ), - ] = ..., # type: ignore[assignment] + ] = None, context_dir: Annotated[ Path | None, typer.Option( @@ -270,6 +419,10 @@ def context_export( agents actor context export docs --output /tmp/docs-context.json agents actor context export docs -o ctx.yaml --format json """ + if output is None: + typer.echo("Error: --output is required.", err=True) + raise typer.Exit(code=1) + base = _default_context_base(context_dir) if not (base / name).exists(): typer.echo(f"Error: Context '{name}' does not exist.", err=True) @@ -339,7 +492,7 @@ def context_import( ), ] = None, input_file: Annotated[ - Path, + Path | None, typer.Option( "--input", "-i", @@ -350,7 +503,7 @@ def context_import( readable=True, resolve_path=True, ), - ] = ..., # type: ignore[assignment] + ] = None, update: Annotated[ bool, typer.Option("--update", help="Replace existing context with same name"), @@ -379,6 +532,10 @@ def context_import( agents actor context import docs --input /tmp/docs-context.json agents actor context import --input ctx.yaml --update """ + if input_file is None: + typer.echo("Error: --input is required.", err=True) + raise typer.Exit(code=1) + # Parse input file (JSON or YAML) text = input_file.read_text(encoding="utf-8") suffix = input_file.suffix.lower()