chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
"""Composable slash-command registry for the interactive REPL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from collections.abc import Callable
|
||||
from itertools import chain
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.agent_harness.session.terminal_access import pop_turn_outcome_hint, session_terminal
|
||||
from surfaces.interactive_shell.command_registry.agents import COMMANDS as AGENTS_COMMANDS
|
||||
from surfaces.interactive_shell.command_registry.alerts import COMMANDS as ALERTS_COMMANDS
|
||||
from surfaces.interactive_shell.command_registry.background_cmds import (
|
||||
COMMANDS as BACKGROUND_COMMANDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.cli_parity import (
|
||||
COMMANDS as PARITY_COMMANDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.diagnostics_cmds import (
|
||||
COMMANDS as DIAGNOSTICS_COMMANDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.gateway_cmds import (
|
||||
COMMANDS as GATEWAY_COMMANDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.help import COMMANDS as HELP_COMMANDS
|
||||
from surfaces.interactive_shell.command_registry.integrations import (
|
||||
COMMANDS as INTEGRATIONS_COMMANDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.investigation import (
|
||||
COMMANDS as INVESTIGATION_COMMANDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.model import COMMANDS as MODEL_COMMANDS
|
||||
from surfaces.interactive_shell.command_registry.model import (
|
||||
switch_llm_provider,
|
||||
switch_reasoning_model,
|
||||
switch_toolcall_model,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.privacy_cmds import (
|
||||
COMMANDS as PRIVACY_COMMANDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.rca_cmds import COMMANDS as RCA_COMMANDS
|
||||
from surfaces.interactive_shell.command_registry.repl_data import (
|
||||
load_llm_settings,
|
||||
load_verified_integrations,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.session_cmds import (
|
||||
COMMANDS as SESSION_COMMANDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.settings_cmds import (
|
||||
COMMANDS as SETTINGS_COMMANDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.suggestions import (
|
||||
format_unknown_slash_message,
|
||||
resolve_literal_slash_typo,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.system import COMMANDS as SYSTEM_COMMANDS
|
||||
from surfaces.interactive_shell.command_registry.tasks_cmds import COMMANDS as TASK_COMMANDS
|
||||
from surfaces.interactive_shell.command_registry.theme import COMMANDS as THEME_COMMANDS
|
||||
from surfaces.interactive_shell.command_registry.tools_cmds import COMMANDS as TOOLS_COMMANDS
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.command_registry.watch_cmds import COMMANDS as WATCH_COMMANDS
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
|
||||
from surfaces.interactive_shell.utils.telemetry.console_capture import capture_console_segment
|
||||
from surfaces.interactive_shell.utils.telemetry.turn_outcome import format_terminal_turn_outcome
|
||||
from tools.interactive_shell.shared import allow_tool
|
||||
|
||||
_MERGED_SEQUENCE = tuple(
|
||||
chain(
|
||||
HELP_COMMANDS,
|
||||
SESSION_COMMANDS,
|
||||
THEME_COMMANDS,
|
||||
BACKGROUND_COMMANDS,
|
||||
SETTINGS_COMMANDS,
|
||||
DIAGNOSTICS_COMMANDS,
|
||||
INTEGRATIONS_COMMANDS,
|
||||
MODEL_COMMANDS,
|
||||
TOOLS_COMMANDS,
|
||||
INVESTIGATION_COMMANDS,
|
||||
RCA_COMMANDS,
|
||||
TASK_COMMANDS,
|
||||
WATCH_COMMANDS,
|
||||
GATEWAY_COMMANDS,
|
||||
PRIVACY_COMMANDS,
|
||||
AGENTS_COMMANDS,
|
||||
ALERTS_COMMANDS,
|
||||
PARITY_COMMANDS,
|
||||
SYSTEM_COMMANDS,
|
||||
)
|
||||
)
|
||||
|
||||
SLASH_COMMANDS: dict[str, SlashCommand] = {cmd.name: cmd for cmd in _MERGED_SEQUENCE}
|
||||
|
||||
# Slash commands that adopt a different session file must record the turn after
|
||||
# the handler settles session identity (see /resume).
|
||||
_DEFER_SLASH_RECORDING: frozenset[str] = frozenset({"/resume"})
|
||||
|
||||
|
||||
def _latest_record_ok(session: Session, kind: str, *, default: bool = True) -> bool:
|
||||
"""Return ``ok`` from the newest history row of ``kind`` after the handler runs."""
|
||||
for entry in reversed(session.history):
|
||||
if entry.get("type") == kind:
|
||||
return bool(entry.get("ok", default))
|
||||
return default
|
||||
|
||||
|
||||
def _latest_slash_record(session: Session) -> dict[str, Any] | None:
|
||||
for entry in reversed(session.history):
|
||||
if entry.get("type") == "slash":
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _attach_slash_analytics(
|
||||
session: Session,
|
||||
command_line: str,
|
||||
*,
|
||||
captured_output: str,
|
||||
) -> None:
|
||||
latest = _latest_slash_record(session)
|
||||
ok = _latest_record_ok(session, "slash")
|
||||
if latest is not None and latest.get("slash_outcome"):
|
||||
response_text = str(latest.get("response_text") or "").strip()
|
||||
else:
|
||||
response_text = format_terminal_turn_outcome(
|
||||
command_line,
|
||||
kind="slash",
|
||||
ok=ok,
|
||||
captured_output=captured_output,
|
||||
outcome_hint=pop_turn_outcome_hint(session),
|
||||
include_captured_on_summary_only=session_terminal(session) is None,
|
||||
)
|
||||
session.complete_latest_record(
|
||||
"slash",
|
||||
response_text=response_text,
|
||||
)
|
||||
|
||||
|
||||
def dispatch_slash(
|
||||
command_line: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
policy_precleared: bool = False,
|
||||
) -> bool:
|
||||
"""Dispatch a slash command line. Returns False iff the REPL should exit.
|
||||
|
||||
When ``policy_precleared`` is True, skip the execution gate (caller already ran
|
||||
:func:`execution_allowed`) and run the handler directly. Only valid for lines
|
||||
the registry resolves to a known command, or bare ``/`` after an equivalent
|
||||
gate for help.
|
||||
"""
|
||||
env_backup = os.environ.get("OPENSRE_INTERACTIVE")
|
||||
if is_tty is False:
|
||||
os.environ["OPENSRE_INTERACTIVE"] = "0"
|
||||
|
||||
stripped = command_line.strip()
|
||||
slash_recorded = False
|
||||
|
||||
def record_slash(
|
||||
*,
|
||||
ok: bool = True,
|
||||
response_text: str | None = None,
|
||||
slash_outcome: str | None = None,
|
||||
) -> None:
|
||||
nonlocal slash_recorded
|
||||
session.record(
|
||||
"slash",
|
||||
stripped,
|
||||
ok=ok,
|
||||
response_text=response_text,
|
||||
slash_outcome=slash_outcome,
|
||||
)
|
||||
slash_recorded = True
|
||||
|
||||
try:
|
||||
with capture_console_segment(console) as get_captured:
|
||||
try:
|
||||
if stripped == "/":
|
||||
from surfaces.interactive_shell.command_registry.help import _cmd_help
|
||||
|
||||
if policy_precleared:
|
||||
record_slash(ok=True)
|
||||
return _cmd_help(session, console, [])
|
||||
|
||||
gate = allow_tool("slash")
|
||||
if not execution_allowed(
|
||||
gate,
|
||||
session=session,
|
||||
console=console,
|
||||
action_summary=stripped,
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
):
|
||||
record_slash(ok=False)
|
||||
return True
|
||||
record_slash(ok=True)
|
||||
return _cmd_help(session, console, [])
|
||||
|
||||
parts = stripped.split()
|
||||
if not parts:
|
||||
return True
|
||||
name = parts[0].lower()
|
||||
if name in ("/watch", "/unwatch"):
|
||||
head = parts[0]
|
||||
body = stripped[len(head) :].strip()
|
||||
try:
|
||||
# Use POSIX mode on all platforms so quoted values are unwrapped
|
||||
# consistently (e.g., --max-cpu "80" -> 80).
|
||||
args = shlex.split(body, posix=True)
|
||||
except ValueError:
|
||||
args = body.split()
|
||||
else:
|
||||
args = parts[1:]
|
||||
cmd = SLASH_COMMANDS.get(name)
|
||||
if cmd is None:
|
||||
typo_message = format_unknown_slash_message(
|
||||
stripped,
|
||||
command_names=tuple(SLASH_COMMANDS),
|
||||
)
|
||||
record_slash(
|
||||
ok=False,
|
||||
response_text=typo_message,
|
||||
slash_outcome="unknown_command",
|
||||
)
|
||||
console.print()
|
||||
console.print(typo_message)
|
||||
return True
|
||||
typo = resolve_literal_slash_typo(stripped, SLASH_COMMANDS)
|
||||
if typo is not None:
|
||||
record_slash(
|
||||
ok=False,
|
||||
response_text=typo.message,
|
||||
slash_outcome=typo.outcome,
|
||||
)
|
||||
console.print()
|
||||
console.print(typo.message)
|
||||
return True
|
||||
if cmd.validate_args is not None:
|
||||
validation_error = cmd.validate_args(args)
|
||||
if validation_error is not None:
|
||||
record_slash(ok=False)
|
||||
console.print(validation_error)
|
||||
return True
|
||||
if policy_precleared:
|
||||
if name not in _DEFER_SLASH_RECORDING:
|
||||
record_slash(ok=True)
|
||||
return cmd.handler(session, console, args)
|
||||
policy = allow_tool("slash")
|
||||
if not execution_allowed(
|
||||
policy,
|
||||
session=session,
|
||||
console=console,
|
||||
action_summary=stripped,
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
):
|
||||
record_slash(ok=False)
|
||||
return True
|
||||
if name not in _DEFER_SLASH_RECORDING:
|
||||
record_slash(ok=True)
|
||||
return cmd.handler(session, console, args)
|
||||
finally:
|
||||
if slash_recorded:
|
||||
_attach_slash_analytics(
|
||||
session,
|
||||
stripped,
|
||||
captured_output=get_captured(),
|
||||
)
|
||||
finally:
|
||||
if is_tty is False:
|
||||
if env_backup is None:
|
||||
del os.environ["OPENSRE_INTERACTIVE"]
|
||||
else:
|
||||
os.environ["OPENSRE_INTERACTIVE"] = env_backup
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SLASH_COMMANDS",
|
||||
"SlashCommand",
|
||||
"dispatch_slash",
|
||||
"load_llm_settings",
|
||||
"load_verified_integrations",
|
||||
"switch_llm_provider",
|
||||
"switch_reasoning_model",
|
||||
"switch_toolcall_model",
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Slash command: ``/fleet`` (registered local AI agent fleet view)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from surfaces.interactive_shell.command_registry.agents.core import COMMANDS
|
||||
|
||||
__all__ = ["COMMANDS"]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Rich presentation for fleet file-write conflict detection results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from rich.markup import escape
|
||||
from rich.table import Table
|
||||
|
||||
import platform.terminal.theme as ui_theme
|
||||
from surfaces.interactive_shell.ui.components.rendering import repl_table
|
||||
from tools.system.fleet_monitoring.conflicts import FileWriteConflict
|
||||
|
||||
_EMPTY_STATE = "no conflicts detected"
|
||||
|
||||
|
||||
def _format_timestamp(timestamp: float) -> str:
|
||||
return datetime.fromtimestamp(timestamp, tz=UTC).strftime("%H:%M:%S UTC")
|
||||
|
||||
|
||||
def render_conflicts(conflicts: list[FileWriteConflict]) -> Table | str:
|
||||
"""Render conflicts as a Rich table, or the empty-state string."""
|
||||
if not conflicts:
|
||||
return _EMPTY_STATE
|
||||
|
||||
table = repl_table(
|
||||
title="Agent file-write conflicts",
|
||||
title_style=ui_theme.BOLD_BRAND,
|
||||
)
|
||||
table.add_column("path", style="bold", overflow="fold")
|
||||
table.add_column("agents", overflow="fold")
|
||||
table.add_column("first seen", style=ui_theme.DIM)
|
||||
table.add_column("last seen", style=ui_theme.DIM)
|
||||
|
||||
for conflict in conflicts:
|
||||
table.add_row(
|
||||
escape(conflict.path),
|
||||
escape(", ".join(conflict.agents)),
|
||||
_format_timestamp(conflict.first_seen),
|
||||
_format_timestamp(conflict.last_seen),
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
__all__ = ["render_conflicts"]
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Slash command: ``/fleet`` (registered local AI agent fleet view).
|
||||
|
||||
Bare ``/fleet`` renders the registered-agents dashboard; subcommands
|
||||
cover ``budget``, ``bus``, ``claim``, ``conflicts``, ``kill``, ``release``,
|
||||
and ``trace`` (with more surfaces planned for monitor-local-agents).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import ValidationError
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.tree import Tree
|
||||
|
||||
from surfaces.interactive_shell.command_registry.agents.conflicts_view import render_conflicts
|
||||
from surfaces.interactive_shell.command_registry.agents.kill import _cmd_agents_kill
|
||||
from surfaces.interactive_shell.command_registry.agents.trace import _cmd_agents_trace
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import (
|
||||
BOLD_BRAND,
|
||||
DIM,
|
||||
ERROR,
|
||||
HIGHLIGHT,
|
||||
WARNING,
|
||||
print_repl_table,
|
||||
render_agents_table,
|
||||
repl_table,
|
||||
)
|
||||
from tools.system.fleet_monitoring.bus import BusMessage, subscribe
|
||||
from tools.system.fleet_monitoring.config import (
|
||||
agents_config_path,
|
||||
load_agents_config,
|
||||
set_agent_budget,
|
||||
)
|
||||
from tools.system.fleet_monitoring.conflicts import (
|
||||
DEFAULT_WINDOW_SECONDS,
|
||||
WriteEvent,
|
||||
detect_conflicts,
|
||||
)
|
||||
from tools.system.fleet_monitoring.coordination import BranchClaims
|
||||
from tools.system.fleet_monitoring.discovery import registered_and_discovered_agents
|
||||
from tools.system.fleet_monitoring.registry import AgentRegistry
|
||||
|
||||
_AGENTS_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("budget", "view or edit per-agent hourly budgets"),
|
||||
("bus", "live-tail the cross-agent context bus"),
|
||||
("claim", "claim a branch for an agent"),
|
||||
("conflicts", "show file-write conflicts between local AI agents"),
|
||||
("kill", "SIGTERM → SIGKILL a local agent by PID"),
|
||||
("release", "release a branch claim"),
|
||||
("trace", "live tail of an agent's stdout by pid"),
|
||||
("graph", "render the wait-on dependency graph as a tree"),
|
||||
("wait", "mark <pid> as waiting on another pid: /fleet wait <pid> --on <other-pid>"),
|
||||
)
|
||||
|
||||
|
||||
def _opensre_agent_id() -> str:
|
||||
return f"opensre:{os.getpid()}"
|
||||
|
||||
|
||||
def _display_path(path: Path) -> str:
|
||||
"""Replace the user's home prefix with ``~`` for cleaner CLI output."""
|
||||
try:
|
||||
return f"~/{path.relative_to(Path.home())}"
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
def _print_config_error(console: Console, exc: ValidationError) -> None:
|
||||
console.print(f"[{ERROR}]agents.yaml has invalid contents:[/] {escape(str(exc))}")
|
||||
|
||||
|
||||
def _cmd_agents_list(console: Console) -> bool:
|
||||
"""Render registered plus read-only discovered agents as a Rich table.
|
||||
|
||||
Bare ``/fleet`` resolves here. Explicit registry rows keep winning
|
||||
on PID collisions; process discovery fills in Cursor, Claude Code,
|
||||
Codex, Aider, and Gemini CLI sessions that the user never registered.
|
||||
"""
|
||||
registry = AgentRegistry()
|
||||
render_agents_table(console, registered_and_discovered_agents(registry))
|
||||
return True
|
||||
|
||||
|
||||
def _format_bus_message(msg: BusMessage) -> str:
|
||||
"""Render one ``BusMessage`` as ``[agent] path — summary`` (path optional)."""
|
||||
parts = [f"[{HIGHLIGHT}]\\[{escape(msg.agent)}][/]"]
|
||||
if msg.path:
|
||||
parts.append(escape(msg.path))
|
||||
parts.append("—")
|
||||
parts.append(escape(msg.summary))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _cmd_agents_bus(console: Console) -> bool:
|
||||
"""Live-tail the cross-agent context bus until ``Ctrl-C`` or broker exit.
|
||||
|
||||
Self-elects a broker if none is running, then streams each ``BusMessage``
|
||||
as it arrives. The loop ends in three ways, each with explicit feedback:
|
||||
``KeyboardInterrupt`` (user detached), broker disconnect (e.g. the
|
||||
publishing process exited), or socket error.
|
||||
"""
|
||||
console.print(
|
||||
f"[{DIM}]tailing /fleet bus — Ctrl-C to exit[/]",
|
||||
)
|
||||
try:
|
||||
for msg in subscribe():
|
||||
console.print(_format_bus_message(msg))
|
||||
except KeyboardInterrupt:
|
||||
console.print(f"[{DIM}](detached)[/]")
|
||||
return True
|
||||
except OSError as exc:
|
||||
console.print(f"[{ERROR}]bus error:[/] {escape(str(exc))}")
|
||||
return False
|
||||
# ``subscribe()`` returned cleanly — the broker closed our connection
|
||||
# (e.g. it stopped, or its host process exited). Surface that explicitly
|
||||
# so the user isn't left wondering why the prompt came back.
|
||||
console.print(f"[{DIM}]bus broker disconnected[/]")
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_agents_conflicts(console: Console) -> bool:
|
||||
# Real write-event collection comes from #1500 (filesystem blast-radius
|
||||
# watcher), out of scope for this PR. Until that lands, the event source
|
||||
# is empty and `/fleet conflicts` reports "no conflicts detected".
|
||||
events: list[WriteEvent] = []
|
||||
conflicts = detect_conflicts(
|
||||
events,
|
||||
window_seconds=DEFAULT_WINDOW_SECONDS,
|
||||
opensre_agent_id=_opensre_agent_id(),
|
||||
)
|
||||
console.print(render_conflicts(conflicts))
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_agents_claim(session: Session, console: Console, args: list[str]) -> bool:
|
||||
"""Handle /fleet claim <branch> <agent-name>."""
|
||||
if len(args) < 2:
|
||||
console.print(f"[{ERROR}]Usage:[/] /fleet claim <branch> <agent-name>")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return False
|
||||
|
||||
branch = args[0].strip()
|
||||
agent_name = args[1].strip()
|
||||
|
||||
# Look up the PID from the registry for the given agent name
|
||||
registry = AgentRegistry()
|
||||
pid = None
|
||||
for record in registry.list():
|
||||
if record.name == agent_name:
|
||||
pid = record.pid
|
||||
break
|
||||
|
||||
if pid is None:
|
||||
console.print(
|
||||
f"[{ERROR}]Agent '{escape(agent_name)}' not found in registry. "
|
||||
"Use /fleet to see registered agents."
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return False
|
||||
|
||||
claims = BranchClaims()
|
||||
claim = claims.claim(branch, agent_name, pid)
|
||||
|
||||
if claim is None:
|
||||
existing = claims.get(branch)
|
||||
assert existing is not None # claim() only returns None when branch is held
|
||||
console.print(
|
||||
f"[{ERROR}]Cannot claim:[/] {escape(branch)} is already held by "
|
||||
f"{escape(existing.agent_name)} (pid {existing.pid}). "
|
||||
"Use /fleet release first."
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return False
|
||||
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]Branch {escape(branch)} now held by {escape(agent_name)} (pid {pid}).[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_agents_release(session: Session, console: Console, args: list[str]) -> bool:
|
||||
"""Handle /fleet release <branch>."""
|
||||
if len(args) < 1:
|
||||
console.print(f"[{ERROR}]Usage:[/] /fleet release <branch>")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return False
|
||||
|
||||
branch = args[0].strip()
|
||||
claims = BranchClaims()
|
||||
|
||||
existing = claims.get(branch)
|
||||
if existing is None:
|
||||
console.print(f"[{ERROR}]{escape(branch)} is not currently held by any agent.")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return False
|
||||
|
||||
# release() cannot return None here because we confirmed existing is not None above
|
||||
removed = claims.release(branch)
|
||||
assert removed is not None
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]Released {escape(branch)} (was held by {escape(removed.agent_name)}).[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_agents_budget(session: Session, console: Console, args: list[str]) -> bool:
|
||||
"""View or edit per-agent budgets stored in ``~/.opensre/agents.yaml``.
|
||||
|
||||
No args -> render the current budgets as a table. Two args
|
||||
(``<agent> <usd>``) -> set ``hourly_budget_usd`` for that agent and
|
||||
persist. Anything else -> usage hint.
|
||||
"""
|
||||
if not args:
|
||||
try:
|
||||
config = load_agents_config()
|
||||
except ValidationError as exc:
|
||||
_print_config_error(console, exc)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
if not config.agents:
|
||||
console.print(
|
||||
f"[{DIM}]no per-agent budgets configured.[/] "
|
||||
"use [bold]/fleet budget <agent> <usd>[/bold] to set one."
|
||||
)
|
||||
return True
|
||||
table = repl_table(title="agent budgets", title_style=BOLD_BRAND)
|
||||
table.add_column("agent", style="bold")
|
||||
table.add_column("hourly $", justify="right")
|
||||
table.add_column("progress min", justify="right")
|
||||
table.add_column("error %", justify="right")
|
||||
for name in sorted(config.agents):
|
||||
budget = config.agents[name]
|
||||
table.add_row(
|
||||
escape(name),
|
||||
f"${budget.hourly_budget_usd:.2f}" if budget.hourly_budget_usd is not None else "-",
|
||||
str(budget.progress_minutes) if budget.progress_minutes is not None else "-",
|
||||
f"{budget.error_rate_pct:.1f}" if budget.error_rate_pct is not None else "-",
|
||||
)
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
|
||||
if len(args) != 2:
|
||||
console.print(f"[{ERROR}]usage:[/] /fleet budget [<agent> <usd>]")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
name = args[0].strip()
|
||||
raw_usd = args[1]
|
||||
try:
|
||||
usd = float(raw_usd)
|
||||
except ValueError:
|
||||
console.print(f"[{ERROR}]invalid budget:[/] {escape(raw_usd)} is not a number")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
# ``nan`` and ``inf`` slip past ``usd <= 0`` because both
|
||||
# ``float("nan") <= 0`` and ``float("inf") <= 0`` are ``False``.
|
||||
# Without this guard a stored ``nan`` would corrupt agents.yaml
|
||||
# (next load fails Pydantic's ``gt=0`` since ``nan > 0`` is
|
||||
# ``False``) and ``inf`` would render as ``$inf`` in the dashboard.
|
||||
if not math.isfinite(usd) or usd <= 0:
|
||||
console.print(f"[{ERROR}]invalid budget:[/] must be a positive finite number")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
try:
|
||||
set_agent_budget(name, usd)
|
||||
except ValidationError as exc:
|
||||
_print_config_error(console, exc)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
console.print(
|
||||
f"updated [bold]{escape(name)}[/]: ${usd:.2f}/hr -> {_display_path(agents_config_path())}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_agents_wait(session: Session, console: Console, args: list[str]) -> bool:
|
||||
"""Handle ``/fleet wait <pid> --on <other-pid>``.
|
||||
|
||||
Parse the two pids out of ``args``, registers the dependency in the agent registry.
|
||||
"""
|
||||
if len(args) != 3 or args[1] != "--on":
|
||||
console.print(f"[{ERROR}]usage:[/] /fleet wait <pid> --on <other-pid>")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
try:
|
||||
pid = int(args[0])
|
||||
except ValueError:
|
||||
console.print(f"[{ERROR}]invalid pid:[/] {escape(args[0])}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
try:
|
||||
on_pid = int(args[2])
|
||||
except ValueError:
|
||||
console.print(f"[{ERROR}]invalid other-pid:[/] {escape(args[2])}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
if pid == on_pid:
|
||||
console.print(f"[{ERROR}]invalid pid:[/] {pid} waiting for itself")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
registry = AgentRegistry()
|
||||
waiter = registry.get(pid)
|
||||
if waiter is None:
|
||||
console.print(f"[{ERROR}]pid {pid} is not in the agent registry[/]")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
target = registry.get(on_pid)
|
||||
if target is None:
|
||||
console.print(f"[{ERROR}]pid {on_pid} is not in the agent registry[/]")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
waiter = waiter.add_waits_on(target)
|
||||
registry.register(waiter)
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]{escape(waiter.name)} (pid {pid}) now waits on "
|
||||
f"{escape(target.name)} (pid {on_pid}).[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_agents_graph(console: Console) -> bool:
|
||||
"""Render the ``waits_on`` dependency graph as a Rich tree.
|
||||
|
||||
Single-pass DFS over the inverse ``waits_on`` edges (depended-on
|
||||
-> waiter), building the Rich tree as it descends. A back edge — a
|
||||
pid re-encountered while still in the active path — is the
|
||||
canonical cycle witness for a directed graph; a warning naming the agents
|
||||
in the loop is emitted instead.
|
||||
"""
|
||||
|
||||
def _label(pid: int, ppid: int | None = None) -> str:
|
||||
r = records[pid]
|
||||
if ppid is None:
|
||||
return f"{escape(r.name)} ({pid}) \\[active]"
|
||||
|
||||
pr = records[ppid]
|
||||
return f"{escape(r.name)} ({pid}) \\[waiting on {escape(pr.name)}]"
|
||||
|
||||
def _walk(pid: int, parent: Tree, path: list[int], visited: set[int]) -> list[int] | None:
|
||||
for child in waiters_of.get(pid, []):
|
||||
if child in visited:
|
||||
return path[path.index(child) :] + [child]
|
||||
|
||||
path.append(child)
|
||||
visited.add(child)
|
||||
node = parent.add(_label(child, pid))
|
||||
c = _walk(child, node, path, visited)
|
||||
if c is not None:
|
||||
return c
|
||||
|
||||
path.pop()
|
||||
visited.remove(child)
|
||||
return None
|
||||
|
||||
registry = AgentRegistry()
|
||||
records = {r.pid: r for r in registry.list()}
|
||||
if not records:
|
||||
console.print(f"[{DIM}]no registered agents[/]")
|
||||
return True
|
||||
|
||||
waiters_of: dict[int, list[int]] = defaultdict(list)
|
||||
for record in records.values():
|
||||
for on_pid in record.waits_on:
|
||||
waiters_of[on_pid].append(record.pid)
|
||||
|
||||
# Roots are pids that wait on nothing. If every pid waits on
|
||||
# something the graph is fully covered by a cycle — fall back to
|
||||
# all pids so the walker enters somewhere and surfaces the back
|
||||
# edge instead of silently exiting on an empty root list.
|
||||
roots = [pid for pid, r in records.items() if not r.waits_on] or list(records)
|
||||
|
||||
trees: list[Tree] = []
|
||||
chain: str | None = None
|
||||
for root in roots:
|
||||
tree = Tree(label=_label(root))
|
||||
cycle = _walk(root, tree, [root], {root})
|
||||
if cycle is not None:
|
||||
chain = " -> ".join(f"{records[p].name} ({p})" for p in cycle)
|
||||
break
|
||||
trees.append(tree)
|
||||
|
||||
for i, tree in enumerate(trees):
|
||||
console.print(tree)
|
||||
if i != len(trees) - 1 and chain is None:
|
||||
console.line()
|
||||
|
||||
if chain is not None:
|
||||
console.print(f"[{WARNING}]: agent dependency cycle detected: {escape(chain)}.[/]")
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_agents(session: Session, console: Console, args: list[str]) -> bool:
|
||||
# The sampler is lazy: the first /fleet renders a cold snapshot and starts
|
||||
# the background sampler so CPU/token columns warm up on subsequent views.
|
||||
session.terminal.ensure_fleet_sampler_started()
|
||||
if not args:
|
||||
return _cmd_agents_list(console)
|
||||
|
||||
sub = args[0].lower().strip()
|
||||
if sub == "budget":
|
||||
return _cmd_agents_budget(session, console, args[1:])
|
||||
if sub == "bus":
|
||||
return _cmd_agents_bus(console)
|
||||
if sub == "conflicts":
|
||||
return _cmd_agents_conflicts(console)
|
||||
|
||||
if sub == "claim":
|
||||
return _cmd_agents_claim(session, console, args[1:])
|
||||
|
||||
if sub == "kill":
|
||||
return _cmd_agents_kill(session, console, args[1:])
|
||||
|
||||
if sub == "release":
|
||||
return _cmd_agents_release(session, console, args[1:])
|
||||
|
||||
if sub == "trace":
|
||||
return _cmd_agents_trace(session, console, args[1:])
|
||||
|
||||
if sub == "wait":
|
||||
return _cmd_agents_wait(session, console, args[1:])
|
||||
|
||||
if sub == "graph":
|
||||
return _cmd_agents_graph(console)
|
||||
|
||||
console.print(
|
||||
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
|
||||
"(try [bold]/fleet[/bold], [bold]/fleet budget[/bold], "
|
||||
"[bold]/fleet bus[/bold], [bold]/fleet claim[/bold], "
|
||||
"[bold]/fleet conflicts[/bold], [bold]/fleet kill[/bold], "
|
||||
"[bold]/fleet release[/bold], [bold]/fleet trace[/bold], "
|
||||
"[bold]/fleet wait[/bold] or [bold]/fleet graph[/bold])"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/fleet",
|
||||
"Show and manage registered local AI agents.",
|
||||
_cmd_agents,
|
||||
usage=(
|
||||
"/fleet",
|
||||
"/fleet budget",
|
||||
"/fleet bus",
|
||||
"/fleet claim",
|
||||
"/fleet conflicts",
|
||||
"/fleet kill",
|
||||
"/fleet release",
|
||||
"/fleet trace",
|
||||
"/fleet wait",
|
||||
"/fleet graph",
|
||||
),
|
||||
first_arg_completions=_AGENTS_FIRST_ARGS,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
"""/fleet kill subcommand."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from platform.analytics.events import Event
|
||||
from platform.analytics.provider import get_analytics
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING
|
||||
from tools.system.fleet_monitoring.lifecycle import TerminateResult, terminate
|
||||
from tools.system.fleet_monitoring.registry import AgentRegistry
|
||||
|
||||
# Type alias for the optional confirmation callback (used for testing).
|
||||
_ConfirmFn = Callable[[str], str]
|
||||
|
||||
|
||||
def _cmd_agents_kill(
|
||||
session: Session,
|
||||
console: Console,
|
||||
args: list[str],
|
||||
*,
|
||||
confirm_fn: _ConfirmFn | None = None,
|
||||
) -> bool:
|
||||
"""Handle ``/fleet kill <pid> [--force]``.
|
||||
|
||||
Sends SIGTERM, waits up to 5 s, then escalates to SIGKILL.
|
||||
Asks for confirmation unless ``--force`` is present.
|
||||
Emits an ``agent_killed`` analytics event on success.
|
||||
"""
|
||||
force = "--force" in args
|
||||
positional = [a for a in args if a != "--force"]
|
||||
|
||||
if not positional:
|
||||
console.print(f"[{ERROR}]usage:[/] /fleet kill <pid> [--force]")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
raw_pid = positional[0]
|
||||
try:
|
||||
pid = int(raw_pid)
|
||||
except ValueError:
|
||||
console.print(f"[{ERROR}]invalid pid:[/] {escape(raw_pid)} is not an integer")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
if pid == os.getpid():
|
||||
console.print(f"[{ERROR}]refusing to kill the opensre process itself[/]")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
# Look up agent name from registry for friendlier output.
|
||||
registry = AgentRegistry()
|
||||
record = registry.get(pid)
|
||||
label = f"{record.name} (pid {pid})" if record else f"pid {pid}"
|
||||
|
||||
if not force:
|
||||
prompt_text = f"About to SIGTERM {label}. Confirm? [y/N] "
|
||||
if confirm_fn is not None:
|
||||
answer = confirm_fn(prompt_text)
|
||||
else:
|
||||
answer = console.input(prompt_text)
|
||||
if answer.strip().lower() not in ("y", "yes"):
|
||||
console.print(f"[{DIM}]aborted.[/]")
|
||||
return True
|
||||
|
||||
try:
|
||||
result: TerminateResult = terminate(pid)
|
||||
except ProcessLookupError:
|
||||
console.print(f"[{ERROR}]no such process:[/] pid {pid}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
except PermissionError:
|
||||
console.print(f"[{ERROR}]permission denied:[/] cannot signal pid {pid}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
if result.exited:
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]Sent {result.signal_sent}. "
|
||||
f"Process exited after {result.elapsed_seconds:.1f}s.[/]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"[{WARNING}]Sent {result.signal_sent} but process may still be running "
|
||||
f"after {result.elapsed_seconds:.1f}s.[/]"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
|
||||
# Remove from the agent registry so `/fleet` no longer shows the dead PID.
|
||||
# Only forget when the process actually exited — otherwise it stays visible
|
||||
# for further monitoring or another kill attempt.
|
||||
if record is not None and result.exited:
|
||||
registry.forget(pid)
|
||||
|
||||
event = Event.AGENT_KILLED if result.exited else Event.AGENT_KILL_FAILED
|
||||
get_analytics().capture(
|
||||
event,
|
||||
{
|
||||
"pid": str(pid),
|
||||
"agent_name": record.name if record else "unknown",
|
||||
"signal": result.signal_sent,
|
||||
"exited": result.exited,
|
||||
"elapsed_seconds": str(round(result.elapsed_seconds, 2)),
|
||||
},
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Live trace rendering and /fleet trace subcommand."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from contextlib import nullcontext
|
||||
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from rich.markup import escape
|
||||
from rich.text import Text
|
||||
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import BOLD_BRAND, DIM, ERROR
|
||||
from tools.system.fleet_monitoring.registry import AgentRegistry
|
||||
from tools.system.fleet_monitoring.tail import AttachSession, AttachUnsupported, attach
|
||||
|
||||
_TRACE_REFRESH_PER_SECOND = 10
|
||||
# Match the throttle period to ``Live``'s refresh rate: under a 1k-line/sec
|
||||
# agent the reader thread can publish chunks faster than Rich actually
|
||||
# paints, and each ``live.update(Text.from_ansi(...))`` we make in
|
||||
# excess just creates a Renderable Rich will discard at the next paint.
|
||||
# Throttling the *call* to ``Live`` to one period bounds CPU under burst
|
||||
# writers without affecting how fast the screen updates.
|
||||
_TRACE_RENDER_PERIOD_S = 1.0 / _TRACE_REFRESH_PER_SECOND
|
||||
# Cap the on-screen render to the most recent slice of the 4 MiB buffer
|
||||
# so we don't reparse a 4 MiB string through Rich at 10 fps under burst
|
||||
# writers. A few screens of context is plenty for "what is the agent
|
||||
# doing right now"; the full tail is still in ``sess.buffer`` for any
|
||||
# future drill-down view.
|
||||
_TRACE_RENDER_TAIL_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def _render_trace_snapshot(live: Live, sess: AttachSession) -> None:
|
||||
"""Decode the bounded snapshot with UTF-8 boundary safety for ``Live``.
|
||||
|
||||
ANSI sequences are interpreted (Rich); treat traced output like unfiltered ``kubectl logs``.
|
||||
"""
|
||||
snapshot = _slice_to_utf8_boundary(sess.buffer.snapshot(), _TRACE_RENDER_TAIL_BYTES)
|
||||
live.update(Text.from_ansi(snapshot.decode("utf-8", errors="replace")))
|
||||
|
||||
|
||||
def _slice_to_utf8_boundary(data: bytes, max_bytes: int) -> bytes:
|
||||
"""Return the suffix of ``data`` that fits in ``max_bytes`` and starts
|
||||
on a UTF-8 codepoint boundary.
|
||||
|
||||
A plain ``data[-max_bytes:]`` can land mid-codepoint, which decodes to
|
||||
a leading U+FFFD under ``errors="replace"`` — visible as a stray
|
||||
replacement character at the top of the live view. ``TailBuffer``
|
||||
preserves boundaries by dropping whole chunks; we have to do the same
|
||||
once we re-flatten and slice on the render side. UTF-8 continuation
|
||||
bytes match ``10xxxxxx`` (``b & 0xC0 == 0x80``); a codepoint is at
|
||||
most 4 bytes, so we walk forward at most 3 continuation bytes to
|
||||
reach the next start byte.
|
||||
"""
|
||||
if len(data) <= max_bytes:
|
||||
return data
|
||||
sliced = data[-max_bytes:]
|
||||
start = 0
|
||||
while start < 4 and start < len(sliced) and (sliced[start] & 0xC0) == 0x80:
|
||||
start += 1
|
||||
return sliced[start:]
|
||||
|
||||
|
||||
def _render_live_tail(console: Console, label: str, sess: AttachSession) -> None:
|
||||
"""kubectl-logs-style render: a single Ctrl+C returns to the prompt.
|
||||
|
||||
Catches :class:`KeyboardInterrupt` inside the ``Live`` block and
|
||||
swallows it so the REPL doesn't see a traceback. ``stream_to_console``
|
||||
in ``streaming.py`` uses a double-press pattern because it's
|
||||
rendering an LLM response that the user might *not* want to abort
|
||||
on a stray keypress; a logs-style view is the inverse — one press
|
||||
is the canonical "stop" signal.
|
||||
"""
|
||||
console.print(f"[{BOLD_BRAND}]trace {escape(label)}[/] [{DIM}]Ctrl+C to stop[/]")
|
||||
isatty = getattr(console.file, "isatty", None)
|
||||
stdout_context = patch_stdout(raw=True) if callable(isatty) and isatty() else nullcontext()
|
||||
try:
|
||||
with (
|
||||
stdout_context,
|
||||
Live(
|
||||
Text(""),
|
||||
console=console,
|
||||
refresh_per_second=_TRACE_REFRESH_PER_SECOND,
|
||||
transient=False,
|
||||
vertical_overflow="visible",
|
||||
) as live,
|
||||
):
|
||||
# Iterating ``sess`` is what drains the reader queue and
|
||||
# appends to ``sess.buffer`` — the loop body only needs the
|
||||
# *side effect* of advancing, not the chunk value, so the
|
||||
# iteration variable is intentionally discarded.
|
||||
# Seeded at 0.0 so the first iteration always renders (any
|
||||
# ``time.monotonic() - 0.0`` clears the period); the throttle
|
||||
# kicks in from the second iteration onward.
|
||||
last_render = 0.0
|
||||
pending = False
|
||||
for _ in sess:
|
||||
now = time.monotonic()
|
||||
if now - last_render >= _TRACE_RENDER_PERIOD_S:
|
||||
_render_trace_snapshot(live, sess)
|
||||
last_render = now
|
||||
pending = False
|
||||
else:
|
||||
pending = True
|
||||
# Final flush: the last chunk(s) may have arrived inside a
|
||||
# throttle window; render once after the loop so the user
|
||||
# sees the very latest state instead of whatever was on
|
||||
# screen at the last gated update.
|
||||
if pending:
|
||||
_render_trace_snapshot(live, sess)
|
||||
except KeyboardInterrupt:
|
||||
# kubectl-logs-style: a single Ctrl+C ends the trace and returns
|
||||
# to the REPL prompt without propagating a traceback. The
|
||||
# ``with sess:`` in the caller still runs and joins the reader
|
||||
# thread, so this swallow is safe.
|
||||
pass
|
||||
if sess.producer_exited:
|
||||
# Distinguish "the agent died and we noticed" from "the user
|
||||
# asked us to stop" so a long unattended trace doesn't look the
|
||||
# same as a Ctrl+C abort.
|
||||
console.print(f"[{DIM}]· process exited[/]")
|
||||
console.print(f"[{DIM}]· trace ended[/]")
|
||||
|
||||
|
||||
def _cmd_agents_trace(session: Session, console: Console, args: list[str]) -> bool:
|
||||
"""Live-tail an agent's stdout by pid; see :func:`_render_live_tail`.
|
||||
|
||||
Validates eagerly (``attach()`` raises :class:`AttachUnsupported`
|
||||
synchronously on bad pid / unsupported fd type / missing file) so
|
||||
we never enter the ``Live`` block on a target we cannot tail.
|
||||
"""
|
||||
if len(args) != 1:
|
||||
console.print(f"[{ERROR}]usage:[/] /fleet trace <pid>")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
try:
|
||||
pid = int(args[0])
|
||||
except ValueError:
|
||||
console.print(f"[{ERROR}]invalid pid:[/] {escape(args[0])}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
record = AgentRegistry().get(pid)
|
||||
label = f"{record.name} (pid {pid})" if record else f"pid {pid}"
|
||||
|
||||
try:
|
||||
sess = attach(pid)
|
||||
except AttachUnsupported as exc:
|
||||
console.print(f"[{ERROR}]cannot trace {escape(label)}:[/] {escape(exc.reason)}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
with sess:
|
||||
_render_live_tail(console, label, sess)
|
||||
return True
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Slash command: /alerts — show alert listener status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.domain.alerts.inbox import get_current_inbox
|
||||
from platform.terminal.theme import BOLD_BRAND, DIM, HIGHLIGHT, WARNING
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import print_repl_table, repl_table
|
||||
|
||||
|
||||
def _cmd_alerts(_session: Session, console: Console, _args: list[str]) -> bool:
|
||||
inbox = get_current_inbox()
|
||||
if inbox is None:
|
||||
console.print(f"[{WARNING}]alert listener is not active.[/]")
|
||||
return True
|
||||
|
||||
table = repl_table(title="Alert Inbox\n", title_style=BOLD_BRAND, show_header=False)
|
||||
table.add_column("key", style="bold")
|
||||
table.add_column("value")
|
||||
table.add_row("status", f"[{HIGHLIGHT}]listening[/]")
|
||||
table.add_row("queue depth", str(inbox.qsize))
|
||||
table.add_row("dropped", str(inbox.dropped))
|
||||
|
||||
for alert in inbox.peek_last(5):
|
||||
table.add_row(f"[{DIM}]recent[/]", f"{alert.alert_name or 'untitled'} — {alert.text[:80]}")
|
||||
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand("/alerts", "Show alert listener status.", _cmd_alerts),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS"]
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Slash commands for session-local background investigation mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import (
|
||||
BOLD_BRAND,
|
||||
DIM,
|
||||
ERROR,
|
||||
HIGHLIGHT,
|
||||
print_repl_table,
|
||||
repl_table,
|
||||
)
|
||||
|
||||
_ALLOWED_NOTIFY_CHANNELS = ("email",)
|
||||
_BACKGROUND_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("on", "enable background investigation mode"),
|
||||
("off", "disable background investigation mode"),
|
||||
("status", "show current background-mode state"),
|
||||
("list", "list tracked background investigations"),
|
||||
("show", "show one background investigation summary"),
|
||||
("use", "promote a completed background RCA into active follow-up context"),
|
||||
("notify", "show or update background notification channels"),
|
||||
)
|
||||
|
||||
|
||||
def _render_background_status(session: Session, console: Console) -> None:
|
||||
table = repl_table(title="Background mode\n", title_style=BOLD_BRAND, show_header=False)
|
||||
table.add_column("key", style="bold")
|
||||
table.add_column("value")
|
||||
table.add_row("enabled", "yes" if session.terminal.background_mode_enabled else "no")
|
||||
table.add_row("tracked jobs", str(len(session.terminal.background_investigations)))
|
||||
table.add_row(
|
||||
"notify channels",
|
||||
", ".join(session.terminal.background_notification_preferences.channels) or "none",
|
||||
)
|
||||
print_repl_table(console, table)
|
||||
|
||||
|
||||
def _cmd_background(session: Session, console: Console, args: list[str]) -> bool:
|
||||
sub = (args[0].lower() if args else "status").strip()
|
||||
|
||||
if sub == "on":
|
||||
session.terminal.background_mode_enabled = True
|
||||
console.print(f"[{HIGHLIGHT}]background mode enabled[/]")
|
||||
return True
|
||||
if sub == "off":
|
||||
session.terminal.background_mode_enabled = False
|
||||
console.print(f"[{DIM}]background mode disabled[/]")
|
||||
return True
|
||||
if sub == "status":
|
||||
_render_background_status(session, console)
|
||||
return True
|
||||
if sub == "list":
|
||||
if not session.terminal.background_investigations:
|
||||
console.print(f"[{DIM}]no background investigations tracked in this session.[/]")
|
||||
return True
|
||||
table = repl_table(title="Background investigations\n", title_style=BOLD_BRAND)
|
||||
table.add_column("id", style="bold")
|
||||
table.add_column("status")
|
||||
table.add_column("command")
|
||||
table.add_column("root cause", overflow="fold")
|
||||
for task_id, tracked_record in session.terminal.background_investigations.items():
|
||||
table.add_row(
|
||||
task_id,
|
||||
tracked_record.status,
|
||||
tracked_record.command,
|
||||
escape(tracked_record.root_cause or "—"),
|
||||
)
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
if sub == "show":
|
||||
if len(args) < 2:
|
||||
console.print(f"[{ERROR}]usage:[/] /background show <task_id>")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
task_id = args[1]
|
||||
selected_record = session.terminal.background_investigations.get(task_id)
|
||||
if selected_record is None:
|
||||
console.print(f"[{ERROR}]unknown background task:[/] {escape(task_id)}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
table = repl_table(
|
||||
title=f"Background investigation: {task_id}\n",
|
||||
title_style=BOLD_BRAND,
|
||||
show_header=False,
|
||||
)
|
||||
table.add_column("key", style="bold")
|
||||
table.add_column("value", overflow="fold")
|
||||
table.add_row("status", selected_record.status)
|
||||
table.add_row("command", selected_record.command)
|
||||
table.add_row("root cause", escape(selected_record.root_cause or "—"))
|
||||
table.add_row(
|
||||
"top analysis",
|
||||
escape(
|
||||
"; ".join(selected_record.top_analysis) if selected_record.top_analysis else "—"
|
||||
),
|
||||
)
|
||||
table.add_row(
|
||||
"next steps",
|
||||
escape("; ".join(selected_record.next_steps) if selected_record.next_steps else "—"),
|
||||
)
|
||||
table.add_row(
|
||||
"notify",
|
||||
escape(
|
||||
", ".join(f"{k}:{v}" for k, v in selected_record.notification_results.items())
|
||||
or "—"
|
||||
),
|
||||
)
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
if sub == "use":
|
||||
if len(args) < 2:
|
||||
console.print(f"[{ERROR}]usage:[/] /background use <task_id>")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
task_id = args[1]
|
||||
selected_record = session.terminal.background_investigations.get(task_id)
|
||||
if selected_record is None:
|
||||
console.print(f"[{ERROR}]unknown background task:[/] {escape(task_id)}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
if not selected_record.final_state:
|
||||
console.print(
|
||||
f"[{ERROR}]background task has no completed RCA state yet:[/] {escape(task_id)}"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
session.last_state = dict(selected_record.final_state)
|
||||
session.accumulate_from_state(selected_record.final_state)
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]background RCA active[/] "
|
||||
f"[{DIM}]— follow-up context now points to {escape(task_id)}.[/]"
|
||||
)
|
||||
return True
|
||||
if sub == "notify":
|
||||
action = (args[1].lower() if len(args) > 1 else "list").strip()
|
||||
if action == "list":
|
||||
console.print(
|
||||
f"[{DIM}]background notify channels:[/] "
|
||||
f"{', '.join(session.terminal.background_notification_preferences.channels) or 'none'}"
|
||||
)
|
||||
return True
|
||||
if action == "set":
|
||||
if len(args) < 3:
|
||||
console.print(f"[{ERROR}]usage:[/] /background notify set <channel[,channel...]>")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
requested = [part.strip().lower() for part in args[2].split(",") if part.strip()]
|
||||
invalid = [part for part in requested if part not in _ALLOWED_NOTIFY_CHANNELS]
|
||||
if invalid:
|
||||
console.print(
|
||||
f"[{ERROR}]invalid channel(s):[/] {escape(', '.join(invalid))} "
|
||||
f"[{DIM}](allowed: {', '.join(_ALLOWED_NOTIFY_CHANNELS)})[/]"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
session.terminal.background_notification_preferences.set_channels(requested)
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]background notify channels set:[/] "
|
||||
f"{', '.join(session.terminal.background_notification_preferences.channels)}"
|
||||
)
|
||||
return True
|
||||
console.print(f"[{ERROR}]unknown notify subcommand:[/] {escape(action)}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
console.print(
|
||||
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
|
||||
"(try [bold]/background status[/bold], [bold]/background list[/bold], "
|
||||
"[bold]/background show <task_id>[/bold], or [bold]/background notify list[/bold])"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/background",
|
||||
"Manage background investigation mode and completed RCA summaries.",
|
||||
_cmd_background,
|
||||
usage=(
|
||||
"/background on",
|
||||
"/background off",
|
||||
"/background status",
|
||||
"/background list",
|
||||
"/background show <task_id>",
|
||||
"/background use <task_id>",
|
||||
"/background notify list",
|
||||
"/background notify set <channel[,channel...]>",
|
||||
),
|
||||
first_arg_completions=_BACKGROUND_FIRST_ARGS,
|
||||
)
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS"]
|
||||
@@ -0,0 +1,479 @@
|
||||
"""Slash commands for CLI parity, delegating to the Click CLI via subprocess."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from core.agent_harness.session.terminal_access import (
|
||||
session_terminal,
|
||||
set_turn_outcome_hint,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.suggestions import closest_choice
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session, TaskKind
|
||||
from surfaces.interactive_shell.runtime.subprocess_runner import (
|
||||
SYNTHETIC_TEST_TIMEOUT_SECONDS,
|
||||
build_opensre_cli_argv,
|
||||
start_background_cli_task,
|
||||
)
|
||||
from surfaces.interactive_shell.ui import DIM, ERROR, print_command_output
|
||||
from surfaces.interactive_shell.utils.telemetry.turn_outcome import format_wizard_cli_outcome
|
||||
|
||||
_UPDATE_SUBPROCESS_TIMEOUT_SECONDS = 300
|
||||
_BACKGROUND_TEST_SUBCOMMANDS = frozenset({"run", "synthetic", "cloudopsbench"})
|
||||
_TEST_SUBCOMMANDS = ("list", "run", "synthetic", "cloudopsbench")
|
||||
_TEST_PICKER_SELECTION_FILE_ENV = "OPENSRE_TEST_PICKER_SELECTION_FILE"
|
||||
_PARENT_INTERACTIVE_SHELL_ENV = "OPENSRE_PARENT_INTERACTIVE_SHELL"
|
||||
_HEADLESS_CLI_SUBPROCESS_TIMEOUT_SECONDS = 90.0
|
||||
|
||||
|
||||
def publish_headless_slash_response(
|
||||
session: Session,
|
||||
*,
|
||||
message: str,
|
||||
ok: bool = True,
|
||||
) -> None:
|
||||
"""Pin an explicit slash reply for gateway/headless surfaces (wizards, setup)."""
|
||||
session.complete_latest_record(
|
||||
"slash",
|
||||
response_text=message.strip(),
|
||||
ok=ok,
|
||||
slash_outcome="headless_guidance",
|
||||
)
|
||||
|
||||
|
||||
def _decode_subprocess_stream(value: str | bytes | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return value
|
||||
|
||||
|
||||
def _cli_command_succeeded(exit_code: int | None) -> bool:
|
||||
return exit_code == 0
|
||||
|
||||
|
||||
def run_cli_command(
|
||||
console: Console,
|
||||
args: list[str],
|
||||
*,
|
||||
session: Session | None = None,
|
||||
subprocess_timeout: float | None = None,
|
||||
capture_output: bool = False,
|
||||
) -> bool:
|
||||
"""Helper to delegate complex or interactive Click commands to a child process.
|
||||
|
||||
``subprocess_timeout`` caps how long ``subprocess.run`` waits before raising
|
||||
:class:`~subprocess.TimeoutExpired`. Interactive flows use ``None`` so the
|
||||
child can prompt as long as needed; callers that hit the network without a
|
||||
TTY (like ``opensre update``) pass a bounded timeout.
|
||||
|
||||
``capture_output`` (default ``False``) makes the helper capture stdout/stderr
|
||||
and replay them through ``console`` even without a timeout. Set this for
|
||||
non-interactive delegated commands (e.g. ``opensre tests list``) so their
|
||||
output appears inside the REPL buffer instead of bypassing ``console.print``
|
||||
via the child's inherited stdout FD. Interactive commands like ``onboard``
|
||||
must leave this ``False`` so the child's prompts stay attached to the real
|
||||
TTY. Capture is also enabled automatically whenever a timeout is set.
|
||||
|
||||
**Return value:** Reports subprocess success for headless/gateway sessions
|
||||
(``session`` with no terminal facet) so slash analytics can show failure.
|
||||
On the interactive REPL, always returns ``True`` so delegated CLI failures
|
||||
do not propagate to :func:`dispatch_slash` and exit the shell — failures
|
||||
are still recorded via :meth:`Session.mark_latest`.
|
||||
|
||||
Ctrl+C sends :exc:`KeyboardInterrupt`, which subclasses :exc:`BaseException`
|
||||
rather than :exc:`Exception`; it is handled here so the REPL survives and the
|
||||
child process exits on SIGINT alongside the interrupted ``run`` call.
|
||||
"""
|
||||
console.print()
|
||||
cmd = build_opensre_cli_argv(args)
|
||||
headless = session is not None and session_terminal(session) is None
|
||||
should_capture = capture_output or subprocess_timeout is not None or headless
|
||||
if headless and subprocess_timeout is None:
|
||||
subprocess_timeout = _HEADLESS_CLI_SUBPROCESS_TIMEOUT_SECONDS
|
||||
child_env = os.environ.copy()
|
||||
child_env[_PARENT_INTERACTIVE_SHELL_ENV] = "1"
|
||||
if should_capture:
|
||||
# Captured child stdout isn't a TTY, so force Rich colour there and parse
|
||||
# it back in print_command_output — otherwise its styling would be lost.
|
||||
child_env["FORCE_COLOR"] = "1"
|
||||
exit_code: int | None = 0
|
||||
try:
|
||||
if should_capture:
|
||||
captured_result = subprocess.run(
|
||||
cmd,
|
||||
check=False,
|
||||
timeout=subprocess_timeout,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=child_env,
|
||||
)
|
||||
exit_code = captured_result.returncode
|
||||
print_command_output(console, captured_result.stdout or "")
|
||||
print_command_output(console, captured_result.stderr or "", style=ERROR)
|
||||
if captured_result.returncode != 0:
|
||||
console.print(
|
||||
f"[{ERROR}]CLI command exited with non-zero code {captured_result.returncode}[/]"
|
||||
)
|
||||
else:
|
||||
interactive_result = subprocess.run(cmd, check=False, env=child_env)
|
||||
exit_code = interactive_result.returncode
|
||||
if interactive_result.returncode != 0:
|
||||
console.print(
|
||||
f"[{ERROR}]CLI command exited with non-zero code {interactive_result.returncode}[/]"
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
exit_code = None
|
||||
print_command_output(console, _decode_subprocess_stream(exc.stdout))
|
||||
print_command_output(console, _decode_subprocess_stream(exc.stderr), style=ERROR)
|
||||
console.print(f"[{ERROR}]error:[/] CLI command timed out")
|
||||
except KeyboardInterrupt:
|
||||
exit_code = None
|
||||
console.print(f"[{DIM}]CLI command cancelled (Ctrl+C).[/]")
|
||||
except Exception as exc:
|
||||
exit_code = None
|
||||
console.print(f"[{ERROR}]error running CLI command:[/] {exc}")
|
||||
console.print()
|
||||
if session is not None and not should_capture:
|
||||
set_turn_outcome_hint(session, format_wizard_cli_outcome(args, exit_code=exit_code))
|
||||
ok = _cli_command_succeeded(exit_code)
|
||||
if session is not None and not ok:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
# Headless/gateway surfaces need the real exit status for slash analytics.
|
||||
# Interactive REPL handlers must not return False to dispatch_slash on CLI
|
||||
# failure — that would exit the shell (/exit is the only intentional False).
|
||||
return ok if headless else True
|
||||
|
||||
|
||||
def _cmd_onboard(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
if session_terminal(session) is None:
|
||||
cli_cmd = " ".join(["uv run opensre onboard", *args]).strip()
|
||||
message = (
|
||||
"Onboarding is an interactive wizard (LLM provider, integrations, messaging). "
|
||||
"It cannot run inside a Telegram chat.\n\n"
|
||||
f"Run on the server:\n {cli_cmd}\n\n"
|
||||
"Or configure individual services with "
|
||||
"`/integrations setup <service>`."
|
||||
)
|
||||
console.print()
|
||||
console.print(message)
|
||||
publish_headless_slash_response(session, message=message)
|
||||
return True
|
||||
# The REPL loop treats ``/onboard`` as exclusive-stdin in
|
||||
# ``runtime.utils.input_policy`` so the prompt_toolkit Application is torn down before
|
||||
# this handler runs — the wizard subprocess therefore gets exclusive
|
||||
# stdin and can drive its own interactive prompts without conflicting
|
||||
# with the shell's UI.
|
||||
return run_cli_command(console, ["onboard", *args], session=session)
|
||||
|
||||
|
||||
def _cmd_auth(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
capture_output = not args or args[0].lower() in {"status", "logout"}
|
||||
return run_cli_command(console, ["auth", *args], capture_output=capture_output, session=session)
|
||||
|
||||
|
||||
def _cmd_login(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
return run_cli_command(console, ["auth", "login", *args], session=session)
|
||||
|
||||
|
||||
def _cmd_remote(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
return run_cli_command(console, ["remote", *args], session=session)
|
||||
|
||||
|
||||
def _catalog_task_kind(command: list[str]) -> TaskKind:
|
||||
return TaskKind.SYNTHETIC_TEST if "synthetic" in command else TaskKind.CLI_COMMAND
|
||||
|
||||
|
||||
def _argv_for_catalog_command(command: list[str]) -> list[str]:
|
||||
if command[:1] == ["opensre"]:
|
||||
return build_opensre_cli_argv(command[1:])
|
||||
return command
|
||||
|
||||
|
||||
def _start_test_command(
|
||||
*,
|
||||
session: Session,
|
||||
console: Console,
|
||||
command: list[str],
|
||||
display_command: str | None = None,
|
||||
) -> None:
|
||||
shown = display_command or shlex.join(command)
|
||||
session.record("cli_command", shown)
|
||||
start_background_cli_task(
|
||||
display_command=shown,
|
||||
argv_list=_argv_for_catalog_command(command),
|
||||
session=session,
|
||||
console=console,
|
||||
timeout_seconds=SYNTHETIC_TEST_TIMEOUT_SECONDS,
|
||||
kind=_catalog_task_kind(command),
|
||||
use_pty=True,
|
||||
)
|
||||
|
||||
|
||||
def _run_test_picker_for_background(session: Session, console: Console) -> bool:
|
||||
console.print()
|
||||
with contextlib.closing(
|
||||
tempfile.NamedTemporaryFile(
|
||||
prefix="opensre-test-selection-",
|
||||
suffix=".json",
|
||||
delete=False,
|
||||
)
|
||||
) as handle:
|
||||
selection_path = Path(handle.name)
|
||||
try:
|
||||
env = dict(os.environ)
|
||||
env[_TEST_PICKER_SELECTION_FILE_ENV] = str(selection_path)
|
||||
result = subprocess.run(
|
||||
build_opensre_cli_argv(["tests"]),
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
console.print(f"[{ERROR}]CLI command exited with non-zero code {result.returncode}[/]")
|
||||
console.print()
|
||||
return True
|
||||
if not selection_path.stat().st_size:
|
||||
console.print()
|
||||
return True
|
||||
payload = json.loads(selection_path.read_text(encoding="utf-8"))
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
selection_path.unlink()
|
||||
|
||||
if not isinstance(payload, list):
|
||||
console.print(f"[{ERROR}]test picker returned an invalid selection[/]")
|
||||
console.print()
|
||||
return True
|
||||
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
command = item.get("command")
|
||||
if not isinstance(command, list) or not all(isinstance(part, str) for part in command):
|
||||
continue
|
||||
display = item.get("command_display")
|
||||
_start_test_command(
|
||||
session=session,
|
||||
console=console,
|
||||
command=command,
|
||||
display_command=display if isinstance(display, str) else None,
|
||||
)
|
||||
console.print()
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_tests(session: Session, console: Console, args: list[str]) -> bool:
|
||||
if not args:
|
||||
return _run_test_picker_for_background(session, console)
|
||||
|
||||
subcommand = args[0].lower()
|
||||
if subcommand in _BACKGROUND_TEST_SUBCOMMANDS:
|
||||
_start_test_command(
|
||||
session=session,
|
||||
console=console,
|
||||
command=["opensre", "tests", *args],
|
||||
)
|
||||
return True
|
||||
|
||||
if subcommand.startswith("-"):
|
||||
return run_cli_command(console, ["tests", *args], capture_output=True)
|
||||
|
||||
if subcommand not in _TEST_SUBCOMMANDS:
|
||||
suggestion = closest_choice(subcommand, _TEST_SUBCOMMANDS)
|
||||
if suggestion is None:
|
||||
console.print(
|
||||
f"[{ERROR}]unknown tests subcommand:[/] {escape(args[0])} "
|
||||
"(try [bold]/tests list[/bold], [bold]/tests run <test_id>[/bold], "
|
||||
"[bold]/tests synthetic[/bold], or [bold]/tests cloudopsbench[/bold])"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"[{ERROR}]unknown tests subcommand:[/] {escape(args[0])} "
|
||||
f"Did you mean [bold]/tests {suggestion}[/bold]?"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
return run_cli_command(console, ["tests", *args], capture_output=True)
|
||||
|
||||
|
||||
def _cmd_guardrails(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
# ``opensre guardrails`` and its subcommands are all non-interactive printers
|
||||
# (init/test/audit/rules just ``click.echo``). Capture so the output — and
|
||||
# Click's usage block when no subcommand is given — reaches the REPL buffer
|
||||
# instead of bypassing ``console.print`` via the child's inherited stdout FD.
|
||||
return run_cli_command(console, ["guardrails", *args], capture_output=True)
|
||||
|
||||
|
||||
def _cmd_update(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
return run_cli_command(
|
||||
console,
|
||||
["update", *args],
|
||||
subprocess_timeout=_UPDATE_SUBPROCESS_TIMEOUT_SECONDS,
|
||||
session=session,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_uninstall(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
return run_cli_command(console, ["uninstall", *args])
|
||||
|
||||
|
||||
def _cmd_config(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
# Non-interactive click.echo only; capture so output reaches the REPL buffer
|
||||
# instead of the child's inherited stdout while prompt_toolkit redraws.
|
||||
return run_cli_command(console, ["config", *args], capture_output=True)
|
||||
|
||||
|
||||
def _cmd_messaging(session: Session, console: Console, args: list[str]) -> bool:
|
||||
# Non-interactive subcommands: capture so output renders through the REPL
|
||||
# (inherited stdout gets clipped by prompt_toolkit's screen management).
|
||||
return run_cli_command(console, ["messaging", *args], capture_output=True, session=session)
|
||||
|
||||
|
||||
def _cmd_hermes(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
return run_cli_command(console, ["hermes", *args])
|
||||
|
||||
|
||||
def _cmd_cron(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
return run_cli_command(console, ["cron", *args])
|
||||
|
||||
|
||||
def _cmd_watchdog(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
return run_cli_command(console, ["watchdog", *args])
|
||||
|
||||
|
||||
def _cmd_debug(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
return run_cli_command(console, ["debug", *args])
|
||||
|
||||
|
||||
def _cmd_misses(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
# Non-interactive printers only (list/stats/export/convert) — capture so the
|
||||
# output reaches the REPL buffer instead of the child's inherited stdout.
|
||||
return run_cli_command(console, ["misses", *args], capture_output=True)
|
||||
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/auth",
|
||||
"Log in to LLM providers and inspect local auth state.",
|
||||
_cmd_auth,
|
||||
usage=("/auth", "/auth status", "/auth login deepseek", "/auth logout deepseek"),
|
||||
),
|
||||
SlashCommand(
|
||||
"/login",
|
||||
"Shortcut for LLM provider login.",
|
||||
_cmd_login,
|
||||
usage=("/login", "/login chatgpt", "/login claude", "/login deepseek"),
|
||||
),
|
||||
SlashCommand(
|
||||
"/onboard",
|
||||
"Run the interactive onboarding wizard.",
|
||||
_cmd_onboard,
|
||||
usage=("/onboard", "/onboard local_llm"),
|
||||
),
|
||||
SlashCommand(
|
||||
"/remote",
|
||||
"Connect to and trigger a remote deployed agent.",
|
||||
_cmd_remote,
|
||||
usage=(
|
||||
"/remote health",
|
||||
"/remote investigate",
|
||||
"/remote ops",
|
||||
"/remote pull",
|
||||
"/remote trigger",
|
||||
),
|
||||
),
|
||||
SlashCommand(
|
||||
"/tests",
|
||||
"Browse and run inventoried tests.",
|
||||
_cmd_tests,
|
||||
usage=("/tests", "/tests list", "/tests run", "/tests synthetic"),
|
||||
first_arg_completions=tuple((name, f"/tests {name}") for name in _TEST_SUBCOMMANDS),
|
||||
),
|
||||
SlashCommand(
|
||||
"/guardrails",
|
||||
"Manage sensitive information guardrail rules.",
|
||||
_cmd_guardrails,
|
||||
usage=(
|
||||
"/guardrails audit",
|
||||
"/guardrails init",
|
||||
"/guardrails rules",
|
||||
"/guardrails test",
|
||||
),
|
||||
),
|
||||
SlashCommand(
|
||||
"/update",
|
||||
"Check for a newer version and update if available.",
|
||||
_cmd_update,
|
||||
),
|
||||
SlashCommand(
|
||||
"/uninstall",
|
||||
"Remove OpenSRE and all local data from this machine.",
|
||||
_cmd_uninstall,
|
||||
),
|
||||
SlashCommand(
|
||||
"/config",
|
||||
"Show or edit local OpenSRE config.",
|
||||
_cmd_config,
|
||||
usage=("/config show", "/config set <key> <value>"),
|
||||
),
|
||||
SlashCommand(
|
||||
"/messaging",
|
||||
"Manage messaging security and identities.",
|
||||
_cmd_messaging,
|
||||
usage=(
|
||||
"/messaging pair",
|
||||
"/messaging allow",
|
||||
"/messaging revoke",
|
||||
"/messaging status",
|
||||
),
|
||||
),
|
||||
SlashCommand(
|
||||
"/hermes",
|
||||
"Live-tail Hermes logs and send incidents to Telegram.",
|
||||
_cmd_hermes,
|
||||
usage=("/hermes watch",),
|
||||
),
|
||||
SlashCommand(
|
||||
"/cron",
|
||||
"Manage cron-driven scheduled deliveries.",
|
||||
_cmd_cron,
|
||||
usage=("/cron list", "/cron add", "/cron remove <id>", "/cron run <id>", "/cron logs <id>"),
|
||||
),
|
||||
SlashCommand(
|
||||
"/watchdog",
|
||||
"Monitor one process and send threshold alarms.",
|
||||
_cmd_watchdog,
|
||||
usage=("/watchdog --pid <pid> [--max-rss <size>] [--max-cpu <percent>]",),
|
||||
examples=("/watchdog --pid 123 --max-rss 1G",),
|
||||
),
|
||||
SlashCommand(
|
||||
"/debug",
|
||||
"run targeted runtime diagnostics",
|
||||
_cmd_debug,
|
||||
),
|
||||
SlashCommand(
|
||||
"/misses",
|
||||
"Triage investigation misses and export them as benchmark scenarios.",
|
||||
_cmd_misses,
|
||||
usage=(
|
||||
"/misses list",
|
||||
"/misses stats",
|
||||
"/misses export --out <dir>",
|
||||
"/misses convert <miss_id>",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Slash commands: session diagnostics (/status, /cost, /context)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from config.llm_reasoning_effort import display_reasoning_effort
|
||||
from core.agent_harness.accounting.token_accounting import format_token_total
|
||||
from core.agent_harness.session.terminal_access import trust_mode_enabled
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.grounding.cli_reference import session_cli_reference
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import (
|
||||
BOLD_BRAND,
|
||||
DIM,
|
||||
print_repl_table,
|
||||
repl_table,
|
||||
)
|
||||
|
||||
|
||||
def _status_provider_display() -> str:
|
||||
"""Render the active LLM provider, flagging a fallback away from configured."""
|
||||
from config.config import get_configured_llm_provider, resolve_llm_settings_verbose
|
||||
|
||||
try:
|
||||
resolution = resolve_llm_settings_verbose()
|
||||
except Exception:
|
||||
return get_configured_llm_provider()
|
||||
|
||||
if not resolution.fell_back:
|
||||
return resolution.resolved_provider
|
||||
|
||||
from surfaces.interactive_shell.ui import WARNING
|
||||
|
||||
note = f"fallback from '{resolution.configured_provider}'"
|
||||
if resolution.missing_key_env:
|
||||
note += f": {resolution.missing_key_env} not set"
|
||||
return f"{resolution.resolved_provider} [{WARNING}]({note})[/]"
|
||||
|
||||
|
||||
def _incoming_alerts_status(session: Session) -> tuple[str, str]:
|
||||
"""Return (label, value) for the incoming-alerts row; headless sessions have no inbox."""
|
||||
alerts = getattr(session, "alerts", None)
|
||||
if alerts is None:
|
||||
return "incoming alerts", "0"
|
||||
most_recent = alerts.most_recent
|
||||
if most_recent is not None:
|
||||
from surfaces.interactive_shell.ui.alerts import time_ago
|
||||
|
||||
age_str = time_ago(most_recent.received_at)
|
||||
return "incoming alerts", f"{len(alerts.entries)} (last {age_str})"
|
||||
return "incoming alerts", "0"
|
||||
|
||||
|
||||
def _cmd_status(session: Session, console: Console, _args: list[str]) -> bool:
|
||||
table = repl_table(title="Session status\n", title_style=BOLD_BRAND, show_header=False)
|
||||
table.add_column("key", style="bold")
|
||||
table.add_column("value")
|
||||
table.add_row("interactions", str(len(session.history)))
|
||||
|
||||
alert_key, alert_value = _incoming_alerts_status(session)
|
||||
table.add_row(alert_key, alert_value)
|
||||
|
||||
table.add_row("last investigation", "yes" if session.last_state else "none")
|
||||
table.add_row("trust mode", "on" if trust_mode_enabled(session) else "off")
|
||||
table.add_row("reasoning effort", display_reasoning_effort(session.reasoning_effort))
|
||||
table.add_row("provider", _status_provider_display())
|
||||
table.add_row(
|
||||
"grounding cli cache",
|
||||
session_cli_reference(session).stats().render(),
|
||||
)
|
||||
for source in session.grounding.iter_sources():
|
||||
table.add_row(f"grounding {source.name} cache", source.stats_fn().render())
|
||||
acc = session.accumulated_context
|
||||
if acc:
|
||||
table.add_row("accumulated context", ", ".join(sorted(acc.keys())))
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_cost(session: Session, console: Console, _args: list[str]) -> bool:
|
||||
title = "Session cost"
|
||||
if session.tokens.has_estimates:
|
||||
title = "Session cost (includes estimates)"
|
||||
table = repl_table(title=f"{title}\n", title_style=BOLD_BRAND, show_header=False)
|
||||
table.add_column("key", style="bold")
|
||||
table.add_column("value")
|
||||
table.add_row("history entries", str(len(session.history)))
|
||||
if session.tokens.call_count:
|
||||
table.add_row("llm calls", str(session.tokens.call_count))
|
||||
|
||||
if session.tokens.totals:
|
||||
for direction in ("input", "output"):
|
||||
label, value = format_token_total(session, direction=direction)
|
||||
table.add_row(label, value)
|
||||
else:
|
||||
table.add_row("token usage", f"[{DIM}]no LLM usage recorded yet[/]")
|
||||
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_context(session: Session, console: Console, _args: list[str]) -> bool:
|
||||
if not session.accumulated_context:
|
||||
console.print(f"[{DIM}]no infra context accumulated yet.[/]")
|
||||
return True
|
||||
|
||||
table = repl_table(title="Accumulated context\n", title_style=BOLD_BRAND, show_header=False)
|
||||
table.add_column("key", style="bold")
|
||||
table.add_column("value")
|
||||
for k, v in sorted(session.accumulated_context.items()):
|
||||
table.add_row(k, escape(str(v)))
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand("/status", "Show session status.", _cmd_status),
|
||||
SlashCommand("/cost", "Show token usage and session cost.", _cmd_cost),
|
||||
SlashCommand("/context", "Show accumulated infra context.", _cmd_context),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS"]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Slash command: /gateway — control the OpenSRE gateway daemon."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from gateway.daemon import (
|
||||
GATEWAY_LOG_FILE,
|
||||
gateway_daemon_pid,
|
||||
read_component_status,
|
||||
read_gateway_log_tail,
|
||||
start_gateway_daemon,
|
||||
stop_gateway_daemon,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT
|
||||
|
||||
_USAGE = "/gateway [start|stop|status|logs [lines]]"
|
||||
|
||||
|
||||
def _print_outcome(console: Console, ok: bool, message: str) -> None:
|
||||
console.print(f"[{HIGHLIGHT if ok else ERROR}]{escape(message)}[/]")
|
||||
|
||||
|
||||
def _cmd_gateway(_session: Session, console: Console, args: list[str]) -> bool:
|
||||
sub = args[0].lower() if args else "status"
|
||||
|
||||
if sub == "start":
|
||||
_print_outcome(console, *start_gateway_daemon())
|
||||
console.print(f"[{DIM}]logs: {GATEWAY_LOG_FILE} — /gateway logs to tail[/]")
|
||||
elif sub == "stop":
|
||||
_print_outcome(console, *stop_gateway_daemon())
|
||||
elif sub == "status":
|
||||
pid = gateway_daemon_pid()
|
||||
state = f"[{HIGHLIGHT}]running (pid {pid})[/]" if pid else f"[{DIM}]stopped[/]"
|
||||
console.print(f"OpenSRE gateway: {state}")
|
||||
for name, detail in read_component_status().items():
|
||||
console.print(f" {escape(name)}: {escape(detail)}")
|
||||
console.print(f"[{DIM}]logs: {GATEWAY_LOG_FILE}[/]")
|
||||
elif sub == "logs":
|
||||
lines = int(args[1]) if len(args) > 1 and args[1].isdigit() else 30
|
||||
if tail := read_gateway_log_tail(lines):
|
||||
console.print(escape(tail))
|
||||
else:
|
||||
console.print(f"[{DIM}]no gateway logs yet at {GATEWAY_LOG_FILE}[/]")
|
||||
else:
|
||||
console.print(f"[{ERROR}]usage:[/] {_USAGE}")
|
||||
return True
|
||||
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/gateway",
|
||||
"Control the background OpenSRE gateway daemon: start, stop, status, logs.",
|
||||
_cmd_gateway,
|
||||
usage=(_USAGE,),
|
||||
notes=(
|
||||
"The gateway daemon runs the web health app, Telegram chat, and the "
|
||||
"task scheduler; logs are stored in ~/.opensre/gateway/gateway.log.",
|
||||
),
|
||||
first_arg_completions=(
|
||||
("start", "start the gateway daemon (web, telegram, scheduler)"),
|
||||
("stop", "stop the gateway daemon"),
|
||||
("status", "show the daemon and its components"),
|
||||
("logs", "print recent gateway log lines"),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS"]
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Slash commands: /help and /?."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import ERROR
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import repl_tty_interactive
|
||||
from surfaces.interactive_shell.ui.help.help_menu import (
|
||||
HelpSection,
|
||||
choose_help_command,
|
||||
render_command_detail,
|
||||
render_help_index,
|
||||
render_section_detail,
|
||||
)
|
||||
|
||||
QUICK_ACCESS_COMMANDS: list[str] = [
|
||||
"/investigate",
|
||||
"/integrations",
|
||||
"/model",
|
||||
"/health",
|
||||
"/watch",
|
||||
"/status",
|
||||
"/help",
|
||||
]
|
||||
|
||||
|
||||
def _quick_access_section() -> HelpSection:
|
||||
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS
|
||||
|
||||
cmds = [SLASH_COMMANDS[n] for n in QUICK_ACCESS_COMMANDS if n in SLASH_COMMANDS]
|
||||
return ("Quick Access", cmds)
|
||||
|
||||
|
||||
def _raw_help_sections() -> list[HelpSection]:
|
||||
from surfaces.interactive_shell.command_registry.agents import COMMANDS as AGENTS_CMDS
|
||||
from surfaces.interactive_shell.command_registry.alerts import COMMANDS as ALERTS_CMDS
|
||||
from surfaces.interactive_shell.command_registry.background_cmds import (
|
||||
COMMANDS as BACKGROUND_CMDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.cli_parity import (
|
||||
COMMANDS as PARITY_COMMANDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.diagnostics_cmds import (
|
||||
COMMANDS as DIAGNOSTICS_CMDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.gateway_cmds import (
|
||||
COMMANDS as GATEWAY_CMDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.integrations import COMMANDS as INT_CMDS
|
||||
from surfaces.interactive_shell.command_registry.investigation import COMMANDS as INV_CMDS
|
||||
from surfaces.interactive_shell.command_registry.model import COMMANDS as MODEL_CMDS
|
||||
from surfaces.interactive_shell.command_registry.privacy_cmds import (
|
||||
COMMANDS as PRIVACY_CMDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.rca_cmds import COMMANDS as RCA_CMDS
|
||||
from surfaces.interactive_shell.command_registry.session_cmds import (
|
||||
COMMANDS as SESSION_CMDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.settings_cmds import (
|
||||
COMMANDS as SETTINGS_CMDS,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.system import COMMANDS as SYS_CMDS
|
||||
from surfaces.interactive_shell.command_registry.tasks_cmds import COMMANDS as TASK_CMDS
|
||||
from surfaces.interactive_shell.command_registry.theme import COMMANDS as THEME_CMDS
|
||||
from surfaces.interactive_shell.command_registry.tools_cmds import COMMANDS as TOOLS_CMDS
|
||||
from surfaces.interactive_shell.command_registry.watch_cmds import COMMANDS as WATCH_CMDS
|
||||
|
||||
return [
|
||||
_quick_access_section(),
|
||||
("Help", list(COMMANDS)),
|
||||
(
|
||||
"Session",
|
||||
list(SESSION_CMDS)
|
||||
+ list(BACKGROUND_CMDS)
|
||||
+ list(SETTINGS_CMDS)
|
||||
+ list(DIAGNOSTICS_CMDS),
|
||||
),
|
||||
("Integrations, Models & Tools", list(INT_CMDS) + list(MODEL_CMDS) + list(TOOLS_CMDS)),
|
||||
("Investigation", list(INV_CMDS) + list(RCA_CMDS)),
|
||||
("Privacy", list(PRIVACY_CMDS)),
|
||||
("Tasks", list(TASK_CMDS) + list(WATCH_CMDS) + list(GATEWAY_CMDS)),
|
||||
("Theme", list(THEME_CMDS)),
|
||||
("Agents", list(AGENTS_CMDS)),
|
||||
("Alerts", list(ALERTS_CMDS)),
|
||||
("CLI (parity)", list(PARITY_COMMANDS)),
|
||||
("System", list(SYS_CMDS)),
|
||||
]
|
||||
|
||||
|
||||
_QUICK_ACCESS_SECTION_NAME = "Quick Access"
|
||||
|
||||
|
||||
def _help_sections() -> list[HelpSection]:
|
||||
"""Return user-visible help sections with duplicate command names hidden.
|
||||
|
||||
The "Quick Access" section is intentionally exempted from the dedup set so
|
||||
its curated commands remain visible in their canonical sections too (e.g.
|
||||
``/help investigation`` still contains ``/investigate``).
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
sections: list[HelpSection] = []
|
||||
for section_name, commands in _raw_help_sections():
|
||||
visible: list[SlashCommand] = []
|
||||
is_quick_access = section_name == _QUICK_ACCESS_SECTION_NAME
|
||||
for command in commands:
|
||||
if command.name in seen:
|
||||
continue
|
||||
visible.append(command)
|
||||
if not is_quick_access:
|
||||
seen.add(command.name)
|
||||
sections.append((section_name, visible))
|
||||
return sections
|
||||
|
||||
|
||||
def _find_command(sections: Sequence[HelpSection], target: str) -> SlashCommand | None:
|
||||
normalized = target.strip().lower()
|
||||
if not normalized:
|
||||
return None
|
||||
if not normalized.startswith("/"):
|
||||
normalized = f"/{normalized}"
|
||||
for _section_name, commands in sections:
|
||||
for command in commands:
|
||||
if command.name.lower() == normalized:
|
||||
return command
|
||||
return None
|
||||
|
||||
|
||||
def _find_section(
|
||||
sections: Sequence[HelpSection],
|
||||
target: str,
|
||||
) -> tuple[str, Sequence[SlashCommand]] | None:
|
||||
normalized = target.strip().lower().replace("-", " ")
|
||||
for section_name, commands in sections:
|
||||
aliases = {
|
||||
section_name.lower(),
|
||||
section_name.lower().replace("&", "and"),
|
||||
section_name.lower().replace(" & ", " "),
|
||||
}
|
||||
if normalized in aliases:
|
||||
return section_name, commands
|
||||
return None
|
||||
|
||||
|
||||
def _cmd_help(_session: Session, console: Console, args: list[str]) -> bool:
|
||||
sections = _help_sections()
|
||||
if args:
|
||||
target = " ".join(args).strip()
|
||||
if target.lower() in {"all", "commands"}:
|
||||
render_help_index(console, sections)
|
||||
return True
|
||||
if not target.startswith("/"):
|
||||
section = _find_section(sections, target)
|
||||
if section is not None:
|
||||
section_name, commands = section
|
||||
render_section_detail(console, section_name, commands)
|
||||
return True
|
||||
command = _find_command(sections, target)
|
||||
if command is not None:
|
||||
render_command_detail(console, command)
|
||||
return True
|
||||
console.print(f"[{ERROR}]unknown help topic:[/] {escape(target)}")
|
||||
console.print(
|
||||
"Try [bold]/help[/bold], [bold]/help /model[/bold], or [bold]/help tasks[/bold]."
|
||||
)
|
||||
return True
|
||||
|
||||
if repl_tty_interactive():
|
||||
selected = choose_help_command(sections)
|
||||
if selected:
|
||||
# Re-dispatch the selected slash command so Enter in the help picker
|
||||
# runs the command directly instead of only opening details.
|
||||
from surfaces.interactive_shell.command_registry import dispatch_slash
|
||||
|
||||
return dispatch_slash(selected, _session, console)
|
||||
return True
|
||||
|
||||
render_help_index(console, sections)
|
||||
return True
|
||||
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/help",
|
||||
"Show available commands.",
|
||||
_cmd_help,
|
||||
usage=("/help", "/help <command>", "/help <category>"),
|
||||
examples=("/help /model", "/help tasks"),
|
||||
),
|
||||
SlashCommand("/?", "Shortcut for /help.", _cmd_help),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS", "QUICK_ACCESS_COMMANDS"]
|
||||
@@ -0,0 +1,508 @@
|
||||
"""Slash commands for /integrations and /mcp."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
import surfaces.interactive_shell.command_registry.repl_data as repl_data
|
||||
from core.agent_harness.session.terminal_access import session_terminal
|
||||
from surfaces.interactive_shell.command_registry.cli_parity import (
|
||||
publish_headless_slash_response,
|
||||
run_cli_command,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import (
|
||||
BOLD_BRAND,
|
||||
DIM,
|
||||
ERROR,
|
||||
HIGHLIGHT,
|
||||
MCP_INTEGRATION_SERVICES,
|
||||
WARNING,
|
||||
render_integrations_table,
|
||||
render_mcp_table,
|
||||
repl_table,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import (
|
||||
CRUMB_SEP,
|
||||
prepare_repl_output_line,
|
||||
repl_choose_one,
|
||||
repl_section_break,
|
||||
repl_tty_interactive,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.rendering import (
|
||||
_repl_table_width,
|
||||
print_repl_table,
|
||||
repl_print,
|
||||
)
|
||||
|
||||
_ROOT_INTEGRATIONS = "/integrations"
|
||||
_ROOT_MCP = "/mcp"
|
||||
|
||||
_MAX_OBSERVATION_DETAIL_CHARS = 160
|
||||
|
||||
|
||||
def _record_integrations_observation(session: Session, results: list[dict[str, str]]) -> None:
|
||||
"""Stash a compact text view of verification results for agent summarization.
|
||||
|
||||
Lets the agent answer questions like "is sentry installed?" by summarizing
|
||||
what ``/integrations`` actually found, instead of leaving the user with only
|
||||
a raw table. Kept plain-text and bounded so it is cheap to feed back to the
|
||||
assistant.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for record in results:
|
||||
service = str(record.get("service", "")).strip()
|
||||
if not service:
|
||||
continue
|
||||
status = str(record.get("status", "")).strip() or "unknown"
|
||||
detail = str(record.get("detail", "")).strip()
|
||||
if len(detail) > _MAX_OBSERVATION_DETAIL_CHARS:
|
||||
detail = f"{detail[: _MAX_OBSERVATION_DETAIL_CHARS - 1]}…"
|
||||
line = f"- {service}: {status}"
|
||||
if detail:
|
||||
line += f" ({detail})"
|
||||
lines.append(line)
|
||||
if lines:
|
||||
session.agent.last_observation = "Integration status from `/integrations`:\n" + "\n".join(
|
||||
lines
|
||||
)
|
||||
|
||||
|
||||
def _record_integration_show_observation(session: Session, match: dict[str, str]) -> None:
|
||||
"""Stash a compact text view of a single integration's verified details."""
|
||||
lines: list[str] = []
|
||||
for key, value in match.items():
|
||||
text = str(value).strip()
|
||||
if len(text) > _MAX_OBSERVATION_DETAIL_CHARS:
|
||||
text = f"{text[: _MAX_OBSERVATION_DETAIL_CHARS - 1]}…"
|
||||
lines.append(f"- {key}: {text}")
|
||||
if lines:
|
||||
session.agent.last_observation = (
|
||||
"Integration detail from `/integrations show`:\n" + "\n".join(lines)
|
||||
)
|
||||
|
||||
|
||||
def _configured_service_choices() -> list[tuple[str, str]]:
|
||||
"""Build picker choices from configured integrations (no live verification)."""
|
||||
return [(name, name) for name in repl_data.configured_integration_names()]
|
||||
|
||||
|
||||
def _handle_remove(session: Session, console: Console, service: str | None) -> bool:
|
||||
"""Remove an integration with a native inline-picker confirmation (no subprocess)."""
|
||||
from integrations.registry import resolve_management_service
|
||||
from integrations.store import remove_integration
|
||||
from platform.analytics.cli import capture_integration_removed
|
||||
|
||||
svc = resolve_management_service(service) if service else service
|
||||
if not svc:
|
||||
if not repl_tty_interactive():
|
||||
repl_print(console, f"[{DIM}]usage:[/] /integrations remove <service>")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
choices = _configured_service_choices()
|
||||
if not choices:
|
||||
repl_print(console, f"[{DIM}]no integrations in store to remove.[/]")
|
||||
return True
|
||||
svc = repl_choose_one(
|
||||
title="select integration to remove",
|
||||
breadcrumb=f"{_ROOT_INTEGRATIONS}{CRUMB_SEP}remove",
|
||||
choices=choices,
|
||||
)
|
||||
if not svc:
|
||||
return True
|
||||
|
||||
if repl_tty_interactive():
|
||||
confirmed = repl_choose_one(
|
||||
title=f"remove '{escape(svc)}'?",
|
||||
breadcrumb=f"{_ROOT_INTEGRATIONS}{CRUMB_SEP}remove{CRUMB_SEP}{escape(svc)}",
|
||||
choices=[
|
||||
("no", "No, cancel"),
|
||||
("yes", f"Yes, remove '{svc}'"),
|
||||
],
|
||||
)
|
||||
prepare_repl_output_line()
|
||||
if confirmed != "yes":
|
||||
repl_print(console, f"[{DIM}]cancelled.[/]")
|
||||
session.refresh_integration_state()
|
||||
return True
|
||||
else:
|
||||
import sys
|
||||
|
||||
try:
|
||||
import questionary
|
||||
|
||||
confirmed_bool = questionary.confirm(f" Remove '{svc}'?", default=False).ask()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
session.refresh_integration_state()
|
||||
return True
|
||||
if not confirmed_bool:
|
||||
print(" Cancelled.", file=sys.stderr)
|
||||
session.refresh_integration_state()
|
||||
return True
|
||||
|
||||
if remove_integration(svc):
|
||||
repl_print(console, f"[{HIGHLIGHT}]removed '{escape(svc)}'.[/]")
|
||||
capture_integration_removed(svc)
|
||||
if svc == "github":
|
||||
from surfaces.interactive_shell.runtime.startup.first_launch_github import (
|
||||
clear_github_login_deferral,
|
||||
)
|
||||
|
||||
clear_github_login_deferral()
|
||||
else:
|
||||
repl_print(console, f"[{ERROR}]no integration found for:[/] {escape(svc)}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
session.refresh_integration_state()
|
||||
return True
|
||||
|
||||
|
||||
def _mcp_service_choices() -> list[tuple[str, str]]:
|
||||
names = [
|
||||
name
|
||||
for name in repl_data.configured_integration_names()
|
||||
if name in MCP_INTEGRATION_SERVICES
|
||||
]
|
||||
return [(name, name) for name in names]
|
||||
|
||||
|
||||
def _print_verify_summary(
|
||||
console: Console, results: list[dict[str, str]], *, single_service: bool
|
||||
) -> None:
|
||||
failed = [r for r in results if r.get("status") in ("failed", "missing")]
|
||||
if single_service:
|
||||
if not results:
|
||||
return
|
||||
service = escape(str(results[0].get("service", "?")))
|
||||
style = WARNING if failed else HIGHLIGHT
|
||||
detail = "needs attention" if failed else "ok"
|
||||
repl_print(console, f"[{style}]{service} {detail}.[/]")
|
||||
return
|
||||
if failed:
|
||||
repl_print(console, f"[{WARNING}]{len(failed)} integration(s) need attention.[/]")
|
||||
else:
|
||||
repl_print(console, f"[{HIGHLIGHT}]all integrations ok.[/]")
|
||||
|
||||
|
||||
def _run_verify(session: Session, console: Console, service: str | None = None) -> bool:
|
||||
normalized = ""
|
||||
if service is not None:
|
||||
from integrations.registry import SUPPORTED_VERIFY_SERVICES, resolve_management_service
|
||||
|
||||
normalized = resolve_management_service(service)
|
||||
if normalized not in SUPPORTED_VERIFY_SERVICES:
|
||||
repl_print(
|
||||
console,
|
||||
f"[{ERROR}]unsupported verify target:[/] {escape(normalized)} "
|
||||
f"(try [bold]/verify[/bold] with no args to verify all)",
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
prepare_repl_output_line()
|
||||
label = escape(normalized) if service is not None else "integrations"
|
||||
with console.status(f"[{DIM}]Verifying {label}…[/]", spinner="dots"):
|
||||
if service is not None:
|
||||
match = repl_data.verify_integration(normalized)
|
||||
if match is None:
|
||||
repl_print(console, f"[{ERROR}]service not found:[/] {escape(normalized)}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
results = [match]
|
||||
else:
|
||||
results = repl_data.load_verified_integrations()
|
||||
|
||||
_record_integrations_observation(session, results)
|
||||
render_integrations_table(console, results)
|
||||
_print_verify_summary(console, results, single_service=service is not None)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_verify(session: Session, console: Console, args: list[str]) -> bool:
|
||||
return _cmd_integrations(session, console, ["verify", *args])
|
||||
|
||||
|
||||
def _render_integration_show(session: Session, console: Console, service: str) -> bool:
|
||||
"""Verify and print one integration. Returns False when the service is unknown."""
|
||||
from integrations.registry import resolve_management_service
|
||||
|
||||
normalized = resolve_management_service(service)
|
||||
configured = set(repl_data.configured_integration_names())
|
||||
if normalized not in configured:
|
||||
repl_print(console, f"[{ERROR}]service not found:[/] {escape(normalized)}")
|
||||
return False
|
||||
|
||||
prepare_repl_output_line()
|
||||
with console.status(
|
||||
f"[{DIM}]Verifying {escape(normalized)}…[/]",
|
||||
spinner="dots",
|
||||
):
|
||||
match = repl_data.verify_integration(normalized)
|
||||
if match is None:
|
||||
repl_print(console, f"[{ERROR}]service not found:[/] {escape(normalized)}")
|
||||
return False
|
||||
|
||||
_record_integration_show_observation(session, match)
|
||||
|
||||
width = _repl_table_width(console)
|
||||
table = repl_table(
|
||||
title=f"Integration: {normalized}",
|
||||
title_style=BOLD_BRAND,
|
||||
show_header=False,
|
||||
width=width,
|
||||
)
|
||||
table.add_column("key", style="bold", no_wrap=True)
|
||||
value_width = max(20, width - 20)
|
||||
table.add_column("value", overflow="fold", max_width=value_width)
|
||||
for key, value in match.items():
|
||||
table.add_row(escape(key), escape(str(value)))
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
|
||||
|
||||
def _run_integrations_setup(session: Session, console: Console, args: list[str]) -> bool:
|
||||
headless = session_terminal(session) is None
|
||||
if len(args) < 2:
|
||||
# Bare setup delegates to the CLI service picker on the REPL; headless
|
||||
# surfaces (Telegram) have no picker, so return usage guidance instead.
|
||||
if not headless:
|
||||
result = run_cli_command(console, ["integrations", "setup"])
|
||||
session.refresh_integration_state()
|
||||
return result
|
||||
repl_print(console, f"[{DIM}]usage:[/] /integrations setup <service>")
|
||||
publish_headless_slash_response(
|
||||
session, message="Usage: /integrations setup <service>", ok=False
|
||||
)
|
||||
return True
|
||||
|
||||
service = args[1]
|
||||
cli_cmd = " ".join(["uv run opensre integrations setup", service, *args[2:]]).strip()
|
||||
if headless:
|
||||
message = (
|
||||
f"{escape(service)} setup needs interactive credentials (API keys, URLs, tokens) "
|
||||
f"and cannot finish in Telegram.\n\n"
|
||||
f"Run on the server:\n {cli_cmd}\n\n"
|
||||
"Then check status with `/integrations list` or "
|
||||
f"`/integrations verify {escape(service)}`."
|
||||
)
|
||||
repl_print(console, message)
|
||||
publish_headless_slash_response(session, message=message, ok=True)
|
||||
session.refresh_integration_state()
|
||||
return True
|
||||
|
||||
result = run_cli_command(
|
||||
console,
|
||||
["integrations", "setup", service, *args[2:]],
|
||||
session=session,
|
||||
)
|
||||
session.refresh_integration_state()
|
||||
return result
|
||||
|
||||
|
||||
def _cmd_integrations(session: Session, console: Console, args: list[str]) -> bool:
|
||||
if not args and repl_tty_interactive():
|
||||
return _interactive_integrations_menu(session, console)
|
||||
|
||||
sub = (args[0].lower() if args else "list").strip()
|
||||
|
||||
if sub in ("list", "ls"):
|
||||
prepare_repl_output_line()
|
||||
with console.status(f"[{DIM}]Verifying integrations…[/]", spinner="dots"):
|
||||
results = repl_data.load_verified_integrations()
|
||||
_record_integrations_observation(session, results)
|
||||
render_integrations_table(console, results)
|
||||
return True
|
||||
|
||||
if sub == "verify":
|
||||
if len(args) > 2:
|
||||
repl_print(
|
||||
console,
|
||||
f"[{DIM}]usage:[/] /integrations verify [service] "
|
||||
f"(or [bold]/verify [service][/bold])",
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
return _run_verify(session, console, args[1] if len(args) == 2 else None)
|
||||
|
||||
if sub == "setup":
|
||||
return _run_integrations_setup(session, console, args)
|
||||
|
||||
if sub == "remove":
|
||||
return _handle_remove(session, console, args[1] if len(args) > 1 else None)
|
||||
|
||||
if sub == "show":
|
||||
if len(args) < 2:
|
||||
repl_print(console, f"[{DIM}]usage:[/] /integrations show <service>")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
if not _render_integration_show(session, console, args[1]):
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
repl_print(
|
||||
console,
|
||||
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
|
||||
"(try [bold]/integrations list[/bold], [bold]/integrations verify[/bold], "
|
||||
"or [bold]/integrations show <service>[/bold])",
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
|
||||
def _interactive_integrations_menu(session: Session, console: Console) -> bool:
|
||||
root = _ROOT_INTEGRATIONS
|
||||
while True:
|
||||
sub = repl_choose_one(
|
||||
title="integrations",
|
||||
breadcrumb=root,
|
||||
choices=[
|
||||
("list", "/integrations list"),
|
||||
("verify", "/integrations verify"),
|
||||
("show", "/integrations show <service>"),
|
||||
("setup", "/integrations setup <service>"),
|
||||
("remove", "/integrations remove <service>"),
|
||||
("done", "done"),
|
||||
],
|
||||
)
|
||||
if sub is None or sub == "done":
|
||||
return True
|
||||
show_section_break = False
|
||||
if sub == "list":
|
||||
_cmd_integrations(session, console, ["list"])
|
||||
show_section_break = True
|
||||
elif sub == "verify":
|
||||
_cmd_integrations(session, console, ["verify"])
|
||||
show_section_break = True
|
||||
elif sub == "setup":
|
||||
_cmd_integrations(session, console, ["setup"])
|
||||
show_section_break = True
|
||||
elif sub == "show":
|
||||
choices = _configured_service_choices()
|
||||
if not choices:
|
||||
repl_print(console, f"[{DIM}]no integrations in store to show.[/]")
|
||||
show_section_break = True
|
||||
else:
|
||||
svc = repl_choose_one(
|
||||
title="service",
|
||||
breadcrumb=f"{root}{CRUMB_SEP}show",
|
||||
choices=choices,
|
||||
)
|
||||
if svc and _render_integration_show(session, console, svc):
|
||||
show_section_break = True
|
||||
elif sub == "remove":
|
||||
_handle_remove(session, console, None)
|
||||
show_section_break = True
|
||||
if show_section_break:
|
||||
repl_section_break(console)
|
||||
|
||||
|
||||
def _cmd_mcp(session: Session, console: Console, args: list[str]) -> bool:
|
||||
if not args and repl_tty_interactive():
|
||||
return _interactive_mcp_menu(session, console)
|
||||
|
||||
sub = (args[0].lower() if args else "list").strip()
|
||||
|
||||
if sub in ("list", "ls"):
|
||||
render_mcp_table(console, repl_data.load_verified_integrations())
|
||||
return True
|
||||
|
||||
if sub == "connect":
|
||||
return _run_integrations_setup(session, console, ["setup", *args[1:]])
|
||||
|
||||
if sub == "disconnect":
|
||||
return _handle_remove(session, console, args[1] if len(args) > 1 else None)
|
||||
|
||||
console.print(
|
||||
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
|
||||
"(try [bold]/mcp list[/bold], [bold]/mcp connect[/bold], or [bold]/mcp disconnect[/bold])"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _interactive_mcp_menu(session: Session, console: Console) -> bool:
|
||||
root = _ROOT_MCP
|
||||
while True:
|
||||
sub = repl_choose_one(
|
||||
title="mcp",
|
||||
breadcrumb=root,
|
||||
choices=[
|
||||
("list", "/mcp list"),
|
||||
("connect", "/mcp connect <server>"),
|
||||
("disconnect", "/mcp disconnect <server>"),
|
||||
("done", "done"),
|
||||
],
|
||||
)
|
||||
if sub is None or sub == "done":
|
||||
return True
|
||||
show_section_break = False
|
||||
if sub == "list":
|
||||
_cmd_mcp(session, console, ["list"])
|
||||
show_section_break = True
|
||||
elif sub == "connect":
|
||||
_cmd_mcp(session, console, ["connect"])
|
||||
show_section_break = True
|
||||
elif sub == "disconnect":
|
||||
choices = _mcp_service_choices()
|
||||
if not choices:
|
||||
repl_print(console, f"[{DIM}]no MCP servers configured.[/]")
|
||||
show_section_break = True
|
||||
else:
|
||||
svc = repl_choose_one(
|
||||
title="server",
|
||||
breadcrumb=f"{root}{CRUMB_SEP}disconnect",
|
||||
choices=choices,
|
||||
)
|
||||
if svc:
|
||||
_cmd_mcp(session, console, ["disconnect", svc])
|
||||
show_section_break = True
|
||||
if show_section_break:
|
||||
repl_section_break(console)
|
||||
|
||||
|
||||
_INTEGRATIONS_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("list", "list all configured integrations"),
|
||||
("ls", "alias for list"),
|
||||
("verify", "run health checks on all integrations"),
|
||||
("show", "show details for a single integration"),
|
||||
)
|
||||
|
||||
_MCP_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("list", "list connected MCP servers"),
|
||||
("ls", "alias for list"),
|
||||
("connect", "add an MCP server via opensre integrations setup"),
|
||||
("disconnect", "remove an MCP server"),
|
||||
)
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/verify",
|
||||
"Verify configured integration connectivity.",
|
||||
_cmd_verify,
|
||||
usage=("/verify", "/verify <service>"),
|
||||
),
|
||||
SlashCommand(
|
||||
"/integrations",
|
||||
"Manage integrations.",
|
||||
_cmd_integrations,
|
||||
usage=(
|
||||
"/integrations",
|
||||
"/integrations list",
|
||||
"/integrations verify",
|
||||
"/integrations verify <service>",
|
||||
"/integrations show <service>",
|
||||
),
|
||||
notes=("In a TTY, bare /integrations opens an interactive menu.",),
|
||||
first_arg_completions=_INTEGRATIONS_FIRST_ARGS,
|
||||
),
|
||||
SlashCommand(
|
||||
"/mcp",
|
||||
"Manage MCP servers.",
|
||||
_cmd_mcp,
|
||||
usage=("/mcp", "/mcp list", "/mcp connect", "/mcp disconnect"),
|
||||
notes=("In a TTY, bare /mcp opens an interactive menu.",),
|
||||
first_arg_completions=_MCP_FIRST_ARGS,
|
||||
),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS"]
|
||||
@@ -0,0 +1,533 @@
|
||||
"""Slash commands: /investigate, /template, /last, /save."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from config.llm_reasoning_effort import apply_reasoning_effort
|
||||
from core.agent_harness.session.terminal_access import (
|
||||
background_mode_enabled,
|
||||
session_terminal,
|
||||
)
|
||||
from platform.common.task_types import TaskRecord
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.runtime.background.runner import (
|
||||
start_background_template_investigation,
|
||||
start_background_text_investigation,
|
||||
)
|
||||
from surfaces.interactive_shell.ui import (
|
||||
DIM,
|
||||
ERROR,
|
||||
HIGHLIGHT,
|
||||
print_repl_json,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import (
|
||||
repl_choose_one,
|
||||
repl_section_break,
|
||||
repl_tty_interactive,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.foreground_investigation import run_foreground_investigation
|
||||
from surfaces.interactive_shell.ui.investigation_outcome import (
|
||||
InvestigationOutcome,
|
||||
normalize_investigation_target,
|
||||
)
|
||||
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
|
||||
from surfaces.interactive_shell.utils.telemetry.investigation_analytics import (
|
||||
publish_investigation_outcome_analytics,
|
||||
)
|
||||
from surfaces.interactive_shell.utils.telemetry.turn_outcome import (
|
||||
format_investigation_outcome,
|
||||
format_investigation_terminal_outcome,
|
||||
)
|
||||
|
||||
|
||||
def _interactive_template_menu(session: Session, console: Console) -> bool:
|
||||
from surfaces.cli.constants import ALERT_TEMPLATE_CHOICES
|
||||
|
||||
root = "/template"
|
||||
choices: list[tuple[str, str]] = [(c, c) for c in ALERT_TEMPLATE_CHOICES]
|
||||
choices.append(("done", "done"))
|
||||
while True:
|
||||
name = repl_choose_one(
|
||||
title="template",
|
||||
breadcrumb=root,
|
||||
choices=choices,
|
||||
)
|
||||
if name is None or name == "done":
|
||||
return True
|
||||
_cmd_template(session, console, [name])
|
||||
repl_section_break(console)
|
||||
|
||||
|
||||
def _queue_investigate_target(session: Session, target: str) -> None:
|
||||
"""Defer a menu selection to a normal ``/investigate <target>`` turn.
|
||||
|
||||
The interactive picker needs exclusive stdin, but long-running RCA must not
|
||||
hold it — queue the resolved target so the loop auto-submits it on the next
|
||||
prompt iteration without ``queue.join()`` blocking.
|
||||
"""
|
||||
session.terminal.set_auto_command(f"/investigate {target}")
|
||||
|
||||
|
||||
def _interactive_investigate_menu(session: Session, console: Console) -> bool:
|
||||
from surfaces.cli.constants import SAMPLE_ALERT_OPTIONS
|
||||
|
||||
root = "/investigate"
|
||||
choices: list[tuple[str, str]] = [
|
||||
("alert.json", "alert.json (bundled demo alert file)"),
|
||||
]
|
||||
choices.extend(SAMPLE_ALERT_OPTIONS)
|
||||
choices.append(("__browse__", "custom file path…"))
|
||||
choices.append(("done", "done"))
|
||||
|
||||
while True:
|
||||
target = repl_choose_one(
|
||||
title="investigate",
|
||||
breadcrumb=root,
|
||||
choices=choices,
|
||||
)
|
||||
if target is None or target == "done":
|
||||
return True
|
||||
if target == "__browse__":
|
||||
custom_path = _prompt_investigate_path(console)
|
||||
if custom_path is None:
|
||||
continue
|
||||
target = custom_path
|
||||
_queue_investigate_target(session, target)
|
||||
return True
|
||||
|
||||
|
||||
def _prompt_investigate_path(console: Console) -> str | None:
|
||||
"""Prompt for a user-supplied alert path from the investigate picker."""
|
||||
console.print()
|
||||
console.print(
|
||||
f"[{DIM}]Enter a local alert file path (.json/.md/.txt). Use absolute or relative path.[/]"
|
||||
)
|
||||
try:
|
||||
value = console.input(f"[{HIGHLIGHT}]file path> [/]").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
return value if value else None
|
||||
|
||||
|
||||
def _cmd_template(session: Session, console: Console, args: list[str]) -> bool:
|
||||
from surfaces.cli.constants import ALERT_TEMPLATE_CHOICES
|
||||
from tools.investigation.alert_templates import build_alert_template
|
||||
|
||||
if not args and repl_tty_interactive():
|
||||
return _interactive_template_menu(session, console)
|
||||
|
||||
if not args:
|
||||
console.print(
|
||||
f"[{DIM}]usage:[/] /template <type> (choices: {', '.join(ALERT_TEMPLATE_CHOICES)})"
|
||||
)
|
||||
return True
|
||||
|
||||
template_name = args[0].lower()
|
||||
try:
|
||||
payload = build_alert_template(template_name)
|
||||
except ValueError:
|
||||
console.print(
|
||||
f"[{ERROR}]unknown template:[/] {escape(template_name)} "
|
||||
f"(choices: {', '.join(ALERT_TEMPLATE_CHOICES)})"
|
||||
)
|
||||
return True
|
||||
|
||||
print_repl_json(console, json.dumps(payload, indent=2))
|
||||
return True
|
||||
|
||||
|
||||
def _validate_investigate_args(args: list[str]) -> str | None:
|
||||
if not args and repl_tty_interactive():
|
||||
return None
|
||||
if not args:
|
||||
return (
|
||||
f"[{DIM}]usage:[/] /investigate <file|template> "
|
||||
f"(e.g. /investigate alert.json or /investigate generic)"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _validate_save_args(args: list[str]) -> str | None:
|
||||
if not args:
|
||||
return f"[{DIM}]usage:[/] /save <path> (e.g. /save report.md or /save out.json)"
|
||||
return None
|
||||
|
||||
|
||||
def _stage_investigation_turn_telemetry(session: Session, outcome: InvestigationOutcome) -> None:
|
||||
"""Stage LLM run metadata and structured errors for this turn's recorder flush."""
|
||||
from core.agent_harness.accounting.token_accounting import LlmRunInfo, record_llm_turn
|
||||
|
||||
if outcome.llm_input_tokens or outcome.llm_output_tokens:
|
||||
record_llm_turn(
|
||||
session,
|
||||
prompt="",
|
||||
response="",
|
||||
input_tokens=outcome.llm_input_tokens,
|
||||
output_tokens=outcome.llm_output_tokens,
|
||||
)
|
||||
|
||||
terminal = session_terminal(session)
|
||||
if terminal is None:
|
||||
return
|
||||
|
||||
if outcome.llm_model or outcome.llm_input_tokens or outcome.llm_output_tokens:
|
||||
terminal.set_pending_turn_llm(
|
||||
LlmRunInfo(
|
||||
model=outcome.llm_model or None,
|
||||
provider=outcome.llm_provider or None,
|
||||
latency_ms=outcome.duration_ms or None,
|
||||
input_tokens=outcome.llm_input_tokens or None,
|
||||
output_tokens=outcome.llm_output_tokens or None,
|
||||
)
|
||||
)
|
||||
if outcome.status == "failed":
|
||||
terminal.set_pending_turn_error(
|
||||
outcome.failure_category or "unknown",
|
||||
outcome.error_message or "investigation failed",
|
||||
)
|
||||
|
||||
|
||||
def _record_investigation_turn(
|
||||
session: Session,
|
||||
*,
|
||||
command_line: str,
|
||||
outcome: InvestigationOutcome,
|
||||
) -> None:
|
||||
ok = outcome.status == "completed"
|
||||
response_text = format_investigation_terminal_outcome(
|
||||
command_line,
|
||||
target=outcome.target,
|
||||
ok=ok,
|
||||
final_state=outcome.final_state,
|
||||
error_message=outcome.error_message,
|
||||
status=outcome.status,
|
||||
)
|
||||
session.record(
|
||||
"alert",
|
||||
command_line,
|
||||
ok=ok,
|
||||
response_text=response_text,
|
||||
)
|
||||
if not ok:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
if outcome.investigation_id:
|
||||
session.last_investigation_id = outcome.investigation_id
|
||||
_stage_investigation_turn_telemetry(session, outcome)
|
||||
publish_investigation_outcome_analytics(outcome)
|
||||
|
||||
|
||||
def _cmd_investigate_file(session: Session, console: Console, args: list[str]) -> bool:
|
||||
from platform.analytics.cli import track_investigation
|
||||
from platform.analytics.source import EntrypointSource, TriggerMode
|
||||
from surfaces.cli.constants import ALERT_TEMPLATE_CHOICES
|
||||
from surfaces.cli.investigation.payload import resolve_alert_path
|
||||
from surfaces.interactive_shell.runtime.investigation_adapter import (
|
||||
run_investigation_for_session,
|
||||
run_sample_alert_for_session,
|
||||
)
|
||||
|
||||
if not args and repl_tty_interactive():
|
||||
return _interactive_investigate_menu(session, console)
|
||||
if not args:
|
||||
console.print(
|
||||
f"[{DIM}]usage:[/] /investigate <file|template> "
|
||||
f"(e.g. /investigate alert.json or /investigate generic)"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
raw_target = args[0]
|
||||
normalized_target = raw_target.strip().lower()
|
||||
template_name = normalized_target
|
||||
for prefix in ("sample:", "template:"):
|
||||
if template_name.startswith(prefix):
|
||||
template_name = template_name[len(prefix) :].strip()
|
||||
break
|
||||
if template_name not in ALERT_TEMPLATE_CHOICES:
|
||||
template_name = ""
|
||||
|
||||
# Treat canonical template names as templates even if same-named files exist
|
||||
# in the working directory. Users can still force file mode with an explicit
|
||||
# path form (for example: ``/investigate ./generic``).
|
||||
if template_name:
|
||||
target_slug = normalize_investigation_target(template_name)
|
||||
if background_mode_enabled(session):
|
||||
start_background_template_investigation(
|
||||
template_name=template_name,
|
||||
session=session,
|
||||
console=console,
|
||||
display_command=f"/investigate {template_name}",
|
||||
investigation_target=target_slug,
|
||||
)
|
||||
session.record(
|
||||
"alert",
|
||||
f"/investigate {template_name}",
|
||||
response_text=format_investigation_outcome(
|
||||
target_slug,
|
||||
background=True,
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
def _run_template(task: TaskRecord) -> dict[str, object]:
|
||||
with (
|
||||
apply_reasoning_effort(session.reasoning_effort),
|
||||
track_investigation(
|
||||
entrypoint=EntrypointSource.CLI_REPL_FILE,
|
||||
trigger_mode=TriggerMode.FILE,
|
||||
input_path=f"template:{template_name}",
|
||||
interactive=True,
|
||||
session=session,
|
||||
investigation_target=target_slug,
|
||||
) as tracker,
|
||||
):
|
||||
final_state = run_sample_alert_for_session(
|
||||
template_name=template_name,
|
||||
context_overrides=session.accumulated_context or None,
|
||||
cancel_requested=task.cancel_requested,
|
||||
)
|
||||
tracker.record_loop_metrics_from_state(final_state)
|
||||
return final_state
|
||||
|
||||
command_line = f"/investigate {template_name}"
|
||||
outcome = run_foreground_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
task_command=command_line,
|
||||
run=_run_template,
|
||||
exception_context="surfaces.interactive_shell.investigate_template",
|
||||
target=target_slug,
|
||||
)
|
||||
_record_investigation_turn(session, command_line=command_line, outcome=outcome)
|
||||
return True
|
||||
|
||||
path = resolve_alert_path(raw_target)
|
||||
if not path.exists():
|
||||
console.print(f"[{ERROR}]file not found:[/] {escape(str(path))}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
report_exception(exc, context="surfaces.interactive_shell.investigate_file.read")
|
||||
console.print(f"[{ERROR}]cannot read file:[/] {escape(str(exc))}")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
if background_mode_enabled(session):
|
||||
target_slug = normalize_investigation_target(raw_target, path=path)
|
||||
start_background_text_investigation(
|
||||
alert_text=text,
|
||||
session=session,
|
||||
console=console,
|
||||
display_command=f"/investigate {path}",
|
||||
investigation_target=target_slug,
|
||||
)
|
||||
session.record(
|
||||
"alert",
|
||||
args[0],
|
||||
response_text=format_investigation_outcome(target_slug, background=True),
|
||||
)
|
||||
return True
|
||||
|
||||
target_slug = normalize_investigation_target(raw_target, path=path)
|
||||
|
||||
def _run_file(task: TaskRecord) -> dict[str, object]:
|
||||
with (
|
||||
apply_reasoning_effort(session.reasoning_effort),
|
||||
track_investigation(
|
||||
entrypoint=EntrypointSource.CLI_REPL_FILE,
|
||||
trigger_mode=TriggerMode.FILE,
|
||||
input_path=str(path),
|
||||
interactive=True,
|
||||
session=session,
|
||||
investigation_target=target_slug,
|
||||
) as tracker,
|
||||
):
|
||||
final_state = run_investigation_for_session(
|
||||
alert_text=text,
|
||||
context_overrides=session.accumulated_context or None,
|
||||
cancel_requested=task.cancel_requested,
|
||||
)
|
||||
tracker.record_loop_metrics_from_state(final_state)
|
||||
return final_state
|
||||
|
||||
command_line = f"/investigate {raw_target}"
|
||||
outcome = run_foreground_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
task_command=f"/investigate {path}",
|
||||
run=_run_file,
|
||||
exception_context="surfaces.interactive_shell.investigate_file",
|
||||
target=target_slug,
|
||||
)
|
||||
_record_investigation_turn(session, command_line=command_line, outcome=outcome)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_last(session: Session, console: Console, _args: list[str]) -> bool:
|
||||
if session.last_state is None:
|
||||
console.print(f"[{DIM}]no investigation in this session yet.[/]")
|
||||
return True
|
||||
|
||||
root_cause = session.last_state.get("root_cause", "")
|
||||
report = session.last_state.get("problem_md") or session.last_state.get("slack_message") or ""
|
||||
|
||||
if not root_cause and not report:
|
||||
console.print(f"[{DIM}]last investigation has no report content.[/]")
|
||||
return True
|
||||
|
||||
render_investigation_report(
|
||||
console,
|
||||
root_cause=str(root_cause),
|
||||
report=str(report),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def render_investigation_report(
|
||||
console: Console,
|
||||
*,
|
||||
root_cause: str,
|
||||
report: str,
|
||||
) -> None:
|
||||
"""Render root cause and report sections shared by /last and /rca show."""
|
||||
from rich.markdown import Markdown
|
||||
from rich.padding import Padding
|
||||
from rich.rule import Rule
|
||||
|
||||
for title, body in (("Root Cause", root_cause), ("Report", report)):
|
||||
if not body:
|
||||
continue
|
||||
console.print()
|
||||
console.print(Rule(f"[bold {HIGHLIGHT}] {title} [/]", style=DIM, align="left"))
|
||||
console.print(Padding(Markdown(str(body).strip()), (1, 2)))
|
||||
|
||||
|
||||
def write_investigation_export(
|
||||
dest: Path,
|
||||
*,
|
||||
root_cause: str = "",
|
||||
report: str = "",
|
||||
full_state: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
"""Write investigation content to ``dest`` as markdown or JSON."""
|
||||
if full_state is not None:
|
||||
if not root_cause:
|
||||
root_cause = str(full_state.get("root_cause") or "")
|
||||
if not report:
|
||||
report = str(
|
||||
full_state.get("problem_md")
|
||||
or full_state.get("slack_message")
|
||||
or full_state.get("report")
|
||||
or ""
|
||||
)
|
||||
|
||||
if dest.suffix.lower() == ".json":
|
||||
payload = dict(full_state) if full_state is not None else {}
|
||||
if root_cause:
|
||||
payload.setdefault("root_cause", root_cause)
|
||||
if report:
|
||||
payload.setdefault("problem_md", report)
|
||||
payload.setdefault("report", report)
|
||||
dest.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
|
||||
return
|
||||
|
||||
lines: list[str] = []
|
||||
if root_cause:
|
||||
lines.append(f"## Root Cause\n\n{root_cause}\n")
|
||||
if report:
|
||||
lines.append(f"## Report\n\n{report}\n")
|
||||
dest.write_text("\n".join(lines) or "(no report content)", encoding="utf-8")
|
||||
|
||||
|
||||
def _cmd_save(session: Session, console: Console, args: list[str]) -> bool:
|
||||
if session.last_state is None:
|
||||
console.print(f"[{DIM}]nothing to save — run an investigation first.[/]")
|
||||
return True
|
||||
|
||||
dest = Path(args[0])
|
||||
try:
|
||||
write_investigation_export(dest, full_state=session.last_state)
|
||||
console.print(f"[{HIGHLIGHT}]saved:[/] {escape(str(dest))}")
|
||||
except Exception as exc:
|
||||
report_exception(exc, context="surfaces.interactive_shell.save_report")
|
||||
console.print(f"[{ERROR}]save failed:[/] {escape(str(exc))}")
|
||||
return True
|
||||
|
||||
|
||||
_TEMPLATE_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("generic", "generic alert JSON template"),
|
||||
("datadog", "Datadog monitor alert template"),
|
||||
("grafana", "Grafana alert template"),
|
||||
("honeycomb", "Honeycomb trigger template"),
|
||||
("coralogix", "Coralogix alert template"),
|
||||
("splunk", "Splunk alert template"),
|
||||
)
|
||||
|
||||
_INVESTIGATE_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("alert.json", "run bundled demo alert file"),
|
||||
("generic", "run generic sample alert"),
|
||||
("datadog", "run Datadog sample alert"),
|
||||
("grafana", "run Grafana sample alert"),
|
||||
("honeycomb", "run Honeycomb sample alert"),
|
||||
("coralogix", "run Coralogix sample alert"),
|
||||
("splunk", "run Splunk sample alert"),
|
||||
)
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/template",
|
||||
"Print a starter alert JSON template.",
|
||||
_cmd_template,
|
||||
usage=(
|
||||
"/template",
|
||||
"/template generic",
|
||||
"/template datadog",
|
||||
"/template grafana",
|
||||
"/template honeycomb",
|
||||
"/template coralogix",
|
||||
"/template splunk",
|
||||
),
|
||||
notes=("In a TTY, bare /template opens an interactive menu.",),
|
||||
first_arg_completions=_TEMPLATE_FIRST_ARGS,
|
||||
),
|
||||
SlashCommand(
|
||||
"/investigate",
|
||||
"Run an RCA investigation from a file or sample template.",
|
||||
_cmd_investigate_file,
|
||||
usage=(
|
||||
"/investigate <file|template>",
|
||||
"/investigate alert.json",
|
||||
"/investigate generic",
|
||||
),
|
||||
notes=(
|
||||
"In a TTY, bare /investigate opens runnable demo/template options.",
|
||||
"Menu selections queue a normal /investigate <target> turn so the prompt "
|
||||
"stays free during RCA.",
|
||||
),
|
||||
first_arg_completions=_INVESTIGATE_FIRST_ARGS,
|
||||
validate_args=_validate_investigate_args,
|
||||
),
|
||||
SlashCommand(
|
||||
"/last",
|
||||
"Reprint the most recent investigation report.",
|
||||
_cmd_last,
|
||||
),
|
||||
SlashCommand(
|
||||
"/save",
|
||||
"Save the last investigation to a file.",
|
||||
_cmd_save,
|
||||
usage=("/save <path>",),
|
||||
validate_args=_validate_save_args,
|
||||
),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS"]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Slash command /model package exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from surfaces.interactive_shell.command_registry.model.command import (
|
||||
COMMANDS,
|
||||
parse_model_set_args,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.model.switching import (
|
||||
restore_default_model,
|
||||
switch_llm_provider,
|
||||
switch_reasoning_model,
|
||||
switch_toolcall_model,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"COMMANDS",
|
||||
"parse_model_set_args",
|
||||
"restore_default_model",
|
||||
"switch_llm_provider",
|
||||
"switch_reasoning_model",
|
||||
"switch_toolcall_model",
|
||||
]
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Slash command /model and interactive provider/model menus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
import surfaces.interactive_shell.command_registry.repl_data as repl_data
|
||||
from surfaces.interactive_shell.command_registry.model.switching import (
|
||||
_provider_allows_custom_models,
|
||||
restore_default_model,
|
||||
switch_llm_provider,
|
||||
switch_reasoning_model,
|
||||
switch_toolcall_model,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING, render_models_table
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import (
|
||||
CRUMB_SEP,
|
||||
repl_choose_one,
|
||||
repl_section_break,
|
||||
repl_tty_interactive,
|
||||
)
|
||||
|
||||
_ROOT = "/model" # breadcrumb root label
|
||||
|
||||
|
||||
def _provider_menu_choices() -> list[tuple[str, str]]:
|
||||
from surfaces.cli.wizard.config import SUPPORTED_PROVIDERS
|
||||
|
||||
current_provider = (os.getenv("LLM_PROVIDER", "anthropic") or "anthropic").strip().lower()
|
||||
options: list[tuple[str, str]] = []
|
||||
for provider in SUPPORTED_PROVIDERS:
|
||||
suffix = "*" if provider.value == current_provider else ""
|
||||
options.append((provider.value, f"{provider.value}{suffix}"))
|
||||
return options
|
||||
|
||||
|
||||
def _reasoning_model_menu_choices(provider: object) -> list[tuple[str, str]]:
|
||||
model_options = list(getattr(provider, "models", ()))
|
||||
choices: list[tuple[str, str]] = [
|
||||
("__provider_default__", "provider default (one step)"),
|
||||
]
|
||||
for option in model_options:
|
||||
value = str(getattr(option, "value", ""))
|
||||
display = value if value else "cli-default"
|
||||
choices.append((value, display))
|
||||
if _provider_allows_custom_models(provider):
|
||||
choices.append(("__custom__", "custom model ID"))
|
||||
return choices
|
||||
|
||||
|
||||
def _toolcall_model_menu_choices(provider: object) -> list[tuple[str, str]]:
|
||||
model_options = list(getattr(provider, "models", ()))
|
||||
choices: list[tuple[str, str]] = [
|
||||
("__keep__", "keep"),
|
||||
("__match_reasoning__", "match-reasoning"),
|
||||
]
|
||||
for option in model_options:
|
||||
value = str(getattr(option, "value", ""))
|
||||
display = value if value else "cli-default"
|
||||
choices.append((value, display))
|
||||
if _provider_allows_custom_models(provider):
|
||||
choices.append(("__custom__", "custom model ID"))
|
||||
return choices
|
||||
|
||||
|
||||
def _prompt_custom_model_id(console: Console, provider_value: str = "provider") -> str | None:
|
||||
"""Prompt the user to type a custom model ID."""
|
||||
console.print()
|
||||
console.print(
|
||||
f"[{DIM}]Enter a model ID for {escape(provider_value)}. "
|
||||
"The provider will validate availability when OpenSRE sends a request.[/]"
|
||||
)
|
||||
try:
|
||||
value = console.input(f"[{HIGHLIGHT}]model ID> [/]").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
return value if value else None
|
||||
|
||||
|
||||
def _interactive_set_provider(console: Console) -> bool | None:
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
|
||||
crumb_set = f"{_ROOT}{CRUMB_SEP}set"
|
||||
while True:
|
||||
provider_value = repl_choose_one(
|
||||
title="LLM provider",
|
||||
breadcrumb=crumb_set,
|
||||
choices=_provider_menu_choices(),
|
||||
)
|
||||
if provider_value is None:
|
||||
return None
|
||||
provider = PROVIDER_BY_VALUE.get(provider_value)
|
||||
if provider is None:
|
||||
return False
|
||||
|
||||
crumb_model = f"{crumb_set}{CRUMB_SEP}{provider_value}"
|
||||
while True:
|
||||
reasoning_choice = repl_choose_one(
|
||||
title="reasoning model",
|
||||
breadcrumb=crumb_model,
|
||||
choices=_reasoning_model_menu_choices(provider),
|
||||
)
|
||||
if reasoning_choice is None:
|
||||
break
|
||||
|
||||
if reasoning_choice == "__custom__":
|
||||
custom = _prompt_custom_model_id(console, provider.value)
|
||||
if custom is None:
|
||||
continue
|
||||
reasoning_choice = custom
|
||||
|
||||
model_choice = (
|
||||
None if reasoning_choice == "__provider_default__" else str(reasoning_choice)
|
||||
)
|
||||
toolcall_model: str | None = None
|
||||
# Default reasoning: switch provider + default reasoning only — do not
|
||||
# prompt for toolcall (matches non-interactive `/model set <provider>`).
|
||||
if provider.toolcall_model_env and reasoning_choice != "__provider_default__":
|
||||
crumb_tc = f"{crumb_model}{CRUMB_SEP}toolcall"
|
||||
while True:
|
||||
toolcall_value = repl_choose_one(
|
||||
title="toolcall model",
|
||||
breadcrumb=crumb_tc,
|
||||
choices=_toolcall_model_menu_choices(provider),
|
||||
)
|
||||
if toolcall_value is None:
|
||||
return None
|
||||
if toolcall_value == "__keep__":
|
||||
break
|
||||
if toolcall_value == "__match_reasoning__":
|
||||
toolcall_model = model_choice or provider.default_model
|
||||
break
|
||||
if toolcall_value == "__custom__":
|
||||
custom_tc = _prompt_custom_model_id(console, provider.value)
|
||||
if custom_tc is None:
|
||||
continue
|
||||
toolcall_model = custom_tc
|
||||
break
|
||||
toolcall_model = str(toolcall_value)
|
||||
break
|
||||
|
||||
return switch_llm_provider(
|
||||
provider.value,
|
||||
console,
|
||||
model=model_choice,
|
||||
toolcall_model=toolcall_model,
|
||||
)
|
||||
|
||||
|
||||
def _interactive_restore_provider(console: Console) -> bool | None:
|
||||
provider_value = repl_choose_one(
|
||||
title="LLM provider",
|
||||
breadcrumb=f"{_ROOT}{CRUMB_SEP}restore",
|
||||
choices=_provider_menu_choices(),
|
||||
)
|
||||
if provider_value is None:
|
||||
return None
|
||||
return restore_default_model(provider_value, console)
|
||||
|
||||
|
||||
def _interactive_set_toolcall(console: Console) -> bool | None:
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
|
||||
crumb_tc = f"{_ROOT}{CRUMB_SEP}toolcall"
|
||||
provider_value = repl_choose_one(
|
||||
title="LLM provider",
|
||||
breadcrumb=crumb_tc,
|
||||
choices=_provider_menu_choices(),
|
||||
)
|
||||
if provider_value is None:
|
||||
return None
|
||||
provider = PROVIDER_BY_VALUE.get(provider_value)
|
||||
if provider is None:
|
||||
return False
|
||||
if not provider.toolcall_model_env:
|
||||
console.print(
|
||||
f"[{WARNING}]provider {provider.value} does not expose a separate "
|
||||
"toolcall model[/] — nothing to set."
|
||||
)
|
||||
return False
|
||||
model_value = repl_choose_one(
|
||||
title="toolcall model",
|
||||
breadcrumb=f"{crumb_tc}{CRUMB_SEP}{provider_value}",
|
||||
choices=_toolcall_model_menu_choices(provider),
|
||||
)
|
||||
if model_value is None:
|
||||
return None
|
||||
if model_value == "__keep__":
|
||||
console.print("[dim]toolcall model left unchanged.[/dim]")
|
||||
return True
|
||||
if model_value == "__match_reasoning__":
|
||||
reasoning = (os.getenv(provider.model_env, "") or "").strip() or provider.default_model
|
||||
return switch_toolcall_model(reasoning, console, provider_name=provider.value)
|
||||
if model_value == "__custom__":
|
||||
custom_tc = _prompt_custom_model_id(console, provider.value)
|
||||
if custom_tc is None:
|
||||
return None
|
||||
model_value = custom_tc
|
||||
return switch_toolcall_model(str(model_value), console, provider_name=provider.value)
|
||||
|
||||
|
||||
def _interactive_model_menu(session: Session, console: Console) -> bool:
|
||||
while True:
|
||||
action = repl_choose_one(
|
||||
title="Select Model and Effort",
|
||||
breadcrumb=f"{_ROOT}",
|
||||
choices=[
|
||||
("show", "show"),
|
||||
("set", "set"),
|
||||
("restore", "restore"),
|
||||
("toolcall", "toolcall"),
|
||||
("done", "done"),
|
||||
],
|
||||
)
|
||||
if action is None or action == "done":
|
||||
return True
|
||||
if action == "show":
|
||||
repl_section_break(console)
|
||||
render_models_table(console, repl_data.load_llm_settings())
|
||||
repl_section_break(console)
|
||||
continue
|
||||
if action == "set":
|
||||
switched = _interactive_set_provider(console)
|
||||
if switched is None:
|
||||
continue
|
||||
if not switched:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
repl_section_break(console)
|
||||
continue
|
||||
return True
|
||||
if action == "restore":
|
||||
restored = _interactive_restore_provider(console)
|
||||
if restored is None:
|
||||
continue
|
||||
if not restored:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
repl_section_break(console)
|
||||
continue
|
||||
return True
|
||||
if action == "toolcall":
|
||||
switched = _interactive_set_toolcall(console)
|
||||
if switched is None:
|
||||
continue
|
||||
if not switched:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
repl_section_break(console)
|
||||
continue
|
||||
return True
|
||||
|
||||
|
||||
def parse_model_set_args(args: list[str]) -> tuple[str, str | None, str | None]:
|
||||
"""Parse `set <provider> [reasoning_model] [--toolcall-model <m>]`.
|
||||
|
||||
``args`` is the slice after the ``set``/``use``/``switch`` keyword.
|
||||
|
||||
Raises :class:`ValueError` with a user-facing message when the input is
|
||||
malformed.
|
||||
"""
|
||||
if not args:
|
||||
raise ValueError("missing provider name")
|
||||
|
||||
provider = args[0]
|
||||
reasoning_model: str | None = None
|
||||
toolcall_model: str | None = None
|
||||
|
||||
i = 1
|
||||
while i < len(args):
|
||||
token = args[i]
|
||||
if token == "--toolcall-model":
|
||||
if i + 1 >= len(args):
|
||||
raise ValueError("missing value for --toolcall-model")
|
||||
toolcall_model = args[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
if token.startswith("--"):
|
||||
raise ValueError(f"unknown flag: {token}")
|
||||
if reasoning_model is not None:
|
||||
raise ValueError(f"unexpected extra argument: {token}")
|
||||
reasoning_model = token
|
||||
i += 1
|
||||
|
||||
return provider, reasoning_model, toolcall_model
|
||||
|
||||
|
||||
def _cmd_model(session: Session, console: Console, args: list[str]) -> bool:
|
||||
if not args and repl_tty_interactive():
|
||||
return _interactive_model_menu(session, console)
|
||||
|
||||
sub = (args[0].lower() if args else "show").strip()
|
||||
|
||||
if sub == "show":
|
||||
render_models_table(console, repl_data.load_llm_settings())
|
||||
return True
|
||||
|
||||
if sub == "toolcall":
|
||||
if len(args) >= 2 and args[1].lower() == "show":
|
||||
render_models_table(console, repl_data.load_llm_settings())
|
||||
return True
|
||||
if len(args) >= 2 and args[1].lower() in ("set", "use", "switch"):
|
||||
if len(args) < 3:
|
||||
console.print(f"[{DIM}]usage:[/] /model toolcall set <model>")
|
||||
return True
|
||||
switch_toolcall_model(args[2], console)
|
||||
return True
|
||||
console.print(
|
||||
f"[{DIM}]usage:[/] /model toolcall set <model> "
|
||||
f"[{DIM}](sets the toolcall model for the active provider)[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
if sub in ("restore", "default", "reset"):
|
||||
if len(args) > 2:
|
||||
console.print(f"[{DIM}]usage:[/] /model restore [provider]")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
provider_name = args[1] if len(args) == 2 else os.getenv("LLM_PROVIDER", "anthropic")
|
||||
restored = restore_default_model(provider_name, console)
|
||||
if not restored:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
if sub in ("set", "use", "switch"):
|
||||
try:
|
||||
provider_name, reasoning_model, tc_model = parse_model_set_args(args[1:])
|
||||
except ValueError as exc:
|
||||
console.print()
|
||||
console.print(f"[{ERROR}]{escape(str(exc))}[/]")
|
||||
console.print()
|
||||
console.print(
|
||||
f"[{DIM}]usage:[/] /model set <provider> [model] [--toolcall-model <model>]"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
|
||||
if provider_name.strip().lower() not in PROVIDER_BY_VALUE:
|
||||
if tc_model is not None:
|
||||
console.print()
|
||||
console.print(f"[{ERROR}]--toolcall-model requires an explicit provider[/]")
|
||||
console.print()
|
||||
console.print(
|
||||
f"[{DIM}]usage:[/] /model set <provider> [model] [--toolcall-model <model>]"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
model_value = (
|
||||
provider_name if reasoning_model is None else f"{provider_name}-{reasoning_model}"
|
||||
)
|
||||
switched = switch_reasoning_model(model_value, console)
|
||||
if not switched:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
switched = switch_llm_provider(
|
||||
provider_name,
|
||||
console,
|
||||
model=reasoning_model,
|
||||
toolcall_model=tc_model,
|
||||
)
|
||||
if not switched:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
console.print(
|
||||
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
|
||||
"(try [bold]/model show[/bold], "
|
||||
"[bold]/model set <provider> [model] [--toolcall-model <m>][/bold], "
|
||||
"[bold]/model restore [provider][/bold], "
|
||||
"or [bold]/model toolcall set <model>[/bold])"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
_MODEL_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("show", "show active provider and models"),
|
||||
("set", "switch provider · /model set <provider> [model]"),
|
||||
("restore", "restore the active provider's default reasoning model"),
|
||||
("toolcall", "manage toolcall model for the active provider"),
|
||||
)
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/model",
|
||||
"Show or change active LLM settings.",
|
||||
_cmd_model,
|
||||
usage=(
|
||||
"/model",
|
||||
"/model show",
|
||||
"/model set <provider> [model] [--toolcall-model <model>]",
|
||||
"/model restore [provider]",
|
||||
"/model toolcall set <model>",
|
||||
),
|
||||
notes=(
|
||||
"In a TTY, bare /model opens an interactive menu.",
|
||||
"The menu stays open after show actions and closes after set, restore, or toolcall changes.",
|
||||
),
|
||||
first_arg_completions=_MODEL_FIRST_ARGS,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Provider/model switch helpers for the /model slash command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
import surfaces.interactive_shell.command_registry.repl_data as repl_data
|
||||
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING, render_models_table
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import print_valid_choice_list
|
||||
|
||||
|
||||
def _format_supported_models(provider_models: tuple[object, ...]) -> str:
|
||||
values = [str(getattr(model, "value", "")) for model in provider_models]
|
||||
visible = [value for value in values if value]
|
||||
return ", ".join(visible) if visible else "provider default"
|
||||
|
||||
|
||||
def _normalize_model_id(model: str) -> str:
|
||||
"""Collapse internal whitespace in a model id to single hyphens.
|
||||
|
||||
A model id is a single token, so a value like ``"gpt 5.5"`` is a mis-parsed
|
||||
``"gpt-5.5"``. The CLI path (``/model set gpt 5.5``) already rebuilds the id as
|
||||
``gpt-5.5``; normalizing here keeps the planner/tool path
|
||||
(``llm_set_provider`` -> ``switch_reasoning_model``) consistent so a custom-model
|
||||
provider (e.g. openai) can't persist a whitespace-bearing slug that later fails
|
||||
availability checks and silently falls back.
|
||||
"""
|
||||
return "-".join(model.split())
|
||||
|
||||
|
||||
def _is_model_supported(
|
||||
_provider_value: str, model: str, provider_models: tuple[object, ...]
|
||||
) -> bool:
|
||||
supported_values = {str(getattr(option, "value", "")) for option in provider_models}
|
||||
return model in supported_values
|
||||
|
||||
|
||||
def _provider_allows_custom_models(provider: object) -> bool:
|
||||
return bool(getattr(provider, "allow_custom_models", False))
|
||||
|
||||
|
||||
def _is_model_allowed(provider: object, model: str) -> bool:
|
||||
provider_value = str(getattr(provider, "value", ""))
|
||||
provider_models = getattr(provider, "models", ())
|
||||
if _is_model_supported(provider_value, model, provider_models):
|
||||
return True
|
||||
return bool(model) and _provider_allows_custom_models(provider)
|
||||
|
||||
|
||||
def _reset_runtime_llm_caches() -> None:
|
||||
"""Force subsequent REPL assistant calls to use the updated model env."""
|
||||
from core.llm.factory import reset_llm_clients
|
||||
|
||||
reset_llm_clients()
|
||||
|
||||
|
||||
def switch_llm_provider(
|
||||
provider_name: str,
|
||||
console: Console,
|
||||
model: str | None = None,
|
||||
*,
|
||||
toolcall_model: str | None = None,
|
||||
) -> bool:
|
||||
from config.llm_auth.credentials import status as credential_status
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
from surfaces.cli.wizard.env_sync import sync_provider_env
|
||||
|
||||
provider_key = provider_name.strip().lower()
|
||||
provider = PROVIDER_BY_VALUE.get(provider_key)
|
||||
if provider is None:
|
||||
console.print(f"[{ERROR}]unknown LLM provider:[/] {escape(provider_name)}")
|
||||
print_valid_choice_list(
|
||||
console,
|
||||
title="valid providers:",
|
||||
choices=sorted(PROVIDER_BY_VALUE),
|
||||
)
|
||||
return False
|
||||
|
||||
# Refuse to half-update .env when prompt-safe status says the target
|
||||
# provider has no credential path. Stale metadata gets a warning, because
|
||||
# confirming it requires an intentional request-time credential read.
|
||||
auth_status = credential_status(provider.value)
|
||||
if provider.value == "azure-openai":
|
||||
from core.llm.providers.azure_openai import azure_openai_endpoint_configured
|
||||
|
||||
if not azure_openai_endpoint_configured():
|
||||
console.print(
|
||||
f"[{ERROR}]missing Azure OpenAI endpoint config:[/] "
|
||||
"set AZURE_OPENAI_BASE_URL, or run [bold]opensre onboard[/bold]."
|
||||
)
|
||||
return False
|
||||
if provider.credential_secret and provider.api_key_env and not auth_status.configured:
|
||||
console.print(
|
||||
f"[{ERROR}]missing credential for {provider.value}:[/] "
|
||||
f"{provider.api_key_env} is not set."
|
||||
)
|
||||
if not getattr(console, "is_terminal", False):
|
||||
# Non-interactive (script/headless): no stdin to prompt on.
|
||||
console.print(
|
||||
f"[{DIM}]set it with[/] [bold]export {provider.api_key_env}=<your-key>[/bold] "
|
||||
f"[{DIM}]or run[/] [bold]opensre auth login {provider.value}[/bold] "
|
||||
f"[{DIM}]to save it, then rerun this command.[/]"
|
||||
)
|
||||
return False
|
||||
api_key = console.input(
|
||||
f"[{HIGHLIGHT}]paste your {provider.api_key_env} (blank to cancel)> [/]",
|
||||
password=True,
|
||||
).strip()
|
||||
if not api_key:
|
||||
console.print(
|
||||
f"[{DIM}]cancelled — set it later with[/] "
|
||||
f"[bold]opensre auth login {provider.value}[/bold][{DIM}].[/]"
|
||||
)
|
||||
return False
|
||||
from surfaces.cli.llm_auth.providers import resolve_auth_profile
|
||||
from surfaces.cli.llm_auth.service import AuthSetupError, configure_api_key_provider
|
||||
|
||||
console.print(f"[{DIM}]validating {provider.value} key…[/]")
|
||||
try:
|
||||
configure_api_key_provider(
|
||||
profile=resolve_auth_profile(provider.value),
|
||||
api_key=api_key,
|
||||
set_provider=False,
|
||||
)
|
||||
except (AuthSetupError, KeyError) as exc:
|
||||
console.print(f"[{ERROR}]could not save {provider.api_key_env}:[/] {escape(str(exc))}")
|
||||
return False
|
||||
console.print(f"[{DIM}]saved {provider.api_key_env}.[/]")
|
||||
auth_status = credential_status(provider.value)
|
||||
if provider.credential_secret and provider.api_key_env and auth_status.stale:
|
||||
console.print(
|
||||
f"[{WARNING}]credential status for {provider.value} is stale:[/] "
|
||||
f"{escape(auth_status.detail)}"
|
||||
)
|
||||
console.print(
|
||||
f"[{DIM}]run[/] [bold]opensre auth verify {provider.value}[/bold] "
|
||||
f"[{DIM}]to refresh metadata if the next LLM request fails.[/]"
|
||||
)
|
||||
|
||||
selected_model = _normalize_model_id(model) if model else provider.default_model
|
||||
if selected_model and not _is_model_allowed(provider, selected_model):
|
||||
console.print(f"[{ERROR}]unknown model for {provider.value}:[/] {escape(selected_model)}")
|
||||
console.print(
|
||||
f"[{DIM}]known reasoning models:[/] {escape(_format_supported_models(provider.models))}"
|
||||
)
|
||||
return False
|
||||
|
||||
selected_toolcall: str | None = None
|
||||
if toolcall_model is not None:
|
||||
if not provider.toolcall_model_env:
|
||||
console.print(
|
||||
f"[{WARNING}]provider {provider.value} does not expose a separate "
|
||||
"toolcall model[/] — toolcall override ignored."
|
||||
)
|
||||
else:
|
||||
selected_toolcall = _normalize_model_id(toolcall_model)
|
||||
if selected_toolcall and not _is_model_allowed(provider, selected_toolcall):
|
||||
console.print(
|
||||
f"[{ERROR}]unknown model for {provider.value}:[/] {escape(selected_toolcall)}"
|
||||
)
|
||||
console.print(
|
||||
f"[{DIM}]known toolcall models:[/] "
|
||||
f"{escape(_format_supported_models(provider.models))}"
|
||||
)
|
||||
return False
|
||||
|
||||
env_path = sync_provider_env(
|
||||
provider=provider,
|
||||
model=selected_model,
|
||||
toolcall_model=selected_toolcall or None,
|
||||
)
|
||||
_reset_runtime_llm_caches()
|
||||
|
||||
# Be explicit about which slot each model lands in.
|
||||
console.print(f"[{HIGHLIGHT}]switched LLM provider:[/] {provider.value}")
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]reasoning model:[/] {selected_model or 'provider default'} "
|
||||
f"[{DIM}]({provider.model_env})[/]"
|
||||
)
|
||||
if selected_toolcall:
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]toolcall model:[/] {selected_toolcall} "
|
||||
f"[{DIM}]({provider.toolcall_model_env})[/]"
|
||||
)
|
||||
console.print(f"[{DIM}]updated {env_path}[/]")
|
||||
render_models_table(console, repl_data.load_llm_settings())
|
||||
return True
|
||||
|
||||
|
||||
def switch_toolcall_model(
|
||||
toolcall_model: str,
|
||||
console: Console,
|
||||
*,
|
||||
provider_name: str | None = None,
|
||||
) -> bool:
|
||||
"""Set the toolcall model for the active (or named) provider."""
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
from surfaces.cli.wizard.env_sync import sync_env_values
|
||||
|
||||
raw_name = provider_name if provider_name else os.getenv("LLM_PROVIDER", "anthropic")
|
||||
resolved_name = (raw_name or "anthropic").strip().lower()
|
||||
provider = PROVIDER_BY_VALUE.get(resolved_name)
|
||||
if provider is None:
|
||||
console.print(f"[{ERROR}]unknown LLM provider:[/] {escape(resolved_name)}")
|
||||
print_valid_choice_list(
|
||||
console,
|
||||
title="valid providers:",
|
||||
choices=sorted(PROVIDER_BY_VALUE),
|
||||
)
|
||||
return False
|
||||
if not provider.toolcall_model_env:
|
||||
console.print(
|
||||
f"[{WARNING}]provider {provider.value} does not expose a separate "
|
||||
"toolcall model[/] — nothing to set."
|
||||
)
|
||||
return False
|
||||
new_model = _normalize_model_id(toolcall_model)
|
||||
if not new_model:
|
||||
console.print(f"[{ERROR}]toolcall model cannot be empty[/]")
|
||||
return False
|
||||
|
||||
values = {provider.toolcall_model_env: new_model}
|
||||
env_path = sync_env_values(values)
|
||||
os.environ.update(values)
|
||||
_reset_runtime_llm_caches()
|
||||
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]toolcall model set to:[/] {new_model} "
|
||||
f"[{DIM}]({provider.value} · {provider.toolcall_model_env})[/]"
|
||||
)
|
||||
console.print(f"[{DIM}]updated {env_path}[/]")
|
||||
render_models_table(console, repl_data.load_llm_settings())
|
||||
return True
|
||||
|
||||
|
||||
def switch_reasoning_model(
|
||||
reasoning_model: str,
|
||||
console: Console,
|
||||
*,
|
||||
provider_name: str | None = None,
|
||||
) -> bool:
|
||||
"""Set the reasoning model for the active (or named) provider."""
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
from surfaces.cli.wizard.env_sync import sync_reasoning_model_env
|
||||
|
||||
raw_name = provider_name if provider_name else os.getenv("LLM_PROVIDER", "anthropic")
|
||||
resolved_name = (raw_name or "anthropic").strip().lower()
|
||||
provider = PROVIDER_BY_VALUE.get(resolved_name)
|
||||
if provider is None:
|
||||
console.print(f"[{ERROR}]unknown LLM provider:[/] {escape(resolved_name)}")
|
||||
print_valid_choice_list(
|
||||
console,
|
||||
title="valid providers:",
|
||||
choices=sorted(PROVIDER_BY_VALUE),
|
||||
)
|
||||
return False
|
||||
|
||||
new_model = _normalize_model_id(reasoning_model)
|
||||
if not new_model:
|
||||
console.print(f"[{ERROR}]reasoning model cannot be empty[/]")
|
||||
return False
|
||||
if not _is_model_allowed(provider, new_model):
|
||||
console.print(f"[{ERROR}]unknown model for {provider.value}:[/] {escape(new_model)}")
|
||||
console.print(
|
||||
f"[{DIM}]known reasoning models:[/] {escape(_format_supported_models(provider.models))}"
|
||||
)
|
||||
return False
|
||||
|
||||
env_path = sync_reasoning_model_env(provider=provider, model=new_model)
|
||||
_reset_runtime_llm_caches()
|
||||
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]reasoning model set to:[/] {new_model} "
|
||||
f"[{DIM}]({provider.value} · {provider.model_env})[/]"
|
||||
)
|
||||
console.print(f"[{DIM}]updated {env_path}[/]")
|
||||
render_models_table(console, repl_data.load_llm_settings())
|
||||
return True
|
||||
|
||||
|
||||
def restore_default_model(provider_name: str, console: Console) -> bool:
|
||||
"""Reset a provider to its configured default reasoning model."""
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
|
||||
provider_key = provider_name.strip().lower()
|
||||
provider = PROVIDER_BY_VALUE.get(provider_key)
|
||||
if provider is None:
|
||||
console.print(f"[{ERROR}]unknown LLM provider:[/] {escape(provider_name)}")
|
||||
print_valid_choice_list(
|
||||
console,
|
||||
title="valid providers:",
|
||||
choices=sorted(PROVIDER_BY_VALUE),
|
||||
)
|
||||
return False
|
||||
return switch_llm_provider(provider.value, console, model=provider.default_model)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Slash commands: history management and privacy controls (/history, /privacy)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from prompt_toolkit.history import FileHistory, InMemoryHistory
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.prompt_history import (
|
||||
clear_persisted_history,
|
||||
load_command_history_entries,
|
||||
prompt_history_path,
|
||||
)
|
||||
from surfaces.interactive_shell.prompt_history.policy import (
|
||||
DEFAULT_REDACTION_RULES,
|
||||
RedactingFileHistory,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import (
|
||||
BOLD_BRAND,
|
||||
DIM,
|
||||
ERROR,
|
||||
HIGHLIGHT,
|
||||
print_repl_table,
|
||||
repl_table,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import (
|
||||
CRUMB_SEP,
|
||||
repl_choose_one,
|
||||
repl_section_break,
|
||||
repl_tty_interactive,
|
||||
)
|
||||
|
||||
|
||||
def _show_history(console: Console) -> bool:
|
||||
entries = load_command_history_entries()
|
||||
if not entries:
|
||||
console.print(f"[{DIM}]no history yet.[/]")
|
||||
return True
|
||||
|
||||
table = repl_table(title="Command history\n", title_style=BOLD_BRAND)
|
||||
table.add_column("#", style=DIM, justify="right")
|
||||
table.add_column("text", overflow="fold")
|
||||
|
||||
for i, entry in enumerate(entries, start=1):
|
||||
table.add_row(str(i), escape(entry))
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
|
||||
|
||||
def _history_clear(session: Session, console: Console) -> bool: # noqa: ARG001
|
||||
if clear_persisted_history():
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]cleared[/] persistent history. Up-arrow recall resets on next launch."
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"[{ERROR}]could not clear history[/] (file system error). "
|
||||
f"path: {prompt_history_path()}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _history_pause(session: Session, console: Console, *, paused: bool) -> bool:
|
||||
backend = session.terminal.prompt_history_backend
|
||||
if isinstance(backend, RedactingFileHistory):
|
||||
backend.paused = paused
|
||||
state = "off" if paused else "on"
|
||||
console.print(f"[{DIM}]history persistence is now {state} for this session.[/]")
|
||||
return True
|
||||
if isinstance(backend, FileHistory):
|
||||
if paused:
|
||||
console.print(
|
||||
f"[{DIM}]history is persisting to disk without redaction. "
|
||||
"Restart with OPENSRE_HISTORY_REDACT=1 to enable runtime pause support, "
|
||||
"or OPENSRE_HISTORY_ENABLED=0 to disable persistence entirely.[/]"
|
||||
)
|
||||
return True
|
||||
console.print(f"[{DIM}]history persistence is already on (raw file history).[/]")
|
||||
return True
|
||||
if backend is None or isinstance(backend, InMemoryHistory):
|
||||
if paused:
|
||||
console.print(f"[{DIM}]history is already not persisting in this session.[/]")
|
||||
return True
|
||||
console.print(
|
||||
f"[{DIM}]history is in-memory only. "
|
||||
"Restart with OPENSRE_HISTORY_ENABLED=1 to enable persistence.[/]"
|
||||
)
|
||||
return True
|
||||
if paused:
|
||||
console.print(f"[{DIM}]history is already not persisting in this session.[/]")
|
||||
return True
|
||||
console.print(
|
||||
f"[{DIM}]history backend does not support runtime persistence controls in this session.[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _history_retention(session: Session, console: Console, args: list[str]) -> bool:
|
||||
if not args:
|
||||
console.print(f"[{ERROR}]usage:[/] /history retention <N>")
|
||||
return True
|
||||
try:
|
||||
n = int(args[0])
|
||||
if n < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
console.print(f"[{ERROR}]retention must be a non-negative integer[/]")
|
||||
return True
|
||||
|
||||
backend = session.terminal.prompt_history_backend
|
||||
if isinstance(backend, RedactingFileHistory):
|
||||
backend.set_max_entries(n, prune=True)
|
||||
console.print(
|
||||
f"[{DIM}]retention cap set to {n} for this session "
|
||||
"(set OPENSRE_HISTORY_MAX_ENTRIES or interactive.history.max_entries to persist).[/]"
|
||||
)
|
||||
return True
|
||||
console.print(
|
||||
f"[{DIM}]retention applies only when redacting persistent history. "
|
||||
"Restart with OPENSRE_HISTORY_REDACT=1 to enable.[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _interactive_history_menu(session: Session, console: Console) -> bool:
|
||||
root = "/history"
|
||||
while True:
|
||||
sub = repl_choose_one(
|
||||
title="history",
|
||||
breadcrumb=root,
|
||||
choices=[
|
||||
("show", "show"),
|
||||
("clear", "clear"),
|
||||
("off", "off"),
|
||||
("on", "on"),
|
||||
("retention", "retention"),
|
||||
("done", "done"),
|
||||
],
|
||||
)
|
||||
if sub is None or sub == "done":
|
||||
return True
|
||||
show_section_break = False
|
||||
if sub == "show":
|
||||
_show_history(console)
|
||||
show_section_break = True
|
||||
elif sub == "clear":
|
||||
_history_clear(session, console)
|
||||
show_section_break = True
|
||||
elif sub == "off":
|
||||
_history_pause(session, console, paused=True)
|
||||
show_section_break = True
|
||||
elif sub == "on":
|
||||
_history_pause(session, console, paused=False)
|
||||
show_section_break = True
|
||||
elif sub == "retention":
|
||||
cap = repl_choose_one(
|
||||
title="retention cap",
|
||||
breadcrumb=f"{root}{CRUMB_SEP}retention",
|
||||
choices=[
|
||||
("100", "100"),
|
||||
("500", "500"),
|
||||
("1000", "1000"),
|
||||
("5000", "5000"),
|
||||
],
|
||||
)
|
||||
if cap:
|
||||
_history_retention(session, console, [cap])
|
||||
show_section_break = True
|
||||
if show_section_break:
|
||||
repl_section_break(console)
|
||||
|
||||
|
||||
def _cmd_history(session: Session, console: Console, args: list[str]) -> bool:
|
||||
if not args and repl_tty_interactive():
|
||||
return _interactive_history_menu(session, console)
|
||||
|
||||
if not args:
|
||||
return _show_history(console)
|
||||
|
||||
sub = args[0].lower()
|
||||
if sub == "clear":
|
||||
return _history_clear(session, console)
|
||||
if sub == "off":
|
||||
return _history_pause(session, console, paused=True)
|
||||
if sub == "on":
|
||||
return _history_pause(session, console, paused=False)
|
||||
if sub == "retention":
|
||||
return _history_retention(session, console, args[1:])
|
||||
|
||||
console.print(f"[{ERROR}]usage:[/] /history [clear|off|on|retention <N>]")
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_privacy(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
backend = session.terminal.prompt_history_backend
|
||||
table = repl_table(title="Privacy settings\n", title_style=BOLD_BRAND, show_header=False)
|
||||
table.add_column("setting", style="bold")
|
||||
table.add_column("value")
|
||||
|
||||
if isinstance(backend, RedactingFileHistory):
|
||||
persistence = "off (paused)" if backend.paused else "on"
|
||||
redaction = "on"
|
||||
retention = str(backend.max_entries) if backend.max_entries > 0 else "unlimited"
|
||||
elif backend is None or isinstance(backend, InMemoryHistory):
|
||||
persistence = "off (in-memory only)"
|
||||
redaction = "n/a"
|
||||
retention = "n/a"
|
||||
elif isinstance(backend, FileHistory):
|
||||
persistence = "on (no redaction)"
|
||||
redaction = "off"
|
||||
retention = "n/a"
|
||||
else:
|
||||
persistence = "unknown"
|
||||
redaction = "unknown"
|
||||
retention = "n/a"
|
||||
|
||||
table.add_row("persistence", persistence)
|
||||
table.add_row("redaction", redaction)
|
||||
table.add_row("retention cap", retention)
|
||||
table.add_row("file", str(prompt_history_path()))
|
||||
table.add_row("built-in patterns", str(len(DEFAULT_REDACTION_RULES)))
|
||||
print_repl_table(console, table)
|
||||
console.print(
|
||||
f"[{DIM}]threat model: prompt history is stored unencrypted on disk. "
|
||||
f"Use[/] [{HIGHLIGHT}]/history clear[/] [{DIM}]after sharing your machine, "
|
||||
f"or[/] [{HIGHLIGHT}]/history off[/] [{DIM}]to pause this session, "
|
||||
f"or set OPENSRE_HISTORY_ENABLED=0 to disable persistence entirely.[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
_HISTORY_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("clear", "delete persisted history file"),
|
||||
("off", "pause history persistence for this session"),
|
||||
("on", "resume history persistence for this session"),
|
||||
("retention", "set max entries cap (e.g. /history retention 1000)"),
|
||||
)
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/history",
|
||||
"Manage command history.",
|
||||
_cmd_history,
|
||||
usage=(
|
||||
"/history",
|
||||
"/history clear",
|
||||
"/history off",
|
||||
"/history on",
|
||||
"/history retention <N>",
|
||||
),
|
||||
notes=("In a TTY, bare /history opens an interactive menu.",),
|
||||
first_arg_completions=_HISTORY_FIRST_ARGS,
|
||||
),
|
||||
SlashCommand(
|
||||
"/privacy",
|
||||
"Show history persistence, redaction status, and threat model.",
|
||||
_cmd_privacy,
|
||||
),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS"]
|
||||
@@ -0,0 +1,488 @@
|
||||
"""Slash command /rca — browse persisted RCA reports across sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from core.agent_harness.session import default_session_repo
|
||||
from surfaces.interactive_shell.command_registry.investigation import (
|
||||
render_investigation_report,
|
||||
write_investigation_export,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import (
|
||||
BOLD_BRAND,
|
||||
DIM,
|
||||
ERROR,
|
||||
HIGHLIGHT,
|
||||
WARNING,
|
||||
print_repl_table,
|
||||
repl_table,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import (
|
||||
CRUMB_SEP,
|
||||
prepare_repl_output_line,
|
||||
repl_choose_one,
|
||||
repl_section_break,
|
||||
repl_tty_interactive,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.time_format import format_repl_timestamp
|
||||
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
|
||||
|
||||
_RCA_ROOT = "/rca"
|
||||
_RCA_LATEST = "__latest__"
|
||||
_RCA_HISTORY = "__history__"
|
||||
_RCA_SAVE = "__save__"
|
||||
_HISTORY_ALIASES = frozenset({"history", "list", "ls"})
|
||||
_EXPORT_SUFFIXES = frozenset({".md", ".json"})
|
||||
|
||||
|
||||
def _investigation_id(record: dict[str, object]) -> str:
|
||||
return str(record.get("investigation_id") or "")
|
||||
|
||||
|
||||
def _record_timestamp(record: dict[str, object], *, style: str) -> str:
|
||||
return format_repl_timestamp(record.get("completed_at"), style=style) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _rca_breadcrumb(suffix: str) -> str:
|
||||
return _RCA_ROOT if not suffix else f"{_RCA_ROOT}{CRUMB_SEP}{suffix}"
|
||||
|
||||
|
||||
def _rca_record_label(record: dict[str, object]) -> str:
|
||||
inv_id = _investigation_id(record) or "—"
|
||||
completed = _record_timestamp(record, style="compact")
|
||||
preview = str(record.get("root_cause_preview") or "—")
|
||||
if len(preview) > 44:
|
||||
preview = preview[:41] + "…"
|
||||
trigger = str(record.get("trigger") or "").strip()
|
||||
trigger_part = f" {trigger[:28]}" if trigger else ""
|
||||
return f"{inv_id[:8]} {completed} {preview}{trigger_part}"
|
||||
|
||||
|
||||
def _print_rca_empty(console: Console) -> None:
|
||||
console.print(f"[{DIM}]no persisted RCA reports yet.[/]")
|
||||
console.print(
|
||||
f"[{DIM}]run an investigation with[/] [{WARNING}]/investigate[/] "
|
||||
f"[{DIM}]to populate history.[/]"
|
||||
)
|
||||
|
||||
|
||||
def _require_rca_records(console: Console) -> list[dict[str, object]] | None:
|
||||
records = default_session_repo().load_investigation_history()
|
||||
if not records:
|
||||
_print_rca_empty(console)
|
||||
return None
|
||||
return records
|
||||
|
||||
|
||||
def _rca_record_export_state(record: dict[str, object]) -> dict[str, object]:
|
||||
report = str(record.get("report") or "")
|
||||
return {
|
||||
"investigation_id": record.get("investigation_id"),
|
||||
"session_id": record.get("session_id"),
|
||||
"completed_at": record.get("completed_at"),
|
||||
"trigger": record.get("trigger"),
|
||||
"root_cause": record.get("root_cause"),
|
||||
"problem_md": report,
|
||||
"report": report,
|
||||
"root_cause_category": record.get("root_cause_category"),
|
||||
"alert_name": record.get("alert_name"),
|
||||
"run_id": record.get("run_id"),
|
||||
}
|
||||
|
||||
|
||||
def _print_rca_lookup_failure(
|
||||
console: Console,
|
||||
investigation_id: str,
|
||||
*,
|
||||
match_count: int,
|
||||
) -> None:
|
||||
if match_count > 1:
|
||||
console.print(
|
||||
f"[{WARNING}]ambiguous id prefix:[/] {escape(investigation_id)} "
|
||||
f"[{DIM}]({match_count} matches — use more characters)[/]"
|
||||
)
|
||||
return
|
||||
console.print(f"[{ERROR}]RCA report not found:[/] {escape(investigation_id)}")
|
||||
|
||||
|
||||
def _resolve_rca_record(
|
||||
investigation_id: str | None,
|
||||
*,
|
||||
records: list[dict[str, object]] | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
repo = default_session_repo()
|
||||
if investigation_id:
|
||||
loaded = repo.load_investigation(investigation_id)
|
||||
if loaded is not None:
|
||||
return loaded
|
||||
if records:
|
||||
for record in records:
|
||||
inv_id = _investigation_id(record)
|
||||
if inv_id.startswith(investigation_id):
|
||||
return record
|
||||
return None
|
||||
|
||||
history = records or repo.load_investigation_history()
|
||||
if not history:
|
||||
return None
|
||||
latest = history[0]
|
||||
inv_id = _investigation_id(latest)
|
||||
if not inv_id:
|
||||
return latest
|
||||
return repo.load_investigation(inv_id) or latest
|
||||
|
||||
|
||||
def _strip_outer_quotes(value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
return value[1:-1].strip()
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_rca_save_path(raw_path: str, *, investigation_id: str = "") -> Path:
|
||||
"""Normalize user-entered save paths (strip quotes, expand ~, folder → file)."""
|
||||
value = _strip_outer_quotes(raw_path.strip())
|
||||
treat_as_dir = value.endswith(("/", "\\"))
|
||||
dest = Path(value).expanduser()
|
||||
if dest.suffix.lower() not in _EXPORT_SUFFIXES and (treat_as_dir or dest.is_dir()):
|
||||
dest = dest / f"rca-{investigation_id[:8] or 'report'}.md"
|
||||
return dest
|
||||
|
||||
|
||||
def _save_rca_record(console: Console, record: dict[str, object], dest_path: str) -> bool:
|
||||
inv_id = _investigation_id(record)
|
||||
dest = _normalize_rca_save_path(dest_path, investigation_id=inv_id)
|
||||
try:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_investigation_export(
|
||||
dest,
|
||||
root_cause=str(record.get("root_cause") or ""),
|
||||
report=str(record.get("report") or ""),
|
||||
full_state=_rca_record_export_state(record),
|
||||
)
|
||||
console.print(f"[{HIGHLIGHT}]saved:[/] {escape(str(dest))}")
|
||||
except IsADirectoryError:
|
||||
console.print(
|
||||
f"[{ERROR}]save failed:[/] {escape(str(dest))} is a directory — "
|
||||
f"include a filename (e.g. [{WARNING}]report.md[/])"
|
||||
)
|
||||
except Exception as exc:
|
||||
report_exception(exc, context="surfaces.interactive_shell.rca_save")
|
||||
console.print(f"[{ERROR}]save failed:[/] {escape(str(exc))}")
|
||||
return True
|
||||
|
||||
|
||||
def _prompt_rca_save_path(console: Console) -> str | None:
|
||||
console.print()
|
||||
console.print(
|
||||
f"[{DIM}]Enter output file or folder (.md or .json). "
|
||||
f"Example:[/] [{WARNING}]rca-report.md[/] "
|
||||
f"[{DIM}]or[/] [{WARNING}]/Users/you/Downloads/rca reports/[/]"
|
||||
)
|
||||
try:
|
||||
value = console.input(f"[{HIGHLIGHT}]file path> [/]").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
return value or None
|
||||
|
||||
|
||||
def _report_picker_choices(
|
||||
records: list[dict[str, object]],
|
||||
*,
|
||||
include_latest: bool,
|
||||
) -> list[tuple[str, str]]:
|
||||
choices: list[tuple[str, str]] = []
|
||||
if include_latest:
|
||||
choices.append((_RCA_LATEST, "latest"))
|
||||
choices.extend(
|
||||
(inv_id, _rca_record_label(record))
|
||||
for record in records
|
||||
if (inv_id := _investigation_id(record))
|
||||
)
|
||||
choices.append(("done", "done"))
|
||||
return choices
|
||||
|
||||
|
||||
def _pick_rca_report(
|
||||
records: list[dict[str, object]],
|
||||
*,
|
||||
breadcrumb_suffix: str,
|
||||
include_latest: bool = False,
|
||||
) -> str | None:
|
||||
picked = repl_choose_one(
|
||||
title="rca report",
|
||||
breadcrumb=_rca_breadcrumb(breadcrumb_suffix),
|
||||
choices=_report_picker_choices(records, include_latest=include_latest),
|
||||
)
|
||||
if picked is None or picked == "done":
|
||||
return None
|
||||
return picked
|
||||
|
||||
|
||||
def _picked_investigation_id(picked: str, records: list[dict[str, object]]) -> str:
|
||||
if picked == _RCA_LATEST:
|
||||
return _investigation_id(records[0])
|
||||
return picked
|
||||
|
||||
|
||||
def _interactive_rca_report_menu(
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
breadcrumb_suffix: str,
|
||||
include_latest: bool,
|
||||
on_pick: Callable[[Session, Console, dict[str, object]], bool],
|
||||
) -> bool:
|
||||
records = _require_rca_records(console)
|
||||
if records is None:
|
||||
return True
|
||||
|
||||
picked = _pick_rca_report(
|
||||
records,
|
||||
breadcrumb_suffix=breadcrumb_suffix,
|
||||
include_latest=include_latest,
|
||||
)
|
||||
if picked is None:
|
||||
return True
|
||||
|
||||
record = _resolve_rca_record(_picked_investigation_id(picked, records), records=records)
|
||||
if record is None:
|
||||
console.print(f"[{DIM}]RCA report not found.[/]")
|
||||
return True
|
||||
return on_pick(session, console, record)
|
||||
|
||||
|
||||
def _interactive_show_record(
|
||||
session: Session,
|
||||
console: Console,
|
||||
record: dict[str, object],
|
||||
) -> bool:
|
||||
inv_id = _investigation_id(record)
|
||||
if not inv_id:
|
||||
return True
|
||||
_cmd_rca_show(session, console, inv_id, record=record)
|
||||
repl_section_break(console)
|
||||
return True
|
||||
|
||||
|
||||
def _interactive_save_record(
|
||||
_session: Session,
|
||||
console: Console,
|
||||
record: dict[str, object],
|
||||
) -> bool:
|
||||
dest_path = _prompt_rca_save_path(console)
|
||||
if dest_path is None:
|
||||
return True
|
||||
return _save_rca_record(console, record, dest_path)
|
||||
|
||||
|
||||
def _interactive_rca_history_menu(session: Session, console: Console) -> bool:
|
||||
return _interactive_rca_report_menu(
|
||||
session,
|
||||
console,
|
||||
breadcrumb_suffix="history",
|
||||
include_latest=False,
|
||||
on_pick=_interactive_show_record,
|
||||
)
|
||||
|
||||
|
||||
def _interactive_rca_save_menu(session: Session, console: Console) -> bool:
|
||||
return _interactive_rca_report_menu(
|
||||
session,
|
||||
console,
|
||||
breadcrumb_suffix="save",
|
||||
include_latest=True,
|
||||
on_pick=_interactive_save_record,
|
||||
)
|
||||
|
||||
|
||||
def _interactive_rca_root_menu(session: Session, console: Console) -> bool:
|
||||
records = _require_rca_records(console)
|
||||
if records is None:
|
||||
return True
|
||||
|
||||
picked = repl_choose_one(
|
||||
title="rca report",
|
||||
breadcrumb=_RCA_ROOT,
|
||||
choices=[
|
||||
(_RCA_LATEST, "latest"),
|
||||
(_RCA_HISTORY, "history"),
|
||||
(_RCA_SAVE, "save"),
|
||||
("done", "done"),
|
||||
],
|
||||
)
|
||||
if picked is None or picked == "done":
|
||||
return True
|
||||
if picked == _RCA_HISTORY:
|
||||
return _interactive_rca_history_menu(session, console)
|
||||
if picked == _RCA_SAVE:
|
||||
return _interactive_rca_save_menu(session, console)
|
||||
|
||||
latest_id = _investigation_id(records[0])
|
||||
if not latest_id:
|
||||
return True
|
||||
return _interactive_show_record(session, console, records[0])
|
||||
|
||||
|
||||
def _cmd_rca_history(_session: Session, console: Console) -> bool:
|
||||
records = _require_rca_records(console)
|
||||
if records is None:
|
||||
return True
|
||||
|
||||
table = repl_table(title="RCA history\n", title_style=BOLD_BRAND)
|
||||
table.add_column("#", style="bold", justify="right")
|
||||
table.add_column("ID", style="bold")
|
||||
table.add_column("Completed")
|
||||
table.add_column("Trigger", overflow="fold")
|
||||
table.add_column("Root cause", overflow="fold", style=DIM)
|
||||
|
||||
for index, record in enumerate(records, start=1):
|
||||
table.add_row(
|
||||
str(index),
|
||||
_investigation_id(record) or "—",
|
||||
_record_timestamp(record, style="table"),
|
||||
escape(str(record.get("trigger") or record.get("session_name") or "—")),
|
||||
escape(str(record.get("root_cause_preview") or "—")),
|
||||
)
|
||||
|
||||
print_repl_table(console, table)
|
||||
console.print(
|
||||
f"[{DIM}]show full report:[/] [{WARNING}]/rca show <id>[/] "
|
||||
f"[{DIM}]save:[/] [{WARNING}]/rca save <path>[/] "
|
||||
f"[{DIM}]or[/] [{WARNING}]/rca save <id> <path>[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _print_rca_record_header(console: Console, record: dict[str, object]) -> None:
|
||||
console.print()
|
||||
console.print(
|
||||
f"[{DIM}]id[/] [bold]{escape(_investigation_id(record))}[/] "
|
||||
f"[{DIM}]session[/] {escape(str(record.get('session_id') or '')[:8])} "
|
||||
f"[{DIM}]completed[/] {escape(_record_timestamp(record, style='table'))}"
|
||||
)
|
||||
trigger = str(record.get("trigger") or "").strip()
|
||||
if trigger:
|
||||
console.print(f"[{DIM}]trigger[/] {escape(trigger)}")
|
||||
|
||||
|
||||
def _cmd_rca_show(
|
||||
_session: Session,
|
||||
console: Console,
|
||||
investigation_id: str,
|
||||
*,
|
||||
record: dict[str, object] | None = None,
|
||||
) -> bool:
|
||||
if record is not None:
|
||||
resolved = record
|
||||
else:
|
||||
loaded, match_count = default_session_repo().lookup_investigation(investigation_id)
|
||||
if match_count != 1:
|
||||
_print_rca_lookup_failure(console, investigation_id, match_count=match_count)
|
||||
return True
|
||||
if loaded is None:
|
||||
_print_rca_lookup_failure(console, investigation_id, match_count=0)
|
||||
return True
|
||||
resolved = loaded
|
||||
|
||||
_print_rca_record_header(console, resolved)
|
||||
render_investigation_report(
|
||||
console,
|
||||
root_cause=str(resolved.get("root_cause") or ""),
|
||||
report=str(resolved.get("report") or ""),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_rca_save(
|
||||
_session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
investigation_id: str | None,
|
||||
dest_path: str,
|
||||
) -> bool:
|
||||
if investigation_id:
|
||||
record, match_count = default_session_repo().lookup_investigation(investigation_id)
|
||||
if match_count != 1:
|
||||
_print_rca_lookup_failure(console, investigation_id, match_count=match_count)
|
||||
return True
|
||||
if record is None:
|
||||
_print_rca_lookup_failure(console, investigation_id, match_count=0)
|
||||
return True
|
||||
else:
|
||||
record = _resolve_rca_record(None)
|
||||
if record is None:
|
||||
_print_rca_empty(console)
|
||||
return True
|
||||
return _save_rca_record(console, record, dest_path)
|
||||
|
||||
|
||||
def _cmd_rca(_session: Session, console: Console, args: list[str]) -> bool:
|
||||
prepare_repl_output_line()
|
||||
if not args:
|
||||
if repl_tty_interactive():
|
||||
return _interactive_rca_root_menu(_session, console)
|
||||
return _cmd_rca_history(_session, console)
|
||||
|
||||
sub = args[0].lower().strip()
|
||||
if sub in _HISTORY_ALIASES:
|
||||
if repl_tty_interactive():
|
||||
return _interactive_rca_history_menu(_session, console)
|
||||
return _cmd_rca_history(_session, console)
|
||||
if sub == "show":
|
||||
if len(args) < 2:
|
||||
if repl_tty_interactive():
|
||||
return _interactive_rca_root_menu(_session, console)
|
||||
console.print(f"[{DIM}]usage:[/] /rca show <investigation-id-prefix>")
|
||||
return True
|
||||
return _cmd_rca_show(_session, console, args[1])
|
||||
if sub == "save":
|
||||
if len(args) == 1:
|
||||
if repl_tty_interactive():
|
||||
return _interactive_rca_save_menu(_session, console)
|
||||
console.print(
|
||||
f"[{DIM}]usage:[/] /rca save <path> "
|
||||
f"[{DIM}]or[/] /rca save <investigation-id> <path>"
|
||||
)
|
||||
return True
|
||||
if len(args) == 2:
|
||||
return _cmd_rca_save(_session, console, investigation_id=None, dest_path=args[1])
|
||||
return _cmd_rca_save(_session, console, investigation_id=args[1], dest_path=args[2])
|
||||
|
||||
console.print(
|
||||
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
|
||||
f"(try [bold]/rca history[/bold], [bold]/rca show <id>[/bold], "
|
||||
f"or [bold]/rca save <path>[/bold])"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
_RCA_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("history", "list persisted RCA reports across sessions"),
|
||||
("show", "show one RCA report by investigation id"),
|
||||
("save", "save an RCA report to a file (.md or .json)"),
|
||||
)
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/rca",
|
||||
"Browse persisted RCA investigation reports.",
|
||||
_cmd_rca,
|
||||
usage=(
|
||||
"/rca",
|
||||
"/rca history",
|
||||
"/rca show <investigation-id>",
|
||||
"/rca save <path>",
|
||||
"/rca save <investigation-id> <path>",
|
||||
),
|
||||
first_arg_completions=_RCA_FIRST_ARGS,
|
||||
),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS"]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Lazy loaders for verified integrations and LLM settings (repl slash commands)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_verified_integrations() -> list[dict[str, str]]:
|
||||
"""Import lazily so an unconfigured store doesn't slow down every REPL turn."""
|
||||
from integrations.verify import verify_integrations
|
||||
|
||||
return verify_integrations()
|
||||
|
||||
|
||||
def configured_integration_names() -> list[str]:
|
||||
"""Return configured integration service names without running verifiers."""
|
||||
from integrations.verify import resolve_effective_integrations
|
||||
|
||||
return sorted(resolve_effective_integrations())
|
||||
|
||||
|
||||
def verify_integration(service: str) -> dict[str, str] | None:
|
||||
"""Verify a single integration and return its result row."""
|
||||
from integrations.verify import verify_integrations
|
||||
|
||||
normalized = service.strip().lower()
|
||||
if not normalized:
|
||||
return None
|
||||
rows = verify_integrations(normalized)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def load_llm_settings() -> Any | None:
|
||||
"""Best-effort LLM settings load; returns None if env is misconfigured."""
|
||||
try:
|
||||
from config.config import LLMSettings
|
||||
|
||||
return LLMSettings.from_env()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"configured_integration_names",
|
||||
"load_llm_settings",
|
||||
"load_verified_integrations",
|
||||
"verify_integration",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Session lifecycle slash commands: /clear, /new, /sessions, /resume, /compact."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from surfaces.interactive_shell.command_registry.session_cmds.lifecycle import (
|
||||
_cmd_clear,
|
||||
_cmd_compact,
|
||||
_cmd_new,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.session_cmds.list import _cmd_sessions
|
||||
from surfaces.interactive_shell.command_registry.session_cmds.resume import (
|
||||
_apply_resume_data,
|
||||
_cmd_resume,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand("/clear", "Clear the screen and re-render the banner.", _cmd_clear),
|
||||
SlashCommand(
|
||||
"/sessions",
|
||||
"List recent REPL sessions.",
|
||||
_cmd_sessions,
|
||||
usage=("/sessions",),
|
||||
),
|
||||
SlashCommand(
|
||||
"/resume",
|
||||
"Resume a previous session by restoring its conversation context.",
|
||||
_cmd_resume,
|
||||
usage=("/resume <session-id-prefix>", "/resume <session-id-prefix>:<entry-id-prefix>"),
|
||||
notes=(
|
||||
"Restores cli_agent_messages and accumulated infra context from the chosen session.",
|
||||
"Bare /resume opens an interactive session picker in a TTY.",
|
||||
"Accepts a session ID prefix, entry ref, or name substring (e.g. /resume redis).",
|
||||
"Replaces the current session's LLM conversation context; warns if messages exist.",
|
||||
),
|
||||
),
|
||||
SlashCommand(
|
||||
"/new",
|
||||
"Start a new session while keeping the current conversation context.",
|
||||
_cmd_new,
|
||||
notes=(
|
||||
"Unlike /clear, /new rotates the session ID and resets state while keeping LLM context.",
|
||||
"Use after /resume to continue a conversation in a clean session file.",
|
||||
),
|
||||
),
|
||||
SlashCommand(
|
||||
"/compact",
|
||||
"Compact the current session context into a replayable summary entry.",
|
||||
_cmd_compact,
|
||||
usage=("/compact",),
|
||||
notes=(
|
||||
"Writes a compaction entry and keeps the most recent messages plus a summary.",
|
||||
"Useful before continuing a long-running investigation in the same REPL.",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS", "_apply_resume_data"]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Session lifecycle slash commands: /clear, /new, and /compact."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.agent_harness.session import SessionManager
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import DIM, HIGHLIGHT
|
||||
|
||||
|
||||
def _cmd_clear(session: Session, console: Console, _args: list[str]) -> bool:
|
||||
from surfaces.interactive_shell.ui import render_ready_box
|
||||
|
||||
console.clear()
|
||||
render_ready_box(console, session=session)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_new(session: Session, console: Console, _args: list[str]) -> bool:
|
||||
"""Start a new session while preserving the current LLM conversation context.
|
||||
|
||||
Unlike /clear (which only clears the screen), /new rotates the session ID
|
||||
and resets all session state while keeping cli_agent_messages and
|
||||
accumulated_context so a resumed or in-progress conversation continues
|
||||
seamlessly in a fresh session file.
|
||||
"""
|
||||
saved_messages = list(session.agent.messages)
|
||||
saved_context = dict(session.accumulated_context)
|
||||
saved_resumed_name = session.resumed_from_name
|
||||
|
||||
SessionManager.for_session(session).rotate_in_place(session)
|
||||
|
||||
session.agent.messages = saved_messages
|
||||
session.accumulated_context = saved_context
|
||||
session.resumed_from_name = saved_resumed_name
|
||||
console.print(
|
||||
f"[{DIM}]new session started[/] [{HIGHLIGHT}]—[/] [{DIM}]conversation context carried forward.[/]"
|
||||
)
|
||||
if saved_messages:
|
||||
console.print(f"[{DIM}] {len(saved_messages)} messages in context · type to continue[/]")
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_compact(session: Session, console: Console, _args: list[str]) -> bool:
|
||||
"""Compact the live session branch and persist a compaction entry."""
|
||||
from core.agent_harness.turns.transcript_compaction import compact_session_branch
|
||||
|
||||
result = compact_session_branch(session)
|
||||
if result is None:
|
||||
console.print(f"[{DIM}]Nothing to compact yet.[/]")
|
||||
return True
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]compacted session context[/] "
|
||||
f"[{DIM}]({result.before_chars} chars -> {result.after_chars} chars)[/]"
|
||||
)
|
||||
session.record("slash", "/compact")
|
||||
return True
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Session listing slash command: /sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import (
|
||||
BOLD_BRAND,
|
||||
DIM,
|
||||
print_repl_table,
|
||||
repl_table,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.time_format import (
|
||||
format_repl_duration,
|
||||
format_repl_timestamp,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_sessions(session: Session, console: Console, _args: list[str]) -> bool:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from core.agent_harness.session import default_session_repo
|
||||
|
||||
entries = default_session_repo().load_recent(20)
|
||||
if not entries:
|
||||
console.print(f"[{DIM}]No sessions recorded yet.[/]")
|
||||
return True
|
||||
|
||||
table = repl_table(title="Recent sessions\n", title_style=BOLD_BRAND)
|
||||
table.add_column("#", style="bold", justify="right")
|
||||
table.add_column("Session ID", style="bold")
|
||||
table.add_column("Name")
|
||||
table.add_column("Started")
|
||||
table.add_column("Duration")
|
||||
table.add_column("Turns", justify="right")
|
||||
table.add_column("Investigations", justify="right")
|
||||
|
||||
for i, entry in enumerate(entries, start=1):
|
||||
sid = entry["session_id"]
|
||||
short_id = sid[:8] if len(sid) >= 8 else sid
|
||||
is_current = sid == session.session_id
|
||||
|
||||
name = entry.get("name") or ""
|
||||
if is_current and not name and session.resumed_from_name:
|
||||
name = f"↩ {session.resumed_from_name}"
|
||||
if is_current:
|
||||
name_col = f"[{DIM}](current)[/]" if not name else f"{escape(name)} [{DIM}](current)[/]"
|
||||
else:
|
||||
name_col = escape(name) if name else f"[{DIM}]—[/]"
|
||||
|
||||
started_str = format_repl_timestamp(entry.get("started_at"), style="table")
|
||||
|
||||
duration_secs = entry.get("duration_secs")
|
||||
if is_current:
|
||||
with contextlib.suppress(OSError, OverflowError, ValueError):
|
||||
elapsed = int(
|
||||
(
|
||||
datetime.now(UTC) - datetime.fromtimestamp(session.started_at, tz=UTC)
|
||||
).total_seconds()
|
||||
)
|
||||
duration_secs = elapsed
|
||||
|
||||
total = entry.get("total_turns")
|
||||
investigations = entry.get("investigation_turns")
|
||||
|
||||
table.add_row(
|
||||
str(i),
|
||||
short_id,
|
||||
name_col,
|
||||
started_str,
|
||||
format_repl_duration(duration_secs),
|
||||
str(total) if total is not None else "—",
|
||||
str(investigations) if investigations is not None else "—",
|
||||
)
|
||||
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Session resume slash command and helpers: /resume."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from core.agent_harness.session import SessionManager
|
||||
from surfaces.interactive_shell.command_registry.session_cmds.resume_rendering import (
|
||||
render_resumed_session_history,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import (
|
||||
repl_choose_one,
|
||||
repl_tty_interactive,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.time_format import format_repl_timestamp
|
||||
|
||||
|
||||
def _record_resume_slash(
|
||||
session: Session,
|
||||
args: list[str],
|
||||
*,
|
||||
ok: bool = True,
|
||||
picked_id: str | None = None,
|
||||
) -> None:
|
||||
"""Record /resume in the active session file after identity is settled."""
|
||||
if picked_id:
|
||||
text = f"/resume {picked_id[:8]}"
|
||||
elif args:
|
||||
text = f"/resume {' '.join(args)}"
|
||||
else:
|
||||
text = "/resume"
|
||||
session.record("slash", text, ok=ok)
|
||||
|
||||
|
||||
def _interactive_resume_menu(session: Session, console: Console) -> bool:
|
||||
"""Show a numbered list of recent sessions and resume the selected one."""
|
||||
from core.agent_harness.session import default_session_repo
|
||||
|
||||
entries = [
|
||||
e for e in default_session_repo().load_recent(10) if e["session_id"] != session.session_id
|
||||
]
|
||||
if not entries:
|
||||
console.print(f"[{DIM}]No previous sessions to resume.[/]")
|
||||
return True
|
||||
|
||||
choices: list[tuple[str, str]] = []
|
||||
for entry in entries:
|
||||
sid = entry["session_id"]
|
||||
short_id = sid[:8]
|
||||
name = entry.get("name") or f"[{short_id}]"
|
||||
started_str = format_repl_timestamp(entry.get("started_at"), style="compact")
|
||||
label = f"{name[:40]:<40} {short_id} {started_str}"
|
||||
choices.append((sid, label))
|
||||
choices.append(("done", "done"))
|
||||
|
||||
picked = repl_choose_one(title="resume session", breadcrumb="/resume", choices=choices)
|
||||
if picked is None or picked == "done":
|
||||
return True
|
||||
|
||||
slash_command = f"/resume {picked[:8]}"
|
||||
if not _do_resume(picked, session, console, slash_command=slash_command):
|
||||
_record_resume_slash(session, [], picked_id=picked, ok=False)
|
||||
return True
|
||||
|
||||
|
||||
def _apply_resume_data(
|
||||
data: dict,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
slash_command: str | None = None,
|
||||
) -> bool:
|
||||
"""Apply loaded session data into the running session and print a summary."""
|
||||
messages = data.get("cli_agent_messages") or []
|
||||
context = data.get("accumulated_context") or {}
|
||||
history = data.get("history") or []
|
||||
has_snapshot = data.get("has_snapshot", False)
|
||||
sid = data.get("session_id", "")
|
||||
short_id = sid[:8] if len(sid) >= 8 else sid
|
||||
name = data.get("name") or ""
|
||||
|
||||
if not messages and not context:
|
||||
console.print(
|
||||
f"[{DIM}]session {short_id} has no conversation to resume "
|
||||
"(no chat turns or context found).[/]"
|
||||
)
|
||||
if not data.get("turn_details") and not has_snapshot:
|
||||
console.print(
|
||||
f"[{DIM}]tip: turn_detail records are only written when prompt logging is enabled.[/]"
|
||||
)
|
||||
if slash_command:
|
||||
session.record("slash", slash_command, ok=False)
|
||||
return True
|
||||
|
||||
existing = session.agent.messages
|
||||
if existing:
|
||||
console.print(
|
||||
f"[{WARNING}]current session has {len(existing)} messages — "
|
||||
"they will be replaced by the resumed context.[/]"
|
||||
)
|
||||
|
||||
manager = SessionManager.for_session(session)
|
||||
manager.rebind_for_resume(
|
||||
session,
|
||||
session_id=sid,
|
||||
started_at=data.get("started_at"),
|
||||
)
|
||||
manager.restore_context(session, data)
|
||||
|
||||
source = "snapshot" if has_snapshot else "turn records"
|
||||
name_str = f" · {escape(name)}" if name else ""
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]resumed session {short_id}{name_str}[/] "
|
||||
f"[{DIM}]({len(messages)} messages in context from {source})[/]"
|
||||
)
|
||||
|
||||
render_resumed_session_history(
|
||||
console,
|
||||
history=history,
|
||||
turn_details=data.get("turn_details") or [],
|
||||
messages=list(messages),
|
||||
)
|
||||
|
||||
if context:
|
||||
console.print(
|
||||
f"[{DIM}]accumulated context restored:[/] "
|
||||
+ ", ".join(f"{escape(k)}={escape(str(v))}" for k, v in sorted(context.items()))
|
||||
)
|
||||
|
||||
if slash_command:
|
||||
session.record("slash", slash_command)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _lookup_resume_session_data(
|
||||
prefix: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
) -> dict | None:
|
||||
"""Resolve a session to resume by ID prefix or name substring."""
|
||||
from core.agent_harness.session import default_session_repo
|
||||
|
||||
repo = default_session_repo()
|
||||
data = repo.load_session(prefix)
|
||||
if data is None and len(prefix) >= 3:
|
||||
candidates = [
|
||||
e
|
||||
for e in repo.load_recent(20)
|
||||
if prefix.lower() in (e.get("name") or "").lower()
|
||||
and e["session_id"] != session.session_id
|
||||
]
|
||||
if len(candidates) == 1:
|
||||
data = repo.load_session(candidates[0]["session_id"])
|
||||
elif len(candidates) > 1:
|
||||
console.print(
|
||||
f"[{WARNING}]'{escape(prefix)}' matches {len(candidates)} sessions by name — "
|
||||
"use a session ID prefix or be more specific.[/]"
|
||||
)
|
||||
return None
|
||||
|
||||
if data is not None:
|
||||
return data
|
||||
|
||||
n = repo.count_prefix_matches(prefix)
|
||||
if n > 1:
|
||||
console.print(
|
||||
f"[{WARNING}]ambiguous prefix '{escape(prefix)}' matches {n} sessions — "
|
||||
"use more characters.[/]"
|
||||
)
|
||||
else:
|
||||
console.print(f"[{ERROR}]session '{escape(prefix)}' not found.[/]")
|
||||
return None
|
||||
|
||||
|
||||
def _do_resume(
|
||||
prefix: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
slash_command: str | None = None,
|
||||
) -> bool:
|
||||
"""Load session by ID prefix and restore context into the running session."""
|
||||
data = _lookup_resume_session_data(prefix, session, console)
|
||||
if data is None:
|
||||
return False
|
||||
return _apply_resume_data(data, session, console, slash_command=slash_command)
|
||||
|
||||
|
||||
def resume_session_by_prefix(
|
||||
prefix: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
slash_command: str | None = None,
|
||||
) -> bool:
|
||||
"""Load session by ID prefix and restore context into the running session."""
|
||||
return _do_resume(prefix, session, console, slash_command=slash_command)
|
||||
|
||||
|
||||
def _cmd_resume(session: Session, console: Console, args: list[str]) -> bool:
|
||||
if not args and repl_tty_interactive():
|
||||
return _interactive_resume_menu(session, console)
|
||||
|
||||
if not args:
|
||||
console.print(f"[{DIM}]usage: /resume <session-id-prefix>[/]")
|
||||
console.print(f"[{DIM}]run /sessions to list session IDs.[/]")
|
||||
_record_resume_slash(session, args)
|
||||
return True
|
||||
|
||||
prefix = args[0].strip()
|
||||
session_prefix = prefix.split(":", 1)[0]
|
||||
|
||||
if session.session_id.startswith(session_prefix) and ":" not in prefix:
|
||||
console.print(
|
||||
f"[{DIM}]session {session_prefix[:8]} is the current session — "
|
||||
"run /sessions to pick a previous one.[/]"
|
||||
)
|
||||
_record_resume_slash(session, args)
|
||||
return True
|
||||
|
||||
data = _lookup_resume_session_data(prefix, session, console)
|
||||
if data is None:
|
||||
_record_resume_slash(session, args, ok=False)
|
||||
return True
|
||||
|
||||
slash_command = f"/resume {' '.join(args)}" if args else "/resume"
|
||||
_apply_resume_data(data, session, console, slash_command=slash_command)
|
||||
return True
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Presentation for /resume: render a resumed session's prior activity.
|
||||
|
||||
Pure rendering — takes a console plus already-loaded session data and prints it
|
||||
in REPL turn order. Holds no lookup or orchestration logic so the resume command
|
||||
module stays focused on the resume flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from surfaces.interactive_shell.ui import DIM, HIGHLIGHT
|
||||
|
||||
_HISTORY_DISPLAY_CHAT_KINDS: frozenset[str] = frozenset(
|
||||
{"chat", "cli_agent", "follow_up", "alert", "incoming_alert"}
|
||||
)
|
||||
|
||||
|
||||
def _response_for_prompt(turn_details: list[dict], prompt: str) -> str:
|
||||
for detail in turn_details:
|
||||
if detail.get("prompt") == prompt:
|
||||
return str(detail.get("response") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def render_resumed_session_history(
|
||||
console: Console,
|
||||
*,
|
||||
history: list[dict],
|
||||
turn_details: list[dict],
|
||||
messages: list[tuple[str, str]],
|
||||
) -> None:
|
||||
"""Render prior session activity in REPL turn order, including slash commands."""
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from platform.terminal.theme import MARKDOWN_THEME
|
||||
from surfaces.interactive_shell.ui.streaming import render_response_header
|
||||
|
||||
if not history and not messages:
|
||||
return
|
||||
|
||||
console.print(f"[{DIM}]─── conversation history ─────────────────────────────────[/]")
|
||||
|
||||
if history:
|
||||
assistant_by_user: dict[str, deque[str]] = {}
|
||||
pending_user: str | None = None
|
||||
for role, text in messages:
|
||||
if role == "user":
|
||||
pending_user = text
|
||||
elif role == "assistant" and pending_user is not None:
|
||||
assistant_by_user.setdefault(pending_user, deque()).append(text)
|
||||
pending_user = None
|
||||
|
||||
for rec in history:
|
||||
kind = rec.get("kind", "")
|
||||
text = rec.get("text") or ""
|
||||
if kind == "slash":
|
||||
console.print(f"[bold]$ {escape(text)}[/bold]")
|
||||
continue
|
||||
if kind not in _HISTORY_DISPLAY_CHAT_KINDS or not text:
|
||||
continue
|
||||
console.print(f"[bold {HIGHLIGHT}]❯[/] {escape(text)}")
|
||||
response = _response_for_prompt(turn_details, text)
|
||||
if not response:
|
||||
queued = assistant_by_user.get(text)
|
||||
response = queued.popleft() if queued else ""
|
||||
if response:
|
||||
render_response_header(console, "assistant")
|
||||
with console.use_theme(MARKDOWN_THEME):
|
||||
console.print(Markdown(response, code_theme="ansi_dark"))
|
||||
console.print(f"[{DIM}]─────────────────────────────────────────────────────────[/]")
|
||||
return
|
||||
|
||||
has_pending_user = False
|
||||
for role, text in messages:
|
||||
if role == "user":
|
||||
console.print(f"[bold {HIGHLIGHT}]❯[/] {escape(text)}")
|
||||
has_pending_user = True
|
||||
elif role == "assistant" and has_pending_user:
|
||||
render_response_header(console, "assistant")
|
||||
with console.use_theme(MARKDOWN_THEME):
|
||||
console.print(Markdown(text, code_theme="ansi_dark"))
|
||||
has_pending_user = False
|
||||
console.print(f"[{DIM}]─────────────────────────────────────────────────────────[/]")
|
||||
|
||||
|
||||
__all__ = ["render_resumed_session_history"]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Slash commands: session settings (/trust, /effort, /verbose, /compact)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
import surfaces.interactive_shell.command_registry.repl_data as repl_data
|
||||
from config.llm_reasoning_effort import (
|
||||
REASONING_EFFORT_OPTIONS,
|
||||
describe_reasoning_effort_default,
|
||||
display_reasoning_effort,
|
||||
parse_reasoning_effort,
|
||||
provider_supports_reasoning_effort,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import (
|
||||
DIM,
|
||||
ERROR,
|
||||
HIGHLIGHT,
|
||||
WARNING,
|
||||
resolve_provider_models,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import (
|
||||
repl_choose_one,
|
||||
repl_section_break,
|
||||
repl_tty_interactive,
|
||||
)
|
||||
|
||||
_TRUST_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("on", "enable trust mode (skip approval prompts)"),
|
||||
("off", "disable trust mode"),
|
||||
)
|
||||
|
||||
_VERBOSE_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("on", "enable verbose logging"),
|
||||
("off", "disable verbose logging"),
|
||||
)
|
||||
|
||||
_EFFORT_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("low", "favor speed and lower reasoning cost"),
|
||||
("medium", "balanced reasoning effort"),
|
||||
("high", "favor more thorough reasoning"),
|
||||
("xhigh", "favor deepest supported reasoning"),
|
||||
("max", "alias for xhigh"),
|
||||
)
|
||||
|
||||
|
||||
def _interactive_trust_menu(session: Session, console: Console) -> bool:
|
||||
while True:
|
||||
mode = repl_choose_one(
|
||||
title="trust",
|
||||
breadcrumb="/trust",
|
||||
choices=[("on", "on"), ("off", "off"), ("done", "done")],
|
||||
)
|
||||
if mode is None or mode == "done":
|
||||
return True
|
||||
_cmd_trust(session, console, [mode])
|
||||
repl_section_break(console)
|
||||
|
||||
|
||||
def _cmd_trust(session: Session, console: Console, args: list[str]) -> bool:
|
||||
if not args and repl_tty_interactive():
|
||||
return _interactive_trust_menu(session, console)
|
||||
|
||||
if args and args[0].lower() in ("off", "false", "disable"):
|
||||
session.terminal.trust_mode = False
|
||||
console.print(f"[{DIM}]trust mode off[/]")
|
||||
else:
|
||||
session.terminal.trust_mode = True
|
||||
console.print(f"[{WARNING}]trust mode on[/] — future approval prompts will be skipped")
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_effort(session: Session, console: Console, args: list[str]) -> bool:
|
||||
settings = repl_data.load_llm_settings()
|
||||
provider = str(getattr(settings, "provider", os.getenv("LLM_PROVIDER", "anthropic")))
|
||||
reasoning_model = ""
|
||||
if settings is not None:
|
||||
reasoning_model, _toolcall_model = resolve_provider_models(settings, provider)
|
||||
supported_values = ", ".join(REASONING_EFFORT_OPTIONS)
|
||||
|
||||
if not args:
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]reasoning effort:[/] {display_reasoning_effort(session.reasoning_effort)}"
|
||||
)
|
||||
console.print(
|
||||
f"[{DIM}]default config:[/] "
|
||||
f"{escape(describe_reasoning_effort_default(provider, reasoning_model))}"
|
||||
)
|
||||
console.print(f"[{DIM}]usage:[/] /effort <{supported_values}>")
|
||||
if not provider_supports_reasoning_effort(provider):
|
||||
console.print(
|
||||
f"[{DIM}]current provider {provider} ignores this setting; "
|
||||
"switch to openai or codex to use it.[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
effort = parse_reasoning_effort(args[0])
|
||||
if effort is None:
|
||||
console.print(
|
||||
f"[{ERROR}]unknown reasoning effort:[/] {escape(args[0])} "
|
||||
f"[{DIM}](choices: {supported_values})[/]"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
session.reasoning_effort = effort
|
||||
console.print(f"[{HIGHLIGHT}]reasoning effort set to:[/] {display_reasoning_effort(effort)}")
|
||||
if not provider_supports_reasoning_effort(provider):
|
||||
console.print(
|
||||
f"[{DIM}]current provider {provider} ignores this setting; "
|
||||
"switch to openai or codex to use it.[/]"
|
||||
)
|
||||
elif effort in {"xhigh", "max"}:
|
||||
console.print(
|
||||
f"[{DIM}]xhigh/max work best with newer GPT-5 or Codex models; "
|
||||
"older reasoning models may reject them.[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _interactive_verbose_menu(_session: Session, console: Console) -> bool:
|
||||
while True:
|
||||
mode = repl_choose_one(
|
||||
title="verbose",
|
||||
breadcrumb="/verbose",
|
||||
choices=[("on", "on"), ("off", "off"), ("done", "done")],
|
||||
)
|
||||
if mode is None or mode == "done":
|
||||
return True
|
||||
_cmd_verbose(_session, console, [mode])
|
||||
repl_section_break(console)
|
||||
|
||||
|
||||
def _cmd_verbose(_session: Session, console: Console, args: list[str]) -> bool:
|
||||
if not args and repl_tty_interactive():
|
||||
return _interactive_verbose_menu(_session, console)
|
||||
|
||||
if args and args[0].lower() in ("off", "false", "0", "disable"):
|
||||
os.environ.pop("TRACER_VERBOSE", None)
|
||||
console.print(f"[{DIM}]verbose logging off[/]")
|
||||
else:
|
||||
os.environ["TRACER_VERBOSE"] = "1"
|
||||
console.print(f"[{WARNING}]verbose logging on[/]")
|
||||
return True
|
||||
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/trust",
|
||||
"Manage trust mode.",
|
||||
_cmd_trust,
|
||||
usage=("/trust", "/trust on", "/trust off"),
|
||||
notes=("In a TTY, bare /trust opens an interactive menu.",),
|
||||
first_arg_completions=_TRUST_FIRST_ARGS,
|
||||
),
|
||||
SlashCommand(
|
||||
"/effort",
|
||||
"Set REPL reasoning effort.",
|
||||
_cmd_effort,
|
||||
usage=("/effort <low|medium|high|xhigh|max>",),
|
||||
first_arg_completions=_EFFORT_FIRST_ARGS,
|
||||
),
|
||||
SlashCommand(
|
||||
"/verbose",
|
||||
"Manage verbose logging.",
|
||||
_cmd_verbose,
|
||||
usage=("/verbose", "/verbose on", "/verbose off"),
|
||||
notes=("In a TTY, bare /verbose opens an interactive menu.",),
|
||||
first_arg_completions=_VERBOSE_FIRST_ARGS,
|
||||
),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS", "_TRUST_FIRST_ARGS", "_VERBOSE_FIRST_ARGS", "_EFFORT_FIRST_ARGS"]
|
||||
@@ -0,0 +1,510 @@
|
||||
"""MCP-style slash-command catalog for LLM planners and tool specs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from core.agent_harness.tools.tool_context import (
|
||||
object_schema,
|
||||
string_array_property,
|
||||
string_property,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
|
||||
_MAX_COMPACT_DESC_CHARS = 120
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SlashCommandSpec:
|
||||
name: str
|
||||
description: str
|
||||
llm_description: str
|
||||
use_cases: tuple[str, ...]
|
||||
anti_examples: tuple[str, ...]
|
||||
usage: tuple[str, ...]
|
||||
examples: tuple[str, ...]
|
||||
args_schema: dict[str, Any] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SlashMcpFields:
|
||||
llm_description: str
|
||||
use_cases: tuple[str, ...]
|
||||
anti_examples: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _mcp(
|
||||
llm_description: str,
|
||||
*use_cases: str,
|
||||
anti_examples: tuple[str, ...] = (),
|
||||
) -> _SlashMcpFields:
|
||||
return _SlashMcpFields(
|
||||
llm_description=llm_description,
|
||||
use_cases=use_cases,
|
||||
anti_examples=anti_examples,
|
||||
)
|
||||
|
||||
|
||||
_MCP_BY_COMMAND: dict[str, _SlashMcpFields] = {
|
||||
"/?": _mcp(
|
||||
"Shortcut for /help — open the interactive slash-command help browser.",
|
||||
"User types ? or asks for command help via the shortcut alias",
|
||||
anti_examples=("User asks a docs/how-to question about OpenSRE features",),
|
||||
),
|
||||
"/alerts": _mcp(
|
||||
"Show status of the local alert listener inbox: queue depth, dropped count, "
|
||||
"and the most recent ingested alerts.",
|
||||
"User asks about the alert inbox, listener, or queued alerts",
|
||||
anti_examples=("User wants to investigate an alert body (use investigation_start)",),
|
||||
),
|
||||
"/auth": _mcp(
|
||||
"Log in to LLM providers and inspect local auth state. Subcommands: login, status, logout.",
|
||||
"User asks to log in, authenticate, connect an LLM provider, or check provider auth",
|
||||
anti_examples=("User asks to configure an observability integration (use /integrations)",),
|
||||
),
|
||||
"/background": _mcp(
|
||||
"Manage session-local background investigation mode and completed RCA summaries. "
|
||||
"Subcommands: on, off, status, list, show <task_id>, use <task_id>, notify list, notify set.",
|
||||
"User asks to enable or disable background investigation mode",
|
||||
"User asks to list or inspect completed background RCAs",
|
||||
"User asks to configure background RCA notification channels",
|
||||
),
|
||||
"/cancel": _mcp(
|
||||
"Cancel a running background task by task id. Requires confirmation in non-trust mode.",
|
||||
"User asks to cancel a specific task when they provide or imply a task id",
|
||||
anti_examples=("User asks to stop everything without an id (use /stop guidance first)",),
|
||||
),
|
||||
"/clear": _mcp(
|
||||
"Clear the terminal screen and re-render the OpenSRE banner.",
|
||||
"User asks to clear the screen or terminal",
|
||||
),
|
||||
"/compact": _mcp(
|
||||
"Compact the current long-running session context into a replayable summary entry "
|
||||
"while keeping recent messages available for the next turn.",
|
||||
"User asks to compact or summarize the current session context",
|
||||
"User wants to reduce context size without starting a new session",
|
||||
),
|
||||
"/config": _mcp(
|
||||
"Show or edit local OpenSRE configuration (~/.opensre/config.yml). "
|
||||
"Subcommands: show, set <key> <value>.",
|
||||
"User asks to view or change OpenSRE config settings",
|
||||
anti_examples=("User asks how to configure an integration (may need assistant_handoff)",),
|
||||
),
|
||||
"/context": _mcp(
|
||||
"Display accumulated infrastructure context collected during the session.",
|
||||
"User asks what context or infra metadata the session has accumulated",
|
||||
),
|
||||
"/cost": _mcp(
|
||||
"Show token usage and estimated session cost for LLM calls in this REPL session.",
|
||||
"User asks about token usage, cost, or spend in the current session",
|
||||
),
|
||||
"/cron": _mcp(
|
||||
"Manage cron-driven scheduled deliveries. "
|
||||
"Subcommands: list, add, remove <id>, run <id>, logs <id>.",
|
||||
"User asks to list, add, remove, run, or view logs for scheduled delivery tasks",
|
||||
anti_examples=("User asks about one-off messaging without a schedule (use /messaging)",),
|
||||
),
|
||||
"/doctor": _mcp(
|
||||
"Run a full local environment diagnostic and print pass/warn/fail per check.",
|
||||
"User asks for environment diagnostics, setup validation, or doctor check",
|
||||
anti_examples=("User only wants integration health (use /health instead)",),
|
||||
),
|
||||
"/effort": _mcp(
|
||||
"Set REPL reasoning effort level: low, medium, high, xhigh, or max.",
|
||||
"User asks to change reasoning effort or depth for the active model",
|
||||
anti_examples=("User asks to switch provider or model name (use /model)",),
|
||||
),
|
||||
"/exit": _mcp(
|
||||
"Exit the interactive shell and return to the parent terminal.",
|
||||
"User asks to exit, quit, or leave the REPL",
|
||||
),
|
||||
"/fleet": _mcp(
|
||||
"Show and manage the local AI agent fleet (Claude Code, Cursor, Aider, etc.). "
|
||||
"Subcommands include budget, bus, claim, conflicts, kill, release, trace, wait, graph.",
|
||||
"User asks to list, scan, or manage local coding agents",
|
||||
anti_examples=("User asks about remote/hosted agents only",),
|
||||
),
|
||||
"/gateway": _mcp(
|
||||
"Control the background OpenSRE gateway daemon (web health app, Telegram chat, "
|
||||
"task scheduler). Subcommands: start, stop, status, logs [lines].",
|
||||
"User asks to start, stop, check, or read logs of the gateway daemon",
|
||||
anti_examples=("User asks to send a single Telegram message (use messaging tools)",),
|
||||
),
|
||||
"/guardrails": _mcp(
|
||||
"Manage sensitive-information guardrail rules. Subcommands: audit, init, rules, test.",
|
||||
"User asks about guardrails, PII rules, or sensitive-data masking configuration",
|
||||
),
|
||||
"/health": _mcp(
|
||||
"Run a read-only health check of the local OpenSRE agent, LLM connectivity, "
|
||||
"and configured integrations with pass/fail per component.",
|
||||
"User asks if OpenSRE is healthy, working, or connected",
|
||||
anti_examples=(
|
||||
"User asks what integrations OpenSRE supports in general (docs → assistant_handoff)",
|
||||
"User asks to list connected integrations (use /integrations list)",
|
||||
),
|
||||
),
|
||||
"/help": _mcp(
|
||||
"Show the slash-command help index or detailed help for a command or category.",
|
||||
"User asks for available commands or help using /help",
|
||||
anti_examples=("User asks a procedural docs question (assistant_handoff)",),
|
||||
),
|
||||
"/hermes": _mcp(
|
||||
"Live-tail Hermes logs and send detected incidents to Telegram. Subcommand: watch.",
|
||||
"User asks to watch Hermes logs or Hermes incident escalation",
|
||||
),
|
||||
"/history": _mcp(
|
||||
"Manage persisted command history: clear, off, on, retention <N>.",
|
||||
"User asks to clear, disable, or configure command history persistence",
|
||||
),
|
||||
"/integrations": _mcp(
|
||||
"Manage configured integrations. Subcommands: list, verify, show <service>, remove.",
|
||||
"User asks to verify an integration by name",
|
||||
"User asks to show details for a configured integration",
|
||||
anti_examples=(
|
||||
"User asks which integrations OpenSRE supports without configuring (assistant_handoff)",
|
||||
"User asks to list connected integrations (prefer /integrations list)",
|
||||
),
|
||||
),
|
||||
"/investigate": _mcp(
|
||||
"Run an RCA investigation from a local alert file path or a built-in sample template.",
|
||||
"User asks to investigate a file path or run RCA from a saved alert file",
|
||||
"User asks to run one of the built-in sample alerts/templates",
|
||||
anti_examples=(
|
||||
"User pastes alert text inline (use investigation_start instead)",
|
||||
"User asks how investigations work (assistant_handoff)",
|
||||
),
|
||||
),
|
||||
"/last": _mcp(
|
||||
"Reprint the most recent investigation report from this session.",
|
||||
"User asks to show the last investigation result or report again",
|
||||
),
|
||||
"/login": _mcp(
|
||||
"Shortcut for /auth login. Supports subscription aliases chatgpt and claude, "
|
||||
"and API-key providers such as deepseek.",
|
||||
"User asks to log in to ChatGPT, Claude, DeepSeek, or another LLM provider",
|
||||
anti_examples=("User asks to log in to a non-LLM integration (use /integrations or /mcp)",),
|
||||
),
|
||||
"/rca": _mcp(
|
||||
"Browse persisted RCA reports across sessions. Subcommands: history, show <id>, save <path>.",
|
||||
"User asks for past RCA reports, investigation history, or to export a previous root-cause report",
|
||||
anti_examples=("User asks for command history or up-arrow recall (use /history)",),
|
||||
),
|
||||
"/mcp": _mcp(
|
||||
"Manage connected MCP servers. Subcommands: list, connect, disconnect.",
|
||||
"User asks to list, connect, or disconnect MCP servers",
|
||||
anti_examples=("User asks about remote deployments or remote agents (use /remote)",),
|
||||
),
|
||||
"/messaging": _mcp(
|
||||
"Manage messaging security and Telegram identities. Subcommands: pair, allow, revoke, status.",
|
||||
"User asks about Telegram pairing, messaging allowlist, or messaging status",
|
||||
),
|
||||
"/misses": _mcp(
|
||||
"Triage investigation misses and export them as benchmark regression scenarios. "
|
||||
"Subcommands: list, stats, export --out <dir>, convert <miss_id>.",
|
||||
"User asks about investigation misses, miss triage, or miss trends",
|
||||
"User asks to convert recent misses into regression scenarios or evals",
|
||||
anti_examples=(
|
||||
"User asks for raw feedback ratings without taxonomy (read ~/.opensre/feedback.jsonl directly)",
|
||||
),
|
||||
),
|
||||
"/model": _mcp(
|
||||
"Explicit /model command operations: show the model table, open the model menu, "
|
||||
"set/restore the active LLM provider or reasoning model, or manage the toolcall model.",
|
||||
"User explicitly types /model or asks to run /model show",
|
||||
"User asks to change, set, restore, or switch the active provider or model",
|
||||
anti_examples=(
|
||||
"User asks a natural-language model status question like 'which model is being used now?' (assistant_handoff)",
|
||||
"User asks what model/provider the assistant is using (assistant_handoff)",
|
||||
"User asks whether OpenAI is configured now without explicitly asking to run /model or verify credentials (assistant_handoff)",
|
||||
"User says switch to local llama without a concrete provider (assistant_handoff)",
|
||||
),
|
||||
),
|
||||
"/onboard": _mcp(
|
||||
"Launch the interactive onboarding wizard (handoff if run inside the REPL).",
|
||||
"User asks to run onboarding or initial setup wizard",
|
||||
),
|
||||
"/privacy": _mcp(
|
||||
"Show history persistence settings, redaction status, and the local threat model.",
|
||||
"User asks about privacy, history encryption, or data retention in the shell",
|
||||
),
|
||||
"/quit": _mcp(
|
||||
"Alias for /exit — leave the interactive shell.",
|
||||
"User asks to quit the REPL",
|
||||
),
|
||||
"/remote": _mcp(
|
||||
"Connect to, list, and operate remote deployed OpenSRE agents. "
|
||||
"Subcommands: health, investigate, ops, pull, trigger.",
|
||||
"User explicitly asks to connect to a remote/hosted/EC2/Nitro OpenSRE instance",
|
||||
"User asks how many remote deployments are configured or wants to inspect a remote agent",
|
||||
"User asks about remote deployment status, health, or operations",
|
||||
anti_examples=("Vague connect to X without remote/hosted context (assistant_handoff)",),
|
||||
),
|
||||
"/new": _mcp(
|
||||
"Start a new session while preserving the current LLM conversation context and "
|
||||
"accumulated infra context. Rotates the session ID and resets all session state "
|
||||
"while keeping the conversation thread so you can continue seamlessly in a fresh session file.",
|
||||
"User wants to continue a conversation in a new session after /resume",
|
||||
"User asks to start a new session without losing their current conversation",
|
||||
anti_examples=(
|
||||
"User wants to clear the screen (use /clear)",
|
||||
"User asks to list sessions (use /sessions)",
|
||||
),
|
||||
),
|
||||
"/resume": _mcp(
|
||||
"Restore the conversation context from a previous session. "
|
||||
"Bare /resume opens an interactive numbered picker. "
|
||||
"Pass a session ID prefix, a session:entry reference, or a name substring to resume directly "
|
||||
"(e.g. /resume 9b2e4f7a, /resume 9b2e4f7a:abc123, or /resume redis).",
|
||||
"User asks to resume or continue a previous session",
|
||||
"User wants to pick up where they left off in an earlier REPL session",
|
||||
"User types /resume with no argument to pick from a list",
|
||||
anti_examples=(
|
||||
"User asks to list sessions (use /sessions)",
|
||||
"User asks to start a new session keeping context (use /new)",
|
||||
),
|
||||
),
|
||||
"/save": _mcp(
|
||||
"Save the last investigation report to a file path. Requires confirmation.",
|
||||
"User asks to export or save the last investigation to disk",
|
||||
),
|
||||
"/sessions": _mcp(
|
||||
"List recent REPL sessions stored on disk. Shows session ID, start time, duration, "
|
||||
"total turns, and investigation count for each session.",
|
||||
"User asks to see past sessions, session history, or what was run in previous sessions",
|
||||
anti_examples=("User asks for the current session status (use /status)",),
|
||||
),
|
||||
"/status": _mcp(
|
||||
"Explicit /status command operation: show REPL session status, including "
|
||||
"provider, models, trust mode, and active flags.",
|
||||
"User explicitly types /status or asks to run /status",
|
||||
anti_examples=(
|
||||
"User asks conversationally what the current session status is (assistant_handoff)",
|
||||
"User asks if integrations are healthy (use /health)",
|
||||
),
|
||||
),
|
||||
"/stop": _mcp(
|
||||
"Print guidance for stopping in-flight investigations and background tasks.",
|
||||
"User asks how to stop a running investigation or background work",
|
||||
anti_examples=("User provides a task id to cancel (use /cancel)",),
|
||||
),
|
||||
"/tasks": _mcp(
|
||||
"List recent and in-flight shell background tasks with ids and status.",
|
||||
"User asks to list running or recent tasks",
|
||||
),
|
||||
"/template": _mcp(
|
||||
"Print a starter alert JSON template (generic, datadog, grafana, honeycomb, coralogix, splunk).",
|
||||
"User asks for an alert template or example payload format",
|
||||
),
|
||||
"/tools": _mcp(
|
||||
"Explicit /tools command operation: list registered investigation/chat tools "
|
||||
"wired into this OpenSRE build.",
|
||||
"User explicitly types /tools or asks to run /tools",
|
||||
"User explicitly asks to list registered tools as a shell command",
|
||||
anti_examples=(
|
||||
"User asks conversationally what tools or capabilities the assistant can use (assistant_handoff)",
|
||||
),
|
||||
),
|
||||
"/tests": _mcp(
|
||||
"Browse and run inventoried tests from the terminal. Subcommands: list, run, synthetic.",
|
||||
"User asks to list or run bundled tests via /tests",
|
||||
),
|
||||
"/theme": _mcp(
|
||||
"Choose and persist the interactive shell color palette (TTY picker or /theme <name>).",
|
||||
"User asks to change the REPL color theme or palette",
|
||||
anti_examples=("User asks about light/dark mode in a web UI",),
|
||||
),
|
||||
"/trust": _mcp(
|
||||
"Enable or disable trust mode (skip execution confirmation prompts). on | off.",
|
||||
"User asks to enable or disable trust mode or auto-approve",
|
||||
),
|
||||
"/uninstall": _mcp(
|
||||
"Remove OpenSRE and all local data from this machine. Destructive — requires confirmation.",
|
||||
"User explicitly asks to uninstall OpenSRE locally",
|
||||
),
|
||||
"/unwatch": _mcp(
|
||||
"Cancel a running watchdog task by task id. Requires confirmation.",
|
||||
"User asks to stop a /watch background task by id",
|
||||
),
|
||||
"/update": _mcp(
|
||||
"Check for a newer OpenSRE version and update if available.",
|
||||
"User asks to update or upgrade OpenSRE",
|
||||
),
|
||||
"/verbose": _mcp(
|
||||
"Toggle verbose logging in the REPL. on | off.",
|
||||
"User asks to enable or disable verbose logging",
|
||||
),
|
||||
"/verify": _mcp(
|
||||
"Run connectivity checks on configured integrations. "
|
||||
"Bare /verify checks all; pass a service name to verify one.",
|
||||
"User asks to verify integrations or check if a named integration is reachable",
|
||||
"User asks to verify one integration by name (e.g. datadog, telegram)",
|
||||
anti_examples=(
|
||||
"User asks for full agent and LLM health (use /health)",
|
||||
"User asks to show integration config details (use /integrations show)",
|
||||
),
|
||||
),
|
||||
"/version": _mcp(
|
||||
"Print OpenSRE version, Python version, and OS information.",
|
||||
"User asks for version information",
|
||||
),
|
||||
"/watch": _mcp(
|
||||
"Watch a process by PID and send Telegram threshold alarms. Requires confirmation.",
|
||||
"User asks to watch a process or set resource threshold alarms",
|
||||
),
|
||||
"/watchdog": _mcp(
|
||||
"Monitor one process and send threshold alarms (CLI parity wrapper).",
|
||||
"User asks to run the watchdog monitor CLI from the REPL",
|
||||
),
|
||||
"/watches": _mcp(
|
||||
"List active watchdog background tasks with latest resource samples.",
|
||||
"User asks to list running watchdog watches",
|
||||
),
|
||||
"/debug": _mcp(
|
||||
"Run targeted runtime diagnostics (e.g. /debug sentry to trigger a Sentry smoke test).",
|
||||
"User asks to run a debug check or diagnostic",
|
||||
anti_examples=("User asks a general debugging or troubleshooting question",),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_mcp_fields(command: SlashCommand) -> _SlashMcpFields:
|
||||
registry = _MCP_BY_COMMAND.get(command.name)
|
||||
llm_description = (
|
||||
command.llm_description or (registry.llm_description if registry else "")
|
||||
).strip()
|
||||
if not llm_description:
|
||||
llm_description = command.description.strip()
|
||||
if command.usage:
|
||||
llm_description = f"{llm_description} Common forms: {', '.join(command.usage[:3])}."
|
||||
|
||||
use_cases = command.use_cases or (registry.use_cases if registry else ())
|
||||
if not use_cases:
|
||||
use_cases = (f"User intent matches: {command.description.rstrip('.')}",)
|
||||
|
||||
anti_examples = command.anti_examples or (registry.anti_examples if registry else ())
|
||||
return _SlashMcpFields(
|
||||
llm_description=llm_description,
|
||||
use_cases=use_cases,
|
||||
anti_examples=anti_examples,
|
||||
)
|
||||
|
||||
|
||||
def _derive_args_schema(command: SlashCommand) -> dict[str, Any] | None:
|
||||
if command.args_schema is not None:
|
||||
return command.args_schema
|
||||
if not command.first_arg_completions:
|
||||
return None
|
||||
hints = "; ".join(f"{keyword} ({label})" for keyword, label in command.first_arg_completions)
|
||||
return {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
f"Positional arguments after {command.name}. First-argument options: {hints}."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def spec_from_command(command: SlashCommand) -> SlashCommandSpec:
|
||||
mcp = _resolve_mcp_fields(command)
|
||||
return SlashCommandSpec(
|
||||
name=command.name,
|
||||
description=command.description,
|
||||
llm_description=mcp.llm_description,
|
||||
use_cases=mcp.use_cases,
|
||||
anti_examples=mcp.anti_examples,
|
||||
usage=command.usage,
|
||||
examples=command.examples,
|
||||
args_schema=_derive_args_schema(command),
|
||||
)
|
||||
|
||||
|
||||
def build_slash_command_specs(
|
||||
commands: dict[str, SlashCommand] | None = None,
|
||||
) -> list[SlashCommandSpec]:
|
||||
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS
|
||||
|
||||
source = commands if commands is not None else SLASH_COMMANDS
|
||||
return [spec_from_command(source[name]) for name in sorted(source.keys())]
|
||||
|
||||
|
||||
def format_slash_catalog_text(
|
||||
specs: list[SlashCommandSpec] | None = None,
|
||||
*,
|
||||
compact: bool = False,
|
||||
) -> str:
|
||||
entries = specs if specs is not None else build_slash_command_specs()
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
lines: list[str] = []
|
||||
for spec in entries:
|
||||
desc = spec.llm_description
|
||||
if compact and len(desc) > _MAX_COMPACT_DESC_CHARS:
|
||||
desc = desc[: _MAX_COMPACT_DESC_CHARS - 1].rstrip() + "…"
|
||||
lines.append(f"- **{spec.name}** — {desc}")
|
||||
if spec.use_cases and not compact:
|
||||
lines.append(f" - use when: {spec.use_cases[0]}")
|
||||
if spec.anti_examples and not compact:
|
||||
lines.append(f" - not for: {spec.anti_examples[0]}")
|
||||
if spec.usage and not compact:
|
||||
lines.append(f" - usage: {', '.join(spec.usage[:2])}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def slash_invoke_tool_description(specs: list[SlashCommandSpec] | None = None) -> str:
|
||||
entries = specs if specs is not None else build_slash_command_specs()
|
||||
header = (
|
||||
"Run a slash command in the OpenSRE interactive shell. "
|
||||
"Use this only for explicit slash-command operations: literal /command "
|
||||
"text, requests that explicitly ask to run a slash command, or "
|
||||
"operation/discovery cases that the system prompt explicitly maps to a "
|
||||
"slash command. Do not use this as a natural-language router for "
|
||||
"ordinary informational, how-to, capability, or status questions merely "
|
||||
"because a slash command can display related information; hand those to "
|
||||
"assistant_handoff unless a prompt rule names a read-only discovery "
|
||||
"exception. Supply positional args in the args array. This tool covers "
|
||||
"only the slash-command clause of a request. For compound requests, "
|
||||
"still emit a separate tool call for every other actionable clause in "
|
||||
"order; for example "
|
||||
'`run /remote and then investigate "hello world"` requires '
|
||||
'slash_invoke(command="/remote", args=[]) followed by '
|
||||
'investigation_start(alert_text="hello world").'
|
||||
)
|
||||
# Keep planner payload intentionally tiny for live LLM runs with strict
|
||||
# prompt budgets. The full rich catalog remains available via
|
||||
# format_slash_catalog_text(..., compact=False).
|
||||
body = "\n".join(f"- `{spec.name}`" for spec in entries)
|
||||
return f"{header}\n\n{body}"
|
||||
|
||||
|
||||
def slash_invoke_input_schema(
|
||||
specs: list[SlashCommandSpec] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
entries = specs if specs is not None else build_slash_command_specs()
|
||||
command_names = tuple(spec.name for spec in entries)
|
||||
args_description = (
|
||||
"Positional arguments after the command name. Valid values depend on the "
|
||||
"chosen command — see the slash_invoke tool description. Examples: "
|
||||
'["list"] for /tools, ["verify", "datadog"] for /integrations.'
|
||||
)
|
||||
return object_schema(
|
||||
properties={
|
||||
"command": string_property(
|
||||
description="Slash command name including leading `/`.",
|
||||
enum=command_names,
|
||||
),
|
||||
"args": string_array_property(description=args_description),
|
||||
},
|
||||
required=("command",),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SlashCommandSpec",
|
||||
"build_slash_command_specs",
|
||||
"format_slash_catalog_text",
|
||||
"slash_invoke_input_schema",
|
||||
"slash_invoke_tool_description",
|
||||
"spec_from_command",
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Small helpers for human-friendly slash-command suggestions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from difflib import get_close_matches
|
||||
from typing import Literal
|
||||
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
|
||||
SlashOutcome = Literal["unknown_command", "invalid_subcommand"]
|
||||
|
||||
_RICH_TAG_RE = re.compile(r"\[[^\]]+\]")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LiteralSlashTypo:
|
||||
"""A user-typed literal slash line that should not run."""
|
||||
|
||||
message: str
|
||||
outcome: SlashOutcome
|
||||
|
||||
|
||||
def closest_choice(value: str, choices: list[str] | tuple[str, ...]) -> str | None:
|
||||
"""Return the nearest command-like choice for a typo, if confidence is high enough."""
|
||||
normalized = value.strip().lower()
|
||||
if not normalized:
|
||||
return None
|
||||
matches = get_close_matches(normalized, choices, n=1, cutoff=0.72)
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def subcommand_hints(cmd: SlashCommand) -> tuple[str, ...]:
|
||||
"""Return enumerable first-argument keywords for ``cmd``.
|
||||
|
||||
Only ``first_arg_completions`` are used — usage strings often contain
|
||||
free-form placeholders like ``<session-id-prefix>`` that must not be
|
||||
treated as literal subcommands.
|
||||
"""
|
||||
return tuple(sorted({keyword.lower() for keyword, _label in cmd.first_arg_completions}))
|
||||
|
||||
|
||||
def format_unknown_slash_message(
|
||||
command_line: str,
|
||||
*,
|
||||
command_names: tuple[str, ...],
|
||||
) -> str:
|
||||
"""Plain-text guidance for an unknown slash command root."""
|
||||
stripped = command_line.strip()
|
||||
name = stripped.split()[0] if stripped else stripped
|
||||
suggestion = closest_choice(name, command_names)
|
||||
if suggestion:
|
||||
return (
|
||||
f"Unknown command: {name}. "
|
||||
f"Did you mean {suggestion}? "
|
||||
"Type /help for the full command list."
|
||||
)
|
||||
return f"Unknown command: {name}. Type /help for the full command list."
|
||||
|
||||
|
||||
def format_invalid_subcommand_message(
|
||||
cmd: SlashCommand,
|
||||
args: list[str],
|
||||
) -> str:
|
||||
"""Plain-text guidance for an invalid subcommand on a known slash command."""
|
||||
subcommand = args[0] if args else ""
|
||||
hints = subcommand_hints(cmd)
|
||||
if hints:
|
||||
choices_text = ", ".join(f"{cmd.name} {hint}" for hint in hints)
|
||||
return (
|
||||
f"Invalid subcommand: {subcommand}. "
|
||||
f"Try one of: {choices_text}. "
|
||||
f"Type /help for the full command list."
|
||||
)
|
||||
return f"Invalid subcommand: {subcommand}. Type /help for the full command list."
|
||||
|
||||
|
||||
def resolve_literal_slash_typo(
|
||||
command_line: str,
|
||||
registry: dict[str, SlashCommand],
|
||||
) -> LiteralSlashTypo | None:
|
||||
"""Return typo guidance when a user-typed literal slash line should not run."""
|
||||
stripped = command_line.strip()
|
||||
if not stripped.startswith("/"):
|
||||
return None
|
||||
|
||||
parts = stripped.split()
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
name = parts[0].lower()
|
||||
args = parts[1:]
|
||||
command_names = tuple(registry)
|
||||
|
||||
cmd = registry.get(name)
|
||||
if cmd is None:
|
||||
return LiteralSlashTypo(
|
||||
message=format_unknown_slash_message(stripped, command_names=command_names),
|
||||
outcome="unknown_command",
|
||||
)
|
||||
|
||||
if cmd.validate_args is not None:
|
||||
validation_error = cmd.validate_args(args)
|
||||
if validation_error is not None and args:
|
||||
return LiteralSlashTypo(
|
||||
message=format_invalid_subcommand_message(cmd, args),
|
||||
outcome="invalid_subcommand",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def strip_rich_markup(text: str) -> str:
|
||||
"""Best-effort plain text for analytics payloads sourced from Rich strings."""
|
||||
return _RICH_TAG_RE.sub("", text).strip()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LiteralSlashTypo",
|
||||
"SlashOutcome",
|
||||
"closest_choice",
|
||||
"format_invalid_subcommand_message",
|
||||
"format_unknown_slash_message",
|
||||
"resolve_literal_slash_typo",
|
||||
"strip_rich_markup",
|
||||
"subcommand_hints",
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Slash commands: diagnostics, version, exit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
import platform
|
||||
from platform.terminal.prompt_support import print_session_resume_hint
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import (
|
||||
BOLD_BRAND,
|
||||
DIM,
|
||||
ERROR,
|
||||
HIGHLIGHT,
|
||||
WARNING,
|
||||
print_repl_table,
|
||||
repl_table,
|
||||
)
|
||||
|
||||
|
||||
def _flush_analytics_on_exit(console: Console) -> None:
|
||||
"""Best-effort PostHog drain with a spinner so /quit is not silent or fire-and-forget."""
|
||||
from platform.analytics.provider import analytics_needs_flush, shutdown_analytics
|
||||
|
||||
if not analytics_needs_flush():
|
||||
shutdown_analytics(flush=False)
|
||||
return
|
||||
|
||||
if console.is_terminal:
|
||||
with console.status(
|
||||
f"[{DIM}]finishing up…[/]",
|
||||
spinner="dots",
|
||||
spinner_style=DIM,
|
||||
):
|
||||
shutdown_analytics(flush=True)
|
||||
else:
|
||||
shutdown_analytics(flush=True)
|
||||
|
||||
|
||||
def _cmd_exit(session: Session, console: Console, _args: list[str]) -> bool:
|
||||
if session.session_id:
|
||||
console.print()
|
||||
print_session_resume_hint(console, session.session_id)
|
||||
_flush_analytics_on_exit(console)
|
||||
console.print(f"[{DIM}]goodbye.[/]")
|
||||
return False
|
||||
|
||||
|
||||
def _cmd_health(_session: Session, console: Console, _args: list[str]) -> bool:
|
||||
from config.config import get_environment
|
||||
from integrations.store import STORE_PATH
|
||||
from integrations.verify import verify_integrations
|
||||
from surfaces.interactive_shell.ui.health import render_health_report
|
||||
|
||||
results = verify_integrations()
|
||||
environment = get_environment().value
|
||||
render_health_report(
|
||||
console=console,
|
||||
environment=environment,
|
||||
integration_store_path=STORE_PATH,
|
||||
results=results,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_doctor(_session: Session, console: Console, _args: list[str]) -> bool:
|
||||
from surfaces.cli.commands.doctor import _CHECKS, _check
|
||||
|
||||
status_styles: dict[str, str] = {"ok": HIGHLIGHT, "warn": WARNING, "error": ERROR}
|
||||
table = repl_table(title="OpenSRE Doctor\n", title_style=BOLD_BRAND)
|
||||
table.add_column("check", style="bold")
|
||||
table.add_column("status")
|
||||
table.add_column("detail", style=DIM, overflow="fold")
|
||||
|
||||
issues = 0
|
||||
for name, fn in _CHECKS:
|
||||
result = _check(name, fn)
|
||||
status = result["status"]
|
||||
style = status_styles.get(status, DIM)
|
||||
table.add_row(name, f"[{style}]{status}[/]", result["detail"])
|
||||
if status in ("warn", "error"):
|
||||
issues += 1
|
||||
|
||||
print_repl_table(console, table)
|
||||
if issues:
|
||||
console.print(f"[{WARNING}]{issues} issue(s) found.[/]")
|
||||
else:
|
||||
console.print(f"[{HIGHLIGHT}]all checks passed.[/]")
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_version(_session: Session, console: Console, _args: list[str]) -> bool:
|
||||
from config.version import get_opensre_version
|
||||
|
||||
table = repl_table(title="Version info\n", title_style=BOLD_BRAND, show_header=False)
|
||||
table.add_column("key", style="bold")
|
||||
table.add_column("value")
|
||||
table.add_row("opensre", get_opensre_version())
|
||||
table.add_row("python", platform.python_version())
|
||||
table.add_row("os", f"{platform.system().lower()} ({platform.machine()})")
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand("/exit", "Exit the interactive shell.", _cmd_exit),
|
||||
SlashCommand("/quit", "Alias for /exit.", _cmd_exit),
|
||||
SlashCommand("/health", "Show integration and agent health.", _cmd_health),
|
||||
SlashCommand("/doctor", "Run full environment diagnostic.", _cmd_doctor),
|
||||
SlashCommand("/version", "Print version, Python, and OS info.", _cmd_version),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS"]
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Slash commands: /history, /tasks, /cancel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from surfaces.interactive_shell.command_registry.types import (
|
||||
SlashCommand,
|
||||
)
|
||||
from surfaces.interactive_shell.prompt_history import load_command_history_entries
|
||||
from surfaces.interactive_shell.runtime import Session, TaskKind, TaskRecord, TaskStatus
|
||||
from surfaces.interactive_shell.ui import (
|
||||
BOLD_BRAND,
|
||||
DIM,
|
||||
ERROR,
|
||||
HIGHLIGHT,
|
||||
WARNING,
|
||||
print_repl_table,
|
||||
repl_table,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.time_format import format_repl_timestamp
|
||||
|
||||
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[mA-Za-z]")
|
||||
_MAX_DETAIL_CHARS = 120
|
||||
_WATCHDOG_PID = re.compile(r"pid=(\d+)")
|
||||
|
||||
|
||||
def _task_started_label(task: TaskRecord) -> str:
|
||||
return format_repl_timestamp(task.started_at, style="utc")
|
||||
|
||||
|
||||
def _task_duration_label(task: TaskRecord) -> str:
|
||||
duration = task.duration_seconds()
|
||||
if duration is None:
|
||||
return "—"
|
||||
return f"{duration:.1f}s"
|
||||
|
||||
|
||||
def _synthetic_scenario_label(command: str) -> str:
|
||||
"""Extract the short scenario identifier from a synthetic test command string."""
|
||||
if "--scenario" in command:
|
||||
return command.split("--scenario", 1)[1].strip()
|
||||
if command.strip().endswith("all"):
|
||||
return "all"
|
||||
return command.strip()
|
||||
|
||||
|
||||
def _clean_first_line(text: str) -> str:
|
||||
"""Strip ANSI codes and return the first non-empty line of ``text``."""
|
||||
clean = _ANSI_ESCAPE.sub("", text)
|
||||
return next((line.strip() for line in clean.splitlines() if line.strip()), clean.strip())
|
||||
|
||||
|
||||
def _kind_label(task: TaskRecord) -> str:
|
||||
"""Return a concise kind label — for synthetic tests use the scenario name."""
|
||||
if task.kind == TaskKind.SYNTHETIC_TEST and task.command:
|
||||
return _synthetic_scenario_label(task.command)
|
||||
if task.kind == TaskKind.WATCHDOG and task.command:
|
||||
match = _WATCHDOG_PID.search(task.command)
|
||||
if match:
|
||||
return f"watchdog {match.group(1)}"
|
||||
return task.kind.value
|
||||
|
||||
|
||||
def _task_detail_label(task: TaskRecord) -> str:
|
||||
if task.status == TaskStatus.RUNNING and task.progress:
|
||||
line = _clean_first_line(task.progress)
|
||||
if len(line) > _MAX_DETAIL_CHARS:
|
||||
return line[:_MAX_DETAIL_CHARS] + "…"
|
||||
return line or "—"
|
||||
|
||||
# Synthetic tests: the kind column already carries the scenario, so show
|
||||
# only the compact outcome here (e.g. "exit code 1" or "ok").
|
||||
if task.kind == TaskKind.SYNTHETIC_TEST:
|
||||
if task.error:
|
||||
err_line = _clean_first_line(task.error)
|
||||
# "exit code 1: …" → keep only "exit code 1"
|
||||
outcome = err_line.split(":")[0].strip() if ":" in err_line else err_line
|
||||
return outcome or "—"
|
||||
if task.result:
|
||||
return task.result
|
||||
if task.command:
|
||||
return _synthetic_scenario_label(task.command)
|
||||
return "—"
|
||||
|
||||
if task.kind == TaskKind.WATCHDOG:
|
||||
if task.error:
|
||||
raw = task.error
|
||||
elif task.result:
|
||||
raw = task.result
|
||||
elif task.command:
|
||||
raw = task.command
|
||||
else:
|
||||
return "—"
|
||||
first_line = _clean_first_line(raw)
|
||||
if len(first_line) > _MAX_DETAIL_CHARS:
|
||||
return first_line[:_MAX_DETAIL_CHARS] + "…"
|
||||
return first_line or "—"
|
||||
|
||||
# All other task kinds: show error > result > command, first line, truncated.
|
||||
if task.error:
|
||||
raw = task.error
|
||||
elif task.result:
|
||||
raw = task.result
|
||||
elif task.command:
|
||||
raw = task.command
|
||||
else:
|
||||
return "—"
|
||||
first_line = _clean_first_line(raw)
|
||||
if len(first_line) > _MAX_DETAIL_CHARS:
|
||||
return first_line[:_MAX_DETAIL_CHARS] + "…"
|
||||
return first_line or "—"
|
||||
|
||||
|
||||
def _cmd_history(_session: Session, console: Console, _args: list[str]) -> bool:
|
||||
entries = load_command_history_entries()
|
||||
if not entries:
|
||||
console.print(f"[{DIM}]no history yet.[/]")
|
||||
return True
|
||||
|
||||
table = repl_table(title="Command history\n", title_style=BOLD_BRAND)
|
||||
table.add_column("#", style=DIM, justify="right")
|
||||
table.add_column("text", overflow="fold")
|
||||
|
||||
for i, entry in enumerate(entries, start=1):
|
||||
table.add_row(str(i), escape(entry))
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_tasks(session: Session, console: Console, _args: list[str]) -> bool:
|
||||
tasks = session.task_registry.list_recent(n=50)
|
||||
if not tasks:
|
||||
console.print(f"[{DIM}]no tasks recorded this session.[/]")
|
||||
return True
|
||||
|
||||
table = repl_table(title="Tasks\n", title_style=BOLD_BRAND)
|
||||
table.add_column("id", style="bold")
|
||||
table.add_column("kind")
|
||||
table.add_column("status")
|
||||
table.add_column("started", style=DIM)
|
||||
table.add_column("duration", style=DIM, justify="right")
|
||||
table.add_column("detail", style=DIM, overflow="fold")
|
||||
|
||||
status_style = {
|
||||
TaskStatus.RUNNING: WARNING,
|
||||
TaskStatus.COMPLETED: HIGHLIGHT,
|
||||
TaskStatus.CANCELLED: WARNING,
|
||||
TaskStatus.FAILED: ERROR,
|
||||
TaskStatus.PENDING: DIM,
|
||||
}
|
||||
for task in tasks:
|
||||
st = status_style.get(task.status, DIM)
|
||||
table.add_row(
|
||||
task.task_id,
|
||||
_kind_label(task),
|
||||
f"[{st}]{task.status.value}[/]",
|
||||
_task_started_label(task),
|
||||
_task_duration_label(task),
|
||||
escape(_task_detail_label(task)),
|
||||
)
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_stop(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
||||
console.print(
|
||||
f"[{DIM}]in-flight work: press[/] [bold]Ctrl+C[/bold] "
|
||||
f"[{DIM}]during a streaming investigation, or run[/] [{HIGHLIGHT}]/tasks[/] "
|
||||
f"[{DIM}]then[/] [{HIGHLIGHT}]/cancel <id>[/] [{DIM}]for background tasks.[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _validate_cancel_args(args: list[str]) -> str | None:
|
||||
if not args:
|
||||
return f"[{ERROR}]usage:[/] /cancel <task_id> — use [{HIGHLIGHT}]/tasks[/] to list ids"
|
||||
return None
|
||||
|
||||
|
||||
def _cmd_cancel(session: Session, console: Console, args: list[str]) -> bool:
|
||||
needle = args[0]
|
||||
candidates = session.task_registry.candidates(needle)
|
||||
if not candidates:
|
||||
console.print(f"[{ERROR}]no task matches id:[/] {escape(needle)}")
|
||||
return True
|
||||
if len(candidates) > 1:
|
||||
console.print(
|
||||
f"[{ERROR}]ambiguous id prefix:[/] {escape(needle)} "
|
||||
f"[{DIM}]({len(candidates)} matches — use a longer prefix)[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
task = candidates[0]
|
||||
if task.status != TaskStatus.RUNNING:
|
||||
console.print(
|
||||
f"[{DIM}]task {escape(task.task_id)} already finished (status: {task.status.value}).[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
task.request_cancel()
|
||||
if task.kind == TaskKind.INVESTIGATION:
|
||||
console.print(
|
||||
f"[{WARNING}]cancellation signaled.[/] "
|
||||
f"[{DIM}]if the investigation is still streaming, press[/] [bold]Ctrl+C[/bold] "
|
||||
f"[{DIM}]to interrupt the current run.[/]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]stop requested[/] "
|
||||
f"[{DIM}]for {escape(task.kind.value)} {escape(task.task_id)}.[/] "
|
||||
f"[{DIM}]use[/] [{HIGHLIGHT}]/tasks[/] [{DIM}]to confirm status.[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand("/history", "Show persisted command history.", _cmd_history),
|
||||
SlashCommand(
|
||||
"/tasks",
|
||||
"List recent and in-flight shell tasks.",
|
||||
_cmd_tasks,
|
||||
usage=("/tasks",),
|
||||
),
|
||||
SlashCommand(
|
||||
"/cancel",
|
||||
"Cancel a running task by id.",
|
||||
_cmd_cancel,
|
||||
usage=("/cancel <task_id>",),
|
||||
notes=("Use /tasks to list task ids.",),
|
||||
validate_args=_validate_cancel_args,
|
||||
),
|
||||
SlashCommand(
|
||||
"/stop",
|
||||
"Show how to stop in-flight investigations and background tasks.",
|
||||
_cmd_stop,
|
||||
),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Slash command: interactive theme selection and persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from platform.terminal import theme as ui_theme
|
||||
from platform.terminal.theme import (
|
||||
get_active_theme_name,
|
||||
list_theme_names,
|
||||
set_active_theme,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import (
|
||||
repl_choose_one,
|
||||
repl_tty_interactive,
|
||||
)
|
||||
|
||||
|
||||
def _refresh_prompt_style(session: Session) -> None:
|
||||
"""Defer prompt-toolkit style refresh until the next prompt_async turn."""
|
||||
session.terminal.pending_theme_refresh = True
|
||||
|
||||
|
||||
def _settle_and_drain_cpr() -> None:
|
||||
"""Let in-flight terminal CPR replies land, then discard them from stdin."""
|
||||
from surfaces.interactive_shell.ui.components.cpr_stdin import drain_stale_cpr_bytes
|
||||
|
||||
time.sleep(0.05)
|
||||
drain_stale_cpr_bytes()
|
||||
|
||||
|
||||
def _persist_and_report_theme(
|
||||
session: Session,
|
||||
console: Console,
|
||||
selected: str,
|
||||
) -> None:
|
||||
from surfaces.cli.commands.config import _load_config, _save_config, _set_nested_key
|
||||
from surfaces.interactive_shell.ui.components.rendering import refresh_welcome_poster
|
||||
|
||||
active = set_active_theme(selected)
|
||||
session.terminal.active_theme_name = active.name
|
||||
|
||||
updated = _set_nested_key(_load_config(), "interactive.theme", active.name)
|
||||
_save_config(updated)
|
||||
|
||||
# Poster redraw and prompt invalidation both trigger prompt_toolkit DSR/CPR
|
||||
# queries under patch_stdout. Drain between each step so bytes never leak into
|
||||
# the next prompt buffer (e.g. ``^[[1;1Rtheme set: pink``).
|
||||
_settle_and_drain_cpr()
|
||||
refresh_welcome_poster(console, session=session, theme_notice=active.name)
|
||||
_settle_and_drain_cpr()
|
||||
_refresh_prompt_style(session)
|
||||
|
||||
|
||||
def _cmd_theme(session: Session, console: Console, args: list[str]) -> bool:
|
||||
if args:
|
||||
selected = args[0].strip().lower()
|
||||
if selected not in list_theme_names():
|
||||
supported = ", ".join(list_theme_names())
|
||||
console.print(f"[{ui_theme.ERROR}]unknown theme:[/] {selected} (choose: {supported})")
|
||||
return True
|
||||
_persist_and_report_theme(session, console, selected)
|
||||
return True
|
||||
|
||||
if not repl_tty_interactive():
|
||||
console.print(f"[{ui_theme.DIM}]/theme requires an interactive TTY session.[/]")
|
||||
return True
|
||||
|
||||
current = get_active_theme_name()
|
||||
session.terminal.active_theme_name = current
|
||||
choices = [
|
||||
(name, f"{name}{' (current)' if name == current else ''}") for name in list_theme_names()
|
||||
]
|
||||
picked = repl_choose_one(
|
||||
title="theme",
|
||||
breadcrumb="/theme",
|
||||
choices=choices,
|
||||
initial_value=current,
|
||||
)
|
||||
if picked is None:
|
||||
console.print(f"[{ui_theme.DIM}]theme unchanged.[/]")
|
||||
return True
|
||||
|
||||
_persist_and_report_theme(session, console, picked)
|
||||
return True
|
||||
|
||||
|
||||
_THEME_FIRST_ARGS: tuple[tuple[str, str], ...] = tuple(
|
||||
(name, "interactive palette") for name in list_theme_names()
|
||||
)
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/theme",
|
||||
"Choose and persist the interactive shell color theme.",
|
||||
_cmd_theme,
|
||||
usage=("/theme", "/theme <name>"),
|
||||
examples=("/theme webflux", "/theme sunset", "/theme gruvbox"),
|
||||
first_arg_completions=_THEME_FIRST_ARGS,
|
||||
)
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS"]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Slash command /tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.command_registry.types import (
|
||||
SlashCommand,
|
||||
make_list_root_handler,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import render_tools_table
|
||||
from surfaces.interactive_shell.ui.tables.tool_catalog import build_tool_catalog
|
||||
|
||||
|
||||
def _list_tools(_session: Session, console: Console, _args: list[str]) -> bool:
|
||||
render_tools_table(console, build_tool_catalog())
|
||||
return True
|
||||
|
||||
|
||||
_cmd_tools = make_list_root_handler(
|
||||
"/tools",
|
||||
_list_tools,
|
||||
list_aliases=("list", "ls", "tool", "tools"),
|
||||
)
|
||||
|
||||
_TOOLS_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("list", "list registered tools (investigation + chat surfaces)"),
|
||||
("ls", "alias for list"),
|
||||
("tool", "alias for list"),
|
||||
("tools", "alias for list"),
|
||||
)
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/tools",
|
||||
"List registered tools.",
|
||||
_cmd_tools,
|
||||
usage=("/tools", "/tools list"),
|
||||
first_arg_completions=_TOOLS_FIRST_ARGS,
|
||||
)
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS", "_TOOLS_FIRST_ARGS", "_cmd_tools"]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Slash-command type definitions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape as _rich_escape
|
||||
|
||||
from platform.terminal.theme import ERROR
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SlashCommand:
|
||||
name: str
|
||||
description: str
|
||||
handler: Callable[[Session, Console, list[str]], bool]
|
||||
usage: tuple[str, ...] = ()
|
||||
examples: tuple[str, ...] = ()
|
||||
notes: tuple[str, ...] = ()
|
||||
#: Tab-completion hints for the first argument after the command name (keyword, meta text).
|
||||
first_arg_completions: tuple[tuple[str, str], ...] = ()
|
||||
#: Optional pre-policy arg validator. Returns ``None`` if args are valid, or
|
||||
#: a user-facing error string (rendered via ``console.print``) to short-circuit
|
||||
#: dispatch with no policy prompt and no handler invocation.
|
||||
validate_args: Callable[[list[str]], str | None] | None = None
|
||||
#: Multi-sentence description for LLM planners; falls back to ``description``.
|
||||
llm_description: str = ""
|
||||
#: Natural-language triggers that should select this command.
|
||||
use_cases: tuple[str, ...] = ()
|
||||
#: Requests that look similar but should NOT use this command.
|
||||
anti_examples: tuple[str, ...] = ()
|
||||
#: JSON Schema for positional args after the command name (optional override).
|
||||
args_schema: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def make_list_root_handler(
|
||||
command_name: str,
|
||||
list_handler: Callable[[Session, Console, list[str]], bool],
|
||||
*,
|
||||
list_aliases: tuple[str, ...] = ("list", "ls"),
|
||||
) -> Callable[[Session, Console, list[str]], bool]:
|
||||
"""Build a root handler that accepts list aliases and delegates to *list_handler*.
|
||||
|
||||
Bare invocation (no args) defaults to ``list``. Unknown subcommands
|
||||
print a hint pointing at ``/<command> list``.
|
||||
"""
|
||||
aliases = frozenset(list_aliases)
|
||||
|
||||
def _root(session: Session, console: Console, args: list[str]) -> bool:
|
||||
sub = (args[0].lower() if args else "list").strip()
|
||||
if sub in aliases:
|
||||
return list_handler(session, console, args[1:])
|
||||
|
||||
console.print(
|
||||
f"[{ERROR}]unknown subcommand:[/] {_rich_escape(sub)} "
|
||||
f"(try [bold]{command_name} list[/bold])"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
return _root
|
||||
|
||||
|
||||
__all__ = ["SlashCommand", "make_list_root_handler"]
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Slash commands: /watch, /watches, /unwatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from integrations.telegram.alarms import AlarmDispatcher
|
||||
from integrations.telegram.credentials import load_credentials_from_env
|
||||
from platform.common.errors import OpenSREError
|
||||
from surfaces.interactive_shell.command_registry.types import (
|
||||
SlashCommand,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime import Session, TaskKind, TaskRecord, TaskStatus
|
||||
from surfaces.interactive_shell.ui import (
|
||||
BOLD_BRAND,
|
||||
DIM,
|
||||
ERROR,
|
||||
HIGHLIGHT,
|
||||
WARNING,
|
||||
print_repl_table,
|
||||
repl_table,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.time_format import format_repl_timestamp
|
||||
from tools.system.fleet_monitoring.probe import pid_exists
|
||||
from tools.system.watch_dog.monitor import start_watchdog_daemon_thread
|
||||
|
||||
_PID_IN_COMMAND_RE = re.compile(r"pid=(\d+)")
|
||||
_CONSOLE_PRINT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WatchdogStartSpec:
|
||||
pid: int
|
||||
max_cpu: float | None = None
|
||||
max_runtime_seconds: float | None = None
|
||||
max_rss_mib: float | None = None
|
||||
cooldown_seconds: float = 300.0
|
||||
interval_seconds: float = 2.0
|
||||
once: bool = False
|
||||
|
||||
|
||||
def _parse_duration_seconds(raw: str) -> float | None:
|
||||
text = raw.strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
mult = 1.0
|
||||
if text.endswith("h"):
|
||||
mult = 3600.0
|
||||
text = text[:-1]
|
||||
elif text.endswith("m") and not text.endswith("ms"):
|
||||
mult = 60.0
|
||||
text = text[:-1]
|
||||
elif text.endswith("s"):
|
||||
mult = 1.0
|
||||
text = text[:-1]
|
||||
try:
|
||||
return float(text) * mult
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_mib(raw: str) -> float | None:
|
||||
text = raw.strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
mult = 1.0
|
||||
if text.endswith("gib") or text.endswith("gb") or text.endswith("g"):
|
||||
mult = 1024.0
|
||||
for suffix in ("gib", "gb", "g"):
|
||||
if text.endswith(suffix):
|
||||
text = text[: -len(suffix)]
|
||||
break
|
||||
elif text.endswith("mib") or text.endswith("mb") or text.endswith("m"):
|
||||
mult = 1.0
|
||||
for suffix in ("mib", "mb", "m"):
|
||||
if text.endswith(suffix):
|
||||
text = text[: -len(suffix)]
|
||||
break
|
||||
try:
|
||||
return float(text) * mult
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_watch_argv(argv: list[str]) -> WatchdogStartSpec | str:
|
||||
"""Parse ``/watch`` arguments (tokens after the command name)."""
|
||||
if not argv:
|
||||
return (
|
||||
f"[{ERROR}]usage:[/] /watch <pid> [--max-cpu N] [--max-runtime D] [--max-rss S] "
|
||||
f"[--cooldown D] [--interval N] [--once]"
|
||||
)
|
||||
if argv[0].startswith("-"):
|
||||
return f"[{ERROR}]usage:[/] /watch <pid> ... — the process id must come first"
|
||||
|
||||
try:
|
||||
pid = int(argv[0], 10)
|
||||
except ValueError:
|
||||
return f"[{ERROR}]invalid pid:[/] {escape(argv[0])}"
|
||||
if pid <= 0:
|
||||
return f"[{ERROR}]pid must be a positive integer:[/] {pid}"
|
||||
|
||||
max_cpu: float | None = None
|
||||
max_runtime_seconds: float | None = None
|
||||
max_rss_mib: float | None = None
|
||||
cooldown_seconds = 300.0
|
||||
interval_seconds = 2.0
|
||||
once = False
|
||||
|
||||
i = 1
|
||||
while i < len(argv):
|
||||
token = argv[i]
|
||||
if token == "--once":
|
||||
once = True
|
||||
i += 1
|
||||
continue
|
||||
if not token.startswith("--"):
|
||||
return f"[{ERROR}]unexpected argument:[/] {escape(token)}"
|
||||
if i + 1 >= len(argv):
|
||||
return f"[{ERROR}]missing value for[/] {escape(token)}"
|
||||
value = argv[i + 1]
|
||||
i += 2
|
||||
if token == "--max-cpu":
|
||||
try:
|
||||
pct = float(value)
|
||||
except ValueError:
|
||||
return f"[{ERROR}]invalid --max-cpu:[/] {escape(value)}"
|
||||
max_supported_cpu = 100.0 * float(max(1, os.cpu_count() or 1))
|
||||
if pct <= 0 or pct > max_supported_cpu:
|
||||
return f"[{ERROR}]--max-cpu must be between 0 and {max_supported_cpu:g}:[/] {pct}"
|
||||
max_cpu = pct
|
||||
elif token == "--max-runtime":
|
||||
seconds = _parse_duration_seconds(value)
|
||||
if seconds is None or seconds <= 0:
|
||||
return f"[{ERROR}]invalid --max-runtime:[/] {escape(value)}"
|
||||
max_runtime_seconds = seconds
|
||||
elif token == "--max-rss":
|
||||
mib = _parse_mib(value)
|
||||
if mib is None or mib <= 0:
|
||||
return f"[{ERROR}]invalid --max-rss:[/] {escape(value)}"
|
||||
max_rss_mib = mib
|
||||
elif token == "--cooldown":
|
||||
seconds = _parse_duration_seconds(value)
|
||||
if seconds is None or seconds <= 0:
|
||||
return f"[{ERROR}]invalid --cooldown:[/] {escape(value)}"
|
||||
cooldown_seconds = seconds
|
||||
elif token == "--interval":
|
||||
seconds = _parse_duration_seconds(value)
|
||||
if seconds is None or seconds <= 0:
|
||||
return f"[{ERROR}]invalid --interval:[/] {escape(value)}"
|
||||
interval_seconds = seconds
|
||||
else:
|
||||
return f"[{ERROR}]unknown flag:[/] {escape(token)}"
|
||||
|
||||
return WatchdogStartSpec(
|
||||
pid=pid,
|
||||
max_cpu=max_cpu,
|
||||
max_runtime_seconds=max_runtime_seconds,
|
||||
max_rss_mib=max_rss_mib,
|
||||
cooldown_seconds=cooldown_seconds,
|
||||
interval_seconds=interval_seconds,
|
||||
once=once,
|
||||
)
|
||||
|
||||
|
||||
def _watch_command_summary(spec: WatchdogStartSpec) -> str:
|
||||
parts = [f"watchdog pid={spec.pid}"]
|
||||
if spec.max_cpu is not None:
|
||||
parts.append(f"max_cpu={spec.max_cpu:g}%")
|
||||
if spec.max_runtime_seconds is not None:
|
||||
parts.append(f"max_runtime={spec.max_runtime_seconds:g}s")
|
||||
if spec.max_rss_mib is not None:
|
||||
parts.append(f"max_rss={spec.max_rss_mib:g}MiB")
|
||||
parts.append(f"cooldown={spec.cooldown_seconds:g}s")
|
||||
parts.append(f"interval={spec.interval_seconds:g}s")
|
||||
if spec.once:
|
||||
parts.append("once")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _watched_pid_from_task(task: TaskRecord) -> str:
|
||||
if task.command:
|
||||
match = _PID_IN_COMMAND_RE.search(task.command)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return "—"
|
||||
|
||||
|
||||
def _cmd_watch(session: Session, console: Console, args: list[str]) -> bool:
|
||||
parsed = parse_watch_argv(args)
|
||||
if isinstance(parsed, str):
|
||||
console.print(parsed)
|
||||
return True
|
||||
|
||||
if not pid_exists(parsed.pid):
|
||||
console.print(f"[{ERROR}]no such process:[/] pid {parsed.pid}")
|
||||
return True
|
||||
|
||||
try:
|
||||
creds = load_credentials_from_env()
|
||||
except OpenSREError as exc:
|
||||
console.print(f"[{ERROR}]{escape(str(exc))}[/]")
|
||||
return True
|
||||
|
||||
summary = _watch_command_summary(parsed)
|
||||
task = session.task_registry.create(TaskKind.WATCHDOG, command=summary)
|
||||
task.mark_running()
|
||||
task.attach_pid(parsed.pid)
|
||||
dispatcher = AlarmDispatcher(creds, cooldown_seconds=parsed.cooldown_seconds)
|
||||
|
||||
def _on_alarm(threshold: str, detail: str) -> None:
|
||||
with _CONSOLE_PRINT_LOCK:
|
||||
console.print(
|
||||
f"[task {escape(task.task_id)}] alarm fired: {escape(threshold)} "
|
||||
f"{escape(detail)} (telegram delivered)"
|
||||
)
|
||||
|
||||
start_watchdog_daemon_thread(
|
||||
task=task,
|
||||
watched_pid=parsed.pid,
|
||||
interval_seconds=parsed.interval_seconds,
|
||||
max_cpu=parsed.max_cpu,
|
||||
max_runtime_seconds=parsed.max_runtime_seconds,
|
||||
max_rss_mib=parsed.max_rss_mib,
|
||||
once=parsed.once,
|
||||
dispatcher=dispatcher,
|
||||
on_alarm=_on_alarm,
|
||||
)
|
||||
console.print(f"[{DIM}]task[/] [bold]{escape(task.task_id)}[/bold] [{DIM}]started.[/]")
|
||||
return True
|
||||
|
||||
|
||||
def _cmd_watches(session: Session, console: Console, _args: list[str]) -> bool:
|
||||
rows = [t for t in session.task_registry.list_recent(n=100) if t.kind == TaskKind.WATCHDOG]
|
||||
if not rows:
|
||||
console.print(f"[{DIM}]no watchdog tasks in this session.[/]")
|
||||
return True
|
||||
|
||||
table = repl_table(title="Watchdogs\n", title_style=BOLD_BRAND)
|
||||
table.add_column("id", style="bold")
|
||||
table.add_column("pid", justify="right")
|
||||
table.add_column("kind")
|
||||
table.add_column("status")
|
||||
table.add_column("started", style=DIM)
|
||||
table.add_column("thresholds", overflow="fold")
|
||||
table.add_column("last sample", style=DIM, overflow="fold")
|
||||
|
||||
status_style = {
|
||||
TaskStatus.RUNNING: WARNING,
|
||||
TaskStatus.COMPLETED: HIGHLIGHT,
|
||||
TaskStatus.CANCELLED: WARNING,
|
||||
TaskStatus.FAILED: ERROR,
|
||||
TaskStatus.PENDING: DIM,
|
||||
}
|
||||
for task in rows:
|
||||
st = status_style.get(task.status, DIM)
|
||||
started = format_repl_timestamp(task.started_at, style="utc")
|
||||
thresholds = task.command or "—"
|
||||
sample = task.progress or "—"
|
||||
table.add_row(
|
||||
task.task_id,
|
||||
_watched_pid_from_task(task),
|
||||
TaskKind.WATCHDOG.value,
|
||||
f"[{st}]{task.status.value}[/]",
|
||||
started,
|
||||
escape(thresholds),
|
||||
escape(sample),
|
||||
)
|
||||
print_repl_table(console, table)
|
||||
return True
|
||||
|
||||
|
||||
def _validate_unwatch_args(args: list[str]) -> str | None:
|
||||
if not args:
|
||||
return f"[{ERROR}]usage:[/] /unwatch <task_id> — use [{HIGHLIGHT}]/watches[/] to list ids"
|
||||
return None
|
||||
|
||||
|
||||
def _cmd_unwatch(session: Session, console: Console, args: list[str]) -> bool:
|
||||
needle = args[0]
|
||||
candidates = session.task_registry.candidates(needle)
|
||||
if not candidates:
|
||||
console.print(f"[{ERROR}]no task matches id:[/] {escape(needle)}")
|
||||
return True
|
||||
if len(candidates) > 1:
|
||||
console.print(
|
||||
f"[{ERROR}]ambiguous id prefix:[/] {escape(needle)} "
|
||||
f"[{DIM}]({len(candidates)} matches — use a longer prefix)[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
task = candidates[0]
|
||||
if task.kind != TaskKind.WATCHDOG:
|
||||
console.print(
|
||||
f"[{ERROR}]task {escape(task.task_id)} is not a watchdog "
|
||||
f"(kind: {escape(task.kind.value)}); use /cancel instead.[/]"
|
||||
)
|
||||
return True
|
||||
if task.status != TaskStatus.RUNNING:
|
||||
console.print(
|
||||
f"[{DIM}]task {escape(task.task_id)} already finished (status: {task.status.value}).[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
task.request_cancel()
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]stop requested[/] "
|
||||
f"[{DIM}]for watchdog {escape(task.task_id)}.[/] "
|
||||
f"[{DIM}]use[/] [{HIGHLIGHT}]/watches[/] [{DIM}]to confirm status.[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _validate_watch_args(args: list[str]) -> str | None:
|
||||
if not args:
|
||||
return (
|
||||
f"[{ERROR}]usage:[/] /watch <pid> [--max-cpu N] [--max-runtime D] [--max-rss S] "
|
||||
f"[--cooldown D] [--interval N] [--once]"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/watch",
|
||||
"Watch a process and send threshold alarms.",
|
||||
_cmd_watch,
|
||||
usage=(
|
||||
"/watch <pid> [--max-cpu N] [--max-runtime D] [--max-rss S] "
|
||||
"[--cooldown D] [--interval N] [--once]",
|
||||
),
|
||||
notes=("Alarms are sent to Telegram when Telegram delivery is configured.",),
|
||||
validate_args=_validate_watch_args,
|
||||
),
|
||||
SlashCommand(
|
||||
"/watches",
|
||||
"List watchdog background tasks with the latest resource sample.",
|
||||
_cmd_watches,
|
||||
usage=("/watches",),
|
||||
),
|
||||
SlashCommand(
|
||||
"/unwatch",
|
||||
"Cancel a running watchdog task by id.",
|
||||
_cmd_unwatch,
|
||||
usage=("/unwatch <task_id>",),
|
||||
notes=("Use /watches to list watchdog task ids.",),
|
||||
validate_args=_validate_unwatch_args,
|
||||
),
|
||||
]
|
||||
|
||||
__all__ = ["COMMANDS", "WatchdogStartSpec", "parse_watch_argv"]
|
||||
Reference in New Issue
Block a user