feat(cli): add actor context clear command #6470

Merged
HAL9000 merged 2 commits from feat/issue-6370-actor-context-clear into master 2026-05-05 01:33:32 +00:00
4 changed files with 325 additions and 14 deletions
+4 -2
View File
@@ -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 <plan-id>` and `agents plan checkpoint-delete <checkpoint-id>` 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 <id>` 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
+38 -1
View File
@@ -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
+115
View File
@@ -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"
+168 -11
View File
@@ -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:<hex>`` 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()