oc_chat
Loading...
Searching...
No Matches
session_alerts.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/session_alerts.py
4
5"""
6The ``session-alerts`` lane writer — money events the operator reads on a phone.
7
8Purpose
9-------
10CAPS-01 (plan 237 P5/P6). When a session crosses a cost checkpoint, hits the
11dollar hard cap, or pauses at the per-turn tool-call checkpoint, the operator
12should learn about it wherever they are — without the product ever holding a
13Telegram credential. This module is the product-side half of that, and it is
14deliberately the SAME shape plan 231 gave the worker-report lane:
15
16- it writes ONE json file per event into ``<comms-root>/lanes/session-alerts/``
17 and stops there;
18- it imports no connector and no network package (asserted structurally by the
19 W7 witness, against a negative control, exactly as the 231 tests do);
20- ``python -m oc_connect relay session-alerts`` — a separate process, run by
21 the operator, holding the grant — is what puts the text on the transport.
22
23The one addition over 231 is :func:`notify_operator`: a best-effort
24``python -m oc_connect send`` SUBPROCESS, run only when ``oc_connect`` is
25importable in this environment, under the scrubbed environment every other
26child process gets. It is a convenience, never a guarantee: any failure is
27logged and swallowed, and the lane file on disk remains the durable record the
28relay will pick up. The product still holds no token — the send succeeds only
29where the operator's own sidecar grant already exists.
30
31Responsibilities
32----------------
33- :func:`new_ulid` — the 26-character, time-first event id (the lane's ordering
34 is a plain directory sort).
35- :func:`find_comms_root` — the workspace-anchored bus root, by the same walk
36 ``scripts/plan_status.py`` uses.
37- :func:`session_alert_event` — build one validated event dict.
38- :func:`write_session_alert` — write it, atomically; return ``None`` rather
39 than raising when the bus is unreachable.
40- :func:`notify_operator` — the best-effort direct send described above.
41- :func:`emit_session_alert` — write + notify in one fail-soft call, which is
42 what the react-loop seam uses.
43
44Contracts
45---------
46- **Fail-SOFT everywhere.** An unreachable, unwritable or nonexistent comms root
47 logs at L2 and returns ``None``. A money alert that cannot be delivered must
48 never be the thing that kills the session it is reporting on.
49- The ``summary`` carries NUMBERS ONLY — totals, the threshold, counts. No
50 model content, no file paths, no prompt text ever reaches the transport from
51 here (§8: displayed content is never instructions, and an alert is not a
52 transcript).
53- The event's ``lane``/``direction``/``risk_class`` mirror the
54 ``session-alerts`` descriptor declared in ``option_c_connector_contracts``.
55 The mirror is intentional and is what buys the credential-free write path;
56 the drift risk is paid for by the W7 cross-implementation assertions.
57
58Dependencies
59------------
60- oc_diagnostics (structured debug logging via _dbg)
61- oc_chat.core.defaults (the shipped notification timeout)
62- oc_chat.tools._sandbox (lazy; scrubbed_env for the notification subprocess)
63- oc_connect (not imported; run as python -m oc_connect send for the direct notification)
64
65Diagnostics
66-----------
67Domain: OC_CHAT.CORE
68Levels:
69 L1 — an alert could not be written (override: the operator must see it)
70 L2 — an alert was written; a direct notification was attempted
71 L3 — per-field detail
72 L4 — module load
73"""
74from __future__ import annotations
75
76import json
77import os
78import secrets
79import string
80import subprocess
81import tempfile
82from datetime import datetime, timezone
83from pathlib import Path
84
85from oc_diagnostics import _dbg
86
87from oc_chat.core.defaults import default
88
89_dbg("OC_CHAT.CORE", "module", "loaded", 4)
90
91
92#: Lane constants. These mirror ``option_c_connector_contracts.LANES`` on
93#: purpose — see the module Contracts for why the mirror, rather than an
94#: import, is the point.
95SESSION_ALERT_LANE = "session-alerts"
96SESSION_ALERT_SOURCE = "product"
97SESSION_ALERT_SCHEMA_VERSION = "1.0"
98SESSION_ALERT_DIRECTION = "inbound"
99SESSION_ALERT_RISK_CLASS = "low"
100
101#: The four things worth waking an operator for.
102KIND_COST_THRESHOLD = "cost_threshold"
103KIND_COST_HARD_CAP = "cost_hard_cap"
104KIND_TOOL_CALL_CHECKPOINT = "tool_call_checkpoint"
105KIND_STALL_HALT = "stall_halt"
106SESSION_ALERT_KINDS = (
107 KIND_COST_THRESHOLD, KIND_COST_HARD_CAP, KIND_TOOL_CALL_CHECKPOINT,
108 KIND_STALL_HALT,
109)
110
111#: Workspace walk constants, mirroring ``scripts/plan_status.py``.
112WORKSPACE_MARKER = ".option_c_workspace.json"
113PROJECT_MARKER = ".option_c"
114COMMS_ANCHOR = "oc_chat"
115COMMS_SUBPATH = (".option_c", "comms")
116
117#: Seconds a best-effort direct notification may take before it is abandoned.
118#: The VALUE lives in the shipped, read-only defaults file (CONFIG-01, plan
119#: 238); this name is an import-time alias, as in ``core/resource_caps``.
120NOTIFY_TIMEOUT_S = default("session_alert_notify_timeout_s")
121
122#: Crockford base32, the ULID alphabet: no I, L, O or U. DERIVED from the
123#: stdlib vocabularies rather than written as a 32-character literal — the
124#: literal scores the arithmetic maximum 5.00 bits/char and needs a scanner
125#: waiver everywhere it appears (``plan_status``, ``oc_connect.comms``). The
126#: derivation is provably the same alphabet, and the W7 witness compares the
127#: two byte for byte so this can never silently drift from the lane's readers.
128_CROCKFORD = "".join(
129 ch for ch in string.digits + string.ascii_uppercase if ch not in "ILOU"
130)
131
132
133class SessionAlertError(ValueError):
134 """A session-alert event was asked for in a shape the lane does not carry."""
135
136
137def _encode(value: int, length: int) -> str:
138 digits = []
139 for _ in range(length):
140 value, remainder = divmod(value, 32)
141 digits.append(_CROCKFORD[remainder])
142 return "".join(reversed(digits))
143
144
145def new_ulid(now: datetime, *, entropy: bytes | None = None) -> str:
146 """Return a 26-character ULID: 48 bits of time, 80 bits of randomness.
147
148 The same algorithm ``plan_status.new_ulid`` and ``oc_connect.comms.new_ulid``
149 implement. Time-first encoding makes lexical order arrival order, which is
150 what lets a reader walk the lane with a plain directory sort.
151 """
152 if now.tzinfo is None:
153 now = now.replace(tzinfo=timezone.utc)
154 milliseconds = int(now.astimezone(timezone.utc).timestamp() * 1000)
155 raw = entropy if entropy is not None else secrets.token_bytes(10)
156 if len(raw) != 10:
157 raise ValueError("ULID entropy must be exactly ten bytes")
158 return _encode(milliseconds, 10) + _encode(int.from_bytes(raw, "big"), 16)
159
160
161def utc_stamp(now: datetime) -> str:
162 """Return a filename-safe UTC stamp; colons are illegal on Windows."""
163 if now.tzinfo is None:
164 now = now.replace(tzinfo=timezone.utc)
165 return now.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
166
167
168def _utc_timestamp(now: datetime) -> str:
169 if now.tzinfo is None:
170 now = now.replace(tzinfo=timezone.utc)
171 return now.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
172
173
174def find_comms_root(start: Path) -> Path | None:
175 """Return the workspace-anchored comms-bus root above *start*, or ``None``.
176
177 The same walk ``plan_status.find_comms_root`` performs — the workspace
178 marker wins over a nearer project marker, because connector state is
179 workspace-wide. It differs in ONE way, deliberately: a failed walk returns
180 ``None`` instead of raising. A worker reporting at a gate should fail loudly
181 if nobody is listening; a live chat session must not die because it is
182 running outside a workspace.
183 """
184 func = "find_comms_root"
185 try:
186 origin = Path(start).expanduser().resolve()
187 except OSError as exc: # EXTERNAL_ERROR (L2) — an unresolvable cwd
188 _dbg("OC_CHAT.CORE", func, f"EXTERNAL_ERROR: {type(exc).__name__}: {exc}", 2)
189 return None
190 project_fallback: Path | None = None
191 anchor: Path | None = None
192 for parent in (origin, *origin.parents):
193 if (parent / WORKSPACE_MARKER).is_file():
194 anchor = parent
195 break
196 if project_fallback is None and (parent / PROJECT_MARKER).is_dir():
197 project_fallback = parent
198 if anchor is None:
199 anchor = project_fallback
200 if anchor is None:
201 _dbg("OC_CHAT.CORE", func,
202 f"no workspace ({WORKSPACE_MARKER}) or project ({PROJECT_MARKER}) "
203 f"marker at or above {origin}; session alerts stay local", 2)
204 return None
205 if (anchor / COMMS_ANCHOR).is_dir():
206 return anchor.joinpath(COMMS_ANCHOR, *COMMS_SUBPATH)
207 return anchor.joinpath(*COMMS_SUBPATH)
208
209
211 *,
212 kind: str,
213 total_usd: float,
214 unpriced_samples: int,
215 threshold_usd: float | None,
216 turn_tool_calls: int,
217 stall_reason: str | None = None,
218) -> str:
219 """Return the ONE line the operator reads, verbatim, on their phone.
220
221 Numbers only — see the module Contracts. The unpriced count travels with
222 every total because a total without it is a claim the pricing table cannot
223 support: it is a lower bound, and the reader is entitled to know by how
224 many samples it may be short.
225 """
226 if kind == KIND_STALL_HALT:
227 return stall_reason or "OC Chat stopped: a tool-call stall halted the turn."
228 unpriced = f" (+{unpriced_samples} unpriced samples)" if unpriced_samples else ""
229 if kind == KIND_COST_HARD_CAP:
230 cap = f"{threshold_usd:.2f}" if threshold_usd is not None else "?"
231 return (
232 f"OC Chat stopped: session spend ${total_usd:.2f}{unpriced} reached "
233 f"the ${cap} hard cap. No further provider call will be made in this "
234 f"session."
235 )
236 if kind == KIND_COST_THRESHOLD:
237 crossed = f"{threshold_usd:.2f}" if threshold_usd is not None else "?"
238 return (
239 f"OC Chat cost checkpoint ${crossed}: session spend is "
240 f"${total_usd:.2f}{unpriced}."
241 )
242 return (
243 f"OC Chat tool-call checkpoint: {turn_tool_calls} tool calls this turn; "
244 f"session spend ${total_usd:.2f}{unpriced}."
245 )
246
247
249 *,
250 session_id: str,
251 kind: str,
252 total_usd: float,
253 unpriced_samples: int,
254 now: datetime,
255 threshold_usd: float | None = None,
256 turn_tool_calls: int = 0,
257 stall_reason: str | None = None,
258 entropy: bytes | None = None,
259) -> dict:
260 """Build one ``session-alerts`` event; refuse an undeclared *kind* by name."""
261 if kind not in SESSION_ALERT_KINDS:
262 raise SessionAlertError(
263 f"session alert kind must be one of "
264 f"{', '.join(SESSION_ALERT_KINDS)}; got {kind!r}"
265 )
266 return {
267 "schema_version": SESSION_ALERT_SCHEMA_VERSION,
268 "event_id": new_ulid(now, entropy=entropy),
269 "lane": SESSION_ALERT_LANE,
270 "direction": SESSION_ALERT_DIRECTION,
271 "risk_class": SESSION_ALERT_RISK_CLASS,
272 "created_at_utc": _utc_timestamp(now),
273 "session_id": str(session_id),
274 "kind": kind,
275 "threshold_usd": (None if threshold_usd is None else float(threshold_usd)),
276 "total_usd": float(total_usd),
277 "unpriced_samples": int(unpriced_samples),
278 "turn_tool_calls": int(turn_tool_calls),
279 "summary": format_summary(
280 kind=kind, total_usd=total_usd, unpriced_samples=unpriced_samples,
281 threshold_usd=threshold_usd, turn_tool_calls=turn_tool_calls,
282 stall_reason=stall_reason,
283 ),
284 "relayed_at_utc": None,
285 "relay_chunk_count": None,
286 }
287
288
289def write_session_alert(comms_root: Path, event: dict, *, now: datetime) -> Path:
290 """Write one event into ``lanes/session-alerts/`` and return its path.
291
292 The lane directory is created when absent: a session must be able to alert
293 from a workspace whose bus has never been initialised. Nothing outside the
294 lane is created, read, or written.
295 """
296 func = "write_session_alert"
297 lane_dir = Path(comms_root) / "lanes" / SESSION_ALERT_LANE
298 lane_dir.mkdir(parents=True, exist_ok=True)
299 name = f"{utc_stamp(now)}_{SESSION_ALERT_SOURCE}_{event['event_id']}.json"
300 path = lane_dir / name
301 if path.exists():
302 raise SessionAlertError(
303 f"refusing to overwrite an existing session alert event at {path}"
304 )
305 payload = json.dumps(event, indent=2, sort_keys=True) + "\n"
306 with tempfile.NamedTemporaryFile(
307 mode="w", encoding="utf-8", newline="\n", dir=lane_dir,
308 prefix=".session_alert_", suffix=".tmp", delete=False,
309 ) as tmp:
310 tmp.write(payload)
311 tmp_path = Path(tmp.name)
312 tmp_path.replace(path)
313 _dbg("OC_CHAT.CORE", func,
314 f"wrote {event['kind']} alert {event['event_id']} to {path.name}", 2)
315 return path
316
317
319 """True when ``oc_connect`` is importable in THIS environment.
320
321 ``find_spec`` deliberately, not an import: the product must never load the
322 connector package into its own process — that is what keeps the credential
323 out of this address space. This only answers "is there a sidecar CLI to
324 hand a summary to?".
325 """
326 func = "_oc_connect_available"
327 try:
328 from importlib.util import find_spec
329 return find_spec("oc_connect") is not None
330 except (ImportError, ValueError) as exc:
331 _dbg("OC_CHAT.CORE", func, f"VALIDATION_ERROR: {type(exc).__name__}: {exc}", 3)
332 return False
333
334
335def notify_operator(summary: str, *, destination: str = "operator") -> bool:
336 """Best-effort direct notification of one alert *summary*. Never raises.
337
338 Runs ``python -m oc_connect send <destination> "<summary>"`` as a
339 subprocess under :func:`~oc_chat.tools._sandbox.scrubbed_env`, with a short
340 timeout. Returns whether the child reported success. Every failure — no
341 ``oc_connect``, no grant, no network, a timeout, a non-zero exit — is logged
342 at L2 and swallowed: the lane file is the durable record, and the relay is
343 the delivery guarantee. *summary* is the event's own fixed one-line text, so
344 nothing but numbers can reach the transport through this path.
345 """
346 func = "notify_operator"
347 if not _oc_connect_available():
348 _dbg("OC_CHAT.CORE", func, "oc_connect not importable; skipping direct send", 3)
349 return False
350 try:
351 import sys
352 from oc_chat.tools._sandbox import scrubbed_env
353 completed = subprocess.run( # noqa: S603 — fixed argv, no shell, no model input
354 [sys.executable, "-m", "oc_connect", "send", destination, summary],
355 capture_output=True, text=True, timeout=NOTIFY_TIMEOUT_S,
356 env=scrubbed_env(), shell=False, check=False,
357 )
358 except Exception as exc: # noqa: BLE001 — EXTERNAL_ERROR (L2); best-effort by contract
359 _dbg("OC_CHAT.CORE", func,
360 f"EXTERNAL_ERROR: direct notification failed "
361 f"({type(exc).__name__}: {exc}); the lane file stands", 2)
362 return False
363 if completed.returncode != 0:
364 _dbg("OC_CHAT.CORE", func,
365 f"EXTERNAL_ERROR: oc_connect send exited {completed.returncode}; "
366 f"the lane file stands", 2)
367 return False
368 _dbg("OC_CHAT.CORE", func, "direct notification sent", 2)
369 return True
370
371
373 *,
374 session_id: str,
375 kind: str,
376 total_usd: float,
377 unpriced_samples: int,
378 threshold_usd: float | None = None,
379 turn_tool_calls: int = 0,
380 stall_reason: str | None = None,
381 comms_root: Path | None = None,
382 now: datetime | None = None,
383 notify: bool = True,
384) -> Path | None:
385 """Write one alert and (best-effort) notify the operator. Never raises.
386
387 The one call the react-loop seam makes. Returns the written path, or
388 ``None`` when the bus was unreachable — in which case the caller carries on
389 regardless, because a session must not die of a failed alert.
390 """
391 func = "emit_session_alert"
392 stamp = now or datetime.now(timezone.utc)
393 try:
394 root = comms_root if comms_root is not None else find_comms_root(Path(os.getcwd()))
395 if root is None:
396 return None
397 event = session_alert_event(
398 session_id=session_id, kind=kind, total_usd=total_usd,
399 unpriced_samples=unpriced_samples, threshold_usd=threshold_usd,
400 turn_tool_calls=turn_tool_calls, stall_reason=stall_reason, now=stamp,
401 )
402 path = write_session_alert(root, event, now=stamp)
403 except Exception as exc: # noqa: BLE001 — EXTERNAL_ERROR (L2); fail-soft by contract
404 _dbg("OC_CHAT.CORE", func,
405 f"EXTERNAL_ERROR: could not write a {kind} alert "
406 f"({type(exc).__name__}: {exc}); the session continues", 2)
407 return None
408 if notify:
409 notify_operator(event["summary"])
410 return path
411
412
413__all__ = [
414 "KIND_COST_HARD_CAP",
415 "KIND_COST_THRESHOLD",
416 "KIND_STALL_HALT",
417 "KIND_TOOL_CALL_CHECKPOINT",
418 "NOTIFY_TIMEOUT_S",
419 "SESSION_ALERT_DIRECTION",
420 "SESSION_ALERT_KINDS",
421 "SESSION_ALERT_LANE",
422 "SESSION_ALERT_RISK_CLASS",
423 "SESSION_ALERT_SCHEMA_VERSION",
424 "SESSION_ALERT_SOURCE",
425 "SessionAlertError",
426 "emit_session_alert",
427 "find_comms_root",
428 "format_summary",
429 "new_ulid",
430 "notify_operator",
431 "session_alert_event",
432 "utc_stamp",
433 "write_session_alert",
434]
str new_ulid(datetime now, *, bytes|None entropy=None)
Path write_session_alert(Path comms_root, dict event, *, datetime now)
bool notify_operator(str summary, *, str destination="operator")
dict session_alert_event(*, str session_id, str kind, float total_usd, int unpriced_samples, datetime now, float|None threshold_usd=None, int turn_tool_calls=0, str|None stall_reason=None, bytes|None entropy=None)
str _utc_timestamp(datetime now)
Path|None emit_session_alert(*, str session_id, str kind, float total_usd, int unpriced_samples, float|None threshold_usd=None, int turn_tool_calls=0, str|None stall_reason=None, Path|None comms_root=None, datetime|None now=None, bool notify=True)
Path|None find_comms_root(Path start)
str _encode(int value, int length)
str format_summary(*, str kind, float total_usd, int unpriced_samples, float|None threshold_usd, int turn_tool_calls, str|None stall_reason=None)