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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:45 +08:00
commit 4b6817381b
3933 changed files with 525247 additions and 0 deletions
+82
View File
@@ -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.
+29
View File
@@ -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"]
+35
View File
@@ -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",
]
+59
View File
@@ -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"]
+181
View File
@@ -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"]
+456
View File
@@ -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",
]
+17
View File
@@ -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
+68
View File
@@ -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"]
+125
View File
@@ -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"]
+159
View File
@@ -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",
]
+71
View File
@@ -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",
]
+246
View File
@@ -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"]
+227
View File
@@ -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
+231
View File
@@ -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"]