oc_chat
Loading...
Searching...
No Matches
tool_activity.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/tool_activity.py
4
5"""
6The one process-level record of what a running tool call is doing.
7
8Purpose
9-------
10A tool cannot reach a presenter. Tools are invoked as ``tool_fn(**args)`` with
11the model's arguments and nothing else (``react_loop._dispatch_tool``), so a
12child process that has been printing for four minutes has no way to say so to
13the terminal. Plan 279 gives it one narrow channel: the react-loop opens a
14record around every tool call, the tool's subprocess runner notes each output
15line on it, and the status bar reads it on every render pass.
16
17Why a module-level object and not a ``ContextVar``: the multi-tool dispatch path
18runs its calls in a :class:`~concurrent.futures.ThreadPoolExecutor`, and a
19``ContextVar`` set in the react-loop does not propagate into those workers. The
20precedent this follows is
21:func:`oc_chat.core.cancellation.tool_cancel_requested`, the other process-wide
22flag a tool in a worker thread has to be able to see.
23
24The record is TERMINAL-ONLY. Nothing here is published to the event bus, written
25to the session, logged to the JSONL presenter log, or returned to the model —
26the tool result, the events and the logs are byte-identical to what they were
27before this module existed.
28
29Responsibilities
30----------------
31- :class:`ToolActivitySnapshot` — a frozen, self-consistent view of one
32 in-flight call: its label, when it started, and the last line it printed.
33- :class:`ToolActivity` — the thread-safe record: :meth:`~ToolActivity.begin`,
34 :meth:`~ToolActivity.note_line`, :meth:`~ToolActivity.end`,
35 :meth:`~ToolActivity.snapshot`.
36- :data:`TOOL_ACTIVITY` — the one instance. Nothing else in the process holds
37 tool-progress state.
38
39Diagnostics
40-----------
41Domain: OC_CHAT.TOOLS
42Levels:
43 L2 — a call's record opening and closing, with how long it ran
44 L3 — the first output line of a call (when a child started producing)
45 L4 — module load
46
47Per-line tracing is deliberately absent: ``note_line`` runs once per line of a
48child's output, which for a full test suite is tens of thousands of calls, so
49only the FIRST line of each call is traced.
50
51Contracts
52---------
53- A snapshot is published as ONE atomic attribute assignment under the lock, so
54 a reader never sees a name from one call beside a line from another. The read
55 side takes no lock at all, because it runs on Rich's refresh thread fifteen
56 times a second and must never contend with a child's output.
57- ``note_line`` outside a call is inert: a line arriving from a child that has
58 already been ended cannot resurrect a tail on the bar.
59- ``end`` is idempotent; the bracket's ``finally`` may run it more than once.
60- Calls do not nest. The react-loop's bracket wraps the whole dispatch, and the
61 confirmation resolvers re-dispatch strictly after it has closed.
62- **Redaction is the CALLER's job, and it is not optional.** ``run_command``
63 and the ``oct`` wrappers pass each line through a per-stream
64 ``_sandbox.LineRedactor`` before it reaches :meth:`~ToolActivity.note_line`,
65 so the bar can never show what the tool result would have hidden. Since plan
66 287 that filter tracks PEM private-key armor across lines — it remembers that
67 it is inside a block, which the table's one multi-line pattern cannot see line
68 by line — so no key body line reaches this record for even one frame, on
69 either stream, and a BEGIN whose END never arrives keeps redacting to the end
70 of that stream. The residual that remains is the pattern table's, not this
71 filter's: a key printed as a SINGLE line inside another format (JSON-escaped
72 with ``\\n``, say) is caught only if one of the single-line patterns covers it.
73
74Dependencies
75------------
76- oc_diagnostics (structured debug logging via _dbg)
77"""
78from __future__ import annotations
79
80import threading
81import time
82from dataclasses import dataclass, replace
83
84from oc_diagnostics import _dbg
85
86_dbg("OC_CHAT.TOOLS", "module", "loaded", 4)
87
88
89@dataclass(frozen=True, slots=True)
91 """One self-consistent view of the tool call currently in flight."""
92
93 name: str
94 """What is running, as the terminal names it — in practice the presenter's
95 busy label (``running run_command…`` / ``running 3 tools…``)."""
96 started_monotonic: float
97 """``time.monotonic()`` at :meth:`ToolActivity.begin`. Monotonic, so the
98 elapsed time it feeds cannot go backwards when the wall clock is adjusted."""
99 last_line: "str | None" = None
100 """The most recent output line, already redacted by the caller, or ``None``
101 when the call has printed nothing yet."""
102
103 def elapsed_ms(self, now: "float | None" = None) -> int:
104 """Milliseconds since the call began, for :func:`format_duration`."""
105 current = time.monotonic() if now is None else now
106 return int((current - self.started_monotonic) * 1000)
107
108
110 """The process-wide record of the tool call in flight, or of nothing."""
111
112 def __init__(self) -> None:
113 self._lock = threading.Lock()
114 self._current: "ToolActivitySnapshot | None" = None
115
116 def begin(self, name: str) -> None:
117 """Open a record for *name*, starting its clock and clearing any tail."""
118 func = "begin"
119 _dbg("OC_CHAT.TOOLS", func, f"opened {name!r}", 2)
120 with self._lock:
122 name=name, started_monotonic=time.monotonic(), last_line=None)
123
124 def note_line(self, line: str) -> None:
125 """Record *line* as the call's most recent output. Inert outside a call.
126
127 *line* must already have been through the caller's redaction filter —
128 see this module's Contracts.
129 """
130 func = "note_line"
131 with self._lock:
132 current = self._current
133 if current is None:
134 return
135 first = current.last_line is None
136 self._current = replace(current, last_line=line)
137 if first:
138 # Outside the lock: the one trace per call, not one per line.
139 _dbg("OC_CHAT.TOOLS", func,
140 f"{current.name!r} produced its first line after "
141 f"{current.elapsed_ms()}ms", 3)
142
143 def end(self) -> None:
144 """Close the record. Idempotent."""
145 func = "end"
146 with self._lock:
147 current = self._current
148 if current is None:
149 return
150 self._current = None
151 _dbg("OC_CHAT.TOOLS", func,
152 f"closed {current.name!r} after {current.elapsed_ms()}ms", 2)
153
154 def snapshot(self) -> "ToolActivitySnapshot | None":
155 """The current record, or ``None`` when no tool call is in flight.
156
157 Lock-free on purpose: the value is only ever replaced by one whole
158 object under the lock, so a bare read is already atomic and consistent.
159 """
160 return self._current
161
162
163#: The one record. Nothing else in the process holds tool-progress state.
164TOOL_ACTIVITY = ToolActivity()
165
166
167__all__ = ["TOOL_ACTIVITY", "ToolActivity", "ToolActivitySnapshot"]
"ToolActivitySnapshot | None" snapshot(self)
int elapsed_ms(self, "float | None" now=None)