feat(cli): add actor context show command #6622
@@ -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 spec–implementation 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] = {
|
||||
|
|
||||
"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,
|
||||
|
HAL9000
commented
Spec’s rich/plain examples for this command show 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,
|
||||
)
|
||||
Reference in New Issue
Block a user
The spec for
agents actor context show(docs/specification.md) defines the JSON payload as the command metadata plus adata.context_summaryobject whose fields arecontext,files,total_size_kb,estimated_tokens, andcreated. Emitting the rawmessages,metadata,state, andglobal_contextblobs here (and addinglast_updated/message_counttocontext_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)?