Files
tracer-cloud--opensre/surfaces/interactive_shell/controller.py
T
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

269 lines
9.3 KiB
Python

"""Top-level interactive-shell controller: prompt input, dispatch, and shutdown."""
from __future__ import annotations
import asyncio
import logging
import os
from collections.abc import Iterator
from contextlib import contextmanager
from prompt_toolkit import PromptSession
from prompt_toolkit.patch_stdout import patch_stdout
from rich.console import Console
from config.repl_config import ReplConfig
from core.domain.alerts import inbox as _alert_inbox
from surfaces.interactive_shell.runtime.background.workers import BackgroundTaskManager
from surfaces.interactive_shell.runtime.context import (
ReplRuntimeContext,
create_repl_runtime_context,
)
from surfaces.interactive_shell.runtime.core.prompt_manager import PromptManager
from surfaces.interactive_shell.runtime.core.state import (
ReplState,
SpinnerState,
)
from surfaces.interactive_shell.runtime.input import (
PromptInputReader,
)
from surfaces.interactive_shell.runtime.input.actions import (
CancelTurn,
CloseShell,
DeliverConfirmation,
IgnoreInput,
InputAction,
SubmitTurn,
)
from surfaces.interactive_shell.runtime.turn_host import (
AgentTurnRuntime,
run_agent_turn,
run_agent_turn_queue,
run_input_loop,
)
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui import DIM
log = logging.getLogger(__name__)
@contextmanager
def _alert_listener_token(token: str | None) -> Iterator[None]:
"""Install the configured alert token for this listener, restoring any prior value."""
key = "OPENSRE_ALERT_LISTENER_TOKEN"
previous = os.environ.get(key)
try:
if token:
os.environ[key] = token
else:
os.environ.pop(key, None)
yield
finally:
if previous is None:
os.environ.pop(key, None)
else:
os.environ[key] = previous
@contextmanager
def _alert_listener(
cfg: ReplConfig | None,
console: Console,
*,
existing: _alert_inbox.AlertInbox | None = None,
) -> Iterator[_alert_inbox.AlertInbox | None]:
if existing is not None:
yield existing
return
if cfg is None or not cfg.alert_listener_enabled:
yield None
return
from gateway.web_server import WebAppServerHandle, serve_webapp_in_thread
inbox: _alert_inbox.AlertInbox | None = None
handle: WebAppServerHandle | None = None
try:
inbox = _alert_inbox.AlertInbox()
_alert_inbox.set_current_inbox(inbox)
with _alert_listener_token(cfg.alert_listener_token):
handle = serve_webapp_in_thread(
host=cfg.alert_listener_host,
port=cfg.alert_listener_port,
)
console.print(f"[{DIM}]listening for alerts on http://{handle.bound_address}/alerts[/]")
try:
yield inbox
finally:
if handle is not None:
handle.stop()
_alert_inbox.set_current_inbox(None)
except Exception as exc:
log.warning("Alert listener could not start: %s — continuing without it.", exc)
_alert_inbox.set_current_inbox(None)
yield None
def _resolve_runtime_context(
session: Session | ReplRuntimeContext | None,
*,
state: ReplState | None,
spinner: SpinnerState | None,
pt_session: PromptSession[str] | None,
inbox: _alert_inbox.AlertInbox | None,
) -> ReplRuntimeContext:
if isinstance(session, ReplRuntimeContext):
if state is None and spinner is None and pt_session is None and inbox is None:
return session
return ReplRuntimeContext(
session=session.session,
state=state if state is not None else session.state,
spinner=spinner if spinner is not None else session.spinner,
pt_session=pt_session if pt_session is not None else session.pt_session,
inbox=inbox if inbox is not None else session.inbox,
)
return create_repl_runtime_context(
session,
state=state,
spinner=spinner,
pt_session=pt_session,
inbox=inbox,
)
class InteractiveShellController:
"""Coordinate prompt input, queued dispatch, background workers, and shutdown."""
def __init__(
self,
session: Session | ReplRuntimeContext | None = None,
*,
config: ReplConfig | None = None,
state: ReplState | None = None,
spinner: SpinnerState | None = None,
pt_session: PromptSession[str] | None = None,
inbox: _alert_inbox.AlertInbox | None = None,
console: Console | None = None,
) -> None:
self.runtime_context = _resolve_runtime_context(
session,
state=state,
spinner=spinner,
pt_session=pt_session,
inbox=inbox,
)
self.config = config
self.session = self.runtime_context.session
self.inbox = self.runtime_context.inbox
self.state = self.runtime_context.state
self.spinner = self.runtime_context.spinner
self.service_console = console or Console(
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
self.prompt = PromptManager(
self.session,
self.state,
self.spinner,
self.runtime_context.pt_session,
)
self.turn_runtime = AgentTurnRuntime(
session=self.session,
state=self.state,
spinner=self.spinner,
invalidate_prompt=lambda: self.prompt.invalidate_prompt(),
request_exit=self.prompt.request_exit,
)
self.echo_console = Console(highlight=False, force_terminal=True, color_system="truecolor")
self.input_reader = PromptInputReader(
self.prompt,
self.state,
self.session,
self.echo_console,
)
self.background: BackgroundTaskManager | None = None
self.tasks: list[tuple[str, asyncio.Task[None]]] = []
async def start_interactive_shell(self) -> None:
with _alert_listener(self.config, self.service_console, existing=self.inbox) as inbox:
self.inbox = inbox
self.runtime_context.inbox = inbox
self._start_runtime_services()
try:
with patch_stdout(raw=True):
# Main input loop: reads prompts and enqueues submitted turns
# onto state.queue. The agent turns themselves run in
# run_agent_turn_queue, started above in _start_runtime_services.
await run_input_loop(
state=self.state,
session=self.session,
background=self.background,
input_reader=self.input_reader,
echo_console=self.echo_console,
handle_input_action=self._handle_input_action,
)
finally:
await self._shutdown_runtime()
def _start_runtime_services(self) -> None:
self.prompt.setup()
self.background = BackgroundTaskManager(
self.session,
self.state,
self.spinner,
self.inbox,
self.prompt.invalidate_prompt,
)
self.tasks = self.background.start_all(
lambda: run_agent_turn_queue(
state=self.state,
run_turn=lambda text: run_agent_turn(self.turn_runtime, text),
)
)
# Fleet sampler is lazy: /fleet triggers it on first live use.
self.session.terminal.fleet_sampler_starter = self.background.ensure_fleet_sampler_started
async def _handle_input_action(self, action: InputAction) -> bool:
match action:
case IgnoreInput():
return True
case CloseShell():
return False
case CancelTurn(submitted_text=text):
if text:
self.prompt.render_submitted_prompt(self.echo_console, text)
self.state.cancel_current_dispatch()
return True
case DeliverConfirmation(text=text):
self.state.deliver_confirmation(text)
return True
case SubmitTurn(text=text, wait_until_idle=wait, warning=warning):
if warning:
self.echo_console.print(warning)
self.prompt.render_submitted_prompt(self.echo_console, text)
await self.state.queue.put(text)
if wait:
await self.state.queue.join()
return True
raise AssertionError(f"Unhandled input action: {action!r}")
async def _shutdown_runtime(self) -> None:
self.state.request_exit()
self.state.cancel_current_dispatch()
for _label, task in self.tasks:
task.cancel()
shutdown_results = await asyncio.gather(
*(task for _label, task in self.tasks),
return_exceptions=True,
)
for (label, _task), result in zip(self.tasks, shutdown_results, strict=True):
if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
log.debug("%s task shutdown raised exception: %s", label, result)
__all__ = ["InteractiveShellController"]