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
269 lines
9.3 KiB
Python
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"]
|