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

331 lines
13 KiB
Python

"""Prompt/response recorder for interactive-shell turns."""
from __future__ import annotations
import contextlib
import time
import uuid
from datetime import UTC, datetime
from typing import Any
from config.version import get_opensre_version
from core.agent_harness.accounting.token_accounting import LlmRunInfo
from core.llm_invoke_errors import LLM_PROVIDER_FAILURE_KINDS, classify_provider_error_kind
from platform.analytics.provider import JsonValue
from surfaces.interactive_shell.prompt_history.policy import redact_text
from surfaces.interactive_shell.utils.telemetry.config import PromptLogConfig
from surfaces.interactive_shell.utils.telemetry.integration_snapshot import (
build_turn_integration_snapshot,
)
from surfaces.interactive_shell.utils.telemetry.sinks.local_jsonl import (
append_prompt_log_record,
)
from surfaces.interactive_shell.utils.telemetry.sinks.posthog_ai import capture_ai_generation
_SUPPORTED_TURN_KINDS = frozenset({"agent", "follow_up", "new_alert", "background_task"})
# Sentinel for turns handled by terminal tools/slash commands without the
# conversational assistant LLM (PostHog ``$ai_model`` / ``$ai_provider``).
NO_CONVERSATIONAL_AGENT = "no_conversational_agent"
# Sentinel for turns where the conversational LLM was attempted but failed
# before the model/provider identity could be resolved (missing key, bad
# config). Distinct from ``NO_CONVERSATIONAL_AGENT`` so provider failures are
# never mislabeled as terminal-action turns in analytics.
UNKNOWN_LLM = "unknown"
# Maps PromptRecorder turn_kind to session turn kind stored in turn_detail records.
_TURN_TO_SESSION_KIND: dict[str, str] = {
"agent": "chat",
"follow_up": "follow_up",
"new_alert": "alert",
"background_task": "cli_command",
}
def _latest_slash_outcome(session: Any) -> str | None:
history = getattr(session, "history", None)
if not isinstance(history, list):
return None
for entry in reversed(history):
if not isinstance(entry, dict) or entry.get("type") != "slash":
continue
outcome = entry.get("slash_outcome")
if isinstance(outcome, str) and outcome:
return outcome
return None
return None
def _fallback_terminal_response(*, prompt: str) -> str:
stripped = prompt.strip()
if stripped:
return f"terminal turn handled: {stripped}"
return "terminal turn handled"
def _prompt_relates_to_investigation(*, prompt: str, turn_kind: str) -> bool:
stripped = prompt.strip().lower()
if turn_kind == "background_task":
return "investigate" in stripped
return stripped.startswith("/investigate")
class PromptRecorder:
"""Captures one `(prompt, response)` pair and flushes to configured sinks."""
def __init__(
self,
*,
config: PromptLogConfig,
turn_kind: str,
session_id: str,
turn_id: str,
prompt: str,
session: Any | None = None,
) -> None:
self._config = config
self._turn_kind = turn_kind
self._session_id = session_id
self._turn_id = turn_id
self._prompt = prompt
self._session = session
self._response: str = ""
self._scoped_investigation_id: str | None = None
self._error_kind: str = ""
self._error_message: str = ""
self._model: str | None = None
self._provider: str | None = None
self._latency_ms: int | None = None
self._input_tokens: int | None = None
self._output_tokens: int | None = None
self._start = time.monotonic()
self._flushed = False
@property
def turn_id(self) -> str:
"""Stable correlation id for this prompt turn."""
return self._turn_id
@classmethod
def start(
cls,
*,
session: Any,
text: str,
turn_kind: str,
) -> PromptRecorder | None:
config = PromptLogConfig.load()
if not config.enabled or turn_kind not in _SUPPORTED_TURN_KINDS:
# When prompt logging is fully disabled, no recorder is created and
# no turn_detail records are written to the session file. This means
# the crash-recovery fallback in load_session() will produce empty
# cli_agent_messages for sessions that crashed before flush(). The
# conversation_snapshot written at clean exit is unaffected.
return None
return cls(
config=config,
turn_kind=turn_kind,
session_id=_session_id(session),
turn_id=str(uuid.uuid4()),
prompt=_sanitize_text(text, config=config),
session=session,
)
@classmethod
def for_background_task(
cls,
*,
session: Any,
command: str,
task_id: str,
) -> PromptRecorder | None:
"""Create a recorder for an async background task.
Background CLI tasks (e.g. ``opensre investigate``) finish long after
the originating turn has flushed, so their stdout/stderr/exit outcome is
not available to the turn-level recorder. This recorder is created at
task launch — so its latency clock spans the full task duration — and is
flushed by the task watcher once the outcome (including any error text)
is known. ``turn_id`` is set to ``task_id`` so the prompt-log event
correlates with the task surfaced by ``/tasks``.
"""
config = PromptLogConfig.load()
if not config.enabled:
return None
recorder = cls(
config=config,
turn_kind="background_task",
session_id=_session_id(session),
turn_id=task_id or str(uuid.uuid4()),
prompt=_sanitize_text(command, config=config),
session=session,
)
if _prompt_relates_to_investigation(prompt=command, turn_kind="background_task"):
investigation_id = str(uuid.uuid4())
recorder.bind_investigation_id(investigation_id)
session.last_investigation_id = investigation_id
return recorder
def bind_investigation_id(self, investigation_id: str) -> None:
cleaned = investigation_id.strip()
if cleaned:
self._scoped_investigation_id = cleaned
def set_error(self, kind: str, message: str) -> None:
"""Attach a structured turn error emitted as ``$ai_error`` properties.
The human-readable response text is unaffected; these properties make
LLM/provider error detection exact instead of a regex over
``$ai_output_choices``.
"""
kind = kind.strip()
message = message.strip()
if not (kind or message):
return
self._error_kind = kind or "error"
self._error_message = _sanitize_text(message, config=self._config)
def _resolve_investigation_id(self) -> str:
if self._scoped_investigation_id:
return self._scoped_investigation_id
if self._session is None or not _prompt_relates_to_investigation(
prompt=self._prompt,
turn_kind=self._turn_kind,
):
return ""
investigation_id = getattr(self._session, "last_investigation_id", "")
if isinstance(investigation_id, str):
return investigation_id
return ""
def set_response(self, text: str, run: LlmRunInfo | None = None) -> None:
cleaned = _sanitize_text(text, config=self._config)
if not cleaned.strip():
cleaned = ""
self._response = cleaned
if run is None:
self._latency_ms = int((time.monotonic() - self._start) * 1000)
return
self._model = run.model
self._provider = run.provider
self._latency_ms = run.latency_ms or int((time.monotonic() - self._start) * 1000)
self._input_tokens = run.input_tokens
self._output_tokens = run.output_tokens
def _response_for_emit(self) -> str:
"""Resolve the assistant text written to sinks at flush time."""
if self._response.strip():
return self._response
if self._error_message.strip():
return self._error_message
return _fallback_terminal_response(prompt=self._prompt)
def flush(self) -> None:
if self._flushed:
return
self._flushed = True
response_text = self._response_for_emit()
latency_ms = self._latency_ms or int((time.monotonic() - self._start) * 1000)
record = {
"ts": datetime.now(UTC).isoformat(),
"session_id": self._session_id,
"turn_id": self._turn_id,
"turn_kind": self._turn_kind,
"prompt": self._prompt,
"response": response_text,
"model": self._model or "",
"provider": self._provider or "",
"latency_ms": latency_ms,
"input_tokens": self._input_tokens,
"output_tokens": self._output_tokens,
"opensre_version": get_opensre_version(),
}
if self._config.local_enabled:
with contextlib.suppress(OSError):
append_prompt_log_record(path=self._config.log_path, record=record)
# Also write enriched turn to the session file so /resume can restore context.
with contextlib.suppress(Exception):
from core.agent_harness.session import default_session_storage
session_kind = _TURN_TO_SESSION_KIND.get(self._turn_kind, self._turn_kind)
default_session_storage().append_turn_detail(
self._session_id,
session_kind,
self._prompt,
response=response_text or None,
turn_id=self._turn_id,
model=self._model or None,
provider=self._provider or None,
latency_ms=latency_ms,
)
if self._config.posthog_enabled:
with contextlib.suppress(Exception):
# When the conversational LLM was attempted but the provider
# failed, the turn is a failed LLM call — never a terminal
# action. Fall back to "unknown" instead of the terminal
# sentinel when the attempted model could not be resolved.
llm_provider_failed = self._error_kind in LLM_PROVIDER_FAILURE_KINDS
fallback_label = UNKNOWN_LLM if llm_provider_failed else NO_CONVERSATIONAL_AGENT
integration_snapshot = build_turn_integration_snapshot(self._session)
posthog_properties: dict[str, JsonValue] = {
"$ai_trace_id": self._turn_id,
"$ai_session_id": self._session_id,
"$ai_span_id": self._turn_id,
"$ai_span_name": f"surfaces.interactive_shell.{self._turn_kind}",
"$ai_model": self._model or fallback_label,
"$ai_provider": self._provider or fallback_label,
"$ai_input": [{"role": "user", "content": self._prompt}],
"$ai_output_choices": [
{
"role": "assistant",
"content": response_text,
}
],
"$ai_latency": (
round((self._latency_ms or 0) / 1000.0, 3) if self._latency_ms else 0.0
),
"$ai_input_tokens": self._input_tokens or 0,
"$ai_output_tokens": self._output_tokens or 0,
"cli_turn_kind": self._turn_kind,
"cli_session_id": self._session_id,
"cli_turn_id": self._turn_id,
"opensre_version": get_opensre_version(),
**integration_snapshot,
}
slash_outcome = _latest_slash_outcome(self._session)
if slash_outcome:
posthog_properties["slash_outcome"] = slash_outcome
investigation_id = self._resolve_investigation_id()
if investigation_id:
posthog_properties["investigation_id"] = investigation_id
if self._error_kind:
posthog_properties["$ai_is_error"] = True
posthog_properties["$ai_error"] = self._error_message or self._error_kind
posthog_properties["error_kind"] = self._error_kind
if llm_provider_failed:
posthog_properties["ai_error_kind"] = classify_provider_error_kind(
self._error_message or self._error_kind
)
capture_ai_generation(posthog_properties)
def _sanitize_text(text: str, *, config: PromptLogConfig) -> str:
if config.redact:
text = redact_text(text)
return text[: config.max_chars]
def _session_id(session: Any) -> str:
# Prefer the stable first-class field set at Session construction.
# Fall back to the legacy side-channel for non-Session callers.
sid = getattr(session, "session_id", None) or getattr(session, "_prompt_log_session_id", None)
if isinstance(sid, str) and sid:
return sid
sid = str(uuid.uuid4())
with contextlib.suppress(AttributeError):
session._prompt_log_session_id = sid
return sid