feat(cli): add actor context show command #6622

Merged
HAL9000 merged 6 commits from feat/issue-6369-actor-context-show into master 2026-06-19 06:42:03 +00:00
5 changed files with 348 additions and 20 deletions
+1
View File
@@ -6,6 +6,7 @@ Changed `wf10_batch.robot` to be less likely to create files, and
`plan_generation_graph.robot` to give more test answers.
## [Unreleased]
- **feat(cli): add `agents actor context show` command** (#6369 / PR #6622): Adds `agents actor context show <name>` to display a named actor context's summary, messages, metadata, state, and global context. Supports `--format` (rich/json/yaml/plain/table/color) and `--context-dir` options. Returns exit code 1 for non-existent contexts. Hoists `_default_context_base()` into the show module and re-imports it from `actor_context.py` to remove the duplicated base-path resolution. Adds `command=` kwargs to existing `_render_output` calls in `actor_context.py` so JSON/YAML envelopes report the originating subcommand.
- **docs(spec): fix checkpoint config key path and trigger name defaults** (#5009 / PR #5163): Corrects the Configuration Reference table entry `sandbox.checkpoint.auto-create-on``core.checkpoints.auto-create-on`, matching the implementation in `config_service.py`. Aligns the default trigger-name values (`before_tool_execute`, `after_tool_execute`) with the implementation in `tool/runner.py`, resolving specimplementation discrepancies identified in issue #5009.
- **docs: module guides for Sandbox & Checkpoint, Correction Attempts, and Invariant Reconciliation** (#4848): Added three comprehensive module guides covering purpose, core classes, lifecycle diagrams, exception hierarchies, CLI usage, and ADR links for `SandboxManager`, `CorrectionAttemptManager`, and `InvariantReconciliationActor`. Includes security callouts for `NoSandbox` bypass (permanent writes, no rollback), `guidance` prompt-injection risk, `archived_artifacts_path` provenance, and `non_overridable` global invariant access control.
- **feat(context): PriorityContextStrategy** (#9997 / PR #10772): Implements a priority-based context strategy that ranks context fragments by configurable priority scores — default role-based rules (system > tool > user > assistant), exponential recency decay (7-day half-life), and explicit priority tag boost. Supports custom scoring function injection and custom PriorityRule list injection. Registered in the ACMS pipeline under key `priority_context`. `PriorityRule` uses Pydantic `BaseModel` for architecture conformance. Includes 18 BDD scenarios covering all acceptance criteria.
+21
View File
@@ -135,3 +135,24 @@ Feature: Actor context clear, remove, export, and import commands
And I import the context from that JSON file as "roundtrip"
Then the context "roundtrip" should exist
And the imported context should have the same messages as the original
# ── context show ───────────────────────────────────────────
Scenario: Show a context in rich format
Given an actor context named "docs" exists with messages
When I run actor context show "docs" without specifying format
Then the actor context show command should succeed
And the output should include a context summary for "docs"
Scenario: Show a context in JSON format
Given an actor context named "docs" exists with messages
When I run actor context show "docs" with format "json"
Then the actor context show command should succeed
And the output should contain valid JSON with key "context_summary"
And the JSON payload should list 3 messages
And the JSON payload metadata context_name should be "docs"
Scenario: Show non-existent context fails
When I run actor context show "ghost" that does not exist
Then the actor context show command should fail with exit code 1
+83 -1
View File
@@ -1,4 +1,3 @@
# pyright: reportRedeclaration=false
"""Step definitions for actor context remove/export/import commands."""
from __future__ import annotations
@@ -331,6 +330,42 @@ def step_import_with_update(context, name):
)
# ---------------------------------------------------------------------------
# When — show
# ---------------------------------------------------------------------------
@when('I run actor context show "{name}" without specifying format')
def step_show_context_default(context, name):
context.result = context.runner.invoke(
actor_context_app,
["show", name, "--context-dir", str(context.context_dir)],
)
@when('I run actor context show "{name}" with format "{fmt}"')
def step_show_context_format(context, name, fmt):
context.result = context.runner.invoke(
actor_context_app,
[
"show",
name,
"--context-dir",
str(context.context_dir),
"--format",
fmt,
],
)
@when('I run actor context show "{name}" that does not exist')
def step_show_nonexistent_context(context, name):
context.result = context.runner.invoke(
actor_context_app,
["show", name, "--context-dir", str(context.context_dir)],
)
# ---------------------------------------------------------------------------
# When — round-trip helpers
# ---------------------------------------------------------------------------
@@ -419,6 +454,15 @@ def step_import_success(context):
)
@then("the actor context show command should succeed")
def step_show_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 clear command should fail with exit code 1")
def step_clear_fail(context):
assert context.result.exit_code == 1, (
@@ -455,6 +499,15 @@ def step_import_fail(context):
)
@then("the actor context show command should fail with exit code 1")
def step_show_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 — state assertions
# ---------------------------------------------------------------------------
@@ -522,6 +575,35 @@ def step_output_json_key(context, key):
assert key in data, f"Key '{key}' not found in JSON output: {data.keys()}"
@then('the output should include a context summary for "{name}"')
def step_output_contains_summary(context, name):
output = context.result.output
assert "Context Summary" in output, (
f"Context Summary not found in output:\n{output}"
)
assert name in output, f"Context name '{name}' not found in output:\n{output}"
@then("the JSON payload should list {count:d} messages")
def step_json_message_count(context, count):
parsed = json.loads(context.result.output)
data = _unwrap_envelope(parsed)
messages = data.get("messages")
assert isinstance(messages, list), "messages is not a list"
assert len(messages) == count, f"Expected {count} messages, found {len(messages)}"
@then('the JSON payload metadata context_name should be "{name}"')
def step_json_metadata_context_name(context, name):
parsed = json.loads(context.result.output)
data = _unwrap_envelope(parsed)
metadata = data.get("metadata")
assert isinstance(metadata, dict), "metadata is not a dict"
assert metadata.get("context_name") == name, (
f"Expected metadata.context_name={name!r}, got {metadata.get('context_name')!r}"
)
@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)
+54 -19
View File
@@ -24,6 +24,8 @@ from rich.panel import Panel
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.reactive.context_manager import ContextManager
from .actor_context_show import _default_context_base, register_show_command
app = typer.Typer(
help="Manage manual contexts for actor runs.",
)
@@ -36,13 +38,6 @@ _FORMAT_HELP = "Output format: json, yaml, plain, table, rich, or color (default
# ---------------------------------------------------------------------------
def _default_context_base(context_dir: Path | None) -> Path:
"""Return the base directory where named contexts are stored."""
if context_dir is not None:
return context_dir
return Path.home() / ".cleveragents" / "context"
def _list_context_names(base: Path) -> list[str]:
"""Return sorted list of context names present under *base*."""
if not base.exists():
@@ -83,13 +78,13 @@ def _render_output(
fmt: str,
rich_panels: list[tuple[str, str]] | None = None,
ok_message: str = "",
*,
command: str | None = None,
status: str = "ok",
exit_code: int = 0,
) -> None:
"""Emit command output in the requested format.
"""Emit command output in the requested format."""
For ``rich`` format the *rich_panels* list of ``(title, body)`` pairs
are rendered via :class:`rich.panel.Panel`. For all other formats the
flat *data* dict is passed through :func:`format_output`.
"""
if fmt == OutputFormat.RICH.value and rich_panels:
for title, body in rich_panels:
console.print(Panel(body, title=title, expand=False))
@@ -97,8 +92,18 @@ def _render_output(
console.print(f"[green]✓ OK[/green] {ok_message}")
return
# Machine-readable / non-rich
console.print(format_output(data, fmt))
console.print(
format_output(
data,
fmt,
command=command or "",
status=status,
exit_code=exit_code,
)
)
register_show_command(app, _render_output, _FORMAT_HELP)
# ---------------------------------------------------------------------------
@@ -193,7 +198,13 @@ def context_remove(
"[bold]Remaining Size:[/bold] 0 KB",
),
]
_render_output(data, fmt, rich_panels=panels, ok_message="Context updated")
_render_output(
data,
fmt,
rich_panels=panels,
ok_message="Context updated",
command="agents actor context remove",
)
return
# Single context removal
@@ -236,7 +247,13 @@ def context_remove(
(f"[bold]Remaining Size:[/bold] {remaining_total} KB"),
),
]
_render_output(data, fmt, rich_panels=panels, ok_message="Context updated")
_render_output(
data,
fmt,
rich_panels=panels,
ok_message="Context updated",
command=f"agents actor context remove {name}",
)
@app.command("clear")
@@ -377,7 +394,13 @@ def context_clear(
),
]
_render_output(data, fmt, rich_panels=panels, ok_message="Context cleared")
_render_output(
data,
fmt,
rich_panels=panels,
ok_message="Context cleared",
command=f"agents actor context clear {context_label}",
)
@app.command("export")
@@ -480,7 +503,13 @@ def context_export(
(f"[bold]Checksum:[/bold] {checksum}\n[bold]Compressed:[/bold] no"),
),
]
_render_output(data, fmt, rich_panels=panels, ok_message="Export completed")
_render_output(
data,
fmt,
rich_panels=panels,
ok_message="Export completed",
command=f"agents actor context export {name}",
)
@app.command("import")
@@ -600,4 +629,10 @@ def context_import(
(f"[bold]Strategy:[/bold] {strategy}\n[bold]Conflicts:[/bold] 0"),
),
]
_render_output(data, fmt, rich_panels=panels, ok_message="Import completed")
_render_output(
data,
fmt,
rich_panels=panels,
ok_message="Import completed",
command=f"agents actor context import {resolved_name}",
)
@@ -0,0 +1,189 @@
from __future__ import annotations
import json
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Annotated, Any
import typer
from cleveragents.cli.formatting import OutputFormat
from cleveragents.reactive.context_manager import ContextManager
def _estimate_tokens_for_paths(paths: list[Path]) -> int:
"""Return a coarse token estimate based on file sizes."""
total = 0
for path in paths:
try:
size = path.stat().st_size
except OSError: # pragma: no cover
continue
tokens = size // 4
total += tokens if tokens > 0 else 1
return total
def _format_metadata_timestamp(value: str | None) -> str | None:
"""Return a human-readable timestamp for Rich output."""
if not value: # pragma: no cover
return None
try:
cleaned = value.replace("Z", "+00:00") if value.endswith("Z") else value
dt = datetime.fromisoformat(cleaned)
except (ValueError, TypeError): # pragma: no cover
return value
return dt.strftime("%Y-%m-%d %H:%M")
def _default_context_base(context_dir: Path | None) -> Path:
"""Return the base directory where named contexts are stored."""
if context_dir is not None:
return context_dir
return Path.home() / ".cleveragents" / "context"
def register_show_command(
app: typer.Typer,
render_output: Callable[..., None],
format_help: str,
) -> None:
"""Register the `agents actor context show` command on *app*."""
# Note: we use the ``default=Option(...)`` pattern instead of
# ``Annotated[str, Option(..., help=format_help)]`` because this module
# imports ``from __future__ import annotations``: under PEP 563 typer's
# ``inspect.signature(..., eval_str=True)`` would eval the annotation
# string against module globals where the closure-captured ``format_help``
# is not visible, raising ``NameError: name 'format_help' is not defined``.
format_option = typer.Option(
"rich",
"--format",
"-f",
help=format_help,
)
@app.command("show")
def context_show(
name: Annotated[
str,
typer.Argument(help="Context name to display"),
],
context_dir: Annotated[
Path | None,
typer.Option(
"--context-dir",
help="Directory where contexts are stored",
resolve_path=True,
),
] = None,
fmt: str = format_option,
) -> None:
"""Show the content and metadata for a named actor context."""
context_base = _default_context_base(context_dir)
if not (context_base / name).exists():
typer.echo(f"Error: Context '{name}' does not exist.", err=True)
raise typer.Exit(code=1)
ctx_mgr = ContextManager(name, context_dir)
files = [path for path in ctx_mgr.context_dir.rglob("*") if path.is_file()]
total_size_kb = round(sum(p.stat().st_size for p in files) / 1024, 1)
estimated_tokens = _estimate_tokens_for_paths(files)
created_at = ctx_mgr.metadata.get("created_at")
created_human = _format_metadata_timestamp(created_at)
summary: dict[str, Any] = {
Outdated
Review

The spec for agents actor context show (docs/specification.md) defines the JSON payload as the command metadata plus a data.context_summary object whose fields are context, files, total_size_kb, estimated_tokens, and created. Emitting the raw messages, metadata, state, and global_context blobs here (and adding last_updated/message_count to context_summary) means the CLI no longer matches the published schema, so downstream consumers that follow the spec will break. Could we trim the JSON output back to the documented shape (or move the additional data behind a separate flag)?

The spec for `agents actor context show` (docs/specification.md) defines the JSON payload as the command metadata plus a `data.context_summary` object whose fields are `context`, `files`, `total_size_kb`, `estimated_tokens`, and `created`. Emitting the raw `messages`, `metadata`, `state`, and `global_context` blobs here (and adding `last_updated`/`message_count` to `context_summary`) means the CLI no longer matches the published schema, so downstream consumers that follow the spec will break. Could we trim the JSON output back to the documented shape (or move the additional data behind a separate flag)?
"context": name,
"files": len(files),
"total_size_kb": total_size_kb,
"estimated_tokens": estimated_tokens,
"created": created_at,
"last_updated": ctx_mgr.metadata.get("last_updated"),
"message_count": len(ctx_mgr.messages),
}
data: dict[str, Any] = {
"context_summary": summary,
"messages": ctx_mgr.messages,
"metadata": ctx_mgr.metadata,
Outdated
Review

Spec’s rich/plain examples for this command show Estimated Tokens: ~6,600 with thousands separators. Using ~{estimated_tokens} yields ~6600, which diverges from that contract. Could we format this with a thousands separator (e.g. estimated_tokens:,) to stay spec-consistent?

Spec’s rich/plain examples for this command show `Estimated Tokens: ~6,600` with thousands separators. Using `~{estimated_tokens}` yields `~6600`, which diverges from that contract. Could we format this with a thousands separator (e.g. `estimated_tokens:,`) to stay spec-consistent?
"state": ctx_mgr.state,
"global_context": ctx_mgr.global_context,
}
rich_panels: list[tuple[str, str]] | None = None
if fmt == OutputFormat.RICH.value:
summary_lines = [
f"[bold]Context:[/bold] {name}",
f"[bold]Files:[/bold] {len(files)}",
f"[bold]Total Size:[/bold] {total_size_kb} KB",
f"[bold]Estimated Tokens:[/bold] ~{estimated_tokens:,}",
f"[bold]Messages:[/bold] {len(ctx_mgr.messages)}",
]
if created_human:
summary_lines.append(f"[bold]Created:[/bold] {created_human}")
elif created_at: # pragma: no cover
summary_lines.append(f"[bold]Created:[/bold] {created_at}")
last_updated = summary.get("last_updated")
last_updated_human = _format_metadata_timestamp(last_updated)
if last_updated_human:
summary_lines.append(f"[bold]Last Updated:[/bold] {last_updated_human}")
elif last_updated: # pragma: no cover
summary_lines.append(f"[bold]Last Updated:[/bold] {last_updated}")
metadata_lines = [
f"[bold]{key}:[/bold] {value}"
for key, value in sorted(ctx_mgr.metadata.items())
]
def _truncate_content(text: str, limit: int = 180) -> str:
return text if len(text) <= limit else text[: limit - 3] + "..."
message_lines: list[str] = []
max_messages = 10
for idx, message in enumerate(ctx_mgr.messages, start=1):
if idx > max_messages: # pragma: no cover
remaining = len(ctx_mgr.messages) - max_messages
msg_suffix = "s" if remaining != 1 else ""
message_lines.append(
f"(and {remaining} more message{msg_suffix}...)"
)
break
role = message.get("role", "unknown")
timestamp = message.get("timestamp", "")
content = _truncate_content(message.get("content", ""))
message_lines.append(
f"[bold]{idx}. {role}[/bold] {timestamp}\n{content}"
)
state_text = json.dumps(ctx_mgr.state, indent=2, default=str)
global_context_text = json.dumps(
ctx_mgr.global_context, indent=2, default=str
)
rich_panels = [
("Context Summary", "\n".join(summary_lines)),
(
"Metadata",
"\n".join(metadata_lines) if metadata_lines else "(empty)",
),
]
if message_lines:
rich_panels.append(("Messages", "\n\n".join(message_lines)))
if ctx_mgr.state: # pragma: no cover
rich_panels.append(("State", state_text))
if ctx_mgr.global_context: # pragma: no cover
rich_panels.append(("Global Context", global_context_text))
command_str = f"agents actor context show {name}"
render_output(
data,
fmt,
rich_panels=rich_panels,
ok_message="Context displayed",
command=command_str,
)