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

251 lines
8.7 KiB
Python

"""Runtime turn host for submitted interactive-shell prompts.
Three public runtime functions live here:
- ``run_agent_turn`` — set up shell presentation for one submitted turn and drive
its lifecycle (the injected ``run_turn`` callable for the queue).
- ``run_input_loop`` — read prompt input events and dispatch them until exit.
- ``run_agent_turn_queue`` — consume queued turns and run each one until exit.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import threading
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from rich.console import Console
from platform.analytics.repl_context import bound_repl_turn_context
from platform.observability.trace.spans import bind_session_trace, emit_thread_boundary
from surfaces.interactive_shell.runtime.agent_presentation import (
AgentEvent,
AgentEventSink,
ConsoleAgentEventSink,
)
from surfaces.interactive_shell.runtime.background.workers import BackgroundTaskManager
from surfaces.interactive_shell.runtime.core.confirmation import (
DispatchCancelled,
request_confirmation_via_prompt,
)
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 (
InputAction,
ShellInputSnapshot,
decide_input_action,
)
from surfaces.interactive_shell.runtime.utils.input_policy import (
turn_needs_exclusive_stdin,
)
from surfaces.interactive_shell.session import Session
from surfaces.interactive_shell.ui.output.console_state import set_investigation_spinner
from surfaces.interactive_shell.ui.output.repl_progress import repl_safe_progress_scope
from surfaces.interactive_shell.ui.streaming.console import StreamingConsole
from surfaces.interactive_shell.utils.error_handling.exception_reporting import report_exception
from surfaces.interactive_shell.utils.telemetry import PromptRecorder
_logger = logging.getLogger(__name__)
_AGENT_TURN_KIND = "agent"
@dataclass(frozen=True)
class AgentTurnRuntime:
"""Immutable dependencies for running one submitted shell turn."""
session: Session
state: ReplState
spinner: SpinnerState
invalidate_prompt: Callable[[], None]
request_exit: Callable[[], None] | None = None
async def run_agent_turn(runtime: AgentTurnRuntime, text: str) -> None:
"""Set up shell presentation for one turn and drive its lifecycle."""
dispatch_cancel = threading.Event()
console = StreamingConsole(
runtime.spinner,
dispatch_cancel,
highlight=False,
force_terminal=True,
color_system="truecolor",
legacy_windows=False,
)
emit = ConsoleAgentEventSink(
session=runtime.session,
spinner=runtime.spinner,
console=console,
)
recorder = PromptRecorder.start(
session=runtime.session,
text=text,
turn_kind=_AGENT_TURN_KIND,
)
exclusive_stdin = turn_needs_exclusive_stdin(text, runtime.session)
progress_scope = contextlib.nullcontext() if exclusive_stdin else repl_safe_progress_scope()
runtime.session.terminal.exclusive_stdin_active = exclusive_stdin
# Expose this turn's spinner so investigation stages can animate phase labels.
set_investigation_spinner(runtime.spinner)
emit_thread_boundary(
runtime.session.session_id,
name="turn_boundary",
phase="turn_start",
)
try:
with (
bind_session_trace(runtime.session.session_id),
progress_scope,
):
await _run_agent_turn_loop(
runtime=runtime,
text=text,
output=console,
recorder=recorder,
confirm=lambda prompt: request_confirmation_via_prompt(runtime.state, prompt),
emit=emit,
dispatch_cancel=dispatch_cancel,
)
finally:
set_investigation_spinner(None)
runtime.session.terminal.exclusive_stdin_active = False
emit_thread_boundary(
runtime.session.session_id,
name="turn_boundary",
phase="turn_end",
)
async def _run_agent_turn_loop(
*,
runtime: AgentTurnRuntime,
text: str,
output: StreamingConsole,
recorder: PromptRecorder | None,
confirm: Callable[[str], str],
emit: AgentEventSink,
dispatch_cancel: threading.Event,
) -> None:
current_task = asyncio.current_task()
if current_task is not None:
runtime.state.start_dispatch(task=current_task, cancel_event=dispatch_cancel)
else:
runtime.state.attach_cancel_event(dispatch_cancel)
await emit(AgentEvent(type="turn_start", text=text))
try:
# Imported lazily so constructing the controller (and importing this
# module) does not pull the harness/turn-execution stack
# (``action_agent -> core.agent``) before the first turn is queued.
from surfaces.interactive_shell.runtime.shell_turn_execution import execute_shell_turn
with bound_repl_turn_context(
session_id=runtime.session.session_id,
turn_kind=_AGENT_TURN_KIND,
prompt_turn_id=recorder.turn_id if recorder is not None else None,
):
await asyncio.to_thread(
execute_shell_turn,
text,
runtime.session,
output,
recorder=recorder,
confirm_fn=confirm,
is_tty=None,
request_exit=runtime.request_exit,
)
except asyncio.CancelledError:
await emit(AgentEvent(type="turn_interrupted"))
raise
except DispatchCancelled:
await emit(AgentEvent(type="turn_interrupted"))
except Exception as exc:
report_exception(exc, context="surfaces.interactive_shell.turn")
await emit(AgentEvent(type="turn_error", error=exc))
finally:
runtime.state.finish_dispatch(dispatch_cancel)
await emit(AgentEvent(type="turn_end"))
async def run_input_loop(
*,
state: ReplState,
session: Session,
background: BackgroundTaskManager | None,
input_reader: PromptInputReader,
echo_console: Console,
handle_input_action: Callable[[InputAction], Awaitable[bool]],
) -> None:
"""Run the interactive session's main input loop until exit or close.
This loop reads input; it does not run agent turns itself. Each raw input
event is classified into an ``InputAction`` by ``decide_input_action`` and
handed to ``handle_input_action``. For a submitted prompt that handler pushes
the text onto ``state.queue``; the queued text is then consumed
asynchronously by ``run_agent_turn_queue`` (started in the controller's
``_start_runtime_services``), which runs each turn via ``run_agent_turn``.
Keeping input reading and turn execution as two separate loops joined only by
``state.queue`` is deliberate: it lets the user keep typing, cancel, or
answer a confirmation while a turn is still in flight.
"""
while not state.exit_requested:
if background is not None:
background.drain_turn_start_output(echo_console)
event = await input_reader.read()
action = decide_input_action(
event,
ShellInputSnapshot(
exit_requested=state.exit_requested,
dispatch_running=state.is_dispatch_running(),
awaiting_confirmation=state.is_awaiting_confirmation(),
),
needs_exclusive_stdin=lambda text: turn_needs_exclusive_stdin(
text,
session,
),
)
should_continue = await handle_input_action(action)
if not should_continue:
return
async def run_agent_turn_queue(
*,
state: ReplState,
run_turn: Callable[[str], Coroutine[Any, Any, None]],
) -> None:
"""Consume queued turns and run each one until exit."""
while not state.exit_requested:
try:
text = await state.queue.get()
except asyncio.CancelledError:
return
if state.exit_requested:
state.queue.task_done()
return
turn_task = asyncio.create_task(run_turn(text))
state.attach_turn_task(turn_task)
try:
await turn_task
except asyncio.CancelledError:
_logger.debug("Queued turn task was cancelled")
except Exception as exc:
_logger.debug("Queued turn task ended with exception: %s", exc)
finally:
state.clear_current_task()
state.queue.task_done()
__all__ = [
"AgentTurnRuntime",
"run_agent_turn",
"run_agent_turn_queue",
"run_input_loop",
]