diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b48bb42d..99894f63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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. diff --git a/features/actor_context_cmds.feature b/features/actor_context_cmds.feature index 2e716f9bd..da6fb8ce5 100644 --- a/features/actor_context_cmds.feature +++ b/features/actor_context_cmds.feature @@ -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 diff --git a/features/steps/actor_context_cmds_steps.py b/features/steps/actor_context_cmds_steps.py index 16925aa17..fd687c506 100644 --- a/features/steps/actor_context_cmds_steps.py +++ b/features/steps/actor_context_cmds_steps.py @@ -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) diff --git a/src/cleveragents/cli/commands/actor_context.py b/src/cleveragents/cli/commands/actor_context.py index c90d33e49..c5c09bd4b 100644 --- a/src/cleveragents/cli/commands/actor_context.py +++ b/src/cleveragents/cli/commands/actor_context.py @@ -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}", + ) diff --git a/src/cleveragents/cli/commands/actor_context_show.py b/src/cleveragents/cli/commands/actor_context_show.py new file mode 100644 index 000000000..c50e46555 --- /dev/null +++ b/src/cleveragents/cli/commands/actor_context_show.py @@ -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, + "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, + )