feat(cli): add actor context show command (#6369)
CI / benchmark-publish (pull_request) Has been skipped
CI / push-validation (pull_request) Successful in 21s
CI / lint (pull_request) Failing after 35s
CI / helm (pull_request) Successful in 30s
CI / quality (pull_request) Successful in 52s
CI / build (pull_request) Successful in 45s
CI / typecheck (pull_request) Successful in 1m2s
CI / security (pull_request) Successful in 1m2s
CI / coverage (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 3m23s
CI / integration_tests (pull_request) Successful in 4m11s
CI / unit_tests (pull_request) Successful in 5m14s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 2s
CI / benchmark-publish (pull_request) Has been skipped
CI / push-validation (pull_request) Successful in 21s
CI / lint (pull_request) Failing after 35s
CI / helm (pull_request) Successful in 30s
CI / quality (pull_request) Successful in 52s
CI / build (pull_request) Successful in 45s
CI / typecheck (pull_request) Successful in 1m2s
CI / security (pull_request) Successful in 1m2s
CI / coverage (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 3m23s
CI / integration_tests (pull_request) Successful in 4m11s
CI / unit_tests (pull_request) Successful in 5m14s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 2s
ISSUES CLOSED: #6369
This commit is contained in:
@@ -98,3 +98,20 @@ Feature: Actor context 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"
|
||||
|
||||
@@ -278,6 +278,34 @@ 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 — round-trip helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -356,6 +384,14 @@ def step_import_success(context):
|
||||
f"stderr: {getattr(context.result, 'stderr', '')}"
|
||||
)
|
||||
|
||||
@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 remove command should fail with exit code 1")
|
||||
def step_remove_fail(context):
|
||||
@@ -421,6 +457,36 @@ def step_output_json_key(context, key):
|
||||
data = _unwrap_envelope(parsed)
|
||||
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 exported file should exist and contain valid JSON")
|
||||
def step_exported_json_valid(context):
|
||||
|
||||
@@ -21,6 +21,7 @@ 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 register_show_command
|
||||
|
||||
app = typer.Typer(
|
||||
help="Manage manual contexts for actor runs.",
|
||||
@@ -73,13 +74,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))
|
||||
@@ -87,8 +88,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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Callable
|
||||
|
||||
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:
|
||||
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:
|
||||
return None
|
||||
try:
|
||||
cleaned = value.replace("Z", "+00:00") if value.endswith("Z") else value
|
||||
dt = datetime.fromisoformat(cleaned)
|
||||
except (ValueError, TypeError):
|
||||
return value
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def register_show_command(
|
||||
app: typer.Typer,
|
||||
render_output: Callable[..., None],
|
||||
format_help: str,
|
||||
) -> None:
|
||||
"""Register the `agents actor context show` command on *app*."""
|
||||
|
||||
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."""
|
||||
|
||||
base = Path(context_dir) if context_dir else Path.home() / ".cleveragents" / "context"
|
||||
if not (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(path.stat().st_size for path 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,
|
||||
"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:
|
||||
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:
|
||||
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:
|
||||
remaining = len(ctx_mgr.messages) - max_messages
|
||||
message_lines.append(
|
||||
f"(and {remaining} more message{'s' if remaining != 1 else ''}...)"
|
||||
)
|
||||
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:
|
||||
rich_panels.append(("State", state_text))
|
||||
|
||||
if ctx_mgr.global_context:
|
||||
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