chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""Core runtime engine for the interactive shell.
|
||||
|
||||
Reusable session state lives in ``core.agent_harness.session`` and terminal runtime
|
||||
context lives in ``interactive_shell.runtime.context``. This package owns the
|
||||
remaining runtime engine concerns (mutable runtime state, prompt manager,
|
||||
turn detection).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from platform.common.task_registry import TaskRegistry
|
||||
|
||||
__all__ = ["TaskRegistry"]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Prompt-mediated confirmation waiting for interactive-shell turns.
|
||||
|
||||
A turn worker thread parks here while it waits for the user to answer a
|
||||
confirmation prompt rendered by the REPL prompt loop. The wait is
|
||||
cancel-safe: it polls the dispatch cancel event and raises
|
||||
:class:`DispatchCancelled` instead of silently auto-confirming.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from surfaces.interactive_shell.runtime.core.state import PROMPT_REFRESH_INTERVAL_S, ReplState
|
||||
|
||||
|
||||
class DispatchCancelled(Exception):
|
||||
"""Raised when in-flight dispatch is cancelled during confirmation."""
|
||||
|
||||
|
||||
def request_confirmation_via_prompt(state: ReplState, prompt_text: str) -> str:
|
||||
response_event = threading.Event()
|
||||
state.begin_confirmation(response_event, prompt_text)
|
||||
try:
|
||||
while not response_event.is_set():
|
||||
cancel = state.current_cancel_event
|
||||
if cancel is not None and cancel.is_set():
|
||||
raise DispatchCancelled("cancelled while awaiting confirmation")
|
||||
response_event.wait(timeout=PROMPT_REFRESH_INTERVAL_S)
|
||||
if not state.confirm_response:
|
||||
raise DispatchCancelled("cancelled while awaiting confirmation")
|
||||
return state.confirm_response[0]
|
||||
finally:
|
||||
state.clear_confirmation()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DispatchCancelled",
|
||||
"request_confirmation_via_prompt",
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Prompt lifecycle and rendering glue for the interactive REPL loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.formatted_text import ANSI
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.runtime.core.state import (
|
||||
PROMPT_REFRESH_INTERVAL_S,
|
||||
ReplState,
|
||||
SpinnerState,
|
||||
)
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui import input_prompt
|
||||
from surfaces.interactive_shell.ui.components.cpr_stdin import (
|
||||
drain_stale_cpr_bytes,
|
||||
strip_cpr_sequences,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.input_prompt import rendering as prompt_rendering
|
||||
from surfaces.interactive_shell.ui.input_prompt.key_bindings import (
|
||||
build_cancel_key_bindings,
|
||||
install_session_key_bindings,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.input_prompt.refresh import wire_prompt_refresh
|
||||
from surfaces.interactive_shell.ui.input_prompt.style import refresh_prompt_theme
|
||||
|
||||
# Brief pause so a CPR reply still in flight lands in the stdin buffer before the
|
||||
# non-blocking drain runs; without it the reply leaks into this prompt as literal bytes.
|
||||
_CPR_SETTLE_SECONDS = 0.05
|
||||
|
||||
|
||||
class PromptManager:
|
||||
"""Own prompt-toolkit setup, prompt rendering, and prompt redraw hooks."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
state: ReplState,
|
||||
spinner: SpinnerState,
|
||||
pt_session: PromptSession[str] | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.state = state
|
||||
self.spinner = spinner
|
||||
self.pt_session = pt_session
|
||||
self.pt_app: Application[str] | None = None
|
||||
self.loop: asyncio.AbstractEventLoop | None = None
|
||||
self._invalidate_prompt: Callable[[], None] | None = None
|
||||
|
||||
def setup(self) -> None:
|
||||
if self.pt_session is None:
|
||||
self.pt_session = input_prompt.build_prompt_session(self.session)
|
||||
self.session.terminal.prompt_history_backend = self.pt_session.history
|
||||
|
||||
cancel_kb = build_cancel_key_bindings(self.state)
|
||||
install_session_key_bindings(self.pt_session, cancel_kb)
|
||||
|
||||
self.pt_app = self.pt_session.app
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.session.terminal.prompt_app = self.pt_app
|
||||
self.session.terminal.main_loop = self.loop
|
||||
self.state.bind_loop(self.loop)
|
||||
self._invalidate_prompt = wire_prompt_refresh(self.session, self.pt_app, self.loop)
|
||||
|
||||
@property
|
||||
def invalidate_prompt(self) -> Callable[[], None]:
|
||||
if self._invalidate_prompt is None:
|
||||
raise RuntimeError("PromptManager.setup() must run before prompt invalidation")
|
||||
return self._invalidate_prompt
|
||||
|
||||
def request_exit(self) -> None:
|
||||
if self.pt_app is None or self.loop is None:
|
||||
self.state.request_exit()
|
||||
return
|
||||
|
||||
self.state.request_exit()
|
||||
|
||||
def _exit_prompt_app(attempts_left: int = 5) -> None:
|
||||
if self.pt_app is not None and self.pt_app.is_running:
|
||||
self.pt_app.exit()
|
||||
return
|
||||
if attempts_left > 0 and self.loop is not None:
|
||||
self.loop.call_later(0.02, _exit_prompt_app, attempts_left - 1)
|
||||
|
||||
self.loop.call_soon_threadsafe(_exit_prompt_app)
|
||||
|
||||
def message_with_spinner(self) -> ANSI:
|
||||
base = prompt_rendering._prompt_message(self.session).value
|
||||
if self.state.is_awaiting_confirmation():
|
||||
confirm_text = self.state.confirm_prompt_text
|
||||
return ANSI(f"{confirm_text}\n{base}")
|
||||
prefix = strip_cpr_sequences(
|
||||
prompt_rendering.resolve_prompt_prefix_ansi(
|
||||
inline_spinner=self.spinner.inline_spinner_ansi(),
|
||||
idle_hint=prompt_rendering.resolve_idle_hint_ansi(self.session),
|
||||
)
|
||||
)
|
||||
return ANSI(f"{prefix}\n{base}")
|
||||
|
||||
async def read_prompt_text(self) -> str:
|
||||
if self.pt_session is None:
|
||||
raise RuntimeError("PromptManager.setup() must run before reading prompts")
|
||||
|
||||
if self.session.terminal.pending_theme_refresh:
|
||||
self.session.terminal.pending_theme_refresh = False
|
||||
refresh_prompt_theme(self.session)
|
||||
await asyncio.sleep(_CPR_SETTLE_SECONDS)
|
||||
drain_stale_cpr_bytes()
|
||||
|
||||
prefilled = self.session.terminal.pop_pending_prompt_default()
|
||||
if prefilled and self.session.terminal.pop_pending_autosubmit():
|
||||
return prefilled
|
||||
|
||||
return await self.pt_session.prompt_async(
|
||||
message=self.message_with_spinner,
|
||||
bottom_toolbar=self.spinner.toolbar_ansi,
|
||||
refresh_interval=PROMPT_REFRESH_INTERVAL_S,
|
||||
placeholder=lambda: prompt_rendering.resolve_prompt_placeholder(self.session),
|
||||
default=prefilled,
|
||||
)
|
||||
|
||||
def render_submitted_prompt(self, console: Console, text: str) -> None:
|
||||
prompt_rendering.render_submitted_prompt(console, self.session, text)
|
||||
@@ -0,0 +1,268 @@
|
||||
"""State models for the interactive shell UI runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import enum
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from prompt_toolkit.application.current import get_app_or_none
|
||||
|
||||
from platform.terminal import theme as ui_theme
|
||||
from surfaces.interactive_shell.ui.components.token_format import (
|
||||
_CHARS_PER_TOKEN,
|
||||
format_token_count_short,
|
||||
)
|
||||
|
||||
# How often prompt-toolkit refreshes prompt callbacks and confirmation polling.
|
||||
PROMPT_REFRESH_INTERVAL_S = 0.25
|
||||
|
||||
|
||||
class TurnPhase(enum.Enum):
|
||||
"""Explicit lifecycle phase of the current interactive-shell turn.
|
||||
|
||||
``phase`` is the declared turn intent and is authoritative for the
|
||||
confirmation and cancelling states. ``is_dispatch_running()`` remains
|
||||
derived from the asyncio task (the runtime truth of the in-flight turn),
|
||||
because a task can settle on its own without an explicit transition.
|
||||
"""
|
||||
|
||||
IDLE = "idle"
|
||||
DISPATCHING = "dispatching"
|
||||
AWAITING_CONFIRMATION = "awaiting_confirmation"
|
||||
CANCELLING = "cancelling"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplState:
|
||||
"""Shared runtime state for prompt loop, queue worker, and cancel handlers.
|
||||
|
||||
Single source of truth for the active dispatch task, cancellation event,
|
||||
confirmation lifecycle, exit request, and the explicit ``TurnPhase``.
|
||||
Mutate turn state through the transition methods below rather than poking
|
||||
raw fields, so ``phase`` stays consistent with the cancellation primitives.
|
||||
"""
|
||||
|
||||
queue: asyncio.Queue[str] = field(default_factory=asyncio.Queue)
|
||||
current_task: asyncio.Task[None] | None = None
|
||||
current_cancel_event: threading.Event | None = None
|
||||
loop: asyncio.AbstractEventLoop | None = None
|
||||
exit_requested: bool = False
|
||||
confirm_event: threading.Event | None = None
|
||||
confirm_response: list[str] = field(default_factory=list)
|
||||
confirm_prompt_text: str = ""
|
||||
phase: TurnPhase = TurnPhase.IDLE
|
||||
|
||||
def is_dispatch_running(self) -> bool:
|
||||
return self.current_task is not None and not self.current_task.done()
|
||||
|
||||
def is_awaiting_confirmation(self) -> bool:
|
||||
return self.phase is TurnPhase.AWAITING_CONFIRMATION
|
||||
|
||||
def is_cancelling(self) -> bool:
|
||||
return self.phase is TurnPhase.CANCELLING
|
||||
|
||||
def deliver_confirmation(self, answer: str) -> None:
|
||||
if self.confirm_event is None:
|
||||
return
|
||||
self.confirm_response.append(answer)
|
||||
self.confirm_event.set()
|
||||
|
||||
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self.loop = loop
|
||||
|
||||
def request_exit(self) -> None:
|
||||
self.exit_requested = True
|
||||
|
||||
def begin_confirmation(self, event: threading.Event, prompt_text: str = "") -> None:
|
||||
# Reset the response list BEFORE publishing ``confirm_event`` so a
|
||||
# concurrent ``deliver_confirmation`` cannot have its answer clobbered.
|
||||
# ``phase`` is set before the publish so a parked worker is observable
|
||||
# as awaiting confirmation the instant the event is visible.
|
||||
self.confirm_response = []
|
||||
self.confirm_prompt_text = prompt_text
|
||||
self.phase = TurnPhase.AWAITING_CONFIRMATION
|
||||
self.confirm_event = event
|
||||
|
||||
def clear_confirmation(self) -> None:
|
||||
self.confirm_event = None
|
||||
self.confirm_response = []
|
||||
self.confirm_prompt_text = ""
|
||||
# Only a normal confirmation completion returns to dispatching/idle; a
|
||||
# cancel in progress must keep its CANCELLING phase.
|
||||
if self.phase is TurnPhase.AWAITING_CONFIRMATION:
|
||||
self.phase = TurnPhase.DISPATCHING if self.is_dispatch_running() else TurnPhase.IDLE
|
||||
|
||||
def start_dispatch(self, *, task: asyncio.Task[None], cancel_event: threading.Event) -> None:
|
||||
self.current_task = task
|
||||
self.current_cancel_event = cancel_event
|
||||
self.phase = TurnPhase.DISPATCHING
|
||||
|
||||
def attach_turn_task(self, task: asyncio.Task[None]) -> None:
|
||||
"""Mark a queued turn task as the active dispatch (queue worker entry)."""
|
||||
self.current_task = task
|
||||
self.phase = TurnPhase.DISPATCHING
|
||||
|
||||
def attach_cancel_event(self, cancel_event: threading.Event) -> None:
|
||||
"""Park a cancel event for a dispatch that has no asyncio task."""
|
||||
self.current_cancel_event = cancel_event
|
||||
self.phase = TurnPhase.DISPATCHING
|
||||
|
||||
def clear_current_task(self, task: asyncio.Task[None] | None = None) -> None:
|
||||
if task is None or self.current_task is task:
|
||||
self.current_task = None
|
||||
self.phase = TurnPhase.IDLE
|
||||
|
||||
def finish_dispatch(self, cancel_event: threading.Event) -> None:
|
||||
if self.current_cancel_event is cancel_event:
|
||||
self.current_cancel_event = None
|
||||
self.phase = TurnPhase.IDLE
|
||||
|
||||
def cancel_current_dispatch(self) -> None:
|
||||
# Mark the cancel intent first, but only when there is something to
|
||||
# cancel, so an idle no-op call does not leave a stale CANCELLING phase.
|
||||
if (
|
||||
self.current_cancel_event is not None
|
||||
or self.confirm_event is not None
|
||||
or self.is_dispatch_running()
|
||||
):
|
||||
self.phase = TurnPhase.CANCELLING
|
||||
if self.current_cancel_event is not None:
|
||||
self.current_cancel_event.set()
|
||||
if self.confirm_event is not None:
|
||||
self.confirm_event.set()
|
||||
task = self.current_task
|
||||
if task is not None and not task.done():
|
||||
if self.loop is not None:
|
||||
self.loop.call_soon_threadsafe(task.cancel)
|
||||
else:
|
||||
task.cancel()
|
||||
|
||||
|
||||
class SpinnerState:
|
||||
"""Mutable state read by prompt callbacks for toolbar + inline spinner."""
|
||||
|
||||
_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
||||
# One glyph advance per interval of *elapsed time*. The frame must be a
|
||||
# pure function of the clock, never of how often the prompt message
|
||||
# callback runs: prompt_toolkit evaluates the message several times per
|
||||
# render pass (layout measurement + paint), so a per-call counter can land
|
||||
# on the same frame every visible render and freeze the animation.
|
||||
_FRAME_INTERVAL_S = 0.1
|
||||
_THINKING_VERBS = (
|
||||
"thinking",
|
||||
"pondering",
|
||||
"exploring",
|
||||
"reasoning",
|
||||
"considering",
|
||||
"analysing",
|
||||
"investigating",
|
||||
"deliberating",
|
||||
"ruminating",
|
||||
"deducing",
|
||||
"noodling",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.streaming: bool = False
|
||||
self.started_at: float = 0.0
|
||||
self.bytes_in: int = 0
|
||||
self._verb: str = self._THINKING_VERBS[0]
|
||||
self.phase: str = ""
|
||||
|
||||
def start(self) -> None:
|
||||
self.streaming = True
|
||||
self.started_at = time.monotonic()
|
||||
self.bytes_in = 0
|
||||
self._verb = random.choice(self._THINKING_VERBS)
|
||||
self.phase = ""
|
||||
|
||||
def set_phase(self, label: str) -> None:
|
||||
"""Animate a caller-supplied phase label instead of a thinking verb.
|
||||
|
||||
Investigation stages (``/investigate``) dispatch deterministically, so
|
||||
the turn-level "thinking" spinner never starts. The progress display
|
||||
calls this to keep the prompt spinner cycling with the active pipeline
|
||||
stage; it can be called repeatedly to advance the phase.
|
||||
"""
|
||||
if not self.streaming:
|
||||
self.started_at = time.monotonic()
|
||||
self._frame_idx = 0
|
||||
self.streaming = True
|
||||
self.phase = label
|
||||
|
||||
def stop(self) -> None:
|
||||
self.streaming = False
|
||||
self.phase = ""
|
||||
|
||||
def toolbar_ansi(self) -> str:
|
||||
# Always return an empty string so prompt_toolkit's ConditionalContainer
|
||||
# collapses the toolbar in every state. A visible toolbar causes
|
||||
# prompt_toolkit to emit \033[6n (CPR) cursor-position queries on every
|
||||
# refresh_interval; those responses leak into the vt100 input parser as
|
||||
# literal keystrokes, corrupting the input field. Hiding the toolbar
|
||||
# unconditionally also keeps its height at zero in both streaming and
|
||||
# idle states, which prevents the one-row height delta that would cause
|
||||
# prompt_toolkit to misplace the cursor and leave stale spinner lines on
|
||||
# screen. Idle hints are surfaced through idle_hint_ansi() instead,
|
||||
# which is rendered in the prompt message's reserved first line.
|
||||
return ""
|
||||
|
||||
def idle_hint_ansi(self) -> str:
|
||||
"""Dim hint line shown above the rule when no dispatch is running."""
|
||||
hint = "/ for commands · ↑↓ history"
|
||||
app = get_app_or_none()
|
||||
if app is not None and app.current_buffer.text:
|
||||
hint += " · esc to clear"
|
||||
return f"{ui_theme.DIM_ANSI}{hint}{ui_theme.ANSI_RESET}"
|
||||
|
||||
def inline_spinner_ansi(self) -> str:
|
||||
if not self.streaming:
|
||||
return ""
|
||||
elapsed = time.monotonic() - self.started_at
|
||||
token_count = self.bytes_in // _CHARS_PER_TOKEN
|
||||
frame_idx = int(elapsed / self._FRAME_INTERVAL_S)
|
||||
glyph = self._SPINNER_FRAMES[frame_idx % len(self._SPINNER_FRAMES)]
|
||||
if token_count > 0:
|
||||
tokens_str = format_token_count_short(token_count)
|
||||
suffix = f" ({elapsed:.0f}s · ↓ {tokens_str} tokens)"
|
||||
else:
|
||||
suffix = f" ({elapsed:.0f}s)"
|
||||
label = self.phase or f"{self._verb}…"
|
||||
return (
|
||||
f"{ui_theme.PROMPT_ACCENT_ANSI}{glyph} {label}{ui_theme.ANSI_RESET}"
|
||||
f"{ui_theme.ANSI_DIM}{suffix} esc to cancel{ui_theme.ANSI_RESET}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReplMutableState:
|
||||
"""Initial mutable state bundle shared by the interactive runtime."""
|
||||
|
||||
state: ReplState
|
||||
spinner: SpinnerState
|
||||
|
||||
|
||||
def create_repl_mutable_state(
|
||||
*,
|
||||
state: ReplState | None = None,
|
||||
spinner: SpinnerState | None = None,
|
||||
) -> ReplMutableState:
|
||||
"""Return the canonical initial mutable state objects for a REPL runtime."""
|
||||
return ReplMutableState(
|
||||
state=state if state is not None else ReplState(),
|
||||
spinner=spinner if spinner is not None else SpinnerState(),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROMPT_REFRESH_INTERVAL_S",
|
||||
"ReplMutableState",
|
||||
"ReplState",
|
||||
"SpinnerState",
|
||||
"TurnPhase",
|
||||
"create_repl_mutable_state",
|
||||
]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Turn-result data model and the single owner of shell-turn accounting.
|
||||
|
||||
This module holds the shell's accounting side effects around the core
|
||||
"facts only" turn-result models: action-agent analytics, terminal-turn
|
||||
aggregate telemetry, prompt-recorder flushing, conversational-turn
|
||||
persistence, and the final assistant-intent stamp.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# The neutral "facts only" turn-result models live in the decoupled agent
|
||||
# package; this module owns only the shell's accounting side effects over them.
|
||||
from core.agent_harness.turns.turn_results import (
|
||||
ShellTurnResult,
|
||||
ToolCallingAccountingStatus,
|
||||
ToolCallingTurnResult,
|
||||
)
|
||||
from platform.analytics.cli import capture_terminal_turn_summarized
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.utils.telemetry import PromptRecorder
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShellTurnAccounting:
|
||||
"""Single owner of a shell turn's accounting side effects.
|
||||
|
||||
Separates "what happened" (decided by the turn flow) from "how it is
|
||||
accounted for": action-agent analytics, terminal-turn aggregate telemetry,
|
||||
prompt-recorder flushing, conversational-turn persistence, and the final
|
||||
assistant-intent stamp.
|
||||
"""
|
||||
|
||||
session: Session
|
||||
text: str
|
||||
recorder: PromptRecorder | None
|
||||
|
||||
def record_action_result(self, action_result: ToolCallingTurnResult) -> None:
|
||||
"""Emit action-agent analytics and update terminal-turn aggregates."""
|
||||
self._record_action_analytics(action_result)
|
||||
self._record_terminal_turn(action_result)
|
||||
|
||||
def finalize(self, result: ShellTurnResult) -> ShellTurnResult:
|
||||
"""Flush the recorder, persist the turn, and stamp the session intent."""
|
||||
self._flush_prompt_recorder(result)
|
||||
if result.llm_run is not None:
|
||||
self.session.record("cli_agent", self.text)
|
||||
self.session.last_assistant_intent = result.final_intent
|
||||
return result
|
||||
|
||||
def _record_action_analytics(self, action_result: ToolCallingTurnResult) -> None:
|
||||
from platform.analytics.cli import (
|
||||
capture_repl_execution_policy_decision,
|
||||
capture_terminal_actions_executed,
|
||||
capture_terminal_actions_planned,
|
||||
)
|
||||
|
||||
if action_result.accounting_status == "not_run":
|
||||
capture_terminal_actions_executed(
|
||||
planned_count=0,
|
||||
executed_count=0,
|
||||
executed_success_count=0,
|
||||
)
|
||||
return
|
||||
|
||||
capture_terminal_actions_planned(
|
||||
planned_count=action_result.planned_count,
|
||||
has_unhandled_clause=action_result.has_unhandled_clause,
|
||||
)
|
||||
capture_repl_execution_policy_decision(
|
||||
{
|
||||
"policy_stage": "shell_action_agent",
|
||||
"policy_trace": (
|
||||
"agent_tool_calls" if action_result.planned_count else "assistant_handoff"
|
||||
),
|
||||
"planned_count": action_result.planned_count,
|
||||
"has_unhandled_clause": action_result.has_unhandled_clause,
|
||||
}
|
||||
)
|
||||
capture_terminal_actions_executed(
|
||||
planned_count=action_result.planned_count,
|
||||
executed_count=action_result.executed_count,
|
||||
executed_success_count=action_result.executed_success_count,
|
||||
)
|
||||
|
||||
def _record_terminal_turn(self, action_result: ToolCallingTurnResult) -> None:
|
||||
fallback_to_llm = not action_result.handled
|
||||
snapshot = self.session.terminal.metrics.record_turn(
|
||||
executed_count=action_result.executed_count,
|
||||
executed_success_count=action_result.executed_success_count,
|
||||
fallback_to_llm=fallback_to_llm,
|
||||
)
|
||||
capture_terminal_turn_summarized(
|
||||
planned_count=action_result.planned_count,
|
||||
executed_count=action_result.executed_count,
|
||||
executed_success_count=action_result.executed_success_count,
|
||||
fallback_to_llm=fallback_to_llm,
|
||||
session_turn_index=snapshot.turn_index,
|
||||
session_fallback_count=snapshot.fallback_count,
|
||||
session_action_success_percent=snapshot.action_success_percent,
|
||||
session_fallback_rate_percent=snapshot.fallback_rate_percent,
|
||||
)
|
||||
|
||||
def _flush_prompt_recorder(self, result: ShellTurnResult) -> None:
|
||||
# Pending turn LLM/error state is consumed unconditionally so a turn
|
||||
# that stages it can never leak it into a later turn's flush.
|
||||
pending_run = self.session.terminal.pop_pending_turn_llm()
|
||||
pending_error = self.session.terminal.pop_pending_turn_error()
|
||||
if self.recorder is None:
|
||||
return
|
||||
if pending_error is not None:
|
||||
self.recorder.set_error(pending_error[0], pending_error[1])
|
||||
self.recorder.set_response(
|
||||
result.assistant_response_text,
|
||||
result.llm_run if result.llm_run is not None else pending_run,
|
||||
)
|
||||
self.recorder.flush()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ShellTurnAccounting",
|
||||
"ShellTurnResult",
|
||||
"ToolCallingAccountingStatus",
|
||||
"ToolCallingTurnResult",
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Pure text classifiers for interactive-shell prompt turns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_INTERVENTION_CORRECTION_RE = re.compile(
|
||||
r"("
|
||||
r"no(?=[,.!?]|$)"
|
||||
r"|nope\b"
|
||||
r"|nvm\b"
|
||||
r"|nevermind\b|never\s*mind\b"
|
||||
r"|wrong\b"
|
||||
r"|wait(?=[,.!?]|$)"
|
||||
r"|stop(?=[,.!?]|$)"
|
||||
r"|actually\b"
|
||||
r"|scratch\s+that\b"
|
||||
r"|instead(?=[,.!?]|$)"
|
||||
r"|(?:let'?s\s+)?do\s+[^.\n]{1,60}\s+instead\b"
|
||||
r"|try\s+[^.\n]{1,60}\s+instead\b"
|
||||
r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONFIRMATION_TOKENS: frozenset[str] = frozenset({"", "y", "yes", "n", "no"})
|
||||
_CANCEL_REQUEST_TOKENS: frozenset[str] = frozenset({"/cancel", "/stop", "/abort"})
|
||||
|
||||
|
||||
def looks_like_confirmation_answer(text: str | None) -> bool:
|
||||
return (text or "").strip().lower() in _CONFIRMATION_TOKENS
|
||||
|
||||
|
||||
def looks_like_cancel_request(text: str | None) -> bool:
|
||||
return (text or "").strip().lower() in _CANCEL_REQUEST_TOKENS
|
||||
|
||||
|
||||
def looks_like_correction(text: str) -> bool:
|
||||
stripped = text.lstrip()
|
||||
if not stripped or stripped.startswith("```"):
|
||||
return False
|
||||
return _INTERVENTION_CORRECTION_RE.match(stripped[:80]) is not None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"looks_like_cancel_request",
|
||||
"looks_like_confirmation_answer",
|
||||
"looks_like_correction",
|
||||
]
|
||||
Reference in New Issue
Block a user