Files
wehub-resource-sync 4b6817381b
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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

534 lines
18 KiB
Python

"""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"]