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,275 @@
|
||||
# Interactive shell package
|
||||
|
||||
These instructions apply to `interactive_shell/` and all of its
|
||||
subdirectories. The repo-root `AGENTS.md` still applies.
|
||||
|
||||
## Purpose
|
||||
|
||||
`interactive_shell/` owns the interactive OpenSRE terminal surface: the REPL
|
||||
loop, slash-command surface, local alert ingestion, shell execution, and Rich /
|
||||
prompt-toolkit UI. Reusable agent session state, prompt history, grounding, and
|
||||
prompt construction live under `core.agent`.
|
||||
|
||||
Design for a terminal user who may be in the middle of an incident: behavior
|
||||
should be predictable, interruptible, explainable, and safe by default.
|
||||
|
||||
## Package map and ownership
|
||||
|
||||
| Area | Owns | Keep out |
|
||||
| --- | --- | --- |
|
||||
| `main.py` | process/bootstrap boundary for starting the REPL | per-turn dispatch/runtime logic |
|
||||
| `controller.py` | top-level REPL wiring, alert listener lifecycle, prompt loop, background workers, and shutdown | feature-specific business logic or compatibility-only forwarding |
|
||||
| `runtime/core/turn_accounting.py` | shell turn accounting (`ShellTurnAccounting`) for analytics, telemetry, recorder flush, turn persistence, and intent stamps | turn-flow control (owned by `core.agent_harness`) or tool-calling turn execution |
|
||||
| `command_registry/` | slash-command definitions, argument validation, command dispatch | long-running implementation details better placed in services/runtime modules |
|
||||
| `runtime/` | background task workers, lifecycle/`ReplState`, runtime context assembly, semantic shell-turn execution, and core harness adapters | prompt text, reusable session persistence, or compatibility shims |
|
||||
| `tools/interactive_shell/shell/` | shell command parsing, shell execution policy, subprocess execution, and the `run_shell_command`/`run_cd`/`run_pwd` runner (next to the `shell_run` tool in `tools/interactive_shell/actions/shell.py`) | slash-command execution |
|
||||
| `references/` | CLI/docs/source/AGENTS reference loading and caching | generated model prose |
|
||||
| `config/` | interactive-shell config loading and tool catalog metadata | global app config unrelated to the REPL |
|
||||
| `ui/` | Rich/prompt-toolkit rendering, theme, menus, streaming output, and domain views such as `incoming_alerts.py` (receiver/queue/listener lifecycle lives in `core.domain.alerts.inbox`) | business logic or network calls |
|
||||
|
||||
When a change crosses these boundaries, prefer extracting a small helper in the
|
||||
owning area rather than adding more logic to the caller.
|
||||
|
||||
## Cross-cutting rules
|
||||
|
||||
- Treat every external input as untrusted: user prompt text, slash-command args,
|
||||
alert payloads, files read into prompts, history, subprocess output, model
|
||||
output, and integration metadata.
|
||||
- Keep the interactive path responsive. Long-running work must be cancellable,
|
||||
timeout-bounded, moved off the input path, or surfaced with clear progress.
|
||||
- Preserve import-time lightness. Do not start threads, call LLMs, read large
|
||||
files, or contact networks at module import time.
|
||||
- Prefer explicit data models and typed helpers over loosely shaped dictionaries
|
||||
when data crosses submodule boundaries.
|
||||
- Keep user-visible strings intentional. Slash-command names, flags, output
|
||||
labels, prompts, response bodies, and error wording are user-facing API.
|
||||
- Avoid new module-level mutable globals. If global coordination is unavoidable,
|
||||
provide deterministic reset/cleanup hooks and test isolation.
|
||||
- Do not keep compatibility-only forwarding modules after moving code. Migrate
|
||||
callers/tests to the canonical owner and remove the old import path in the
|
||||
same change.
|
||||
|
||||
## Slash commands
|
||||
|
||||
- Add commands as `SlashCommand` entries in the relevant `command_registry/*`
|
||||
module. Keep handlers small: parse args, call focused helpers, render result.
|
||||
- **REPL + CLI parity (required):** Every command in `SLASH_COMMANDS` must have a
|
||||
matching `_MCP_BY_COMMAND` entry in
|
||||
`command_registry/slash_catalog.py`. That catalog feeds the LLM planner (`slash_invoke`),
|
||||
planner tool specs, and compact help text. Without it, CI fails
|
||||
(`test_slash_catalog_covers_all_registered_commands`).
|
||||
- **New REPL-only slash command:** add `SlashCommand` in the owning
|
||||
`command_registry/*` module **and** `_mcp(...)` in `slash_catalog.py` (keep
|
||||
keys sorted alphabetically in `_MCP_BY_COMMAND`).
|
||||
- **New CLI with REPL parity:** add the Click command under `surfaces/cli/commands/`,
|
||||
register a `SlashCommand` in `command_registry/cli_parity.py` (subprocess to
|
||||
`opensre …`), **and** add `_MCP_BY_COMMAND` in `slash_catalog.py` with
|
||||
`llm_description`, `use_cases`, and `anti_examples` aligned to the command’s
|
||||
`usage` tuple.
|
||||
- **Verify before push:**
|
||||
`uv run python -m pytest tests/interactive_shell/command_registry/test_slash_catalog.py -q`
|
||||
- Use `validate_args` for cheap pre-policy validation so bad arguments do not
|
||||
trigger confirmations or side effects.
|
||||
- Send command execution through the central dispatch and execution-policy
|
||||
helpers. Do not bypass `execution_policy.py` for new commands.
|
||||
- **Alpha allow-all execution policy (current behavior):** the REPL runs with
|
||||
**no command guardrails**. `execution_policy.py` resolves every action to
|
||||
`allow` with **no confirmation prompt** — all slash/`opensre` commands,
|
||||
investigations, synthetic tests, code-agent launches, LLM runtime switches, and
|
||||
**all** shell commands run immediately, in any context (TTY or not, trust mode
|
||||
or not). There is **no shell-command safety policy**: the
|
||||
read-only/mutating/restricted classification and the `deny` floor were removed
|
||||
(`shell_policy.py` deleted; parsing/policy/execution live under
|
||||
`tools/shell/`). Mutating commands (`rm`/`mv`/`docker`), `restricted` commands
|
||||
(`sudo`, `systemctl`, `kill`, `dd`, …), shell operators (`| && ; > <`), and
|
||||
command substitution all run; the `!` prefix is honored but optional. The only
|
||||
shell input still rejected is genuinely empty input (a bare `!` or whitespace).
|
||||
Do **not** re-add a shell allowlist or deny floor while in alpha — see
|
||||
`docs/interactive-shell-action-policy.md`. The former `ExecutionTier`
|
||||
classification was removed because it gated nothing under default-allow; if an
|
||||
opt-in stricter policy is reintroduced after alpha, gate it in
|
||||
`execution_policy.py` (the `ask` verdict, confirmation UX, and `trust_mode` are
|
||||
retained as the hook), not via a planner-stage denial.
|
||||
- Non-TTY behavior under default-allow: actions no longer fail closed on
|
||||
non-interactive stdin (there is nothing to confirm). The fail-closed path only
|
||||
applies if a verdict is explicitly `ask`, which the default policy does not
|
||||
emit.
|
||||
- **CPR / exclusive-stdin registration (required for table-outputting commands):**
|
||||
Under `patch_stdout(raw=True)`, the REPL runs dispatch concurrently with the
|
||||
next `prompt_async()`. When a command emits Rich table output, prompt_toolkit
|
||||
redraws the prompt mid-flight, sending an `ESC[6n` DSR query; the terminal's
|
||||
CPR response (`ESC[<row>;<col>R`) arrives as literal keystrokes in the incoming
|
||||
prompt buffer, causing garbage like `^[[60;1R` to appear.
|
||||
**Any command that calls `print_repl_table` (directly or via `render_table` /
|
||||
`render_integrations_table` / `render_models_table` / etc.) must be added to
|
||||
`_EXCLUSIVE_STDIN_MENU_COMMANDS` in `runtime/utils/input_policy.py`.** That makes the main
|
||||
loop call `await state.queue.join()`, blocking the next prompt until dispatch
|
||||
completes and both drain cycles clean up stale CPR bytes before the next
|
||||
`prompt_async()` starts.
|
||||
- **How to check:** after adding a command, run it in the REPL and type a few
|
||||
characters in the next prompt. If no `^[[…R` garbage appears, the registration
|
||||
is correct.
|
||||
- **Agent-selected interactive commands:** `_EXCLUSIVE_STDIN_MENU_COMMANDS`
|
||||
only reserves stdin for literal `/slash` command text that
|
||||
`_literal_slash_command_text` recognizes. When free text like
|
||||
"remove github" is resolved by the action agent into an inline-picker
|
||||
command (`/integrations remove`, `/integrations setup`, `/mcp connect`,
|
||||
`/mcp disconnect`, or a bare `/integrations` / `/mcp` menu), the loop has not
|
||||
reserved stdin, so `tools/interactive_shell/actions/slash.py` must NOT run the picker inline. It defers
|
||||
via `session.queue_auto_command(...)`, which re-submits the command as
|
||||
literal command text so the loop can reserve exclusive stdin before the
|
||||
agent path runs it. New raw-stdin picker/wizard commands the action agent can emit
|
||||
must be added to
|
||||
`_INTERACTIVE_PICKER_MENUS` / `_INTERACTIVE_PICKER_SUBCOMMANDS` in
|
||||
`tools/interactive_shell/actions/slash.py`.
|
||||
|
||||
## Action Selection And Execution
|
||||
|
||||
- **Hard boundary:** do not add regex/keyword/fuzzy intent routing for natural
|
||||
language, or any deterministic mapping from non-`/`-prefixed prose to an action
|
||||
(e.g. "show integrations" -> `/integrations`). Engineers have been fired before
|
||||
for reintroducing intent heuristics that compete with the action agent.
|
||||
- **Sanctioned exception:** input the user types as a literal `/slash` command
|
||||
is dispatched deterministically (a static `slash_invoke` call in
|
||||
`core/agent_harness/turns/action_driver.py`), so slash commands keep working when the
|
||||
action-agent LLM is unavailable. This is an explicit-command bypass, not
|
||||
intent inference — it fires only when the message *is* a `/command`, and
|
||||
free-form text is still LLM-selected. See
|
||||
`docs/interactive-shell-action-policy.md` ("Deterministic literal-`/slash`
|
||||
dispatch").
|
||||
- **No planning-stage fail-closed safeguard (v0.1 decision).** The second-phase
|
||||
action agent never denies a turn. Because every terminal action is read-only,
|
||||
an unmatched/ambiguous/chatty clause is not a safety risk — the agent executes
|
||||
the clauses it can map and lets the rest fall through to the conversational
|
||||
assistant. We removed the `denied` decision path, the `mark_unhandled` planner
|
||||
tool, the `UNHANDLED:` convention, and the "I couldn't safely decide actions"
|
||||
message because they caused frequent false denials (e.g. a conversational
|
||||
question that embedded a quoted, list-style directive) with no safety upside.
|
||||
Details and rationale live in `core/agent_harness/AGENTS.md`. If
|
||||
mutating actions are ever introduced, gate them with the
|
||||
execution-stage confirmation policy (`tools/shared/execution_policy.py`), not a
|
||||
planner-stage denial.
|
||||
- Keep deterministic command detection in `orchestration/` for terminal UI
|
||||
policy only; use the action agent for slash/tool action selection.
|
||||
- Send uncertainty to a safe surface: help/chat or a clarification, not direct
|
||||
mutation or shell execution.
|
||||
- LLM-generated text must never execute directly. Convert proposed actions into
|
||||
explicit planned actions, show them to the user when appropriate, then execute
|
||||
through `orchestration/` and policy gates.
|
||||
- Keep action summaries human-readable and specific enough for confirmation UX
|
||||
and audit logs.
|
||||
- When adding a new action type, test allowed, denied, and confirmation-required
|
||||
paths.
|
||||
|
||||
## LLM prompts, grounding, and references
|
||||
|
||||
- Keep prompts bounded. Enforce size caps for docs, source chunks, histories,
|
||||
observations, alert text, and command output included in model context.
|
||||
- Ground procedural/help answers in maintained references (`docs/`, CLI help,
|
||||
AGENTS files, source snippets). If references do not support an answer, say so
|
||||
rather than inventing steps.
|
||||
- Do not include secrets in prompts. Redact or omit tokens, auth headers, env
|
||||
values, local credentials, and raw integration config.
|
||||
- Keep prompt rules reusable in `core.agent_harness.prompts` so chat/help/action
|
||||
surfaces use consistent terminology and formatting.
|
||||
- Reference caches should be deterministic, invalidatable when source files
|
||||
change, and cheap to rebuild in tests.
|
||||
|
||||
## Terminal UI and rendering
|
||||
|
||||
- Escape user-controlled content before passing it to Rich markup
|
||||
(`rich.markup.escape`): alerts, command output, file paths, integration names,
|
||||
model/provider labels, errors, docs snippets, and model text that is not
|
||||
already intentionally rendered as Markdown.
|
||||
- Use semantic tokens from `ui/theme.py`. Do not introduce raw hex colors, Rich
|
||||
named colors, or raw ANSI escapes outside `ui/theme.py` unless a narrow
|
||||
prompt-toolkit compatibility path requires it.
|
||||
- Keep rendering helpers as pure as practical: accept data, return/render Rich
|
||||
objects, avoid reading config or mutating session state from UI modules.
|
||||
- Any raw terminal-mode code must check TTY support and restore terminal state
|
||||
in `finally`.
|
||||
- Be careful mixing `prompt_toolkit.patch_stdout`, Rich live rendering, and
|
||||
background output. Prefer append-only, paragraph-buffered, or throttled
|
||||
rendering paths that do not corrupt the editable prompt.
|
||||
- UI changes should handle narrow terminals, non-ASCII fallback where relevant,
|
||||
long text, empty states, and non-TTY automation.
|
||||
|
||||
## Shell, subprocesses, and local system effects
|
||||
|
||||
- Shell execution changes belong under `shell/` and must preserve parsing,
|
||||
quoting, timeout, redaction, and policy behavior.
|
||||
- Treat subprocess output as untrusted display text; escape it before Rich
|
||||
markup and cap what is retained or sent to prompts.
|
||||
- Use explicit timeouts and clear cancellation behavior for subprocesses. Avoid
|
||||
waits that can hang the REPL indefinitely.
|
||||
- Keep allow/deny decisions explainable. If a command is blocked, return a
|
||||
user-facing reason and a safe alternative when possible.
|
||||
|
||||
## State, history, config, and background work
|
||||
|
||||
- Prefer explicit `Session` fields for session state. Keep ownership clear:
|
||||
runtime owns lifecycle, history owns persistence, config owns shell-specific
|
||||
settings.
|
||||
- Background threads/tasks/listeners must have deterministic shutdown. Tests
|
||||
should stop handles and workers in fixtures or `finally` blocks.
|
||||
- Protect shared queues and mutable session data with locks or single-owner
|
||||
discipline. Avoid check-then-act races around queues, cancellation flags,
|
||||
current tasks, and listener handles.
|
||||
- History should avoid storing secrets or excessive payloads. Apply truncation
|
||||
and privacy policy consistently.
|
||||
- Config loading should degrade gracefully with actionable errors; do not make
|
||||
the REPL unusable because an optional config or catalog source is missing.
|
||||
|
||||
## External input and local listener safety
|
||||
|
||||
- Network-ish local surfaces such as `core.domain.alerts.inbox` (started by the
|
||||
REPL entrypoint) must validate cheap request metadata before blocking reads or
|
||||
expensive parsing.
|
||||
- Never perform unbounded request-body reads. For alert POSTs specifically,
|
||||
validate `Content-Length` first, and only then read the bounded body:
|
||||
- non-numeric `Content-Length` values make `int(...)` raise `ValueError`;
|
||||
catch this and return `400`.
|
||||
- negative lengths must return `400`; `rfile.read(-1)` reads until EOF rather
|
||||
than zero bytes, which can stall the single-threaded handler.
|
||||
- oversized positive lengths must return `413` without attempting to read the
|
||||
advertised body.
|
||||
- Preserve clean unauthorized responses for real POST bodies by draining only a
|
||||
bounded body before returning `401`; this avoids close-with-unread-data resets
|
||||
on some platforms without allowing oversized pre-auth reads.
|
||||
- Keep request-size and malformed-header checks effective for both authenticated
|
||||
and unauthenticated callers.
|
||||
- Keep non-loopback listener binding protected by a token. Use constant-time
|
||||
token comparison and never log bearer tokens, raw auth headers, or full alert
|
||||
payloads.
|
||||
|
||||
## Testing expectations
|
||||
|
||||
- Put tests under `tests/interactive_shell/`, mirroring the package area
|
||||
when useful (`orchestration/`, `ui/`, etc.). Never add tests under
|
||||
source packages.
|
||||
- For focused changes, run the closest tests, for example:
|
||||
- `uv run python -m pytest tests/core/domain/alerts/test_inbox.py`
|
||||
- `uv run python -m pytest tests/interactive_shell/<area>/`
|
||||
- `uv run python -m pytest tests/interactive_shell/`
|
||||
- Add regression tests for incident-prone edges: platform socket behavior,
|
||||
malformed input, non-TTY execution, cancellation, policy denial, prompt-size
|
||||
caps, Rich escaping, and background cleanup.
|
||||
- Prefer deterministic tests over sleep-heavy tests. Use fake classifiers,
|
||||
fake sessions, fake consoles, monkeypatched subprocesses, and small fixtures.
|
||||
- For UI work, test pure formatting/rendering helpers where possible and keep
|
||||
full REPL-loop tests minimal.
|
||||
- For action-planning or execution-policy changes, test both safe fallback behavior and
|
||||
the intended positive path.
|
||||
|
||||
## Change checklist
|
||||
|
||||
Before considering an interactive-shell change complete, check:
|
||||
|
||||
1. Is the logic in the right submodule, with import-time side effects avoided?
|
||||
2. Is user-facing behavior preserved or intentionally documented?
|
||||
3. Are unsafe actions sent through execution policy with the correct tier?
|
||||
4. Are external inputs bounded, escaped, redacted, and timeout-protected?
|
||||
5. Do background resources shut down deterministically?
|
||||
6. Are focused tests added or updated under `tests/interactive_shell/`?
|
||||
7. If `SLASH_COMMANDS` changed, does `slash_catalog.py` include every command
|
||||
(REPL and `cli_parity`)? Run `test_slash_catalog.py`.
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Interactive REPL for OpenSRE — Claude Code-style incident response terminal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def run_repl(*args: Any, **kwargs: Any) -> Any:
|
||||
from surfaces.interactive_shell.main import run_repl as runtime_run_repl
|
||||
|
||||
return runtime_run_repl(*args, **kwargs)
|
||||
|
||||
|
||||
__all__ = ["run_repl"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Top-level interactive-shell controller: prompt input, dispatch, and shutdown."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from rich.console import Console
|
||||
|
||||
from config.repl_config import ReplConfig
|
||||
from core.domain.alerts import inbox as _alert_inbox
|
||||
from surfaces.interactive_shell.runtime.background.workers import BackgroundTaskManager
|
||||
from surfaces.interactive_shell.runtime.context import (
|
||||
ReplRuntimeContext,
|
||||
create_repl_runtime_context,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.core.prompt_manager import PromptManager
|
||||
from surfaces.interactive_shell.runtime.core.state import (
|
||||
ReplState,
|
||||
SpinnerState,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.input import (
|
||||
PromptInputReader,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.input.actions import (
|
||||
CancelTurn,
|
||||
CloseShell,
|
||||
DeliverConfirmation,
|
||||
IgnoreInput,
|
||||
InputAction,
|
||||
SubmitTurn,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.turn_host import (
|
||||
AgentTurnRuntime,
|
||||
run_agent_turn,
|
||||
run_agent_turn_queue,
|
||||
run_input_loop,
|
||||
)
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui import DIM
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _alert_listener_token(token: str | None) -> Iterator[None]:
|
||||
"""Install the configured alert token for this listener, restoring any prior value."""
|
||||
key = "OPENSRE_ALERT_LISTENER_TOKEN"
|
||||
previous = os.environ.get(key)
|
||||
try:
|
||||
if token:
|
||||
os.environ[key] = token
|
||||
else:
|
||||
os.environ.pop(key, None)
|
||||
yield
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = previous
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _alert_listener(
|
||||
cfg: ReplConfig | None,
|
||||
console: Console,
|
||||
*,
|
||||
existing: _alert_inbox.AlertInbox | None = None,
|
||||
) -> Iterator[_alert_inbox.AlertInbox | None]:
|
||||
if existing is not None:
|
||||
yield existing
|
||||
return
|
||||
if cfg is None or not cfg.alert_listener_enabled:
|
||||
yield None
|
||||
return
|
||||
|
||||
from gateway.web_server import WebAppServerHandle, serve_webapp_in_thread
|
||||
|
||||
inbox: _alert_inbox.AlertInbox | None = None
|
||||
handle: WebAppServerHandle | None = None
|
||||
try:
|
||||
inbox = _alert_inbox.AlertInbox()
|
||||
_alert_inbox.set_current_inbox(inbox)
|
||||
with _alert_listener_token(cfg.alert_listener_token):
|
||||
handle = serve_webapp_in_thread(
|
||||
host=cfg.alert_listener_host,
|
||||
port=cfg.alert_listener_port,
|
||||
)
|
||||
console.print(f"[{DIM}]listening for alerts on http://{handle.bound_address}/alerts[/]")
|
||||
try:
|
||||
yield inbox
|
||||
finally:
|
||||
if handle is not None:
|
||||
handle.stop()
|
||||
_alert_inbox.set_current_inbox(None)
|
||||
except Exception as exc:
|
||||
log.warning("Alert listener could not start: %s — continuing without it.", exc)
|
||||
_alert_inbox.set_current_inbox(None)
|
||||
yield None
|
||||
|
||||
|
||||
def _resolve_runtime_context(
|
||||
session: Session | ReplRuntimeContext | None,
|
||||
*,
|
||||
state: ReplState | None,
|
||||
spinner: SpinnerState | None,
|
||||
pt_session: PromptSession[str] | None,
|
||||
inbox: _alert_inbox.AlertInbox | None,
|
||||
) -> ReplRuntimeContext:
|
||||
if isinstance(session, ReplRuntimeContext):
|
||||
if state is None and spinner is None and pt_session is None and inbox is None:
|
||||
return session
|
||||
return ReplRuntimeContext(
|
||||
session=session.session,
|
||||
state=state if state is not None else session.state,
|
||||
spinner=spinner if spinner is not None else session.spinner,
|
||||
pt_session=pt_session if pt_session is not None else session.pt_session,
|
||||
inbox=inbox if inbox is not None else session.inbox,
|
||||
)
|
||||
return create_repl_runtime_context(
|
||||
session,
|
||||
state=state,
|
||||
spinner=spinner,
|
||||
pt_session=pt_session,
|
||||
inbox=inbox,
|
||||
)
|
||||
|
||||
|
||||
class InteractiveShellController:
|
||||
"""Coordinate prompt input, queued dispatch, background workers, and shutdown."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Session | ReplRuntimeContext | None = None,
|
||||
*,
|
||||
config: ReplConfig | None = None,
|
||||
state: ReplState | None = None,
|
||||
spinner: SpinnerState | None = None,
|
||||
pt_session: PromptSession[str] | None = None,
|
||||
inbox: _alert_inbox.AlertInbox | None = None,
|
||||
console: Console | None = None,
|
||||
) -> None:
|
||||
self.runtime_context = _resolve_runtime_context(
|
||||
session,
|
||||
state=state,
|
||||
spinner=spinner,
|
||||
pt_session=pt_session,
|
||||
inbox=inbox,
|
||||
)
|
||||
self.config = config
|
||||
self.session = self.runtime_context.session
|
||||
self.inbox = self.runtime_context.inbox
|
||||
self.state = self.runtime_context.state
|
||||
self.spinner = self.runtime_context.spinner
|
||||
self.service_console = console or Console(
|
||||
highlight=False,
|
||||
force_terminal=True,
|
||||
color_system="truecolor",
|
||||
legacy_windows=False,
|
||||
)
|
||||
self.prompt = PromptManager(
|
||||
self.session,
|
||||
self.state,
|
||||
self.spinner,
|
||||
self.runtime_context.pt_session,
|
||||
)
|
||||
self.turn_runtime = AgentTurnRuntime(
|
||||
session=self.session,
|
||||
state=self.state,
|
||||
spinner=self.spinner,
|
||||
invalidate_prompt=lambda: self.prompt.invalidate_prompt(),
|
||||
request_exit=self.prompt.request_exit,
|
||||
)
|
||||
self.echo_console = Console(highlight=False, force_terminal=True, color_system="truecolor")
|
||||
self.input_reader = PromptInputReader(
|
||||
self.prompt,
|
||||
self.state,
|
||||
self.session,
|
||||
self.echo_console,
|
||||
)
|
||||
self.background: BackgroundTaskManager | None = None
|
||||
self.tasks: list[tuple[str, asyncio.Task[None]]] = []
|
||||
|
||||
async def start_interactive_shell(self) -> None:
|
||||
with _alert_listener(self.config, self.service_console, existing=self.inbox) as inbox:
|
||||
self.inbox = inbox
|
||||
self.runtime_context.inbox = inbox
|
||||
self._start_runtime_services()
|
||||
try:
|
||||
with patch_stdout(raw=True):
|
||||
# Main input loop: reads prompts and enqueues submitted turns
|
||||
# onto state.queue. The agent turns themselves run in
|
||||
# run_agent_turn_queue, started above in _start_runtime_services.
|
||||
await run_input_loop(
|
||||
state=self.state,
|
||||
session=self.session,
|
||||
background=self.background,
|
||||
input_reader=self.input_reader,
|
||||
echo_console=self.echo_console,
|
||||
handle_input_action=self._handle_input_action,
|
||||
)
|
||||
finally:
|
||||
await self._shutdown_runtime()
|
||||
|
||||
def _start_runtime_services(self) -> None:
|
||||
self.prompt.setup()
|
||||
self.background = BackgroundTaskManager(
|
||||
self.session,
|
||||
self.state,
|
||||
self.spinner,
|
||||
self.inbox,
|
||||
self.prompt.invalidate_prompt,
|
||||
)
|
||||
self.tasks = self.background.start_all(
|
||||
lambda: run_agent_turn_queue(
|
||||
state=self.state,
|
||||
run_turn=lambda text: run_agent_turn(self.turn_runtime, text),
|
||||
)
|
||||
)
|
||||
# Fleet sampler is lazy: /fleet triggers it on first live use.
|
||||
self.session.terminal.fleet_sampler_starter = self.background.ensure_fleet_sampler_started
|
||||
|
||||
async def _handle_input_action(self, action: InputAction) -> bool:
|
||||
match action:
|
||||
case IgnoreInput():
|
||||
return True
|
||||
case CloseShell():
|
||||
return False
|
||||
case CancelTurn(submitted_text=text):
|
||||
if text:
|
||||
self.prompt.render_submitted_prompt(self.echo_console, text)
|
||||
self.state.cancel_current_dispatch()
|
||||
return True
|
||||
case DeliverConfirmation(text=text):
|
||||
self.state.deliver_confirmation(text)
|
||||
return True
|
||||
case SubmitTurn(text=text, wait_until_idle=wait, warning=warning):
|
||||
if warning:
|
||||
self.echo_console.print(warning)
|
||||
self.prompt.render_submitted_prompt(self.echo_console, text)
|
||||
await self.state.queue.put(text)
|
||||
if wait:
|
||||
await self.state.queue.join()
|
||||
return True
|
||||
raise AssertionError(f"Unhandled input action: {action!r}")
|
||||
|
||||
async def _shutdown_runtime(self) -> None:
|
||||
self.state.request_exit()
|
||||
self.state.cancel_current_dispatch()
|
||||
|
||||
for _label, task in self.tasks:
|
||||
task.cancel()
|
||||
|
||||
shutdown_results = await asyncio.gather(
|
||||
*(task for _label, task in self.tasks),
|
||||
return_exceptions=True,
|
||||
)
|
||||
for (label, _task), result in zip(self.tasks, shutdown_results, strict=True):
|
||||
if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
|
||||
log.debug("%s task shutdown raised exception: %s", label, result)
|
||||
|
||||
|
||||
__all__ = ["InteractiveShellController"]
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Reference text for OpenSRE interactive-shell CLI answers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from core.agent_harness.grounding.diagnostics import (
|
||||
GroundingSource,
|
||||
log_grounding_cache_diagnostics,
|
||||
)
|
||||
from core.agent_harness.grounding.models import CacheStats
|
||||
from core.agent_harness.prompts.prompt_context import DefaultPromptContextProvider
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_REFERENCE_CHARS = 28_000
|
||||
|
||||
# Heuristic: truncated or failed reference output must not be cached or the
|
||||
# assistant would keep an empty reference for the whole process.
|
||||
_MIN_CACHEABLE_CLI_REFERENCE_CHARS = 80
|
||||
_CLI_REFERENCE_SENTINEL = "=== opensre --help ==="
|
||||
SlashCommandProvider = Callable[[], Mapping[str, Any]]
|
||||
CommandGroupProvider = Callable[[], click.Command | None]
|
||||
|
||||
|
||||
def _is_cacheable_cli_reference(text: str) -> bool:
|
||||
stripped = text.strip()
|
||||
if len(stripped) < _MIN_CACHEABLE_CLI_REFERENCE_CHARS:
|
||||
return False
|
||||
return _CLI_REFERENCE_SENTINEL in text
|
||||
|
||||
|
||||
def _slash_command_names(provider: SlashCommandProvider | None) -> list[str]:
|
||||
if provider is None:
|
||||
return []
|
||||
return sorted(provider().keys())
|
||||
|
||||
|
||||
def _current_cli_signature(
|
||||
cli_group: click.Command | None,
|
||||
slash_provider: SlashCommandProvider | None = None,
|
||||
) -> str:
|
||||
"""Stable signature of the CLI command surface and interactive slash commands.
|
||||
|
||||
Bumps cache when subcommands change, slash-command metadata changes, or the
|
||||
installed package version changes.
|
||||
"""
|
||||
from config.version import get_opensre_version
|
||||
|
||||
if isinstance(cli_group, click.Group):
|
||||
cmd_names = ",".join(sorted(cli_group.commands.keys()))
|
||||
else:
|
||||
cmd_names = ""
|
||||
slash_names = ",".join(_slash_command_names(slash_provider))
|
||||
return f"opensre={get_opensre_version()}|commands={cmd_names}|slash={slash_names}"
|
||||
|
||||
|
||||
def _format_param(param: click.Parameter) -> str:
|
||||
"""Return a compact, side-effect-free description of a Click parameter."""
|
||||
if isinstance(param, click.Option):
|
||||
names = ", ".join((*param.opts, *param.secondary_opts))
|
||||
if not names:
|
||||
names = param.name or "(option)"
|
||||
value_hint = ""
|
||||
if not param.is_flag and not param.count:
|
||||
value_hint = " " + (param.metavar or param.name or "VALUE").upper()
|
||||
default = ""
|
||||
if param.show_default and param.default not in (None, "", ()):
|
||||
default = f" [default: {param.default}]"
|
||||
help_text = (param.help or "").strip()
|
||||
return f" {names}{value_hint} - {help_text}{default}".rstrip()
|
||||
|
||||
if isinstance(param, click.Argument):
|
||||
name = (param.name or "ARG").upper()
|
||||
required = "" if param.required else " (optional)"
|
||||
return f" {name}{required}"
|
||||
|
||||
return f" {param.name or type(param).__name__}"
|
||||
|
||||
|
||||
def _format_command_reference(
|
||||
command: click.Command,
|
||||
*,
|
||||
path: str,
|
||||
include_subcommands: bool = True,
|
||||
) -> str:
|
||||
"""Render Click command metadata without invoking help callbacks.
|
||||
|
||||
``click.Command.get_help()`` eventually calls ``format_help()``. The root
|
||||
OpenSRE group overrides that path to render via Rich's live console, which
|
||||
can leak into the interactive terminal when the assistant builds grounding
|
||||
context. This renderer inspects command objects directly instead.
|
||||
"""
|
||||
lines = [f"Usage: {path} [OPTIONS]"]
|
||||
if isinstance(command, click.Group):
|
||||
lines[0] += " COMMAND [ARGS]..."
|
||||
elif command.params:
|
||||
arg_names = [
|
||||
(param.name or "ARG").upper()
|
||||
for param in command.params
|
||||
if isinstance(param, click.Argument)
|
||||
]
|
||||
if arg_names:
|
||||
lines[0] += " " + " ".join(arg_names)
|
||||
|
||||
help_text = (command.help or command.short_help or "").strip()
|
||||
if help_text:
|
||||
lines.extend(["", help_text])
|
||||
|
||||
params = [param for param in command.params if not getattr(param, "hidden", False)]
|
||||
if params:
|
||||
lines.extend(["", "Options/Arguments:"])
|
||||
lines.extend(_format_param(param) for param in params)
|
||||
|
||||
if include_subcommands and isinstance(command, click.Group):
|
||||
command_rows: list[tuple[str, str]] = []
|
||||
with click.Context(command, info_name=path.rsplit(" ", 1)[-1]) as ctx:
|
||||
for name in command.list_commands(ctx):
|
||||
subcommand = command.get_command(ctx, name)
|
||||
if subcommand is None or subcommand.hidden:
|
||||
continue
|
||||
command_rows.append((name, subcommand.get_short_help_str(limit=160)))
|
||||
if command_rows:
|
||||
lines.extend(["", "Commands:"])
|
||||
lines.extend(f" {name} - {summary}".rstrip() for name, summary in command_rows)
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def _build_cli_reference_text_uncached(
|
||||
cli_group: click.Command | None,
|
||||
slash_provider: SlashCommandProvider | None = None,
|
||||
) -> str:
|
||||
"""Build a side-effect-free CLI reference without invoking Click commands."""
|
||||
parts: list[str] = []
|
||||
|
||||
if cli_group is None:
|
||||
parts.append("=== opensre --help ===\n")
|
||||
parts.append(
|
||||
"The CLI command surface is not available in this runtime.\n"
|
||||
"Launch the OpenSRE CLI (`opensre`) for command-level help.\n"
|
||||
)
|
||||
else:
|
||||
parts.append("=== opensre --help ===\n")
|
||||
parts.append(_format_command_reference(cli_group, path="opensre"))
|
||||
|
||||
if isinstance(cli_group, click.Group):
|
||||
with click.Context(cli_group, info_name="opensre") as ctx:
|
||||
for name in sorted(cli_group.commands.keys()):
|
||||
command = cli_group.get_command(ctx, name)
|
||||
if command is None or command.hidden:
|
||||
continue
|
||||
parts.append(f"\n=== opensre {name} --help ===\n")
|
||||
parts.append(_format_command_reference(command, path=f"opensre {name}"))
|
||||
|
||||
parts.append("\n=== Interactive-shell slash commands ===\n")
|
||||
parts.append(_interactive_shell_slash_hints(slash_provider))
|
||||
|
||||
text = "".join(parts)
|
||||
if len(text) > _MAX_REFERENCE_CHARS:
|
||||
return text[:_MAX_REFERENCE_CHARS] + "\n\n[... reference truncated ...]\n"
|
||||
return text
|
||||
|
||||
|
||||
def _interactive_shell_slash_hints(provider: SlashCommandProvider | None = None) -> str:
|
||||
lines = [
|
||||
"In the interactive shell, describe an incident or paste alert JSON to run "
|
||||
+ "a investigation pipeline, or chat with the terminal assistant for CLI help.",
|
||||
"Alpha mode runs every shell command with no guardrails: plain commands are parsed to "
|
||||
+ "argv and run without a shell, while pipes, redirects, command substitution, and a "
|
||||
+ "leading ! all run through a full shell. There is no read-only allowlist or blocked "
|
||||
+ "command list.",
|
||||
"Slash commands:",
|
||||
"",
|
||||
]
|
||||
if provider is not None:
|
||||
for cmd in provider().values():
|
||||
name = getattr(cmd, "name", "")
|
||||
description = getattr(cmd, "description", "")
|
||||
if name:
|
||||
lines.append(f" {name} - {description}".rstrip())
|
||||
else:
|
||||
lines.append(" (slash command registry is supplied by the interactive shell runtime)")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"Non-interactive investigation: `opensre investigate` with stdin, file, or flags.",
|
||||
"Launch the interactive shell: `opensre` (requires a TTY).",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class CliReference:
|
||||
"""Session-scoped cache for assembled CLI help reference text.
|
||||
|
||||
Holds its cache as instance state so each REPL session (via
|
||||
:func:`session_cli_reference`) owns an isolated cache with no module-level
|
||||
mutable globals.
|
||||
"""
|
||||
|
||||
name = "cli"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._signature: str | None = None
|
||||
self._text: str | None = None
|
||||
self._created_at_monotonic: float = 0.0
|
||||
self._hits: int = 0
|
||||
self._misses: int = 0
|
||||
self._slash_commands_provider: SlashCommandProvider | None = None
|
||||
self._command_group_provider: CommandGroupProvider | None = None
|
||||
|
||||
def set_slash_commands_provider(self, provider: SlashCommandProvider | None) -> None:
|
||||
"""Set the shell-owned slash command source used for grounding text."""
|
||||
self._slash_commands_provider = provider
|
||||
self.invalidate()
|
||||
|
||||
def set_command_group_provider(self, provider: CommandGroupProvider | None) -> None:
|
||||
"""Bind a surface-owned callable that returns the root Click command group."""
|
||||
self._command_group_provider = provider
|
||||
self.invalidate()
|
||||
|
||||
def _resolve_command_group(self) -> click.Command | None:
|
||||
provider = self._command_group_provider
|
||||
if provider is None:
|
||||
return None
|
||||
try:
|
||||
return provider()
|
||||
except Exception:
|
||||
_logger.debug("Command group provider raised; skipping CLI reference", exc_info=True)
|
||||
return None
|
||||
|
||||
def build_text(self) -> str:
|
||||
"""Assemble ``opensre`` and subcommand ``--help`` output for LLM grounding.
|
||||
|
||||
Cached on this instance while the command registry signature matches.
|
||||
"""
|
||||
slash_provider = self._slash_commands_provider
|
||||
cli_group = self._resolve_command_group()
|
||||
sig = _current_cli_signature(cli_group, slash_provider)
|
||||
if self._text is not None and self._signature == sig:
|
||||
self._hits += 1
|
||||
return self._text
|
||||
|
||||
self._misses += 1
|
||||
text = _build_cli_reference_text_uncached(cli_group, slash_provider)
|
||||
if cli_group is not None and _is_cacheable_cli_reference(text):
|
||||
self._signature = sig
|
||||
self._text = text
|
||||
self._created_at_monotonic = time.monotonic()
|
||||
else:
|
||||
self._signature = None
|
||||
self._text = None
|
||||
self._created_at_monotonic = 0.0
|
||||
_logger.warning(
|
||||
"CLI reference build produced non-cacheable output (%d chars); skipping cache",
|
||||
len(text),
|
||||
)
|
||||
return text
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Drop cached CLI reference text (for tests or forced refresh)."""
|
||||
self._signature = None
|
||||
self._text = None
|
||||
self._created_at_monotonic = 0.0
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
|
||||
def stats(self) -> CacheStats:
|
||||
"""Debug counters for grounding cache hit/miss and last signature."""
|
||||
return CacheStats(
|
||||
name=self.name,
|
||||
hits=self._hits,
|
||||
misses=self._misses,
|
||||
cached=self._text is not None,
|
||||
signature=self._signature,
|
||||
created_at_monotonic=self._created_at_monotonic,
|
||||
)
|
||||
|
||||
def as_grounding_source(self) -> GroundingSource:
|
||||
return GroundingSource(name=self.name, stats_fn=self.stats)
|
||||
|
||||
|
||||
def _slash_commands() -> Mapping[str, object]:
|
||||
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS
|
||||
|
||||
return SLASH_COMMANDS
|
||||
|
||||
|
||||
def _cli_command_group() -> click.Command | None:
|
||||
from surfaces.cli.__main__ import cli
|
||||
|
||||
return cli
|
||||
|
||||
|
||||
_SESSION_CLI_REFERENCE_ATTR = "_shell_cli_reference"
|
||||
|
||||
|
||||
def session_cli_reference(session: Any) -> CliReference:
|
||||
"""Return the session-scoped CLI catalog cache, creating it on first use."""
|
||||
cached = getattr(session, _SESSION_CLI_REFERENCE_ATTR, None)
|
||||
if isinstance(cached, CliReference):
|
||||
return cached
|
||||
cli = CliReference()
|
||||
cli.set_command_group_provider(_cli_command_group)
|
||||
cli.set_slash_commands_provider(_slash_commands)
|
||||
setattr(session, _SESSION_CLI_REFERENCE_ATTR, cli)
|
||||
return cli
|
||||
|
||||
|
||||
def shell_prompt_context_provider(session: Any) -> ShellPromptContextProvider:
|
||||
"""Build a prompt provider that reuses the session's CLI catalog cache."""
|
||||
return ShellPromptContextProvider(session)
|
||||
|
||||
|
||||
class ShellPromptContextProvider:
|
||||
""":class:`core.agent_harness.ports.PromptContextProvider` for the interactive shell.
|
||||
|
||||
Owns CLI catalog assembly (``surfaces.cli`` + slash commands) and delegates
|
||||
repo-level grounding (AGENTS.md, environment block) to
|
||||
:class:`~core.agent_harness.prompts.prompt_context.DefaultPromptContextProvider`.
|
||||
"""
|
||||
|
||||
def __init__(self, session: Any) -> None:
|
||||
self._base = DefaultPromptContextProvider(session)
|
||||
self._cli = session_cli_reference(session)
|
||||
|
||||
def cli_reference(self) -> str:
|
||||
return self._cli.build_text()
|
||||
|
||||
def agents_md(self) -> str:
|
||||
return self._base.agents_md()
|
||||
|
||||
def investigation_flow(self) -> str:
|
||||
return self._base.investigation_flow()
|
||||
|
||||
def environment_block(self) -> str:
|
||||
return self._base.environment_block()
|
||||
|
||||
def suggested_synthetic_prompt(self) -> str:
|
||||
return self._base.suggested_synthetic_prompt()
|
||||
|
||||
def log_diagnostics(self, reason: str) -> None:
|
||||
self._base.log_diagnostics(reason)
|
||||
log_grounding_cache_diagnostics([self._cli.as_grounding_source()], reason)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CliReference",
|
||||
"CommandGroupProvider",
|
||||
"ShellPromptContextProvider",
|
||||
"SlashCommandProvider",
|
||||
"session_cli_reference",
|
||||
"shell_prompt_context_provider",
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Public REPL entrypoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from config.repl_config import ReplConfig
|
||||
from core.agent_harness.session import SessionManager
|
||||
from surfaces.interactive_shell.controller import InteractiveShellController
|
||||
from surfaces.interactive_shell.runtime.context import create_repl_runtime_context
|
||||
from surfaces.interactive_shell.runtime.startup.first_launch_github import (
|
||||
require_startup_github_login,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.startup.initial_input import run_initial_input
|
||||
from surfaces.interactive_shell.ui.banner import render_banner
|
||||
from surfaces.interactive_shell.ui.input_prompt import build_prompt_session
|
||||
from tools.system.fleet_monitoring.sweep import run_startup_sweep
|
||||
|
||||
_console = Console(
|
||||
highlight=False, force_terminal=True, color_system="truecolor", legacy_windows=False
|
||||
)
|
||||
|
||||
|
||||
async def run_repl_async(
|
||||
initial_input: str | None = None,
|
||||
_config: ReplConfig | None = None,
|
||||
resume_session_id: str | None = None,
|
||||
) -> int:
|
||||
from platform.analytics.cli import identify_saved_github_username
|
||||
|
||||
identify_saved_github_username()
|
||||
|
||||
cfg = _config or ReplConfig.load()
|
||||
pt_session = build_prompt_session()
|
||||
runtime_context = create_repl_runtime_context(pt_session=pt_session)
|
||||
session = runtime_context.session
|
||||
|
||||
if initial_input:
|
||||
session.warm_resolved_integrations()
|
||||
return run_initial_input(initial_input, session)
|
||||
|
||||
# Open the session file now that we know this is an interactive REPL run.
|
||||
SessionManager.for_session(session).open_storage(session)
|
||||
|
||||
try:
|
||||
if resume_session_id:
|
||||
from surfaces.interactive_shell.command_registry.session_cmds.resume import (
|
||||
resume_session_by_prefix,
|
||||
)
|
||||
|
||||
slash_command = f"/resume {resume_session_id.strip()}"
|
||||
if not resume_session_by_prefix(
|
||||
resume_session_id.strip(),
|
||||
session,
|
||||
_console,
|
||||
slash_command=slash_command,
|
||||
):
|
||||
return 1
|
||||
|
||||
await InteractiveShellController(
|
||||
runtime_context,
|
||||
config=cfg,
|
||||
console=_console,
|
||||
).start_interactive_shell()
|
||||
return 0
|
||||
finally:
|
||||
# True end-of-run teardown: persist and release the session's resources.
|
||||
SessionManager.for_session(session).close(session)
|
||||
|
||||
|
||||
def run_repl(
|
||||
initial_input: str | None = None,
|
||||
config: ReplConfig | None = None,
|
||||
*,
|
||||
resume_session_id: str | None = None,
|
||||
) -> int:
|
||||
cfg = config or ReplConfig.load()
|
||||
if not cfg.enabled and not resume_session_id:
|
||||
return 0
|
||||
if not sys.stdin.isatty() and initial_input is None:
|
||||
return 0
|
||||
|
||||
run_startup_sweep()
|
||||
|
||||
try:
|
||||
if not initial_input:
|
||||
render_banner(_console)
|
||||
if not require_startup_github_login(_console):
|
||||
return 0
|
||||
|
||||
return asyncio.run(
|
||||
run_repl_async(
|
||||
initial_input=initial_input,
|
||||
_config=cfg,
|
||||
resume_session_id=resume_session_id,
|
||||
)
|
||||
)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = ["run_repl", "run_repl_async"]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Persistent interactive-shell prompt history + redaction policies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from surfaces.interactive_shell.prompt_history.policy import (
|
||||
DEFAULT_MAX_ENTRIES,
|
||||
DEFAULT_REDACTION_RULES,
|
||||
HistoryPolicy,
|
||||
RedactingFileHistory,
|
||||
RedactionRule,
|
||||
redact_text,
|
||||
)
|
||||
from surfaces.interactive_shell.prompt_history.storage import (
|
||||
clear_persisted_history,
|
||||
load_command_history_entries,
|
||||
load_prompt_history,
|
||||
prompt_history_path,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_ENTRIES",
|
||||
"DEFAULT_REDACTION_RULES",
|
||||
"HistoryPolicy",
|
||||
"RedactingFileHistory",
|
||||
"RedactionRule",
|
||||
"clear_persisted_history",
|
||||
"load_command_history_entries",
|
||||
"load_prompt_history",
|
||||
"prompt_history_path",
|
||||
"redact_text",
|
||||
]
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Privacy controls for the interactive-shell command history.
|
||||
|
||||
Built-in redaction patterns + a ``RedactingFileHistory`` that subclasses
|
||||
``prompt_toolkit.FileHistory`` to apply redaction before each entry is
|
||||
persisted. Settings resolve from env vars and the ``interactive.history``
|
||||
section of ``~/.opensre/config.yml``; built-in defaults keep
|
||||
redaction on, persistence on, and entries capped at 5000.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from config.strict_config import StrictConfigModel
|
||||
|
||||
DEFAULT_MAX_ENTRIES = 5000
|
||||
|
||||
|
||||
class RedactionRule(StrictConfigModel):
|
||||
"""One named regex with its replacement."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, arbitrary_types_allowed=True)
|
||||
|
||||
name: str
|
||||
pattern: re.Pattern[str]
|
||||
replacement: str
|
||||
|
||||
|
||||
def _build_default_rules() -> tuple[RedactionRule, ...]:
|
||||
raw: list[tuple[str, str, str]] = [
|
||||
("aws_key", r"(?:AKIA|ASIA)[A-Z0-9]{16}", "[REDACTED:aws_key]"),
|
||||
(
|
||||
"aws_secret",
|
||||
r"(?i)aws_secret_access_key[\s=:]+[A-Za-z0-9/+=]{40}",
|
||||
"aws_secret_access_key=[REDACTED:aws_secret]",
|
||||
),
|
||||
("github_pat_classic", r"ghp_[A-Za-z0-9]{36}", "[REDACTED:github_pat]"),
|
||||
("github_pat_fine", r"github_pat_[A-Za-z0-9_]{82}", "[REDACTED:github_pat]"),
|
||||
("anthropic_key", r"sk-ant-[A-Za-z0-9_\-]{40,}", "[REDACTED:anthropic_key]"),
|
||||
("openai_key", r"sk-(?!ant-)[A-Za-z0-9_\-]{20,}", "[REDACTED:openai_key]"),
|
||||
("slack_token", r"xox[bopas]-[A-Za-z0-9-]{10,}", "[REDACTED:slack_token]"),
|
||||
("stripe_key", r"sk_(?:live|test)_[A-Za-z0-9]{24,}", "[REDACTED:stripe_key]"),
|
||||
("bearer", r"(?i)bearer\s+[A-Za-z0-9_\-\.]{20,}", "Bearer [REDACTED]"),
|
||||
(
|
||||
"jwt",
|
||||
r"eyJ[A-Za-z0-9_\-]{8,}\.eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}",
|
||||
"[REDACTED:jwt]",
|
||||
),
|
||||
("password_arg", r"(?i)(--password=|password=)\S+", "[REDACTED:password]"),
|
||||
(
|
||||
"private_key",
|
||||
r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
|
||||
"[REDACTED:private_key]",
|
||||
),
|
||||
]
|
||||
return tuple(
|
||||
RedactionRule(name=name, pattern=re.compile(p), replacement=repl) for (name, p, repl) in raw
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_REDACTION_RULES: tuple[RedactionRule, ...] = _build_default_rules()
|
||||
|
||||
|
||||
def redact_text(text: str, rules: tuple[RedactionRule, ...] = DEFAULT_REDACTION_RULES) -> str:
|
||||
"""Apply each rule's pattern in declared order, replacing every match."""
|
||||
for rule in rules:
|
||||
text = rule.pattern.sub(rule.replacement, text)
|
||||
return text
|
||||
|
||||
|
||||
class HistoryPolicy(StrictConfigModel):
|
||||
"""Resolved privacy policy for the prompt-history persistence layer."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
enabled: bool = True
|
||||
redact: bool = True
|
||||
max_entries: int = DEFAULT_MAX_ENTRIES
|
||||
|
||||
_ENV_ENABLED: ClassVar[str] = "OPENSRE_HISTORY_ENABLED"
|
||||
_ENV_REDACT: ClassVar[str] = "OPENSRE_HISTORY_REDACT"
|
||||
_ENV_MAX_ENTRIES: ClassVar[str] = "OPENSRE_HISTORY_MAX_ENTRIES"
|
||||
|
||||
@classmethod
|
||||
def load(cls, file_settings: dict[str, Any] | None = None) -> HistoryPolicy:
|
||||
"""Resolve from env -> file -> defaults (env wins)."""
|
||||
file_settings = file_settings or {}
|
||||
return cls(
|
||||
enabled=_parse_bool(os.getenv(cls._ENV_ENABLED), file_settings.get("enabled"), True),
|
||||
redact=_parse_bool(os.getenv(cls._ENV_REDACT), file_settings.get("redact"), True),
|
||||
max_entries=_parse_int(
|
||||
os.getenv(cls._ENV_MAX_ENTRIES),
|
||||
file_settings.get("max_entries"),
|
||||
DEFAULT_MAX_ENTRIES,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_bool(env_val: str | None, file_val: Any, default: bool) -> bool:
|
||||
if env_val is not None and env_val != "":
|
||||
return env_val.strip().lower() not in {"0", "false", "off", "no"}
|
||||
if isinstance(file_val, bool):
|
||||
return file_val
|
||||
if isinstance(file_val, str) and file_val.strip() != "":
|
||||
return file_val.strip().lower() not in {"0", "false", "off", "no"}
|
||||
if file_val is not None:
|
||||
return bool(file_val)
|
||||
return default
|
||||
|
||||
|
||||
def _parse_int(env_val: str | None, file_val: Any, default: int) -> int:
|
||||
if env_val is not None and env_val.strip():
|
||||
try:
|
||||
return max(0, int(env_val.strip()))
|
||||
except ValueError:
|
||||
return default
|
||||
if isinstance(file_val, int) and file_val >= 0:
|
||||
return file_val
|
||||
return default
|
||||
|
||||
|
||||
class RedactingFileHistory(FileHistory):
|
||||
"""``FileHistory`` that redacts known token shapes before persisting.
|
||||
|
||||
Also enforces a max-entry retention cap by rewriting the file when
|
||||
the new total exceeds the cap. ``paused`` lets ``/history off`` stop
|
||||
persistence at runtime without swapping the History instance.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
filename: str,
|
||||
*,
|
||||
rules: tuple[RedactionRule, ...] = DEFAULT_REDACTION_RULES,
|
||||
max_entries: int = DEFAULT_MAX_ENTRIES,
|
||||
) -> None:
|
||||
super().__init__(filename)
|
||||
self.paused: bool = False
|
||||
self._rules = rules
|
||||
self._max_entries = max_entries
|
||||
self._entry_count: int | None = None
|
||||
|
||||
def load_history_strings(self) -> Iterator[str]:
|
||||
"""Yield entries with CRLF artifacts from the on-disk format normalized away."""
|
||||
for item in super().load_history_strings():
|
||||
yield item.rstrip("\r\n")
|
||||
|
||||
@property
|
||||
def max_entries(self) -> int:
|
||||
return self._max_entries
|
||||
|
||||
def set_max_entries(self, max_entries: int, *, prune: bool = True) -> None:
|
||||
self._max_entries = max(0, max_entries)
|
||||
if prune:
|
||||
self._prune_to_cap()
|
||||
|
||||
def store_string(self, string: str) -> None:
|
||||
if self.paused:
|
||||
return
|
||||
cleaned = redact_text(string, self._rules) if self._rules else string
|
||||
super().store_string(cleaned)
|
||||
if self._max_entries <= 0:
|
||||
return
|
||||
if self._entry_count is None:
|
||||
self._entry_count = self._count_entries()
|
||||
else:
|
||||
self._entry_count += 1
|
||||
if self._entry_count > self._max_entries:
|
||||
self._prune_to_cap()
|
||||
|
||||
def _prune_to_cap(self) -> None:
|
||||
if self._max_entries <= 0:
|
||||
return
|
||||
path = Path(os.fsdecode(self.filename))
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
# Each persisted entry begins with a "# <timestamp>" comment line.
|
||||
boundaries = [i for i, ln in enumerate(lines) if ln.startswith("# ")]
|
||||
if len(boundaries) <= self._max_entries:
|
||||
self._entry_count = len(boundaries)
|
||||
return
|
||||
|
||||
keep_from = boundaries[len(boundaries) - self._max_entries]
|
||||
if keep_from > 0 and lines[keep_from - 1].strip() == "":
|
||||
keep_from -= 1
|
||||
try:
|
||||
path.write_text("".join(lines[keep_from:]), encoding="utf-8")
|
||||
self._entry_count = self._max_entries
|
||||
except OSError:
|
||||
return
|
||||
|
||||
def _count_entries(self) -> int:
|
||||
path = Path(os.fsdecode(self.filename))
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return 0
|
||||
return sum(1 for line in text.splitlines() if line.startswith("# "))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_ENTRIES",
|
||||
"DEFAULT_REDACTION_RULES",
|
||||
"HistoryPolicy",
|
||||
"RedactingFileHistory",
|
||||
"RedactionRule",
|
||||
"redact_text",
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Persistent command history for the interactive shell prompt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from prompt_toolkit.history import FileHistory, History, InMemoryHistory
|
||||
|
||||
from surfaces.interactive_shell.prompt_history.policy import (
|
||||
HistoryPolicy,
|
||||
RedactingFileHistory,
|
||||
)
|
||||
|
||||
_HISTORY_FILENAME = "interactive_history"
|
||||
|
||||
|
||||
def prompt_history_path() -> Path:
|
||||
from config.constants import OPENSRE_HOME_DIR
|
||||
|
||||
return OPENSRE_HOME_DIR / _HISTORY_FILENAME
|
||||
|
||||
|
||||
def load_prompt_history(policy: HistoryPolicy | None = None) -> History:
|
||||
"""Use persistent prompt history when possible, with an in-memory fallback.
|
||||
|
||||
When ``policy.enabled`` is False, persistence is skipped entirely and
|
||||
an in-memory ring is returned. When ``policy.redact`` is False, raw
|
||||
``FileHistory`` is used so power users can opt out of redaction.
|
||||
"""
|
||||
settings = policy or _load_policy()
|
||||
|
||||
if not settings.enabled:
|
||||
return InMemoryHistory()
|
||||
|
||||
try:
|
||||
path = prompt_history_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if settings.redact:
|
||||
return RedactingFileHistory(str(path), max_entries=settings.max_entries)
|
||||
return FileHistory(str(path))
|
||||
except OSError:
|
||||
return InMemoryHistory()
|
||||
|
||||
|
||||
def load_command_history_entries() -> list[str]:
|
||||
"""Return persisted prompt entries in chronological order."""
|
||||
try:
|
||||
path = prompt_history_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
history = FileHistory(str(path))
|
||||
raw = list(reversed(list(history.load_history_strings())))
|
||||
return [line.rstrip("\r\n") for line in raw]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def clear_persisted_history() -> bool:
|
||||
"""Truncate the on-disk history file. Returns True if the file is gone or empty."""
|
||||
path = prompt_history_path()
|
||||
try:
|
||||
if path.exists():
|
||||
path.write_text("", encoding="utf-8")
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _load_policy() -> HistoryPolicy:
|
||||
from config.repl_config import read_history_settings
|
||||
|
||||
return HistoryPolicy.load(read_history_settings())
|
||||
|
||||
|
||||
__all__ = [
|
||||
"clear_persisted_history",
|
||||
"load_command_history_entries",
|
||||
"load_prompt_history",
|
||||
"prompt_history_path",
|
||||
]
|
||||
@@ -0,0 +1,252 @@
|
||||
# Runtime package rules
|
||||
|
||||
## Human summary
|
||||
|
||||
The `runtime` package holds the focused support modules for the interactive
|
||||
shell runtime. The top-level bootstrap and controller live one level up in
|
||||
`interactive_shell/`.
|
||||
|
||||
In simple terms:
|
||||
|
||||
- `../main.py` starts the interactive session and handles process/bootstrap gates.
|
||||
- `startup/first_launch_github.py` owns the first-launch GitHub sign-in gate.
|
||||
- `../controller.py` owns the `InteractiveShellController` orchestration class,
|
||||
including prompt input, submitted prompt handling, queued turn consumption,
|
||||
prompt-mediated confirmation waits, one-turn pipeline handoff, background output draining, and
|
||||
shutdown.
|
||||
- `core/prompt_manager.py` owns prompt-toolkit setup and prompt rendering.
|
||||
- `input/` owns prompt input event conversion: EOF, Ctrl-C, CPR cleanup, and
|
||||
resume hints.
|
||||
- `utils/input_policy.py` owns prompt stdin/spinner decisions for turns.
|
||||
- `startup/initial_input.py` owns non-interactive initial-input replay.
|
||||
- `background/workers.py` owns alert watching, spinner ticking, sampler startup,
|
||||
and turn-start background output drains.
|
||||
- `background/` also owns background investigation records, launchers, and
|
||||
completion notification delivery.
|
||||
- `core/` holds the core runtime engine:
|
||||
- `state.py` — shared runtime state (`ReplState`, `SpinnerState`)
|
||||
- `turn_detection.py` — pure text classifiers for cancel, confirm, and correction detection
|
||||
- `core.agent_harness.session.tasks` owns the cross-session task registry surfaced via
|
||||
`/tasks` and `/cancel`.
|
||||
- Reusable per-agent session state (`Session`) lives in
|
||||
`core.agent_harness.session`. Terminal runtime context assembly
|
||||
(`ReplRuntimeContext`, `create_repl_runtime_context`) lives in
|
||||
`interactive_shell.runtime.context`.
|
||||
|
||||
These instructions apply to `interactive_shell/runtime/` and all
|
||||
subdirectories. Parent `AGENTS.md` files still apply.
|
||||
|
||||
## Architectural intent (locked)
|
||||
|
||||
The runtime package is intentionally split into focused concerns:
|
||||
|
||||
- `core/state.py` — runtime state and transition helpers only.
|
||||
- `core/turn_detection.py` — pure prompt text classification only.
|
||||
- `utils/input_policy.py` — terminal stdin/spinner gating decisions only.
|
||||
- `agent_presentation.py` — terminal presentation for the agent prompt only (agent
|
||||
lifecycle events, presentation-state reducer/renderer, `ConsoleAgentEventSink`).
|
||||
- `turn_host.py` — terminal/runtime host for `ShellAgent` prompts only.
|
||||
- `../controller.py` — stable async entrypoint and async prompt runtime/event loop
|
||||
orchestration, submitted prompt handling, queued-turn consumption,
|
||||
prompt-mediated confirmation waits, turn telemetry, one-turn pipeline
|
||||
handoff, background output draining, and shutdown only.
|
||||
- `core/prompt_manager.py` — prompt-toolkit setup and prompt rendering only.
|
||||
- `input/` — prompt input event conversion and terminal-input cleanup only.
|
||||
- `background/workers.py` — background worker startup and turn-start drain hooks
|
||||
only.
|
||||
- `core.agent_harness.session.background` — background investigation record and
|
||||
preferences only.
|
||||
- `background/runner.py` — session-local background investigation launchers only.
|
||||
- `background/notifications.py` — background RCA completion notification delivery only.
|
||||
- `../main.py` — process/bootstrap boundary only.
|
||||
- `startup/initial_input.py` — scripted initial-input replay only.
|
||||
- `startup/first_launch_github.py` — first-launch GitHub sign-in gate only.
|
||||
- `core.agent_harness.session.tasks` — task registry + persistence only.
|
||||
- `core.agent_harness.accounting.token_accounting` — session-scoped LLM token accounting and run metadata only.
|
||||
|
||||
Keep these boundaries strict. If a change crosses concerns, move code to the
|
||||
owner module instead of broadening module responsibilities.
|
||||
|
||||
## Data flow contract (locked)
|
||||
|
||||
The interactive runtime must keep this shape:
|
||||
|
||||
1. `interactive_shell.main.run_repl` (synchronous entrypoint) sets up
|
||||
process-level concerns and calls `run_repl_async`.
|
||||
2. `interactive_shell.main.run_repl_async` (async body) creates `InteractiveShellController`.
|
||||
3. `InteractiveShellController.start_interactive_shell` owns prompt lifecycle,
|
||||
submitted input handling, queued-turn consumption, and per-turn task
|
||||
scheduling.
|
||||
4. `runtime.turn_host.run_agent_turn_queue` consumes queued prompts through an
|
||||
injected `run_turn` callable, which in production is
|
||||
`lambda text: run_agent_turn(self.turn_runtime, text)`.
|
||||
5. `runtime.turn_host.run_agent_turn(runtime, text)` drives one submitted turn.
|
||||
`AgentTurnRuntime` is the immutable dependency bundle it operates on
|
||||
(`session`, `state`, `spinner`, `invalidate_prompt`, `request_exit`);
|
||||
`run_agent_turn` owns presentation setup, prompt-mediated confirmation,
|
||||
dispatch state, and per-turn execution.
|
||||
6. `interactive_shell.runtime.shell_turn_execution.execute_shell_turn` binds shell adapters
|
||||
around `core.agent_harness.turns.orchestrator.run_turn`.
|
||||
7. `core.agent_harness` owns one prompt's action/answer mechanics and accounting
|
||||
finalization. The terminal presentation for `AgentEvent` emissions lives in
|
||||
`runtime/agent_presentation.py`.
|
||||
|
||||
Do not invert this dependency direction.
|
||||
|
||||
### Architecture diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
runRepl["interactive_shell.main.run_repl"] --> replMain["interactive_shell.main.run_repl_async"]
|
||||
replMain --> controller["interactive_shell.controller.InteractiveShellController"]
|
||||
controller --> turnHost["runtime.turn_host.run_agent_turn(turn_runtime, text)"]
|
||||
turnHost --> turnEntry["interactive_shell.runtime.shell_turn_execution.execute_shell_turn"]
|
||||
turnEntry --> coreHarness["core.agent_harness.turns.orchestrator.run_turn"]
|
||||
coreHarness --> sideEffects["slash/help/agent/follow-up/investigation side effects"]
|
||||
controller --> replState["core.state.ReplState"]
|
||||
controller --> spinnerState["core.state.SpinnerState"]
|
||||
controller --> inputReader["input.PromptInputReader"]
|
||||
```
|
||||
|
||||
## State ownership rules
|
||||
|
||||
- `ReplState` is the single source of truth for:
|
||||
- active dispatch task
|
||||
- cancellation event
|
||||
- confirmation event/response lifecycle
|
||||
- exit requests
|
||||
- the explicit turn `phase` (`TurnPhase`: `IDLE`, `DISPATCHING`,
|
||||
`AWAITING_CONFIRMATION`, `CANCELLING`)
|
||||
- Mutate turn state through the `ReplState` transition methods, never by poking
|
||||
raw fields from other modules:
|
||||
- `start_dispatch` / `attach_turn_task` / `attach_cancel_event` -> `DISPATCHING`
|
||||
- `begin_confirmation` -> `AWAITING_CONFIRMATION`; `clear_confirmation` returns
|
||||
to `DISPATCHING`/`IDLE` (and never clobbers an in-progress `CANCELLING`)
|
||||
- `cancel_current_dispatch` -> `CANCELLING` (only when there is something to
|
||||
cancel) then signals the cancel/confirm events and `task.cancel`
|
||||
- `finish_dispatch` / `clear_current_task` -> `IDLE`
|
||||
- `phase` is authoritative for confirmation and cancelling. `is_dispatch_running()`
|
||||
stays derived from the asyncio task (the runtime truth of the in-flight turn);
|
||||
`is_awaiting_confirmation()` and `is_cancelling()` are derived from `phase`.
|
||||
- Do not reorder the signaling inside `cancel_current_dispatch` or move the
|
||||
`confirm_response` reset after the `confirm_event` publish in
|
||||
`begin_confirmation`; both orderings are load-bearing for cancellation and
|
||||
confirmation race-safety.
|
||||
- `SpinnerState` owns spinner rendering state only; it must not depend on
|
||||
runtime task management.
|
||||
|
||||
## Turn execution rules
|
||||
|
||||
- Do not reintroduce `dispatch.py`, an `AgentTurnRunner`-style wrapper class, or
|
||||
any compatibility-only forwarding module.
|
||||
- The terminal host lives in `runtime/turn_host.py`: `run_agent_turn(runtime,
|
||||
text)` owns shell presentation (StreamingConsole, spinner, recorder, progress
|
||||
scope), constructs a `ConsoleAgentEventSink`, owns dispatch state, and
|
||||
lazily imports + calls
|
||||
`interactive_shell.runtime.shell_turn_execution.execute_shell_turn`.
|
||||
`AgentTurnRuntime` is the immutable dependency bundle it operates on; the
|
||||
controller constructs it and passes a bound `run_agent_turn` coroutine into
|
||||
`run_agent_turn_queue`.
|
||||
- The shell adapter entry lives in `runtime/shell_turn_execution.py`: `execute_shell_turn`
|
||||
composes the action-turn (`runtime/action_turn.py`), gather (`runtime/integration_tool_gathering.py`),
|
||||
and answer (`runtime/answer_turn.py`) adapters plus accounting around
|
||||
`core.agent_harness.turns.orchestrator.run_turn`. Each adapter owns its own
|
||||
binding; tests import them from their owning module (not `shell_turn_execution`).
|
||||
- The reusable per-prompt loop lives in `core.agent_harness`: turn snapshots,
|
||||
observation reset, action/response routing, and core result construction stay
|
||||
surface-agnostic.
|
||||
- Keep terminal side effects (spinner, prompt suppression, `console.print`, CPR
|
||||
drain) in `ConsoleAgentEventSink` — defined in `runtime/agent_presentation.py`
|
||||
— not in the turn-entry adapter or the core harness.
|
||||
- Put cancel/confirm/correction text classifiers in `core/turn_detection.py`.
|
||||
- Put stdin blocking and spinner decisions in `utils/input_policy.py`.
|
||||
- Keep prompt-mediated confirmation waiting in `runtime/core/confirmation.py`.
|
||||
- Turn accounting is consolidated behind `ShellTurnAccounting` in
|
||||
`interactive_shell/runtime/core/turn_accounting.py`, invoked from
|
||||
`interactive_shell.runtime.shell_turn_execution.execute_shell_turn`. It owns action-agent analytics, terminal-turn aggregate
|
||||
telemetry, prompt-recorder flush, conversational-turn persistence, and the final
|
||||
assistant-intent stamp. `interactive_shell.runtime.action_turn.run_action_tool_turn` returns facts
|
||||
only (`ToolCallingTurnResult` with `accounting_status` of `completed` / `not_run`)
|
||||
and emits no analytics itself. Do not re-scatter accounting back into
|
||||
`run_action_tool_turn` or standalone `_record_*` helpers.
|
||||
|
||||
## Controller rules
|
||||
|
||||
- `../controller.py` owns:
|
||||
- `InteractiveShellController`
|
||||
- `start_interactive_shell` shell lifecycle orchestration
|
||||
- alert listener setup/teardown
|
||||
- `AgentTurnRuntime` construction and shutdown
|
||||
- queued prompt submission
|
||||
- `turn_host.py` owns:
|
||||
- `run_input_loop` (module-level) — read and handle user input until exit
|
||||
- `run_agent_turn_queue` (module-level) — consume queued prompts until exit (runs an injected `run_turn`)
|
||||
- `run_agent_turn(runtime, text)` (module-level) — presentation, dispatch state, prompt-mediated confirmation, and turn-entry invocation over an `AgentTurnRuntime`
|
||||
- `ConsoleAgentEventSink` (in `runtime/agent_presentation.py`) — terminal presentation for agent lifecycle events over `_reduce_agent_presentation` / `_render_agent_presentation_transition`
|
||||
- queued turn consumption
|
||||
- per-turn task lifecycle
|
||||
- dispatch start/finish state transitions
|
||||
- prompt-mediated confirmation waiting
|
||||
- turn telemetry and `execute_shell_turn` invocation
|
||||
- current turn cancellation helpers
|
||||
- coordination between prompt, background, and shutdown helpers
|
||||
- `core/prompt_manager.py` owns:
|
||||
- prompt-toolkit wiring
|
||||
- prompt rendering callbacks
|
||||
- pending prompt defaults and autosubmit handling
|
||||
- `input/` owns:
|
||||
- prompt input event types
|
||||
- terminal EOF and Ctrl-C conversion
|
||||
- CPR cleanup for submitted prompt text
|
||||
- session resume hints when prompt input closes
|
||||
- `background/workers.py` owns:
|
||||
- alert watcher lifecycle
|
||||
- spinner ticker lifecycle
|
||||
- sampler startup
|
||||
- background notice drains at turn start
|
||||
- Keep prompt rendering concerns in runtime/prompting modules, not in
|
||||
dispatch/execution.
|
||||
|
||||
## Main/bootstrap rules
|
||||
|
||||
- `../main.py` owns:
|
||||
- startup sweep
|
||||
- TTY/non-TTY gate
|
||||
- banner display for interactive runs
|
||||
- async boundary (`asyncio.run`)
|
||||
- `../controller.py` owns alert listener setup/teardown because the inbox is
|
||||
part of the running shell lifecycle.
|
||||
- Do not move per-turn dispatch/runtime logic back into startup bootstrap.
|
||||
|
||||
## Compatibility surface policy
|
||||
|
||||
- `runtime/__init__.py` should be a thin export layer.
|
||||
- Do not duplicate business logic in `__init__.py`.
|
||||
- `runtime/__init__.py` exports `Session` from `core.agent_harness.session` and
|
||||
runtime-context helpers from `interactive_shell.runtime.context`. New shared
|
||||
runtime code should import session names directly from `core.agent_harness.session`.
|
||||
- Do not re-add `_xxx` underscore aliases or wrapper functions for
|
||||
compatibility. Tests and callers should import canonical names from their
|
||||
owning submodule.
|
||||
|
||||
## Test seam policy
|
||||
|
||||
- Prefer patching canonical module seams:
|
||||
- `interactive_shell.controller.*` for prompt-loop, queued-turn, confirmation behavior,
|
||||
one-turn pipeline execution, and side effects
|
||||
- `interactive_shell.main.*` for process/bootstrap behavior
|
||||
- `runtime.core.state.*` for state-specific behavior
|
||||
- Avoid adding new tests that monkeypatch package-root internals in
|
||||
`runtime.__init__` unless there is no stable canonical seam.
|
||||
|
||||
## Refactor guardrails
|
||||
|
||||
- No behavior changes to action-planning policy should be introduced from
|
||||
`runtime/` refactors.
|
||||
- Keep interruption semantics unchanged:
|
||||
- Esc or bare cancel commands interrupt active dispatch
|
||||
- cancellation moves the turn to `TurnPhase.CANCELLING` and signals both the
|
||||
cancel event and any pending confirmation event before `task.cancel`
|
||||
- confirmation prompts are cancel-safe and never silently auto-confirm; a
|
||||
cancel during confirmation must not be downgraded back to `DISPATCHING`
|
||||
- Preserve observability semantics (turn telemetry and turn summaries).
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from platform.common.task_registry import TaskRegistry
|
||||
from platform.common.task_types import TaskKind, TaskRecord, TaskStatus
|
||||
from surfaces.interactive_shell.runtime.context import (
|
||||
ReplRuntimeContext,
|
||||
SessionBootstrapSpec,
|
||||
create_repl_runtime_context,
|
||||
prepare_repl_session,
|
||||
)
|
||||
from surfaces.interactive_shell.session.background_investigations import (
|
||||
BackgroundInvestigationRecord,
|
||||
BackgroundNotificationPreferences,
|
||||
)
|
||||
from surfaces.interactive_shell.session.session import Session
|
||||
|
||||
__all__ = [
|
||||
"BackgroundInvestigationRecord",
|
||||
"BackgroundNotificationPreferences",
|
||||
"ReplRuntimeContext",
|
||||
"Session",
|
||||
"SessionBootstrapSpec",
|
||||
"TaskKind",
|
||||
"TaskRecord",
|
||||
"TaskRegistry",
|
||||
"TaskStatus",
|
||||
"create_repl_runtime_context",
|
||||
"prepare_repl_session",
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Shell adapter for one action-selection turn.
|
||||
|
||||
Binds the interactive shell's console, session, and default providers around
|
||||
core ``run_action_agent_turn``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.agent_harness.error_reporting import DefaultErrorReporter
|
||||
from core.agent_harness.llm_resolution import default_llm_factory
|
||||
from core.agent_harness.ports import OutputSink
|
||||
from core.agent_harness.tools.tool_provider import DefaultToolProvider
|
||||
from core.agent_harness.turns.action_driver import ToolCallingDeps, run_action_agent_turn
|
||||
from core.agent_harness.turns.turn_plan import TurnPlan
|
||||
from core.agent_harness.turns.turn_results import ToolCallingTurnResult
|
||||
from core.execution import ToolExecutionHooks
|
||||
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS
|
||||
from surfaces.interactive_shell.command_registry.suggestions import resolve_literal_slash_typo
|
||||
from surfaces.interactive_shell.runtime.agent_harness_adapters import resolve_output_sink
|
||||
from surfaces.interactive_shell.runtime.subprocess_runner.repl_presenter import (
|
||||
ReplSubprocessPresenter,
|
||||
)
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui.action_rendering import ActionRenderObserver
|
||||
|
||||
|
||||
def _complete_literal_slash_typo_turn(
|
||||
message: str,
|
||||
session: Session,
|
||||
output: OutputSink,
|
||||
) -> ToolCallingTurnResult | None:
|
||||
"""Handle unknown slash roots and invalid subcommands before tool validation."""
|
||||
typo = resolve_literal_slash_typo(message, SLASH_COMMANDS)
|
||||
if typo is None:
|
||||
return None
|
||||
output.print()
|
||||
output.print(typo.message)
|
||||
session.record(
|
||||
"slash",
|
||||
message.strip(),
|
||||
ok=False,
|
||||
response_text=typo.message,
|
||||
slash_outcome=typo.outcome,
|
||||
)
|
||||
return ToolCallingTurnResult(0, 1, 0, False, True, response_text=typo.message)
|
||||
|
||||
|
||||
def _subprocess_presenter_factory(
|
||||
session: Session,
|
||||
console: Console,
|
||||
confirm_fn: Callable[[str], str] | None,
|
||||
is_tty: bool | None,
|
||||
action_already_listed: bool,
|
||||
) -> ReplSubprocessPresenter:
|
||||
return ReplSubprocessPresenter(
|
||||
session,
|
||||
console,
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
action_already_listed=action_already_listed,
|
||||
)
|
||||
|
||||
|
||||
def run_action_tool_turn(
|
||||
message: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
request_exit: Callable[[], None] | None = None,
|
||||
deps: ToolCallingDeps | None = None,
|
||||
turn_plan: TurnPlan | None = None,
|
||||
output: OutputSink | None = None,
|
||||
tool_hooks: ToolExecutionHooks | None = None,
|
||||
) -> ToolCallingTurnResult:
|
||||
"""Run one action-selection turn through core with shell adapters bound."""
|
||||
resolved_output = resolve_output_sink(console, output)
|
||||
typo_result = _complete_literal_slash_typo_turn(message, session, resolved_output)
|
||||
if typo_result is not None:
|
||||
return typo_result
|
||||
effective_deps = (
|
||||
deps
|
||||
if deps is not None and deps.llm_factory is not None
|
||||
else ToolCallingDeps(llm_factory=default_llm_factory)
|
||||
)
|
||||
return run_action_agent_turn(
|
||||
message,
|
||||
session,
|
||||
output=resolved_output,
|
||||
tools=DefaultToolProvider(
|
||||
session,
|
||||
console,
|
||||
request_exit=request_exit,
|
||||
observer_factory=lambda msg: ActionRenderObserver(
|
||||
session=session, console=console, message=msg
|
||||
),
|
||||
subprocess_presenter_factory=_subprocess_presenter_factory,
|
||||
),
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
deps=effective_deps,
|
||||
turn_plan=turn_plan,
|
||||
error_reporter=DefaultErrorReporter(),
|
||||
tool_hooks=tool_hooks,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["run_action_tool_turn"]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Interactive-shell output adapter implementing :mod:`core.agent_harness.ports`.
|
||||
|
||||
This module owns terminal rendering only. Shared action-tool, reasoning-client,
|
||||
run-record, and error-reporting providers live in :mod:`core.agent_harness`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from core.agent_harness.ports import OutputSink
|
||||
from core.llm.shared.llm_retry import CREDIT_EXHAUSTED_MARKER
|
||||
from surfaces.interactive_shell.ui import (
|
||||
stream_to_console,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.streaming import render_response_header
|
||||
|
||||
|
||||
class ShellOutputSink:
|
||||
""":class:`core.agent_harness.ports.OutputSink` over a Rich console."""
|
||||
|
||||
def __init__(self, console: Console) -> None:
|
||||
self._console = console
|
||||
|
||||
def print(self, message: str = "") -> None:
|
||||
self._console.print(message)
|
||||
|
||||
def render_response_header(self, label: str) -> None:
|
||||
render_response_header(self._console, label)
|
||||
|
||||
def render_error(self, message: str) -> None:
|
||||
self._console.print(f"[yellow]{escape(message)}[/]")
|
||||
# On a credit/billing wall, add the in-tool recovery hint.
|
||||
if CREDIT_EXHAUSTED_MARKER in message:
|
||||
self._console.print("[dim]Run /model to switch to another provider.[/]")
|
||||
self._console.print(
|
||||
"[dim]Or run /auth login <provider> to re-authenticate "
|
||||
"or add a different provider.[/]"
|
||||
)
|
||||
|
||||
def stream(
|
||||
self,
|
||||
*,
|
||||
label: str,
|
||||
chunks: Iterable[str],
|
||||
suppress_if_starts_with: str | None = None,
|
||||
) -> str:
|
||||
return stream_to_console(
|
||||
self._console,
|
||||
label=label,
|
||||
chunks=iter(chunks),
|
||||
suppress_if_starts_with=suppress_if_starts_with,
|
||||
)
|
||||
|
||||
|
||||
def resolve_output_sink(console: Console, output: OutputSink | None) -> OutputSink:
|
||||
"""Return the caller's sink, or a shell sink bound to ``console``."""
|
||||
if output is not None:
|
||||
return output
|
||||
return ShellOutputSink(console)
|
||||
|
||||
|
||||
__all__ = ["ShellOutputSink", "resolve_output_sink"]
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Terminal presentation for the interactive shell agent prompt.
|
||||
|
||||
This module owns the **UI / presentation** side of one submitted shell prompt:
|
||||
the pure presentation-state reducer, the effectful terminal transition renderer,
|
||||
and the ``ConsoleAgentEventSink`` imperative shell that wires them together.
|
||||
|
||||
Keeping this separate from ``runtime/shell_turn_execution.py`` isolates spinner
|
||||
lifecycle, prompt suppression, interruption/error messages, and stale CPR
|
||||
draining from the turn's action-routing and prompt-construction logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from rich.markup import escape
|
||||
|
||||
from surfaces.interactive_shell.runtime.core.state import SpinnerState
|
||||
from surfaces.interactive_shell.runtime.utils.input_policy import turn_should_show_spinner
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui import (
|
||||
DIM,
|
||||
ERROR,
|
||||
WARNING,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.cpr_stdin import drain_stale_cpr_bytes
|
||||
from surfaces.interactive_shell.ui.streaming.console import StreamingConsole
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentEvent:
|
||||
"""Agent lifecycle event emitted during one submitted shell turn."""
|
||||
|
||||
type: Literal["turn_start", "turn_interrupted", "turn_error", "turn_end"]
|
||||
text: str | None = None
|
||||
error: Exception | None = None
|
||||
|
||||
|
||||
AgentEventSink = Callable[[AgentEvent], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentPresentationState:
|
||||
"""Immutable presentation state evolved across lifecycle events."""
|
||||
|
||||
show_spinner: bool = False
|
||||
prompt_suppressed: bool = False
|
||||
|
||||
|
||||
def _reduce_agent_presentation(
|
||||
state: AgentPresentationState,
|
||||
event: AgentEvent,
|
||||
*,
|
||||
should_show_spinner: bool,
|
||||
) -> AgentPresentationState:
|
||||
"""Compute the next presentation state for *event* (pure)."""
|
||||
if event.type == "turn_start":
|
||||
return AgentPresentationState(
|
||||
show_spinner=should_show_spinner,
|
||||
prompt_suppressed=should_show_spinner,
|
||||
)
|
||||
if event.type == "turn_end":
|
||||
return AgentPresentationState()
|
||||
if event.type in {"turn_interrupted", "turn_error"}:
|
||||
return state
|
||||
raise ValueError(f"Unknown agent event type: {event.type!r}")
|
||||
|
||||
|
||||
async def _render_agent_presentation_transition(
|
||||
*,
|
||||
previous: AgentPresentationState,
|
||||
current: AgentPresentationState,
|
||||
event: AgentEvent,
|
||||
console: StreamingConsole,
|
||||
spinner: SpinnerState,
|
||||
) -> None:
|
||||
"""Perform the terminal side effects for one presentation transition."""
|
||||
match event.type:
|
||||
case "turn_start":
|
||||
if current.show_spinner:
|
||||
spinner.start()
|
||||
case "turn_interrupted":
|
||||
console.print(f"[{WARNING}]· interrupted[/]")
|
||||
case "turn_error":
|
||||
exc = event.error
|
||||
if exc is None:
|
||||
raise ValueError("turn_error event requires an error")
|
||||
console.print(f"[{ERROR}]turn error:[/] {escape(str(exc))}")
|
||||
# On a credit/billing wall, add the in-tool recovery hint.
|
||||
from core.llm.shared.llm_retry import LLMCreditExhaustedError
|
||||
|
||||
if isinstance(exc, LLMCreditExhaustedError):
|
||||
console.print(f"[{DIM}]Run /model to switch to another provider.[/]")
|
||||
console.print(
|
||||
f"[{DIM}]Or run /auth login <provider> to re-authenticate "
|
||||
f"or add a different provider.[/]"
|
||||
)
|
||||
case "turn_end":
|
||||
if previous.show_spinner:
|
||||
spinner.stop()
|
||||
await asyncio.sleep(0.05)
|
||||
drain_stale_cpr_bytes()
|
||||
case _:
|
||||
raise ValueError(f"Unknown agent event type: {event.type!r}")
|
||||
|
||||
|
||||
class ConsoleAgentEventSink:
|
||||
"""Render agent lifecycle events to the terminal console.
|
||||
|
||||
Imperative shell: it holds the evolving ``AgentPresentationState`` and routes
|
||||
each event through the pure ``_reduce_agent_presentation`` reducer and the
|
||||
effectful ``_render_agent_presentation_transition`` renderer.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
spinner: SpinnerState,
|
||||
console: StreamingConsole,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.spinner = spinner
|
||||
self.console = console
|
||||
self.state = AgentPresentationState()
|
||||
|
||||
async def __call__(self, event: AgentEvent) -> None:
|
||||
previous = self.state
|
||||
self.state = _reduce_agent_presentation(
|
||||
previous,
|
||||
event,
|
||||
should_show_spinner=turn_should_show_spinner(event.text or "", self.session),
|
||||
)
|
||||
await _render_agent_presentation_transition(
|
||||
previous=previous,
|
||||
current=self.state,
|
||||
event=event,
|
||||
console=self.console,
|
||||
spinner=self.spinner,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentEvent",
|
||||
"AgentPresentationState",
|
||||
"ConsoleAgentEventSink",
|
||||
"AgentEventSink",
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Shell adapter for one conversational answer turn.
|
||||
|
||||
Binds the interactive shell's Rich output, grounding caches, reasoning client,
|
||||
and telemetry around core ``stream_answer``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.agent_harness.accounting.run_record import DefaultRunRecordFactory
|
||||
from core.agent_harness.error_reporting import DefaultErrorReporter
|
||||
from core.agent_harness.ports import OutputSink
|
||||
from core.agent_harness.turns.default_reasoning_client import DefaultReasoningClientProvider
|
||||
from core.agent_harness.turns.orchestrator import (
|
||||
stream_answer as core_stream_answer,
|
||||
)
|
||||
from core.agent_harness.turns.turn_plan import TurnPlan
|
||||
from surfaces.interactive_shell.grounding.cli_reference import shell_prompt_context_provider
|
||||
from surfaces.interactive_shell.runtime.agent_harness_adapters import resolve_output_sink
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.utils.telemetry import LlmRunInfo
|
||||
|
||||
|
||||
def answer_shell_question(
|
||||
message: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
tool_observation: str | None = None,
|
||||
tool_observation_on_screen: bool = True,
|
||||
handoff_contents: tuple[str, ...] = (),
|
||||
turn_plan: TurnPlan | None = None,
|
||||
output: OutputSink | None = None,
|
||||
) -> LlmRunInfo | None:
|
||||
"""Answer one shell question through the grounded conversational assistant."""
|
||||
resolved_output = resolve_output_sink(console, output)
|
||||
return core_stream_answer(
|
||||
message,
|
||||
session,
|
||||
resolved_output,
|
||||
prompts=shell_prompt_context_provider(session),
|
||||
reasoning=DefaultReasoningClientProvider(
|
||||
output=resolved_output,
|
||||
error_reporter=DefaultErrorReporter(),
|
||||
session=session,
|
||||
),
|
||||
run_factory=DefaultRunRecordFactory(session),
|
||||
error_reporter=DefaultErrorReporter(),
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
tool_observation=tool_observation,
|
||||
tool_observation_on_screen=tool_observation_on_screen,
|
||||
handoff_contents=handoff_contents,
|
||||
turn_plan=turn_plan,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["answer_shell_question"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Background runtime support for interactive shell."""
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Background RCA notification helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from surfaces.interactive_shell.session.background_investigations import (
|
||||
BackgroundInvestigationRecord,
|
||||
)
|
||||
|
||||
|
||||
def deliver_background_notifications(
|
||||
*,
|
||||
record: BackgroundInvestigationRecord,
|
||||
channels: tuple[str, ...],
|
||||
) -> dict[str, str]:
|
||||
"""Send configured notifications for a completed background RCA."""
|
||||
# Imported lazily: email delivery only fires on background-RCA completion, so
|
||||
# the SMTP client must not load into the base REPL boot import path.
|
||||
from integrations.smtp.delivery import format_background_rca_email, send_smtp_report
|
||||
|
||||
results: dict[str, str] = {}
|
||||
from integrations.catalog import resolve_effective_integrations
|
||||
|
||||
effective_integrations = resolve_effective_integrations()
|
||||
|
||||
for channel in channels:
|
||||
if channel != "email":
|
||||
results[channel] = "unsupported"
|
||||
continue
|
||||
|
||||
smtp_integration = effective_integrations.get("smtp")
|
||||
smtp_config = smtp_integration.get("config") if isinstance(smtp_integration, dict) else None
|
||||
if not isinstance(smtp_config, dict):
|
||||
results["email"] = "missing smtp integration"
|
||||
continue
|
||||
|
||||
subject, body = format_background_rca_email(
|
||||
task_id=record.task_id,
|
||||
command=record.command,
|
||||
root_cause=record.root_cause,
|
||||
top_analysis=record.top_analysis,
|
||||
next_steps=record.next_steps,
|
||||
stats=record.stats,
|
||||
)
|
||||
ok, error = send_smtp_report(report=body, subject=subject, smtp_ctx=smtp_config)
|
||||
results["email"] = "sent" if ok else f"failed: {error}"
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Helpers for launching session-local background investigations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from contextlib import nullcontext
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from platform.analytics.cli import track_investigation
|
||||
from platform.analytics.source import EntrypointSource, TriggerMode
|
||||
from platform.common.errors import OpenSREError
|
||||
from surfaces.interactive_shell.runtime import (
|
||||
BackgroundInvestigationRecord,
|
||||
Session,
|
||||
TaskKind,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.background.notifications import (
|
||||
deliver_background_notifications,
|
||||
)
|
||||
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING
|
||||
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
|
||||
|
||||
BackgroundRunFn = Callable[..., dict[str, Any]]
|
||||
|
||||
|
||||
def _safe_console_print(console: Console, message: str) -> None:
|
||||
isatty = getattr(console.file, "isatty", None)
|
||||
stdout_context = patch_stdout(raw=True) if callable(isatty) and isatty() else nullcontext()
|
||||
with stdout_context:
|
||||
console.print(message)
|
||||
|
||||
|
||||
def drain_background_notices(session: Session, console: Console) -> None:
|
||||
"""Print queued background investigation status lines on the main REPL thread."""
|
||||
for message in session.terminal.drain_background_notices():
|
||||
_safe_console_print(console, message)
|
||||
|
||||
|
||||
def _build_record(
|
||||
*,
|
||||
task_id: str,
|
||||
command: str,
|
||||
investigation_id: str,
|
||||
) -> BackgroundInvestigationRecord:
|
||||
return BackgroundInvestigationRecord(
|
||||
task_id=task_id,
|
||||
status="running",
|
||||
command=command,
|
||||
investigation_id=investigation_id,
|
||||
)
|
||||
|
||||
|
||||
def _top_analysis(final_state: dict[str, Any]) -> tuple[str, ...]:
|
||||
claims = final_state.get("validated_claims", [])
|
||||
if not isinstance(claims, list):
|
||||
return ()
|
||||
lines: list[str] = []
|
||||
for entry in claims:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
claim = str(entry.get("claim") or "").strip()
|
||||
if claim:
|
||||
lines.append(claim)
|
||||
if len(lines) >= 3:
|
||||
break
|
||||
return tuple(lines)
|
||||
|
||||
|
||||
def _next_steps(final_state: dict[str, Any]) -> tuple[str, ...]:
|
||||
steps = final_state.get("remediation_steps", [])
|
||||
if not isinstance(steps, list):
|
||||
return ()
|
||||
values: list[str] = []
|
||||
for step in steps[:3]:
|
||||
text = str(step).strip()
|
||||
if text:
|
||||
values.append(text)
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def _stats(final_state: dict[str, Any]) -> dict[str, Any]:
|
||||
tool_calls = final_state.get("evidence_entries", [])
|
||||
loops = final_state.get("investigation_loop_count", 0)
|
||||
validity = final_state.get("validity_score", 0.0)
|
||||
return {
|
||||
"tool_call_count": len(tool_calls) if isinstance(tool_calls, list) else 0,
|
||||
"investigation_loop_count": int(loops) if isinstance(loops, int | float) else 0,
|
||||
"validity_score": float(validity) if isinstance(validity, int | float) else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _start_background_investigation(
|
||||
*,
|
||||
session: Session,
|
||||
console: Console,
|
||||
display_command: str,
|
||||
run_fn: BackgroundRunFn,
|
||||
kwargs: dict[str, Any],
|
||||
investigation_target: str = "",
|
||||
input_path: str | None = None,
|
||||
) -> str:
|
||||
investigation_id = str(uuid4())
|
||||
session.last_investigation_id = investigation_id
|
||||
task = session.task_registry.create(TaskKind.INVESTIGATION, command=display_command)
|
||||
task.mark_running()
|
||||
record = _build_record(
|
||||
task_id=task.task_id,
|
||||
command=display_command,
|
||||
investigation_id=investigation_id,
|
||||
)
|
||||
session.terminal.background_investigations[task.task_id] = record
|
||||
|
||||
def _worker() -> None:
|
||||
try:
|
||||
with track_investigation(
|
||||
entrypoint=EntrypointSource.CLI_REPL_FILE,
|
||||
trigger_mode=TriggerMode.FILE,
|
||||
input_path=input_path,
|
||||
interactive=True,
|
||||
investigation_id=investigation_id,
|
||||
investigation_target=investigation_target or None,
|
||||
session=session,
|
||||
) as tracker:
|
||||
final_state = run_fn(cancel_requested=task.cancel_requested, **kwargs)
|
||||
tracker.record_loop_metrics_from_state(final_state)
|
||||
root = str(final_state.get("root_cause") or "")
|
||||
record.status = "completed"
|
||||
record.root_cause = root
|
||||
record.top_analysis = _top_analysis(final_state)
|
||||
record.next_steps = _next_steps(final_state)
|
||||
record.stats = _stats(final_state)
|
||||
record.final_state = dict(final_state)
|
||||
record.notification_results = deliver_background_notifications(
|
||||
record=record,
|
||||
channels=session.terminal.background_notification_preferences.channels,
|
||||
)
|
||||
task.mark_completed(result=root)
|
||||
session.terminal.enqueue_background_notice(
|
||||
f"[{HIGHLIGHT}]background investigation complete[/] "
|
||||
f"[{DIM}]— task {escape(task.task_id)} ready; "
|
||||
f"use[/] [{HIGHLIGHT}]/background show {escape(task.task_id)}[/]",
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
record.status = "cancelled"
|
||||
task.mark_cancelled()
|
||||
session.terminal.enqueue_background_notice(
|
||||
f"[{WARNING}]background investigation cancelled[/] "
|
||||
f"[{DIM}]for task {escape(task.task_id)}.[/]",
|
||||
)
|
||||
except OpenSREError as exc:
|
||||
record.status = "failed"
|
||||
task.mark_failed(str(exc))
|
||||
session.terminal.enqueue_background_notice(
|
||||
f"[{ERROR}]background investigation failed[/] "
|
||||
f"[{DIM}]for task {escape(task.task_id)}:[/] {escape(str(exc))}",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
record.status = "failed"
|
||||
task.mark_failed(str(exc))
|
||||
report_exception(exc, context="surfaces.interactive_shell.background_investigation")
|
||||
session.terminal.enqueue_background_notice(
|
||||
f"[{ERROR}]background investigation failed[/] "
|
||||
f"[{DIM}]for task {escape(task.task_id)}:[/] {escape(str(exc))}",
|
||||
)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_worker,
|
||||
daemon=True,
|
||||
name=f"background-investigation-{task.task_id}",
|
||||
)
|
||||
thread.start()
|
||||
_safe_console_print(
|
||||
console,
|
||||
f"[{DIM}]background investigation started — task[/] [bold]{escape(task.task_id)}[/bold]. "
|
||||
f"[{HIGHLIGHT}]/background list[/] [{DIM}]to monitor, "
|
||||
f"[/][{HIGHLIGHT}]/cancel {escape(task.task_id)}[/] [{DIM}]to stop.[/]",
|
||||
)
|
||||
return task.task_id
|
||||
|
||||
|
||||
def start_background_text_investigation(
|
||||
*,
|
||||
alert_text: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
display_command: str = "background free-text investigation",
|
||||
investigation_target: str = "",
|
||||
) -> str:
|
||||
from surfaces.interactive_shell.runtime.investigation_adapter import (
|
||||
run_investigation_for_session_background,
|
||||
)
|
||||
|
||||
return _start_background_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
display_command=display_command,
|
||||
run_fn=run_investigation_for_session_background,
|
||||
kwargs={
|
||||
"alert_text": alert_text,
|
||||
"context_overrides": session.accumulated_context or None,
|
||||
},
|
||||
investigation_target=investigation_target,
|
||||
input_path=display_command,
|
||||
)
|
||||
|
||||
|
||||
def start_background_template_investigation(
|
||||
*,
|
||||
template_name: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
display_command: str,
|
||||
investigation_target: str = "",
|
||||
) -> str:
|
||||
from surfaces.interactive_shell.runtime.investigation_adapter import (
|
||||
run_sample_alert_for_session_background,
|
||||
)
|
||||
|
||||
return _start_background_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
display_command=display_command,
|
||||
run_fn=run_sample_alert_for_session_background,
|
||||
kwargs={
|
||||
"template_name": template_name,
|
||||
"context_overrides": session.accumulated_context or None,
|
||||
},
|
||||
investigation_target=investigation_target,
|
||||
input_path=f"template:{template_name}",
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Background task lifecycle for the interactive REPL runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.domain.alerts import inbox as _alert_inbox
|
||||
from surfaces.interactive_shell.runtime.background.runner import drain_background_notices
|
||||
from surfaces.interactive_shell.runtime.core.state import ReplState, SpinnerState
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui.alerts import drain_and_render_incoming
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackgroundTaskManager:
|
||||
"""Start background workers and drain their user-visible output."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
state: ReplState,
|
||||
spinner: SpinnerState,
|
||||
inbox: _alert_inbox.AlertInbox | None,
|
||||
prompt_invalidator: Callable[[], None],
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.state = state
|
||||
self.spinner = spinner
|
||||
self.inbox = inbox
|
||||
self.prompt_invalidator = prompt_invalidator
|
||||
self.tasks: list[tuple[str, asyncio.Task[None]]] = []
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._sampler_started = False
|
||||
|
||||
def start_all(
|
||||
self,
|
||||
processor_coro: Callable[[], Coroutine[Any, Any, None]],
|
||||
) -> list[tuple[str, asyncio.Task[None]]]:
|
||||
# The fleet sampler (and its psutil dependency) is intentionally NOT
|
||||
# started here: local-agent monitoring only runs once the user actually
|
||||
# opens /fleet, via ``ensure_fleet_sampler_started``.
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self.tasks = [
|
||||
("processor", asyncio.create_task(processor_coro())),
|
||||
("alert watcher", asyncio.create_task(self._alert_watcher())),
|
||||
("spinner ticker", asyncio.create_task(self._spinner_ticker())),
|
||||
]
|
||||
return self.tasks
|
||||
|
||||
def ensure_fleet_sampler_started(self) -> None:
|
||||
"""Start the fleet sampler on demand (first live ``/fleet`` use).
|
||||
|
||||
Safe to call from any thread: a shell turn (and thus the ``/fleet``
|
||||
handler) runs in a worker thread via ``asyncio.to_thread``, so sampler
|
||||
task creation is marshalled back onto the REPL event loop. Idempotent.
|
||||
"""
|
||||
loop = self._loop
|
||||
if loop is None:
|
||||
return
|
||||
loop.call_soon_threadsafe(self._start_sampler_task)
|
||||
|
||||
def _start_sampler_task(self) -> None:
|
||||
if self._sampler_started:
|
||||
return
|
||||
# Imported lazily so base REPL startup does not pull the sampler +
|
||||
# psutil into the import path.
|
||||
from tools.system.fleet_monitoring.sampler import start_sampler
|
||||
|
||||
self._sampler_started = True
|
||||
self.tasks.append(("sampler", start_sampler()))
|
||||
|
||||
def drain_turn_start_output(self, console: Console) -> None:
|
||||
if self.inbox is not None:
|
||||
try:
|
||||
drain_and_render_incoming(self.session, console, self.inbox)
|
||||
except Exception as exc:
|
||||
log.warning("Error draining alerts at turn start: %s", exc)
|
||||
try:
|
||||
drain_background_notices(self.session, console)
|
||||
except Exception as exc:
|
||||
log.warning("Error draining background notices at turn start: %s", exc)
|
||||
|
||||
async def _alert_watcher(self) -> None:
|
||||
if self.inbox is None:
|
||||
return
|
||||
alert_console = Console(
|
||||
highlight=False,
|
||||
force_terminal=True,
|
||||
color_system="truecolor",
|
||||
legacy_windows=False,
|
||||
)
|
||||
drain_and_render_incoming(self.session, alert_console, self.inbox)
|
||||
while not self.state.exit_requested:
|
||||
try:
|
||||
await asyncio.to_thread(self.inbox.pending_event.wait, timeout=1)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
try:
|
||||
drain_and_render_incoming(self.session, alert_console, self.inbox)
|
||||
except Exception as exc:
|
||||
log.warning("Error draining incoming alerts: %s", exc)
|
||||
|
||||
async def _spinner_ticker(self) -> None:
|
||||
# prompt_async's refresh_interval alone is not guaranteed to drive
|
||||
# visible prompt redraws while patch_stdout(raw=True) is active and
|
||||
# the LLM stream is writing rapidly. This task explicitly invalidates
|
||||
# the prompt at 100 ms intervals so the braille glyph cycles smoothly.
|
||||
tick_s = 0.1
|
||||
was_streaming = False
|
||||
while not self.state.exit_requested:
|
||||
try:
|
||||
await asyncio.sleep(tick_s)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
streaming = self.spinner.streaming
|
||||
# Invalidate while streaming, plus one extra tick on the
|
||||
# streaming->idle edge so the prompt repaints without the stale
|
||||
# spinner/phase label instead of waiting for unrelated I/O.
|
||||
if streaming or was_streaming:
|
||||
self.prompt_invalidator()
|
||||
was_streaming = streaming
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Validated runtime context for interactive shell sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Self
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from pydantic import BaseModel, ConfigDict, Field, InstanceOf, field_validator, model_validator
|
||||
|
||||
from core.agent_harness.session import SessionManager
|
||||
from core.domain.alerts import inbox as _alert_inbox
|
||||
from platform.observability.trace.spans import set_session_trace_sink
|
||||
from surfaces.interactive_shell.runtime.core.state import (
|
||||
ReplState,
|
||||
SpinnerState,
|
||||
create_repl_mutable_state,
|
||||
)
|
||||
from surfaces.interactive_shell.session.session import Session
|
||||
from surfaces.interactive_shell.session.trace_sink import jsonl_trace_sink_for_session
|
||||
|
||||
|
||||
class SessionBootstrapSpec(BaseModel):
|
||||
"""Pydantic-enforced inputs for preparing a REPL session."""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
|
||||
|
||||
session: InstanceOf[Session] = Field(default_factory=Session)
|
||||
pt_session: PromptSession[str] | None = None
|
||||
active_theme_name: str | None = None
|
||||
hydrate_integrations: bool = True
|
||||
persistent_tasks: bool = True
|
||||
|
||||
@field_validator("active_theme_name")
|
||||
@classmethod
|
||||
def _active_theme_name_must_not_be_blank(cls, value: str | None) -> str | None:
|
||||
if value is not None and not value.strip():
|
||||
raise ValueError("active_theme_name must not be blank")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def apply_to_session(self) -> Self:
|
||||
"""Apply the canonical startup mutations to the validated session.
|
||||
|
||||
Core bootstrap (persistent task registry + integration hydration) is
|
||||
delegated to :class:`SessionManager`; the shell layers its own UI
|
||||
concerns (theme, grounding providers, prompt history) on top.
|
||||
"""
|
||||
SessionManager().bootstrap(
|
||||
self.session,
|
||||
hydrate_integrations=self.hydrate_integrations,
|
||||
persistent_tasks=self.persistent_tasks,
|
||||
)
|
||||
self.session.terminal.active_theme_name = self.active_theme_name or _current_theme_name()
|
||||
if self.pt_session is not None:
|
||||
self.session.terminal.prompt_history_backend = self.pt_session.history
|
||||
return self
|
||||
|
||||
|
||||
class ReplRuntimeContext(BaseModel):
|
||||
"""Validated bundle shared by REPL entrypoints and the controller."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
arbitrary_types_allowed=True,
|
||||
extra="forbid",
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
session: InstanceOf[Session]
|
||||
state: InstanceOf[ReplState]
|
||||
spinner: InstanceOf[SpinnerState]
|
||||
pt_session: PromptSession[str] | None = None
|
||||
inbox: _alert_inbox.AlertInbox | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def apply_initial_mutable_state(cls, data: object) -> object:
|
||||
"""Set the paired mutable state defaults through one canonical factory."""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
if "state" in data and "spinner" in data:
|
||||
return data
|
||||
mutable_state = create_repl_mutable_state(
|
||||
state=data.get("state"),
|
||||
spinner=data.get("spinner"),
|
||||
)
|
||||
return {
|
||||
**data,
|
||||
"state": mutable_state.state,
|
||||
"spinner": mutable_state.spinner,
|
||||
}
|
||||
|
||||
@model_validator(mode="after")
|
||||
def bind_prompt_history_backend(self) -> Self:
|
||||
"""Keep session prompt-history state aligned with the prompt session."""
|
||||
if self.pt_session is not None:
|
||||
self.session.terminal.prompt_history_backend = self.pt_session.history
|
||||
return self
|
||||
|
||||
|
||||
def _current_theme_name() -> str:
|
||||
from platform.terminal.theme import get_active_theme_name
|
||||
|
||||
return get_active_theme_name()
|
||||
|
||||
|
||||
def prepare_repl_session(
|
||||
session: Session | None = None,
|
||||
*,
|
||||
pt_session: PromptSession[str] | None = None,
|
||||
active_theme_name: str | None = None,
|
||||
hydrate_integrations: bool = True,
|
||||
persistent_tasks: bool = True,
|
||||
) -> Session:
|
||||
"""Return a session with the same defaults used by REPL boot."""
|
||||
spec = SessionBootstrapSpec(
|
||||
session=session or Session(),
|
||||
pt_session=pt_session,
|
||||
active_theme_name=active_theme_name,
|
||||
hydrate_integrations=hydrate_integrations,
|
||||
persistent_tasks=persistent_tasks,
|
||||
)
|
||||
return spec.session
|
||||
|
||||
|
||||
def create_repl_runtime_context(
|
||||
session: Session | None = None,
|
||||
*,
|
||||
state: ReplState | None = None,
|
||||
spinner: SpinnerState | None = None,
|
||||
pt_session: PromptSession[str] | None = None,
|
||||
inbox: _alert_inbox.AlertInbox | None = None,
|
||||
active_theme_name: str | None = None,
|
||||
hydrate_integrations: bool = True,
|
||||
persistent_tasks: bool = True,
|
||||
) -> ReplRuntimeContext:
|
||||
"""Create the canonical validated context for a REPL controller."""
|
||||
prepared_session = prepare_repl_session(
|
||||
session,
|
||||
pt_session=pt_session,
|
||||
active_theme_name=active_theme_name,
|
||||
hydrate_integrations=hydrate_integrations,
|
||||
persistent_tasks=persistent_tasks,
|
||||
)
|
||||
set_session_trace_sink(jsonl_trace_sink_for_session(prepared_session))
|
||||
mutable_state = create_repl_mutable_state(state=state, spinner=spinner)
|
||||
return ReplRuntimeContext(
|
||||
session=prepared_session,
|
||||
state=mutable_state.state,
|
||||
spinner=mutable_state.spinner,
|
||||
pt_session=pt_session,
|
||||
inbox=inbox,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ReplRuntimeContext",
|
||||
"SessionBootstrapSpec",
|
||||
"create_repl_runtime_context",
|
||||
"prepare_repl_session",
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Core runtime engine for the interactive shell.
|
||||
|
||||
Reusable session state lives in ``core.agent_harness.session`` and terminal runtime
|
||||
context lives in ``interactive_shell.runtime.context``. This package owns the
|
||||
remaining runtime engine concerns (mutable runtime state, prompt manager,
|
||||
turn detection).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from platform.common.task_registry import TaskRegistry
|
||||
|
||||
__all__ = ["TaskRegistry"]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Prompt-mediated confirmation waiting for interactive-shell turns.
|
||||
|
||||
A turn worker thread parks here while it waits for the user to answer a
|
||||
confirmation prompt rendered by the REPL prompt loop. The wait is
|
||||
cancel-safe: it polls the dispatch cancel event and raises
|
||||
:class:`DispatchCancelled` instead of silently auto-confirming.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from surfaces.interactive_shell.runtime.core.state import PROMPT_REFRESH_INTERVAL_S, ReplState
|
||||
|
||||
|
||||
class DispatchCancelled(Exception):
|
||||
"""Raised when in-flight dispatch is cancelled during confirmation."""
|
||||
|
||||
|
||||
def request_confirmation_via_prompt(state: ReplState, prompt_text: str) -> str:
|
||||
response_event = threading.Event()
|
||||
state.begin_confirmation(response_event, prompt_text)
|
||||
try:
|
||||
while not response_event.is_set():
|
||||
cancel = state.current_cancel_event
|
||||
if cancel is not None and cancel.is_set():
|
||||
raise DispatchCancelled("cancelled while awaiting confirmation")
|
||||
response_event.wait(timeout=PROMPT_REFRESH_INTERVAL_S)
|
||||
if not state.confirm_response:
|
||||
raise DispatchCancelled("cancelled while awaiting confirmation")
|
||||
return state.confirm_response[0]
|
||||
finally:
|
||||
state.clear_confirmation()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DispatchCancelled",
|
||||
"request_confirmation_via_prompt",
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Prompt lifecycle and rendering glue for the interactive REPL loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.formatted_text import ANSI
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.runtime.core.state import (
|
||||
PROMPT_REFRESH_INTERVAL_S,
|
||||
ReplState,
|
||||
SpinnerState,
|
||||
)
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui import input_prompt
|
||||
from surfaces.interactive_shell.ui.components.cpr_stdin import (
|
||||
drain_stale_cpr_bytes,
|
||||
strip_cpr_sequences,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.input_prompt import rendering as prompt_rendering
|
||||
from surfaces.interactive_shell.ui.input_prompt.key_bindings import (
|
||||
build_cancel_key_bindings,
|
||||
install_session_key_bindings,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.input_prompt.refresh import wire_prompt_refresh
|
||||
from surfaces.interactive_shell.ui.input_prompt.style import refresh_prompt_theme
|
||||
|
||||
# Brief pause so a CPR reply still in flight lands in the stdin buffer before the
|
||||
# non-blocking drain runs; without it the reply leaks into this prompt as literal bytes.
|
||||
_CPR_SETTLE_SECONDS = 0.05
|
||||
|
||||
|
||||
class PromptManager:
|
||||
"""Own prompt-toolkit setup, prompt rendering, and prompt redraw hooks."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
state: ReplState,
|
||||
spinner: SpinnerState,
|
||||
pt_session: PromptSession[str] | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.state = state
|
||||
self.spinner = spinner
|
||||
self.pt_session = pt_session
|
||||
self.pt_app: Application[str] | None = None
|
||||
self.loop: asyncio.AbstractEventLoop | None = None
|
||||
self._invalidate_prompt: Callable[[], None] | None = None
|
||||
|
||||
def setup(self) -> None:
|
||||
if self.pt_session is None:
|
||||
self.pt_session = input_prompt.build_prompt_session(self.session)
|
||||
self.session.terminal.prompt_history_backend = self.pt_session.history
|
||||
|
||||
cancel_kb = build_cancel_key_bindings(self.state)
|
||||
install_session_key_bindings(self.pt_session, cancel_kb)
|
||||
|
||||
self.pt_app = self.pt_session.app
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.session.terminal.prompt_app = self.pt_app
|
||||
self.session.terminal.main_loop = self.loop
|
||||
self.state.bind_loop(self.loop)
|
||||
self._invalidate_prompt = wire_prompt_refresh(self.session, self.pt_app, self.loop)
|
||||
|
||||
@property
|
||||
def invalidate_prompt(self) -> Callable[[], None]:
|
||||
if self._invalidate_prompt is None:
|
||||
raise RuntimeError("PromptManager.setup() must run before prompt invalidation")
|
||||
return self._invalidate_prompt
|
||||
|
||||
def request_exit(self) -> None:
|
||||
if self.pt_app is None or self.loop is None:
|
||||
self.state.request_exit()
|
||||
return
|
||||
|
||||
self.state.request_exit()
|
||||
|
||||
def _exit_prompt_app(attempts_left: int = 5) -> None:
|
||||
if self.pt_app is not None and self.pt_app.is_running:
|
||||
self.pt_app.exit()
|
||||
return
|
||||
if attempts_left > 0 and self.loop is not None:
|
||||
self.loop.call_later(0.02, _exit_prompt_app, attempts_left - 1)
|
||||
|
||||
self.loop.call_soon_threadsafe(_exit_prompt_app)
|
||||
|
||||
def message_with_spinner(self) -> ANSI:
|
||||
base = prompt_rendering._prompt_message(self.session).value
|
||||
if self.state.is_awaiting_confirmation():
|
||||
confirm_text = self.state.confirm_prompt_text
|
||||
return ANSI(f"{confirm_text}\n{base}")
|
||||
prefix = strip_cpr_sequences(
|
||||
prompt_rendering.resolve_prompt_prefix_ansi(
|
||||
inline_spinner=self.spinner.inline_spinner_ansi(),
|
||||
idle_hint=prompt_rendering.resolve_idle_hint_ansi(self.session),
|
||||
)
|
||||
)
|
||||
return ANSI(f"{prefix}\n{base}")
|
||||
|
||||
async def read_prompt_text(self) -> str:
|
||||
if self.pt_session is None:
|
||||
raise RuntimeError("PromptManager.setup() must run before reading prompts")
|
||||
|
||||
if self.session.terminal.pending_theme_refresh:
|
||||
self.session.terminal.pending_theme_refresh = False
|
||||
refresh_prompt_theme(self.session)
|
||||
await asyncio.sleep(_CPR_SETTLE_SECONDS)
|
||||
drain_stale_cpr_bytes()
|
||||
|
||||
prefilled = self.session.terminal.pop_pending_prompt_default()
|
||||
if prefilled and self.session.terminal.pop_pending_autosubmit():
|
||||
return prefilled
|
||||
|
||||
return await self.pt_session.prompt_async(
|
||||
message=self.message_with_spinner,
|
||||
bottom_toolbar=self.spinner.toolbar_ansi,
|
||||
refresh_interval=PROMPT_REFRESH_INTERVAL_S,
|
||||
placeholder=lambda: prompt_rendering.resolve_prompt_placeholder(self.session),
|
||||
default=prefilled,
|
||||
)
|
||||
|
||||
def render_submitted_prompt(self, console: Console, text: str) -> None:
|
||||
prompt_rendering.render_submitted_prompt(console, self.session, text)
|
||||
@@ -0,0 +1,268 @@
|
||||
"""State models for the interactive shell UI runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import enum
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from prompt_toolkit.application.current import get_app_or_none
|
||||
|
||||
from platform.terminal import theme as ui_theme
|
||||
from surfaces.interactive_shell.ui.components.token_format import (
|
||||
_CHARS_PER_TOKEN,
|
||||
format_token_count_short,
|
||||
)
|
||||
|
||||
# How often prompt-toolkit refreshes prompt callbacks and confirmation polling.
|
||||
PROMPT_REFRESH_INTERVAL_S = 0.25
|
||||
|
||||
|
||||
class TurnPhase(enum.Enum):
|
||||
"""Explicit lifecycle phase of the current interactive-shell turn.
|
||||
|
||||
``phase`` is the declared turn intent and is authoritative for the
|
||||
confirmation and cancelling states. ``is_dispatch_running()`` remains
|
||||
derived from the asyncio task (the runtime truth of the in-flight turn),
|
||||
because a task can settle on its own without an explicit transition.
|
||||
"""
|
||||
|
||||
IDLE = "idle"
|
||||
DISPATCHING = "dispatching"
|
||||
AWAITING_CONFIRMATION = "awaiting_confirmation"
|
||||
CANCELLING = "cancelling"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplState:
|
||||
"""Shared runtime state for prompt loop, queue worker, and cancel handlers.
|
||||
|
||||
Single source of truth for the active dispatch task, cancellation event,
|
||||
confirmation lifecycle, exit request, and the explicit ``TurnPhase``.
|
||||
Mutate turn state through the transition methods below rather than poking
|
||||
raw fields, so ``phase`` stays consistent with the cancellation primitives.
|
||||
"""
|
||||
|
||||
queue: asyncio.Queue[str] = field(default_factory=asyncio.Queue)
|
||||
current_task: asyncio.Task[None] | None = None
|
||||
current_cancel_event: threading.Event | None = None
|
||||
loop: asyncio.AbstractEventLoop | None = None
|
||||
exit_requested: bool = False
|
||||
confirm_event: threading.Event | None = None
|
||||
confirm_response: list[str] = field(default_factory=list)
|
||||
confirm_prompt_text: str = ""
|
||||
phase: TurnPhase = TurnPhase.IDLE
|
||||
|
||||
def is_dispatch_running(self) -> bool:
|
||||
return self.current_task is not None and not self.current_task.done()
|
||||
|
||||
def is_awaiting_confirmation(self) -> bool:
|
||||
return self.phase is TurnPhase.AWAITING_CONFIRMATION
|
||||
|
||||
def is_cancelling(self) -> bool:
|
||||
return self.phase is TurnPhase.CANCELLING
|
||||
|
||||
def deliver_confirmation(self, answer: str) -> None:
|
||||
if self.confirm_event is None:
|
||||
return
|
||||
self.confirm_response.append(answer)
|
||||
self.confirm_event.set()
|
||||
|
||||
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self.loop = loop
|
||||
|
||||
def request_exit(self) -> None:
|
||||
self.exit_requested = True
|
||||
|
||||
def begin_confirmation(self, event: threading.Event, prompt_text: str = "") -> None:
|
||||
# Reset the response list BEFORE publishing ``confirm_event`` so a
|
||||
# concurrent ``deliver_confirmation`` cannot have its answer clobbered.
|
||||
# ``phase`` is set before the publish so a parked worker is observable
|
||||
# as awaiting confirmation the instant the event is visible.
|
||||
self.confirm_response = []
|
||||
self.confirm_prompt_text = prompt_text
|
||||
self.phase = TurnPhase.AWAITING_CONFIRMATION
|
||||
self.confirm_event = event
|
||||
|
||||
def clear_confirmation(self) -> None:
|
||||
self.confirm_event = None
|
||||
self.confirm_response = []
|
||||
self.confirm_prompt_text = ""
|
||||
# Only a normal confirmation completion returns to dispatching/idle; a
|
||||
# cancel in progress must keep its CANCELLING phase.
|
||||
if self.phase is TurnPhase.AWAITING_CONFIRMATION:
|
||||
self.phase = TurnPhase.DISPATCHING if self.is_dispatch_running() else TurnPhase.IDLE
|
||||
|
||||
def start_dispatch(self, *, task: asyncio.Task[None], cancel_event: threading.Event) -> None:
|
||||
self.current_task = task
|
||||
self.current_cancel_event = cancel_event
|
||||
self.phase = TurnPhase.DISPATCHING
|
||||
|
||||
def attach_turn_task(self, task: asyncio.Task[None]) -> None:
|
||||
"""Mark a queued turn task as the active dispatch (queue worker entry)."""
|
||||
self.current_task = task
|
||||
self.phase = TurnPhase.DISPATCHING
|
||||
|
||||
def attach_cancel_event(self, cancel_event: threading.Event) -> None:
|
||||
"""Park a cancel event for a dispatch that has no asyncio task."""
|
||||
self.current_cancel_event = cancel_event
|
||||
self.phase = TurnPhase.DISPATCHING
|
||||
|
||||
def clear_current_task(self, task: asyncio.Task[None] | None = None) -> None:
|
||||
if task is None or self.current_task is task:
|
||||
self.current_task = None
|
||||
self.phase = TurnPhase.IDLE
|
||||
|
||||
def finish_dispatch(self, cancel_event: threading.Event) -> None:
|
||||
if self.current_cancel_event is cancel_event:
|
||||
self.current_cancel_event = None
|
||||
self.phase = TurnPhase.IDLE
|
||||
|
||||
def cancel_current_dispatch(self) -> None:
|
||||
# Mark the cancel intent first, but only when there is something to
|
||||
# cancel, so an idle no-op call does not leave a stale CANCELLING phase.
|
||||
if (
|
||||
self.current_cancel_event is not None
|
||||
or self.confirm_event is not None
|
||||
or self.is_dispatch_running()
|
||||
):
|
||||
self.phase = TurnPhase.CANCELLING
|
||||
if self.current_cancel_event is not None:
|
||||
self.current_cancel_event.set()
|
||||
if self.confirm_event is not None:
|
||||
self.confirm_event.set()
|
||||
task = self.current_task
|
||||
if task is not None and not task.done():
|
||||
if self.loop is not None:
|
||||
self.loop.call_soon_threadsafe(task.cancel)
|
||||
else:
|
||||
task.cancel()
|
||||
|
||||
|
||||
class SpinnerState:
|
||||
"""Mutable state read by prompt callbacks for toolbar + inline spinner."""
|
||||
|
||||
_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
||||
# One glyph advance per interval of *elapsed time*. The frame must be a
|
||||
# pure function of the clock, never of how often the prompt message
|
||||
# callback runs: prompt_toolkit evaluates the message several times per
|
||||
# render pass (layout measurement + paint), so a per-call counter can land
|
||||
# on the same frame every visible render and freeze the animation.
|
||||
_FRAME_INTERVAL_S = 0.1
|
||||
_THINKING_VERBS = (
|
||||
"thinking",
|
||||
"pondering",
|
||||
"exploring",
|
||||
"reasoning",
|
||||
"considering",
|
||||
"analysing",
|
||||
"investigating",
|
||||
"deliberating",
|
||||
"ruminating",
|
||||
"deducing",
|
||||
"noodling",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.streaming: bool = False
|
||||
self.started_at: float = 0.0
|
||||
self.bytes_in: int = 0
|
||||
self._verb: str = self._THINKING_VERBS[0]
|
||||
self.phase: str = ""
|
||||
|
||||
def start(self) -> None:
|
||||
self.streaming = True
|
||||
self.started_at = time.monotonic()
|
||||
self.bytes_in = 0
|
||||
self._verb = random.choice(self._THINKING_VERBS)
|
||||
self.phase = ""
|
||||
|
||||
def set_phase(self, label: str) -> None:
|
||||
"""Animate a caller-supplied phase label instead of a thinking verb.
|
||||
|
||||
Investigation stages (``/investigate``) dispatch deterministically, so
|
||||
the turn-level "thinking" spinner never starts. The progress display
|
||||
calls this to keep the prompt spinner cycling with the active pipeline
|
||||
stage; it can be called repeatedly to advance the phase.
|
||||
"""
|
||||
if not self.streaming:
|
||||
self.started_at = time.monotonic()
|
||||
self._frame_idx = 0
|
||||
self.streaming = True
|
||||
self.phase = label
|
||||
|
||||
def stop(self) -> None:
|
||||
self.streaming = False
|
||||
self.phase = ""
|
||||
|
||||
def toolbar_ansi(self) -> str:
|
||||
# Always return an empty string so prompt_toolkit's ConditionalContainer
|
||||
# collapses the toolbar in every state. A visible toolbar causes
|
||||
# prompt_toolkit to emit \033[6n (CPR) cursor-position queries on every
|
||||
# refresh_interval; those responses leak into the vt100 input parser as
|
||||
# literal keystrokes, corrupting the input field. Hiding the toolbar
|
||||
# unconditionally also keeps its height at zero in both streaming and
|
||||
# idle states, which prevents the one-row height delta that would cause
|
||||
# prompt_toolkit to misplace the cursor and leave stale spinner lines on
|
||||
# screen. Idle hints are surfaced through idle_hint_ansi() instead,
|
||||
# which is rendered in the prompt message's reserved first line.
|
||||
return ""
|
||||
|
||||
def idle_hint_ansi(self) -> str:
|
||||
"""Dim hint line shown above the rule when no dispatch is running."""
|
||||
hint = "/ for commands · ↑↓ history"
|
||||
app = get_app_or_none()
|
||||
if app is not None and app.current_buffer.text:
|
||||
hint += " · esc to clear"
|
||||
return f"{ui_theme.DIM_ANSI}{hint}{ui_theme.ANSI_RESET}"
|
||||
|
||||
def inline_spinner_ansi(self) -> str:
|
||||
if not self.streaming:
|
||||
return ""
|
||||
elapsed = time.monotonic() - self.started_at
|
||||
token_count = self.bytes_in // _CHARS_PER_TOKEN
|
||||
frame_idx = int(elapsed / self._FRAME_INTERVAL_S)
|
||||
glyph = self._SPINNER_FRAMES[frame_idx % len(self._SPINNER_FRAMES)]
|
||||
if token_count > 0:
|
||||
tokens_str = format_token_count_short(token_count)
|
||||
suffix = f" ({elapsed:.0f}s · ↓ {tokens_str} tokens)"
|
||||
else:
|
||||
suffix = f" ({elapsed:.0f}s)"
|
||||
label = self.phase or f"{self._verb}…"
|
||||
return (
|
||||
f"{ui_theme.PROMPT_ACCENT_ANSI}{glyph} {label}{ui_theme.ANSI_RESET}"
|
||||
f"{ui_theme.ANSI_DIM}{suffix} esc to cancel{ui_theme.ANSI_RESET}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReplMutableState:
|
||||
"""Initial mutable state bundle shared by the interactive runtime."""
|
||||
|
||||
state: ReplState
|
||||
spinner: SpinnerState
|
||||
|
||||
|
||||
def create_repl_mutable_state(
|
||||
*,
|
||||
state: ReplState | None = None,
|
||||
spinner: SpinnerState | None = None,
|
||||
) -> ReplMutableState:
|
||||
"""Return the canonical initial mutable state objects for a REPL runtime."""
|
||||
return ReplMutableState(
|
||||
state=state if state is not None else ReplState(),
|
||||
spinner=spinner if spinner is not None else SpinnerState(),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROMPT_REFRESH_INTERVAL_S",
|
||||
"ReplMutableState",
|
||||
"ReplState",
|
||||
"SpinnerState",
|
||||
"TurnPhase",
|
||||
"create_repl_mutable_state",
|
||||
]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Turn-result data model and the single owner of shell-turn accounting.
|
||||
|
||||
This module holds the shell's accounting side effects around the core
|
||||
"facts only" turn-result models: action-agent analytics, terminal-turn
|
||||
aggregate telemetry, prompt-recorder flushing, conversational-turn
|
||||
persistence, and the final assistant-intent stamp.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# The neutral "facts only" turn-result models live in the decoupled agent
|
||||
# package; this module owns only the shell's accounting side effects over them.
|
||||
from core.agent_harness.turns.turn_results import (
|
||||
ShellTurnResult,
|
||||
ToolCallingAccountingStatus,
|
||||
ToolCallingTurnResult,
|
||||
)
|
||||
from platform.analytics.cli import capture_terminal_turn_summarized
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.utils.telemetry import PromptRecorder
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShellTurnAccounting:
|
||||
"""Single owner of a shell turn's accounting side effects.
|
||||
|
||||
Separates "what happened" (decided by the turn flow) from "how it is
|
||||
accounted for": action-agent analytics, terminal-turn aggregate telemetry,
|
||||
prompt-recorder flushing, conversational-turn persistence, and the final
|
||||
assistant-intent stamp.
|
||||
"""
|
||||
|
||||
session: Session
|
||||
text: str
|
||||
recorder: PromptRecorder | None
|
||||
|
||||
def record_action_result(self, action_result: ToolCallingTurnResult) -> None:
|
||||
"""Emit action-agent analytics and update terminal-turn aggregates."""
|
||||
self._record_action_analytics(action_result)
|
||||
self._record_terminal_turn(action_result)
|
||||
|
||||
def finalize(self, result: ShellTurnResult) -> ShellTurnResult:
|
||||
"""Flush the recorder, persist the turn, and stamp the session intent."""
|
||||
self._flush_prompt_recorder(result)
|
||||
if result.llm_run is not None:
|
||||
self.session.record("cli_agent", self.text)
|
||||
self.session.last_assistant_intent = result.final_intent
|
||||
return result
|
||||
|
||||
def _record_action_analytics(self, action_result: ToolCallingTurnResult) -> None:
|
||||
from platform.analytics.cli import (
|
||||
capture_repl_execution_policy_decision,
|
||||
capture_terminal_actions_executed,
|
||||
capture_terminal_actions_planned,
|
||||
)
|
||||
|
||||
if action_result.accounting_status == "not_run":
|
||||
capture_terminal_actions_executed(
|
||||
planned_count=0,
|
||||
executed_count=0,
|
||||
executed_success_count=0,
|
||||
)
|
||||
return
|
||||
|
||||
capture_terminal_actions_planned(
|
||||
planned_count=action_result.planned_count,
|
||||
has_unhandled_clause=action_result.has_unhandled_clause,
|
||||
)
|
||||
capture_repl_execution_policy_decision(
|
||||
{
|
||||
"policy_stage": "shell_action_agent",
|
||||
"policy_trace": (
|
||||
"agent_tool_calls" if action_result.planned_count else "assistant_handoff"
|
||||
),
|
||||
"planned_count": action_result.planned_count,
|
||||
"has_unhandled_clause": action_result.has_unhandled_clause,
|
||||
}
|
||||
)
|
||||
capture_terminal_actions_executed(
|
||||
planned_count=action_result.planned_count,
|
||||
executed_count=action_result.executed_count,
|
||||
executed_success_count=action_result.executed_success_count,
|
||||
)
|
||||
|
||||
def _record_terminal_turn(self, action_result: ToolCallingTurnResult) -> None:
|
||||
fallback_to_llm = not action_result.handled
|
||||
snapshot = self.session.terminal.metrics.record_turn(
|
||||
executed_count=action_result.executed_count,
|
||||
executed_success_count=action_result.executed_success_count,
|
||||
fallback_to_llm=fallback_to_llm,
|
||||
)
|
||||
capture_terminal_turn_summarized(
|
||||
planned_count=action_result.planned_count,
|
||||
executed_count=action_result.executed_count,
|
||||
executed_success_count=action_result.executed_success_count,
|
||||
fallback_to_llm=fallback_to_llm,
|
||||
session_turn_index=snapshot.turn_index,
|
||||
session_fallback_count=snapshot.fallback_count,
|
||||
session_action_success_percent=snapshot.action_success_percent,
|
||||
session_fallback_rate_percent=snapshot.fallback_rate_percent,
|
||||
)
|
||||
|
||||
def _flush_prompt_recorder(self, result: ShellTurnResult) -> None:
|
||||
# Pending turn LLM/error state is consumed unconditionally so a turn
|
||||
# that stages it can never leak it into a later turn's flush.
|
||||
pending_run = self.session.terminal.pop_pending_turn_llm()
|
||||
pending_error = self.session.terminal.pop_pending_turn_error()
|
||||
if self.recorder is None:
|
||||
return
|
||||
if pending_error is not None:
|
||||
self.recorder.set_error(pending_error[0], pending_error[1])
|
||||
self.recorder.set_response(
|
||||
result.assistant_response_text,
|
||||
result.llm_run if result.llm_run is not None else pending_run,
|
||||
)
|
||||
self.recorder.flush()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ShellTurnAccounting",
|
||||
"ShellTurnResult",
|
||||
"ToolCallingAccountingStatus",
|
||||
"ToolCallingTurnResult",
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Pure text classifiers for interactive-shell prompt turns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_INTERVENTION_CORRECTION_RE = re.compile(
|
||||
r"("
|
||||
r"no(?=[,.!?]|$)"
|
||||
r"|nope\b"
|
||||
r"|nvm\b"
|
||||
r"|nevermind\b|never\s*mind\b"
|
||||
r"|wrong\b"
|
||||
r"|wait(?=[,.!?]|$)"
|
||||
r"|stop(?=[,.!?]|$)"
|
||||
r"|actually\b"
|
||||
r"|scratch\s+that\b"
|
||||
r"|instead(?=[,.!?]|$)"
|
||||
r"|(?:let'?s\s+)?do\s+[^.\n]{1,60}\s+instead\b"
|
||||
r"|try\s+[^.\n]{1,60}\s+instead\b"
|
||||
r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONFIRMATION_TOKENS: frozenset[str] = frozenset({"", "y", "yes", "n", "no"})
|
||||
_CANCEL_REQUEST_TOKENS: frozenset[str] = frozenset({"/cancel", "/stop", "/abort"})
|
||||
|
||||
|
||||
def looks_like_confirmation_answer(text: str | None) -> bool:
|
||||
return (text or "").strip().lower() in _CONFIRMATION_TOKENS
|
||||
|
||||
|
||||
def looks_like_cancel_request(text: str | None) -> bool:
|
||||
return (text or "").strip().lower() in _CANCEL_REQUEST_TOKENS
|
||||
|
||||
|
||||
def looks_like_correction(text: str) -> bool:
|
||||
stripped = text.lstrip()
|
||||
if not stripped or stripped.startswith("```"):
|
||||
return False
|
||||
return _INTERVENTION_CORRECTION_RE.match(stripped[:80]) is not None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"looks_like_cancel_request",
|
||||
"looks_like_confirmation_answer",
|
||||
"looks_like_correction",
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Prompt input event reader for the interactive shell runtime."""
|
||||
|
||||
from surfaces.interactive_shell.runtime.input.actions import (
|
||||
QUEUE_DURING_CONFIRMATION_WARNING,
|
||||
CancelTurn,
|
||||
CloseShell,
|
||||
DeliverConfirmation,
|
||||
IgnoreInput,
|
||||
InputAction,
|
||||
ShellInputSnapshot,
|
||||
SubmitTurn,
|
||||
decide_input_action,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.input.events import (
|
||||
InputCancelled,
|
||||
InputClosed,
|
||||
InputEvent,
|
||||
InputSubmitted,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.input.prompt_input_reader import PromptInputReader
|
||||
|
||||
__all__ = [
|
||||
"CancelTurn",
|
||||
"CloseShell",
|
||||
"DeliverConfirmation",
|
||||
"IgnoreInput",
|
||||
"InputAction",
|
||||
"InputCancelled",
|
||||
"InputClosed",
|
||||
"InputEvent",
|
||||
"InputSubmitted",
|
||||
"PromptInputReader",
|
||||
"QUEUE_DURING_CONFIRMATION_WARNING",
|
||||
"ShellInputSnapshot",
|
||||
"SubmitTurn",
|
||||
"decide_input_action",
|
||||
]
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Pure input-action decisions for the interactive shell controller."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from surfaces.interactive_shell.runtime.core.turn_detection import (
|
||||
looks_like_cancel_request,
|
||||
looks_like_confirmation_answer,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.input.events import (
|
||||
InputCancelled,
|
||||
InputClosed,
|
||||
InputEvent,
|
||||
InputSubmitted,
|
||||
)
|
||||
|
||||
QUEUE_DURING_CONFIRMATION_WARNING = (
|
||||
"[dim](type y/N to confirm the pending action; your input has been queued for after)[/]"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShellInputSnapshot:
|
||||
exit_requested: bool
|
||||
dispatch_running: bool
|
||||
awaiting_confirmation: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IgnoreInput:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CloseShell:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CancelTurn:
|
||||
submitted_text: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliverConfirmation:
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubmitTurn:
|
||||
text: str
|
||||
wait_until_idle: bool = False
|
||||
warning: str | None = None
|
||||
|
||||
|
||||
InputAction = IgnoreInput | CloseShell | CancelTurn | DeliverConfirmation | SubmitTurn
|
||||
|
||||
|
||||
def decide_input_action(
|
||||
event: InputEvent,
|
||||
snapshot: ShellInputSnapshot,
|
||||
*,
|
||||
needs_exclusive_stdin: Callable[[str], bool],
|
||||
) -> InputAction:
|
||||
"""Interpret one prompt event without mutating runtime state."""
|
||||
match event:
|
||||
case InputClosed():
|
||||
return CloseShell()
|
||||
case InputCancelled():
|
||||
return CancelTurn()
|
||||
case InputSubmitted(text):
|
||||
if snapshot.exit_requested or not text:
|
||||
return IgnoreInput()
|
||||
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return IgnoreInput()
|
||||
|
||||
if snapshot.dispatch_running and looks_like_cancel_request(stripped):
|
||||
return CancelTurn(submitted_text=stripped)
|
||||
|
||||
if snapshot.awaiting_confirmation:
|
||||
if looks_like_confirmation_answer(stripped):
|
||||
return DeliverConfirmation(text=stripped)
|
||||
return SubmitTurn(
|
||||
text=stripped,
|
||||
warning=QUEUE_DURING_CONFIRMATION_WARNING,
|
||||
)
|
||||
|
||||
return SubmitTurn(
|
||||
text=stripped,
|
||||
wait_until_idle=needs_exclusive_stdin(stripped),
|
||||
)
|
||||
raise AssertionError(f"Unhandled input event: {event!r}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CancelTurn",
|
||||
"CloseShell",
|
||||
"DeliverConfirmation",
|
||||
"IgnoreInput",
|
||||
"InputAction",
|
||||
"QUEUE_DURING_CONFIRMATION_WARNING",
|
||||
"ShellInputSnapshot",
|
||||
"SubmitTurn",
|
||||
"decide_input_action",
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Explicit input events consumed by the interactive shell loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InputSubmitted:
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InputCancelled:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InputClosed:
|
||||
pass
|
||||
|
||||
|
||||
InputEvent = InputSubmitted | InputCancelled | InputClosed
|
||||
|
||||
|
||||
__all__ = ["InputCancelled", "InputClosed", "InputEvent", "InputSubmitted"]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Convert prompt-toolkit terminal behavior into shell input events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from platform.terminal.prompt_support import (
|
||||
print_session_resume_hint,
|
||||
repl_prompt_note_ctrl_c,
|
||||
repl_reset_ctrl_c_gate,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.core.prompt_manager import PromptManager
|
||||
from surfaces.interactive_shell.runtime.core.state import ReplState
|
||||
from surfaces.interactive_shell.runtime.input.events import (
|
||||
InputCancelled,
|
||||
InputClosed,
|
||||
InputEvent,
|
||||
InputSubmitted,
|
||||
)
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui import DIM
|
||||
from surfaces.interactive_shell.ui.components.cpr_stdin import (
|
||||
contains_cpr_sequence,
|
||||
strip_cpr_sequences,
|
||||
)
|
||||
|
||||
|
||||
class PromptInputReader:
|
||||
"""Read prompt text and hide terminal-specific control flow from the loop."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
prompt: PromptManager,
|
||||
state: ReplState,
|
||||
session: Session,
|
||||
console: Console,
|
||||
) -> None:
|
||||
self.prompt = prompt
|
||||
self.state = state
|
||||
self.session = session
|
||||
self.console = console
|
||||
|
||||
async def read(self) -> InputEvent:
|
||||
while True:
|
||||
try:
|
||||
text = await self.prompt.read_prompt_text()
|
||||
except EOFError:
|
||||
if self.state.is_dispatch_running():
|
||||
return InputCancelled()
|
||||
self._render_session_resume_hint()
|
||||
return InputClosed()
|
||||
except KeyboardInterrupt:
|
||||
if self.state.is_dispatch_running():
|
||||
return InputCancelled()
|
||||
if repl_prompt_note_ctrl_c(self.console, self.session.session_id):
|
||||
return InputClosed()
|
||||
return InputCancelled()
|
||||
|
||||
repl_reset_ctrl_c_gate()
|
||||
raw_text = text
|
||||
text = strip_cpr_sequences(text)
|
||||
if not text.strip() and contains_cpr_sequence(raw_text):
|
||||
continue
|
||||
return InputSubmitted(text)
|
||||
|
||||
def _render_session_resume_hint(self) -> None:
|
||||
if not self.session.session_id:
|
||||
return
|
||||
self.console.print()
|
||||
print_session_resume_hint(self.console, self.session.session_id)
|
||||
self.console.print(f"[{DIM}]Goodbye![/]")
|
||||
|
||||
|
||||
__all__ = ["PromptInputReader"]
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Gather integration evidence for a conversational shell answer.
|
||||
|
||||
The bounded think -> call-tools -> observe loop lives in the decoupled
|
||||
:func:`core.agent_harness.turns.evidence_driver.gather_tool_evidence`. This module is the terminal adapter:
|
||||
it renders each gathering step to the console and persists the gathered tool
|
||||
calls into the shell's session storage, then hands the collected observation back
|
||||
to :func:`interactive_shell.runtime.answer_turn.answer_shell_question`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from core.agent_harness.turns import evidence_driver
|
||||
from core.agent_harness.turns.evidence_driver import GatherAgentFactory
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui import DIM
|
||||
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
|
||||
from surfaces.shared.tool_labels import tool_short_label, tool_source_label
|
||||
|
||||
# Cap so a chatty tool result can't blow up persistence writes.
|
||||
_MAX_PER_TOOL_CHARS = 4_000
|
||||
|
||||
# Keys most likely to distinguish back-to-back calls to the same tool.
|
||||
_GATHER_INPUT_HINT_KEYS: tuple[str, ...] = (
|
||||
"metric_name",
|
||||
"query",
|
||||
"search",
|
||||
"filter",
|
||||
"expression",
|
||||
"promql",
|
||||
"service_name",
|
||||
"owner",
|
||||
"repo",
|
||||
"log_group",
|
||||
"monitor_id",
|
||||
"alert_id",
|
||||
"issue_id",
|
||||
"trace_id",
|
||||
"span_id",
|
||||
"dashboard_uid",
|
||||
"panel_id",
|
||||
"from",
|
||||
"to",
|
||||
"time_range",
|
||||
)
|
||||
|
||||
|
||||
class _ShellGatherErrorReporter:
|
||||
"""Minimal :class:`core.agent_harness.ports.ErrorReporter` over ``report_exception``."""
|
||||
|
||||
def report(self, exc: BaseException, *, context: str, expected: bool = False) -> None:
|
||||
report_exception(exc, context=context, expected=expected)
|
||||
|
||||
|
||||
def _truncate_hint(text: str, *, max_len: int = 48) -> str:
|
||||
compact = " ".join(text.split())
|
||||
if len(compact) <= max_len:
|
||||
return compact
|
||||
return f"{compact[: max_len - 1]}…"
|
||||
|
||||
|
||||
def _tool_input_hint(tool_input: Any) -> str:
|
||||
if not isinstance(tool_input, dict):
|
||||
return ""
|
||||
hints: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for key in _GATHER_INPUT_HINT_KEYS:
|
||||
value = tool_input.get(key)
|
||||
if value in (None, "", [], {}):
|
||||
continue
|
||||
rendered = _truncate_hint(str(value))
|
||||
if not rendered or rendered in seen:
|
||||
continue
|
||||
seen.add(rendered)
|
||||
hints.append(rendered)
|
||||
if len(hints) >= 2:
|
||||
break
|
||||
return " · ".join(hints)
|
||||
|
||||
|
||||
def _format_gathering_progress_line(
|
||||
tool_name: str,
|
||||
tool_input: Any,
|
||||
*,
|
||||
repeat_index: int,
|
||||
) -> str:
|
||||
source = tool_source_label(tool_name)
|
||||
label = tool_short_label(tool_name, source)
|
||||
call_display = f"{source} · {label}" if label else source
|
||||
if repeat_index > 1:
|
||||
call_display = f"{call_display} ({repeat_index})"
|
||||
safe_display = escape(call_display)
|
||||
hint = _tool_input_hint(tool_input)
|
||||
if hint:
|
||||
return f"· gathering via {safe_display} — {escape(hint)}…"
|
||||
return f"· gathering via {safe_display}…"
|
||||
|
||||
|
||||
def _resolve_gather_integrations(
|
||||
session: Session,
|
||||
message: str,
|
||||
resolved_integrations: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve gather integrations through the decoupled agent helper."""
|
||||
return evidence_driver._resolve_gather_integrations( # noqa: SLF001
|
||||
session, message, resolved_integrations=resolved_integrations
|
||||
)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit] + f"\n…[truncated, {len(text)} chars total]"
|
||||
|
||||
|
||||
def _persist_tool_calls(session: Session, executed: list[tuple[Any, Any]]) -> None:
|
||||
"""Record each gathered tool-call result into the session log.
|
||||
|
||||
Arguments and results are redacted and bounded before writing; failures are
|
||||
swallowed so logging never breaks the turn.
|
||||
"""
|
||||
from core.agent_harness.session import default_session_storage
|
||||
from platform.observability.trace.redaction import redact_sensitive
|
||||
|
||||
storage = default_session_storage()
|
||||
for tc, output in executed:
|
||||
with contextlib.suppress(Exception):
|
||||
body = (
|
||||
output
|
||||
if isinstance(output, str)
|
||||
else json.dumps(redact_sensitive(output), default=str)
|
||||
)
|
||||
arguments = (
|
||||
redact_sensitive(tc.input) if isinstance(tc.input, dict) else {"value": tc.input}
|
||||
)
|
||||
storage.append_tool_call(
|
||||
session.session_id,
|
||||
tool=str(tc.name),
|
||||
arguments=arguments,
|
||||
result=_truncate(body, _MAX_PER_TOOL_CHARS),
|
||||
ok=not (isinstance(output, dict) and "error" in output),
|
||||
)
|
||||
|
||||
|
||||
def gather_integration_tool_evidence(
|
||||
message: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
is_tty: bool | None = None,
|
||||
agent_factory: GatherAgentFactory | None = None,
|
||||
resolved_integrations: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""Run a bounded tool-calling loop and return collected evidence, or None.
|
||||
|
||||
Returns a formatted observation block when at least one tool was executed;
|
||||
otherwise ``None`` so the caller falls back to the normal text-only answer.
|
||||
``resolved_integrations`` is the turn's resolved view; it is forwarded so the
|
||||
gather phase reuses it instead of resolving again.
|
||||
"""
|
||||
tool_call_counts: dict[str, int] = {}
|
||||
|
||||
def on_progress(kind: str, data: dict[str, Any]) -> None:
|
||||
if kind == "tool_start":
|
||||
name = str(data.get("name", "")).strip() or "tool"
|
||||
tool_call_counts[name] = tool_call_counts.get(name, 0) + 1
|
||||
line = _format_gathering_progress_line(
|
||||
name,
|
||||
data.get("input"),
|
||||
repeat_index=tool_call_counts[name],
|
||||
)
|
||||
console.print(f"[{DIM}]{line}[/]")
|
||||
elif kind == "gather_cancelled":
|
||||
console.print(f"[{DIM}]· gathering cancelled[/]")
|
||||
|
||||
def persist(executed: list[tuple[Any, Any]]) -> None:
|
||||
_persist_tool_calls(session, executed)
|
||||
|
||||
return evidence_driver.gather_tool_evidence(
|
||||
message,
|
||||
session,
|
||||
on_progress=on_progress,
|
||||
persist=persist,
|
||||
error_reporter=_ShellGatherErrorReporter(),
|
||||
is_tty=is_tty,
|
||||
agent_factory=agent_factory,
|
||||
resolved_integrations=resolved_integrations,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["gather_integration_tool_evidence"]
|
||||
@@ -0,0 +1,170 @@
|
||||
"""REPL adapters for session investigation streaming and action-tool launch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable, Iterator
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.domain.stream import StreamEvent
|
||||
from platform.common.task_types import TaskRecord
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
|
||||
from surfaces.interactive_shell.ui.foreground_investigation import run_foreground_investigation
|
||||
from tools.interactive_shell.shared.execution_policy import ExecutionPolicyResult
|
||||
from tools.interactive_shell.shared.investigation_launch import (
|
||||
ForegroundInvestigationResult,
|
||||
InvestigationLaunchPorts,
|
||||
)
|
||||
from tools.investigation import session_runner
|
||||
|
||||
|
||||
def repl_foreground_renderer() -> session_runner.StreamRendererFn:
|
||||
"""Return a renderer that streams investigation progress to the REPL terminal."""
|
||||
from surfaces.cli.ui.renderer import StreamRenderer
|
||||
|
||||
def _render(events: Iterator[StreamEvent]) -> dict[str, Any]:
|
||||
renderer = StreamRenderer(local=True)
|
||||
return dict(renderer.render_stream(events))
|
||||
|
||||
return _render
|
||||
|
||||
|
||||
def repl_background_renderer() -> session_runner.StreamRendererFn:
|
||||
"""Return a silent renderer for background investigations."""
|
||||
from surfaces.cli.ui.renderer import StreamRenderer
|
||||
from surfaces.interactive_shell.ui.output import reset_tracker, set_silent_tracker
|
||||
|
||||
def _render(events: Iterator[StreamEvent]) -> dict[str, Any]:
|
||||
set_silent_tracker()
|
||||
try:
|
||||
renderer = StreamRenderer(local=True, display=False)
|
||||
return dict(renderer.render_stream(events))
|
||||
finally:
|
||||
reset_tracker()
|
||||
|
||||
return _render
|
||||
|
||||
|
||||
def run_investigation_for_session(
|
||||
*,
|
||||
alert_text: str,
|
||||
context_overrides: dict[str, Any] | None = None,
|
||||
cancel_requested: threading.Event | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a foreground streaming investigation in the REPL."""
|
||||
return session_runner.run_investigation_for_session(
|
||||
alert_text=alert_text,
|
||||
context_overrides=context_overrides,
|
||||
cancel_requested=cancel_requested,
|
||||
render_stream=repl_foreground_renderer(),
|
||||
)
|
||||
|
||||
|
||||
def run_sample_alert_for_session(
|
||||
*,
|
||||
template_name: str = "generic",
|
||||
context_overrides: dict[str, Any] | None = None,
|
||||
cancel_requested: threading.Event | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a foreground sample-alert investigation in the REPL."""
|
||||
return session_runner.run_sample_alert_for_session(
|
||||
template_name=template_name,
|
||||
context_overrides=context_overrides,
|
||||
cancel_requested=cancel_requested,
|
||||
render_stream=repl_foreground_renderer(),
|
||||
)
|
||||
|
||||
|
||||
def run_investigation_for_session_background(
|
||||
*,
|
||||
alert_text: str,
|
||||
context_overrides: dict[str, Any] | None = None,
|
||||
cancel_requested: threading.Event | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a silent background investigation in the REPL."""
|
||||
return session_runner.run_investigation_for_session_background(
|
||||
alert_text=alert_text,
|
||||
context_overrides=context_overrides,
|
||||
cancel_requested=cancel_requested,
|
||||
render_stream=repl_background_renderer(),
|
||||
)
|
||||
|
||||
|
||||
def run_sample_alert_for_session_background(
|
||||
*,
|
||||
template_name: str = "generic",
|
||||
context_overrides: dict[str, Any] | None = None,
|
||||
cancel_requested: threading.Event | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a silent background sample-alert investigation in the REPL."""
|
||||
return session_runner.run_sample_alert_for_session_background(
|
||||
template_name=template_name,
|
||||
context_overrides=context_overrides,
|
||||
cancel_requested=cancel_requested,
|
||||
render_stream=repl_background_renderer(),
|
||||
)
|
||||
|
||||
|
||||
class ReplInvestigationLaunchPorts:
|
||||
"""Default REPL ports for investigation-style action tools."""
|
||||
|
||||
def execution_allowed(
|
||||
self,
|
||||
*,
|
||||
policy: ExecutionPolicyResult,
|
||||
session: Session,
|
||||
console: Console,
|
||||
action_summary: str,
|
||||
confirm_fn: Callable[[str], str] | None,
|
||||
is_tty: bool | None,
|
||||
action_already_listed: bool,
|
||||
) -> bool:
|
||||
return execution_allowed(
|
||||
policy,
|
||||
session=session,
|
||||
console=console,
|
||||
action_summary=action_summary,
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
action_already_listed=action_already_listed,
|
||||
)
|
||||
|
||||
def run_foreground_investigation(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
console: Console,
|
||||
task_command: str,
|
||||
run: Callable[[TaskRecord], dict[str, object]],
|
||||
exception_context: str,
|
||||
target: str,
|
||||
) -> ForegroundInvestigationResult:
|
||||
outcome = run_foreground_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
task_command=task_command,
|
||||
run=run,
|
||||
exception_context=exception_context,
|
||||
target=target,
|
||||
)
|
||||
return ForegroundInvestigationResult(status=outcome.status)
|
||||
|
||||
|
||||
def repl_investigation_launch_ports() -> InvestigationLaunchPorts:
|
||||
"""Return REPL investigation launch ports for action tools."""
|
||||
return ReplInvestigationLaunchPorts()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ReplInvestigationLaunchPorts",
|
||||
"repl_background_renderer",
|
||||
"repl_foreground_renderer",
|
||||
"repl_investigation_launch_ports",
|
||||
"run_investigation_for_session",
|
||||
"run_investigation_for_session_background",
|
||||
"run_sample_alert_for_session",
|
||||
"run_sample_alert_for_session_background",
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Compose one interactive-shell turn from its action/gather/answer adapters.
|
||||
|
||||
Adapter-only: binds the shell's action-turn (``action_turn``), gather pass
|
||||
(``integration_tool_gathering``), and answer (``answer_turn``) adapters to the
|
||||
surface-agnostic ``run_turn`` engine. Each adapter owns its own binding; this
|
||||
file only composes them and attaches turn accounting. The injection contracts
|
||||
live in ``turn_seams``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Unpack
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.agent_harness.ports import OutputSink
|
||||
from core.agent_harness.turns.orchestrator import run_turn
|
||||
from core.agent_harness.turns.turn_plan import TurnPlan
|
||||
from core.agent_harness.turns.turn_results import ShellTurnResult, ToolCallingTurnResult
|
||||
from core.execution import ToolExecutionHooks
|
||||
from surfaces.interactive_shell.runtime.action_turn import run_action_tool_turn
|
||||
from surfaces.interactive_shell.runtime.agent_harness_adapters import resolve_output_sink
|
||||
from surfaces.interactive_shell.runtime.answer_turn import answer_shell_question
|
||||
from surfaces.interactive_shell.runtime.core.turn_accounting import ShellTurnAccounting
|
||||
from surfaces.interactive_shell.runtime.integration_tool_gathering import (
|
||||
gather_integration_tool_evidence,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.turn_seams import (
|
||||
AnswerKwargs,
|
||||
AnswerShellQuestion,
|
||||
GatherEvidence,
|
||||
RunActionToolTurn,
|
||||
)
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.utils.telemetry import LlmRunInfo, PromptRecorder
|
||||
|
||||
|
||||
def execute_shell_turn(
|
||||
text: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
recorder: PromptRecorder | None,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
request_exit: Callable[[], None] | None = None,
|
||||
execute_actions: RunActionToolTurn | None = None,
|
||||
gather_evidence: GatherEvidence | None = None,
|
||||
answer_agent: AnswerShellQuestion | None = None,
|
||||
output: OutputSink | None = None,
|
||||
tool_hooks: ToolExecutionHooks | None = None,
|
||||
) -> ShellTurnResult:
|
||||
"""Execute one submitted interactive-shell turn.
|
||||
|
||||
The action driver, gather pass, and conversational assistant default to the
|
||||
shell adapters but are overridable via ``execute_actions`` / ``gather_evidence``
|
||||
/ ``answer_agent`` (the test injection seams, typed in ``turn_seams``). They are
|
||||
bound to the live ``session``/``console`` here and handed to
|
||||
:func:`core.agent_harness.turns.orchestrator.run_turn`, which performs
|
||||
the pure path routing.
|
||||
"""
|
||||
_execute = execute_actions or run_action_tool_turn
|
||||
_gather = gather_evidence or gather_integration_tool_evidence
|
||||
_answer = answer_agent or answer_shell_question
|
||||
accounting = ShellTurnAccounting(session=session, text=text, recorder=recorder)
|
||||
resolved_output = resolve_output_sink(console, output)
|
||||
|
||||
def execute_bound(
|
||||
t: str,
|
||||
*,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
turn_plan: TurnPlan | None = None,
|
||||
) -> ToolCallingTurnResult:
|
||||
return _execute(
|
||||
t,
|
||||
session,
|
||||
console,
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
request_exit=request_exit,
|
||||
turn_plan=turn_plan,
|
||||
output=resolved_output,
|
||||
tool_hooks=tool_hooks,
|
||||
)
|
||||
|
||||
def answer_bound(t: str, **kwargs: Unpack[AnswerKwargs]) -> LlmRunInfo | None:
|
||||
# run_turn controls which keys are present (it omits tool_observation_on_screen
|
||||
# on the plain path); AnswerKwargs types them without forcing presence.
|
||||
return _answer(t, session, console, output=resolved_output, **kwargs)
|
||||
|
||||
def gather_bound(
|
||||
t: str,
|
||||
*,
|
||||
is_tty: bool | None = None,
|
||||
turn_plan: TurnPlan | None = None,
|
||||
) -> str | None:
|
||||
resolved = turn_plan.resolved_integrations if turn_plan is not None else None
|
||||
return _gather(t, session, console, is_tty=is_tty, resolved_integrations=resolved)
|
||||
|
||||
return run_turn(
|
||||
text,
|
||||
session,
|
||||
execute_actions=execute_bound,
|
||||
answer=answer_bound,
|
||||
gather=gather_bound,
|
||||
accounting=accounting,
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["execute_shell_turn"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Interactive shell startup and first-launch gates."""
|
||||
@@ -0,0 +1,303 @@
|
||||
"""First-launch GitHub login gate.
|
||||
|
||||
On the first interactive launch of ``opensre`` (all platforms), the user is
|
||||
prompted to sign in to GitHub via device flow unless they skip, are in CI/CD, a
|
||||
test harness, or a non-interactive session. The sign-in runs the hosted GitHub
|
||||
MCP setup, persists the integration, and propagates the authenticated GitHub
|
||||
username to PostHog.
|
||||
|
||||
Escape hatch: ``OPENSRE_SKIP_GITHUB_LOGIN=1`` bypasses the gate so a GitHub
|
||||
outage or a disabled device flow can never permanently lock anyone out. The gate
|
||||
is also auto-bypassed in CI/test environments and when stdin is not a TTY.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from config.repl_config import read_github_login_deferred, write_github_login_deferred
|
||||
from platform.analytics.cli import capture_github_login_completed
|
||||
from platform.analytics.source import is_test_run
|
||||
from platform.terminal.theme import DEVICE_CODE
|
||||
from surfaces.interactive_shell.ui import repl_tty_interactive
|
||||
|
||||
_SKIP_ENV_VAR = "OPENSRE_SKIP_GITHUB_LOGIN"
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
_SIGN_IN_CHOICE = "sign_in"
|
||||
_SKIP_CHOICE = "skip"
|
||||
|
||||
|
||||
def _skip_requested() -> bool:
|
||||
return os.getenv(_SKIP_ENV_VAR, "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def _github_login_explicitly_bypassed() -> bool:
|
||||
"""Cheap check for contexts where gate errors should not block startup."""
|
||||
if _skip_requested():
|
||||
return True
|
||||
if os.getenv("OPENSRE_INVESTIGATION_SOURCE", "").strip().lower() == "test":
|
||||
return True
|
||||
if os.getenv("OPENSRE_IS_TEST", "0").strip() == "1":
|
||||
return True
|
||||
if os.getenv("PYTEST_CURRENT_TEST"):
|
||||
return True
|
||||
if os.getenv("GITHUB_ACTIONS", "").strip().lower() == "true":
|
||||
return True
|
||||
ci_value = os.getenv("CI", "").strip().lower()
|
||||
if ci_value in {"1", "true", "yes"}:
|
||||
return True
|
||||
try:
|
||||
return not sys.stdin.isatty()
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _github_already_configured() -> bool:
|
||||
from integrations.github.mcp import github_integration_is_configured
|
||||
|
||||
return github_integration_is_configured()
|
||||
|
||||
|
||||
def should_require_github_login() -> bool:
|
||||
"""Return True when the first-launch GitHub login prompt must run now."""
|
||||
if _skip_requested():
|
||||
return False
|
||||
if read_github_login_deferred():
|
||||
return False
|
||||
if is_test_run():
|
||||
return False
|
||||
if not repl_tty_interactive():
|
||||
return False
|
||||
# GitHub being configured is the authoritative bypass. We intentionally do
|
||||
# NOT consult a first-launch "completion" marker here: a stale marker must
|
||||
# never let the REPL start once the GitHub integration has been removed
|
||||
# (e.g. via ``/integrations remove github``). Re-checking the store is cheap,
|
||||
# so the gate always re-runs when GitHub is not currently configured.
|
||||
return not _github_already_configured()
|
||||
|
||||
|
||||
def clear_github_login_deferral() -> None:
|
||||
"""Clear a saved skip so removing GitHub can re-prompt on the next launch."""
|
||||
if not read_github_login_deferred():
|
||||
return
|
||||
write_github_login_deferred(False)
|
||||
|
||||
|
||||
def _propagate_username(username: str) -> None:
|
||||
if not username:
|
||||
return
|
||||
# ``authenticate_and_configure_github`` already calls identify_github_username;
|
||||
# only emit the one-time login lifecycle event here.
|
||||
capture_github_login_completed(username)
|
||||
|
||||
|
||||
def _print_intro(console: Console) -> None:
|
||||
console.print()
|
||||
console.print("[bold]Connect GitHub to get started[/bold]")
|
||||
console.print(
|
||||
"OpenSRE needs read access to your GitHub repositories to investigate "
|
||||
"incidents against your source. Sign in once with your browser."
|
||||
)
|
||||
console.print(
|
||||
"[dim](Escape to skip for now, or set "
|
||||
f"{_SKIP_ENV_VAR}=1 if GitHub sign-in is unavailable.)[/dim]"
|
||||
)
|
||||
|
||||
|
||||
def _show_device_code(console: Console, code: object) -> None:
|
||||
from integrations.github.mcp_oauth import GitHubDeviceCode
|
||||
|
||||
if not isinstance(code, GitHubDeviceCode):
|
||||
return
|
||||
user_code = escape(code.user_code)
|
||||
console.print()
|
||||
console.print(f" 1. Your browser will open [underline]{code.verification_uri}[/underline]")
|
||||
console.print(" (if it doesn't open automatically, visit that URL yourself).")
|
||||
console.print(f" 2. Enter this one-time code when GitHub asks: [{DEVICE_CODE}]{user_code}[/]")
|
||||
console.print(" 3. Approve the request for OpenSRE.")
|
||||
console.print()
|
||||
console.print(
|
||||
" [dim]Waiting for you to approve in the browser… (Escape or Ctrl-C to skip)[/dim]"
|
||||
)
|
||||
|
||||
|
||||
def _print_skip_guidance(console: Console) -> None:
|
||||
console.print()
|
||||
console.print(
|
||||
"[dim]Skipped GitHub sign-in. Connect later with "
|
||||
"[bold]/integrations setup[/bold] or [bold]/mcp connect github[/bold].[/dim]"
|
||||
)
|
||||
|
||||
|
||||
def _defer_github_login() -> None:
|
||||
write_github_login_deferred(True)
|
||||
|
||||
|
||||
def _sleep_until_or_cancel(seconds: float) -> None:
|
||||
"""Sleep up to ``seconds``, raising ``KeyboardInterrupt`` when the user skips."""
|
||||
if seconds <= 0 or not sys.stdin.isatty():
|
||||
time.sleep(seconds)
|
||||
return
|
||||
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
from surfaces.interactive_shell.ui.components.key_reader import read_key_windows
|
||||
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
if msvcrt.kbhit() and read_key_windows() == "cancel": # type: ignore[attr-defined]
|
||||
raise KeyboardInterrupt
|
||||
time.sleep(0.05)
|
||||
return
|
||||
|
||||
import select
|
||||
import termios
|
||||
import tty
|
||||
|
||||
from surfaces.interactive_shell.ui.components.key_reader import read_key_unix
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_attrs = termios.tcgetattr(fd) # type: ignore[attr-defined]
|
||||
try:
|
||||
tty.setraw(fd) # type: ignore[attr-defined]
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return
|
||||
ready, _, _ = select.select([fd], [], [], min(remaining, 0.15))
|
||||
if not ready:
|
||||
continue
|
||||
if read_key_unix() == "cancel":
|
||||
raise KeyboardInterrupt
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _offer_github_login(_console: Console) -> bool:
|
||||
"""Return True when the user wants to start browser sign-in."""
|
||||
import questionary
|
||||
|
||||
try:
|
||||
choice = questionary.select(
|
||||
"Connect GitHub now?",
|
||||
choices=[
|
||||
questionary.Choice(
|
||||
"Sign in with GitHub (opens browser)",
|
||||
value=_SIGN_IN_CHOICE,
|
||||
),
|
||||
questionary.Choice("Skip for now", value=_SKIP_CHOICE),
|
||||
],
|
||||
default=_SIGN_IN_CHOICE,
|
||||
).ask()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
if choice is None:
|
||||
return False
|
||||
return bool(choice == _SIGN_IN_CHOICE)
|
||||
|
||||
|
||||
def _ask_retry(_console: Console) -> bool:
|
||||
import questionary
|
||||
|
||||
try:
|
||||
answer = questionary.confirm("Try GitHub sign-in again?", default=True).ask()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
if answer is None:
|
||||
return False
|
||||
return bool(answer)
|
||||
|
||||
|
||||
def _attempt_login(console: Console) -> str:
|
||||
"""Run one login attempt. Returns ``"success"``, ``"failed"``, or ``"skipped"``."""
|
||||
from integrations.github.login import authenticate_and_configure_github
|
||||
from integrations.github.mcp_oauth import GitHubDeviceFlowError
|
||||
|
||||
try:
|
||||
result = authenticate_and_configure_github(
|
||||
on_prompt=lambda code: _show_device_code(console, code),
|
||||
poll_sleep=_sleep_until_or_cancel,
|
||||
)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
console.print("\nSkipped GitHub sign-in.")
|
||||
return "skipped"
|
||||
except GitHubDeviceFlowError as err:
|
||||
console.print(f"[yellow]GitHub sign-in is unavailable:[/yellow] {err}")
|
||||
return "failed"
|
||||
except Exception as err: # network/transport issues
|
||||
console.print(f"[yellow]GitHub sign-in failed:[/yellow] {err}")
|
||||
return "failed"
|
||||
|
||||
if result.ok:
|
||||
clear_github_login_deferral()
|
||||
# Persisting the GitHub integration (done inside
|
||||
# ``authenticate_and_configure_github``) is what suppresses the gate on
|
||||
# subsequent launches — there is no separate completion marker to write.
|
||||
_propagate_username(result.username)
|
||||
who = f"@{result.username}" if result.username else "your GitHub account"
|
||||
console.print(f"[bold]Connected.[/bold] Signed in as {who}.")
|
||||
return "success"
|
||||
|
||||
console.print(f"[yellow]Could not verify GitHub access:[/yellow] {result.detail}")
|
||||
return "failed"
|
||||
|
||||
|
||||
def require_github_login_on_first_launch(console: Console | None = None) -> bool:
|
||||
"""Run the first-launch GitHub login prompt.
|
||||
|
||||
Returns True when the caller should proceed into the REPL (login succeeded or
|
||||
the user skipped), and False only when startup must abort.
|
||||
"""
|
||||
con = console or Console(highlight=False)
|
||||
_print_intro(con)
|
||||
if not _offer_github_login(con):
|
||||
_defer_github_login()
|
||||
_print_skip_guidance(con)
|
||||
return True
|
||||
|
||||
while True:
|
||||
outcome = _attempt_login(con)
|
||||
if outcome == "success":
|
||||
return True
|
||||
if outcome == "skipped":
|
||||
_defer_github_login()
|
||||
_print_skip_guidance(con)
|
||||
return True
|
||||
if not _ask_retry(con):
|
||||
_defer_github_login()
|
||||
_print_skip_guidance(con)
|
||||
return True
|
||||
|
||||
|
||||
def require_startup_github_login(console: Console) -> bool:
|
||||
"""Return True when startup may proceed past the GitHub login gate.
|
||||
|
||||
On an unexpected gate error we deliberately do NOT fail open into the REPL:
|
||||
that would let a gate bug silently skip sign-in. Instead we only allow
|
||||
startup when an explicit, documented bypass applies.
|
||||
"""
|
||||
try:
|
||||
if not should_require_github_login():
|
||||
return True
|
||||
return require_github_login_on_first_launch(console)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning(
|
||||
"First-launch GitHub login gate failed.",
|
||||
exc_info=True,
|
||||
)
|
||||
if _github_login_explicitly_bypassed():
|
||||
return True
|
||||
console.print(
|
||||
"GitHub sign-in could not run. "
|
||||
f"Set [bold]{_SKIP_ENV_VAR}=1[/bold] to bypass this, then relaunch "
|
||||
"[bold]opensre[/bold]."
|
||||
)
|
||||
return False
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Non-interactive initial-input replay for REPL startup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from platform.analytics.repl_context import bound_repl_turn_context
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui.banner import render_banner
|
||||
from surfaces.interactive_shell.ui.input_prompt.rendering import render_submitted_prompt
|
||||
from surfaces.interactive_shell.utils.telemetry import PromptRecorder
|
||||
|
||||
_TURN_KIND = "agent"
|
||||
|
||||
|
||||
def run_initial_input(
|
||||
initial_input: str,
|
||||
session: Session,
|
||||
) -> int:
|
||||
# Imported lazily so importing this module during REPL boot (main.py imports
|
||||
# ``run_initial_input`` at top) does not pull the harness/turn-execution
|
||||
# stack into the base import path when there is no initial input to replay.
|
||||
from surfaces.interactive_shell.runtime.shell_turn_execution import execute_shell_turn
|
||||
|
||||
console = Console(
|
||||
highlight=False,
|
||||
force_terminal=True,
|
||||
color_system="truecolor",
|
||||
legacy_windows=False,
|
||||
)
|
||||
render_banner(console)
|
||||
for line in initial_input.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
render_submitted_prompt(console, session, stripped)
|
||||
recorder = PromptRecorder.start(session=session, text=stripped, turn_kind=_TURN_KIND)
|
||||
with bound_repl_turn_context(
|
||||
session_id=session.session_id,
|
||||
turn_kind=_TURN_KIND,
|
||||
prompt_turn_id=recorder.turn_id if recorder is not None else None,
|
||||
):
|
||||
execute_shell_turn(
|
||||
stripped,
|
||||
session,
|
||||
console,
|
||||
recorder=recorder,
|
||||
confirm_fn=None,
|
||||
is_tty=False,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = ["run_initial_input"]
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Execute planned opensre CLI actions.
|
||||
|
||||
Pure subprocess execution, planning, and orchestration for shell / synthetic /
|
||||
Claude Code / opensre CLI action tools live under ``tools.interactive_shell``
|
||||
(``subprocess/``, ``shell/``, ``synthetic/``, ``implementation/``, ``cli/``).
|
||||
This package keeps Rich relay helpers in ``task_streaming``, the REPL presenter
|
||||
in ``repl_presenter``, and backward-compatible surface adapters such as
|
||||
``opensre_cli_runner`` for slash parity and legacy test monkeypatch paths.
|
||||
|
||||
Shell command execution lives in ``tools.interactive_shell.shell`` (parsing,
|
||||
policy, ``execute_shell_command``, and the ``run_shell_command`` / ``run_cd`` /
|
||||
``run_pwd`` runner); it is intentionally not re-exported here. Synthetic test
|
||||
execution lives in ``tools.interactive_shell.synthetic`` (the
|
||||
``run_synthetic_test`` / ``watch_synthetic_subprocess`` runner), Claude Code
|
||||
implementation execution lives in ``tools.interactive_shell.implementation.claude_code_executor``
|
||||
(``run_claude_code_implementation``), and sample-alert / free-text investigation
|
||||
execution lives in ``tools.interactive_shell.actions.sample_alert`` /
|
||||
``tools.interactive_shell.actions.investigation`` (``run_sample_alert`` /
|
||||
``run_text_investigation``); none are re-exported here. Shared stdlib subprocess
|
||||
primitives live in ``tools.interactive_shell.subprocess``; Rich stream relay
|
||||
remains in ``task_streaming``.
|
||||
|
||||
Public API is stable: all names exported below are importable directly from
|
||||
``subprocess_runner`` and will remain so regardless of internal submodule changes.
|
||||
|
||||
Stdlib modules ``os``, ``subprocess``, and ``threading`` are re-imported here so
|
||||
that tests can patch them via the full ``subprocess_runner.<module>.<attr>`` path
|
||||
(e.g. ``subprocess_runner.subprocess.Popen``). Since these are module singletons in
|
||||
``sys.modules``, patching via this attribute also affects the actual call sites
|
||||
inside the submodules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Stdlib singletons — imported so that monkeypatch paths resolve correctly in tests:
|
||||
# ``"…subprocess_runner.os.chdir"``, ``"…subprocess_runner.subprocess.Popen"``,
|
||||
# ``"…subprocess_runner.threading.Thread"``, ``"…subprocess_runner.time.sleep"``,
|
||||
# ``"…subprocess_runner.Path.cwd"``.
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .background_task_executor import start_background_cli_task
|
||||
from .opensre_cli_runner import (
|
||||
_INTERACTIVE_OPENSRE_COMMAND_PATHS,
|
||||
_OPENSRE_BLOCKED_SUBCOMMANDS,
|
||||
OpensreCommandClass,
|
||||
OpensreExecutionMode,
|
||||
OpensreExecutionPlan,
|
||||
OpensreRunOutcome,
|
||||
OpensreRunResult,
|
||||
_build_opensre_execution_plan,
|
||||
_classify_opensre_command,
|
||||
_is_interactive_wizard,
|
||||
_opensre_confirmation_reason,
|
||||
_run_opensre_foreground,
|
||||
_run_opensre_foreground_streaming,
|
||||
build_opensre_cli_argv,
|
||||
print_interactive_wizard_handoff,
|
||||
run_opensre_cli_command,
|
||||
run_opensre_cli_command_result,
|
||||
)
|
||||
from .task_streaming import (
|
||||
_MAX_COMMAND_OUTPUT_CHARS,
|
||||
_MIN_SUBPROCESS_TERMINAL_WIDTH,
|
||||
_SYNTHETIC_DIAG_CHARS,
|
||||
_SYNTHETIC_POLL_SECONDS,
|
||||
_TASK_OUTPUT_JOIN_TIMEOUT_SECONDS,
|
||||
_TASK_OUTPUT_PREFIX_WIDTH,
|
||||
CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS,
|
||||
SHELL_COMMAND_TIMEOUT_SECONDS,
|
||||
SYNTHETIC_TEST_TIMEOUT_SECONDS,
|
||||
_console_file_is_tty,
|
||||
_join_task_output_streams,
|
||||
_print_task_output_line,
|
||||
_pump_task_pty,
|
||||
_pump_task_stream,
|
||||
_should_use_pty,
|
||||
_start_task_output_streams,
|
||||
_subprocess_env_with_aligned_width,
|
||||
read_diag,
|
||||
read_task_output,
|
||||
terminate_child_process,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS",
|
||||
"SHELL_COMMAND_TIMEOUT_SECONDS",
|
||||
"SYNTHETIC_TEST_TIMEOUT_SECONDS",
|
||||
"OpensreCommandClass",
|
||||
"OpensreExecutionMode",
|
||||
"OpensreExecutionPlan",
|
||||
"OpensreRunOutcome",
|
||||
"OpensreRunResult",
|
||||
"Path",
|
||||
"_INTERACTIVE_OPENSRE_COMMAND_PATHS",
|
||||
"_MAX_COMMAND_OUTPUT_CHARS",
|
||||
"_MIN_SUBPROCESS_TERMINAL_WIDTH",
|
||||
"_OPENSRE_BLOCKED_SUBCOMMANDS",
|
||||
"_SYNTHETIC_DIAG_CHARS",
|
||||
"_SYNTHETIC_POLL_SECONDS",
|
||||
"_TASK_OUTPUT_JOIN_TIMEOUT_SECONDS",
|
||||
"_TASK_OUTPUT_PREFIX_WIDTH",
|
||||
"_classify_opensre_command",
|
||||
"_build_opensre_execution_plan",
|
||||
"_console_file_is_tty",
|
||||
"_is_interactive_wizard",
|
||||
"_join_task_output_streams",
|
||||
"_opensre_confirmation_reason",
|
||||
"_print_task_output_line",
|
||||
"_pump_task_pty",
|
||||
"_pump_task_stream",
|
||||
"_run_opensre_foreground",
|
||||
"_run_opensre_foreground_streaming",
|
||||
"_should_use_pty",
|
||||
"_start_task_output_streams",
|
||||
"_subprocess_env_with_aligned_width",
|
||||
"build_opensre_cli_argv",
|
||||
"os",
|
||||
"print_interactive_wizard_handoff",
|
||||
"read_diag",
|
||||
"read_task_output",
|
||||
"run_opensre_cli_command",
|
||||
"run_opensre_cli_command_result",
|
||||
"start_background_cli_task",
|
||||
"subprocess",
|
||||
"terminate_child_process",
|
||||
"threading",
|
||||
"time",
|
||||
]
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Background CLI task launcher — runs subprocesses with streamed output above the prompt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from surfaces.interactive_shell.runtime import Session, TaskKind, TaskRecord
|
||||
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT
|
||||
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
|
||||
from surfaces.interactive_shell.utils.telemetry import PromptRecorder
|
||||
|
||||
from .task_streaming import (
|
||||
_MAX_COMMAND_OUTPUT_CHARS,
|
||||
_SYNTHETIC_DIAG_CHARS,
|
||||
_SYNTHETIC_POLL_SECONDS,
|
||||
SHELL_COMMAND_TIMEOUT_SECONDS,
|
||||
_join_task_output_streams,
|
||||
_pump_task_pty,
|
||||
_should_use_pty,
|
||||
_sr_resolve,
|
||||
_start_task_output_streams,
|
||||
_subprocess_env_with_aligned_width,
|
||||
read_diag,
|
||||
read_task_output,
|
||||
terminate_child_process,
|
||||
)
|
||||
|
||||
|
||||
def _compose_task_log_response(
|
||||
*,
|
||||
headline: str,
|
||||
stdout_buf: tempfile.SpooledTemporaryFile[bytes] | None, # type: ignore[type-arg]
|
||||
stderr_buf: tempfile.SpooledTemporaryFile[bytes], # type: ignore[type-arg]
|
||||
) -> str:
|
||||
"""Build the prompt-log response text for a finished background task.
|
||||
|
||||
Combines a status headline with the captured stdout and stderr so the
|
||||
flushed ``background_task`` event carries the real command output (the
|
||||
error text the user sees in the terminal), not an empty assistant reply.
|
||||
"""
|
||||
parts = [headline]
|
||||
stdout_text = read_task_output(stdout_buf, limit=_MAX_COMMAND_OUTPUT_CHARS)
|
||||
stderr_text = read_task_output(stderr_buf, limit=_MAX_COMMAND_OUTPUT_CHARS)
|
||||
if stdout_text:
|
||||
parts.append(stdout_text)
|
||||
if stderr_text:
|
||||
parts.append(stderr_text)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def start_background_cli_task(
|
||||
*,
|
||||
display_command: str,
|
||||
argv_list: list[str],
|
||||
session: Session,
|
||||
console: Console,
|
||||
timeout_seconds: int = SHELL_COMMAND_TIMEOUT_SECONDS,
|
||||
kind: TaskKind = TaskKind.CLI_COMMAND,
|
||||
use_pty: bool = False,
|
||||
) -> TaskRecord | None:
|
||||
"""Start a subprocess as a REPL task while streaming output above the prompt."""
|
||||
console.print(f"[bold]$ {display_command}[/bold]")
|
||||
task = session.task_registry.create(kind, command=display_command)
|
||||
task.mark_running()
|
||||
# Created at launch so the flushed prompt-log latency spans the full task
|
||||
# duration; the watcher sets the response and flushes once the outcome
|
||||
# (including any error text) is known. See for_background_task() docstring.
|
||||
recorder = PromptRecorder.for_background_task(
|
||||
session=session, command=display_command, task_id=task.task_id
|
||||
)
|
||||
stderr_buf: tempfile.SpooledTemporaryFile[bytes] = tempfile.SpooledTemporaryFile( # type: ignore[type-arg]
|
||||
max_size=_SYNTHETIC_DIAG_CHARS * 2
|
||||
)
|
||||
pty_fds: tuple[int, int] | None = None
|
||||
if _should_use_pty(console, use_pty):
|
||||
try:
|
||||
pty_fds = os.openpty()
|
||||
except OSError:
|
||||
pty_fds = None
|
||||
stdout_buf: tempfile.SpooledTemporaryFile[bytes] | None = None # type: ignore[type-arg]
|
||||
if pty_fds is None:
|
||||
stdout_buf = tempfile.SpooledTemporaryFile( # type: ignore[type-arg]
|
||||
max_size=_MAX_COMMAND_OUTPUT_CHARS
|
||||
)
|
||||
subprocess_env = _subprocess_env_with_aligned_width(console)
|
||||
proc: subprocess.Popen[Any]
|
||||
try:
|
||||
if pty_fds is None:
|
||||
proc = subprocess.Popen(
|
||||
argv_list,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
start_new_session=True,
|
||||
env=subprocess_env,
|
||||
)
|
||||
else:
|
||||
_master_fd, slave_fd = pty_fds
|
||||
proc = subprocess.Popen(
|
||||
argv_list,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True,
|
||||
start_new_session=True,
|
||||
env=subprocess_env,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if pty_fds is not None:
|
||||
for fd in pty_fds:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(fd)
|
||||
task.mark_failed(str(exc))
|
||||
if recorder is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
recorder.set_error("spawn_failed", str(exc))
|
||||
recorder.set_response(
|
||||
_compose_task_log_response(
|
||||
headline=f"command failed to start: {exc}",
|
||||
stdout_buf=stdout_buf,
|
||||
stderr_buf=stderr_buf,
|
||||
)
|
||||
)
|
||||
recorder.flush()
|
||||
if stdout_buf is not None:
|
||||
stdout_buf.close()
|
||||
stderr_buf.close()
|
||||
report_exception(exc, context="surfaces.interactive_shell.background_cli_task.start")
|
||||
console.print(f"[{ERROR}]failed to start:[/] {escape(str(exc))}")
|
||||
return None
|
||||
|
||||
task.attach_process(proc)
|
||||
started_at = time.monotonic()
|
||||
if pty_fds is None:
|
||||
output_threads = _start_task_output_streams(
|
||||
task=task,
|
||||
proc=proc,
|
||||
console=console,
|
||||
stdout_capture=stdout_buf,
|
||||
stderr_capture=stderr_buf,
|
||||
)
|
||||
else:
|
||||
master_fd, slave_fd = pty_fds
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(slave_fd)
|
||||
output_thread = threading.Thread(
|
||||
target=_pump_task_pty,
|
||||
kwargs={"master_fd": master_fd, "console": console, "capture": stderr_buf},
|
||||
daemon=True,
|
||||
name=f"task-terminal-{task.task_id}",
|
||||
)
|
||||
output_thread.start()
|
||||
output_threads = [output_thread]
|
||||
|
||||
history_gen_when_watch_started = session.terminal.history_generation
|
||||
|
||||
def _watch() -> None:
|
||||
terminated_by_watcher = False
|
||||
timed_out = False
|
||||
suggest_follow_up = False
|
||||
outcome_headline = "command completed (exit 0)"
|
||||
outcome_error_kind = ""
|
||||
while proc.poll() is None:
|
||||
if time.monotonic() - started_at > timeout_seconds:
|
||||
timed_out = True
|
||||
task.request_cancel()
|
||||
terminate_child_process(proc)
|
||||
terminated_by_watcher = True
|
||||
break
|
||||
if task.cancel_requested.is_set():
|
||||
terminate_child_process(proc)
|
||||
terminated_by_watcher = True
|
||||
break
|
||||
time.sleep(_SYNTHETIC_POLL_SECONDS)
|
||||
|
||||
try:
|
||||
if timed_out:
|
||||
outcome_headline = f"command timed out after {timeout_seconds} seconds"
|
||||
outcome_error_kind = "timeout"
|
||||
task.mark_failed(f"timed out after {timeout_seconds}s")
|
||||
suggest_follow_up = kind is TaskKind.SYNTHETIC_TEST
|
||||
return
|
||||
if terminated_by_watcher and task.cancel_requested.is_set():
|
||||
outcome_headline = "command cancelled"
|
||||
task.mark_cancelled()
|
||||
return
|
||||
|
||||
_join_task_output_streams(output_threads)
|
||||
code = proc.returncode
|
||||
if code == 0:
|
||||
task.mark_completed()
|
||||
else:
|
||||
diag = _sr_resolve("read_diag", read_diag)(stderr_buf)
|
||||
error_msg = f"exit code {code}" + (f": {diag}" if diag else "")
|
||||
outcome_headline = f"command failed (exit {code})"
|
||||
outcome_error_kind = "cli_exit_nonzero"
|
||||
task.mark_failed(error_msg)
|
||||
console.print(f"[{ERROR}]command failed (exit {code}):[/]")
|
||||
suggest_follow_up = kind is TaskKind.SYNTHETIC_TEST
|
||||
except Exception as exc: # noqa: BLE001
|
||||
outcome_headline = f"command error: {exc}"
|
||||
outcome_error_kind = "watcher_error"
|
||||
task.mark_failed(str(exc))
|
||||
report_exception(exc, context="surfaces.interactive_shell.background_cli_task.watch")
|
||||
console.print(f"[{ERROR}]error:[/] {escape(str(exc))}")
|
||||
suggest_follow_up = kind is TaskKind.SYNTHETIC_TEST
|
||||
finally:
|
||||
_join_task_output_streams(output_threads)
|
||||
# Flush the prompt-log/PostHog event with the real outcome (stdout,
|
||||
# stderr, exit/timeout/cancel) before the capture buffers are closed.
|
||||
if recorder is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
if outcome_error_kind:
|
||||
recorder.set_error(outcome_error_kind, outcome_headline)
|
||||
recorder.set_response(
|
||||
_compose_task_log_response(
|
||||
headline=outcome_headline,
|
||||
stdout_buf=stdout_buf,
|
||||
stderr_buf=stderr_buf,
|
||||
)
|
||||
)
|
||||
recorder.flush()
|
||||
if stdout_buf is not None:
|
||||
stdout_buf.close()
|
||||
stderr_buf.close()
|
||||
if (
|
||||
suggest_follow_up
|
||||
and session.terminal.history_generation == history_gen_when_watch_started
|
||||
):
|
||||
session.suggest_synthetic_failure_follow_up(label=display_command)
|
||||
else:
|
||||
session.terminal.notify_prompt_changed()
|
||||
|
||||
thread = threading.Thread(target=_watch, daemon=True)
|
||||
thread.start()
|
||||
console.print(
|
||||
f"[{DIM}]started — task[/] [bold]{escape(task.task_id)}[/bold]. "
|
||||
f"[{HIGHLIGHT}]/tasks[/] [{DIM}]to monitor,[/] "
|
||||
f"[{HIGHLIGHT}]/cancel {escape(task.task_id)}[/] [{DIM}]to stop.[/]"
|
||||
)
|
||||
return task
|
||||
@@ -0,0 +1,151 @@
|
||||
"""OpenSRE CLI command runner — surface adapter over tools.interactive_shell.cli."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.runtime.subprocess_runner.repl_presenter import make_repl_presenter
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui import DIM, WARNING
|
||||
from tools.interactive_shell.cli import (
|
||||
INTERACTIVE_OPENSRE_COMMAND_PATHS,
|
||||
OPENSRE_BLOCKED_SUBCOMMANDS,
|
||||
OpensreCommandClass,
|
||||
OpensreExecutionMode,
|
||||
OpensreExecutionPlan,
|
||||
OpensreRunOutcome,
|
||||
OpensreRunResult,
|
||||
_run_foreground_via_presenter,
|
||||
_run_streaming_via_presenter,
|
||||
build_opensre_cli_argv,
|
||||
build_opensre_execution_plan,
|
||||
classify_opensre_command,
|
||||
interactive_wizard_handoff_response_text,
|
||||
is_interactive_wizard,
|
||||
opensre_confirmation_reason,
|
||||
)
|
||||
from tools.interactive_shell.cli import (
|
||||
run_opensre_cli_command as _run_opensre_cli_command,
|
||||
)
|
||||
from tools.interactive_shell.cli import (
|
||||
run_opensre_cli_command_result as _run_opensre_cli_command_result,
|
||||
)
|
||||
|
||||
# Backward-compatible aliases for tests and slash parity.
|
||||
_INTERACTIVE_OPENSRE_COMMAND_PATHS = INTERACTIVE_OPENSRE_COMMAND_PATHS
|
||||
_OPENSRE_BLOCKED_SUBCOMMANDS = OPENSRE_BLOCKED_SUBCOMMANDS
|
||||
|
||||
|
||||
def _is_interactive_wizard(tokens: list[str]) -> bool:
|
||||
return is_interactive_wizard(tokens)
|
||||
|
||||
|
||||
def _classify_opensre_command(tokens: list[str]) -> str:
|
||||
return classify_opensre_command(tokens)
|
||||
|
||||
|
||||
def _opensre_confirmation_reason(tokens: list[str]) -> str:
|
||||
return opensre_confirmation_reason(tokens)
|
||||
|
||||
|
||||
def _build_opensre_execution_plan(tokens: list[str]) -> OpensreExecutionPlan:
|
||||
return build_opensre_execution_plan(tokens)
|
||||
|
||||
|
||||
def print_interactive_wizard_handoff(console: Console, command_str: str) -> None:
|
||||
console.print(
|
||||
f"[{WARNING}]`opensre {command_str}` is an interactive wizard "
|
||||
"that needs a full terminal.[/]"
|
||||
)
|
||||
console.print(
|
||||
f"[{DIM}]Type [bold]/{command_str}[/bold] directly in this shell to launch it.[/]"
|
||||
)
|
||||
|
||||
|
||||
def run_opensre_cli_command(
|
||||
args: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
) -> bool:
|
||||
presenter = make_repl_presenter(
|
||||
session,
|
||||
console,
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
action_already_listed=True,
|
||||
)
|
||||
return _run_opensre_cli_command(args, presenter)
|
||||
|
||||
|
||||
def run_opensre_cli_command_result(
|
||||
args: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
) -> OpensreRunResult:
|
||||
presenter = make_repl_presenter(
|
||||
session,
|
||||
console,
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
action_already_listed=True,
|
||||
)
|
||||
return _run_opensre_cli_command_result(args, presenter)
|
||||
|
||||
|
||||
# Foreground helpers kept for monkeypatch tests that patch subprocess_runner paths.
|
||||
def _run_opensre_foreground(
|
||||
argv_list: list[str],
|
||||
display_command: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
) -> None:
|
||||
presenter = make_repl_presenter(session, console, action_already_listed=True)
|
||||
_run_foreground_via_presenter(
|
||||
presenter,
|
||||
argv_list=argv_list,
|
||||
display_command=display_command,
|
||||
)
|
||||
|
||||
|
||||
def _run_opensre_foreground_streaming(
|
||||
argv_list: list[str],
|
||||
display_command: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
) -> None:
|
||||
presenter = make_repl_presenter(session, console, action_already_listed=True)
|
||||
_run_streaming_via_presenter(
|
||||
presenter,
|
||||
argv_list=argv_list,
|
||||
display_command=display_command,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OpensreCommandClass",
|
||||
"OpensreExecutionMode",
|
||||
"OpensreExecutionPlan",
|
||||
"OpensreRunOutcome",
|
||||
"OpensreRunResult",
|
||||
"_INTERACTIVE_OPENSRE_COMMAND_PATHS",
|
||||
"_OPENSRE_BLOCKED_SUBCOMMANDS",
|
||||
"_build_opensre_execution_plan",
|
||||
"_classify_opensre_command",
|
||||
"_is_interactive_wizard",
|
||||
"_opensre_confirmation_reason",
|
||||
"_run_opensre_foreground",
|
||||
"_run_opensre_foreground_streaming",
|
||||
"build_opensre_cli_argv",
|
||||
"interactive_wizard_handoff_response_text",
|
||||
"print_interactive_wizard_handoff",
|
||||
"run_opensre_cli_command",
|
||||
"run_opensre_cli_command_result",
|
||||
]
|
||||
@@ -0,0 +1,226 @@
|
||||
"""REPL subprocess presenter — Rich UI + session hooks for action tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.text import Text
|
||||
|
||||
from platform.common.task_types import TaskKind
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING, print_command_output
|
||||
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
|
||||
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
|
||||
from tools.interactive_shell.shared import ExecutionPolicyResult
|
||||
from tools.interactive_shell.subprocess import SubprocessPresenter, subprocess_env_with_width
|
||||
|
||||
from .background_task_executor import (
|
||||
start_background_cli_task as _start_background_cli_task_default,
|
||||
)
|
||||
from .task_streaming import (
|
||||
_join_task_output_streams,
|
||||
_sr_resolve,
|
||||
_start_task_output_streams,
|
||||
)
|
||||
|
||||
_MARKUP_STYLE_ALIASES: dict[str, str] = {
|
||||
"error": str(ERROR),
|
||||
"dim": str(DIM),
|
||||
"highlight": str(HIGHLIGHT),
|
||||
"warning": str(WARNING),
|
||||
}
|
||||
|
||||
# Intentional Rich markup tags used by subprocess presenters and action tools.
|
||||
_ALLOWED_MARKUP_TAG = re.compile(
|
||||
r"(\["
|
||||
r"(?:"
|
||||
r"#[0-9A-Fa-f]{6}|"
|
||||
r"bold|dim|highlight|warning|error|"
|
||||
r"/(?:bold|dim|highlight|warning|error)?"
|
||||
r")"
|
||||
r"\])"
|
||||
)
|
||||
_MARKUP_HINT = re.compile(r"\[(?:/?(?:error|dim|highlight|warning|bold)|/)\]")
|
||||
|
||||
|
||||
def _expand_markup_aliases(message: str) -> str:
|
||||
for alias, token in _MARKUP_STYLE_ALIASES.items():
|
||||
message = message.replace(f"[{alias}]", f"[{token}]")
|
||||
message = message.replace(f"[/{alias}]", f"[/{token}]")
|
||||
return message
|
||||
|
||||
|
||||
def _message_uses_intentional_markup(message: str) -> bool:
|
||||
if _MARKUP_HINT.search(message):
|
||||
return True
|
||||
return any(
|
||||
f"[{token}]" in message or f"[/{token}]" in message
|
||||
for token in _MARKUP_STYLE_ALIASES.values()
|
||||
)
|
||||
|
||||
|
||||
def _escape_markup_message(message: str) -> str:
|
||||
"""Escape plain-text segments while preserving intentional Rich markup tags."""
|
||||
expanded = _expand_markup_aliases(message)
|
||||
if not _message_uses_intentional_markup(expanded):
|
||||
return escape(expanded)
|
||||
parts = _ALLOWED_MARKUP_TAG.split(expanded)
|
||||
if len(parts) == 1:
|
||||
return escape(expanded)
|
||||
rendered: list[str] = []
|
||||
for index, part in enumerate(parts):
|
||||
if index % 2 == 1:
|
||||
rendered.append(part)
|
||||
elif part:
|
||||
rendered.append(escape(part))
|
||||
return "".join(rendered)
|
||||
|
||||
|
||||
class ReplSubprocessPresenter:
|
||||
"""Surface implementation of :class:`SubprocessPresenter` for the interactive REPL."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
action_already_listed: bool = False,
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._console = console
|
||||
self._confirm_fn = confirm_fn
|
||||
self._is_tty = is_tty
|
||||
self._action_already_listed = action_already_listed
|
||||
|
||||
@property
|
||||
def session(self) -> Session:
|
||||
return self._session
|
||||
|
||||
@property
|
||||
def console(self) -> Console:
|
||||
return self._console
|
||||
|
||||
def execution_allowed(
|
||||
self,
|
||||
policy: ExecutionPolicyResult,
|
||||
*,
|
||||
action_summary: str,
|
||||
) -> bool:
|
||||
return execution_allowed(
|
||||
policy,
|
||||
session=self._session,
|
||||
console=self._console,
|
||||
action_summary=action_summary,
|
||||
confirm_fn=self._confirm_fn,
|
||||
is_tty=self._is_tty,
|
||||
action_already_listed=self._action_already_listed,
|
||||
)
|
||||
|
||||
def print(self, message: str = "") -> None:
|
||||
self._console.print(_escape_markup_message(message))
|
||||
|
||||
def print_bold_command(self, display_command: str) -> None:
|
||||
self._console.print(f"[bold]$ {escape(display_command)}[/bold]")
|
||||
|
||||
def print_command_output(self, text: str, *, style: str | None = None) -> None:
|
||||
resolved: str | None
|
||||
if style in _MARKUP_STYLE_ALIASES:
|
||||
resolved = _MARKUP_STYLE_ALIASES[style]
|
||||
elif style is None:
|
||||
resolved = None
|
||||
else:
|
||||
resolved = style
|
||||
print_command_output(self._console, text, style=resolved)
|
||||
|
||||
def print_plain(self, text: str) -> None:
|
||||
self._console.print(Text(text))
|
||||
|
||||
def report_exception(self, exc: BaseException, *, context: str) -> None:
|
||||
report_exception(exc, context=context)
|
||||
|
||||
def subprocess_env(self) -> dict[str, str]:
|
||||
return subprocess_env_with_width(
|
||||
columns=self._console.size.width or 80,
|
||||
lines=self._console.size.height,
|
||||
)
|
||||
|
||||
def start_task_output_streams(
|
||||
self,
|
||||
*,
|
||||
task: Any,
|
||||
proc: subprocess.Popen[Any],
|
||||
stdout_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
|
||||
stderr_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
|
||||
) -> list[threading.Thread]:
|
||||
return _start_task_output_streams(
|
||||
task=task,
|
||||
proc=proc,
|
||||
console=self._console,
|
||||
stdout_capture=stdout_capture,
|
||||
stderr_capture=stderr_capture,
|
||||
)
|
||||
|
||||
def join_task_output_streams(self, threads: list[threading.Thread]) -> None:
|
||||
_join_task_output_streams(threads)
|
||||
|
||||
def start_background_cli_task(
|
||||
self,
|
||||
*,
|
||||
display_command: str,
|
||||
argv_list: list[str],
|
||||
timeout_seconds: int,
|
||||
kind: TaskKind = TaskKind.CLI_COMMAND,
|
||||
use_pty: bool = False,
|
||||
) -> Any:
|
||||
starter = _sr_resolve("start_background_cli_task", _start_background_cli_task_default)
|
||||
return starter(
|
||||
display_command=display_command,
|
||||
argv_list=argv_list,
|
||||
session=self._session,
|
||||
console=self._console,
|
||||
timeout_seconds=timeout_seconds,
|
||||
kind=kind,
|
||||
use_pty=use_pty,
|
||||
)
|
||||
|
||||
def print_error(self, message: str) -> None:
|
||||
self._console.print(f"[{ERROR}]{escape(message)}[/]")
|
||||
|
||||
def print_dim(self, message: str) -> None:
|
||||
self._console.print(f"[{DIM}]{escape(message)}[/]")
|
||||
|
||||
def print_highlight(self, message: str) -> None:
|
||||
self._console.print(f"[{HIGHLIGHT}]{escape(message)}[/]")
|
||||
|
||||
def print_warning(self, message: str) -> None:
|
||||
self._console.print(f"[{WARNING}]{escape(message)}[/]")
|
||||
|
||||
|
||||
def make_repl_presenter(
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
action_already_listed: bool = False,
|
||||
) -> SubprocessPresenter:
|
||||
"""Construct a :class:`ReplSubprocessPresenter` for runners and tests."""
|
||||
return ReplSubprocessPresenter(
|
||||
session,
|
||||
console,
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
action_already_listed=action_already_listed,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ReplSubprocessPresenter", "make_repl_presenter"]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Shared subprocess-streaming primitives, PTY helpers, and module-wide constants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import errno
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from typing import IO, Any
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.text import Text
|
||||
|
||||
from platform.common.task_types import TaskRecord
|
||||
from surfaces.interactive_shell.ui import DIM, ERROR
|
||||
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
|
||||
from tools.interactive_shell.subprocess import (
|
||||
CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS,
|
||||
MAX_COMMAND_OUTPUT_CHARS,
|
||||
MIN_SUBPROCESS_TERMINAL_WIDTH,
|
||||
SHELL_COMMAND_TIMEOUT_SECONDS,
|
||||
SYNTHETIC_DIAG_CHARS,
|
||||
SYNTHETIC_POLL_SECONDS,
|
||||
SYNTHETIC_TEST_TIMEOUT_SECONDS,
|
||||
TASK_OUTPUT_JOIN_TIMEOUT_SECONDS,
|
||||
TASK_OUTPUT_PREFIX_WIDTH,
|
||||
read_diag,
|
||||
read_task_output,
|
||||
subprocess_env_with_width,
|
||||
terminate_child_process,
|
||||
)
|
||||
|
||||
# Full dotted name of the ``subprocess_runner`` package. Submodules use this to
|
||||
# look up patchable names from the parent namespace at call time so that tests
|
||||
# using ``monkeypatch.setattr("…subprocess_runner.X", fake)`` take effect even
|
||||
# when the implementation lives in a submodule.
|
||||
_SUBPROCESS_RUNNER_MODULE = "surfaces.interactive_shell.runtime.subprocess_runner"
|
||||
|
||||
# Backward-compatible aliases for tests and callers using underscore-prefixed names.
|
||||
_MAX_COMMAND_OUTPUT_CHARS = MAX_COMMAND_OUTPUT_CHARS
|
||||
_SYNTHETIC_POLL_SECONDS = SYNTHETIC_POLL_SECONDS
|
||||
_SYNTHETIC_DIAG_CHARS = SYNTHETIC_DIAG_CHARS
|
||||
_MIN_SUBPROCESS_TERMINAL_WIDTH = MIN_SUBPROCESS_TERMINAL_WIDTH
|
||||
_TASK_OUTPUT_PREFIX_WIDTH = TASK_OUTPUT_PREFIX_WIDTH
|
||||
_TASK_OUTPUT_JOIN_TIMEOUT_SECONDS = TASK_OUTPUT_JOIN_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def _sr_resolve(name: str, default: Any) -> Any:
|
||||
"""Return ``subprocess_runner.<name>`` if the package is loaded, else ``default``.
|
||||
|
||||
Used by submodules to honour monkeypatches applied to the parent package
|
||||
namespace (e.g. ``monkeypatch.setattr("…subprocess_runner.read_diag", …)``).
|
||||
"""
|
||||
sr = sys.modules.get(_SUBPROCESS_RUNNER_MODULE)
|
||||
return getattr(sr, name, default) if sr is not None else default
|
||||
|
||||
|
||||
def _print_task_output_line(
|
||||
console: Console,
|
||||
task: TaskRecord,
|
||||
stream_name: str,
|
||||
line: str,
|
||||
*,
|
||||
style: str | None = None,
|
||||
) -> None:
|
||||
text = Text()
|
||||
text.append(f"{task.task_id} {stream_name} │ ", style=DIM)
|
||||
text.append(line.rstrip("\r\n"), style=style)
|
||||
console.print(text)
|
||||
|
||||
|
||||
def _subprocess_env_with_aligned_width(console: Console) -> dict[str, str]:
|
||||
"""Return ``os.environ`` patched so a piped Rich subprocess wraps to fit."""
|
||||
user_width = console.size.width or _MIN_SUBPROCESS_TERMINAL_WIDTH + _TASK_OUTPUT_PREFIX_WIDTH
|
||||
return subprocess_env_with_width(
|
||||
columns=user_width,
|
||||
lines=console.size.height,
|
||||
)
|
||||
|
||||
|
||||
def _pump_task_stream(
|
||||
*,
|
||||
task: TaskRecord,
|
||||
stream_name: str,
|
||||
stream: IO[str],
|
||||
console: Console,
|
||||
style: str | None = None,
|
||||
capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
|
||||
) -> None:
|
||||
try:
|
||||
for line in stream:
|
||||
if capture is not None:
|
||||
capture.write(line.encode("utf-8", errors="replace"))
|
||||
if line.strip():
|
||||
_print_task_output_line(console, task, stream_name, line, style=style)
|
||||
task.update_progress(line)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
report_exception(exc, context=f"surfaces.interactive_shell.task_stream.{stream_name}")
|
||||
console.print(f"[{DIM}]task output stream ended unexpectedly:[/] {escape(str(exc))}")
|
||||
|
||||
|
||||
def _start_task_output_streams(
|
||||
*,
|
||||
task: TaskRecord,
|
||||
proc: subprocess.Popen[Any],
|
||||
console: Console,
|
||||
stdout_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
|
||||
stderr_capture: tempfile.SpooledTemporaryFile[bytes] | None = None, # type: ignore[type-arg]
|
||||
) -> list[threading.Thread]:
|
||||
threads: list[threading.Thread] = []
|
||||
streams: tuple[tuple[str, IO[str] | None, str | None, Any], ...] = (
|
||||
("stdout", proc.stdout, None, stdout_capture),
|
||||
("stderr", proc.stderr, ERROR, stderr_capture),
|
||||
)
|
||||
for stream_name, stream, style, capture in streams:
|
||||
if stream is None:
|
||||
continue
|
||||
thread = threading.Thread(
|
||||
target=_pump_task_stream,
|
||||
kwargs={
|
||||
"task": task,
|
||||
"stream_name": stream_name,
|
||||
"stream": stream,
|
||||
"console": console,
|
||||
"style": style,
|
||||
"capture": capture,
|
||||
},
|
||||
daemon=True,
|
||||
name=f"task-output-{task.task_id}-{stream_name}",
|
||||
)
|
||||
thread.start()
|
||||
threads.append(thread)
|
||||
return threads
|
||||
|
||||
|
||||
def _join_task_output_streams(threads: list[threading.Thread]) -> None:
|
||||
for thread in threads:
|
||||
thread.join(timeout=TASK_OUTPUT_JOIN_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
def _console_file_is_tty(console: Console) -> bool:
|
||||
isatty = getattr(console.file, "isatty", None)
|
||||
return bool(isatty and isatty())
|
||||
|
||||
|
||||
def _should_use_pty(console: Console, requested: bool) -> bool:
|
||||
return requested and hasattr(os, "openpty") and _console_file_is_tty(console)
|
||||
|
||||
|
||||
def _pump_task_pty(
|
||||
*,
|
||||
master_fd: int,
|
||||
console: Console,
|
||||
capture: tempfile.SpooledTemporaryFile[bytes], # type: ignore[type-arg]
|
||||
) -> None:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
chunk = os.read(master_fd, 4096)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.EIO:
|
||||
break
|
||||
raise
|
||||
if not chunk:
|
||||
break
|
||||
capture.write(chunk)
|
||||
console.file.write(chunk.decode("utf-8", errors="replace"))
|
||||
console.file.flush()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
report_exception(exc, context="surfaces.interactive_shell.task_pty_stream")
|
||||
console.print(f"[{DIM}]task terminal stream ended unexpectedly:[/] {escape(str(exc))}")
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
os.close(master_fd)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SHELL_COMMAND_TIMEOUT_SECONDS",
|
||||
"SYNTHETIC_TEST_TIMEOUT_SECONDS",
|
||||
"CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS",
|
||||
"_SYNTHETIC_POLL_SECONDS",
|
||||
"_MAX_COMMAND_OUTPUT_CHARS",
|
||||
"_SYNTHETIC_DIAG_CHARS",
|
||||
"_TASK_OUTPUT_PREFIX_WIDTH",
|
||||
"_MIN_SUBPROCESS_TERMINAL_WIDTH",
|
||||
"_TASK_OUTPUT_JOIN_TIMEOUT_SECONDS",
|
||||
"terminate_child_process",
|
||||
"read_diag",
|
||||
"read_task_output",
|
||||
"_print_task_output_line",
|
||||
"_subprocess_env_with_aligned_width",
|
||||
"_pump_task_stream",
|
||||
"_start_task_output_streams",
|
||||
"_join_task_output_streams",
|
||||
"_console_file_is_tty",
|
||||
"_should_use_pty",
|
||||
"_pump_task_pty",
|
||||
]
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Runtime turn host for submitted interactive-shell prompts.
|
||||
|
||||
Three public runtime functions live here:
|
||||
|
||||
- ``run_agent_turn`` — set up shell presentation for one submitted turn and drive
|
||||
its lifecycle (the injected ``run_turn`` callable for the queue).
|
||||
- ``run_input_loop`` — read prompt input events and dispatch them until exit.
|
||||
- ``run_agent_turn_queue`` — consume queued turns and run each one until exit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from platform.analytics.repl_context import bound_repl_turn_context
|
||||
from platform.observability.trace.spans import bind_session_trace, emit_thread_boundary
|
||||
from surfaces.interactive_shell.runtime.agent_presentation import (
|
||||
AgentEvent,
|
||||
AgentEventSink,
|
||||
ConsoleAgentEventSink,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.background.workers import BackgroundTaskManager
|
||||
from surfaces.interactive_shell.runtime.core.confirmation import (
|
||||
DispatchCancelled,
|
||||
request_confirmation_via_prompt,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.core.state import ReplState, SpinnerState
|
||||
from surfaces.interactive_shell.runtime.input import PromptInputReader
|
||||
from surfaces.interactive_shell.runtime.input.actions import (
|
||||
InputAction,
|
||||
ShellInputSnapshot,
|
||||
decide_input_action,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.utils.input_policy import (
|
||||
turn_needs_exclusive_stdin,
|
||||
)
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui.output.console_state import set_investigation_spinner
|
||||
from surfaces.interactive_shell.ui.output.repl_progress import repl_safe_progress_scope
|
||||
from surfaces.interactive_shell.ui.streaming.console import StreamingConsole
|
||||
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
|
||||
from surfaces.interactive_shell.utils.telemetry import PromptRecorder
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_AGENT_TURN_KIND = "agent"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentTurnRuntime:
|
||||
"""Immutable dependencies for running one submitted shell turn."""
|
||||
|
||||
session: Session
|
||||
state: ReplState
|
||||
spinner: SpinnerState
|
||||
invalidate_prompt: Callable[[], None]
|
||||
request_exit: Callable[[], None] | None = None
|
||||
|
||||
|
||||
async def run_agent_turn(runtime: AgentTurnRuntime, text: str) -> None:
|
||||
"""Set up shell presentation for one turn and drive its lifecycle."""
|
||||
dispatch_cancel = threading.Event()
|
||||
console = StreamingConsole(
|
||||
runtime.spinner,
|
||||
dispatch_cancel,
|
||||
highlight=False,
|
||||
force_terminal=True,
|
||||
color_system="truecolor",
|
||||
legacy_windows=False,
|
||||
)
|
||||
emit = ConsoleAgentEventSink(
|
||||
session=runtime.session,
|
||||
spinner=runtime.spinner,
|
||||
console=console,
|
||||
)
|
||||
recorder = PromptRecorder.start(
|
||||
session=runtime.session,
|
||||
text=text,
|
||||
turn_kind=_AGENT_TURN_KIND,
|
||||
)
|
||||
exclusive_stdin = turn_needs_exclusive_stdin(text, runtime.session)
|
||||
progress_scope = contextlib.nullcontext() if exclusive_stdin else repl_safe_progress_scope()
|
||||
runtime.session.terminal.exclusive_stdin_active = exclusive_stdin
|
||||
# Expose this turn's spinner so investigation stages can animate phase labels.
|
||||
set_investigation_spinner(runtime.spinner)
|
||||
emit_thread_boundary(
|
||||
runtime.session.session_id,
|
||||
name="turn_boundary",
|
||||
phase="turn_start",
|
||||
)
|
||||
try:
|
||||
with (
|
||||
bind_session_trace(runtime.session.session_id),
|
||||
progress_scope,
|
||||
):
|
||||
await _run_agent_turn_loop(
|
||||
runtime=runtime,
|
||||
text=text,
|
||||
output=console,
|
||||
recorder=recorder,
|
||||
confirm=lambda prompt: request_confirmation_via_prompt(runtime.state, prompt),
|
||||
emit=emit,
|
||||
dispatch_cancel=dispatch_cancel,
|
||||
)
|
||||
finally:
|
||||
set_investigation_spinner(None)
|
||||
runtime.session.terminal.exclusive_stdin_active = False
|
||||
emit_thread_boundary(
|
||||
runtime.session.session_id,
|
||||
name="turn_boundary",
|
||||
phase="turn_end",
|
||||
)
|
||||
|
||||
|
||||
async def _run_agent_turn_loop(
|
||||
*,
|
||||
runtime: AgentTurnRuntime,
|
||||
text: str,
|
||||
output: StreamingConsole,
|
||||
recorder: PromptRecorder | None,
|
||||
confirm: Callable[[str], str],
|
||||
emit: AgentEventSink,
|
||||
dispatch_cancel: threading.Event,
|
||||
) -> None:
|
||||
current_task = asyncio.current_task()
|
||||
if current_task is not None:
|
||||
runtime.state.start_dispatch(task=current_task, cancel_event=dispatch_cancel)
|
||||
else:
|
||||
runtime.state.attach_cancel_event(dispatch_cancel)
|
||||
|
||||
await emit(AgentEvent(type="turn_start", text=text))
|
||||
try:
|
||||
# Imported lazily so constructing the controller (and importing this
|
||||
# module) does not pull the harness/turn-execution stack
|
||||
# (``action_agent -> core.agent``) before the first turn is queued.
|
||||
from surfaces.interactive_shell.runtime.shell_turn_execution import execute_shell_turn
|
||||
|
||||
with bound_repl_turn_context(
|
||||
session_id=runtime.session.session_id,
|
||||
turn_kind=_AGENT_TURN_KIND,
|
||||
prompt_turn_id=recorder.turn_id if recorder is not None else None,
|
||||
):
|
||||
await asyncio.to_thread(
|
||||
execute_shell_turn,
|
||||
text,
|
||||
runtime.session,
|
||||
output,
|
||||
recorder=recorder,
|
||||
confirm_fn=confirm,
|
||||
is_tty=None,
|
||||
request_exit=runtime.request_exit,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await emit(AgentEvent(type="turn_interrupted"))
|
||||
raise
|
||||
except DispatchCancelled:
|
||||
await emit(AgentEvent(type="turn_interrupted"))
|
||||
except Exception as exc:
|
||||
report_exception(exc, context="surfaces.interactive_shell.turn")
|
||||
await emit(AgentEvent(type="turn_error", error=exc))
|
||||
finally:
|
||||
runtime.state.finish_dispatch(dispatch_cancel)
|
||||
await emit(AgentEvent(type="turn_end"))
|
||||
|
||||
|
||||
async def run_input_loop(
|
||||
*,
|
||||
state: ReplState,
|
||||
session: Session,
|
||||
background: BackgroundTaskManager | None,
|
||||
input_reader: PromptInputReader,
|
||||
echo_console: Console,
|
||||
handle_input_action: Callable[[InputAction], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Run the interactive session's main input loop until exit or close.
|
||||
|
||||
This loop reads input; it does not run agent turns itself. Each raw input
|
||||
event is classified into an ``InputAction`` by ``decide_input_action`` and
|
||||
handed to ``handle_input_action``. For a submitted prompt that handler pushes
|
||||
the text onto ``state.queue``; the queued text is then consumed
|
||||
asynchronously by ``run_agent_turn_queue`` (started in the controller's
|
||||
``_start_runtime_services``), which runs each turn via ``run_agent_turn``.
|
||||
|
||||
Keeping input reading and turn execution as two separate loops joined only by
|
||||
``state.queue`` is deliberate: it lets the user keep typing, cancel, or
|
||||
answer a confirmation while a turn is still in flight.
|
||||
"""
|
||||
while not state.exit_requested:
|
||||
if background is not None:
|
||||
background.drain_turn_start_output(echo_console)
|
||||
event = await input_reader.read()
|
||||
action = decide_input_action(
|
||||
event,
|
||||
ShellInputSnapshot(
|
||||
exit_requested=state.exit_requested,
|
||||
dispatch_running=state.is_dispatch_running(),
|
||||
awaiting_confirmation=state.is_awaiting_confirmation(),
|
||||
),
|
||||
needs_exclusive_stdin=lambda text: turn_needs_exclusive_stdin(
|
||||
text,
|
||||
session,
|
||||
),
|
||||
)
|
||||
should_continue = await handle_input_action(action)
|
||||
if not should_continue:
|
||||
return
|
||||
|
||||
|
||||
async def run_agent_turn_queue(
|
||||
*,
|
||||
state: ReplState,
|
||||
run_turn: Callable[[str], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
"""Consume queued turns and run each one until exit."""
|
||||
while not state.exit_requested:
|
||||
try:
|
||||
text = await state.queue.get()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if state.exit_requested:
|
||||
state.queue.task_done()
|
||||
return
|
||||
|
||||
turn_task = asyncio.create_task(run_turn(text))
|
||||
state.attach_turn_task(turn_task)
|
||||
try:
|
||||
await turn_task
|
||||
except asyncio.CancelledError:
|
||||
_logger.debug("Queued turn task was cancelled")
|
||||
except Exception as exc:
|
||||
_logger.debug("Queued turn task ended with exception: %s", exc)
|
||||
finally:
|
||||
state.clear_current_task()
|
||||
state.queue.task_done()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentTurnRuntime",
|
||||
"run_agent_turn",
|
||||
"run_agent_turn_queue",
|
||||
"run_input_loop",
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Injection contracts for the interactive-shell turn seams.
|
||||
|
||||
These protocols describe exactly what ``execute_shell_turn`` requires from the
|
||||
action / gather / answer adapters it composes, so an injected test double is
|
||||
checked at type-time rather than at runtime. The default adapters
|
||||
(``action_turn``, ``answer_turn``, ``integration_tool_gathering``) satisfy them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Protocol, TypedDict
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.agent_harness.ports import OutputSink
|
||||
from core.agent_harness.turns.turn_plan import TurnPlan
|
||||
from core.agent_harness.turns.turn_results import ToolCallingTurnResult
|
||||
from core.execution import ToolExecutionHooks
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.utils.telemetry import LlmRunInfo
|
||||
|
||||
|
||||
class RunActionToolTurn(Protocol):
|
||||
"""Action-selection seam driven by ``execute_shell_turn``.
|
||||
|
||||
``deps`` is intentionally not part of the contract: ``execute_shell_turn``
|
||||
never injects it, and the default adapter supplies its own LLM factory.
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
message: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
request_exit: Callable[[], None] | None = None,
|
||||
turn_plan: TurnPlan | None = None,
|
||||
output: OutputSink | None = None,
|
||||
tool_hooks: ToolExecutionHooks | None = None,
|
||||
) -> ToolCallingTurnResult:
|
||||
"""Run one action turn and return its facts."""
|
||||
|
||||
|
||||
class GatherEvidence(Protocol):
|
||||
"""Gather seam: collect read-only integration evidence, or None."""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
message: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
is_tty: bool | None = None,
|
||||
resolved_integrations: dict[str, Any] | None = None,
|
||||
) -> str | None:
|
||||
"""Gather evidence for the message, or return None when nothing applies."""
|
||||
|
||||
|
||||
class AnswerKwargs(TypedDict, total=False):
|
||||
"""Keyword args ``run_turn`` forwards to the answer seam (all optional).
|
||||
|
||||
``total=False`` mirrors ``run_turn`` omitting ``tool_observation_on_screen``
|
||||
on the plain (no-evidence) path.
|
||||
"""
|
||||
|
||||
confirm_fn: Callable[[str], str] | None
|
||||
is_tty: bool | None
|
||||
tool_observation: str | None
|
||||
tool_observation_on_screen: bool
|
||||
handoff_contents: tuple[str, ...]
|
||||
turn_plan: TurnPlan | None
|
||||
|
||||
|
||||
class AnswerShellQuestion(Protocol):
|
||||
"""Answer seam: respond via the grounded conversational assistant."""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
message: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
tool_observation: str | None = None,
|
||||
tool_observation_on_screen: bool = True,
|
||||
handoff_contents: tuple[str, ...] = (),
|
||||
turn_plan: TurnPlan | None = None,
|
||||
output: OutputSink | None = None,
|
||||
) -> LlmRunInfo | None:
|
||||
"""Answer the question, returning the LLM run info or None."""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AnswerKwargs",
|
||||
"AnswerShellQuestion",
|
||||
"GatherEvidence",
|
||||
"RunActionToolTurn",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
"""Runtime utility helpers for interactive shell orchestration."""
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Prompt input and stdin coordination policy for runtime turns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import repl_tty_interactive
|
||||
|
||||
|
||||
def _literal_slash_command_text(text: str) -> str | None:
|
||||
"""Return literal ``/slash`` command text for command-shaped input, else ``None``.
|
||||
|
||||
Terminal-UI policy only (spinner suppression and exclusive-stdin gating). The
|
||||
matching execution-side deterministic dispatch lives separately in
|
||||
``core/agent_harness/turns/action_driver.py``; keep this function UI-only and do not
|
||||
grow natural-language intent inference here.
|
||||
"""
|
||||
stripped = text.strip()
|
||||
return stripped if stripped.startswith("/") else None
|
||||
|
||||
|
||||
_EXCLUSIVE_STDIN_MENU_COMMANDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"/history",
|
||||
"/auth",
|
||||
"/help",
|
||||
"/integrations",
|
||||
"/investigate",
|
||||
"/mcp",
|
||||
"/model",
|
||||
"/tools",
|
||||
"/template",
|
||||
"/trust",
|
||||
"/verbose",
|
||||
"/?",
|
||||
# Table-outputting commands must complete before the next prompt_async()
|
||||
# starts, otherwise patch_stdout redraws trigger ESC[6n DSR queries whose
|
||||
# CPR responses land as literal keystrokes in the incoming prompt buffer.
|
||||
"/doctor",
|
||||
"/version",
|
||||
"/verify",
|
||||
"/status",
|
||||
"/cost",
|
||||
"/tasks",
|
||||
"/watches",
|
||||
"/alerts",
|
||||
"/privacy",
|
||||
"/context",
|
||||
"/fleet",
|
||||
"/compact",
|
||||
"/welcome",
|
||||
"/sessions",
|
||||
"/resume",
|
||||
"/new",
|
||||
"/rca",
|
||||
}
|
||||
)
|
||||
_EXCLUSIVE_STDIN_SUBCOMMANDS: frozenset[tuple[str, str]] = frozenset(
|
||||
{
|
||||
("/integrations", "setup"),
|
||||
# ``remove`` drives a native inline arrow-key picker (raw os.read on
|
||||
# stdin). Without exclusive stdin the concurrent prompt_async() steals
|
||||
# keystrokes and CPR responses leak into the next prompt buffer.
|
||||
("/integrations", "remove"),
|
||||
("/mcp", "connect"),
|
||||
("/mcp", "disconnect"),
|
||||
("/rca", "history"),
|
||||
("/rca", "list"),
|
||||
("/rca", "ls"),
|
||||
("/rca", "show"),
|
||||
("/rca", "save"),
|
||||
}
|
||||
)
|
||||
_WAIT_FOR_COMPLETION_COMMANDS: frozenset[str] = frozenset(
|
||||
{"/exit", "/quit", "/update", "/onboard", "/config", "/auth", "/login"}
|
||||
)
|
||||
|
||||
|
||||
def turn_should_show_spinner(text: str, _session: Session) -> bool:
|
||||
# UI-only: suppress the "thinking" spinner for literal slash commands, which
|
||||
# dispatch deterministically (no LLM) and would otherwise show a misleading
|
||||
# spinner. Natural-language turns still go through the action-agent LLM.
|
||||
return _literal_slash_command_text(text.strip()) is None
|
||||
|
||||
|
||||
def turn_needs_exclusive_stdin(text: str, _session: Session) -> bool:
|
||||
if not repl_tty_interactive():
|
||||
return False
|
||||
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return False
|
||||
|
||||
# Reserve stdin early for literal command-shaped input, but do not dispatch
|
||||
# here. This stays UI-only; deterministic slash execution lives in the turn
|
||||
# engine (core/agent_harness/turns/action_driver.py), not in this gating layer.
|
||||
dispatch_text = _literal_slash_command_text(t)
|
||||
if dispatch_text is None:
|
||||
return False
|
||||
|
||||
parts = dispatch_text.split()
|
||||
if not parts:
|
||||
return False
|
||||
name = parts[0].lower()
|
||||
args = [arg.lower() for arg in parts[1:]]
|
||||
|
||||
if name in _WAIT_FOR_COMPLETION_COMMANDS:
|
||||
return True
|
||||
if name == "/theme":
|
||||
return True
|
||||
if name in _EXCLUSIVE_STDIN_MENU_COMMANDS and not args:
|
||||
return True
|
||||
if name == "/tests" and not args:
|
||||
return True
|
||||
return bool(args and (name, args[0]) in _EXCLUSIVE_STDIN_SUBCOMMANDS)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"turn_needs_exclusive_stdin",
|
||||
"turn_should_show_spinner",
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Interactive-shell session: the ``Session`` subclass and its UI facets.
|
||||
|
||||
The shell-only half of the session, layered on
|
||||
:class:`~core.agent_harness.session.session_core.SessionCore`: the ``terminal``
|
||||
facet (theme, prompt-toolkit, background jobs, metrics) and the ``alerts`` inbox.
|
||||
Core, gateway, and headless surfaces use ``SessionCore`` directly and never import
|
||||
this package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from surfaces.interactive_shell.session.alert_inbox import SessionAlertInbox
|
||||
from surfaces.interactive_shell.session.background_investigations import (
|
||||
BackgroundInvestigationRecord,
|
||||
BackgroundNotificationPreferences,
|
||||
)
|
||||
from surfaces.interactive_shell.session.session import Session
|
||||
from surfaces.interactive_shell.session.terminal_metrics import (
|
||||
InterventionKind,
|
||||
TerminalMetrics,
|
||||
TerminalMetricsSnapshot,
|
||||
)
|
||||
from surfaces.interactive_shell.session.terminal_session import TerminalSession
|
||||
|
||||
__all__ = [
|
||||
"BackgroundInvestigationRecord",
|
||||
"BackgroundNotificationPreferences",
|
||||
"InterventionKind",
|
||||
"Session",
|
||||
"SessionAlertInbox",
|
||||
"TerminalMetrics",
|
||||
"TerminalMetricsSnapshot",
|
||||
"TerminalSession",
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""The session's inbox of externally-received alerts.
|
||||
|
||||
A surface facet composed onto :class:`~surfaces.interactive_shell.session.session.Session`:
|
||||
the interactive shell's alert listener appends externally-received alerts here, and
|
||||
``/status`` reads them. Kept out of the core session so consumers that never touch
|
||||
alerts don't see the field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from core.domain.alerts.inbox import IncomingAlert
|
||||
|
||||
# Bounded buffer so the alert listener can't grow memory unbounded — keeps the last
|
||||
# few hundred alerts for /status. A round default (not tuned); preserved from the
|
||||
# original ``_INCOMING_ALERTS_MAX``.
|
||||
_DEFAULT_MAX = 256
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionAlertInbox:
|
||||
"""Bounded FIFO of received alerts shown in ``/status`` (oldest dropped past the cap)."""
|
||||
|
||||
entries: list[IncomingAlert] = field(default_factory=list)
|
||||
_max: int = _DEFAULT_MAX
|
||||
|
||||
def add(self, alert: IncomingAlert) -> None:
|
||||
"""Append an alert, dropping the oldest once the cap is exceeded."""
|
||||
self.entries.append(alert)
|
||||
if len(self.entries) > self._max:
|
||||
del self.entries[0]
|
||||
|
||||
@property
|
||||
def most_recent(self) -> IncomingAlert | None:
|
||||
"""The newest alert, or None when the inbox is empty."""
|
||||
return self.entries[-1] if self.entries else None
|
||||
|
||||
def clear(self) -> None:
|
||||
self.entries.clear()
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackgroundInvestigationRecord:
|
||||
"""One completed or in-flight background investigation tracked by the REPL."""
|
||||
|
||||
task_id: str
|
||||
status: str
|
||||
command: str
|
||||
investigation_id: str = ""
|
||||
root_cause: str = ""
|
||||
top_analysis: tuple[str, ...] = ()
|
||||
next_steps: tuple[str, ...] = ()
|
||||
stats: dict[str, Any] = field(default_factory=dict)
|
||||
final_state: dict[str, Any] = field(default_factory=dict)
|
||||
notification_results: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackgroundNotificationPreferences:
|
||||
"""Session-scoped channel preferences for background RCA completion notifications."""
|
||||
|
||||
channels: tuple[str, ...] = ()
|
||||
|
||||
def set_channels(self, values: list[str]) -> None:
|
||||
cleaned: list[str] = []
|
||||
for value in values:
|
||||
normalized = value.strip().lower()
|
||||
if normalized and normalized not in cleaned:
|
||||
cleaned.append(normalized)
|
||||
self.channels = tuple(cleaned)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Interactive-shell session: SessionCore plus terminal UI state.
|
||||
|
||||
Extends :class:`~core.agent_harness.session.session_core.SessionCore` with the
|
||||
shell-only facets (``terminal`` UI/background state and the ``alerts`` inbox) and
|
||||
the methods that drive them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from config.constants.prompts import SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST
|
||||
from core.agent_harness.session.session_core import SessionCore
|
||||
from core.domain.alerts.inbox import IncomingAlert
|
||||
from surfaces.interactive_shell.session.alert_inbox import SessionAlertInbox
|
||||
from surfaces.interactive_shell.session.terminal_session import TerminalSession
|
||||
|
||||
_SCENARIO_FLAG_RE = re.compile(r"--scenario\s+(\S+)")
|
||||
_SYNTHETIC_SCENARIO_ID_RE = re.compile(r"^\d{3}-[a-z0-9][a-z0-9-]*$")
|
||||
|
||||
|
||||
def _scenario_id_from_synthetic_label(label: str) -> str:
|
||||
"""Extract a scenario id from a synthetic command or ``suite:scenario`` label."""
|
||||
match = _SCENARIO_FLAG_RE.search(label)
|
||||
if match is not None:
|
||||
candidate = match.group(1).strip()
|
||||
return candidate if _SYNTHETIC_SCENARIO_ID_RE.fullmatch(candidate) else ""
|
||||
if ":" in label:
|
||||
candidate = label.rsplit(":", 1)[-1].strip()
|
||||
return candidate if _SYNTHETIC_SCENARIO_ID_RE.fullmatch(candidate) else ""
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session(SessionCore):
|
||||
"""Per-REPL-process session: :class:`SessionCore` plus interactive-shell state.
|
||||
|
||||
Adds the shell-only ``terminal`` facet (UI/theme/prompt-toolkit/background)
|
||||
and the ``alerts`` inbox on top of the surface-agnostic core.
|
||||
"""
|
||||
|
||||
terminal: TerminalSession = field(default_factory=TerminalSession)
|
||||
"""Interactive-shell (terminal) session facet — shell-only UI/theme/background state.
|
||||
|
||||
Always present (empty for non-shell sessions) so shell code needs no None-guard;
|
||||
``core``/``gateway``/``tools`` consumers ignore it. Holds the theme, prompt-toolkit,
|
||||
pending-prompt/stdin, background-jobs, and metrics clusters (#3690)."""
|
||||
|
||||
alerts: SessionAlertInbox = field(default_factory=SessionAlertInbox)
|
||||
"""Inbox of externally-received alerts (shell alert listener → ``/status``).
|
||||
|
||||
A surface facet: the bounded alert list + cap live on ``SessionAlertInbox`` so
|
||||
core-session consumers that never touch alerts don't see the field."""
|
||||
|
||||
def suggest_synthetic_failure_follow_up(self, *, label: str = "") -> None:
|
||||
"""Queue RCA prefill after a failed synthetic run and refresh the active prompt."""
|
||||
self.terminal.pending_prompt_default = SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST
|
||||
self.terminal.notify_prompt_changed()
|
||||
self._bind_last_synthetic_observation(_scenario_id_from_synthetic_label(label))
|
||||
self.terminal.notify_prompt_changed()
|
||||
|
||||
def _bind_last_synthetic_observation(self, scenario_id: str) -> None:
|
||||
"""Point ``last_synthetic_observation_path`` (a core field) at the run's latest.json.
|
||||
|
||||
Synthetic-run UX, so it lives on the shell session rather than the core.
|
||||
"""
|
||||
if not scenario_id:
|
||||
self.last_synthetic_observation_path = None
|
||||
return
|
||||
# Shared path constant lives in config so core and surfaces stay decoupled.
|
||||
try:
|
||||
from config.constants.paths import SYNTHETIC_SCENARIOS_DIR
|
||||
except Exception:
|
||||
self.last_synthetic_observation_path = None
|
||||
return
|
||||
latest = SYNTHETIC_SCENARIOS_DIR / "_observations" / scenario_id / "latest.json"
|
||||
for _ in range(8):
|
||||
if latest.is_file():
|
||||
self.last_synthetic_observation_path = str(latest.resolve())
|
||||
return
|
||||
time.sleep(0.06)
|
||||
self.last_synthetic_observation_path = None
|
||||
|
||||
def record_incoming_alert(self, alert: IncomingAlert) -> None:
|
||||
"""Append a full IncomingAlert with all metadata to session history.
|
||||
|
||||
Also stores the alert in the ``alerts`` inbox facet (bounded FIFO), preserving
|
||||
received_at, severity, source, and alert_name so /status displays accurate
|
||||
timestamps and future uses have complete data.
|
||||
"""
|
||||
self.history.append({"type": "incoming_alert", "text": alert.text, "ok": True})
|
||||
self.storage.append_turn(self, "incoming_alert", alert.text)
|
||||
self.alerts.add(alert)
|
||||
|
||||
def clear(self, *, rotate_identity: bool = True) -> None:
|
||||
"""Reset the session — core state plus the shell facets — for /new and /resume."""
|
||||
self.terminal.history_generation += 1
|
||||
super().clear(rotate_identity=rotate_identity)
|
||||
self.alerts.clear()
|
||||
self.terminal.metrics.reset()
|
||||
self.terminal.pending_prompt_default = None
|
||||
self.terminal.pending_prompt_autosubmit = False
|
||||
self.terminal.exclusive_stdin_active = False
|
||||
self.terminal.agent_turn_executed_slashes.clear()
|
||||
self.terminal.background_mode_enabled = False
|
||||
self.terminal.background_investigations.clear()
|
||||
# Preserve notification channel prefs across /new like trust_mode.
|
||||
# Only reset when the user explicitly changes them via /background notify.
|
||||
with self.terminal._background_notices_lock:
|
||||
self.terminal.background_notices.clear()
|
||||
# trust_mode and reasoning_effort are intentionally preserved across /new
|
||||
|
||||
def release_resources(self) -> None:
|
||||
"""Cancel background work and drop loop-owned UI references for teardown.
|
||||
|
||||
Extends :meth:`SessionCore.release_resources` (which cancels the
|
||||
integration-warm task) with the shell facet's own teardown.
|
||||
"""
|
||||
super().release_resources()
|
||||
with self.terminal._background_notices_lock:
|
||||
self.terminal.background_notices.clear()
|
||||
self.terminal.prompt_refresh_fn = None
|
||||
self.terminal.fleet_sampler_starter = None
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Per-session terminal analytics, extracted from the session state object.
|
||||
|
||||
Groups the interactive-shell turn/intervention counters into one cohesive
|
||||
accumulator so the session state class does not carry analytics fields and
|
||||
methods directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from config.strict_config import StrictConfigModel
|
||||
|
||||
InterventionKind = Literal["ctrl_c", "correction"]
|
||||
|
||||
|
||||
class TerminalMetricsSnapshot(StrictConfigModel):
|
||||
"""Immutable per-turn analytics snapshot returned from ``record_turn``.
|
||||
|
||||
Pure value; the mutable :class:`TerminalMetrics` accumulator produces one
|
||||
of these after each turn for the caller to render/emit.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
turn_index: int
|
||||
fallback_count: int
|
||||
action_success_percent: float
|
||||
fallback_rate_percent: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminalMetrics:
|
||||
"""Mutable session-level counters for interactive-shell analytics."""
|
||||
|
||||
turn_count: int = 0
|
||||
fallback_count: int = 0
|
||||
actions_executed_count: int = 0
|
||||
actions_success_count: int = 0
|
||||
ctrl_c_intervention_count: int = 0
|
||||
"""Incremented when the user Ctrl-Cs an active investigation. Bare-prompt
|
||||
Ctrl-C with no agent running is intentionally not counted."""
|
||||
correction_intervention_count: int = 0
|
||||
"""Incremented when a follow-up/new-alert message starts with a correction cue."""
|
||||
|
||||
def record_turn(
|
||||
self,
|
||||
*,
|
||||
executed_count: int,
|
||||
executed_success_count: int,
|
||||
fallback_to_llm: bool,
|
||||
) -> TerminalMetricsSnapshot:
|
||||
"""Update aggregate terminal metrics and return a stable snapshot."""
|
||||
self.turn_count += 1
|
||||
self.actions_executed_count += max(0, executed_count)
|
||||
self.actions_success_count += max(0, executed_success_count)
|
||||
if fallback_to_llm:
|
||||
self.fallback_count += 1
|
||||
action_success_percent = (
|
||||
100.0 * self.actions_success_count / self.actions_executed_count
|
||||
if self.actions_executed_count > 0
|
||||
else 0.0
|
||||
)
|
||||
fallback_rate_percent = 100.0 * self.fallback_count / self.turn_count
|
||||
return TerminalMetricsSnapshot(
|
||||
turn_index=self.turn_count,
|
||||
fallback_count=self.fallback_count,
|
||||
action_success_percent=action_success_percent,
|
||||
fallback_rate_percent=fallback_rate_percent,
|
||||
)
|
||||
|
||||
def record_intervention(self, kind: InterventionKind) -> None:
|
||||
"""Increment the per-kind intervention counter (Ctrl-C or correction)."""
|
||||
if kind == "ctrl_c":
|
||||
self.ctrl_c_intervention_count += 1
|
||||
elif kind == "correction":
|
||||
self.correction_intervention_count += 1
|
||||
else:
|
||||
raise ValueError(f"Unknown intervention kind: {kind!r}")
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Zero all counters (used by ``/new``)."""
|
||||
self.turn_count = 0
|
||||
self.fallback_count = 0
|
||||
self.actions_executed_count = 0
|
||||
self.actions_success_count = 0
|
||||
self.ctrl_c_intervention_count = 0
|
||||
self.correction_intervention_count = 0
|
||||
|
||||
|
||||
__all__ = ["InterventionKind", "TerminalMetrics", "TerminalMetricsSnapshot"]
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Interactive-shell (terminal) session facet.
|
||||
|
||||
Groups the shell-surface-only session state (prompt-toolkit, theme, background jobs,
|
||||
metrics, per-turn analytics staging) that ``core``, ``gateway``, and ``tools``
|
||||
consumers never touch. Composed onto :class:`~surfaces.interactive_shell.session.session.Session`
|
||||
as ``session.terminal`` and always present (empty for non-shell sessions), so shell
|
||||
code accesses fields without a None-guard.
|
||||
|
||||
Populated cluster-by-cluster as the #3690 split lands; theme is the first cluster.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from surfaces.interactive_shell.session.background_investigations import (
|
||||
BackgroundInvestigationRecord,
|
||||
BackgroundNotificationPreferences,
|
||||
)
|
||||
from surfaces.interactive_shell.session.terminal_metrics import TerminalMetrics
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prompt_toolkit.history import History
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminalSession:
|
||||
"""Shell-surface session state, composed onto ``Session`` for the interactive shell."""
|
||||
|
||||
active_theme_name: str = "green"
|
||||
"""Interactive shell palette name for this REPL session (``/theme``, prompts)."""
|
||||
|
||||
pending_theme_refresh: bool = False
|
||||
"""When True, apply the active palette to prompt-toolkit before the next prompt."""
|
||||
|
||||
trust_mode: bool = False
|
||||
"""When True, confirmation prompts for elevated REPL actions are skipped."""
|
||||
|
||||
prompt_history_backend: History | None = None
|
||||
"""The live ``prompt_toolkit.History`` object backing the input prompt.
|
||||
|
||||
Stored here so ``/history`` and ``/privacy`` slash commands can mutate its
|
||||
``paused`` flag (when it is a ``RedactingFileHistory``) without needing access to
|
||||
the ``PromptSession``."""
|
||||
|
||||
prompt_app: Any = None
|
||||
"""The prompt-toolkit ``Application`` instance for this session.
|
||||
|
||||
Stored here (instead of accessed via ``get_app_or_none()``) so that worker-thread
|
||||
slash commands (e.g. ``/theme``) can refresh styles via ``call_soon_threadsafe`` on
|
||||
the main asyncio loop."""
|
||||
|
||||
main_loop: Any = None
|
||||
"""The asyncio event loop for the main REPL coroutine.
|
||||
|
||||
Set once by ``InteractiveShellController.start_interactive_shell`` so worker-thread
|
||||
code can schedule prompt-toolkit updates on the main thread."""
|
||||
|
||||
prompt_refresh_fn: Callable[[], None] | None = field(default=None, repr=False)
|
||||
"""Loop-owned hook to apply pending prefill and redraw the active prompt."""
|
||||
|
||||
fleet_sampler_starter: Callable[[], None] | None = field(default=None, repr=False)
|
||||
"""Loop-owned hook to lazily start the fleet sampler on first live ``/fleet`` use.
|
||||
|
||||
Set by the interactive-shell controller so the sampler (and its ``psutil`` dependency)
|
||||
stays out of base REPL startup and only runs when fleet monitoring is actually
|
||||
requested. Thread-safe: the starter marshals task creation onto the REPL event loop."""
|
||||
|
||||
pending_prompt_default: str | None = None
|
||||
"""When set, the next interactive prompt is pre-filled with this string (then cleared)."""
|
||||
|
||||
pending_prompt_autosubmit: bool = False
|
||||
"""When True alongside ``pending_prompt_default``, the prefilled prompt is
|
||||
submitted automatically instead of waiting for the user to press Enter.
|
||||
|
||||
Used to auto-launch an interactive command the agent decided to run (e.g.
|
||||
``/integrations setup sentry``) so it flows through the normal
|
||||
exclusive-stdin dispatch path — the only place an interactive child process
|
||||
gets clean stdin."""
|
||||
|
||||
exclusive_stdin_active: bool = False
|
||||
"""True while a turn is running with exclusive stdin reserved (no live prompt).
|
||||
|
||||
Inline picker/wizard slash commands must dispatch immediately during these
|
||||
turns instead of re-queueing via ``set_auto_command``, which would loop."""
|
||||
|
||||
agent_turn_executed_slashes: set[str] = field(default_factory=set, repr=False)
|
||||
"""Slash command lines already executed during the current action-agent turn.
|
||||
|
||||
Prevents the tool-calling loop from re-dispatching the same literal slash
|
||||
command when the model emits a duplicate ``slash_invoke`` on a later iteration."""
|
||||
|
||||
background_mode_enabled: bool = False
|
||||
"""Whether new investigations should run as session-local background tasks."""
|
||||
|
||||
background_investigations: dict[str, BackgroundInvestigationRecord] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
"""Completed or in-flight background RCA summaries, keyed by task id."""
|
||||
|
||||
background_notification_preferences: BackgroundNotificationPreferences = field(
|
||||
default_factory=BackgroundNotificationPreferences
|
||||
)
|
||||
"""Preferred notification channels for background RCA completion events."""
|
||||
|
||||
background_notices: list[str] = field(default_factory=list)
|
||||
"""Thread-safe queue of Rich markup messages drained by the REPL main loop."""
|
||||
|
||||
_background_notices_lock: threading.Lock = field(
|
||||
default_factory=threading.Lock, repr=False, compare=False
|
||||
)
|
||||
|
||||
history_generation: int = 0
|
||||
"""Incremented on /new so background synthetic watchers can skip stale history writes."""
|
||||
|
||||
metrics: TerminalMetrics = field(default_factory=TerminalMetrics)
|
||||
"""Interactive-shell turn/intervention analytics counters (see ``/status``)."""
|
||||
|
||||
_turn_outcome_hint: str | None = field(default=None, repr=False, compare=False)
|
||||
"""Optional structured outcome set by a terminal handler for analytics."""
|
||||
|
||||
_pending_turn_llm: Any | None = field(default=None, repr=False, compare=False)
|
||||
"""LLM run metadata (an ``LlmRunInfo``) staged by a terminal handler for the
|
||||
current turn's prompt-recorder flush. Consumed exactly once via
|
||||
``pop_pending_turn_llm`` so it cannot leak into later turns."""
|
||||
|
||||
_pending_turn_error: tuple[str, str] | None = field(default=None, repr=False, compare=False)
|
||||
"""Structured ``(error_kind, message)`` staged by a failing handler for the
|
||||
current turn's prompt-recorder flush. Consumed exactly once via
|
||||
``pop_pending_turn_error`` so it cannot leak into later turns."""
|
||||
|
||||
# ── behavior over the fields above (Session delegates via ``session.terminal``) ──
|
||||
|
||||
def pop_pending_prompt_default(self) -> str:
|
||||
"""Return pre-filled text for the next prompt line, if any, and clear it."""
|
||||
value = self.pending_prompt_default
|
||||
self.pending_prompt_default = None
|
||||
return value or ""
|
||||
|
||||
def pop_pending_autosubmit(self) -> bool:
|
||||
"""Return whether the pending prefill should auto-submit, and clear the flag."""
|
||||
value = self.pending_prompt_autosubmit
|
||||
self.pending_prompt_autosubmit = False
|
||||
return value
|
||||
|
||||
def set_auto_command(self, command: str) -> None:
|
||||
"""Queue a command to run automatically on the next prompt iteration.
|
||||
|
||||
Prefills the input with ``command`` and marks it for auto-submit, then
|
||||
refreshes the active prompt so the loop submits it without waiting for
|
||||
Enter. Lets the agent launch an interactive command (setup/connect)
|
||||
through the normal exclusive-stdin dispatch path rather than spawning it
|
||||
mid-turn, where it would fight the live prompt for stdin.
|
||||
"""
|
||||
self.pending_prompt_default = command
|
||||
self.pending_prompt_autosubmit = True
|
||||
self.notify_prompt_changed()
|
||||
|
||||
def notify_prompt_changed(self) -> None:
|
||||
"""Redraw the active prompt (placeholder state and pending prefill)."""
|
||||
if self.prompt_refresh_fn is not None:
|
||||
self.prompt_refresh_fn()
|
||||
|
||||
def ensure_fleet_sampler_started(self) -> None:
|
||||
"""Request that the fleet sampler start (no-op if unwired or already running)."""
|
||||
if self.fleet_sampler_starter is not None:
|
||||
self.fleet_sampler_starter()
|
||||
|
||||
def enqueue_background_notice(self, message: str) -> None:
|
||||
"""Queue a background-thread status line for the main REPL loop to print."""
|
||||
with self._background_notices_lock:
|
||||
self.background_notices.append(message)
|
||||
self.notify_prompt_changed()
|
||||
|
||||
def drain_background_notices(self) -> list[str]:
|
||||
"""Return and clear any queued background status lines."""
|
||||
with self._background_notices_lock:
|
||||
notices = list(self.background_notices)
|
||||
self.background_notices.clear()
|
||||
return notices
|
||||
|
||||
def set_turn_outcome_hint(self, hint: str | None) -> None:
|
||||
"""Attach a structured outcome for the current terminal handler."""
|
||||
self._turn_outcome_hint = hint.strip() if isinstance(hint, str) and hint.strip() else None
|
||||
|
||||
def pop_turn_outcome_hint(self) -> str | None:
|
||||
"""Return and clear any structured outcome hint for this turn."""
|
||||
hint = self._turn_outcome_hint
|
||||
self._turn_outcome_hint = None
|
||||
return hint
|
||||
|
||||
def set_pending_turn_llm(self, run: Any | None) -> None:
|
||||
"""Stage LLM run metadata for this turn's prompt-recorder flush."""
|
||||
self._pending_turn_llm = run
|
||||
|
||||
def pop_pending_turn_llm(self) -> Any | None:
|
||||
"""Return and clear staged LLM run metadata for this turn."""
|
||||
run = self._pending_turn_llm
|
||||
self._pending_turn_llm = None
|
||||
return run
|
||||
|
||||
def set_pending_turn_error(self, kind: str, message: str) -> None:
|
||||
"""Stage a structured turn error for this turn's prompt-recorder flush."""
|
||||
kind = kind.strip()
|
||||
message = message.strip()
|
||||
if kind or message:
|
||||
self._pending_turn_error = (kind or "error", message)
|
||||
|
||||
def pop_pending_turn_error(self) -> tuple[str, str] | None:
|
||||
"""Return and clear the staged structured turn error."""
|
||||
error = self._pending_turn_error
|
||||
self._pending_turn_error = None
|
||||
return error
|
||||
@@ -0,0 +1,49 @@
|
||||
"""JSONL-backed :class:`~platform.observability.trace.spans.SessionTraceSink` for the REPL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from core.agent_harness.session.persistence.jsonl_storage import JsonlSessionStorage
|
||||
from platform.observability.trace.spans import NoopSessionTraceSink, SessionTraceSink
|
||||
|
||||
|
||||
@dataclass
|
||||
class JsonlSessionTraceSink:
|
||||
"""Write ``trace_span`` records through the session's JSONL storage backend."""
|
||||
|
||||
storage: JsonlSessionStorage
|
||||
|
||||
def emit(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
span_kind: str,
|
||||
name: str,
|
||||
status: str = "ok",
|
||||
duration_ms: int | None = None,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
parent_id: str | None = None,
|
||||
) -> str:
|
||||
return self.storage.append_trace_span(
|
||||
session_id,
|
||||
span_kind=span_kind,
|
||||
name=name,
|
||||
status=status,
|
||||
duration_ms=duration_ms,
|
||||
attributes=attributes,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
|
||||
def jsonl_trace_sink_for_session(session: Any) -> SessionTraceSink:
|
||||
"""Return a JSONL sink wired to ``session.storage``, or a Noop sink for
|
||||
non-JSONL (e.g. in-memory) sessions so tests don't leak trace files to disk."""
|
||||
storage = getattr(session, "storage", None)
|
||||
if not isinstance(storage, JsonlSessionStorage):
|
||||
return NoopSessionTraceSink()
|
||||
return JsonlSessionTraceSink(storage=storage)
|
||||
|
||||
|
||||
__all__ = ["JsonlSessionTraceSink", "jsonl_trace_sink_for_session"]
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from platform.terminal.theme import (
|
||||
ANSI_DIM,
|
||||
ANSI_RESET,
|
||||
BG,
|
||||
BOLD_BRAND,
|
||||
DEVICE_CODE,
|
||||
DEVICE_CODE_ANSI,
|
||||
DIM,
|
||||
DIM_COUNTER_ANSI,
|
||||
ERROR,
|
||||
HIGHLIGHT,
|
||||
MARKDOWN_THEME,
|
||||
PROMPT_ACCENT_ANSI,
|
||||
PROMPT_FRAME_ANSI,
|
||||
SECONDARY,
|
||||
TEXT,
|
||||
WARNING,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.banner import render_banner, render_ready_box
|
||||
from surfaces.interactive_shell.ui.components import (
|
||||
print_valid_choice_list,
|
||||
repl_choose_one,
|
||||
repl_section_break,
|
||||
repl_tty_interactive,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.rendering import (
|
||||
print_repl_json,
|
||||
print_repl_table,
|
||||
refresh_welcome_poster,
|
||||
repl_print,
|
||||
repl_table,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from surfaces.interactive_shell.ui.agents.agents_view import (
|
||||
_build_agents_table,
|
||||
render_agents_table,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.streaming import (
|
||||
STREAM_LABEL_ANSWER,
|
||||
STREAM_LABEL_ASSISTANT,
|
||||
stream_to_console,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.tables import (
|
||||
MCP_INTEGRATION_SERVICES,
|
||||
ColumnDef,
|
||||
print_command_output,
|
||||
render_integrations_table,
|
||||
render_mcp_table,
|
||||
render_models_table,
|
||||
render_table,
|
||||
render_tools_table,
|
||||
resolve_provider_models,
|
||||
)
|
||||
|
||||
# Heavy re-exports resolved lazily so importing ``ui`` (done on every REPL boot
|
||||
# via the prompt/completion path) does not force the table + streaming stack.
|
||||
_LAZY_SUBMODULE_EXPORTS: dict[str, str] = {
|
||||
"_build_agents_table": "surfaces.interactive_shell.ui.agents",
|
||||
"render_agents_table": "surfaces.interactive_shell.ui.agents",
|
||||
"STREAM_LABEL_ANSWER": "surfaces.interactive_shell.ui.streaming",
|
||||
"STREAM_LABEL_ASSISTANT": "surfaces.interactive_shell.ui.streaming",
|
||||
"stream_to_console": "surfaces.interactive_shell.ui.streaming",
|
||||
"MCP_INTEGRATION_SERVICES": "surfaces.interactive_shell.ui.tables",
|
||||
"ColumnDef": "surfaces.interactive_shell.ui.tables",
|
||||
"print_command_output": "surfaces.interactive_shell.ui.tables",
|
||||
"render_integrations_table": "surfaces.interactive_shell.ui.tables",
|
||||
"render_mcp_table": "surfaces.interactive_shell.ui.tables",
|
||||
"render_models_table": "surfaces.interactive_shell.ui.tables",
|
||||
"render_table": "surfaces.interactive_shell.ui.tables",
|
||||
"render_tools_table": "surfaces.interactive_shell.ui.tables",
|
||||
"resolve_provider_models": "surfaces.interactive_shell.ui.tables",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
module_path = _LAZY_SUBMODULE_EXPORTS.get(name)
|
||||
if module_path is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
import importlib
|
||||
|
||||
return getattr(importlib.import_module(module_path), name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ANSI_DIM",
|
||||
"ANSI_RESET",
|
||||
"BG",
|
||||
"BOLD_BRAND",
|
||||
"ColumnDef",
|
||||
"DEVICE_CODE",
|
||||
"DEVICE_CODE_ANSI",
|
||||
"DIM",
|
||||
"DIM_COUNTER_ANSI",
|
||||
"ERROR",
|
||||
"HIGHLIGHT",
|
||||
"MCP_INTEGRATION_SERVICES",
|
||||
"MARKDOWN_THEME",
|
||||
"PROMPT_ACCENT_ANSI",
|
||||
"PROMPT_FRAME_ANSI",
|
||||
"SECONDARY",
|
||||
"STREAM_LABEL_ANSWER",
|
||||
"STREAM_LABEL_ASSISTANT",
|
||||
"TEXT",
|
||||
"WARNING",
|
||||
"_build_agents_table",
|
||||
"print_valid_choice_list",
|
||||
"print_command_output",
|
||||
"print_repl_json",
|
||||
"print_repl_table",
|
||||
"render_agents_table",
|
||||
"refresh_welcome_poster",
|
||||
"render_banner",
|
||||
"render_ready_box",
|
||||
"render_integrations_table",
|
||||
"render_mcp_table",
|
||||
"render_models_table",
|
||||
"render_table",
|
||||
"render_tools_table",
|
||||
"repl_choose_one",
|
||||
"repl_print",
|
||||
"repl_section_break",
|
||||
"repl_table",
|
||||
"repl_tty_interactive",
|
||||
"resolve_provider_models",
|
||||
"stream_to_console",
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Rendering for the shell tool-calling turn.
|
||||
|
||||
This module owns the terminal-facing action observer. Planner tool calls are
|
||||
internal state by default: the observer records them for history/storage while
|
||||
the concrete action executors render user-facing command output. The execution
|
||||
orchestration that drives it lives in
|
||||
:func:`interactive_shell.runtime.action_turn.run_action_tool_turn`.
|
||||
|
||||
Keeping rendering here means the shell turn-entry adapter stays focused on
|
||||
binding core ports while terminal formatting stays in ``ui/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.agent_harness.turns.action_driver import SELF_RECORDING_ACTION_TOOL_NAMES
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
|
||||
# Tools whose preview is just ``(label, single-arg)``. The display content is the
|
||||
# stripped string value of that single argument. Anything that needs to combine
|
||||
# multiple arguments (``slash_invoke``, ``synthetic_run``) keeps a custom branch
|
||||
# in :func:`tool_call_display`.
|
||||
_SIMPLE_TOOL_LABELS: dict[str, tuple[str, str]] = {
|
||||
"llm_set_provider": ("LLM provider", "target"),
|
||||
"alert_sample": ("sample alert", "template"),
|
||||
"investigation_start": ("investigation", "alert_text"),
|
||||
"task_cancel": ("cancel task", "target"),
|
||||
"cli_exec": ("opensre", "payload"),
|
||||
"code_implement": ("implementation", "task"),
|
||||
"shell_run": ("shell", "command"),
|
||||
}
|
||||
|
||||
|
||||
def tool_call_display(tool_name: str, args: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Return a ``(label, content)`` pair describing a planned tool call."""
|
||||
if tool_name == "slash_invoke":
|
||||
command = str(args.get("command", "")).strip()
|
||||
raw_args = args.get("args")
|
||||
parsed_args = [str(item).strip() for item in raw_args] if isinstance(raw_args, list) else []
|
||||
return "command", " ".join([command, *parsed_args]).strip()
|
||||
if tool_name == "synthetic_run":
|
||||
suite = str(args.get("suite", "")).strip()
|
||||
scenario = str(args.get("scenario", "")).strip()
|
||||
return "synthetic test", f"{suite}:{scenario}" if scenario else suite
|
||||
simple = _SIMPLE_TOOL_LABELS.get(tool_name)
|
||||
if simple is not None:
|
||||
label, arg_key = simple
|
||||
return label, str(args.get(arg_key, "")).strip()
|
||||
return tool_name, json.dumps(args, default=str, sort_keys=True)
|
||||
|
||||
|
||||
class ActionRenderObserver:
|
||||
"""Agent event observer that records planner turns not owned by action tools.
|
||||
|
||||
Self-recording tools (``slash_invoke``, ``shell_run``, etc.) append their own
|
||||
history row; chat turns are recorded later by turn accounting when the
|
||||
assistant runs.
|
||||
"""
|
||||
|
||||
def __init__(self, *, session: Session, console: Console, message: str) -> None:
|
||||
self.session = session
|
||||
self.console = console
|
||||
self.message = message
|
||||
self.planned_count = 0
|
||||
|
||||
def __call__(self, kind: str, data: dict[str, Any]) -> None:
|
||||
if kind == "tool_update":
|
||||
with contextlib.suppress(Exception):
|
||||
self.session.storage.append_tool_update(
|
||||
self.session.session_id,
|
||||
tool=str(data.get("name") or "tool"),
|
||||
update=data.get("update"),
|
||||
tool_call_id=str(data.get("id") or "") or None,
|
||||
)
|
||||
return
|
||||
if kind != "tool_start":
|
||||
return
|
||||
name = str(data.get("name", "")).strip()
|
||||
if not name or name == "assistant_handoff":
|
||||
return
|
||||
if self.planned_count == 0 and name not in SELF_RECORDING_ACTION_TOOL_NAMES:
|
||||
self.session.record("cli_agent", self.message)
|
||||
self.planned_count += 1
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ActionRenderObserver",
|
||||
"tool_call_display",
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Fleet and background-agent dashboard rendering."""
|
||||
|
||||
from surfaces.interactive_shell.ui.agents.agents_view import (
|
||||
_build_agents_table,
|
||||
render_agents_table,
|
||||
)
|
||||
|
||||
__all__ = ["_build_agents_table", "render_agents_table"]
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Rich-table rendering for the ``/fleet`` slash-command dashboard.
|
||||
|
||||
Columns: ``agent``, ``pid``, ``uptime``, ``cpu%``, ``tokens/min``,
|
||||
``$/hr``, ``status``. Every metric cell falls back to ``-`` when its
|
||||
sampler accessor returns ``None``. ``0`` versus ``-`` is meaningful
|
||||
in ``tokens/min``: ``0`` is observed-but-idle, ``-`` is unobservable
|
||||
(no meter for this provider, or the JSONL is unreadable, or the
|
||||
sampler task is not running — e.g. non-interactive
|
||||
``opensre fleet list``).
|
||||
|
||||
This module lives outside ``tools/system/fleet_monitoring/`` so collectors don't pull
|
||||
in Rich.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from rich.console import Console, JustifyMethod
|
||||
from rich.markup import escape
|
||||
from rich.table import Table
|
||||
|
||||
from platform.terminal.theme import BOLD_BRAND
|
||||
from surfaces.interactive_shell.ui.components.rendering import print_repl_table, repl_table
|
||||
from tools.system.fleet_monitoring.registry import AgentRecord
|
||||
from tools.system.fleet_monitoring.sampler import get_snapshot, get_tokens_per_min, get_usd_per_hour
|
||||
from tools.system.fleet_monitoring.status import Status, compute_status
|
||||
|
||||
_UNFILLED = "-"
|
||||
|
||||
# Re-using Rich's own ``JustifyMethod`` so column-justify options
|
||||
# stay in lockstep with the library.
|
||||
_COLUMNS: tuple[tuple[str, JustifyMethod], ...] = (
|
||||
("agent", "left"),
|
||||
("pid", "right"),
|
||||
("uptime", "right"),
|
||||
("cpu%", "right"),
|
||||
("tokens/min", "right"),
|
||||
("$/hr", "right"),
|
||||
("status", "left"),
|
||||
)
|
||||
|
||||
_STATUS_COLORS: dict[Status, str] = {
|
||||
Status.ACTIVE: "green",
|
||||
Status.IDLE: "yellow",
|
||||
Status.STUCK: "red",
|
||||
}
|
||||
|
||||
|
||||
def _format_uptime(delta: timedelta) -> str:
|
||||
total_seconds = int(delta.total_seconds())
|
||||
if total_seconds < 60:
|
||||
return f"{total_seconds}s"
|
||||
if total_seconds < 3600:
|
||||
return f"{total_seconds // 60}m"
|
||||
hours = total_seconds // 3600
|
||||
if hours < 24:
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
return f"{hours}h{minutes}m"
|
||||
days = hours // 24
|
||||
remaining_hours = hours % 24
|
||||
return f"{days}d{remaining_hours}h"
|
||||
|
||||
|
||||
def _format_tokens_per_min(value: float | None) -> str:
|
||||
if value is None:
|
||||
return _UNFILLED
|
||||
# Round-then-compare so ``999.6`` doesn't render as 4-digit ``"1000"``
|
||||
# next to its 3-digit neighbors.
|
||||
rounded = int(round(value))
|
||||
if rounded < 1000:
|
||||
return f"{rounded}"
|
||||
return f"{value / 1000:.1f}k"
|
||||
|
||||
|
||||
def _format_usd_per_hour(value: float | None) -> str:
|
||||
if value is None:
|
||||
return _UNFILLED
|
||||
return f"${value:.2f}"
|
||||
|
||||
|
||||
def _format_status(status: Status, msg: str = "") -> str:
|
||||
"""Return a Rich-markup-colorized status cell for the /fleet table."""
|
||||
color = _STATUS_COLORS.get(status, "default")
|
||||
label = f"{status.value} ({msg})" if msg else status.value
|
||||
return f"[{color}]{label}[/{color}]"
|
||||
|
||||
|
||||
def _build_agents_table(records: Iterable[AgentRecord]) -> Table:
|
||||
"""Build and return the agents dashboard Table without printing it."""
|
||||
materialized = list(records)
|
||||
table = repl_table(
|
||||
title="agents",
|
||||
title_style=BOLD_BRAND,
|
||||
caption="no agents discovered or registered yet" if not materialized else None,
|
||||
)
|
||||
for header, justify in _COLUMNS:
|
||||
table.add_column(header, justify=justify)
|
||||
now = datetime.now(UTC)
|
||||
for record in materialized:
|
||||
snapshot = get_snapshot(record.pid)
|
||||
if snapshot is not None:
|
||||
# Use output freshness when available; otherwise the status
|
||||
# heuristic falls back to the process start time.
|
||||
status = compute_status(
|
||||
snapshot,
|
||||
now,
|
||||
last_output_at=snapshot.last_output_at,
|
||||
idle_after_s=120,
|
||||
stuck_after_s=480,
|
||||
)
|
||||
status_msg = ""
|
||||
if status is Status.STUCK:
|
||||
anchor = snapshot.last_output_at or snapshot.started_at
|
||||
status_msg = f"{_format_uptime(now - anchor)} no progress"
|
||||
|
||||
uptime_cell = _format_uptime(now - snapshot.started_at)
|
||||
cpu_cell = f"{snapshot.cpu_percent:.1f}"
|
||||
status_cell = _format_status(status, status_msg)
|
||||
else:
|
||||
uptime_cell = _UNFILLED
|
||||
cpu_cell = _UNFILLED
|
||||
status_cell = _UNFILLED
|
||||
tokens_cell = _format_tokens_per_min(get_tokens_per_min(record.pid))
|
||||
hourly_cell = _format_usd_per_hour(get_usd_per_hour(record.pid))
|
||||
table.add_row(
|
||||
escape(record.name),
|
||||
str(record.pid),
|
||||
uptime_cell,
|
||||
cpu_cell,
|
||||
tokens_cell,
|
||||
hourly_cell,
|
||||
status_cell,
|
||||
)
|
||||
return table
|
||||
|
||||
|
||||
def render_agents_table(console: Console, records: Iterable[AgentRecord]) -> None:
|
||||
"""Print the agents dashboard table to the REPL console with TTY-safe width."""
|
||||
print_repl_table(console, _build_agents_table(records))
|
||||
|
||||
|
||||
__all__ = ["_build_agents_table", "render_agents_table"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Rich rendering for incoming alerts surfaced in the REPL loop.
|
||||
|
||||
The alert receiver, queue, and listener lifecycle live in
|
||||
``core.domain.alerts.inbox``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.panel import Panel
|
||||
|
||||
from core.domain.alerts.inbox import IncomingAlert
|
||||
from platform.terminal.theme import (
|
||||
DIM,
|
||||
INCOMING_ALERT_ACCENT,
|
||||
TEXT,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.console import RenderableType
|
||||
|
||||
from core.domain.alerts.inbox import AlertInbox
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
|
||||
|
||||
def time_ago(then: datetime | None) -> str:
|
||||
"""Format a relative time string like '5 seconds ago', '1 minute ago', etc."""
|
||||
if then is None:
|
||||
return "unknown"
|
||||
|
||||
now = datetime.now(UTC)
|
||||
delta = now - then
|
||||
seconds = int(delta.total_seconds())
|
||||
|
||||
if seconds < 60:
|
||||
return f"{seconds}s ago" if seconds != 1 else "1s ago"
|
||||
elif seconds < 3600:
|
||||
minutes = seconds // 60
|
||||
return f"{minutes}m ago" if minutes != 1 else "1m ago"
|
||||
elif seconds < 86400:
|
||||
hours = seconds // 3600
|
||||
return f"{hours}h ago" if hours != 1 else "1h ago"
|
||||
else:
|
||||
days = seconds // 86400
|
||||
return f"{days}d ago" if days != 1 else "1d ago"
|
||||
|
||||
|
||||
def format_incoming_alert(alert: IncomingAlert) -> RenderableType:
|
||||
"""Format an incoming alert as a Rich renderable with distinct styling.
|
||||
|
||||
Returns a Panel with:
|
||||
- Header showing incoming alert label, source, and severity (if present)
|
||||
- Relative received time
|
||||
- Alert text body
|
||||
"""
|
||||
# Build the header line: source and severity
|
||||
header_parts: list[str] = ["incoming alert"]
|
||||
if alert.source:
|
||||
header_parts.append(f"from {escape(alert.source)}")
|
||||
if alert.severity:
|
||||
# Escape the whole `[severity]` fragment so Rich cannot treat `[bold ...]` etc. as tags.
|
||||
header_parts.append(escape(f"[{alert.severity}]"))
|
||||
|
||||
header = " | ".join(header_parts)
|
||||
|
||||
# Format the alert body with timestamp
|
||||
timestamp_str = time_ago(alert.received_at)
|
||||
body_lines = [
|
||||
f"[{DIM}]received {timestamp_str}[/]",
|
||||
"",
|
||||
f"[{TEXT}]{escape(alert.text)}[/]",
|
||||
]
|
||||
body = "\n".join(body_lines)
|
||||
|
||||
# Create a panel with the distinct accent
|
||||
panel = Panel(
|
||||
body,
|
||||
title=f"[{INCOMING_ALERT_ACCENT}]⚠ {header}[/]",
|
||||
expand=False,
|
||||
border_style=INCOMING_ALERT_ACCENT,
|
||||
)
|
||||
|
||||
return panel
|
||||
|
||||
|
||||
def drain_and_render_incoming(
|
||||
session: Session,
|
||||
console: Console,
|
||||
inbox: AlertInbox,
|
||||
) -> int:
|
||||
"""Pop all queued alerts, render each one, and record them in session.
|
||||
|
||||
Returns the number of alerts rendered.
|
||||
"""
|
||||
alerts = inbox.iter_pending()
|
||||
count = 0
|
||||
|
||||
for alert in alerts:
|
||||
console.print(format_incoming_alert(alert), end="\n")
|
||||
session.record_incoming_alert(alert)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
__all__ = ["format_incoming_alert", "drain_and_render_incoming", "time_ago"]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Startup splash and REPL welcome banner."""
|
||||
|
||||
from surfaces.interactive_shell.ui.banner.banner import (
|
||||
build_ready_panel,
|
||||
render_banner,
|
||||
render_ready_box,
|
||||
render_splash,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.banner.banner_state import integration_display_name
|
||||
|
||||
__all__ = [
|
||||
"build_ready_panel",
|
||||
"integration_display_name",
|
||||
"render_banner",
|
||||
"render_ready_box",
|
||||
"render_splash",
|
||||
]
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Splash screen, agent ready-state box, and REPL launch banner.
|
||||
|
||||
Three exported entry points
|
||||
---------------------------
|
||||
render_splash(console, first_run=False)
|
||||
Full branded startup screen with ASCII art and optional security gate.
|
||||
Called once when the CLI starts.
|
||||
|
||||
render_ready_box(console, session=None)
|
||||
DIM-bordered two-column welcome panel:
|
||||
left → ◉ OpenSRE · provider · model · mode · cwd
|
||||
right → "Tips for getting started" + "What's new"
|
||||
Called after the splash and on /clear, /welcome, and greeting aliases.
|
||||
|
||||
render_banner(console)
|
||||
Backward-compatible shim: render_splash + render_ready_box in one call.
|
||||
Existing callers continue to work unchanged.
|
||||
|
||||
Rendered output legend (colour roles)
|
||||
--------------------------------------
|
||||
# [HIGHLIGHT] ASCII art lines · ◉ glyph · OpenSRE brand name
|
||||
# [BRAND] version string · model name · section headers
|
||||
# [SECONDARY] "opensre" product name label · cwd · tip / note body
|
||||
# [DIM] subtitle description · rule lines · box chrome · dividers
|
||||
# [TEXT] provider/model values · greeting
|
||||
# [WARNING] read-only or trust-mode notice · incomplete-integration marker
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
from rich import box
|
||||
from rich.console import Console, Group
|
||||
from rich.panel import Panel
|
||||
from rich.rule import Rule
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from config.repl_config import WHATS_NEW
|
||||
from config.version import get_opensre_version
|
||||
from platform.terminal.theme import (
|
||||
BRAND,
|
||||
DIM,
|
||||
HIGHLIGHT,
|
||||
SECONDARY,
|
||||
TEXT,
|
||||
WARNING,
|
||||
_parse_hex_color,
|
||||
get_active_theme,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.banner.banner_state import _build_ambient_right_column
|
||||
from surfaces.interactive_shell.ui.components.banner_art import _render_art
|
||||
from surfaces.interactive_shell.ui.tables.provider import detect_provider_model
|
||||
|
||||
|
||||
def _is_first_run() -> bool:
|
||||
"""True when the wizard has never been completed on this machine."""
|
||||
try:
|
||||
from surfaces.cli.wizard.store import get_store_path
|
||||
|
||||
return not get_store_path().exists()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ── Splash screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _interpolate_hex_color(start: str, end: str, t: float) -> str:
|
||||
"""Return a hex color linearly interpolated between ``start`` and ``end``."""
|
||||
start_rgb = _parse_hex_color(start)
|
||||
end_rgb = _parse_hex_color(end)
|
||||
clamped = max(0.0, min(1.0, t))
|
||||
channels = tuple(
|
||||
int(round(start_rgb[idx] + (end_rgb[idx] - start_rgb[idx]) * clamped)) for idx in range(3)
|
||||
)
|
||||
return f"#{channels[0]:02X}{channels[1]:02X}{channels[2]:02X}"
|
||||
|
||||
|
||||
def _splash_block_style(block_index: int, block_total: int) -> str:
|
||||
"""Return the Rich style for one splash block character."""
|
||||
theme = get_active_theme()
|
||||
start = theme.SPLASH_GRADIENT_START
|
||||
end = theme.SPLASH_GRADIENT_END
|
||||
if not start or not end:
|
||||
return f"bold {HIGHLIGHT}"
|
||||
if block_total <= 1:
|
||||
return f"bold {_interpolate_hex_color(start, end, 0.0)}"
|
||||
ratio = block_index / (block_total - 1)
|
||||
return f"bold {_interpolate_hex_color(start, end, ratio)}"
|
||||
|
||||
|
||||
def render_splash(console: Console | None = None, *, first_run: bool | None = None) -> None:
|
||||
"""Print the branded startup splash.
|
||||
|
||||
Rendered output (with colour roles):
|
||||
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ [DIM divider]
|
||||
╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋╋ [HIGHLIGHT art]
|
||||
...
|
||||
opensre [SECONDARY] · v<version> [BRAND]
|
||||
open-source SRE agent for automated incident
|
||||
investigation and root cause analysis [DIM]
|
||||
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ [DIM divider]
|
||||
|
||||
If first_run (or not set and wizard has never run):
|
||||
⚠ This tool runs AI-powered commands … [WARNING]
|
||||
Press Enter to continue… [SECONDARY]
|
||||
"""
|
||||
console = console or Console(
|
||||
highlight=False,
|
||||
force_terminal=True,
|
||||
color_system="truecolor",
|
||||
legacy_windows=False,
|
||||
)
|
||||
if first_run is None:
|
||||
first_run = _is_first_run()
|
||||
|
||||
version = get_opensre_version()
|
||||
art = _render_art(console.width)
|
||||
|
||||
console.print()
|
||||
console.print(Rule(style=DIM))
|
||||
console.print()
|
||||
|
||||
for line in art.splitlines():
|
||||
t = Text()
|
||||
t.append(" ")
|
||||
block_total = line.count("█")
|
||||
block_index = 0
|
||||
for ch in line:
|
||||
if ch == "█":
|
||||
t.append(ch, style=_splash_block_style(block_index, block_total))
|
||||
block_index += 1
|
||||
else:
|
||||
t.append(ch, style=f"bold {BRAND}")
|
||||
console.print(t)
|
||||
|
||||
console.print()
|
||||
|
||||
subtitle = Text()
|
||||
subtitle.append(" ")
|
||||
subtitle.append("opensre", style=SECONDARY)
|
||||
subtitle.append(" · ", style=DIM)
|
||||
subtitle.append(f"v{version}", style=BRAND)
|
||||
console.print(subtitle)
|
||||
|
||||
desc = Text()
|
||||
desc.append(
|
||||
" open-source SRE agent for automated incident investigation and root cause analysis",
|
||||
style=DIM,
|
||||
)
|
||||
console.print(desc)
|
||||
console.print()
|
||||
console.print(Rule(style=DIM))
|
||||
|
||||
if first_run:
|
||||
console.print()
|
||||
notice = Text()
|
||||
notice.append(" ")
|
||||
notice.append("⚠ ", style=f"bold {WARNING}")
|
||||
notice.append(
|
||||
"This tool executes AI-powered commands against your infrastructure.\n"
|
||||
" Review the documentation before connecting production systems.\n"
|
||||
" Source: https://github.com/opensre-dev/opensre",
|
||||
style=SECONDARY,
|
||||
)
|
||||
console.print(notice)
|
||||
console.print()
|
||||
if sys.stdin.isatty():
|
||||
try:
|
||||
console.print(f" [{SECONDARY}]Press Enter to continue…[/]", end="")
|
||||
sys.stdin.readline()
|
||||
except (EOFError, KeyboardInterrupt, OSError):
|
||||
# Non-interactive stdin or user abort — skip blocking and continue startup.
|
||||
pass
|
||||
console.print()
|
||||
|
||||
|
||||
# ── Agent ready-state box ─────────────────────────────────────────────────────
|
||||
|
||||
# Static copy for the right column (first-run only). Keep entries terse.
|
||||
_TIPS: tuple[str, ...] = (
|
||||
"Paste alert JSON or describe an incident",
|
||||
"Type /help to list slash commands",
|
||||
"Run /doctor for environment diagnostics",
|
||||
"Use /investigate for runnable demos/templates",
|
||||
)
|
||||
|
||||
# Panel geometry. The body switches to a stacked layout on narrow terminals,
|
||||
# and otherwise expands to fill the full console width while keeping the left
|
||||
# identity column readable and the right notes column roomy.
|
||||
_MIN_LEFT_COL_WIDTH = 34
|
||||
_MAX_LEFT_COL_WIDTH = 48
|
||||
_MIN_RIGHT_COL_WIDTH = 40
|
||||
_DIVIDER_WIDTH = 3
|
||||
_PANEL_PADDING_X = 2
|
||||
_PANEL_FRAME_WIDTH = 2 + (_PANEL_PADDING_X * 2)
|
||||
_MIN_TWO_COLUMN_CONTENT_WIDTH = _MIN_LEFT_COL_WIDTH + _DIVIDER_WIDTH + _MIN_RIGHT_COL_WIDTH
|
||||
|
||||
# OpenSRE brand mark — single "O" from oh-my-logo tiny font (half-block chars).
|
||||
_LOGO_MARK_ROWS: tuple[tuple[str, str], ...] = (
|
||||
("█▀█", ""),
|
||||
("█▄█", ""),
|
||||
)
|
||||
|
||||
|
||||
def _github_username() -> str:
|
||||
"""Return the saved GitHub login for the configured GitHub integration, or "".
|
||||
|
||||
Best-effort and never raises: the welcome greeting must render even when the
|
||||
integration store is unreadable or GitHub is not configured.
|
||||
"""
|
||||
try:
|
||||
from integrations.github.identity import saved_github_username
|
||||
|
||||
return saved_github_username()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _get_username() -> str:
|
||||
# Prefer the authenticated GitHub handle once it is known, so the greeting
|
||||
# reflects the user's GitHub identity rather than the local system account.
|
||||
github = _github_username()
|
||||
if github:
|
||||
return github
|
||||
try:
|
||||
return getpass.getuser()
|
||||
except Exception:
|
||||
return "there"
|
||||
|
||||
|
||||
def _build_logo_mark() -> Text:
|
||||
"""Return the brand mark left-aligned (flush with the column's 2-space indent)."""
|
||||
logo = Text(no_wrap=True)
|
||||
for index, (body, _echo) in enumerate(_LOGO_MARK_ROWS):
|
||||
if index:
|
||||
logo.append("\n")
|
||||
logo.append(body, style=f"bold {HIGHLIGHT}")
|
||||
return logo
|
||||
|
||||
|
||||
def _format_cwd(path: str) -> str:
|
||||
"""Collapse the user's home directory to ~ for a tidier identity line."""
|
||||
home = os.path.expanduser("~")
|
||||
if home and (path == home or path.startswith(home + os.sep)):
|
||||
return "~" + path[len(home) :]
|
||||
return path
|
||||
|
||||
|
||||
def _build_identity_block(provider: str, model: str, *, trust_mode: bool) -> Text:
|
||||
"""Left column: mascot · blank · greeting · blank · identity line (all left-aligned)."""
|
||||
logo = _build_logo_mark()
|
||||
|
||||
greeting = Text()
|
||||
greeting.append(f"Welcome back {_get_username()}!", style=f"bold {TEXT}")
|
||||
|
||||
# Single flowing line: model · tier · workspace
|
||||
cwd = _format_cwd(os.getcwd())
|
||||
tier = "trust mode" if trust_mode else provider
|
||||
identity = Text(overflow="fold")
|
||||
identity.append(model, style=f"bold {BRAND}")
|
||||
identity.append(" · ", style=DIM)
|
||||
if trust_mode:
|
||||
identity.append(tier, style=f"bold {WARNING}")
|
||||
identity.append(" · ", style=DIM)
|
||||
else:
|
||||
identity.append(tier, style=SECONDARY)
|
||||
identity.append(" · ", style=DIM)
|
||||
identity.append(cwd, style=SECONDARY)
|
||||
|
||||
return Text("\n").join([logo, Text(), Text(), greeting, Text(), Text(), identity])
|
||||
|
||||
|
||||
def _build_notes_block(header_text: str, items: tuple[str, ...]) -> Text:
|
||||
"""Right column section: bold header followed by dim list items."""
|
||||
parts: list[Text] = [Text(header_text, style=f"bold {BRAND}")]
|
||||
for item in items:
|
||||
parts.append(Text(item, style=SECONDARY, overflow="fold"))
|
||||
return Text("\n").join(parts)
|
||||
|
||||
|
||||
def _visual_line_count(block: Text, width: int) -> int:
|
||||
"""Estimate how many terminal lines a Text block will occupy at ``width``."""
|
||||
safe_width = max(width, 1)
|
||||
total = 0
|
||||
for raw_line in block.plain.split("\n"):
|
||||
total += max(1, math.ceil(max(len(raw_line), 1) / safe_width))
|
||||
return total
|
||||
|
||||
|
||||
def _vertical_divider(height: int) -> Text:
|
||||
"""Build a padded vertical rule with ``height`` lines."""
|
||||
return Text("\n".join(" │ " for _ in range(max(height, 1))), style=DIM, no_wrap=True)
|
||||
|
||||
|
||||
def _two_column_widths(console_width: int) -> tuple[int, int]:
|
||||
"""Return responsive left/right widths for the ready panel body."""
|
||||
content_width = max(console_width - _PANEL_FRAME_WIDTH, _MIN_TWO_COLUMN_CONTENT_WIDTH)
|
||||
left_width = int((content_width - _DIVIDER_WIDTH) * 0.42)
|
||||
left_width = max(_MIN_LEFT_COL_WIDTH, min(left_width, _MAX_LEFT_COL_WIDTH))
|
||||
right_width = content_width - _DIVIDER_WIDTH - left_width
|
||||
if right_width < _MIN_RIGHT_COL_WIDTH:
|
||||
right_width = _MIN_RIGHT_COL_WIDTH
|
||||
left_width = content_width - _DIVIDER_WIDTH - right_width
|
||||
return left_width, right_width
|
||||
|
||||
|
||||
def build_ready_panel(
|
||||
console: Console | None = None,
|
||||
*,
|
||||
session: object = None,
|
||||
) -> Panel:
|
||||
"""Build the responsive welcome panel shared by startup and CLI help."""
|
||||
console = console or Console(
|
||||
highlight=False,
|
||||
force_terminal=True,
|
||||
color_system="truecolor",
|
||||
legacy_windows=False,
|
||||
)
|
||||
provider, model = detect_provider_model()
|
||||
version = get_opensre_version()
|
||||
trust_mode: bool = bool(getattr(session, "trust_mode", False))
|
||||
|
||||
panel_title = Text()
|
||||
panel_title.append(" OpenSRE", style=f"bold {HIGHLIGHT}")
|
||||
panel_title.append(" · ", style=DIM)
|
||||
panel_title.append(f"v{version} ", style=BRAND)
|
||||
|
||||
left = _build_identity_block(provider, model, trust_mode=trust_mode)
|
||||
if _is_first_run():
|
||||
right = Text("\n").join(
|
||||
[
|
||||
_build_notes_block("Tips for getting started", _TIPS),
|
||||
Text("───", style=DIM),
|
||||
_build_notes_block("What's new", WHATS_NEW),
|
||||
]
|
||||
)
|
||||
else:
|
||||
right = _build_ambient_right_column(session=session)
|
||||
|
||||
body: Group | Table
|
||||
if console.width - _PANEL_FRAME_WIDTH >= _MIN_TWO_COLUMN_CONTENT_WIDTH:
|
||||
left_width, right_width = _two_column_widths(console.width)
|
||||
height = max(
|
||||
_visual_line_count(left, left_width),
|
||||
_visual_line_count(right, right_width),
|
||||
)
|
||||
divider = _vertical_divider(height)
|
||||
|
||||
grid = Table.grid(padding=0, expand=False)
|
||||
grid.add_column(justify="left", vertical="top", width=left_width)
|
||||
grid.add_column(justify="center", vertical="top", width=_DIVIDER_WIDTH)
|
||||
grid.add_column(justify="left", vertical="top", width=right_width)
|
||||
grid.add_row(left, divider, right)
|
||||
body = grid
|
||||
else:
|
||||
body = Group(
|
||||
left,
|
||||
Rule(style=DIM),
|
||||
right,
|
||||
)
|
||||
|
||||
return Panel(
|
||||
body,
|
||||
title=panel_title,
|
||||
title_align="left",
|
||||
border_style=DIM,
|
||||
padding=(1, _PANEL_PADDING_X),
|
||||
expand=True,
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
|
||||
|
||||
def render_ready_box(
|
||||
console: Console | None = None,
|
||||
*,
|
||||
session: object = None,
|
||||
) -> None:
|
||||
"""Print the two-column welcome panel with an embedded title bar."""
|
||||
console = console or Console(
|
||||
highlight=False,
|
||||
force_terminal=True,
|
||||
color_system="truecolor",
|
||||
legacy_windows=False,
|
||||
)
|
||||
console.print()
|
||||
console.print(build_ready_panel(console, session=session))
|
||||
console.print()
|
||||
|
||||
|
||||
# ── Backward-compatible shim ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def render_banner(console: Console | None = None) -> None:
|
||||
"""Render splash + ready-state box in one call (legacy entry point).
|
||||
|
||||
Existing callers (main.run_repl) continue to work unchanged.
|
||||
"""
|
||||
_console = console or Console(
|
||||
highlight=False,
|
||||
force_terminal=True,
|
||||
color_system="truecolor",
|
||||
legacy_windows=False,
|
||||
)
|
||||
render_splash(_console)
|
||||
render_ready_box(_console)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Live system state helpers for the agent ready-state banner.
|
||||
|
||||
Queries integration health and alert-listener config without making
|
||||
network calls — results are cached-once per banner render and used by
|
||||
:func:`interactive_shell.ui.banner.banner._build_ambient_right_column`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from platform.terminal.theme import BRAND, DIM, HIGHLIGHT, SECONDARY, WARNING
|
||||
|
||||
# Display-name overrides for known integration service slugs.
|
||||
_SERVICE_DISPLAY_NAMES: dict[str, str] = {
|
||||
"grafana": "Grafana",
|
||||
"datadog": "Datadog",
|
||||
"honeycomb": "Honeycomb",
|
||||
"coralogix": "Coralogix",
|
||||
"aws": "AWS",
|
||||
"github": "GitHub",
|
||||
"sentry": "Sentry",
|
||||
"prometheus": "Prometheus",
|
||||
"loki": "Loki",
|
||||
"elasticsearch": "Elasticsearch",
|
||||
"bigquery": "BigQuery",
|
||||
"pagerduty": "PagerDuty",
|
||||
"slack": "Slack",
|
||||
"telegram": "Telegram",
|
||||
"signoz": "SigNoz",
|
||||
"jira": "Jira",
|
||||
"gitlab": "GitLab",
|
||||
"vercel": "Vercel",
|
||||
"mongodb": "MongoDB",
|
||||
"postgresql": "PostgreSQL",
|
||||
"mysql": "MySQL",
|
||||
"redis": "Redis",
|
||||
"kafka": "Kafka",
|
||||
"rabbitmq": "RabbitMQ",
|
||||
"clickhouse": "ClickHouse",
|
||||
"mariadb": "MariaDB",
|
||||
"kubernetes": "Kubernetes",
|
||||
"betterstack": "Better Stack",
|
||||
"snowflake": "Snowflake",
|
||||
"newrelic": "New Relic",
|
||||
"opsgenie": "OpsGenie",
|
||||
"linear": "Linear",
|
||||
"supabase": "Supabase",
|
||||
}
|
||||
|
||||
|
||||
def integration_display_name(service: str) -> str:
|
||||
"""Human-readable label for an integration service slug."""
|
||||
return _SERVICE_DISPLAY_NAMES.get(service, service.replace("_", " ").title())
|
||||
|
||||
|
||||
def _load_integration_health() -> list[tuple[str, str]]:
|
||||
"""Return ``(display_name, status)`` for each configured integration.
|
||||
|
||||
``status`` is ``"ok"`` or ``"incomplete"`` (e.g. a hosted MCP record saved
|
||||
without an API token). Offline and best-effort: never raises and never makes
|
||||
network calls, so the banner reflects health without slowing startup.
|
||||
"""
|
||||
try:
|
||||
from integrations.catalog import ( # lazy — avoids circular deps
|
||||
configured_integration_health,
|
||||
)
|
||||
|
||||
return [
|
||||
(integration_display_name(service), status)
|
||||
for service, status in configured_integration_health()
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _is_alert_listener_active() -> bool:
|
||||
"""Return True if the alert listener is enabled in config. Never raises."""
|
||||
try:
|
||||
from config.repl_config import ReplConfig
|
||||
|
||||
return ReplConfig.load(apply_active_theme=False).alert_listener_enabled
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _build_ambient_right_column(session: object = None) -> Text:
|
||||
"""Right column for returning users: live integration status and alert listener state."""
|
||||
parts: list[Text] = []
|
||||
|
||||
# Integrations — annotate by offline health so the banner never implies a
|
||||
# half-configured integration (e.g. a hosted MCP record with no API token)
|
||||
# is connected. A "⚠" + dim name marks an integration missing credentials.
|
||||
parts.append(Text("Integrations", style=f"bold {BRAND}"))
|
||||
entries = _load_integration_health()
|
||||
if entries:
|
||||
_MAX_SHOWN = 6
|
||||
shown = entries[:_MAX_SHOWN]
|
||||
overflow = len(entries) - len(shown)
|
||||
name_line = Text(overflow="fold")
|
||||
for idx, (name, status) in enumerate(shown):
|
||||
if idx:
|
||||
name_line.append(" · ", style=DIM)
|
||||
if status == "incomplete":
|
||||
name_line.append(f"{name} ⚠", style=DIM)
|
||||
else:
|
||||
name_line.append(name, style=SECONDARY)
|
||||
if overflow:
|
||||
name_line.append(f" +{overflow}", style=DIM)
|
||||
parts.append(name_line)
|
||||
if any(status == "incomplete" for _name, status in entries):
|
||||
parts.append(Text("⚠ incomplete — run /integrations verify", style=WARNING))
|
||||
else:
|
||||
parts.append(Text("run /onboard to connect tools", style=DIM))
|
||||
|
||||
parts.append(Text("───", style=DIM))
|
||||
|
||||
# Alert listener
|
||||
parts.append(Text("Alert listener", style=f"bold {BRAND}"))
|
||||
if _is_alert_listener_active():
|
||||
listener_line = Text()
|
||||
listener_line.append("● ", style=f"bold {HIGHLIGHT}")
|
||||
listener_line.append("active", style=SECONDARY)
|
||||
parts.append(listener_line)
|
||||
else:
|
||||
parts.append(Text("○ not configured", style=DIM))
|
||||
|
||||
# Session summary — only shown when /clear is used mid-session with history
|
||||
if session is not None:
|
||||
history: list[object] = getattr(session, "history", [])
|
||||
if history:
|
||||
parts.append(Text("───", style=DIM))
|
||||
parts.append(Text("This session", style=f"bold {BRAND}"))
|
||||
count = len(history)
|
||||
noun = "interaction" if count == 1 else "interactions"
|
||||
parts.append(Text(f"{count} {noun}", style=SECONDARY))
|
||||
|
||||
return Text("\n").join(parts)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_SERVICE_DISPLAY_NAMES",
|
||||
"integration_display_name",
|
||||
"_build_ambient_right_column",
|
||||
"_is_alert_listener_active",
|
||||
"_load_integration_health",
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Reusable terminal UI primitives (menus, TTY rendering, formatting)."""
|
||||
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import (
|
||||
print_valid_choice_list,
|
||||
repl_choose_one,
|
||||
repl_section_break,
|
||||
repl_tty_interactive,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.loaders import DEFAULT_LOADER_LABEL, llm_loader
|
||||
from surfaces.interactive_shell.ui.components.rendering import (
|
||||
print_repl_json,
|
||||
print_repl_table,
|
||||
refresh_welcome_poster,
|
||||
repl_print,
|
||||
repl_table,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.time_format import (
|
||||
format_repl_duration,
|
||||
format_repl_timestamp,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.components.token_format import (
|
||||
_CHARS_PER_TOKEN,
|
||||
format_token_count_short,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_LOADER_LABEL",
|
||||
"_CHARS_PER_TOKEN",
|
||||
"format_repl_duration",
|
||||
"format_repl_timestamp",
|
||||
"format_token_count_short",
|
||||
"llm_loader",
|
||||
"print_repl_json",
|
||||
"print_repl_table",
|
||||
"print_valid_choice_list",
|
||||
"refresh_welcome_poster",
|
||||
"repl_choose_one",
|
||||
"repl_print",
|
||||
"repl_section_break",
|
||||
"repl_table",
|
||||
"repl_tty_interactive",
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""ASCII splash art and art-selection logic for the startup screen.
|
||||
|
||||
Exported
|
||||
--------
|
||||
SPLASH_ART block font, 59 cols, solid ██ fills
|
||||
SPLASH_ART_NARROW simpleBlock font, 72 cols, pure ASCII fallback
|
||||
_FALLBACK_ART minimal art, 44 cols, last resort
|
||||
_render_art(width) return the best-fit art string for a given terminal width
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from platform.observability.render.figlet import render_figlet
|
||||
|
||||
# Pre-rendered during development and checked into this module as a static string.
|
||||
# Colour codes are stripped; HIGHLIGHT is re-applied at render time.
|
||||
SPLASH_ART = """\
|
||||
██████╗ ██████╗ ███████╗███╗ ██╗███████╗██████╗ ███████╗
|
||||
██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██╔══██╗██╔════╝
|
||||
██║ ██║██████╔╝█████╗ ██╔██╗ ██║███████╗██████╔╝█████╗
|
||||
██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║╚════██║██╔══██╗██╔══╝
|
||||
╚██████╔╝██║ ███████╗██║ ╚████║███████║██║ ██║███████╗
|
||||
╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝╚══════╝"""
|
||||
|
||||
SPLASH_ART_NARROW = """\
|
||||
_|_| _|_|_| _|_|_|_| _| _| _|_|_| _|_|_| _|_|_|_|
|
||||
_| _| _| _| _| _|_| _| _| _| _| _|
|
||||
_| _| _|_|_| _|_|_| _| _| _| _|_| _|_|_| _|_|_|
|
||||
_| _| _| _| _| _|_| _| _| _| _|
|
||||
_|_| _| _|_|_|_| _| _| _|_|_| _| _| _|_|_|_|"""
|
||||
|
||||
_FALLBACK_ART = """\
|
||||
___ ____ ____ _____
|
||||
/ _ \\ _ __ ___ _ __ / ___|| _ \\| ____|
|
||||
| | | | '_ \\ / _ \\ '_ \\ \\___ \\| |_) | _|
|
||||
| |_| | |_) | __/ | | | ___) | _ <| |___
|
||||
\\___/| .__/ \\___|_| |_||____/|_| \\_\\_____|
|
||||
|_|"""
|
||||
|
||||
|
||||
def _render_art(console_width: int = 80) -> str:
|
||||
"""Return the splash art string for the given terminal width.
|
||||
|
||||
Priority: SPLASH_ART (grid, 34 cols) → SPLASH_ART_NARROW (simpleBlock, 72 cols)
|
||||
→ _FALLBACK_ART (minimal, 44 cols). OPENSRE_FIGLET_FONT overrides the default
|
||||
when pyfiglet is installed.
|
||||
"""
|
||||
custom_font = os.getenv("OPENSRE_FIGLET_FONT")
|
||||
if custom_font:
|
||||
rendered = render_figlet("OpenSRE", font=custom_font, max_line_width=console_width - 2)
|
||||
if rendered:
|
||||
return rendered
|
||||
|
||||
art_width = max(len(ln) for ln in SPLASH_ART.splitlines())
|
||||
narrow_width = max(len(ln) for ln in SPLASH_ART_NARROW.splitlines())
|
||||
fallback_width = max(len(ln) for ln in _FALLBACK_ART.splitlines())
|
||||
|
||||
if console_width >= art_width + 4:
|
||||
return SPLASH_ART
|
||||
if console_width >= narrow_width + 4:
|
||||
return SPLASH_ART_NARROW
|
||||
if console_width >= fallback_width + 4:
|
||||
return _FALLBACK_ART
|
||||
return _FALLBACK_ART
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SPLASH_ART",
|
||||
"SPLASH_ART_NARROW",
|
||||
"_FALLBACK_ART",
|
||||
"_render_art",
|
||||
]
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Interactive choice helpers for TTY-first REPL flows.
|
||||
|
||||
Inline menus render in the terminal scrollback (below the submitted command),
|
||||
not as a separate prompt-toolkit full-screen dialog — important when the REPL
|
||||
already runs under asyncio.
|
||||
|
||||
Each menu erases itself on exit (selection or Esc) so nested menus never
|
||||
pile up — only the result output and the next level appear on screen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Literal
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
import platform.terminal.theme as ui_theme
|
||||
from surfaces.interactive_shell.ui.components.key_reader import read_key_unix, read_key_windows
|
||||
|
||||
_HINT = "↑↓/j/k/Tab Enter/Space Esc/q"
|
||||
CRUMB_SEP = " › "
|
||||
# Blank line after the submitted slash line before the menu header (all pickers).
|
||||
_MENU_LEADING_LINES = 1
|
||||
_TERMINAL_NEWLINE = "\r\n"
|
||||
MenuAction = Literal["up", "down", "enter", "cancel", "eof", "ignore"]
|
||||
|
||||
|
||||
def repl_tty_interactive() -> bool:
|
||||
"""Return True when stdin/stdout support an interactive picker UI."""
|
||||
return bool(sys.stdin.isatty() and sys.stdout.isatty())
|
||||
|
||||
|
||||
def ensure_tty_column_zero() -> None:
|
||||
"""Reset the cursor column before Rich output when a TTY is active."""
|
||||
if repl_tty_interactive():
|
||||
reset_tty_column()
|
||||
|
||||
|
||||
def prepare_repl_output_line() -> None:
|
||||
"""Begin Rich output on a new line after inline menu I/O."""
|
||||
if repl_tty_interactive():
|
||||
sys.stdout.write(_TERMINAL_NEWLINE)
|
||||
reset_tty_column()
|
||||
|
||||
|
||||
def repl_section_break(console: Console) -> None:
|
||||
"""Blank line + dim rule between an inline menu step and Rich output."""
|
||||
prepare_repl_output_line()
|
||||
console.print()
|
||||
console.rule(characters="─", style=str(ui_theme.DIM))
|
||||
console.print()
|
||||
|
||||
|
||||
# ── raw key reader ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_action() -> MenuAction:
|
||||
"""Map a raw keypress to a menu action.
|
||||
|
||||
Delegates terminal I/O to :mod:`key_reader` and applies
|
||||
choice_menu-specific overrides: Tab → ``"down"``,
|
||||
right-arrow → ``"enter"``, left-arrow → ``"ignore"``.
|
||||
"""
|
||||
key = read_key_windows() if os.name == "nt" else read_key_unix()
|
||||
if key == "tab":
|
||||
return "down"
|
||||
if key == "right":
|
||||
return "enter"
|
||||
if key == "left":
|
||||
return "ignore"
|
||||
return key # type: ignore[return-value]
|
||||
|
||||
|
||||
def read_menu_action() -> MenuAction:
|
||||
"""Read one normalized inline-menu action from stdin."""
|
||||
return _read_action()
|
||||
|
||||
|
||||
# ── rendering helpers ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _cols() -> int:
|
||||
return max(40, shutil.get_terminal_size(fallback=(80, 24)).columns)
|
||||
|
||||
|
||||
def menu_columns() -> int:
|
||||
"""Return the current terminal width floor used by inline menus."""
|
||||
return _cols()
|
||||
|
||||
|
||||
def _rule(width: int) -> str:
|
||||
return "─" * width
|
||||
|
||||
|
||||
def _pad(sym: str, label: str, width: int) -> str:
|
||||
content = f" {sym} {label}"
|
||||
pad = width - len(content)
|
||||
return content + (" " * pad if pad > 0 else "")
|
||||
|
||||
|
||||
def _menu_height(crumb: str, labels: list[str]) -> int:
|
||||
# leading, title, [crumb], rule, blank, choices, blank, hint
|
||||
return _MENU_LEADING_LINES + 1 + (1 if crumb else 0) + 1 + 1 + len(labels) + 1 + 1
|
||||
|
||||
|
||||
def write_menu_line(text: str = "") -> None:
|
||||
"""Write one inline-menu line at column zero even while the terminal is in raw mode."""
|
||||
if text:
|
||||
sys.stdout.write(f"\r{text}{_TERMINAL_NEWLINE}")
|
||||
return
|
||||
sys.stdout.write(_TERMINAL_NEWLINE)
|
||||
|
||||
|
||||
def _erase_menu_block(height: int) -> None:
|
||||
if height:
|
||||
sys.stdout.write(f"\r\x1b[{height}A\r\x1b[J")
|
||||
reset_tty_column()
|
||||
|
||||
|
||||
def reset_tty_column() -> None:
|
||||
"""Return the cursor to column zero after inline menu I/O.
|
||||
|
||||
Menu rows are padded to the terminal width, so the cursor often ends on a
|
||||
high column. Rich output that follows must start at column zero or tables
|
||||
render as a diagonal block of leading whitespace.
|
||||
"""
|
||||
sys.stdout.write("\r")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def erase_menu_lines(height: int) -> None:
|
||||
"""Erase a previously-rendered inline menu block."""
|
||||
_erase_menu_block(height)
|
||||
|
||||
|
||||
def _draw_menu(
|
||||
*,
|
||||
title: str,
|
||||
crumb: str,
|
||||
labels: list[str],
|
||||
index: int,
|
||||
erase_lines: int,
|
||||
) -> None:
|
||||
out = sys.stdout
|
||||
w = _cols()
|
||||
if erase_lines:
|
||||
_erase_menu_block(erase_lines)
|
||||
for _ in range(_MENU_LEADING_LINES):
|
||||
write_menu_line()
|
||||
# title
|
||||
write_menu_line(f"{ui_theme.PROMPT_ACCENT_ANSI}{title}{ui_theme.ANSI_RESET}")
|
||||
# breadcrumb path
|
||||
if crumb:
|
||||
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{crumb}{ui_theme.ANSI_RESET}")
|
||||
# separator below header
|
||||
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_rule(w)}{ui_theme.ANSI_RESET}")
|
||||
write_menu_line()
|
||||
# choices
|
||||
for i, label in enumerate(labels):
|
||||
here = i == index
|
||||
sym = ">" if here else " "
|
||||
padded = _pad(sym, label, w)
|
||||
if here:
|
||||
write_menu_line(f"{ui_theme.MENU_SELECTION_ROW_ANSI}{padded}{ui_theme.ANSI_RESET}")
|
||||
else:
|
||||
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{padded}{ui_theme.ANSI_RESET}")
|
||||
write_menu_line()
|
||||
write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_HINT}{ui_theme.ANSI_RESET}")
|
||||
out.flush()
|
||||
|
||||
|
||||
def _erase_menu(crumb: str, labels: list[str]) -> None:
|
||||
"""Move cursor up to the start of this menu block and wipe it."""
|
||||
height = _menu_height(crumb, labels)
|
||||
_erase_menu_block(height)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
# ── picker loop ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _pick(
|
||||
*,
|
||||
title: str,
|
||||
crumb: str,
|
||||
labels: list[str],
|
||||
initial_index: int = 0,
|
||||
) -> int | None:
|
||||
"""Draw an inline menu, let user navigate, erase on exit. Returns index or None."""
|
||||
if not labels:
|
||||
return None
|
||||
idx = initial_index % len(labels)
|
||||
height = _menu_height(crumb, labels)
|
||||
first = True
|
||||
while True:
|
||||
_draw_menu(
|
||||
title=title,
|
||||
crumb=crumb,
|
||||
labels=labels,
|
||||
index=idx,
|
||||
erase_lines=0 if first else height,
|
||||
)
|
||||
first = False
|
||||
action = _read_action()
|
||||
if action == "enter":
|
||||
_erase_menu(crumb, labels)
|
||||
return idx
|
||||
if action in ("cancel", "eof"):
|
||||
_erase_menu(crumb, labels)
|
||||
return None
|
||||
if action == "ignore":
|
||||
continue
|
||||
if action == "up":
|
||||
idx = (idx - 1) % len(labels)
|
||||
elif action == "down":
|
||||
idx = (idx + 1) % len(labels)
|
||||
|
||||
|
||||
# ── public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def repl_choose_one(
|
||||
*,
|
||||
title: str,
|
||||
choices: list[tuple[str, str]],
|
||||
breadcrumb: str = "",
|
||||
initial_value: str | None = None,
|
||||
) -> str | None:
|
||||
"""Show an inline erasing arrow-key menu; return selected value or None on Esc.
|
||||
|
||||
``breadcrumb`` is a slash-separated path shown dimly below the title, e.g.
|
||||
``/model › set``. Only call when :func:`repl_tty_interactive` is True.
|
||||
"""
|
||||
from surfaces.interactive_shell.ui.components.cpr_stdin import drain_stale_cpr_bytes
|
||||
|
||||
if not choices or not repl_tty_interactive():
|
||||
return None
|
||||
drain_stale_cpr_bytes()
|
||||
crumb = breadcrumb
|
||||
labels = [label for _value, label in choices]
|
||||
initial_index = 0
|
||||
if initial_value is not None:
|
||||
for index, (value, _label) in enumerate(choices):
|
||||
if value == initial_value:
|
||||
initial_index = index
|
||||
break
|
||||
picked = _pick(title=title, crumb=crumb, labels=labels, initial_index=initial_index)
|
||||
if picked is None:
|
||||
return None
|
||||
value = choices[picked][0]
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def print_valid_choice_list(
|
||||
console: Console,
|
||||
*,
|
||||
title: str,
|
||||
choices: list[str],
|
||||
) -> None:
|
||||
"""Print one choice per line for scan-friendly fallback/error messaging."""
|
||||
if not choices:
|
||||
return
|
||||
console.print(f"[{ui_theme.SECONDARY}]{title}[/]")
|
||||
for choice in choices:
|
||||
console.print(f"[{ui_theme.SECONDARY}] - {escape(choice)}[/]")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CRUMB_SEP",
|
||||
"erase_menu_lines",
|
||||
"menu_columns",
|
||||
"print_valid_choice_list",
|
||||
"read_menu_action",
|
||||
"repl_choose_one",
|
||||
"ensure_tty_column_zero",
|
||||
"prepare_repl_output_line",
|
||||
"repl_section_break",
|
||||
"repl_tty_interactive",
|
||||
"reset_tty_column",
|
||||
"write_menu_line",
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""CPR (cursor position report) stdin hygiene for the interactive REPL loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import sys
|
||||
|
||||
# A leaked cursor-position reply is ``ESC[row;colR`` (8-bit CSI ``\x9b`` too); when it
|
||||
# leaks into the input stream the ESC and/or ``[`` introducer can be lost. The
|
||||
# introducer-less branches below are constrained so they only fire on genuine CPR
|
||||
# context. Without that constraint they can silently strip legitimate input such as
|
||||
# ``5R3``, ``12;34R okay`` or ``12;34R5 nodes``.
|
||||
_CPR_SEQUENCE_RE = re.compile(
|
||||
r"(?:\x1b\[|\x9b)\d{1,4};\d{1,4}R" # ESC [ row ; col R (introducer present)
|
||||
r"|\[\d{1,4};\d{1,4}R" # [row;colR without ESC (leaked into input)
|
||||
r"|\d{1,4};\d{1,4}R(?=[\[\x1b\x9b]|\d{1,4};\d{1,4}R)" # bare row;colR before another fragment
|
||||
r"|\d{1,4}R(?=\[|\x1b|\x9b|\d{1,4};\d{1,4}R)" # bare rowR before another fragment
|
||||
r"|\d{1,4};\d{1,4}R$" # bare row;colR alone at end of the line
|
||||
)
|
||||
_CPR_ESCAPED_SEQUENCE_RE = re.compile(r"(?:\x1b\[|\x9b)\d{1,4};\d{1,4}R")
|
||||
|
||||
|
||||
def drain_stale_cpr_bytes() -> None:
|
||||
"""Discard CPR escape-sequence bytes left in stdin after prompt teardown.
|
||||
|
||||
When ``prompt_async`` returns, prompt_toolkit tears down its input-reader
|
||||
thread. CPR responses (``ESC[row;colR``) that the bottom-toolbar refresh
|
||||
sent but that arrived just after the reader stopped sit in the OS stdin
|
||||
buffer and appear as literal keystrokes in the next prompt. This function
|
||||
non-blockingly drains stdin between ``prompt_async`` calls on POSIX TTYs.
|
||||
"""
|
||||
if os.name == "nt" or not sys.stdin.isatty():
|
||||
return
|
||||
try:
|
||||
fd = sys.stdin.fileno()
|
||||
while select.select([fd], [], [], 0)[0]:
|
||||
chunk = os.read(fd, 256)
|
||||
if not chunk:
|
||||
break
|
||||
except OSError:
|
||||
# Draining stdin is best-effort; ignore when the fd is not readable.
|
||||
pass
|
||||
|
||||
|
||||
def strip_cpr_sequences(text: str | None) -> str:
|
||||
"""Remove terminal cursor-position replies that leaked into submitted text."""
|
||||
if not text:
|
||||
return ""
|
||||
return _CPR_SEQUENCE_RE.sub("", text)
|
||||
|
||||
|
||||
def strip_cpr_escape_sequences(text: str | None) -> str:
|
||||
"""Remove only canonical escaped CPR sequences from text."""
|
||||
if not text:
|
||||
return ""
|
||||
return _CPR_ESCAPED_SEQUENCE_RE.sub("", text)
|
||||
|
||||
|
||||
def contains_cpr_sequence(text: str | None) -> bool:
|
||||
return bool(text and _CPR_SEQUENCE_RE.search(text))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"contains_cpr_sequence",
|
||||
"drain_stale_cpr_bytes",
|
||||
"strip_cpr_escape_sequences",
|
||||
"strip_cpr_sequences",
|
||||
]
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Low-level terminal key reader for TTY-first interactive menus.
|
||||
|
||||
Shared between :mod:`choice_menu` (REPL inline picker) and
|
||||
:mod:`feedback` (post-investigation rating prompt) so the raw-mode
|
||||
terminal I/O lives in one place.
|
||||
|
||||
Return values from :func:`read_key_unix` / :func:`read_key_windows`:
|
||||
``"up"``, ``"down"``, ``"enter"``, ``"cancel"``, ``"tab"``,
|
||||
``"right"``, ``"left"``, ``"eof"``, ``"ignore"``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def flush_stdin_unix() -> None:
|
||||
"""Discard pending stdin bytes before raw-mode reading."""
|
||||
with contextlib.suppress(Exception):
|
||||
import termios
|
||||
|
||||
termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def restore_stdin_terminal() -> None:
|
||||
"""Return stdin to canonical echo mode after Live/raw investigation UI.
|
||||
|
||||
Investigation progress uses a background Ctrl+O watcher that puts stdin in
|
||||
non-canonical mode without echo. If nested watchers restore the wrong
|
||||
snapshot, the shell prompt appears to accept input but characters are not
|
||||
echoed. Call this after investigation UI teardown and before line prompts.
|
||||
"""
|
||||
if os.name == "nt" or not sys.stdin.isatty():
|
||||
return
|
||||
import termios
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
fd = sys.stdin.fileno()
|
||||
attrs = termios.tcgetattr(fd) # type: ignore[attr-defined]
|
||||
# Restore cooked-mode flags a raw menu clears: ICRNL so Enter (CR) submits,
|
||||
# OPOST for output newlines, ICANON/ECHO/ISIG for line editing and signals.
|
||||
attrs[0] |= termios.BRKINT | termios.ICRNL | termios.IXON # type: ignore[attr-defined]
|
||||
attrs[1] |= termios.OPOST # type: ignore[attr-defined]
|
||||
attrs[3] |= termios.ICANON | termios.ECHO | termios.ISIG # type: ignore[attr-defined]
|
||||
if hasattr(termios, "IEXTEN"):
|
||||
attrs[3] |= termios.IEXTEN # type: ignore[attr-defined]
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, attrs) # type: ignore[attr-defined]
|
||||
termios.tcflush(fd, termios.TCIFLUSH) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def read_key_unix(*, also_cancel: tuple[bytes, ...] = ()) -> str:
|
||||
"""Read one logical keypress in raw mode; return a normalised key name.
|
||||
|
||||
Possible return values: ``"up"``, ``"down"``, ``"enter"``,
|
||||
``"cancel"``, ``"tab"``, ``"right"``, ``"left"``, ``"eof"``,
|
||||
``"ignore"``.
|
||||
|
||||
``also_cancel`` treats additional single-byte keys as ``"cancel"`` (e.g.
|
||||
``(b"s", b"S")`` for an explicit skip shortcut).
|
||||
"""
|
||||
import select as _sel
|
||||
import termios
|
||||
import tty
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old = termios.tcgetattr(fd) # type: ignore[attr-defined]
|
||||
try:
|
||||
tty.setraw(fd) # type: ignore[attr-defined]
|
||||
ch = os.read(fd, 1)
|
||||
if not ch:
|
||||
return "eof"
|
||||
b = ch[0]
|
||||
if b in (3, 4) or ch in also_cancel: # Ctrl-C / Ctrl-D / caller shortcuts
|
||||
return "cancel"
|
||||
if b in (10, 13, 32): # LF / CR / Space
|
||||
return "enter"
|
||||
if b == 9: # Tab
|
||||
return "tab"
|
||||
if ch in (b"j", b"J"):
|
||||
return "down"
|
||||
if ch in (b"k", b"K"):
|
||||
return "up"
|
||||
if ch in (b"q", b"Q"):
|
||||
return "cancel"
|
||||
if b == 27: # ESC or arrow-key prefix
|
||||
if _sel.select([fd], [], [], 0.1)[0]:
|
||||
nxt = os.read(fd, 1)
|
||||
if nxt == b"[" and _sel.select([fd], [], [], 0.1)[0]:
|
||||
arr = os.read(fd, 1)
|
||||
if arr == b"A":
|
||||
return "up"
|
||||
if arr == b"B":
|
||||
return "down"
|
||||
if arr == b"C":
|
||||
return "right"
|
||||
if arr == b"D":
|
||||
return "left"
|
||||
# Not an arrow key — drain the rest of the CSI sequence so
|
||||
# bytes like "0;1R" from a CPR (ESC[row;colR) don't leak into
|
||||
# the next read or the prompt buffer as literal characters.
|
||||
# The VT/xterm spec defines 0x40–0x7E as valid CSI final bytes.
|
||||
while arr and not (0x40 <= arr[0] <= 0x7E):
|
||||
if not _sel.select([fd], [], [], 0)[0]:
|
||||
break
|
||||
arr = os.read(fd, 1)
|
||||
return "cancel"
|
||||
return "ignore"
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def read_key_windows(*, also_cancel: tuple[bytes, ...] = ()) -> str:
|
||||
"""Read one logical keypress on Windows; return a normalised key name.
|
||||
|
||||
Possible return values: ``"up"``, ``"down"``, ``"enter"``,
|
||||
``"cancel"``, ``"tab"``, ``"right"``, ``"left"``, ``"eof"``,
|
||||
``"ignore"``.
|
||||
|
||||
``also_cancel`` treats additional single-byte keys as ``"cancel"``.
|
||||
"""
|
||||
import msvcrt # type: ignore[import,attr-defined]
|
||||
|
||||
ch = msvcrt.getch() # type: ignore[attr-defined]
|
||||
if ch in (b"\x03", b"\x1b") or ch in also_cancel:
|
||||
return "cancel"
|
||||
if ch in (b"\r", b"\n", b" "):
|
||||
return "enter"
|
||||
if ch == b"\t":
|
||||
return "tab"
|
||||
if ch in (b"j", b"J"):
|
||||
return "down"
|
||||
if ch in (b"k", b"K"):
|
||||
return "up"
|
||||
if ch in (b"q", b"Q"):
|
||||
return "cancel"
|
||||
if ch in (b"\xe0", b"\x00"):
|
||||
ch2 = msvcrt.getch() # type: ignore[attr-defined]
|
||||
if ch2 == b"H":
|
||||
return "up"
|
||||
if ch2 == b"P":
|
||||
return "down"
|
||||
if ch2 == b"M":
|
||||
return "right"
|
||||
if ch2 == b"K":
|
||||
return "left"
|
||||
return "ignore"
|
||||
return "ignore"
|
||||
|
||||
|
||||
__all__ = ["flush_stdin_unix", "read_key_unix", "read_key_windows", "restore_stdin_terminal"]
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Shared Rich loaders for interactive-shell LLM calls.
|
||||
|
||||
A quiet, dim spinner shows that an LLM call is in flight. Centralised so
|
||||
every LLM-backed surface in the interactive shell (``cli_agent``,
|
||||
``follow_up``) shares the same look.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from platform.terminal.theme import SECONDARY
|
||||
|
||||
# Quiet, secondary-colour spinner — less visual noise than a bright accent.
|
||||
_LOADER_COLOR = SECONDARY
|
||||
_LOADER_SPINNER = "dots"
|
||||
|
||||
DEFAULT_LOADER_LABEL = "thinking"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def llm_loader(console: Console, label: str = DEFAULT_LOADER_LABEL) -> Iterator[None]:
|
||||
"""Show a dim spinner while an LLM call is in flight.
|
||||
|
||||
On non-terminal consoles (CI, captured output, piped stdout), the spinner is
|
||||
skipped so captured logs stay clean — the wrapped call still runs unchanged.
|
||||
"""
|
||||
if not console.is_terminal:
|
||||
yield
|
||||
return
|
||||
|
||||
console.print()
|
||||
text = f"[{_LOADER_COLOR}]{label}…[/{_LOADER_COLOR}]"
|
||||
with console.status(text, spinner=_LOADER_SPINNER, spinner_style=_LOADER_COLOR):
|
||||
yield
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_LOADER_LABEL", "llm_loader"]
|
||||
@@ -0,0 +1,256 @@
|
||||
"""REPL TTY plumbing: buffered print helpers and table factory.
|
||||
|
||||
Keeps cursor at column zero and normalises line endings under prompt_toolkit's
|
||||
patch_stdout so Rich tables and JSON don't render as diagonal blocks.
|
||||
|
||||
Domain-specific table renderers live in :mod:`tables`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import shutil
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
from rich.table import Table
|
||||
|
||||
import platform.terminal.theme as ui_theme
|
||||
|
||||
_REPL_OUTPUT_PREPARED = ContextVar("_REPL_OUTPUT_PREPARED", default=False)
|
||||
|
||||
|
||||
def _repl_output_already_prepared() -> bool:
|
||||
"""Whether current call stack already prepared the TTY for Rich output."""
|
||||
return _REPL_OUTPUT_PREPARED.get()
|
||||
|
||||
|
||||
def _console_print_prepared(console: Console, *objects: Any, **kwargs: Any) -> None:
|
||||
token = _REPL_OUTPUT_PREPARED.set(True)
|
||||
try:
|
||||
console.print(*objects, **kwargs)
|
||||
finally:
|
||||
_REPL_OUTPUT_PREPARED.reset(token)
|
||||
|
||||
|
||||
def _repl_table_width(console: Console) -> int:
|
||||
"""Best-effort terminal width for Rich tables after inline menu I/O."""
|
||||
term_cols = shutil.get_terminal_size(fallback=(80, 24)).columns
|
||||
# Keep one safety column to avoid right-edge auto-wrap artifacts in some
|
||||
# terminals (first-char clipping / duplicate right border when a row lands
|
||||
# exactly on the terminal width).
|
||||
return max(40, min(console.width, term_cols) - 1)
|
||||
|
||||
|
||||
def _prepare_tty_for_rich(console: Console) -> int:
|
||||
"""Return the width Rich should render at.
|
||||
|
||||
prepare_repl_output_line() (which writes \\r\\n) is intentionally NOT called
|
||||
here. Under patch_stdout(raw=True), that extra newline causes the bottom
|
||||
toolbar text to flush into the output stream before the table renders. Slash
|
||||
commands start after the user presses Enter, so the cursor is already on a
|
||||
fresh line; no extra line-feed is needed.
|
||||
"""
|
||||
return _repl_table_width(console)
|
||||
|
||||
|
||||
def _normalize_repl_line_endings(text: str) -> str:
|
||||
"""Convert Rich output to ``\\r\\n`` so each line starts at column zero."""
|
||||
return text.replace("\r\n", "\n").replace("\n", "\r\n")
|
||||
|
||||
|
||||
def _write_repl_tty_buffered(
|
||||
*,
|
||||
width: int,
|
||||
leading_blank: bool,
|
||||
render_to_buffer: Callable[[Console], None],
|
||||
) -> None:
|
||||
"""Render Rich output to a buffer and write it in one TTY-safe stdout call."""
|
||||
buf = io.StringIO()
|
||||
buf_console = Console(
|
||||
file=buf,
|
||||
force_terminal=True,
|
||||
highlight=False,
|
||||
width=width,
|
||||
)
|
||||
render_to_buffer(buf_console)
|
||||
rendered = _normalize_repl_line_endings(buf.getvalue())
|
||||
if leading_blank:
|
||||
rendered = "\r\n" + rendered
|
||||
token = _REPL_OUTPUT_PREPARED.set(True)
|
||||
try:
|
||||
sys.stdout.write(rendered)
|
||||
sys.stdout.flush()
|
||||
finally:
|
||||
_REPL_OUTPUT_PREPARED.reset(token)
|
||||
|
||||
|
||||
def print_repl_table(console: Console, table: Table, *, width: int | None = None) -> None:
|
||||
"""Print a Rich table using REPL-safe TTY width.
|
||||
|
||||
When the console writes to sys.stdout (the real REPL path), tables are
|
||||
rendered into a string buffer first and written in a single sys.stdout.write
|
||||
call with explicit \\r\\n line endings. This prevents the diagonal-render
|
||||
artifact that occurs under prompt_toolkit's patch_stdout: each table row is
|
||||
a separate Rich write, and if the terminal or proxy does not convert \\n to
|
||||
\\r\\n, every row starts where the previous one ended instead of column zero.
|
||||
|
||||
When the console writes to a non-TTY stdout (piped output) or to a
|
||||
different file (e.g. a StringIO in tests), the normal console.print path
|
||||
is used — preserving the caller's color_system and avoiding ANSI pollution
|
||||
in piped output.
|
||||
"""
|
||||
leading_blank = width is None
|
||||
width = width if width is not None else _prepare_tty_for_rich(console)
|
||||
if console.file is sys.stdout and sys.stdout.isatty():
|
||||
_write_repl_tty_buffered(
|
||||
width=width,
|
||||
leading_blank=leading_blank,
|
||||
render_to_buffer=lambda buf_console: buf_console.print(table),
|
||||
)
|
||||
else:
|
||||
if leading_blank:
|
||||
_console_print_prepared(console)
|
||||
_console_print_prepared(console, table, width=width)
|
||||
|
||||
|
||||
def print_repl_json(console: Console, json_str: str) -> None:
|
||||
"""Print JSON via Rich using REPL-safe \\r\\n line endings.
|
||||
|
||||
Mirrors the buffered-write approach in :func:`print_repl_table` to prevent
|
||||
the diagonal-render artifact under prompt_toolkit's patch_stdout: bare
|
||||
``\\n`` from Rich does not imply a carriage-return, so each JSON line would
|
||||
start at the column where the previous one ended. Rendering to a buffer
|
||||
and normalising to ``\\r\\n`` ensures every line begins at column zero.
|
||||
The leading blank is included in the same write call to avoid a stale CPR
|
||||
sequence being left in stdin by a prompt_toolkit toolbar flush.
|
||||
"""
|
||||
width = _prepare_tty_for_rich(console)
|
||||
if console.file is sys.stdout and sys.stdout.isatty():
|
||||
_write_repl_tty_buffered(
|
||||
width=width,
|
||||
leading_blank=True,
|
||||
render_to_buffer=lambda buf_console: buf_console.print_json(json_str),
|
||||
)
|
||||
else:
|
||||
token = _REPL_OUTPUT_PREPARED.set(True)
|
||||
try:
|
||||
console.print_json(json_str)
|
||||
finally:
|
||||
_REPL_OUTPUT_PREPARED.reset(token)
|
||||
|
||||
|
||||
def repl_print(console: Console, *objects: Any, **kwargs: Any) -> None:
|
||||
"""Print via Rich after resetting the TTY column (inline-menu safe)."""
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import prepare_repl_output_line
|
||||
|
||||
prepare_repl_output_line()
|
||||
_console_print_prepared(console, *objects, **kwargs)
|
||||
|
||||
|
||||
def _repl_write_buffer(rendered: str) -> None:
|
||||
"""Flush pre-rendered Rich output with CRLF line endings (patch_stdout safe)."""
|
||||
from surfaces.interactive_shell.ui.components.cpr_stdin import strip_cpr_escape_sequences
|
||||
|
||||
normalized = strip_cpr_escape_sequences(rendered.replace("\r\n", "\n").replace("\n", "\r\n"))
|
||||
token = _REPL_OUTPUT_PREPARED.set(True)
|
||||
try:
|
||||
sys.stdout.write(normalized)
|
||||
sys.stdout.flush()
|
||||
finally:
|
||||
_REPL_OUTPUT_PREPARED.reset(token)
|
||||
|
||||
|
||||
def repl_clear_screen() -> None:
|
||||
"""Clear the terminal scrollback when the REPL runs under patch_stdout."""
|
||||
if not sys.stdout.isatty():
|
||||
return
|
||||
sys.stdout.write("\x1b[2J\x1b[H")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _theme_notice_line(theme_notice: str) -> str:
|
||||
"""REPL-safe ``theme set: <name>`` using the active palette (not stale imports)."""
|
||||
return (
|
||||
f"{ui_theme.HIGHLIGHT_ANSI}theme set: {escape(theme_notice)}{ui_theme.ANSI_RESET}\r\n\r\n"
|
||||
)
|
||||
|
||||
|
||||
def repl_render_launch_poster(
|
||||
console: Console,
|
||||
*,
|
||||
session: object = None,
|
||||
theme_notice: str | None = None,
|
||||
) -> None:
|
||||
"""Render splash + welcome panel using REPL-safe CRLF writes."""
|
||||
from surfaces.interactive_shell.ui import banner as banner_module
|
||||
|
||||
if console.file is sys.stdout and sys.stdout.isatty():
|
||||
width = _prepare_tty_for_rich(console)
|
||||
buf = io.StringIO()
|
||||
buf_console = Console(
|
||||
file=buf,
|
||||
force_terminal=True,
|
||||
highlight=False,
|
||||
color_system="truecolor",
|
||||
legacy_windows=False,
|
||||
width=width,
|
||||
)
|
||||
banner_module.render_splash(buf_console, first_run=False)
|
||||
banner_module.render_ready_box(buf_console, session=session)
|
||||
prefix = _theme_notice_line(theme_notice) if theme_notice else ""
|
||||
_repl_write_buffer(prefix + buf.getvalue())
|
||||
return
|
||||
|
||||
if theme_notice:
|
||||
_console_print_prepared(
|
||||
console,
|
||||
f"[{ui_theme.HIGHLIGHT}]theme set:[/] {escape(theme_notice)}",
|
||||
)
|
||||
banner_module.render_splash(console, first_run=False)
|
||||
banner_module.render_ready_box(console, session=session)
|
||||
|
||||
|
||||
def refresh_welcome_poster(
|
||||
console: Console,
|
||||
*,
|
||||
session: object = None,
|
||||
theme_notice: str | None = None,
|
||||
) -> None:
|
||||
"""Clear scrollback and redraw splash art + welcome panel with the active theme."""
|
||||
from surfaces.interactive_shell.ui.components.cpr_stdin import drain_stale_cpr_bytes
|
||||
|
||||
repl_clear_screen()
|
||||
# ``repl_clear_screen`` can trigger a toolbar DSR/CPR exchange; drain before writing.
|
||||
drain_stale_cpr_bytes()
|
||||
repl_render_launch_poster(console, session=session, theme_notice=theme_notice)
|
||||
|
||||
|
||||
def repl_table(**kwargs: Any) -> Table:
|
||||
"""Minimal outer borders — closer to Claude Code than full ASCII grids."""
|
||||
opts: dict[str, Any] = {
|
||||
"box": box.MINIMAL_HEAVY_HEAD,
|
||||
"show_edge": False,
|
||||
"pad_edge": False,
|
||||
"title_justify": "left",
|
||||
}
|
||||
opts.update(kwargs)
|
||||
return Table(**opts)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_repl_output_already_prepared",
|
||||
"_repl_table_width",
|
||||
"print_repl_json",
|
||||
"print_repl_table",
|
||||
"refresh_welcome_poster",
|
||||
"repl_clear_screen",
|
||||
"repl_print",
|
||||
"repl_render_launch_poster",
|
||||
"repl_table",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user