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,82 @@
|
||||
# tools/interactive_shell/ package rules
|
||||
|
||||
These instructions apply to `tools/interactive_shell/` and all of its
|
||||
subdirectories. The repo-root `AGENTS.md` still applies.
|
||||
|
||||
## Purpose
|
||||
|
||||
This package hosts the **action-tool implementations** the agent harness calls
|
||||
during an interactive-shell turn — the concrete `run` bodies behind the action
|
||||
tools listed in `core/agent_harness/tools/action_tools.py`:
|
||||
|
||||
- `actions/` — the action tools themselves (`shell_run`, `cli_exec`,
|
||||
`slash_invoke`, `code_implement`, `investigation_start`, `alert_sample`,
|
||||
`assistant_handoff`, `llm_set_provider`, `synthetic_run`, `task_cancel`).
|
||||
- `shell/` — shell command parsing, execution policy, and the
|
||||
`run_shell_command`/`run_cd`/`run_pwd` runner behind `actions/shell.py`.
|
||||
- `synthetic/` — the synthetic-test runner behind `actions/synthetic.py`.
|
||||
- `implementation/` — the `/implement` (Claude Code) launcher.
|
||||
- `shared/` — cross-tool helpers (e.g. investigation launch, `allow_tool`).
|
||||
- `contracts` — imported by `command_registry.slash_catalog` during early import
|
||||
wiring; see the `__init__.py` docstring for why tool submodules must be
|
||||
imported explicitly rather than eagerly here (circular-import avoidance).
|
||||
|
||||
Per the repo-root `AGENTS.md`, `tools/` owns every `@tool(...)` function and
|
||||
`RegisteredTool`/`BaseTool` class; this package is the interactive-shell slice of
|
||||
that ownership.
|
||||
|
||||
## Subprocess runner decoupling (T-03)
|
||||
|
||||
Subprocess runners must stay split into two layers:
|
||||
|
||||
1. **Pure execution** under `tools/interactive_shell/` — spawn, communicate,
|
||||
planning, structured results. No Rich, no `surfaces.interactive_shell.ui`,
|
||||
no `execution_confirm`.
|
||||
2. **REPL presentation** under `surfaces/interactive_shell/runtime/subprocess_runner/`
|
||||
— `ReplSubprocessPresenter` implements `SubprocessPresenter` and is injected
|
||||
through `ActionToolContext.subprocess_presenter` from `action_turn.py`.
|
||||
|
||||
Shared stdlib-only helpers live in `tools/interactive_shell/subprocess.py`.
|
||||
Rich stream relay stays in `surfaces/.../subprocess_runner/task_streaming.py`.
|
||||
|
||||
**Do NOT reintroduce** `surfaces.interactive_shell.ui` or
|
||||
`surfaces.interactive_shell.runtime.subprocess_runner` imports in:
|
||||
|
||||
- `shell/runner.py`
|
||||
- `synthetic/runner.py`
|
||||
- `implementation/claude_code_executor.py`
|
||||
- `actions/cli_command.py`
|
||||
|
||||
Enforced by `tests/tools/interactive_shell/test_import_boundaries.py`.
|
||||
|
||||
## Dependency direction
|
||||
|
||||
```text
|
||||
surfaces/interactive_shell (ui, dispatch, ReplSubprocessPresenter)
|
||||
-> tools/interactive_shell (action-tool implementations)
|
||||
-> core/agent_harness (session types, ports)
|
||||
```
|
||||
|
||||
`tools/interactive_shell/` **may** depend on:
|
||||
|
||||
- `core.agent_harness.session` runtime types (`Session`, `TaskKind`,
|
||||
`TaskRecord`, `TaskStatus`) for session/task bookkeeping.
|
||||
- `integrations/` when an action tool wraps an integration client (e.g. Claude
|
||||
Code in `implementation/claude_code_executor.py`).
|
||||
|
||||
It must **not**:
|
||||
|
||||
- Be imported by `core/agent_harness/` (enforced by import-boundary tests).
|
||||
- Import `surfaces.interactive_shell.*` from subprocess runners (see T-03 list
|
||||
above). Other action tools (`slash`, `investigation`, etc.) may still reach
|
||||
into the surface temporarily — that is separate T-4 debt.
|
||||
- Grow eager submodule imports in `__init__.py` (keep the explicit-import
|
||||
discipline documented there; several tool modules import back into
|
||||
`command_registry`, so eager imports here reintroduce circular imports).
|
||||
|
||||
## Remaining surface coupling (T-4 debt, out of T-03 scope)
|
||||
|
||||
Several non-subprocess action tools still import `surfaces.interactive_shell.ui`
|
||||
or `command_registry` directly (`slash.py`, `investigation.py`, etc.). Do not
|
||||
add new surface imports there without a port; subprocess runners are the
|
||||
reference pattern going forward.
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Interactive-shell tools.
|
||||
|
||||
Import tool submodules explicitly (for example
|
||||
``tools.interactive_shell.actions.slash``)
|
||||
rather than relying on this package initializer to eagerly import them.
|
||||
|
||||
``contracts`` lives in this package and is imported by
|
||||
``command_registry.slash_catalog`` during early import wiring. Eagerly importing
|
||||
the tool submodules here (several of which import back into ``command_registry``)
|
||||
would reintroduce a circular import during interactive-shell startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
TOOL_MODULES = (
|
||||
"actions.assistant_handoff",
|
||||
"actions.cli_command",
|
||||
"actions.implementation",
|
||||
"actions.investigation",
|
||||
"actions.llm_provider",
|
||||
"actions.sample_alert",
|
||||
"actions.sentry_fix",
|
||||
"actions.shell",
|
||||
"actions.slash",
|
||||
"actions.synthetic",
|
||||
"actions.task_cancel",
|
||||
)
|
||||
|
||||
__all__ = ["TOOL_MODULES"]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Stable action-tool names used by scenario fixtures and tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
ToolKind = Literal[
|
||||
"slash",
|
||||
"shell",
|
||||
"investigation",
|
||||
"alert",
|
||||
"sample_alert",
|
||||
"synthetic_test",
|
||||
"task_cancel",
|
||||
"cli_command",
|
||||
"implementation",
|
||||
"llm_provider",
|
||||
"assistant_handoff",
|
||||
]
|
||||
|
||||
TOOL_KIND_TO_NAME: dict[ToolKind, str] = {
|
||||
"slash": "slash_invoke",
|
||||
"shell": "shell_run",
|
||||
"investigation": "investigation_start",
|
||||
"alert": "alert_sample",
|
||||
"sample_alert": "alert_sample",
|
||||
"synthetic_test": "synthetic_run",
|
||||
"task_cancel": "task_cancel",
|
||||
"cli_command": "cli_exec",
|
||||
"implementation": "code_implement",
|
||||
"llm_provider": "llm_set_provider",
|
||||
"assistant_handoff": "assistant_handoff",
|
||||
}
|
||||
|
||||
__all__ = ["TOOL_KIND_TO_NAME", "ToolKind"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Interactive-shell action tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Assistant handoff pseudo-tool for non-executable requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.agent_harness.tools.tool_context import (
|
||||
ActionToolContext,
|
||||
execute_with_action_context,
|
||||
object_schema,
|
||||
string_property,
|
||||
)
|
||||
from core.tool_framework.registered_tool import RegisteredTool
|
||||
|
||||
|
||||
def execute_assistant_handoff_tool(args: dict[str, Any], ctx: ActionToolContext) -> bool:
|
||||
_ = args
|
||||
_ = ctx
|
||||
# Handoffs are informational planning outputs and intentionally
|
||||
# execute no terminal side effects.
|
||||
return True
|
||||
|
||||
|
||||
def run_assistant_handoff(*, content: str, context: Any) -> dict[str, Any]:
|
||||
return execute_with_action_context(
|
||||
{"content": content},
|
||||
context,
|
||||
execute_assistant_handoff_tool,
|
||||
)
|
||||
|
||||
|
||||
assistant_handoff_tool = RegisteredTool(
|
||||
name="assistant_handoff",
|
||||
description=(
|
||||
"Mark a request as non-executable and hand off to assistant response generation. "
|
||||
"Use for informational, conversational, ambiguous, or non-actionable requests, "
|
||||
"including a bare pasted alert JSON/YAML/key-value blob or bare incident statement "
|
||||
"when the user did not explicitly ask to investigate, analyze, diagnose, RCA, or "
|
||||
"root-cause it."
|
||||
),
|
||||
input_schema=object_schema(
|
||||
properties={
|
||||
"content": string_property(
|
||||
description=(
|
||||
"Concise assistant handoff text for informational, ambiguous, "
|
||||
"or non-executable requests. Prefer structured tags when the "
|
||||
"topic is known — e.g. docs:datadog_setup, chat:greeting, "
|
||||
"provider:local_llama_connect for vague local-model setup."
|
||||
),
|
||||
min_length=1,
|
||||
)
|
||||
},
|
||||
required=("content",),
|
||||
),
|
||||
source="interactive_shell",
|
||||
surfaces=("action",),
|
||||
parallel_safe=False,
|
||||
accepts_runtime_context=True,
|
||||
run=run_assistant_handoff,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["assistant_handoff_tool", "execute_assistant_handoff_tool"]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""CLI command tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.agent_harness.tools.tool_context import (
|
||||
ActionToolContext,
|
||||
capability_available_from_sources,
|
||||
execute_with_action_context,
|
||||
object_schema,
|
||||
string_property,
|
||||
)
|
||||
from core.tool_framework.registered_tool import RegisteredTool
|
||||
from tools.interactive_shell.cli import run_opensre_cli_command
|
||||
from tools.interactive_shell.subprocess import require_subprocess_presenter
|
||||
|
||||
|
||||
def execute_cli_command_tool(args: dict[str, Any], ctx: ActionToolContext) -> bool:
|
||||
payload = str(args.get("payload", "")).strip()
|
||||
if not payload:
|
||||
return False
|
||||
run_opensre_cli_command(payload, require_subprocess_presenter(ctx))
|
||||
return True
|
||||
|
||||
|
||||
def run_cli_command(*, payload: str, context: Any) -> dict[str, Any]:
|
||||
return execute_with_action_context({"payload": payload}, context, execute_cli_command_tool)
|
||||
|
||||
|
||||
cli_exec_tool = RegisteredTool(
|
||||
name="cli_exec",
|
||||
description=(
|
||||
"Run an `opensre` CLI subcommand payload (without the leading `opensre ` prefix). "
|
||||
"Prefer allowed operational families such as health/status/list/show/integrations/"
|
||||
"synthetic checks; avoid unrelated or dangerous payloads."
|
||||
),
|
||||
input_schema=object_schema(
|
||||
properties={
|
||||
"payload": string_property(
|
||||
description=(
|
||||
"CLI payload passed to `opensre` without the leading command prefix "
|
||||
"(for example: `integrations list`, `health`, `synthetic run ...`). "
|
||||
"Must not start with `opensre `."
|
||||
),
|
||||
min_length=1,
|
||||
)
|
||||
},
|
||||
required=("payload",),
|
||||
),
|
||||
source="interactive_shell",
|
||||
surfaces=("action",),
|
||||
parallel_safe=False,
|
||||
accepts_runtime_context=True,
|
||||
run=run_cli_command,
|
||||
is_available=lambda sources: capability_available_from_sources(sources, "cli_commands"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["cli_exec_tool", "execute_cli_command_tool"]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Implementation tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.agent_harness.tools.tool_context import (
|
||||
ActionToolContext,
|
||||
capability_available_from_sources,
|
||||
execute_with_action_context,
|
||||
object_schema,
|
||||
string_property,
|
||||
)
|
||||
from core.tool_framework.registered_tool import RegisteredTool
|
||||
from tools.interactive_shell.implementation.claude_code_executor import (
|
||||
run_claude_code_implementation,
|
||||
)
|
||||
from tools.interactive_shell.subprocess import require_subprocess_presenter
|
||||
|
||||
|
||||
def execute_implementation_tool(args: dict[str, Any], ctx: ActionToolContext) -> bool:
|
||||
task = str(args.get("task", "")).strip()
|
||||
if not task:
|
||||
return False
|
||||
run_claude_code_implementation(task, require_subprocess_presenter(ctx))
|
||||
return True
|
||||
|
||||
|
||||
def run_implementation(*, task: str, context: Any) -> dict[str, Any]:
|
||||
return execute_with_action_context({"task": task}, context, execute_implementation_tool)
|
||||
|
||||
|
||||
code_implement_tool = RegisteredTool(
|
||||
name="code_implement",
|
||||
description="Run code implementation workflow using Claude Code.",
|
||||
input_schema=object_schema(
|
||||
properties={
|
||||
"task": string_property(
|
||||
description="Implementation task to execute in the codebase.",
|
||||
min_length=1,
|
||||
)
|
||||
},
|
||||
required=("task",),
|
||||
),
|
||||
source="interactive_shell",
|
||||
surfaces=("action",),
|
||||
parallel_safe=False,
|
||||
accepts_runtime_context=True,
|
||||
run=run_implementation,
|
||||
is_available=lambda sources: capability_available_from_sources(sources, "implementation"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["code_implement_tool", "execute_implementation_tool"]
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Investigation tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.agent_harness.tools.tool_context import (
|
||||
ActionToolContext,
|
||||
execute_with_action_context,
|
||||
object_schema,
|
||||
string_property,
|
||||
)
|
||||
from core.tool_framework.registered_tool import RegisteredTool
|
||||
from platform.common.task_types import TaskRecord
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from tools.interactive_shell.shared.investigation_launch import launch_investigation
|
||||
|
||||
|
||||
def normalize_investigation_alert_text(raw: str) -> str:
|
||||
"""Strip outer quotes models often echo from user-quoted investigation payloads."""
|
||||
value = raw.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
return value[1:-1].strip()
|
||||
return value
|
||||
|
||||
|
||||
def run_text_investigation(
|
||||
alert_text: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
action_already_listed: bool = False,
|
||||
) -> None:
|
||||
from surfaces.interactive_shell.runtime.investigation_adapter import (
|
||||
repl_investigation_launch_ports,
|
||||
run_investigation_for_session,
|
||||
)
|
||||
|
||||
def _run(task: TaskRecord) -> dict[str, object]:
|
||||
return run_investigation_for_session(
|
||||
alert_text=alert_text,
|
||||
context_overrides=session.accumulated_context or None,
|
||||
cancel_requested=task.cancel_requested,
|
||||
)
|
||||
|
||||
def _start_background() -> None:
|
||||
from surfaces.interactive_shell.runtime.background.runner import (
|
||||
start_background_text_investigation,
|
||||
)
|
||||
|
||||
start_background_text_investigation(
|
||||
alert_text=alert_text,
|
||||
session=session,
|
||||
console=console,
|
||||
display_command="background free-text investigation",
|
||||
)
|
||||
|
||||
launch_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
ports=repl_investigation_launch_ports(),
|
||||
tool_type="investigation",
|
||||
action_summary=f'investigation from text "{alert_text}"',
|
||||
announce_label="investigation",
|
||||
announce_value=alert_text,
|
||||
record_value=alert_text,
|
||||
foreground_task_command=f"investigate:{alert_text}",
|
||||
exception_context="surfaces.interactive_shell.text_investigation",
|
||||
run=_run,
|
||||
start_background=_start_background,
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
action_already_listed=action_already_listed,
|
||||
)
|
||||
|
||||
|
||||
def execute_investigation_tool(args: dict[str, Any], ctx: ActionToolContext) -> bool:
|
||||
alert_text = normalize_investigation_alert_text(str(args.get("alert_text", "")))
|
||||
if not alert_text:
|
||||
return False
|
||||
run_text_investigation(
|
||||
alert_text,
|
||||
ctx.session,
|
||||
ctx.console,
|
||||
confirm_fn=ctx.confirm_fn,
|
||||
is_tty=ctx.is_tty,
|
||||
action_already_listed=ctx.action_already_listed,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def run_investigation(*, alert_text: str, context: Any) -> dict[str, Any]:
|
||||
return execute_with_action_context(
|
||||
{"alert_text": alert_text},
|
||||
context,
|
||||
execute_investigation_tool,
|
||||
)
|
||||
|
||||
|
||||
investigation_start_tool = RegisteredTool(
|
||||
name="investigation_start",
|
||||
description=(
|
||||
"Start an investigation with the provided alert text or quoted payload. "
|
||||
"Use whenever the user explicitly instructs you to investigate, RCA, "
|
||||
"diagnose, analyze, root-cause, or send an investigation payload — including "
|
||||
"'investigate why X ...' and placeholder quoted text like 'hello world' — "
|
||||
"regardless of CONNECTED INTEGRATIONS. In compound turns like `run /remote "
|
||||
'and then investigate "hello world"`, emit this as a separate second tool '
|
||||
"call; never drop the quoted investigation after emitting the slash command. "
|
||||
"Do NOT use for bare incident statements with no investigate verb, generic "
|
||||
"'Run an investigation.' with no subject, sample/demo alerts, or plain data "
|
||||
"lookups."
|
||||
),
|
||||
input_schema=object_schema(
|
||||
properties={
|
||||
"alert_text": string_property(
|
||||
description="Alert text or incident details to investigate.",
|
||||
min_length=1,
|
||||
)
|
||||
},
|
||||
required=("alert_text",),
|
||||
),
|
||||
source="interactive_shell",
|
||||
surfaces=("action",),
|
||||
parallel_safe=False,
|
||||
accepts_runtime_context=True,
|
||||
run=run_investigation,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"execute_investigation_tool",
|
||||
"investigation_start_tool",
|
||||
"normalize_investigation_alert_text",
|
||||
"run_text_investigation",
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""LLM provider switch tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.markup import escape
|
||||
|
||||
from core.agent_harness.tools.tool_context import (
|
||||
ActionToolContext,
|
||||
capability_available_from_sources,
|
||||
execute_with_action_context,
|
||||
object_schema,
|
||||
)
|
||||
from core.tool_framework.registered_tool import RegisteredTool
|
||||
from surfaces.interactive_shell.command_registry import (
|
||||
switch_llm_provider,
|
||||
switch_reasoning_model,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
|
||||
from tools.interactive_shell.shared import allow_tool
|
||||
|
||||
|
||||
def _provider_values() -> tuple[str, ...]:
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
|
||||
return tuple(sorted(PROVIDER_BY_VALUE.keys()))
|
||||
|
||||
|
||||
def _target_property_schema() -> dict[str, Any]:
|
||||
provider_values = _provider_values()
|
||||
provider_list = ", ".join(provider_values)
|
||||
return {
|
||||
"description": (
|
||||
"Target passed to `/model set <target>`. Use one of the provider names "
|
||||
f"({provider_list}) to switch providers, or pass a valid reasoning model "
|
||||
"name for the active provider."
|
||||
),
|
||||
"oneOf": [
|
||||
{"type": "string", "enum": list(provider_values)},
|
||||
{"type": "string", "minLength": 1},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _apply_model_set_target(target: str, ctx: ActionToolContext) -> bool:
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
|
||||
candidate = target.strip()
|
||||
if candidate.lower() in PROVIDER_BY_VALUE:
|
||||
return switch_llm_provider(candidate, ctx.console)
|
||||
return switch_reasoning_model(candidate, ctx.console)
|
||||
|
||||
|
||||
def execute_llm_provider_tool(args: dict[str, Any], ctx: ActionToolContext) -> bool:
|
||||
target = str(args.get("target", args.get("provider", ""))).strip()
|
||||
if not target:
|
||||
return False
|
||||
policy = allow_tool("switch_llm_provider")
|
||||
if not execution_allowed(
|
||||
policy,
|
||||
session=ctx.session,
|
||||
console=ctx.console,
|
||||
action_summary=f"/model set {target}",
|
||||
confirm_fn=ctx.confirm_fn,
|
||||
is_tty=ctx.is_tty,
|
||||
action_already_listed=ctx.action_already_listed,
|
||||
):
|
||||
return True
|
||||
ctx.console.print(f"[bold]$ /model set {escape(target)}[/bold]")
|
||||
ok = _apply_model_set_target(target, ctx)
|
||||
ctx.session.record("slash", f"/model set {target}", ok=ok)
|
||||
return True
|
||||
|
||||
|
||||
def run_llm_provider(*, target: str, context: Any) -> dict[str, Any]:
|
||||
return execute_with_action_context({"target": target}, context, execute_llm_provider_tool)
|
||||
|
||||
|
||||
llm_set_provider_tool = RegisteredTool(
|
||||
name="llm_set_provider",
|
||||
description="Switch the active LLM provider or reasoning model.",
|
||||
input_schema=object_schema(
|
||||
properties={"target": _target_property_schema()},
|
||||
required=("target",),
|
||||
),
|
||||
source="interactive_shell",
|
||||
surfaces=("action",),
|
||||
parallel_safe=False,
|
||||
accepts_runtime_context=True,
|
||||
run=run_llm_provider,
|
||||
is_available=lambda sources: capability_available_from_sources(sources, "llm_provider"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["execute_llm_provider_tool", "llm_set_provider_tool"]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Sample alert tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.agent_harness.tools.tool_context import (
|
||||
ActionToolContext,
|
||||
execute_with_action_context,
|
||||
object_schema,
|
||||
string_property,
|
||||
)
|
||||
from core.tool_framework.registered_tool import RegisteredTool
|
||||
from platform.common.task_types import TaskRecord
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from tools.interactive_shell.shared.investigation_launch import launch_investigation
|
||||
|
||||
_SAMPLE_ALERT_TEMPLATES = ("generic",)
|
||||
|
||||
|
||||
def run_sample_alert(
|
||||
template_name: str,
|
||||
session: Session,
|
||||
console: Console,
|
||||
*,
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
action_already_listed: bool = False,
|
||||
) -> None:
|
||||
from surfaces.interactive_shell.runtime.investigation_adapter import (
|
||||
repl_investigation_launch_ports,
|
||||
run_sample_alert_for_session,
|
||||
)
|
||||
|
||||
def _run(task: TaskRecord) -> dict[str, object]:
|
||||
return run_sample_alert_for_session(
|
||||
template_name=template_name,
|
||||
context_overrides=session.accumulated_context or None,
|
||||
cancel_requested=task.cancel_requested,
|
||||
)
|
||||
|
||||
def _start_background() -> None:
|
||||
from surfaces.interactive_shell.runtime.background.runner import (
|
||||
start_background_template_investigation,
|
||||
)
|
||||
|
||||
start_background_template_investigation(
|
||||
template_name=template_name,
|
||||
session=session,
|
||||
console=console,
|
||||
display_command=f"sample alert:{template_name}",
|
||||
)
|
||||
|
||||
launch_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
ports=repl_investigation_launch_ports(),
|
||||
tool_type="sample_alert",
|
||||
action_summary=f"sample alert investigation ({template_name})",
|
||||
announce_label="sample alert",
|
||||
announce_value=template_name,
|
||||
record_value=f"sample:{template_name}",
|
||||
foreground_task_command=f"sample alert:{template_name}",
|
||||
exception_context="surfaces.interactive_shell.sample_alert",
|
||||
run=_run,
|
||||
start_background=_start_background,
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
action_already_listed=action_already_listed,
|
||||
)
|
||||
|
||||
|
||||
def execute_sample_alert_tool(args: dict[str, Any], ctx: ActionToolContext) -> bool:
|
||||
template = str(args.get("template", "")).strip()
|
||||
if not template:
|
||||
return False
|
||||
run_sample_alert(
|
||||
template,
|
||||
ctx.session,
|
||||
ctx.console,
|
||||
confirm_fn=ctx.confirm_fn,
|
||||
is_tty=ctx.is_tty,
|
||||
action_already_listed=ctx.action_already_listed,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def run_sample_alert_action(*, template: str, context: Any) -> dict[str, Any]:
|
||||
return execute_with_action_context(
|
||||
{"template": template},
|
||||
context,
|
||||
execute_sample_alert_tool,
|
||||
)
|
||||
|
||||
|
||||
alert_sample_tool = RegisteredTool(
|
||||
name="alert_sample",
|
||||
description=(
|
||||
"Run the built-in synthetic sample alert end-to-end (read alert → "
|
||||
"investigate → diagnose). Use for any request to run/try/start/launch/"
|
||||
"fire/trigger/investigate/look at a 'sample alert', 'test alert', or "
|
||||
"'demo alert' (e.g. 'investigate a sample test alert?', 'kick off a "
|
||||
"sample alert'). These requests carry NO real pasted alert text — that "
|
||||
"is what separates them from investigation_start. Prefer this over "
|
||||
"investigation_start and assistant_handoff for sample/test/demo alerts, "
|
||||
"regardless of the verb or a trailing '?'."
|
||||
),
|
||||
input_schema=object_schema(
|
||||
properties={
|
||||
"template": string_property(
|
||||
description="Sample alert template name to run.",
|
||||
enum=_SAMPLE_ALERT_TEMPLATES,
|
||||
)
|
||||
},
|
||||
required=("template",),
|
||||
),
|
||||
source="interactive_shell",
|
||||
surfaces=("action",),
|
||||
parallel_safe=False,
|
||||
accepts_runtime_context=True,
|
||||
run=run_sample_alert_action,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["alert_sample_tool", "execute_sample_alert_tool", "run_sample_alert"]
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Action tool: dispatch a natural-language "fix this Sentry issue" request.
|
||||
|
||||
This is the intake-routing half of the Sentry issue-fix flow. It exposes the
|
||||
``fix_sentry_issue`` capability to the interactive-shell **action agent** as a
|
||||
proper AgentTool, so a request like *"fix this Sentry issue <url> and open a
|
||||
PR"* is dispatched by the action LLM (semantic intent) rather than by any
|
||||
regex/keyword shortcut. The tool is only offered when the fix capability is
|
||||
opted in (``PI_ISSUE_FIX_ENABLED``); the PR-shipping gate + token check stay
|
||||
inside ``fix_sentry_issue`` itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.agent_harness.tools.tool_context import (
|
||||
ActionToolContext,
|
||||
execute_with_action_context,
|
||||
object_schema,
|
||||
string_property,
|
||||
)
|
||||
from core.tool_framework.registered_tool import RegisteredTool
|
||||
from tools.cross_vendor.fix_sentry_issue import fix_sentry_issue
|
||||
from tools.cross_vendor.fix_sentry_issue.runner import is_issue_fix_enabled
|
||||
|
||||
|
||||
def _render_result(console: Any, out: dict[str, Any]) -> None:
|
||||
"""Print a concise, human-readable summary of the tool result."""
|
||||
if not out.get("success"):
|
||||
console.print(
|
||||
f"[red]Could not fix the Sentry issue[/] "
|
||||
f"({out.get('error_kind') or 'error'}): {out.get('error') or ''}"
|
||||
)
|
||||
# Point the user at their fix so they can recover manually, with guidance
|
||||
# matched to how far shipping got on the new branch:
|
||||
# commit_failed -> on the branch but NOT committed (edits staged only)
|
||||
# push_failed -> committed but not pushed
|
||||
# pr_failed -> pushed, only the PR call failed
|
||||
branch = out.get("branch_name")
|
||||
if branch:
|
||||
kind = out.get("error_kind")
|
||||
if kind == "commit_failed":
|
||||
console.print(
|
||||
f"[dim]Your changes are on branch [cyan]{branch}[/] but not committed — "
|
||||
"commit them, push, and open the PR manually.[/]"
|
||||
)
|
||||
elif kind == "pr_failed":
|
||||
console.print(
|
||||
f"[dim]Your fix is pushed to branch [cyan]{branch}[/] — "
|
||||
"open the PR manually.[/]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f"[dim]Your fix is committed on branch [cyan]{branch}[/] — "
|
||||
"push it and open the PR manually.[/]"
|
||||
)
|
||||
elif out.get("changed_files"):
|
||||
console.print("[dim]The proposed fix is still in your working tree.[/]")
|
||||
return
|
||||
|
||||
console.print(f"[green]Fixed Sentry issue {out.get('issue_id')}[/].")
|
||||
if out.get("summary"):
|
||||
console.print(str(out["summary"]))
|
||||
for path in out.get("changed_files") or []:
|
||||
console.print(f" • {path}")
|
||||
if out.get("pr_url"):
|
||||
console.print(
|
||||
f"[bold]Opened pull request:[/] {out['pr_url']} "
|
||||
f"(branch [cyan]{out.get('branch_name')}[/])"
|
||||
)
|
||||
else:
|
||||
console.print("[dim]Diff left in your working tree (no PR requested).[/]")
|
||||
|
||||
|
||||
def execute_sentry_fix_tool(args: dict[str, Any], ctx: ActionToolContext) -> bool:
|
||||
sentry_url = str(args.get("sentry_url", "")).strip()
|
||||
if not sentry_url:
|
||||
return False
|
||||
open_pr = bool(args.get("open_pr", False))
|
||||
|
||||
ctx.console.print(
|
||||
f"[bold]Fixing Sentry issue[/] {sentry_url}"
|
||||
+ (" and opening a pull request…" if open_pr else "…")
|
||||
)
|
||||
out = fix_sentry_issue.run(sentry_url=sentry_url, open_pr=open_pr)
|
||||
_render_result(ctx.console, out)
|
||||
return True
|
||||
|
||||
|
||||
def run_sentry_fix(*, sentry_url: str, context: Any, open_pr: bool = False) -> dict[str, Any]:
|
||||
return execute_with_action_context(
|
||||
{"sentry_url": sentry_url, "open_pr": open_pr},
|
||||
context,
|
||||
execute_sentry_fix_tool,
|
||||
)
|
||||
|
||||
|
||||
fix_sentry_issue_start_tool = RegisteredTool(
|
||||
name="fix_sentry_issue_start",
|
||||
description=(
|
||||
"Fix a Sentry issue in code with a coding agent, given a Sentry issue URL. "
|
||||
"Use whenever the user asks to FIX, patch, resolve in code, or open/raise a pull "
|
||||
"request for a Sentry issue and provides a Sentry issue URL — e.g. 'fix this sentry "
|
||||
"issue <url>' or 'fix <url> and open a PR'. Set open_pr=true when they ask to "
|
||||
"open/create/raise a PR or to ship the fix; otherwise false to only produce a diff. "
|
||||
"Do NOT use for investigate/RCA/diagnose/analyze requests (use investigation_start), "
|
||||
"for non-Sentry URLs, or when no Sentry issue URL is provided."
|
||||
),
|
||||
input_schema=object_schema(
|
||||
properties={
|
||||
"sentry_url": string_property(
|
||||
description="The Sentry issue URL to fix (e.g. https://<org>.sentry.io/issues/<id>/).",
|
||||
min_length=1,
|
||||
),
|
||||
"open_pr": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"True when the user asks to open/create/raise a pull request or to ship "
|
||||
"the fix; false to only produce a reviewable diff."
|
||||
),
|
||||
},
|
||||
},
|
||||
required=("sentry_url",),
|
||||
),
|
||||
source="interactive_shell",
|
||||
surfaces=("action",),
|
||||
side_effect_level="mutating",
|
||||
parallel_safe=False,
|
||||
accepts_runtime_context=True,
|
||||
is_available=lambda _sources: is_issue_fix_enabled(),
|
||||
run=run_sentry_fix,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"execute_sentry_fix_tool",
|
||||
"fix_sentry_issue_start_tool",
|
||||
"run_sentry_fix",
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Shell execution tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.agent_harness.tools.tool_context import (
|
||||
ActionToolContext,
|
||||
capability_available_from_sources,
|
||||
execute_with_action_context,
|
||||
object_schema,
|
||||
string_property,
|
||||
)
|
||||
from core.tool_framework.registered_tool import RegisteredTool
|
||||
from tools.interactive_shell.shell.runner import run_shell_command
|
||||
from tools.interactive_shell.subprocess import require_subprocess_presenter
|
||||
|
||||
|
||||
def execute_shell_tool(args: dict[str, Any], ctx: ActionToolContext) -> dict[str, Any]:
|
||||
command = str(args.get("command", "")).strip()
|
||||
if not command:
|
||||
return {"ok": False, "command": "", "response_text": "missing shell command"}
|
||||
return run_shell_command(command, require_subprocess_presenter(ctx))
|
||||
|
||||
|
||||
def run_shell(*, command: str, context: Any) -> dict[str, Any]:
|
||||
return execute_with_action_context({"command": command}, context, execute_shell_tool)
|
||||
|
||||
|
||||
shell_run_tool = RegisteredTool(
|
||||
name="shell_run",
|
||||
description=(
|
||||
"Run a narrowly scoped local diagnostic shell command. Use for read-only inspection "
|
||||
"or controlled operational steps already requested by the user; avoid destructive, "
|
||||
"credential-exfiltrating, or unrelated commands."
|
||||
),
|
||||
input_schema=object_schema(
|
||||
properties={
|
||||
"command": string_property(
|
||||
description=(
|
||||
"Exact shell command to execute. Prefer safe diagnostics (for example: "
|
||||
"`ls`, `pwd`, `git status`, `uv run python -m pytest ...`). Do not use "
|
||||
"commands that wipe data or alter unrelated system state."
|
||||
),
|
||||
min_length=1,
|
||||
)
|
||||
},
|
||||
required=("command",),
|
||||
),
|
||||
source="interactive_shell",
|
||||
surfaces=("action",),
|
||||
parallel_safe=False,
|
||||
accepts_runtime_context=True,
|
||||
run=run_shell,
|
||||
is_available=lambda sources: capability_available_from_sources(sources, "shell_commands"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["execute_shell_tool", "shell_run_tool"]
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Slash command tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.markup import escape
|
||||
|
||||
from core.agent_harness.session.terminal_access import (
|
||||
agent_turn_executed_slashes,
|
||||
exclusive_stdin_active,
|
||||
session_terminal,
|
||||
set_auto_command,
|
||||
set_turn_outcome_hint,
|
||||
)
|
||||
from core.agent_harness.tools.tool_context import (
|
||||
ActionToolContext,
|
||||
capability_available_from_sources,
|
||||
execute_with_action_context,
|
||||
)
|
||||
from core.tool_framework.registered_tool import RegisteredTool
|
||||
from surfaces.interactive_shell.command_registry import SLASH_COMMANDS, dispatch_slash
|
||||
from surfaces.interactive_shell.command_registry.slash_catalog import (
|
||||
slash_invoke_input_schema,
|
||||
slash_invoke_tool_description,
|
||||
)
|
||||
from surfaces.interactive_shell.ui import BOLD_BRAND, DIM, repl_tty_interactive
|
||||
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
|
||||
from surfaces.interactive_shell.utils.telemetry.turn_outcome import format_terminal_turn_outcome
|
||||
from tools.interactive_shell.shared import plan_foreground_tool
|
||||
|
||||
# Slash commands that drive a raw-stdin inline picker or wizard (questionary /
|
||||
# repl_choose_one). When the action agent resolves free text (e.g. "remove
|
||||
# github") into one of these, the REPL loop has NOT reserved exclusive stdin for
|
||||
# the turn — it only does so for deterministically-typed commands. Running the
|
||||
# picker inline then races the concurrently open prompt_async() for stdin and the
|
||||
# terminal's cursor-position replies (ESC[row;colR) leak into the input line as
|
||||
# literal keystrokes. Defer them through ``set_auto_command`` so the loop
|
||||
# re-dispatches the command as a deterministic turn it runs with exclusive stdin.
|
||||
_INTERACTIVE_PICKER_MENUS: frozenset[str] = frozenset({"/auth", "/login", "/integrations", "/mcp"})
|
||||
_INTERACTIVE_PICKER_SUBCOMMANDS: frozenset[tuple[str, str]] = frozenset(
|
||||
{
|
||||
("/auth", "login"),
|
||||
("/auth", "logout"),
|
||||
("/integrations", "setup"),
|
||||
("/integrations", "remove"),
|
||||
("/mcp", "connect"),
|
||||
("/mcp", "disconnect"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _slash_drives_interactive_picker(
|
||||
name: str,
|
||||
slash_args: list[str],
|
||||
*,
|
||||
session: Any,
|
||||
is_tty: bool | None,
|
||||
) -> bool:
|
||||
"""True when a planned slash command opens a raw-stdin inline picker/wizard.
|
||||
|
||||
Only relevant in an interactive REPL with a terminal facet: gateway/headless
|
||||
sessions always run inline, and non-TTY turns must not queue back to a REPL
|
||||
loop that does not exist (e.g. gateway running under tmux with a TTY stdin).
|
||||
"""
|
||||
if is_tty is False or session_terminal(session) is None:
|
||||
return False
|
||||
if not repl_tty_interactive():
|
||||
return False
|
||||
if name == "/login":
|
||||
return True
|
||||
if not slash_args:
|
||||
return name in _INTERACTIVE_PICKER_MENUS
|
||||
return (name, slash_args[0].lower()) in _INTERACTIVE_PICKER_SUBCOMMANDS
|
||||
|
||||
|
||||
def _dispatch_and_translate_exit(command: str, ctx: ActionToolContext, **kwargs: Any) -> bool:
|
||||
should_continue = dispatch_slash(
|
||||
command,
|
||||
ctx.session,
|
||||
ctx.console,
|
||||
confirm_fn=ctx.confirm_fn,
|
||||
is_tty=ctx.is_tty,
|
||||
**kwargs,
|
||||
)
|
||||
if not should_continue and ctx.request_exit is not None:
|
||||
ctx.request_exit()
|
||||
return True
|
||||
|
||||
|
||||
def execute_slash_tool(args: dict[str, Any], ctx: ActionToolContext) -> bool:
|
||||
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 []
|
||||
full_command = " ".join([command, *parsed_args]) if parsed_args else command
|
||||
stripped = full_command.strip()
|
||||
if stripped == "/" or not stripped:
|
||||
return _dispatch_and_translate_exit(
|
||||
stripped or "/",
|
||||
ctx,
|
||||
)
|
||||
|
||||
parts = stripped.split()
|
||||
name = parts[0].lower()
|
||||
slash_args = parts[1:]
|
||||
cmd = SLASH_COMMANDS.get(name)
|
||||
if cmd is None:
|
||||
return _dispatch_and_translate_exit(
|
||||
stripped,
|
||||
ctx,
|
||||
)
|
||||
|
||||
if stripped in agent_turn_executed_slashes(ctx.session):
|
||||
return True
|
||||
|
||||
if _slash_drives_interactive_picker(
|
||||
name,
|
||||
slash_args,
|
||||
session=ctx.session,
|
||||
is_tty=ctx.is_tty,
|
||||
) and not exclusive_stdin_active(ctx.session):
|
||||
# Hand the picker back to the REPL loop instead of running it against the
|
||||
# live prompt: set_auto_command re-submits it as a deterministic turn
|
||||
# the loop dispatches with exclusive stdin, so no CPR replies leak in.
|
||||
# Do not record a slash history row here — dispatch_slash will record when
|
||||
# the queued command runs. Attach a turn hint for this turn's analytics.
|
||||
ctx.console.print(f"[{DIM}]Launching[/] [{BOLD_BRAND}]{escape(stripped)}[/]…")
|
||||
set_auto_command(ctx.session, stripped)
|
||||
set_turn_outcome_hint(ctx.session, f"queued {stripped} for exclusive stdin dispatch")
|
||||
return True
|
||||
|
||||
plan = plan_foreground_tool("slash", "slash")
|
||||
if not execution_allowed(
|
||||
plan.policy,
|
||||
session=ctx.session,
|
||||
console=ctx.console,
|
||||
action_summary=stripped,
|
||||
confirm_fn=ctx.confirm_fn,
|
||||
is_tty=ctx.is_tty,
|
||||
action_already_listed=ctx.action_already_listed,
|
||||
):
|
||||
ctx.session.record(
|
||||
"slash",
|
||||
stripped,
|
||||
ok=False,
|
||||
response_text=format_terminal_turn_outcome(stripped, kind="slash", ok=False),
|
||||
)
|
||||
return True
|
||||
|
||||
ctx.console.print(f"[bold]$ {escape(stripped)}[/bold]")
|
||||
_dispatch_and_translate_exit(
|
||||
stripped,
|
||||
ctx,
|
||||
policy_precleared=True,
|
||||
)
|
||||
agent_turn_executed_slashes(ctx.session).add(stripped)
|
||||
return True
|
||||
|
||||
|
||||
def run_slash(*, command: str, args: list[str] | None = None, context: Any) -> dict[str, Any]:
|
||||
return execute_with_action_context(
|
||||
{"command": command, "args": args or []},
|
||||
context,
|
||||
execute_slash_tool,
|
||||
)
|
||||
|
||||
|
||||
slash_invoke_tool = RegisteredTool(
|
||||
name="slash_invoke",
|
||||
description=slash_invoke_tool_description(),
|
||||
input_schema=slash_invoke_input_schema(),
|
||||
source="interactive_shell",
|
||||
surfaces=("action",),
|
||||
parallel_safe=False,
|
||||
accepts_runtime_context=True,
|
||||
run=run_slash,
|
||||
is_available=lambda sources: capability_available_from_sources(sources, "slash_commands"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["execute_slash_tool", "slash_invoke_tool"]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Synthetic test tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from config.constants.paths import SYNTHETIC_SCENARIOS_DIR
|
||||
from core.agent_harness.tools.tool_context import (
|
||||
ActionToolContext,
|
||||
capability_available_from_sources,
|
||||
execute_with_action_context,
|
||||
object_schema,
|
||||
string_property,
|
||||
)
|
||||
from core.tool_framework.registered_tool import RegisteredTool
|
||||
from tools.interactive_shell.subprocess import require_subprocess_presenter
|
||||
from tools.interactive_shell.synthetic.runner import run_synthetic_test
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def list_rds_postgres_scenarios() -> tuple[str, ...]:
|
||||
"""Enumerate available RDS Postgres synthetic scenario directory names."""
|
||||
if not SYNTHETIC_SCENARIOS_DIR.is_dir():
|
||||
return ()
|
||||
return tuple(
|
||||
sorted(
|
||||
entry.name
|
||||
for entry in SYNTHETIC_SCENARIOS_DIR.iterdir()
|
||||
if entry.is_dir()
|
||||
and len(entry.name) >= 5
|
||||
and entry.name[:3].isdigit()
|
||||
and entry.name[3] == "-"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def execute_synthetic_tool(args: dict[str, Any], ctx: ActionToolContext) -> bool:
|
||||
suite = str(args.get("suite", "")).strip()
|
||||
scenario = str(args.get("scenario", "")).strip()
|
||||
if not suite or not scenario:
|
||||
return False
|
||||
run_synthetic_test(f"{suite}:{scenario}", require_subprocess_presenter(ctx))
|
||||
return True
|
||||
|
||||
|
||||
def run_synthetic(*, suite: str, scenario: str, context: Any) -> dict[str, Any]:
|
||||
return execute_with_action_context(
|
||||
{"suite": suite, "scenario": scenario},
|
||||
context,
|
||||
execute_synthetic_tool,
|
||||
)
|
||||
|
||||
|
||||
synthetic_run_tool = RegisteredTool(
|
||||
name="synthetic_run",
|
||||
description=(
|
||||
"Run a synthetic scenario in a suite. Match the scenario id exactly from "
|
||||
"the user request: a bare numeric prefix selects the enum value with that "
|
||||
'same prefix, e.g. "005" -> "005-failover" and "004" -> '
|
||||
'"004-cpu-saturation-bad-query". Never substitute a neighboring numbered '
|
||||
"scenario when the user supplied a numeric id."
|
||||
),
|
||||
input_schema=object_schema(
|
||||
properties={
|
||||
"suite": string_property(
|
||||
description="Synthetic suite name.",
|
||||
enum=("rds_postgres",),
|
||||
),
|
||||
"scenario": string_property(
|
||||
description=(
|
||||
"Synthetic scenario id within the selected suite or `all`. "
|
||||
"For bare numeric requests, use the enum value with the same "
|
||||
"three-digit prefix."
|
||||
),
|
||||
enum=("all", *list_rds_postgres_scenarios()),
|
||||
),
|
||||
},
|
||||
required=("suite", "scenario"),
|
||||
),
|
||||
source="interactive_shell",
|
||||
surfaces=("action",),
|
||||
parallel_safe=False,
|
||||
accepts_runtime_context=True,
|
||||
run=run_synthetic,
|
||||
is_available=lambda sources: capability_available_from_sources(sources, "synthetic_suites"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["execute_synthetic_tool", "list_rds_postgres_scenarios", "synthetic_run_tool"]
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Task cancellation tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from rich.markup import escape
|
||||
|
||||
from core.agent_harness.tools.tool_context import (
|
||||
ActionToolContext,
|
||||
execute_with_action_context,
|
||||
object_schema,
|
||||
)
|
||||
from core.tool_framework.registered_tool import RegisteredTool
|
||||
from surfaces.interactive_shell.command_registry import dispatch_slash
|
||||
from surfaces.interactive_shell.runtime import TaskKind, TaskStatus
|
||||
from surfaces.interactive_shell.ui.execution_confirm import execution_allowed
|
||||
from tools.interactive_shell.shared import plan_foreground_tool
|
||||
|
||||
|
||||
def _running_task_matches(ctx: ActionToolContext, target: str) -> Sequence[object]:
|
||||
running = [
|
||||
task
|
||||
for task in ctx.session.task_registry.list_recent(n=50)
|
||||
if task.status == TaskStatus.RUNNING
|
||||
]
|
||||
if target == "synthetic_test":
|
||||
return [task for task in running if task.kind == TaskKind.SYNTHETIC_TEST]
|
||||
if target == "task":
|
||||
return running
|
||||
return []
|
||||
|
||||
|
||||
def _resolve_task_cancel_target(ctx: ActionToolContext, target: str) -> str | None:
|
||||
if target in {"synthetic_test", "task"}:
|
||||
matches = _running_task_matches(ctx, target)
|
||||
if not matches:
|
||||
ctx.console.print(
|
||||
f"[dim]no running {escape(target)} task found. use[/] [bold]/tasks[/bold]"
|
||||
)
|
||||
ctx.session.record("slash", f"/cancel {target}", ok=False)
|
||||
return None
|
||||
if len(matches) > 1:
|
||||
ids = ", ".join(str(getattr(task, "task_id", "")) for task in matches)
|
||||
ctx.console.print(
|
||||
f"[yellow]multiple running tasks match {escape(target)}:[/] "
|
||||
f"{escape(ids)} [dim](run /cancel <id>)[/]"
|
||||
)
|
||||
ctx.session.record("slash", f"/cancel {target}", ok=False)
|
||||
return None
|
||||
return str(getattr(matches[0], "task_id", ""))
|
||||
|
||||
candidates = ctx.session.task_registry.candidates(target)
|
||||
if not candidates:
|
||||
ctx.console.print(f"[red]no task matches id:[/] {escape(target)}")
|
||||
ctx.session.record("slash", f"/cancel {target}", ok=False)
|
||||
return None
|
||||
if len(candidates) > 1:
|
||||
ctx.console.print(
|
||||
f"[red]ambiguous id prefix:[/] {escape(target)} "
|
||||
f"[dim]({len(candidates)} matches — use a longer prefix)[/]"
|
||||
)
|
||||
ctx.session.record("slash", f"/cancel {target}", ok=False)
|
||||
return None
|
||||
return str(candidates[0].task_id)
|
||||
|
||||
|
||||
def execute_task_cancel_tool(args: dict[str, Any], ctx: ActionToolContext) -> bool:
|
||||
target = str(args.get("target", "")).strip()
|
||||
if not target:
|
||||
return False
|
||||
task_id = _resolve_task_cancel_target(ctx, target)
|
||||
if task_id is None:
|
||||
return True
|
||||
command = f"/cancel {task_id}"
|
||||
plan = plan_foreground_tool("slash", "slash")
|
||||
if not execution_allowed(
|
||||
plan.policy,
|
||||
session=ctx.session,
|
||||
console=ctx.console,
|
||||
action_summary=command,
|
||||
confirm_fn=ctx.confirm_fn,
|
||||
is_tty=ctx.is_tty,
|
||||
action_already_listed=ctx.action_already_listed,
|
||||
):
|
||||
ctx.session.record("slash", command, ok=False)
|
||||
return True
|
||||
ctx.console.print(f"[bold]$ {escape(command)}[/bold]")
|
||||
dispatch_slash(
|
||||
command,
|
||||
ctx.session,
|
||||
ctx.console,
|
||||
confirm_fn=ctx.confirm_fn,
|
||||
is_tty=ctx.is_tty,
|
||||
policy_precleared=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def run_task_cancel(*, target: str, context: Any) -> dict[str, Any]:
|
||||
return execute_with_action_context({"target": target}, context, execute_task_cancel_tool)
|
||||
|
||||
|
||||
task_cancel_tool = RegisteredTool(
|
||||
name="task_cancel",
|
||||
description="Cancel a running task by id or kind.",
|
||||
input_schema=object_schema(
|
||||
properties={
|
||||
"target": {
|
||||
"oneOf": [
|
||||
{"type": "string", "enum": ["synthetic_test", "task"]},
|
||||
{"type": "string", "pattern": "^[A-Za-z0-9_-]{3,}$"},
|
||||
],
|
||||
"description": (
|
||||
"Task selector: `synthetic_test` to cancel the one running synthetic task, "
|
||||
"`task` to cancel a single running task of any kind, or a task id/prefix "
|
||||
"for `/cancel <id>` resolution."
|
||||
),
|
||||
}
|
||||
},
|
||||
required=("target",),
|
||||
),
|
||||
source="interactive_shell",
|
||||
surfaces=("action",),
|
||||
parallel_safe=False,
|
||||
accepts_runtime_context=True,
|
||||
run=run_task_cancel,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["execute_task_cancel_tool", "task_cancel_tool"]
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Opensre CLI argv, planning, execution, and presenter-injected runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from platform.common.task_types import TaskKind
|
||||
from tools.interactive_shell.shared import (
|
||||
ExecutionPolicyResult,
|
||||
ToolExecutionMode,
|
||||
ToolExecutionPlan,
|
||||
)
|
||||
from tools.interactive_shell.subprocess import (
|
||||
SHELL_COMMAND_TIMEOUT_SECONDS,
|
||||
SubprocessPresenter,
|
||||
)
|
||||
|
||||
# Rich style token names passed to presenter.print_command_output (resolved in surface).
|
||||
_ERROR_STYLE = "error"
|
||||
|
||||
_PYTHON_EXECUTABLE_PREFIXES: tuple[str, ...] = ("python", "pypy")
|
||||
|
||||
OPENSRE_BLOCKED_SUBCOMMANDS: frozenset[str] = frozenset({"agent"})
|
||||
|
||||
INTERACTIVE_OPENSRE_COMMAND_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
"onboard",
|
||||
"integrations setup",
|
||||
}
|
||||
)
|
||||
|
||||
READ_ONLY_OPENSRE_SUBCOMMANDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"health",
|
||||
"version",
|
||||
"list",
|
||||
"status",
|
||||
"show",
|
||||
}
|
||||
)
|
||||
|
||||
INVESTIGATION_OPENSRE_SUBCOMMANDS: frozenset[str] = frozenset({"investigate"})
|
||||
|
||||
|
||||
class OpensreCommandClass(StrEnum):
|
||||
READ_ONLY = "read_only"
|
||||
INVESTIGATION = "investigation"
|
||||
MUTATING = "mutating"
|
||||
|
||||
|
||||
class OpensreExecutionMode(StrEnum):
|
||||
FOREGROUND = "foreground"
|
||||
FOREGROUND_STREAMING = "foreground_streaming"
|
||||
BACKGROUND = "background"
|
||||
|
||||
|
||||
class OpensreRunOutcome(StrEnum):
|
||||
BLOCKED = "blocked"
|
||||
HANDED_OFF = "handed_off"
|
||||
EXECUTED_FOREGROUND = "executed_foreground"
|
||||
EXECUTED_BACKGROUND = "executed_background"
|
||||
DECLINED = "declined"
|
||||
INVALID = "invalid"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpensreExecutionPlan:
|
||||
classification: OpensreCommandClass
|
||||
execution_mode: OpensreExecutionMode
|
||||
requires_confirmation: bool
|
||||
confirmation_reason: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpensreRunResult:
|
||||
outcome: OpensreRunOutcome
|
||||
attempted: bool
|
||||
display_command: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForegroundCliResult:
|
||||
"""Outcome of a foreground opensre CLI subprocess."""
|
||||
|
||||
stdout: str
|
||||
stderr: str
|
||||
exit_code: int | None
|
||||
timed_out: bool
|
||||
start_failed: bool
|
||||
start_error: str | None = None
|
||||
|
||||
|
||||
def _sys_executable_is_python() -> bool:
|
||||
return Path(sys.executable).name.lower().startswith(_PYTHON_EXECUTABLE_PREFIXES)
|
||||
|
||||
|
||||
def _current_opensre_entrypoint() -> str | None:
|
||||
"""Return the current ``opensre`` launcher when the REPL was started by one."""
|
||||
argv0 = sys.argv[0].strip() if sys.argv else ""
|
||||
if not argv0:
|
||||
return None
|
||||
if Path(argv0).name.lower() not in ("opensre", "opensre.exe"):
|
||||
return None
|
||||
return argv0
|
||||
|
||||
|
||||
def build_opensre_cli_argv(args: list[str]) -> list[str]:
|
||||
"""Return argv for re-entering the OpenSRE Click CLI."""
|
||||
if entrypoint := _current_opensre_entrypoint():
|
||||
return [entrypoint, *args]
|
||||
if getattr(sys, "frozen", False) or not _sys_executable_is_python():
|
||||
return [sys.executable, *args]
|
||||
return [sys.executable, "-m", "surfaces.cli", *args]
|
||||
|
||||
|
||||
def is_interactive_wizard(tokens: list[str]) -> bool:
|
||||
"""True when ``tokens`` name an opensre subcommand that needs a full TTY wizard."""
|
||||
if not tokens:
|
||||
return False
|
||||
one = tokens[0].lower()
|
||||
if one in INTERACTIVE_OPENSRE_COMMAND_PATHS:
|
||||
return True
|
||||
if len(tokens) < 2:
|
||||
return False
|
||||
two = f"{one} {tokens[1].lower()}"
|
||||
return two in INTERACTIVE_OPENSRE_COMMAND_PATHS
|
||||
|
||||
|
||||
def interactive_wizard_handoff_response_text(command_str: str) -> str:
|
||||
"""Plain-text outcome for analytics when a wizard is redirected to a slash command."""
|
||||
return (
|
||||
f"`opensre {command_str}` is an interactive wizard that needs a full terminal. "
|
||||
f"Type /{command_str} directly in this shell to launch it."
|
||||
)
|
||||
|
||||
|
||||
def classify_opensre_command(tokens: list[str]) -> str:
|
||||
first_token = tokens[0].lower()
|
||||
if first_token in READ_ONLY_OPENSRE_SUBCOMMANDS:
|
||||
return OpensreCommandClass.READ_ONLY.value
|
||||
if first_token in INVESTIGATION_OPENSRE_SUBCOMMANDS:
|
||||
return OpensreCommandClass.INVESTIGATION.value
|
||||
if first_token == "fleet":
|
||||
subcommand = tokens[1].lower() if len(tokens) > 1 else "list"
|
||||
if subcommand in {"list"}:
|
||||
return OpensreCommandClass.READ_ONLY.value
|
||||
if subcommand == "scan" and "--register" not in tokens[2:]:
|
||||
return OpensreCommandClass.READ_ONLY.value
|
||||
return OpensreCommandClass.MUTATING.value
|
||||
|
||||
|
||||
def opensre_confirmation_reason(tokens: list[str]) -> str:
|
||||
if tokens[:2] == ["fleet", "scan"] and "--register" in tokens[2:]:
|
||||
return "register discovered local AI-agent processes"
|
||||
if tokens and tokens[0] == "fleet":
|
||||
return "this updates the local AI-agent registry"
|
||||
return "this opensre subcommand may change local config or infrastructure"
|
||||
|
||||
|
||||
def build_opensre_execution_plan(tokens: list[str]) -> OpensreExecutionPlan:
|
||||
"""Compute classification + execution mode from one canonical policy table."""
|
||||
classification = OpensreCommandClass(classify_opensre_command(tokens))
|
||||
first_token = tokens[0].lower()
|
||||
|
||||
execution_mode = OpensreExecutionMode.BACKGROUND
|
||||
if first_token in READ_ONLY_OPENSRE_SUBCOMMANDS:
|
||||
execution_mode = OpensreExecutionMode.FOREGROUND
|
||||
elif first_token == "fleet":
|
||||
subcommand = tokens[1].lower() if len(tokens) > 1 else "list"
|
||||
if subcommand == "watch":
|
||||
execution_mode = OpensreExecutionMode.FOREGROUND_STREAMING
|
||||
elif subcommand in {"list", "register", "forget", "scan"}:
|
||||
execution_mode = OpensreExecutionMode.FOREGROUND
|
||||
|
||||
requires_confirmation = classification is OpensreCommandClass.MUTATING
|
||||
reason = (
|
||||
opensre_confirmation_reason([token.lower() for token in tokens])
|
||||
if requires_confirmation
|
||||
else None
|
||||
)
|
||||
return OpensreExecutionPlan(
|
||||
classification=classification,
|
||||
execution_mode=execution_mode,
|
||||
requires_confirmation=requires_confirmation,
|
||||
confirmation_reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def to_tool_execution_plan(plan: OpensreExecutionPlan) -> ToolExecutionPlan:
|
||||
mode = ToolExecutionMode.BACKGROUND
|
||||
if plan.execution_mode is OpensreExecutionMode.FOREGROUND:
|
||||
mode = ToolExecutionMode.FOREGROUND
|
||||
elif plan.execution_mode is OpensreExecutionMode.FOREGROUND_STREAMING:
|
||||
mode = ToolExecutionMode.FOREGROUND_STREAMING
|
||||
if not plan.requires_confirmation:
|
||||
policy = ExecutionPolicyResult(
|
||||
verdict="allow",
|
||||
tool_type="cli_command",
|
||||
reason=None,
|
||||
hint=None,
|
||||
shell_classification=plan.classification.value,
|
||||
)
|
||||
else:
|
||||
policy = ExecutionPolicyResult(
|
||||
verdict="ask",
|
||||
tool_type="cli_command",
|
||||
reason=plan.confirmation_reason,
|
||||
hint="Use a read-only subcommand (health, version, list, status, show)",
|
||||
shell_classification=plan.classification.value,
|
||||
)
|
||||
return ToolExecutionPlan(
|
||||
tool_type="cli_command",
|
||||
classification=plan.classification.value,
|
||||
execution_mode=mode,
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
|
||||
def run_foreground_cli(
|
||||
argv_list: list[str],
|
||||
*,
|
||||
timeout_seconds: int = SHELL_COMMAND_TIMEOUT_SECONDS,
|
||||
) -> ForegroundCliResult:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
argv_list,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return ForegroundCliResult(
|
||||
stdout=str(exc.output or ""),
|
||||
stderr=str(exc.stderr or ""),
|
||||
exit_code=None,
|
||||
timed_out=True,
|
||||
start_failed=False,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return ForegroundCliResult(
|
||||
stdout="",
|
||||
stderr="",
|
||||
exit_code=None,
|
||||
timed_out=False,
|
||||
start_failed=True,
|
||||
start_error=str(exc),
|
||||
)
|
||||
return ForegroundCliResult(
|
||||
stdout=completed.stdout or "",
|
||||
stderr=completed.stderr or "",
|
||||
exit_code=completed.returncode,
|
||||
timed_out=False,
|
||||
start_failed=False,
|
||||
)
|
||||
|
||||
|
||||
def spawn_streaming_cli(argv_list: list[str]) -> subprocess.Popen[str]:
|
||||
return subprocess.Popen(
|
||||
argv_list,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
|
||||
def _print_wizard_handoff(presenter: SubprocessPresenter, command_str: str) -> None:
|
||||
presenter.print(
|
||||
f"[warning]`opensre {command_str}` is an interactive wizard that needs a full terminal.[/]"
|
||||
)
|
||||
presenter.print(
|
||||
f"[dim]Type [bold]/{command_str}[/bold] directly in this shell to launch it.[/]"
|
||||
)
|
||||
|
||||
|
||||
def _run_foreground_via_presenter(
|
||||
presenter: SubprocessPresenter,
|
||||
*,
|
||||
argv_list: list[str],
|
||||
display_command: str,
|
||||
) -> None:
|
||||
presenter.print_bold_command(display_command)
|
||||
result = run_foreground_cli(argv_list, timeout_seconds=SHELL_COMMAND_TIMEOUT_SECONDS)
|
||||
if result.start_failed:
|
||||
if result.start_error:
|
||||
presenter.report_exception(
|
||||
RuntimeError(result.start_error),
|
||||
context="surfaces.interactive_shell.opensre_cli.start",
|
||||
)
|
||||
presenter.print_error(f"failed to start: {result.start_error}")
|
||||
presenter.session.record("cli_command", display_command, ok=False)
|
||||
return
|
||||
presenter.print_command_output(result.stdout)
|
||||
presenter.print_command_output(result.stderr, style=_ERROR_STYLE)
|
||||
if result.timed_out:
|
||||
presenter.print(
|
||||
f"[error]command timed out after {SHELL_COMMAND_TIMEOUT_SECONDS} seconds[/]"
|
||||
)
|
||||
presenter.session.record("cli_command", display_command, ok=False)
|
||||
return
|
||||
ok = result.exit_code == 0
|
||||
if not ok:
|
||||
presenter.print(f"[error]command failed (exit {result.exit_code}):[/]")
|
||||
presenter.session.record("cli_command", display_command, ok=ok)
|
||||
|
||||
|
||||
def _run_streaming_via_presenter(
|
||||
presenter: SubprocessPresenter,
|
||||
*,
|
||||
argv_list: list[str],
|
||||
display_command: str,
|
||||
) -> None:
|
||||
presenter.print_bold_command(display_command)
|
||||
try:
|
||||
proc = spawn_streaming_cli(argv_list)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
presenter.report_exception(
|
||||
exc,
|
||||
context="surfaces.interactive_shell.opensre_cli.start",
|
||||
)
|
||||
presenter.print_error(f"failed to start: {exc}")
|
||||
presenter.session.record("cli_command", display_command, ok=False)
|
||||
return
|
||||
if proc.stdout is not None:
|
||||
for line in proc.stdout:
|
||||
presenter.print_command_output(line)
|
||||
code = proc.wait()
|
||||
ok = code == 0
|
||||
if not ok:
|
||||
presenter.print(f"[error]command failed (exit {code}):[/]")
|
||||
presenter.session.record("cli_command", display_command, ok=ok)
|
||||
|
||||
|
||||
def run_opensre_cli_command_result(
|
||||
args: str,
|
||||
presenter: SubprocessPresenter,
|
||||
) -> OpensreRunResult:
|
||||
"""Run an opensre subcommand (not agent) via the injected presenter."""
|
||||
try:
|
||||
tokens = shlex.split(args)
|
||||
except ValueError:
|
||||
tokens = args.split()
|
||||
if not tokens:
|
||||
return OpensreRunResult(outcome=OpensreRunOutcome.INVALID, attempted=False)
|
||||
|
||||
first_token = tokens[0].lower()
|
||||
if first_token in OPENSRE_BLOCKED_SUBCOMMANDS:
|
||||
presenter.print(f"[error]Cannot run `opensre {first_token}`: subcommand is blocked.[/]")
|
||||
return OpensreRunResult(outcome=OpensreRunOutcome.BLOCKED, attempted=False)
|
||||
|
||||
if is_interactive_wizard(tokens):
|
||||
command_str = " ".join(tokens)
|
||||
_print_wizard_handoff(presenter, command_str)
|
||||
presenter.session.record(
|
||||
"cli_command",
|
||||
f"opensre {command_str}",
|
||||
ok=False,
|
||||
response_text=interactive_wizard_handoff_response_text(command_str),
|
||||
)
|
||||
return OpensreRunResult(
|
||||
outcome=OpensreRunOutcome.HANDED_OFF,
|
||||
attempted=True,
|
||||
display_command=f"opensre {command_str}",
|
||||
)
|
||||
|
||||
plan = build_opensre_execution_plan(tokens)
|
||||
execution_plan = to_tool_execution_plan(plan)
|
||||
display_command = f"opensre {' '.join(tokens)}"
|
||||
|
||||
if not presenter.execution_allowed(
|
||||
execution_plan.policy,
|
||||
action_summary=f"$ {display_command}",
|
||||
):
|
||||
presenter.session.record("cli_command", display_command, ok=False)
|
||||
return OpensreRunResult(
|
||||
outcome=OpensreRunOutcome.DECLINED,
|
||||
attempted=True,
|
||||
display_command=display_command,
|
||||
)
|
||||
|
||||
argv_list = build_opensre_cli_argv(tokens)
|
||||
if execution_plan.execution_mode in {
|
||||
ToolExecutionMode.FOREGROUND,
|
||||
ToolExecutionMode.FOREGROUND_STREAMING,
|
||||
}:
|
||||
if execution_plan.execution_mode is ToolExecutionMode.FOREGROUND_STREAMING:
|
||||
_run_streaming_via_presenter(
|
||||
presenter,
|
||||
argv_list=argv_list,
|
||||
display_command=display_command,
|
||||
)
|
||||
else:
|
||||
_run_foreground_via_presenter(
|
||||
presenter,
|
||||
argv_list=argv_list,
|
||||
display_command=display_command,
|
||||
)
|
||||
return OpensreRunResult(
|
||||
outcome=OpensreRunOutcome.EXECUTED_FOREGROUND,
|
||||
attempted=True,
|
||||
display_command=display_command,
|
||||
)
|
||||
|
||||
presenter.session.record("cli_command", display_command)
|
||||
presenter.start_background_cli_task(
|
||||
display_command=display_command,
|
||||
argv_list=argv_list,
|
||||
timeout_seconds=SHELL_COMMAND_TIMEOUT_SECONDS,
|
||||
kind=TaskKind.CLI_COMMAND,
|
||||
)
|
||||
return OpensreRunResult(
|
||||
outcome=OpensreRunOutcome.EXECUTED_BACKGROUND,
|
||||
attempted=True,
|
||||
display_command=display_command,
|
||||
)
|
||||
|
||||
|
||||
def run_opensre_cli_command(args: str, presenter: SubprocessPresenter) -> bool:
|
||||
result = run_opensre_cli_command_result(args, presenter)
|
||||
return result.attempted
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ForegroundCliResult",
|
||||
"INTERACTIVE_OPENSRE_COMMAND_PATHS",
|
||||
"INVESTIGATION_OPENSRE_SUBCOMMANDS",
|
||||
"OPENSRE_BLOCKED_SUBCOMMANDS",
|
||||
"READ_ONLY_OPENSRE_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",
|
||||
"run_foreground_cli",
|
||||
"run_opensre_cli_command",
|
||||
"run_opensre_cli_command_result",
|
||||
"spawn_streaming_cli",
|
||||
"to_tool_execution_plan",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Implementation-agent support for interactive-shell tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Claude Code implementation executor.
|
||||
|
||||
Spawns the Claude Code CLI to implement a requested change in the current
|
||||
repository, applies the execution policy, tracks the launch as a background
|
||||
task, and watches the subprocess lifecycle in a daemon thread.
|
||||
|
||||
Lives next to the agent-facing ``tools.interactive_shell.actions.implementation``.
|
||||
``subprocess`` and ``threading`` are referenced as module globals so tests can
|
||||
patch ``tools.interactive_shell.implementation.claude_code_executor.subprocess.Popen`` /
|
||||
``.threading.Thread``; ``ClaudeCodeAdapter`` is likewise a module global that
|
||||
tests patch directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol, cast
|
||||
|
||||
from integrations.llm_cli.claude_code import ClaudeCodeAdapter
|
||||
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
|
||||
from platform.common.task_types import TaskKind
|
||||
from tools.interactive_shell.shared import allow_tool
|
||||
from tools.interactive_shell.subprocess import (
|
||||
CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS,
|
||||
MAX_COMMAND_OUTPUT_CHARS,
|
||||
SYNTHETIC_DIAG_CHARS,
|
||||
SubprocessPresenter,
|
||||
terminate_child_process,
|
||||
)
|
||||
|
||||
_DIM_STYLE = "dim"
|
||||
_ERROR_STYLE = "error"
|
||||
_WARNING_STYLE = "warning"
|
||||
|
||||
_IMPLEMENT_PERMISSION_MODE_ENV = "CLAUDE_CODE_IMPLEMENT_PERMISSION_MODE"
|
||||
_DEFAULT_IMPLEMENT_PERMISSION_MODE = "acceptEdits"
|
||||
|
||||
|
||||
class ClaudeInvocation(Protocol):
|
||||
@property
|
||||
def argv(self) -> tuple[str, ...]:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def stdin(self) -> str | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def cwd(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def env(self) -> dict[str, str] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaudeCodeRunResult:
|
||||
stdout: str
|
||||
stderr: str
|
||||
exit_code: int | None
|
||||
timed_out: bool
|
||||
cancelled: bool
|
||||
|
||||
|
||||
def is_context_dependent_implementation_request(request: str) -> bool:
|
||||
normalized = " ".join(request.strip().lower().split())
|
||||
return normalized in {
|
||||
"implement",
|
||||
"please implement",
|
||||
"code",
|
||||
"make the change",
|
||||
"make those changes",
|
||||
}
|
||||
|
||||
|
||||
def build_claude_code_implementation_prompt(
|
||||
request: str,
|
||||
*,
|
||||
recent_messages: list[tuple[str, str]],
|
||||
) -> str:
|
||||
context = ""
|
||||
if recent_messages:
|
||||
context = "\n".join(f"{role}: {text}" for role, text in recent_messages)
|
||||
context_block = (
|
||||
f"--- Recent OpenSRE terminal assistant context ---\n{context}\n\n" if context else ""
|
||||
)
|
||||
return (
|
||||
"You are Claude Code working in the current OpenSRE repository.\n\n"
|
||||
f"{context_block}"
|
||||
f"--- User implementation request ---\n{request.strip()}\n\n"
|
||||
"--- Rules ---\n"
|
||||
"- Implement the requested change in this repository.\n"
|
||||
"- Follow AGENTS.md, existing project conventions, and local code style.\n"
|
||||
"- Do not create a git commit or push changes.\n"
|
||||
"- Do not run destructive git commands such as reset --hard or checkout --.\n"
|
||||
"- Preserve unrelated user changes in the working tree.\n"
|
||||
"- Run focused tests or lint checks when practical.\n"
|
||||
"- Finish with a concise summary of changed files and verification performed.\n"
|
||||
)
|
||||
|
||||
|
||||
def implementation_argv(argv: tuple[str, ...]) -> list[str]:
|
||||
exec_argv = list(argv)
|
||||
if not exec_argv:
|
||||
raise ValueError("Claude Code invocation is empty.")
|
||||
executable = Path(exec_argv[0])
|
||||
if not executable.is_absolute():
|
||||
raise ValueError("Claude Code executable path must be absolute.")
|
||||
permission_mode = os.environ.get(
|
||||
_IMPLEMENT_PERMISSION_MODE_ENV,
|
||||
_DEFAULT_IMPLEMENT_PERMISSION_MODE,
|
||||
).strip()
|
||||
if permission_mode and permission_mode.lower() not in {"default", "none", "off"}:
|
||||
exec_argv.extend(["--permission-mode", permission_mode])
|
||||
return exec_argv
|
||||
|
||||
|
||||
def spawn_claude_code(invocation: ClaudeInvocation) -> subprocess.Popen[str]:
|
||||
argv = implementation_argv(invocation.argv)
|
||||
cwd = str(Path(invocation.cwd).resolve())
|
||||
env = build_cli_subprocess_env(invocation.env)
|
||||
popen = cast("type[subprocess.Popen[str]]", subprocess.__dict__["Popen"])
|
||||
return popen(
|
||||
argv,
|
||||
stdin=subprocess.PIPE if invocation.stdin is not None else subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
def run_claude_code_to_completion(
|
||||
proc: subprocess.Popen[str],
|
||||
invocation: ClaudeInvocation,
|
||||
*,
|
||||
cancel_event: threading.Event,
|
||||
) -> ClaudeCodeRunResult:
|
||||
timed_out = False
|
||||
try:
|
||||
stdout, stderr = proc.communicate(
|
||||
input=invocation.stdin,
|
||||
timeout=CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
terminate_child_process(proc)
|
||||
stdout, stderr = proc.communicate()
|
||||
|
||||
out = (stdout or "")[:MAX_COMMAND_OUTPUT_CHARS]
|
||||
err = (stderr or "")[:MAX_COMMAND_OUTPUT_CHARS]
|
||||
code = proc.returncode
|
||||
cancelled = cancel_event.is_set() and code != 0
|
||||
return ClaudeCodeRunResult(
|
||||
stdout=out,
|
||||
stderr=err,
|
||||
exit_code=code,
|
||||
timed_out=timed_out,
|
||||
cancelled=cancelled,
|
||||
)
|
||||
|
||||
|
||||
def format_claude_failure_diag(stdout: str, stderr: str) -> str:
|
||||
return (stderr or stdout).strip()[:SYNTHETIC_DIAG_CHARS]
|
||||
|
||||
|
||||
def run_claude_code_implementation(request: str, presenter: SubprocessPresenter) -> None:
|
||||
session = presenter.session
|
||||
policy = allow_tool("code_agent")
|
||||
if not presenter.execution_allowed(
|
||||
policy,
|
||||
action_summary=f"Claude Code implementation: {request}",
|
||||
):
|
||||
session.record("implementation", request, ok=False)
|
||||
return
|
||||
|
||||
if is_context_dependent_implementation_request(request) and not session.agent.messages:
|
||||
presenter.print(
|
||||
"[error]implementation request is too vague:[/] "
|
||||
"describe what Claude Code should change."
|
||||
)
|
||||
session.record("implementation", request, ok=False)
|
||||
return
|
||||
|
||||
adapter = ClaudeCodeAdapter()
|
||||
probe = adapter.detect()
|
||||
if not probe.installed or not probe.bin_path:
|
||||
presenter.print_error(f"Claude Code CLI not available: {probe.detail}")
|
||||
session.record("implementation", request, ok=False)
|
||||
return
|
||||
if probe.logged_in is False:
|
||||
presenter.print_error(f"Claude Code is not authenticated: {probe.detail}")
|
||||
session.record("implementation", request, ok=False)
|
||||
return
|
||||
|
||||
recent = session.agent.messages[-6:]
|
||||
prompt = build_claude_code_implementation_prompt(request, recent_messages=recent)
|
||||
try:
|
||||
invocation = adapter.build(
|
||||
prompt=prompt,
|
||||
model=os.environ.get("CLAUDE_CODE_MODEL"),
|
||||
workspace=str(Path.cwd()),
|
||||
)
|
||||
except Exception as exc:
|
||||
presenter.report_exception(exc, context="surfaces.interactive_shell.claude_code.build")
|
||||
presenter.print_error(f"Claude Code failed to prepare: {exc}")
|
||||
session.record("implementation", request, ok=False)
|
||||
return
|
||||
|
||||
display_command = "claude -p"
|
||||
presenter.print_bold_command(display_command)
|
||||
task = session.task_registry.create(TaskKind.CODE_AGENT, command=display_command)
|
||||
task.mark_running()
|
||||
history_gen_when_started = session.terminal.history_generation
|
||||
|
||||
try:
|
||||
proc = spawn_claude_code(invocation)
|
||||
except Exception as exc:
|
||||
task.mark_failed(str(exc))
|
||||
presenter.report_exception(exc, context="surfaces.interactive_shell.claude_code.start")
|
||||
presenter.print_error(f"Claude Code failed to start: {exc}")
|
||||
session.record("implementation", request, ok=False)
|
||||
return
|
||||
|
||||
task.attach_process(proc)
|
||||
session.record("implementation", request, ok=True)
|
||||
|
||||
def _watch() -> None:
|
||||
try:
|
||||
result = run_claude_code_to_completion(
|
||||
proc,
|
||||
invocation,
|
||||
cancel_event=task.cancel_requested,
|
||||
)
|
||||
if result.timed_out:
|
||||
task.mark_failed(f"timed out after {CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS}s")
|
||||
presenter.print(
|
||||
f"[error]Claude Code timed out after "
|
||||
f"{CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS} seconds[/]"
|
||||
)
|
||||
return
|
||||
|
||||
if result.cancelled:
|
||||
task.mark_cancelled()
|
||||
if session.terminal.history_generation == history_gen_when_started:
|
||||
session.mark_latest(ok=False, kind="implementation")
|
||||
presenter.print(f"[{_WARNING_STYLE}]Claude Code task cancelled.[/]")
|
||||
return
|
||||
|
||||
if result.exit_code == 0:
|
||||
task.mark_completed(result="ok")
|
||||
presenter.print_highlight(f"Claude Code completed task {task.task_id}")
|
||||
presenter.print_command_output(result.stdout)
|
||||
if result.stderr:
|
||||
presenter.print_command_output(result.stderr, style=_DIM_STYLE)
|
||||
return
|
||||
|
||||
diag = format_claude_failure_diag(result.stdout, result.stderr)
|
||||
error_msg = f"exit code {result.exit_code}" + (f": {diag}" if diag else "")
|
||||
task.mark_failed(error_msg)
|
||||
if session.terminal.history_generation == history_gen_when_started:
|
||||
session.mark_latest(ok=False, kind="implementation")
|
||||
presenter.print(f"[error]Claude Code failed (exit {result.exit_code}):[/]")
|
||||
presenter.print_command_output(result.stdout)
|
||||
presenter.print_command_output(result.stderr, style=_ERROR_STYLE)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
task.mark_failed(str(exc))
|
||||
presenter.report_exception(exc, context="surfaces.interactive_shell.claude_code.watch")
|
||||
if session.terminal.history_generation == history_gen_when_started:
|
||||
session.mark_latest(ok=False, kind="implementation")
|
||||
presenter.print_error(f"Claude Code watcher failed: {exc}")
|
||||
|
||||
threading.Thread(target=_watch, daemon=True, name=f"claude-code-{task.task_id}").start()
|
||||
presenter.print(
|
||||
f"[dim]Claude Code started — task[/] [bold]{task.task_id}[/bold]. "
|
||||
"[highlight]/tasks[/] [dim]to monitor,[/] "
|
||||
f"[highlight]/cancel {task.task_id}[/] [dim]to stop.[/]"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["run_claude_code_implementation"]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Shared contracts reused across interactive-shell tools.
|
||||
|
||||
This package is the single import path for the cross-tool execution policy:
|
||||
``from tools.interactive_shell.shared import ...``. Tool modules should import
|
||||
the policy contracts and helpers from here rather than from the underlying
|
||||
``execution_policy`` module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tools.interactive_shell.shared.execution_policy import (
|
||||
ConfirmationOutcome,
|
||||
ConfirmationPlan,
|
||||
ExecutionPolicyResult,
|
||||
ExecutionVerdict,
|
||||
ToolExecutionMode,
|
||||
ToolExecutionPlan,
|
||||
allow_tool,
|
||||
plan_foreground_tool,
|
||||
resolve_confirmation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConfirmationOutcome",
|
||||
"ConfirmationPlan",
|
||||
"ExecutionPolicyResult",
|
||||
"ExecutionVerdict",
|
||||
"ToolExecutionMode",
|
||||
"ToolExecutionPlan",
|
||||
"allow_tool",
|
||||
"plan_foreground_tool",
|
||||
"resolve_confirmation",
|
||||
]
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Central execution policy (allow / ask / deny) for interactive REPL tools.
|
||||
|
||||
Alpha mode: allow everything
|
||||
----------------------------
|
||||
OpenSRE is in **alpha**, and the interactive REPL runs with **no command
|
||||
guardrails** so developer velocity stays high. Every policy decision below
|
||||
resolves to ``allow`` and nothing prompts for confirmation: slash/``opensre``
|
||||
commands (any tier), investigations, synthetic tests, code-agent launches, LLM
|
||||
runtime switches, and shell commands of every kind — read-only, mutating,
|
||||
``restricted`` (``sudo``, ``systemctl``, ``kill``, ``dd`` …), shell operators
|
||||
(``| && ; > <``), and command substitution (`` ` ``/``$(...)``) — all run
|
||||
immediately, in any context (TTY or not, trust mode or not).
|
||||
|
||||
There is intentionally **no shell-command safety policy**: the former
|
||||
read-only / mutating / restricted classification and its deny floor were removed
|
||||
(see ``docs/interactive-shell-action-policy.md``). The only thing shell
|
||||
evaluation still rejects is genuinely empty input (a bare ``!`` or whitespace),
|
||||
which is input validation rather than a guardrail.
|
||||
|
||||
The ``ask`` verdict is retained so that ``trust_mode`` and any future opt-in
|
||||
stricter policy still have a hook, but the policy functions here never emit
|
||||
``ask``. If guardrails are reintroduced after alpha, gate them here at the
|
||||
execution stage (not the planner).
|
||||
|
||||
This module is intentionally **pure**: it has no terminal I/O, no analytics, and
|
||||
no console dependency. The decision is computed by :func:`resolve_confirmation`,
|
||||
and the interaction layer (printing the reason/hint, the ``Proceed? [Y/n]``
|
||||
prompt, and analytics emission) lives in
|
||||
``interactive_shell.ui.execution_confirm.execution_allowed``.
|
||||
|
||||
Shell-specific evaluation (empty-input rejection, ``plan_shell_execution``)
|
||||
lives next to the rest of the shell machinery in
|
||||
``tools.interactive_shell.shell.policy`` and reuses the contracts defined here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
ExecutionVerdict = Literal["allow", "ask", "deny"]
|
||||
|
||||
|
||||
class ToolExecutionMode(StrEnum):
|
||||
FOREGROUND = "foreground"
|
||||
BACKGROUND = "background"
|
||||
FOREGROUND_STREAMING = "foreground_streaming"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionPolicyResult:
|
||||
"""Result of evaluating whether a tool may run."""
|
||||
|
||||
verdict: ExecutionVerdict
|
||||
tool_type: str
|
||||
reason: str | None
|
||||
hint: str | None = None
|
||||
shell_classification: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolExecutionPlan:
|
||||
"""Unified execution plan contract shared across tool executors."""
|
||||
|
||||
tool_type: str
|
||||
classification: str
|
||||
execution_mode: ToolExecutionMode
|
||||
policy: ExecutionPolicyResult
|
||||
|
||||
|
||||
class ConfirmationOutcome(StrEnum):
|
||||
"""Pure decision for how the interaction layer should treat an action."""
|
||||
|
||||
ALLOW = "allow" # proceed, no prompt
|
||||
DENY = "deny" # blocked by policy (show reason + hint)
|
||||
BLOCK_NON_TTY = "block_non_tty" # ask verdict but stdin is not a TTY
|
||||
NEEDS_CONFIRMATION = "needs_confirmation" # prompt the user
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfirmationPlan:
|
||||
"""Result of :func:`resolve_confirmation` (side-effect free).
|
||||
|
||||
``analytics_outcome`` / ``analytics_reason`` carry the values the interaction
|
||||
layer should emit for the non-prompt outcomes (ALLOW / DENY / BLOCK_NON_TTY).
|
||||
For ``NEEDS_CONFIRMATION`` the analytics outcome depends on the user's answer
|
||||
and is decided by the interaction layer, so both fields are ``None``.
|
||||
"""
|
||||
|
||||
outcome: ConfirmationOutcome
|
||||
result: ExecutionPolicyResult
|
||||
analytics_outcome: str | None = None
|
||||
analytics_reason: str | None = None
|
||||
|
||||
|
||||
def resolve_confirmation(
|
||||
result: ExecutionPolicyResult,
|
||||
*,
|
||||
trust_mode: bool,
|
||||
is_tty: bool,
|
||||
) -> ConfirmationPlan:
|
||||
"""Resolve a policy result into a confirmation decision, with no side effects.
|
||||
|
||||
Pure function: no console, no ``input``, no analytics. The interaction layer
|
||||
(``interactive_shell.ui.execution_confirm``) renders the decision and emits
|
||||
analytics.
|
||||
"""
|
||||
if result.verdict == "deny":
|
||||
return ConfirmationPlan(
|
||||
outcome=ConfirmationOutcome.DENY,
|
||||
result=result,
|
||||
analytics_outcome="blocked",
|
||||
analytics_reason=result.reason,
|
||||
)
|
||||
|
||||
if result.verdict == "allow":
|
||||
return ConfirmationPlan(
|
||||
outcome=ConfirmationOutcome.ALLOW,
|
||||
result=result,
|
||||
analytics_outcome="allowed",
|
||||
analytics_reason=result.reason,
|
||||
)
|
||||
|
||||
# ask
|
||||
if trust_mode:
|
||||
return ConfirmationPlan(
|
||||
outcome=ConfirmationOutcome.ALLOW,
|
||||
result=result,
|
||||
analytics_outcome="allowed",
|
||||
analytics_reason="trust_mode_skipped_prompt",
|
||||
)
|
||||
|
||||
if not is_tty:
|
||||
return ConfirmationPlan(
|
||||
outcome=ConfirmationOutcome.BLOCK_NON_TTY,
|
||||
result=result,
|
||||
analytics_outcome="blocked",
|
||||
analytics_reason="non_interactive_stdin",
|
||||
)
|
||||
|
||||
return ConfirmationPlan(
|
||||
outcome=ConfirmationOutcome.NEEDS_CONFIRMATION,
|
||||
result=result,
|
||||
)
|
||||
|
||||
|
||||
def allow_tool(tool_type: str) -> ExecutionPolicyResult:
|
||||
"""Default-allow verdict for a tool launch.
|
||||
|
||||
Under alpha the policy never denies a tool launch (slash commands,
|
||||
investigations, synthetic tests, code-agent launches, LLM runtime switches),
|
||||
so every caller resolves to ``allow``. ``tool_type`` is carried through for
|
||||
analytics and confirmation UX.
|
||||
"""
|
||||
return ExecutionPolicyResult(verdict="allow", tool_type=tool_type, reason=None)
|
||||
|
||||
|
||||
def plan_foreground_tool(
|
||||
tool_type: str,
|
||||
classification: str | None = None,
|
||||
) -> ToolExecutionPlan:
|
||||
"""Build a FOREGROUND execution plan around a default-allow verdict."""
|
||||
return ToolExecutionPlan(
|
||||
tool_type=tool_type,
|
||||
classification=classification or tool_type,
|
||||
execution_mode=ToolExecutionMode.FOREGROUND,
|
||||
policy=allow_tool(tool_type),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConfirmationOutcome",
|
||||
"ConfirmationPlan",
|
||||
"ExecutionPolicyResult",
|
||||
"ExecutionVerdict",
|
||||
"ToolExecutionMode",
|
||||
"ToolExecutionPlan",
|
||||
"allow_tool",
|
||||
"plan_foreground_tool",
|
||||
"resolve_confirmation",
|
||||
]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Shared launch flow for investigation-style tools.
|
||||
|
||||
``investigation_start`` (free-text) and ``alert_sample`` (template) share the
|
||||
same shape: gate through the execution policy, announce, run in the background or
|
||||
foreground, and record the outcome. This helper holds that flow once; each tool
|
||||
supplies only the parts that differ (the run callable, the background launcher,
|
||||
and the display/record strings).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
from core.agent_harness.session.terminal_access import background_mode_enabled
|
||||
from platform.common.task_types import TaskRecord
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from tools.interactive_shell.shared.execution_policy import (
|
||||
ExecutionPolicyResult,
|
||||
plan_foreground_tool,
|
||||
)
|
||||
|
||||
ForegroundInvestigationStatus = Literal["completed", "failed", "cancelled"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForegroundInvestigationResult:
|
||||
"""Minimal foreground investigation outcome for launch gating."""
|
||||
|
||||
status: ForegroundInvestigationStatus
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class InvestigationLaunchPorts(Protocol):
|
||||
"""Surface-specific hooks for gating and foreground investigation UX."""
|
||||
|
||||
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:
|
||||
raise NotImplementedError
|
||||
|
||||
def run_foreground_investigation(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
console: Console,
|
||||
task_command: str,
|
||||
run: Callable[[TaskRecord], dict[str, object]],
|
||||
exception_context: str,
|
||||
target: str,
|
||||
) -> ForegroundInvestigationResult:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def launch_investigation(
|
||||
*,
|
||||
session: Session,
|
||||
console: Console,
|
||||
ports: InvestigationLaunchPorts,
|
||||
tool_type: str,
|
||||
action_summary: str,
|
||||
announce_label: str,
|
||||
announce_value: str,
|
||||
record_value: str,
|
||||
foreground_task_command: str,
|
||||
exception_context: str,
|
||||
run: Callable[[TaskRecord], dict[str, object]],
|
||||
start_background: Callable[[], None],
|
||||
confirm_fn: Callable[[str], str] | None = None,
|
||||
is_tty: bool | None = None,
|
||||
action_already_listed: bool = False,
|
||||
) -> None:
|
||||
"""Gate, announce, and run an investigation-style tool, recording the outcome.
|
||||
|
||||
Every outcome is recorded on the ``alert`` channel keyed by ``record_value``.
|
||||
"""
|
||||
plan = plan_foreground_tool(tool_type, "investigation_launch")
|
||||
if not ports.execution_allowed(
|
||||
policy=plan.policy,
|
||||
session=session,
|
||||
console=console,
|
||||
action_summary=action_summary,
|
||||
confirm_fn=confirm_fn,
|
||||
is_tty=is_tty,
|
||||
action_already_listed=action_already_listed,
|
||||
):
|
||||
session.record("alert", record_value, ok=False)
|
||||
return
|
||||
|
||||
console.print(f"[bold]{announce_label}:[/bold] {escape(announce_value)}")
|
||||
|
||||
if background_mode_enabled(session):
|
||||
start_background()
|
||||
session.record("alert", record_value)
|
||||
return
|
||||
|
||||
if (
|
||||
ports.run_foreground_investigation(
|
||||
session=session,
|
||||
console=console,
|
||||
task_command=foreground_task_command,
|
||||
run=run,
|
||||
exception_context=exception_context,
|
||||
target=record_value,
|
||||
).status
|
||||
!= "completed"
|
||||
):
|
||||
session.record("alert", record_value, ok=False)
|
||||
return
|
||||
|
||||
session.record("alert", record_value)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ForegroundInvestigationResult",
|
||||
"InvestigationLaunchPorts",
|
||||
"Session",
|
||||
"launch_investigation",
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Shell command machinery for the interactive REPL.
|
||||
|
||||
Groups the shell command-line concern next to the agent-facing
|
||||
``tools.interactive_shell.actions.shell``:
|
||||
|
||||
* ``parsing`` turns command text into an executable shape,
|
||||
* ``policy`` resolves the (alpha-mode, allow-everything) shell execution plan,
|
||||
* ``execution`` runs the subprocess and returns a structured result,
|
||||
* ``runner`` wires parsing, policy, builtins (``cd`` / ``pwd``), and execution
|
||||
together and records the turn.
|
||||
|
||||
Import submodules explicitly (for example ``tools.interactive_shell.shell.runner``)
|
||||
rather than relying on this package initializer, to keep interactive-shell
|
||||
startup import-light.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Terminal-friendly shell command display helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_HEREDOC_DELIMITER_RE = re.compile(r"<<(-)?\s*(?:'([^'\n]+)'|\"([^\"\n]+)\"|([^\s\\|;&<>]+))")
|
||||
|
||||
|
||||
def _delimiter_from_match(match: re.Match[str]) -> tuple[str, bool]:
|
||||
strip_tabs = match.group(1) == "-"
|
||||
delimiter = match.group(2) or match.group(3) or match.group(4) or ""
|
||||
return delimiter, strip_tabs
|
||||
|
||||
|
||||
def _closing_delimiter_line(line: str, *, delimiter: str, strip_tabs: bool) -> bool:
|
||||
normalized = line.rstrip("\r\n")
|
||||
if strip_tabs:
|
||||
normalized = normalized.lstrip("\t")
|
||||
return normalized == delimiter
|
||||
|
||||
|
||||
def format_shell_command_for_display(command: str) -> str:
|
||||
"""Return a compact, user-facing command string for the REPL prompt area.
|
||||
|
||||
Heredoc bodies (for example the Python script in ``python3 - <<'PY'``) are
|
||||
collapsed to a single summary line so incidental agent-generated scripts do
|
||||
not flood the terminal. The full ``command`` is still executed unchanged.
|
||||
"""
|
||||
lines = command.splitlines()
|
||||
if len(lines) <= 1:
|
||||
return command
|
||||
|
||||
display_lines: list[str] = []
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
line = lines[index]
|
||||
match = _HEREDOC_DELIMITER_RE.search(line)
|
||||
if match is None:
|
||||
display_lines.append(line)
|
||||
index += 1
|
||||
continue
|
||||
|
||||
delimiter, strip_tabs = _delimiter_from_match(match)
|
||||
body_start = index + 1
|
||||
close_index: int | None = None
|
||||
for candidate_index in range(body_start, len(lines)):
|
||||
if _closing_delimiter_line(
|
||||
lines[candidate_index],
|
||||
delimiter=delimiter,
|
||||
strip_tabs=strip_tabs,
|
||||
):
|
||||
close_index = candidate_index
|
||||
break
|
||||
|
||||
body_line_count = 0 if close_index is None else close_index - body_start
|
||||
if body_line_count <= 0:
|
||||
summary = f"{line} …"
|
||||
else:
|
||||
noun = "line" if body_line_count == 1 else "lines"
|
||||
summary = f"{line} … ({body_line_count} {noun})"
|
||||
display_lines.append(summary)
|
||||
index = len(lines) if close_index is None else close_index + 1
|
||||
|
||||
return "\n".join(display_lines)
|
||||
|
||||
|
||||
__all__ = ["format_shell_command_for_display"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Structured shell command execution helpers for the interactive REPL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShellExecutionResult:
|
||||
"""Normalized command execution output."""
|
||||
|
||||
command: str
|
||||
argv: list[str] | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
exit_code: int | None
|
||||
timed_out: bool
|
||||
truncated: bool
|
||||
executed_with_shell: bool
|
||||
|
||||
|
||||
def _truncate_output(text: str, *, max_chars: int) -> tuple[str, bool]:
|
||||
if len(text) <= max_chars:
|
||||
return text, False
|
||||
return f"{text[:max_chars].rstrip()}\n... output truncated ...", True
|
||||
|
||||
|
||||
def _text_from_timeout_stream(raw: str | bytes | None) -> str:
|
||||
if raw is None:
|
||||
return ""
|
||||
if isinstance(raw, str):
|
||||
return raw
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _shell_argv(command: str) -> list[str]:
|
||||
if os.name == "nt":
|
||||
shell = os.environ.get("COMSPEC") or "cmd.exe"
|
||||
return [shell, "/d", "/s", "/c", command]
|
||||
shell = os.environ.get("SHELL") or "/bin/sh"
|
||||
return [shell, "-lc", command]
|
||||
|
||||
|
||||
def execute_shell_command(
|
||||
*,
|
||||
command: str,
|
||||
argv: list[str] | None,
|
||||
use_shell: bool,
|
||||
timeout_seconds: int,
|
||||
max_output_chars: int,
|
||||
) -> ShellExecutionResult:
|
||||
"""Execute a command and return a structured result object."""
|
||||
try:
|
||||
if use_shell:
|
||||
# Intentional REPL shell passthrough for local terminal commands.
|
||||
# The caller runs through interactive confirmation/policy first and
|
||||
# records that the command used a shell in ShellExecutionResult.
|
||||
completed = subprocess.run(
|
||||
_shell_argv(command),
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
else:
|
||||
if argv is None:
|
||||
raise ValueError("argv is required for shell=False execution.")
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
shell=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
stdout = _text_from_timeout_stream(exc.stdout)
|
||||
stderr = _text_from_timeout_stream(exc.stderr)
|
||||
stdout, truncated_stdout = _truncate_output(
|
||||
stdout,
|
||||
max_chars=max_output_chars,
|
||||
)
|
||||
stderr, truncated_stderr = _truncate_output(
|
||||
stderr,
|
||||
max_chars=max_output_chars,
|
||||
)
|
||||
return ShellExecutionResult(
|
||||
command=command,
|
||||
argv=argv,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=None,
|
||||
timed_out=True,
|
||||
truncated=truncated_stdout or truncated_stderr,
|
||||
executed_with_shell=use_shell,
|
||||
)
|
||||
|
||||
stdout, truncated_stdout = _truncate_output(
|
||||
completed.stdout or "",
|
||||
max_chars=max_output_chars,
|
||||
)
|
||||
stderr, truncated_stderr = _truncate_output(
|
||||
completed.stderr or "",
|
||||
max_chars=max_output_chars,
|
||||
)
|
||||
return ShellExecutionResult(
|
||||
command=command,
|
||||
argv=argv,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=completed.returncode,
|
||||
timed_out=False,
|
||||
truncated=truncated_stdout or truncated_stderr,
|
||||
executed_with_shell=use_shell,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ShellExecutionResult", "execute_shell_command"]
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Shell command parsing for the interactive REPL.
|
||||
|
||||
Alpha mode: no command-safety policy
|
||||
------------------------------------
|
||||
While OpenSRE is in alpha we run **every** command the user or action agent
|
||||
asks for. There is intentionally no allowlist, no read-only / mutating /
|
||||
restricted classification, and no deny floor — guardrails are deliberately
|
||||
omitted to keep developer velocity high. See
|
||||
``docs/interactive-shell-action-policy.md`` for the rationale.
|
||||
|
||||
This module's only job is to turn command text into a shape the runner can
|
||||
execute:
|
||||
|
||||
* explicit ``!`` passthrough → run the remainder through a shell,
|
||||
* commands using shell operators / substitution / heredocs → run through a shell,
|
||||
* anything that fails to tokenize → hand the raw string to a shell,
|
||||
* everything else → split into ``argv`` and run without a shell (which also lets
|
||||
the runner detect the ``cd`` / ``pwd`` REPL builtins so the working directory
|
||||
persists across turns).
|
||||
|
||||
The only non-execution outcome is a ``parse_error`` for genuinely empty input
|
||||
(e.g. a bare ``!``). That is input validation, not a safety guardrail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
|
||||
_EXPLICIT_SHELL_PREFIX = "!"
|
||||
_SHELL_OPERATOR_RE = re.compile(r"(^|\s)(\|\||&&|[|;<>]|>>|<<|2>)(\s|$)")
|
||||
_INLINE_SUBSHELL_RE = re.compile(r"`|\$\(")
|
||||
# Heredoc starts such as ``<<'PY'`` or ``<<EOF`` — ``<<`` alone is already covered
|
||||
# by ``_SHELL_OPERATOR_RE`` only when followed by whitespace; quoted/unquoted
|
||||
# delimiters need an explicit match so ``python3 - <<'PY'`` is not tokenized.
|
||||
_HEREDOC_START_RE = re.compile(r"(^|\s)<<-?\s*(?:'[^'\n]+'|\"[^\"\n]+\"|[^\s\\|;&<>]+)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedShellCommand:
|
||||
"""Structured command parsing result.
|
||||
|
||||
``use_shell`` is True when the command must run through a real shell (explicit
|
||||
``!`` passthrough, shell operators / substitution, or input that could not be
|
||||
tokenized). ``passthrough`` records only the explicit ``!`` prefix so the
|
||||
runner can surface the "shell passthrough" hint for it.
|
||||
"""
|
||||
|
||||
command: str
|
||||
argv: list[str] | None
|
||||
passthrough: bool
|
||||
use_shell: bool
|
||||
parse_error: str | None = None
|
||||
|
||||
|
||||
def _split_argv(command: str, *, is_windows: bool) -> list[str] | None:
|
||||
try:
|
||||
return shlex.split(command, posix=not is_windows)
|
||||
except ValueError:
|
||||
try:
|
||||
return shlex.split(command, posix=False)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_shell_command(command: str, *, is_windows: bool) -> ParsedShellCommand:
|
||||
"""Parse command text into an executable shape (no safety policy applied)."""
|
||||
stripped = command.strip()
|
||||
|
||||
if stripped.startswith(_EXPLICIT_SHELL_PREFIX):
|
||||
passthrough_command = stripped[len(_EXPLICIT_SHELL_PREFIX) :].strip()
|
||||
if not passthrough_command:
|
||||
return ParsedShellCommand(
|
||||
command="",
|
||||
argv=None,
|
||||
passthrough=True,
|
||||
use_shell=True,
|
||||
parse_error="missing command after passthrough prefix (!).",
|
||||
)
|
||||
return ParsedShellCommand(
|
||||
command=passthrough_command,
|
||||
argv=None,
|
||||
passthrough=True,
|
||||
use_shell=True,
|
||||
)
|
||||
|
||||
if (
|
||||
_SHELL_OPERATOR_RE.search(stripped) is not None
|
||||
or _INLINE_SUBSHELL_RE.search(stripped) is not None
|
||||
or _HEREDOC_START_RE.search(stripped) is not None
|
||||
):
|
||||
# Operators / substitution need a real shell; alpha mode runs them.
|
||||
return ParsedShellCommand(
|
||||
command=stripped,
|
||||
argv=None,
|
||||
passthrough=False,
|
||||
use_shell=True,
|
||||
)
|
||||
|
||||
argv = _split_argv(stripped, is_windows=is_windows)
|
||||
if argv is None:
|
||||
# Could not tokenize (e.g. unbalanced quotes). Hand the raw string to the
|
||||
# shell instead of blocking it.
|
||||
return ParsedShellCommand(
|
||||
command=stripped,
|
||||
argv=None,
|
||||
passthrough=False,
|
||||
use_shell=True,
|
||||
)
|
||||
|
||||
if not argv:
|
||||
return ParsedShellCommand(
|
||||
command=stripped,
|
||||
argv=None,
|
||||
passthrough=False,
|
||||
use_shell=False,
|
||||
parse_error="empty command.",
|
||||
)
|
||||
|
||||
if is_windows:
|
||||
|
||||
def _strip_outer_quotes(value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
argv = [_strip_outer_quotes(token) for token in argv]
|
||||
|
||||
return ParsedShellCommand(
|
||||
command=stripped,
|
||||
argv=argv,
|
||||
passthrough=False,
|
||||
use_shell=False,
|
||||
)
|
||||
|
||||
|
||||
def argv_for_repl_builtin_detection(
|
||||
*, parsed: ParsedShellCommand, is_windows: bool
|
||||
) -> list[str] | None:
|
||||
"""Argv tokens for detecting ``cd`` / ``pwd`` REPL builtins.
|
||||
|
||||
Only the plain ``argv`` path and explicit ``!`` passthrough opt into builtin
|
||||
detection. Operator / substitution commands run wholesale through the shell,
|
||||
so they intentionally return ``None`` here (a leading ``cd`` in
|
||||
``cd /tmp && ls`` must not be hijacked by the builtin handler).
|
||||
"""
|
||||
if parsed.argv is not None:
|
||||
return parsed.argv
|
||||
if not parsed.passthrough or not parsed.command.strip():
|
||||
return None
|
||||
return _split_argv(parsed.command.strip(), is_windows=is_windows)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ParsedShellCommand",
|
||||
"argv_for_repl_builtin_detection",
|
||||
"parse_shell_command",
|
||||
]
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Shell-specific execution policy for the interactive REPL.
|
||||
|
||||
Alpha mode allows every shell command; the only rejected case is genuinely
|
||||
empty input. These helpers live next to the rest of the shell machinery so the shared
|
||||
execution-policy module is not imported for shell-only concerns by other tools.
|
||||
They reuse the shared policy contracts
|
||||
(``ExecutionPolicyResult`` / ``ToolExecutionPlan``) from
|
||||
``tools.interactive_shell.shared``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import config.constants.platform as _platform
|
||||
from tools.interactive_shell.shared import (
|
||||
ExecutionPolicyResult,
|
||||
ToolExecutionMode,
|
||||
ToolExecutionPlan,
|
||||
)
|
||||
from tools.interactive_shell.shell.parsing import (
|
||||
ParsedShellCommand,
|
||||
parse_shell_command,
|
||||
)
|
||||
|
||||
|
||||
def evaluate_shell_from_parsed(parsed: ParsedShellCommand) -> ExecutionPolicyResult:
|
||||
"""Alpha mode: allow every shell command; only reject empty input.
|
||||
|
||||
There is no command classification or deny floor — any command (mutating,
|
||||
``restricted``, operators, substitution, passthrough) is allowed. A
|
||||
``parse_error`` only occurs for empty input (e.g. a bare ``!``), which is
|
||||
rejected because there is nothing to run.
|
||||
"""
|
||||
if parsed.parse_error is not None:
|
||||
return ExecutionPolicyResult(
|
||||
verdict="deny",
|
||||
tool_type="shell",
|
||||
reason=parsed.parse_error,
|
||||
hint="Enter a command to run.",
|
||||
shell_classification="unrestricted",
|
||||
)
|
||||
|
||||
return ExecutionPolicyResult(
|
||||
verdict="allow",
|
||||
tool_type="shell",
|
||||
reason=None,
|
||||
shell_classification="unrestricted",
|
||||
)
|
||||
|
||||
|
||||
def plan_shell_execution(parsed: ParsedShellCommand) -> ToolExecutionPlan:
|
||||
policy = evaluate_shell_from_parsed(parsed)
|
||||
classification = policy.shell_classification or "unrestricted"
|
||||
return ToolExecutionPlan(
|
||||
tool_type="shell",
|
||||
classification=classification,
|
||||
execution_mode=ToolExecutionMode.FOREGROUND,
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
|
||||
def evaluate_shell_command(command: str) -> ExecutionPolicyResult:
|
||||
"""Map shell policy + passthrough rules into allow/ask/deny."""
|
||||
parsed = parse_shell_command(command, is_windows=_platform.IS_WINDOWS)
|
||||
return evaluate_shell_from_parsed(parsed)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"evaluate_shell_command",
|
||||
"evaluate_shell_from_parsed",
|
||||
"plan_shell_execution",
|
||||
]
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Shell command runner: execute builtins and record results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import config.constants.platform as _platform
|
||||
from tools.interactive_shell.shell import execution as shell_execution
|
||||
from tools.interactive_shell.shell.display import format_shell_command_for_display
|
||||
from tools.interactive_shell.shell.parsing import (
|
||||
argv_for_repl_builtin_detection,
|
||||
parse_shell_command,
|
||||
)
|
||||
from tools.interactive_shell.shell.policy import plan_shell_execution
|
||||
from tools.interactive_shell.subprocess import (
|
||||
MAX_COMMAND_OUTPUT_CHARS,
|
||||
SHELL_COMMAND_TIMEOUT_SECONDS,
|
||||
SubprocessPresenter,
|
||||
)
|
||||
|
||||
_ERROR_STYLE = "error"
|
||||
_HIGHLIGHT_STYLE = "highlight"
|
||||
|
||||
|
||||
def _shell_payload(
|
||||
*,
|
||||
command: str,
|
||||
ok: bool,
|
||||
response_text: str | None = None,
|
||||
stdout: str = "",
|
||||
stderr: str = "",
|
||||
exit_code: int | None = None,
|
||||
timed_out: bool = False,
|
||||
truncated: bool = False,
|
||||
executed_with_shell: bool | None = None,
|
||||
cancelled: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"ok": ok,
|
||||
"command": command,
|
||||
"stdout": stdout.strip(),
|
||||
"stderr": stderr.strip(),
|
||||
"exit_code": exit_code,
|
||||
"timed_out": timed_out,
|
||||
"truncated": truncated,
|
||||
"cancelled": cancelled,
|
||||
}
|
||||
if executed_with_shell is not None:
|
||||
payload["executed_with_shell"] = executed_with_shell
|
||||
if response_text:
|
||||
payload["response_text"] = response_text.strip()
|
||||
return payload
|
||||
|
||||
|
||||
def run_shell_command(
|
||||
command: str,
|
||||
presenter: SubprocessPresenter,
|
||||
*,
|
||||
argv: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
session = presenter.session
|
||||
parsed = parse_shell_command(command, is_windows=_platform.IS_WINDOWS)
|
||||
plan = plan_shell_execution(parsed)
|
||||
display_command = format_shell_command_for_display(command)
|
||||
if not presenter.execution_allowed(
|
||||
plan.policy,
|
||||
action_summary=f"$ {display_command}",
|
||||
):
|
||||
session.record("shell", command, ok=False)
|
||||
return _shell_payload(
|
||||
command=command,
|
||||
ok=False,
|
||||
response_text=plan.policy.reason or "shell command blocked",
|
||||
cancelled=plan.policy.verdict != "deny",
|
||||
)
|
||||
|
||||
presenter.print_bold_command(display_command)
|
||||
|
||||
argv_builtin = argv_for_repl_builtin_detection(parsed=parsed, is_windows=_platform.IS_WINDOWS)
|
||||
|
||||
if argv_builtin is not None and argv_builtin[0].lower() == "cd":
|
||||
return run_cd_command(parsed.command, presenter)
|
||||
if argv_builtin is not None and argv_builtin[0].lower() == "pwd":
|
||||
return run_pwd_command(parsed.command, presenter)
|
||||
|
||||
use_shell = parsed.use_shell
|
||||
if parsed.passthrough:
|
||||
presenter.print("[dim]explicit shell passthrough enabled[/]")
|
||||
|
||||
exec_argv = argv if argv is not None else parsed.argv
|
||||
|
||||
response_text: str | None = None
|
||||
|
||||
try:
|
||||
result = shell_execution.execute_shell_command(
|
||||
command=parsed.command,
|
||||
argv=exec_argv,
|
||||
use_shell=use_shell,
|
||||
timeout_seconds=SHELL_COMMAND_TIMEOUT_SECONDS,
|
||||
max_output_chars=MAX_COMMAND_OUTPUT_CHARS,
|
||||
)
|
||||
except Exception as exc:
|
||||
presenter.report_exception(exc, context="surfaces.interactive_shell.shell_command.start")
|
||||
|
||||
response_text = f"command failed to start: {str(exc)}"
|
||||
|
||||
presenter.print_error(f"command failed to start: {exc}")
|
||||
session.record("shell", command, ok=False, response_text=response_text)
|
||||
return _shell_payload(
|
||||
command=command,
|
||||
ok=False,
|
||||
response_text=response_text,
|
||||
stderr=str(exc),
|
||||
executed_with_shell=use_shell,
|
||||
)
|
||||
|
||||
presenter.print_command_output(result.stdout)
|
||||
presenter.print_command_output(result.stderr, style=_ERROR_STYLE)
|
||||
if result.timed_out:
|
||||
response_text = f"command timed out after {SHELL_COMMAND_TIMEOUT_SECONDS} seconds"
|
||||
|
||||
presenter.print(
|
||||
f"[error]command timed out after {SHELL_COMMAND_TIMEOUT_SECONDS} seconds[/]"
|
||||
)
|
||||
session.record("shell", command, ok=False, response_text=response_text)
|
||||
return _shell_payload(
|
||||
command=command,
|
||||
ok=False,
|
||||
response_text=response_text,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
exit_code=result.exit_code,
|
||||
timed_out=True,
|
||||
truncated=result.truncated,
|
||||
executed_with_shell=result.executed_with_shell,
|
||||
)
|
||||
ok = result.exit_code == 0
|
||||
had_stdout = bool((result.stdout or "").strip())
|
||||
had_stderr = bool((result.stderr or "").strip())
|
||||
if ok:
|
||||
if had_stdout:
|
||||
response_text = (result.stdout or "").strip()
|
||||
elif had_stderr:
|
||||
response_text = (result.stderr or "").strip()
|
||||
else:
|
||||
presenter.print(f"[{_HIGHLIGHT_STYLE}]✓[/]")
|
||||
else:
|
||||
code = result.exit_code if result.exit_code is not None else "?"
|
||||
exit_text = f"✗ exit {code}"
|
||||
presenter.print_error(f"✗ exit {code}")
|
||||
|
||||
response_parts = []
|
||||
if had_stdout:
|
||||
response_parts.append((result.stdout or "").strip())
|
||||
if had_stderr:
|
||||
response_parts.append((result.stderr or "").strip())
|
||||
response_parts.append(exit_text)
|
||||
response_text = "\n".join(response_parts)
|
||||
|
||||
session.record("shell", command, ok=ok, response_text=response_text)
|
||||
stderr_for_result = "" if ok and had_stdout else result.stderr
|
||||
return _shell_payload(
|
||||
command=command,
|
||||
ok=ok,
|
||||
response_text=response_text,
|
||||
stdout=result.stdout,
|
||||
stderr=stderr_for_result,
|
||||
exit_code=result.exit_code,
|
||||
timed_out=False,
|
||||
truncated=result.truncated,
|
||||
executed_with_shell=result.executed_with_shell,
|
||||
)
|
||||
|
||||
|
||||
def run_cd_command(command: str, presenter: SubprocessPresenter) -> dict[str, Any]:
|
||||
session = presenter.session
|
||||
|
||||
def _strip_outer_quotes(value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
try:
|
||||
tokens = shlex.split(command, posix=not _platform.IS_WINDOWS)
|
||||
if _platform.IS_WINDOWS and len(tokens) > 1:
|
||||
tokens = [tokens[0], *(_strip_outer_quotes(token) for token in tokens[1:])]
|
||||
except ValueError as exc:
|
||||
response_text = f"cd failed: {str(exc)}"
|
||||
|
||||
presenter.print_error(f"cd failed: {exc}")
|
||||
session.record("shell", command, ok=False, response_text=response_text)
|
||||
return _shell_payload(command=command, ok=False, response_text=response_text)
|
||||
|
||||
if len(tokens) > 2:
|
||||
response_text = "cd failed: too many arguments"
|
||||
|
||||
presenter.print("[error]cd failed:[/] too many arguments")
|
||||
session.record("shell", command, ok=False, response_text=response_text)
|
||||
return _shell_payload(command=command, ok=False, response_text=response_text)
|
||||
|
||||
target = Path(tokens[1]).expanduser() if len(tokens) == 2 else Path.home()
|
||||
try:
|
||||
os.chdir(target)
|
||||
except Exception as exc:
|
||||
presenter.report_exception(exc, context="surfaces.interactive_shell.shell_cd")
|
||||
|
||||
response_text = f"cd failed: {str(exc)}"
|
||||
|
||||
presenter.print_error(f"cd failed: {exc}")
|
||||
session.record("shell", command, ok=False, response_text=response_text)
|
||||
return _shell_payload(command=command, ok=False, response_text=response_text)
|
||||
|
||||
cwd = str(Path.cwd())
|
||||
presenter.print_plain(cwd)
|
||||
session.record("shell", command)
|
||||
return _shell_payload(command=command, ok=True, response_text=cwd)
|
||||
|
||||
|
||||
def run_pwd_command(command: str, presenter: SubprocessPresenter) -> dict[str, Any]:
|
||||
session = presenter.session
|
||||
try:
|
||||
tokens = shlex.split(command, posix=not _platform.IS_WINDOWS)
|
||||
except ValueError as exc:
|
||||
response_text = f"pwd failed: {str(exc)}"
|
||||
|
||||
presenter.print_error(f"pwd failed: {exc}")
|
||||
session.record("shell", command, ok=False, response_text=response_text)
|
||||
return _shell_payload(command=command, ok=False, response_text=response_text)
|
||||
|
||||
if len(tokens) != 1:
|
||||
response_text = "pwd failed: too many arguments"
|
||||
|
||||
presenter.print("[error]pwd failed:[/] too many arguments")
|
||||
session.record("shell", command, ok=False, response_text=response_text)
|
||||
return _shell_payload(command=command, ok=False, response_text=response_text)
|
||||
|
||||
cwd = str(Path.cwd())
|
||||
presenter.print_plain(cwd)
|
||||
session.record("shell", command)
|
||||
return _shell_payload(command=command, ok=True, response_text=cwd, stdout=cwd)
|
||||
|
||||
|
||||
__all__ = ["run_cd_command", "run_pwd_command", "run_shell_command"]
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Pure subprocess primitives and presenter port for interactive-shell action tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from core.agent_harness.tools.tool_context import ActionToolContext
|
||||
from tools.interactive_shell.shared import ExecutionPolicyResult
|
||||
|
||||
# --- constants ---
|
||||
|
||||
SHELL_COMMAND_TIMEOUT_SECONDS = 120
|
||||
SYNTHETIC_TEST_TIMEOUT_SECONDS = 1800
|
||||
CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS = 1800
|
||||
SYNTHETIC_POLL_SECONDS = 0.25
|
||||
MAX_COMMAND_OUTPUT_CHARS = 24_000
|
||||
SYNTHETIC_DIAG_CHARS = 2_000
|
||||
SIGTERM_GRACE_SECONDS = 10
|
||||
TASK_OUTPUT_JOIN_TIMEOUT_SECONDS = 2
|
||||
|
||||
# Width of the ``<task_id> <stream> │ `` prefix relayed subprocess lines add.
|
||||
TASK_OUTPUT_PREFIX_WIDTH = 18
|
||||
MIN_SUBPROCESS_TERMINAL_WIDTH = 60
|
||||
|
||||
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[mA-Za-z]")
|
||||
|
||||
|
||||
# --- lifecycle ---
|
||||
|
||||
|
||||
def terminate_child_process(proc: subprocess.Popen[Any]) -> None:
|
||||
"""Best-effort SIGTERM → wait → SIGKILL → wait without blocking forever."""
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
with contextlib.suppress(OSError):
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=SIGTERM_GRACE_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
with contextlib.suppress(OSError):
|
||||
proc.kill()
|
||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||
proc.wait(timeout=5)
|
||||
|
||||
|
||||
def read_task_output(
|
||||
buf: tempfile.SpooledTemporaryFile[bytes] | None, # type: ignore[type-arg]
|
||||
*,
|
||||
limit: int,
|
||||
) -> str:
|
||||
"""Read up to ``limit`` bytes from a captured output buffer, ANSI-stripped."""
|
||||
if buf is None:
|
||||
return ""
|
||||
try:
|
||||
buf.seek(0)
|
||||
raw = buf.read(limit).decode("utf-8", errors="replace").strip()
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
return _ANSI_ESCAPE.sub("", raw)
|
||||
|
||||
|
||||
def read_diag(buf: tempfile.SpooledTemporaryFile[bytes]) -> str: # type: ignore[type-arg]
|
||||
"""Read up to ``SYNTHETIC_DIAG_CHARS`` bytes from a captured stderr buffer."""
|
||||
return read_task_output(buf, limit=SYNTHETIC_DIAG_CHARS)
|
||||
|
||||
|
||||
# --- environment ---
|
||||
|
||||
|
||||
def subprocess_env_with_width(*, columns: int, lines: int | None = None) -> dict[str, str]:
|
||||
"""Return ``os.environ`` patched so a piped Rich subprocess wraps to fit."""
|
||||
available = max(
|
||||
MIN_SUBPROCESS_TERMINAL_WIDTH,
|
||||
columns - TASK_OUTPUT_PREFIX_WIDTH - 1,
|
||||
)
|
||||
env = dict(os.environ)
|
||||
env["COLUMNS"] = str(available)
|
||||
env.setdefault("LINES", str(max(20, lines or 24)))
|
||||
return env
|
||||
|
||||
|
||||
# --- watcher ---
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubprocessWatchResult:
|
||||
"""Outcome of watching a child process until exit, timeout, or cancel."""
|
||||
|
||||
timed_out: bool
|
||||
cancelled: bool
|
||||
exit_code: int | None
|
||||
terminated_by_watcher: bool
|
||||
|
||||
|
||||
def watch_subprocess_until_exit(
|
||||
proc: subprocess.Popen[Any],
|
||||
*,
|
||||
cancel_event: threading.Event,
|
||||
timeout_seconds: float,
|
||||
poll_seconds: float = SYNTHETIC_POLL_SECONDS,
|
||||
) -> SubprocessWatchResult:
|
||||
"""Poll ``proc`` until it exits, ``cancel_event`` is set, or ``timeout_seconds`` elapses."""
|
||||
started = time.monotonic()
|
||||
timed_out = False
|
||||
terminated_by_watcher = False
|
||||
while proc.poll() is None:
|
||||
if time.monotonic() - started > timeout_seconds:
|
||||
timed_out = True
|
||||
terminate_child_process(proc)
|
||||
terminated_by_watcher = True
|
||||
break
|
||||
if cancel_event.is_set():
|
||||
terminate_child_process(proc)
|
||||
terminated_by_watcher = True
|
||||
break
|
||||
time.sleep(poll_seconds)
|
||||
return SubprocessWatchResult(
|
||||
timed_out=timed_out,
|
||||
cancelled=cancel_event.is_set(),
|
||||
exit_code=proc.returncode,
|
||||
terminated_by_watcher=terminated_by_watcher,
|
||||
)
|
||||
|
||||
|
||||
# --- presenter port ---
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SubprocessPresenter(Protocol):
|
||||
"""Surface-injected UI and session hooks for subprocess runners."""
|
||||
|
||||
@property
|
||||
def session(self) -> Any:
|
||||
"""Mutable per-turn session (``core.agent_harness.session.Session``)."""
|
||||
|
||||
def execution_allowed(
|
||||
self,
|
||||
policy: ExecutionPolicyResult,
|
||||
*,
|
||||
action_summary: str,
|
||||
) -> bool:
|
||||
"""Apply execution policy UX and return whether the action may proceed."""
|
||||
|
||||
def print(self, message: str = "") -> None:
|
||||
"""Print a Rich-markup message."""
|
||||
|
||||
def print_error(self, message: str) -> None:
|
||||
"""Print an error-styled plain-text message."""
|
||||
|
||||
def print_highlight(self, message: str) -> None:
|
||||
"""Print a highlight-styled plain-text message."""
|
||||
|
||||
def print_bold_command(self, display_command: str) -> None:
|
||||
"""Print a ``$ <command>`` header line."""
|
||||
|
||||
def print_command_output(self, text: str, *, style: str | None = None) -> None:
|
||||
"""Print captured subprocess stdout/stderr."""
|
||||
|
||||
def print_plain(self, text: str) -> None:
|
||||
"""Print plain text without Rich markup interpretation."""
|
||||
|
||||
def report_exception(self, exc: BaseException, *, context: str) -> None:
|
||||
"""Report an unexpected exception to observability."""
|
||||
|
||||
def subprocess_env(self) -> dict[str, str]:
|
||||
"""Environment for child subprocesses with terminal width alignment."""
|
||||
|
||||
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]:
|
||||
"""Start relay threads for a background task's stdout/stderr."""
|
||||
|
||||
def join_task_output_streams(self, threads: list[threading.Thread]) -> None:
|
||||
"""Wait briefly for relay threads to finish."""
|
||||
|
||||
def start_background_cli_task(
|
||||
self,
|
||||
*,
|
||||
display_command: str,
|
||||
argv_list: list[str],
|
||||
timeout_seconds: int,
|
||||
kind: Any,
|
||||
use_pty: bool = False,
|
||||
) -> Any:
|
||||
"""Launch a background opensre CLI subprocess with streamed output."""
|
||||
|
||||
|
||||
def require_subprocess_presenter(ctx: ActionToolContext) -> SubprocessPresenter:
|
||||
presenter = ctx.subprocess_presenter
|
||||
if not isinstance(presenter, SubprocessPresenter):
|
||||
raise RuntimeError("subprocess presenter is required for this action tool")
|
||||
return presenter
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CLAUDE_CODE_IMPLEMENTATION_TIMEOUT_SECONDS",
|
||||
"MAX_COMMAND_OUTPUT_CHARS",
|
||||
"MIN_SUBPROCESS_TERMINAL_WIDTH",
|
||||
"SHELL_COMMAND_TIMEOUT_SECONDS",
|
||||
"SIGTERM_GRACE_SECONDS",
|
||||
"SYNTHETIC_DIAG_CHARS",
|
||||
"SYNTHETIC_POLL_SECONDS",
|
||||
"SYNTHETIC_TEST_TIMEOUT_SECONDS",
|
||||
"SubprocessPresenter",
|
||||
"SubprocessWatchResult",
|
||||
"TASK_OUTPUT_JOIN_TIMEOUT_SECONDS",
|
||||
"TASK_OUTPUT_PREFIX_WIDTH",
|
||||
"read_diag",
|
||||
"read_task_output",
|
||||
"require_subprocess_presenter",
|
||||
"subprocess_env_with_width",
|
||||
"terminate_child_process",
|
||||
"watch_subprocess_until_exit",
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Synthetic-test machinery for the interactive REPL.
|
||||
|
||||
Groups the synthetic-test execution concern next to the agent-facing
|
||||
``tools.interactive_shell.actions.synthetic``:
|
||||
|
||||
* ``runner`` spawns the synthetic-test subprocess, watches its lifecycle in a
|
||||
daemon thread, applies the execution policy, and records the turn outcome.
|
||||
|
||||
Import submodules explicitly (for example ``tools.interactive_shell.synthetic.runner``)
|
||||
rather than relying on this package initializer, to keep interactive-shell
|
||||
startup import-light.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Synthetic test task runner — watch subprocess lifecycle and report outcomes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from platform.common.task_types import TaskKind, TaskRecord
|
||||
from tools.interactive_shell.cli import build_opensre_cli_argv
|
||||
from tools.interactive_shell.shared import allow_tool
|
||||
from tools.interactive_shell.subprocess import (
|
||||
SYNTHETIC_DIAG_CHARS,
|
||||
SYNTHETIC_TEST_TIMEOUT_SECONDS,
|
||||
SubprocessPresenter,
|
||||
read_diag,
|
||||
watch_subprocess_until_exit,
|
||||
)
|
||||
|
||||
DEFAULT_SYNTHETIC_SCENARIO = "001-replication-lag"
|
||||
|
||||
_SYNTHETIC_SCENARIO_ID_RE = re.compile(r"^\d{3}-[a-z0-9][a-z0-9-]*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyntheticSuiteSpec:
|
||||
"""Resolved synthetic suite/scenario selection."""
|
||||
|
||||
suite_name: str
|
||||
scenario: str
|
||||
run_all: bool
|
||||
display_command: str
|
||||
valid: bool
|
||||
|
||||
|
||||
def resolve_synthetic_suite(suite_name: str) -> SyntheticSuiteSpec:
|
||||
suite_spec = suite_name.strip().lower()
|
||||
resolved_suite_name = ""
|
||||
resolved_scenario = DEFAULT_SYNTHETIC_SCENARIO
|
||||
run_all = False
|
||||
if suite_spec == "rds_postgres":
|
||||
resolved_suite_name = "rds_postgres"
|
||||
elif suite_spec == "rds_postgres:all":
|
||||
resolved_suite_name = "rds_postgres"
|
||||
run_all = True
|
||||
elif suite_spec.startswith("rds_postgres:"):
|
||||
requested_scenario = suite_spec.split(":", 1)[1].strip()
|
||||
if requested_scenario and _SYNTHETIC_SCENARIO_ID_RE.fullmatch(requested_scenario):
|
||||
resolved_suite_name = "rds_postgres"
|
||||
resolved_scenario = requested_scenario
|
||||
display_command = (
|
||||
"opensre tests synthetic all"
|
||||
if run_all
|
||||
else f"opensre tests synthetic --scenario {resolved_scenario}"
|
||||
)
|
||||
return SyntheticSuiteSpec(
|
||||
suite_name=resolved_suite_name,
|
||||
scenario=resolved_scenario,
|
||||
run_all=run_all,
|
||||
display_command=display_command,
|
||||
valid=resolved_suite_name == "rds_postgres",
|
||||
)
|
||||
|
||||
|
||||
def synthetic_cli_argv(spec: SyntheticSuiteSpec) -> list[str]:
|
||||
argv = (
|
||||
build_opensre_cli_argv(["tests", "synthetic", "all"])
|
||||
if spec.run_all
|
||||
else build_opensre_cli_argv(
|
||||
["tests", "synthetic", "--scenario", spec.scenario],
|
||||
)
|
||||
)
|
||||
if not argv:
|
||||
return argv
|
||||
return [argv[0], "-u", *argv[1:]]
|
||||
|
||||
|
||||
def spawn_synthetic_subprocess(
|
||||
spec: SyntheticSuiteSpec,
|
||||
*,
|
||||
env: dict[str, str],
|
||||
) -> subprocess.Popen[str]:
|
||||
return subprocess.Popen(
|
||||
synthetic_cli_argv(spec),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
start_new_session=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def watch_synthetic_subprocess(
|
||||
task: TaskRecord,
|
||||
proc: subprocess.Popen[Any],
|
||||
presenter: SubprocessPresenter,
|
||||
suite_name: str,
|
||||
stderr_buf: tempfile.SpooledTemporaryFile[bytes], # type: ignore[type-arg]
|
||||
) -> None:
|
||||
session = presenter.session
|
||||
|
||||
def _history_text() -> str:
|
||||
return f"{suite_name} task:{task.task_id}"
|
||||
|
||||
history_gen_when_watch_started = session.terminal.history_generation
|
||||
|
||||
def _record_synthetic_if_current_session(ok: bool) -> None:
|
||||
if session.terminal.history_generation != history_gen_when_watch_started:
|
||||
return
|
||||
session.record("synthetic_test", _history_text(), ok=ok)
|
||||
|
||||
def _run() -> None:
|
||||
output_threads: list[threading.Thread] = []
|
||||
suggest_follow_up = False
|
||||
try:
|
||||
output_threads = presenter.start_task_output_streams(
|
||||
task=task,
|
||||
proc=proc,
|
||||
stderr_capture=stderr_buf,
|
||||
)
|
||||
watch_result = watch_subprocess_until_exit(
|
||||
proc,
|
||||
cancel_event=task.cancel_requested,
|
||||
timeout_seconds=SYNTHETIC_TEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
if watch_result.timed_out:
|
||||
task.mark_failed(f"timed out after {SYNTHETIC_TEST_TIMEOUT_SECONDS}s")
|
||||
_record_synthetic_if_current_session(ok=False)
|
||||
suggest_follow_up = True
|
||||
return
|
||||
|
||||
presenter.join_task_output_streams(output_threads)
|
||||
code = watch_result.exit_code
|
||||
if code is None:
|
||||
task.mark_failed("subprocess did not report exit code")
|
||||
_record_synthetic_if_current_session(ok=False)
|
||||
suggest_follow_up = True
|
||||
return
|
||||
|
||||
if watch_result.terminated_by_watcher and watch_result.cancelled:
|
||||
task.mark_cancelled()
|
||||
_record_synthetic_if_current_session(ok=False)
|
||||
return
|
||||
|
||||
if code == 0:
|
||||
task.mark_completed(result="ok")
|
||||
_record_synthetic_if_current_session(ok=True)
|
||||
else:
|
||||
diag = read_diag(stderr_buf)
|
||||
error_msg = f"exit code {code}" + (f": {diag}" if diag else "")
|
||||
task.mark_failed(error_msg)
|
||||
_record_synthetic_if_current_session(ok=False)
|
||||
suggest_follow_up = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
task.mark_failed(str(exc))
|
||||
presenter.report_exception(
|
||||
exc, context="surfaces.interactive_shell.synthetic_test.watch"
|
||||
)
|
||||
_record_synthetic_if_current_session(ok=False)
|
||||
suggest_follow_up = True
|
||||
presenter.print_error(f"synthetic watcher failed: {exc}")
|
||||
finally:
|
||||
presenter.join_task_output_streams(output_threads)
|
||||
stderr_buf.close()
|
||||
if (
|
||||
suggest_follow_up
|
||||
and session.terminal.history_generation == history_gen_when_watch_started
|
||||
):
|
||||
session.suggest_synthetic_failure_follow_up(label=suite_name)
|
||||
else:
|
||||
session.terminal.notify_prompt_changed()
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name=f"synthetic-{task.task_id}").start()
|
||||
|
||||
|
||||
def run_synthetic_test(suite_name: str, presenter: SubprocessPresenter) -> None:
|
||||
session = presenter.session
|
||||
spec = resolve_synthetic_suite(suite_name)
|
||||
if not spec.valid:
|
||||
presenter.print_error(f"unknown synthetic suite: {suite_name}")
|
||||
session.record("synthetic_test", suite_name, ok=False)
|
||||
return
|
||||
|
||||
policy = allow_tool("synthetic_test")
|
||||
if not presenter.execution_allowed(
|
||||
policy,
|
||||
action_summary=spec.display_command,
|
||||
):
|
||||
session.record("synthetic_test", suite_name, ok=False)
|
||||
return
|
||||
|
||||
presenter.print_bold_command(spec.display_command)
|
||||
session.last_synthetic_observation_path = None
|
||||
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST, command=spec.display_command)
|
||||
task.mark_running()
|
||||
stderr_buf: tempfile.SpooledTemporaryFile[bytes] = tempfile.SpooledTemporaryFile( # type: ignore[type-arg]
|
||||
max_size=SYNTHETIC_DIAG_CHARS * 2
|
||||
)
|
||||
try:
|
||||
proc = spawn_synthetic_subprocess(spec, env=presenter.subprocess_env())
|
||||
except Exception as exc:
|
||||
stderr_buf.close()
|
||||
task.mark_failed(str(exc))
|
||||
presenter.report_exception(exc, context="surfaces.interactive_shell.synthetic_test.start")
|
||||
presenter.print_error(f"synthetic test failed to start: {exc}")
|
||||
session.record("synthetic_test", suite_name, ok=False)
|
||||
return
|
||||
|
||||
session.record("synthetic_test", suite_name)
|
||||
task.attach_process(proc)
|
||||
watch_synthetic_subprocess(
|
||||
task,
|
||||
proc,
|
||||
presenter,
|
||||
f"{spec.suite_name}:{spec.scenario}",
|
||||
stderr_buf,
|
||||
)
|
||||
presenter.print(
|
||||
f"[dim]synthetic test started — task[/] [bold]{task.task_id}[/bold]. "
|
||||
"[highlight]/tasks[/] [dim]to monitor,[/] "
|
||||
f"[highlight]/cancel {task.task_id}[/] [dim]to stop.[/]"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["run_synthetic_test", "watch_synthetic_subprocess"]
|
||||
Reference in New Issue
Block a user