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,34 @@
|
||||
"""Interactive-shell session: the ``Session`` subclass and its UI facets.
|
||||
|
||||
The shell-only half of the session, layered on
|
||||
:class:`~core.agent_harness.session.session_core.SessionCore`: the ``terminal``
|
||||
facet (theme, prompt-toolkit, background jobs, metrics) and the ``alerts`` inbox.
|
||||
Core, gateway, and headless surfaces use ``SessionCore`` directly and never import
|
||||
this package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from surfaces.interactive_shell.session.alert_inbox import SessionAlertInbox
|
||||
from surfaces.interactive_shell.session.background_investigations import (
|
||||
BackgroundInvestigationRecord,
|
||||
BackgroundNotificationPreferences,
|
||||
)
|
||||
from surfaces.interactive_shell.session.session import Session
|
||||
from surfaces.interactive_shell.session.terminal_metrics import (
|
||||
InterventionKind,
|
||||
TerminalMetrics,
|
||||
TerminalMetricsSnapshot,
|
||||
)
|
||||
from surfaces.interactive_shell.session.terminal_session import TerminalSession
|
||||
|
||||
__all__ = [
|
||||
"BackgroundInvestigationRecord",
|
||||
"BackgroundNotificationPreferences",
|
||||
"InterventionKind",
|
||||
"Session",
|
||||
"SessionAlertInbox",
|
||||
"TerminalMetrics",
|
||||
"TerminalMetricsSnapshot",
|
||||
"TerminalSession",
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""The session's inbox of externally-received alerts.
|
||||
|
||||
A surface facet composed onto :class:`~surfaces.interactive_shell.session.session.Session`:
|
||||
the interactive shell's alert listener appends externally-received alerts here, and
|
||||
``/status`` reads them. Kept out of the core session so consumers that never touch
|
||||
alerts don't see the field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from core.domain.alerts.inbox import IncomingAlert
|
||||
|
||||
# Bounded buffer so the alert listener can't grow memory unbounded — keeps the last
|
||||
# few hundred alerts for /status. A round default (not tuned); preserved from the
|
||||
# original ``_INCOMING_ALERTS_MAX``.
|
||||
_DEFAULT_MAX = 256
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionAlertInbox:
|
||||
"""Bounded FIFO of received alerts shown in ``/status`` (oldest dropped past the cap)."""
|
||||
|
||||
entries: list[IncomingAlert] = field(default_factory=list)
|
||||
_max: int = _DEFAULT_MAX
|
||||
|
||||
def add(self, alert: IncomingAlert) -> None:
|
||||
"""Append an alert, dropping the oldest once the cap is exceeded."""
|
||||
self.entries.append(alert)
|
||||
if len(self.entries) > self._max:
|
||||
del self.entries[0]
|
||||
|
||||
@property
|
||||
def most_recent(self) -> IncomingAlert | None:
|
||||
"""The newest alert, or None when the inbox is empty."""
|
||||
return self.entries[-1] if self.entries else None
|
||||
|
||||
def clear(self) -> None:
|
||||
self.entries.clear()
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackgroundInvestigationRecord:
|
||||
"""One completed or in-flight background investigation tracked by the REPL."""
|
||||
|
||||
task_id: str
|
||||
status: str
|
||||
command: str
|
||||
investigation_id: str = ""
|
||||
root_cause: str = ""
|
||||
top_analysis: tuple[str, ...] = ()
|
||||
next_steps: tuple[str, ...] = ()
|
||||
stats: dict[str, Any] = field(default_factory=dict)
|
||||
final_state: dict[str, Any] = field(default_factory=dict)
|
||||
notification_results: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackgroundNotificationPreferences:
|
||||
"""Session-scoped channel preferences for background RCA completion notifications."""
|
||||
|
||||
channels: tuple[str, ...] = ()
|
||||
|
||||
def set_channels(self, values: list[str]) -> None:
|
||||
cleaned: list[str] = []
|
||||
for value in values:
|
||||
normalized = value.strip().lower()
|
||||
if normalized and normalized not in cleaned:
|
||||
cleaned.append(normalized)
|
||||
self.channels = tuple(cleaned)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Interactive-shell session: SessionCore plus terminal UI state.
|
||||
|
||||
Extends :class:`~core.agent_harness.session.session_core.SessionCore` with the
|
||||
shell-only facets (``terminal`` UI/background state and the ``alerts`` inbox) and
|
||||
the methods that drive them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from config.constants.prompts import SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST
|
||||
from core.agent_harness.session.session_core import SessionCore
|
||||
from core.domain.alerts.inbox import IncomingAlert
|
||||
from surfaces.interactive_shell.session.alert_inbox import SessionAlertInbox
|
||||
from surfaces.interactive_shell.session.terminal_session import TerminalSession
|
||||
|
||||
_SCENARIO_FLAG_RE = re.compile(r"--scenario\s+(\S+)")
|
||||
_SYNTHETIC_SCENARIO_ID_RE = re.compile(r"^\d{3}-[a-z0-9][a-z0-9-]*$")
|
||||
|
||||
|
||||
def _scenario_id_from_synthetic_label(label: str) -> str:
|
||||
"""Extract a scenario id from a synthetic command or ``suite:scenario`` label."""
|
||||
match = _SCENARIO_FLAG_RE.search(label)
|
||||
if match is not None:
|
||||
candidate = match.group(1).strip()
|
||||
return candidate if _SYNTHETIC_SCENARIO_ID_RE.fullmatch(candidate) else ""
|
||||
if ":" in label:
|
||||
candidate = label.rsplit(":", 1)[-1].strip()
|
||||
return candidate if _SYNTHETIC_SCENARIO_ID_RE.fullmatch(candidate) else ""
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session(SessionCore):
|
||||
"""Per-REPL-process session: :class:`SessionCore` plus interactive-shell state.
|
||||
|
||||
Adds the shell-only ``terminal`` facet (UI/theme/prompt-toolkit/background)
|
||||
and the ``alerts`` inbox on top of the surface-agnostic core.
|
||||
"""
|
||||
|
||||
terminal: TerminalSession = field(default_factory=TerminalSession)
|
||||
"""Interactive-shell (terminal) session facet — shell-only UI/theme/background state.
|
||||
|
||||
Always present (empty for non-shell sessions) so shell code needs no None-guard;
|
||||
``core``/``gateway``/``tools`` consumers ignore it. Holds the theme, prompt-toolkit,
|
||||
pending-prompt/stdin, background-jobs, and metrics clusters (#3690)."""
|
||||
|
||||
alerts: SessionAlertInbox = field(default_factory=SessionAlertInbox)
|
||||
"""Inbox of externally-received alerts (shell alert listener → ``/status``).
|
||||
|
||||
A surface facet: the bounded alert list + cap live on ``SessionAlertInbox`` so
|
||||
core-session consumers that never touch alerts don't see the field."""
|
||||
|
||||
def suggest_synthetic_failure_follow_up(self, *, label: str = "") -> None:
|
||||
"""Queue RCA prefill after a failed synthetic run and refresh the active prompt."""
|
||||
self.terminal.pending_prompt_default = SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST
|
||||
self.terminal.notify_prompt_changed()
|
||||
self._bind_last_synthetic_observation(_scenario_id_from_synthetic_label(label))
|
||||
self.terminal.notify_prompt_changed()
|
||||
|
||||
def _bind_last_synthetic_observation(self, scenario_id: str) -> None:
|
||||
"""Point ``last_synthetic_observation_path`` (a core field) at the run's latest.json.
|
||||
|
||||
Synthetic-run UX, so it lives on the shell session rather than the core.
|
||||
"""
|
||||
if not scenario_id:
|
||||
self.last_synthetic_observation_path = None
|
||||
return
|
||||
# Shared path constant lives in config so core and surfaces stay decoupled.
|
||||
try:
|
||||
from config.constants.paths import SYNTHETIC_SCENARIOS_DIR
|
||||
except Exception:
|
||||
self.last_synthetic_observation_path = None
|
||||
return
|
||||
latest = SYNTHETIC_SCENARIOS_DIR / "_observations" / scenario_id / "latest.json"
|
||||
for _ in range(8):
|
||||
if latest.is_file():
|
||||
self.last_synthetic_observation_path = str(latest.resolve())
|
||||
return
|
||||
time.sleep(0.06)
|
||||
self.last_synthetic_observation_path = None
|
||||
|
||||
def record_incoming_alert(self, alert: IncomingAlert) -> None:
|
||||
"""Append a full IncomingAlert with all metadata to session history.
|
||||
|
||||
Also stores the alert in the ``alerts`` inbox facet (bounded FIFO), preserving
|
||||
received_at, severity, source, and alert_name so /status displays accurate
|
||||
timestamps and future uses have complete data.
|
||||
"""
|
||||
self.history.append({"type": "incoming_alert", "text": alert.text, "ok": True})
|
||||
self.storage.append_turn(self, "incoming_alert", alert.text)
|
||||
self.alerts.add(alert)
|
||||
|
||||
def clear(self, *, rotate_identity: bool = True) -> None:
|
||||
"""Reset the session — core state plus the shell facets — for /new and /resume."""
|
||||
self.terminal.history_generation += 1
|
||||
super().clear(rotate_identity=rotate_identity)
|
||||
self.alerts.clear()
|
||||
self.terminal.metrics.reset()
|
||||
self.terminal.pending_prompt_default = None
|
||||
self.terminal.pending_prompt_autosubmit = False
|
||||
self.terminal.exclusive_stdin_active = False
|
||||
self.terminal.agent_turn_executed_slashes.clear()
|
||||
self.terminal.background_mode_enabled = False
|
||||
self.terminal.background_investigations.clear()
|
||||
# Preserve notification channel prefs across /new like trust_mode.
|
||||
# Only reset when the user explicitly changes them via /background notify.
|
||||
with self.terminal._background_notices_lock:
|
||||
self.terminal.background_notices.clear()
|
||||
# trust_mode and reasoning_effort are intentionally preserved across /new
|
||||
|
||||
def release_resources(self) -> None:
|
||||
"""Cancel background work and drop loop-owned UI references for teardown.
|
||||
|
||||
Extends :meth:`SessionCore.release_resources` (which cancels the
|
||||
integration-warm task) with the shell facet's own teardown.
|
||||
"""
|
||||
super().release_resources()
|
||||
with self.terminal._background_notices_lock:
|
||||
self.terminal.background_notices.clear()
|
||||
self.terminal.prompt_refresh_fn = None
|
||||
self.terminal.fleet_sampler_starter = None
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Per-session terminal analytics, extracted from the session state object.
|
||||
|
||||
Groups the interactive-shell turn/intervention counters into one cohesive
|
||||
accumulator so the session state class does not carry analytics fields and
|
||||
methods directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from config.strict_config import StrictConfigModel
|
||||
|
||||
InterventionKind = Literal["ctrl_c", "correction"]
|
||||
|
||||
|
||||
class TerminalMetricsSnapshot(StrictConfigModel):
|
||||
"""Immutable per-turn analytics snapshot returned from ``record_turn``.
|
||||
|
||||
Pure value; the mutable :class:`TerminalMetrics` accumulator produces one
|
||||
of these after each turn for the caller to render/emit.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
turn_index: int
|
||||
fallback_count: int
|
||||
action_success_percent: float
|
||||
fallback_rate_percent: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminalMetrics:
|
||||
"""Mutable session-level counters for interactive-shell analytics."""
|
||||
|
||||
turn_count: int = 0
|
||||
fallback_count: int = 0
|
||||
actions_executed_count: int = 0
|
||||
actions_success_count: int = 0
|
||||
ctrl_c_intervention_count: int = 0
|
||||
"""Incremented when the user Ctrl-Cs an active investigation. Bare-prompt
|
||||
Ctrl-C with no agent running is intentionally not counted."""
|
||||
correction_intervention_count: int = 0
|
||||
"""Incremented when a follow-up/new-alert message starts with a correction cue."""
|
||||
|
||||
def record_turn(
|
||||
self,
|
||||
*,
|
||||
executed_count: int,
|
||||
executed_success_count: int,
|
||||
fallback_to_llm: bool,
|
||||
) -> TerminalMetricsSnapshot:
|
||||
"""Update aggregate terminal metrics and return a stable snapshot."""
|
||||
self.turn_count += 1
|
||||
self.actions_executed_count += max(0, executed_count)
|
||||
self.actions_success_count += max(0, executed_success_count)
|
||||
if fallback_to_llm:
|
||||
self.fallback_count += 1
|
||||
action_success_percent = (
|
||||
100.0 * self.actions_success_count / self.actions_executed_count
|
||||
if self.actions_executed_count > 0
|
||||
else 0.0
|
||||
)
|
||||
fallback_rate_percent = 100.0 * self.fallback_count / self.turn_count
|
||||
return TerminalMetricsSnapshot(
|
||||
turn_index=self.turn_count,
|
||||
fallback_count=self.fallback_count,
|
||||
action_success_percent=action_success_percent,
|
||||
fallback_rate_percent=fallback_rate_percent,
|
||||
)
|
||||
|
||||
def record_intervention(self, kind: InterventionKind) -> None:
|
||||
"""Increment the per-kind intervention counter (Ctrl-C or correction)."""
|
||||
if kind == "ctrl_c":
|
||||
self.ctrl_c_intervention_count += 1
|
||||
elif kind == "correction":
|
||||
self.correction_intervention_count += 1
|
||||
else:
|
||||
raise ValueError(f"Unknown intervention kind: {kind!r}")
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Zero all counters (used by ``/new``)."""
|
||||
self.turn_count = 0
|
||||
self.fallback_count = 0
|
||||
self.actions_executed_count = 0
|
||||
self.actions_success_count = 0
|
||||
self.ctrl_c_intervention_count = 0
|
||||
self.correction_intervention_count = 0
|
||||
|
||||
|
||||
__all__ = ["InterventionKind", "TerminalMetrics", "TerminalMetricsSnapshot"]
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Interactive-shell (terminal) session facet.
|
||||
|
||||
Groups the shell-surface-only session state (prompt-toolkit, theme, background jobs,
|
||||
metrics, per-turn analytics staging) that ``core``, ``gateway``, and ``tools``
|
||||
consumers never touch. Composed onto :class:`~surfaces.interactive_shell.session.session.Session`
|
||||
as ``session.terminal`` and always present (empty for non-shell sessions), so shell
|
||||
code accesses fields without a None-guard.
|
||||
|
||||
Populated cluster-by-cluster as the #3690 split lands; theme is the first cluster.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from surfaces.interactive_shell.session.background_investigations import (
|
||||
BackgroundInvestigationRecord,
|
||||
BackgroundNotificationPreferences,
|
||||
)
|
||||
from surfaces.interactive_shell.session.terminal_metrics import TerminalMetrics
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prompt_toolkit.history import History
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminalSession:
|
||||
"""Shell-surface session state, composed onto ``Session`` for the interactive shell."""
|
||||
|
||||
active_theme_name: str = "green"
|
||||
"""Interactive shell palette name for this REPL session (``/theme``, prompts)."""
|
||||
|
||||
pending_theme_refresh: bool = False
|
||||
"""When True, apply the active palette to prompt-toolkit before the next prompt."""
|
||||
|
||||
trust_mode: bool = False
|
||||
"""When True, confirmation prompts for elevated REPL actions are skipped."""
|
||||
|
||||
prompt_history_backend: History | None = None
|
||||
"""The live ``prompt_toolkit.History`` object backing the input prompt.
|
||||
|
||||
Stored here so ``/history`` and ``/privacy`` slash commands can mutate its
|
||||
``paused`` flag (when it is a ``RedactingFileHistory``) without needing access to
|
||||
the ``PromptSession``."""
|
||||
|
||||
prompt_app: Any = None
|
||||
"""The prompt-toolkit ``Application`` instance for this session.
|
||||
|
||||
Stored here (instead of accessed via ``get_app_or_none()``) so that worker-thread
|
||||
slash commands (e.g. ``/theme``) can refresh styles via ``call_soon_threadsafe`` on
|
||||
the main asyncio loop."""
|
||||
|
||||
main_loop: Any = None
|
||||
"""The asyncio event loop for the main REPL coroutine.
|
||||
|
||||
Set once by ``InteractiveShellController.start_interactive_shell`` so worker-thread
|
||||
code can schedule prompt-toolkit updates on the main thread."""
|
||||
|
||||
prompt_refresh_fn: Callable[[], None] | None = field(default=None, repr=False)
|
||||
"""Loop-owned hook to apply pending prefill and redraw the active prompt."""
|
||||
|
||||
fleet_sampler_starter: Callable[[], None] | None = field(default=None, repr=False)
|
||||
"""Loop-owned hook to lazily start the fleet sampler on first live ``/fleet`` use.
|
||||
|
||||
Set by the interactive-shell controller so the sampler (and its ``psutil`` dependency)
|
||||
stays out of base REPL startup and only runs when fleet monitoring is actually
|
||||
requested. Thread-safe: the starter marshals task creation onto the REPL event loop."""
|
||||
|
||||
pending_prompt_default: str | None = None
|
||||
"""When set, the next interactive prompt is pre-filled with this string (then cleared)."""
|
||||
|
||||
pending_prompt_autosubmit: bool = False
|
||||
"""When True alongside ``pending_prompt_default``, the prefilled prompt is
|
||||
submitted automatically instead of waiting for the user to press Enter.
|
||||
|
||||
Used to auto-launch an interactive command the agent decided to run (e.g.
|
||||
``/integrations setup sentry``) so it flows through the normal
|
||||
exclusive-stdin dispatch path — the only place an interactive child process
|
||||
gets clean stdin."""
|
||||
|
||||
exclusive_stdin_active: bool = False
|
||||
"""True while a turn is running with exclusive stdin reserved (no live prompt).
|
||||
|
||||
Inline picker/wizard slash commands must dispatch immediately during these
|
||||
turns instead of re-queueing via ``set_auto_command``, which would loop."""
|
||||
|
||||
agent_turn_executed_slashes: set[str] = field(default_factory=set, repr=False)
|
||||
"""Slash command lines already executed during the current action-agent turn.
|
||||
|
||||
Prevents the tool-calling loop from re-dispatching the same literal slash
|
||||
command when the model emits a duplicate ``slash_invoke`` on a later iteration."""
|
||||
|
||||
background_mode_enabled: bool = False
|
||||
"""Whether new investigations should run as session-local background tasks."""
|
||||
|
||||
background_investigations: dict[str, BackgroundInvestigationRecord] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
"""Completed or in-flight background RCA summaries, keyed by task id."""
|
||||
|
||||
background_notification_preferences: BackgroundNotificationPreferences = field(
|
||||
default_factory=BackgroundNotificationPreferences
|
||||
)
|
||||
"""Preferred notification channels for background RCA completion events."""
|
||||
|
||||
background_notices: list[str] = field(default_factory=list)
|
||||
"""Thread-safe queue of Rich markup messages drained by the REPL main loop."""
|
||||
|
||||
_background_notices_lock: threading.Lock = field(
|
||||
default_factory=threading.Lock, repr=False, compare=False
|
||||
)
|
||||
|
||||
history_generation: int = 0
|
||||
"""Incremented on /new so background synthetic watchers can skip stale history writes."""
|
||||
|
||||
metrics: TerminalMetrics = field(default_factory=TerminalMetrics)
|
||||
"""Interactive-shell turn/intervention analytics counters (see ``/status``)."""
|
||||
|
||||
_turn_outcome_hint: str | None = field(default=None, repr=False, compare=False)
|
||||
"""Optional structured outcome set by a terminal handler for analytics."""
|
||||
|
||||
_pending_turn_llm: Any | None = field(default=None, repr=False, compare=False)
|
||||
"""LLM run metadata (an ``LlmRunInfo``) staged by a terminal handler for the
|
||||
current turn's prompt-recorder flush. Consumed exactly once via
|
||||
``pop_pending_turn_llm`` so it cannot leak into later turns."""
|
||||
|
||||
_pending_turn_error: tuple[str, str] | None = field(default=None, repr=False, compare=False)
|
||||
"""Structured ``(error_kind, message)`` staged by a failing handler for the
|
||||
current turn's prompt-recorder flush. Consumed exactly once via
|
||||
``pop_pending_turn_error`` so it cannot leak into later turns."""
|
||||
|
||||
# ── behavior over the fields above (Session delegates via ``session.terminal``) ──
|
||||
|
||||
def pop_pending_prompt_default(self) -> str:
|
||||
"""Return pre-filled text for the next prompt line, if any, and clear it."""
|
||||
value = self.pending_prompt_default
|
||||
self.pending_prompt_default = None
|
||||
return value or ""
|
||||
|
||||
def pop_pending_autosubmit(self) -> bool:
|
||||
"""Return whether the pending prefill should auto-submit, and clear the flag."""
|
||||
value = self.pending_prompt_autosubmit
|
||||
self.pending_prompt_autosubmit = False
|
||||
return value
|
||||
|
||||
def set_auto_command(self, command: str) -> None:
|
||||
"""Queue a command to run automatically on the next prompt iteration.
|
||||
|
||||
Prefills the input with ``command`` and marks it for auto-submit, then
|
||||
refreshes the active prompt so the loop submits it without waiting for
|
||||
Enter. Lets the agent launch an interactive command (setup/connect)
|
||||
through the normal exclusive-stdin dispatch path rather than spawning it
|
||||
mid-turn, where it would fight the live prompt for stdin.
|
||||
"""
|
||||
self.pending_prompt_default = command
|
||||
self.pending_prompt_autosubmit = True
|
||||
self.notify_prompt_changed()
|
||||
|
||||
def notify_prompt_changed(self) -> None:
|
||||
"""Redraw the active prompt (placeholder state and pending prefill)."""
|
||||
if self.prompt_refresh_fn is not None:
|
||||
self.prompt_refresh_fn()
|
||||
|
||||
def ensure_fleet_sampler_started(self) -> None:
|
||||
"""Request that the fleet sampler start (no-op if unwired or already running)."""
|
||||
if self.fleet_sampler_starter is not None:
|
||||
self.fleet_sampler_starter()
|
||||
|
||||
def enqueue_background_notice(self, message: str) -> None:
|
||||
"""Queue a background-thread status line for the main REPL loop to print."""
|
||||
with self._background_notices_lock:
|
||||
self.background_notices.append(message)
|
||||
self.notify_prompt_changed()
|
||||
|
||||
def drain_background_notices(self) -> list[str]:
|
||||
"""Return and clear any queued background status lines."""
|
||||
with self._background_notices_lock:
|
||||
notices = list(self.background_notices)
|
||||
self.background_notices.clear()
|
||||
return notices
|
||||
|
||||
def set_turn_outcome_hint(self, hint: str | None) -> None:
|
||||
"""Attach a structured outcome for the current terminal handler."""
|
||||
self._turn_outcome_hint = hint.strip() if isinstance(hint, str) and hint.strip() else None
|
||||
|
||||
def pop_turn_outcome_hint(self) -> str | None:
|
||||
"""Return and clear any structured outcome hint for this turn."""
|
||||
hint = self._turn_outcome_hint
|
||||
self._turn_outcome_hint = None
|
||||
return hint
|
||||
|
||||
def set_pending_turn_llm(self, run: Any | None) -> None:
|
||||
"""Stage LLM run metadata for this turn's prompt-recorder flush."""
|
||||
self._pending_turn_llm = run
|
||||
|
||||
def pop_pending_turn_llm(self) -> Any | None:
|
||||
"""Return and clear staged LLM run metadata for this turn."""
|
||||
run = self._pending_turn_llm
|
||||
self._pending_turn_llm = None
|
||||
return run
|
||||
|
||||
def set_pending_turn_error(self, kind: str, message: str) -> None:
|
||||
"""Stage a structured turn error for this turn's prompt-recorder flush."""
|
||||
kind = kind.strip()
|
||||
message = message.strip()
|
||||
if kind or message:
|
||||
self._pending_turn_error = (kind or "error", message)
|
||||
|
||||
def pop_pending_turn_error(self) -> tuple[str, str] | None:
|
||||
"""Return and clear the staged structured turn error."""
|
||||
error = self._pending_turn_error
|
||||
self._pending_turn_error = None
|
||||
return error
|
||||
@@ -0,0 +1,49 @@
|
||||
"""JSONL-backed :class:`~platform.observability.trace.spans.SessionTraceSink` for the REPL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from core.agent_harness.session.persistence.jsonl_storage import JsonlSessionStorage
|
||||
from platform.observability.trace.spans import NoopSessionTraceSink, SessionTraceSink
|
||||
|
||||
|
||||
@dataclass
|
||||
class JsonlSessionTraceSink:
|
||||
"""Write ``trace_span`` records through the session's JSONL storage backend."""
|
||||
|
||||
storage: JsonlSessionStorage
|
||||
|
||||
def emit(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
span_kind: str,
|
||||
name: str,
|
||||
status: str = "ok",
|
||||
duration_ms: int | None = None,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
parent_id: str | None = None,
|
||||
) -> str:
|
||||
return self.storage.append_trace_span(
|
||||
session_id,
|
||||
span_kind=span_kind,
|
||||
name=name,
|
||||
status=status,
|
||||
duration_ms=duration_ms,
|
||||
attributes=attributes,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
|
||||
def jsonl_trace_sink_for_session(session: Any) -> SessionTraceSink:
|
||||
"""Return a JSONL sink wired to ``session.storage``, or a Noop sink for
|
||||
non-JSONL (e.g. in-memory) sessions so tests don't leak trace files to disk."""
|
||||
storage = getattr(session, "storage", None)
|
||||
if not isinstance(storage, JsonlSessionStorage):
|
||||
return NoopSessionTraceSink()
|
||||
return JsonlSessionTraceSink(storage=storage)
|
||||
|
||||
|
||||
__all__ = ["JsonlSessionTraceSink", "jsonl_trace_sink_for_session"]
|
||||
Reference in New Issue
Block a user