6The one process-level record of what a running tool call is doing.
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.
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.
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.
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
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)
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.
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.
76- oc_diagnostics (structured debug logging via _dbg)
78from __future__
import annotations
82from dataclasses
import dataclass, replace
84from oc_diagnostics
import _dbg
86_dbg(
"OC_CHAT.TOOLS",
"module",
"loaded", 4)
89@dataclass(frozen=True, slots=True)
91 """One self-consistent view of the tool call currently in flight."""
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."""
104 """Milliseconds since the call began, for :func:`format_duration`."""
105 current = time.monotonic()
if now
is None else now
110 """The process-wide record of the tool call in flight, or of nothing."""
114 self.
_current:
"ToolActivitySnapshot | None" =
None
117 """Open a record for *name*, starting its clock and clearing any tail."""
119 _dbg(
"OC_CHAT.TOOLS", func, f
"opened {name!r}", 2)
122 name=name, started_monotonic=time.monotonic(), last_line=
None)
125 """Record *line* as the call's most recent output. Inert outside a call.
127 *line* must already have been through the caller's redaction filter —
128 see this module's Contracts.
135 first = current.last_line
is None
136 self.
_current = replace(current, last_line=line)
139 _dbg(
"OC_CHAT.TOOLS", func,
140 f
"{current.name!r} produced its first line after "
141 f
"{current.elapsed_ms()}ms", 3)
144 """Close the record. Idempotent."""
151 _dbg(
"OC_CHAT.TOOLS", func,
152 f
"closed {current.name!r} after {current.elapsed_ms()}ms", 2)
154 def snapshot(self) -> "ToolActivitySnapshot | None":
155 """The current record, or ``None`` when no tool call is in flight.
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.
167__all__ = [
"TOOL_ACTIVITY",
"ToolActivity",
"ToolActivitySnapshot"]