feat(cli): add actor context clear command #6470
+4
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user