premanmcp 1.1.13 → 1.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,21 +44,23 @@ function litList(values) {
44
44
  return `[${(values || []).map((v) => lit(v)).join(", ")}]`;
45
45
  }
46
46
 
47
+ const TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
48
+ /** Frameworks whose tracing plugin records their own tools (decorate_tools.py COVERED). */
49
+ const COVERING_FRAMEWORKS = new Set(["langchain"]);
50
+
47
51
  /**
48
- * Python literal for the named tools. Every value came from a model, so each is
49
- * checked here; the backend filtered the same rules, and this is the second
50
- * gate the templates promise ("every value the model supplied is checked").
51
- * Membership in the repository is not checked here -- the caller passes only
52
- * the entry file as `tree` -- and a file that is never loaded stays "pending",
53
- * which every turn reports as not visible, naming it.
52
+ * Python literals for the tools the wrapper watches: those carrying
53
+ * @preman_tool, and those a framework's tracing plugin records instead. Every
54
+ * value came from a model or from decorate_tools.py, so each is checked before
55
+ * it is quoted into the template.
54
56
  */
55
- function toolsLiteral(tools) {
56
- const list = Array.isArray(tools) ? tools : [];
57
- const pairs = list.map((tool) => {
57
+ function decoratedLiterals(tools) {
58
+ const decorated = [];
59
+ const covered = [];
60
+ for (const tool of Array.isArray(tools) ? tools : []) {
58
61
  const file = String(tool?.file || "");
59
62
  const symbol = String(tool?.symbol || "");
60
- // The same rules as the backend's `_tools` (flowtest/services/agent_entry_search.py),
61
- // plus no control characters. `lit()` quotes everything else safely.
63
+ const toolName = String(tool?.tool_name || "");
62
64
  if (
63
65
  !file.endsWith(".py")
64
66
  || file.startsWith("/")
@@ -70,9 +72,20 @@ function toolsLiteral(tools) {
70
72
  if (!TOOL_SYMBOL.test(symbol)) {
71
73
  throw new TemplateError(`tool symbol ${JSON.stringify(symbol)} is not a name or Class.method`);
72
74
  }
73
- return `(${lit(file)}, ${lit(symbol)})`;
74
- });
75
- return `[${pairs.join(", ")}]`;
75
+ if (!TOOL_NAME.test(toolName)) {
76
+ throw new TemplateError(`tool name ${JSON.stringify(toolName)} is not a name a model can call`);
77
+ }
78
+ const by = String(tool?.covered_by || "");
79
+ if (by) {
80
+ if (!COVERING_FRAMEWORKS.has(by)) {
81
+ throw new TemplateError(`no tracing plugin records ${JSON.stringify(by)} tools`);
82
+ }
83
+ covered.push(`(${lit(file)}, ${lit(symbol)}, ${lit(toolName)}, ${lit(by)})`);
84
+ } else {
85
+ decorated.push(`(${lit(file)}, ${lit(symbol)}, ${lit(toolName)})`);
86
+ }
87
+ }
88
+ return { decorated: `[${decorated.join(", ")}]`, covered: `[${covered.join(", ")}]` };
76
89
  }
77
90
 
78
91
  // ── The parts every wrapper shares ──────────────────────────────────────
@@ -144,46 +157,90 @@ def preman_selfcheck() -> str:
144
157
  `;
145
158
 
146
159
  /**
147
- * Tool capture for the Python shapes (docs/superpowers/specs/2026-09-30-tool-call-capture-design.md §4).
160
+ * Tool capture for the Python shapes (docs/superpowers/specs/2026-10-04-tool-decorators-design.md §6).
148
161
  *
149
- * Each named tool function is replaced, once, by a recording version -- in its
150
- * own module, in every loaded module holding the same object (`from x import
151
- * f`), in module-level dicts and lists holding it, and on the class for a
152
- * method. Replacing once rather than per turn is what keeps concurrent cases
153
- * from racing each other's swaps.
154
- *
155
- * A turn's calls go into a context variable set in `chat()`. assert-ai runs a
156
- * synchronous `chat` through `asyncio.to_thread`, which copies the context, so
157
- * concurrent cases stay separate without a lock. A call made in a thread the
158
- * agent starts itself sees no turn; it is not guessed onto one -- every turn
159
- * open at that moment is reported not visible instead.
162
+ * The tools record themselves: @preman_tool in the agent's own code creates a
163
+ * TOOL span per call, which the harness collects. This file only checks that
164
+ * every named tool still carries its decorator, and decides whether a turn's
165
+ * count can be trusted: a tool run in another process, in a thread still
166
+ * running when the reply comes back, or outside any turn would be missed or
167
+ * filed under the wrong turn, so such a turn is "not visible", never "recorded".
160
168
  */
161
- const PY_CAPTURE = String.raw`
169
+ const PY_DECORATED = String.raw`
170
+ import asyncio
171
+ import concurrent.futures
162
172
  import contextvars
163
173
  import functools
164
174
  import inspect
165
- import json
175
+ import math
176
+ import os
166
177
  import threading
167
178
  import time
168
179
 
169
- _TOOLS = %%TOOLS%%
170
- _turn_calls = contextvars.ContextVar("preman_turn_calls", default=None)
180
+ # (file, symbol, tool name) for each tool carrying @preman_tool, and
181
+ # (file, symbol, tool name, framework) for each tool a framework's tracing
182
+ # plugin records instead.
183
+ _DECORATED = %%DECORATED%%
184
+ _COVERED = %%COVERED%%
185
+ # Set when the customer declined the decorators: every turn says why.
186
+ _NOT_RECORDED = %%NOT_RECORDED%%
187
+
188
+ _FRAMEWORK_PLUGINS = {
189
+ "langchain": ("openinference.instrumentation.langchain", "LangChainInstrumentor"),
190
+ }
191
+
192
+ _turn_state = contextvars.ContextVar("preman_turn_state", default=None)
193
+ _local = threading.local()
194
+ _patched = []
171
195
  _open_turns = {}
172
196
  _open_lock = threading.Lock()
173
197
  _stray_calls = []
174
- _installed = []
198
+ _watched = []
199
+ _watched_keys = []
175
200
  _missing = {}
176
201
  _pending = {}
177
- _unsupported = {}
202
+
203
+ # An async entry point returns a coroutine. It runs on this one loop, kept for
204
+ # the whole run because an agent object can hold clients tied to the loop that
205
+ # created them, on its own thread so concurrent cases can all wait on it.
206
+ # Started here, before any turn, so the thread rules never count it as the
207
+ # agent's work outliving a turn.
208
+ _async_loop = asyncio.new_event_loop()
209
+ threading.Thread(target=_async_loop.run_forever, name="preman-async-entry", daemon=True).start()
210
+
211
+
212
+ def _settle(value):
213
+ if not inspect.isawaitable(value):
214
+ return value
215
+ # The turn state is a context variable; the task gets a copy so the tools
216
+ # it calls are counted in this turn.
217
+ context = contextvars.copy_context()
218
+ done = concurrent.futures.Future()
219
+
220
+ async def awaited():
221
+ return await value
222
+
223
+ def finished(task):
224
+ if task.cancelled():
225
+ done.cancel()
226
+ elif task.exception() is not None:
227
+ done.set_exception(task.exception())
228
+ else:
229
+ done.set_result(task.result())
230
+
231
+ def start():
232
+ _async_loop.create_task(awaited(), context=context).add_done_callback(finished)
233
+
234
+ _async_loop.call_soon_threadsafe(start)
235
+ return done.result()
178
236
  _install_lock = threading.Lock()
237
+ _HIDDEN = "a module named preman in this project hides PreMan's recorder"
179
238
 
180
239
 
181
240
  def _tool_module(file):
182
241
  # Only a module that is already loaded, found by the file it came from.
183
242
  # Importing it here under a name guessed from its path would run the
184
- # customer's module-level code a second time, as a second copy the agent
185
- # never calls -- the copy that got wrapped would then see no calls at all.
186
- # A file that is not loaded yet is left pending and retried next turn.
243
+ # customer's module-level code a second time.
187
244
  if (_REPO / file).resolve() == _SRC.resolve():
188
245
  return _agent()
189
246
  wanted = (_REPO / file).resolve()
@@ -198,251 +255,289 @@ def _tool_module(file):
198
255
  return None
199
256
 
200
257
 
201
- def _jsonable(value):
202
- return json.loads(json.dumps(value, ensure_ascii=False, default=str))
258
+ def _resolve(mod, symbol):
259
+ owner_name, _, method = symbol.partition(".")
260
+ owner = getattr(mod, owner_name, None)
261
+ if owner is None or not method:
262
+ return owner
263
+ raw = inspect.getattr_static(owner, method, None)
264
+ if isinstance(raw, property):
265
+ raw = raw.fget
266
+ return getattr(raw, "__func__", raw) # staticmethod / classmethod
203
267
 
204
268
 
205
- def _record(name, original, args, kwargs, result):
206
- """Build and file the event. Must never raise into the caller ('recorded()',
207
- which is standing in for the agent's own call) -- capture failing is not
208
- allowed to change what the agent sees. 'default=str' covers values json
209
- does not know how to serialise, but not dict keys (a tuple key) or a
210
- container that contains itself, both of which raise out of 'json.dumps'
211
- regardless. Each of those falls back to 'repr()'; if even that raises, the
212
- call is simply not recorded rather than recorded wrong or crashing the turn.
213
- """
269
+ def _listed(preman, file, tool_name):
270
+ # None: no @preman_tool("tool_name") ran in that file. False: one ran above
271
+ # an object-returning decorator.
272
+ wanted = str((_REPO / file).resolve())
273
+ found = [placed for where, name, placed in preman.decorated() if where == wanted and name == tool_name]
274
+ return all(found) if found else None
275
+
276
+
277
+ def _observer(event, name, detail):
278
+ # Called by preman.tool around each recorded call, in the caller's context.
279
+ turn = _turn_state.get()
280
+ with _open_lock:
281
+ if turn is None or turn["closed"]:
282
+ _stray_calls.append(time.monotonic())
283
+ return
284
+ if event == "start":
285
+ turn["open"] += 1
286
+ elif event == "end":
287
+ turn["open"] -= 1
288
+ elif event == "broken":
289
+ turn["broken"].append("%s (%s)" % (name, detail))
290
+
291
+
292
+ def _framework_traced(framework):
293
+ module_name, cls_name = _FRAMEWORK_PLUGINS.get(framework, ("", ""))
294
+ cls = getattr(sys.modules.get(module_name), cls_name, None)
214
295
  try:
215
- try:
216
- bound = inspect.signature(original).bind(*args, **kwargs).arguments
217
- tool_args = {k: v for k, v in bound.items() if k not in ("self", "cls")}
218
- except Exception:
219
- tool_args = {"args": list(args), "kwargs": dict(kwargs)}
220
- if isinstance(result, str):
221
- content = result
222
- elif inspect.isgenerator(result):
223
- content = "<generator>"
224
- else:
225
- try:
226
- content = json.dumps(result, ensure_ascii=False, default=str)
227
- except Exception:
228
- content = repr(result)
229
- try:
230
- args_out = _jsonable(tool_args)
231
- except Exception:
232
- args_out = {str(k): repr(v) for k, v in tool_args.items()}
233
- event = {
234
- "role": "tool_result",
235
- "tool_name": name,
236
- "tool_args": args_out,
237
- "content": content,
238
- }
296
+ return bool(cls is not None and cls().is_instrumented_by_opentelemetry)
239
297
  except Exception:
240
- with _open_lock:
241
- _stray_calls.append(time.monotonic())
242
- return
298
+ return False
299
+
243
300
 
244
- calls = _turn_calls.get()
245
- if calls is None:
301
+ def _thread_wait():
302
+ # How long a turn waits for threads it started that are still running.
303
+ try:
304
+ value = float(os.environ.get("PREMAN_THREAD_WAIT", "1"))
305
+ except ValueError:
306
+ return 1.0
307
+ return max(0.0, value) if math.isfinite(value) else 1.0 # "inf" would hang the case
308
+
309
+
310
+ def _track(fn):
311
+ # (carried over from the earlier wrapper) fn, set to run in a copy of the caller's
312
+ # context -- so a tool it calls files under the caller's turn, and its span
313
+ # under the caller's span -- and counted against that turn until it finishes.
314
+ ctx = contextvars.copy_context()
315
+ turn = _turn_state.get()
316
+ if turn is not None:
246
317
  with _open_lock:
247
- _stray_calls.append(time.monotonic())
248
- else:
249
- calls.append(event)
318
+ turn["pending"] += 1
319
+ done = []
250
320
 
321
+ def finish():
322
+ if turn is not None and not done:
323
+ done.append(True)
324
+ with _open_lock:
325
+ turn["pending"] -= 1
251
326
 
252
- def _error_text(exc):
253
- # str(exc) runs the agent's own __str__, which may itself raise; a new
254
- # error here would replace the tool's exception before it is re-raised.
255
- name = type(exc).__name__
327
+ def run(*args, **kwargs):
328
+ try:
329
+ return ctx.run(fn, *args, **kwargs)
330
+ finally:
331
+ finish()
332
+
333
+ run.__preman_finish__ = finish
334
+ return run
335
+
336
+
337
+ def _repo_code(thread):
338
+ # NEW (hooks design §7.1): a thread whose code lives in a library
339
+ # (braintrust's log publisher, LangSmith, Sentry, an OTel exporter) is not
340
+ # the agent's work, and many run for the life of the process. Unknown code
341
+ # counts as the agent's. site-packages is checked because a .venv often
342
+ # lives inside the repository.
343
+ fn = getattr(thread, "_target", None) or type(thread).run
344
+ fn = getattr(fn, "func", fn) # functools.partial
345
+ fn = getattr(fn, "__func__", fn) # bound method
256
346
  try:
257
- return "error: %s: %s" % (name, exc)
347
+ fn = inspect.unwrap(fn) # @preman_tool's closure lives outside the repo
258
348
  except Exception:
259
349
  pass
260
- try:
261
- return "error: %s: %r" % (name, exc)
262
- except Exception:
263
- return "error: %s" % name
264
-
350
+ code = getattr(fn, "__code__", None)
351
+ if code is None:
352
+ return True
353
+ path = Path(code.co_filename).resolve()
354
+ if "site-packages" in path.parts or "dist-packages" in path.parts:
355
+ return False
356
+ return _REPO in path.parents
357
+
358
+
359
+ def _patch_threads():
360
+ # (carried over from the earlier wrapper except where marked)
361
+ import concurrent.futures
362
+ import multiprocessing.pool
363
+ import multiprocessing.process
364
+
365
+ pool_submit = concurrent.futures.ThreadPoolExecutor.submit
366
+
367
+ @functools.wraps(pool_submit)
368
+ def submit(self, fn, /, *args, **kwargs):
369
+ run = _track(fn)
370
+ before = getattr(_local, "in_submit", False)
371
+ _local.in_submit = True
372
+ try:
373
+ return pool_submit(self, run, *args, **kwargs)
374
+ except BaseException:
375
+ run.__preman_finish__()
376
+ raise
377
+ finally:
378
+ _local.in_submit = before
379
+
380
+ thread_start = threading.Thread.start
381
+
382
+ @functools.wraps(thread_start)
383
+ def start(self):
384
+ # CHANGED: _turn_state instead of the removed _turn_calls, and library
385
+ # threads are left alone.
386
+ if (_turn_state.get() is None or getattr(_local, "in_submit", False)
387
+ or not _repo_code(self)):
388
+ return thread_start(self)
389
+ original = self.run
390
+ self.run = _track(original)
391
+ try:
392
+ return thread_start(self)
393
+ except BaseException:
394
+ self.run.__preman_finish__()
395
+ self.run = original
396
+ raise
265
397
 
266
- def _recording(name, original):
267
- if inspect.iscoroutinefunction(original):
398
+ def flagging(original):
268
399
  @functools.wraps(original)
269
- async def recorded(*args, **kwargs):
270
- try:
271
- result = await original(*args, **kwargs)
272
- except BaseException as exc:
273
- _record(name, original, args, kwargs, _error_text(exc))
274
- raise
275
- _record(name, original, args, kwargs, result)
276
- return result
277
- else:
278
- @functools.wraps(original)
279
- def recorded(*args, **kwargs):
280
- try:
281
- result = original(*args, **kwargs)
282
- except BaseException as exc:
283
- _record(name, original, args, kwargs, _error_text(exc))
284
- raise
285
- _record(name, original, args, kwargs, result)
286
- return result
287
- recorded.__preman_original__ = original
288
- return recorded
289
-
290
-
291
- def _replace_everywhere(original, recorded):
292
- for mod in list(sys.modules.values()):
293
- try:
294
- space = getattr(mod, "__dict__", None)
295
- if not isinstance(space, dict):
296
- continue
297
- for key, value in list(space.items()):
298
- if value is original:
299
- space[key] = recorded
300
- elif isinstance(value, dict):
301
- for inner, item in list(value.items()):
302
- if item is original:
303
- value[inner] = recorded
304
- elif isinstance(value, list):
305
- for index, item in enumerate(value):
306
- if item is original:
307
- value[index] = recorded
308
- except Exception:
309
- # One odd entry in sys.modules (a proxy whose __dict__ or .items()
310
- # raises) must not fail the whole install and take every other
311
- # tool down with it.
312
- continue
400
+ def sent(self, *args, **kwargs):
401
+ turn = _turn_state.get()
402
+ if turn is not None and not isinstance(self, multiprocessing.pool.ThreadPool):
403
+ turn["process"] = True
404
+ return original(self, *args, **kwargs)
405
+ return sent
406
+
407
+ concurrent.futures.ThreadPoolExecutor.submit = submit
408
+ threading.Thread.start = start
409
+ for owner, name in (
410
+ (concurrent.futures.ProcessPoolExecutor, "submit"),
411
+ (multiprocessing.pool.Pool, "apply_async"),
412
+ (multiprocessing.pool.Pool, "_map_async"),
413
+ (multiprocessing.pool.Pool, "imap"),
414
+ (multiprocessing.pool.Pool, "imap_unordered"),
415
+ (multiprocessing.process.BaseProcess, "start"),
416
+ ):
417
+ setattr(owner, name, flagging(getattr(owner, name)))
313
418
 
314
419
 
315
420
  def _install():
316
- # Serialized, and the agent loaded first, unconditionally, inside the
317
- # lock -- PY_LOADER's own _agent() has no lock of its own, so two threads
318
- # reaching it at once can each exec_module a distinct module object, and
319
- # whichever one _turn() ends up calling is not reliably the one whose
320
- # functions got wrapped below. Taking the lock here and loading the agent
321
- # before touching _TOOLS means every chat() (every chat() calls _install()
322
- # first) either does that one load-and-wrap or blocks until it is done, so
323
- # _turn() always sees the same, already-wrapped module -- concurrent first
324
- # calls included.
421
+ # Serialized, and the agent loaded first inside the lock, for the reason
422
+ # the earlier wrapper gave: concurrent first calls must all see one loaded module.
325
423
  with _install_lock:
424
+ if not _patched:
425
+ _patch_threads()
426
+ _patched.append(True)
326
427
  _agent()
327
- for file, symbol in _TOOLS:
428
+ try:
429
+ import preman
430
+ except ImportError:
431
+ preman = None
432
+ # The customer's own preman.py at the top of the repo is found first.
433
+ hidden = preman is not None and not hasattr(preman, "_observer")
434
+ if preman is not None and not hidden:
435
+ preman._observer = _observer
436
+ for file, symbol, tool_name in _DECORATED:
328
437
  key = "%s:%s" % (file, symbol)
329
- if key in _installed:
438
+ if key in _watched_keys:
330
439
  continue
331
440
  mod = _tool_module(file)
332
441
  if mod is None:
333
442
  _pending[key] = "module not loaded yet"
334
443
  continue
335
444
  _pending.pop(key, None)
336
- owner_name, _, method = symbol.partition(".")
337
- owner = getattr(mod, owner_name, None)
338
- if owner is None:
339
- _missing[key] = "%s not defined" % owner_name
340
- continue
341
- if method:
342
- if not inspect.isclass(owner):
343
- # owner_name names an instance ('support = Support()'),
344
- # not the class -- setattr on it would shadow the method
345
- # with a plain function on one object, and calling it would
346
- # raise a missing-argument TypeError the agent never had.
347
- _missing[key] = "%s.%s is not a method on a class" % (owner_name, method)
348
- continue
349
- raw = inspect.getattr_static(owner, method, None)
350
- if raw is None:
351
- _missing[key] = "%s has no %s" % (owner_name, method)
352
- continue
353
- if isinstance(raw, (staticmethod, classmethod)):
354
- wrapped = type(raw)(_recording(method, raw.__func__))
355
- elif inspect.isfunction(raw):
356
- wrapped = _recording(method, raw)
445
+ fn = _resolve(mod, symbol)
446
+ if fn is None:
447
+ _missing[key] = "not defined"
448
+ elif preman is None:
449
+ _missing[key] = "PreMan's recorder (preman) could not be imported in this run"
450
+ elif hidden:
451
+ _missing[key] = _HIDDEN
452
+ else:
453
+ placed = _listed(preman, file, tool_name)
454
+ if placed is None:
455
+ _missing[key] = 'has no @preman_tool("%s")' % tool_name
456
+ elif not placed:
457
+ _missing[key] = ('@preman_tool("%s") is above a decorator that returns an object, '
458
+ 'so calls skip it; move it directly beneath that decorator' % tool_name)
357
459
  else:
358
- # A property or other descriptor: replacing it with a
359
- # plain function would change what the attribute even is.
360
- _missing[key] = "%s.%s is not a method on a class" % (owner_name, method)
361
- continue
362
- setattr(owner, method, wrapped)
363
- elif not inspect.isfunction(owner):
364
- # A framework tool object (LangChain/crewAI '@tool', OpenAI
365
- # Agents '@function_tool'), a callable instance or a class:
366
- # swapping it for a plain function would remove the attributes
367
- # and type the agent relies on. Left exactly as it is.
368
- _missing.pop(key, None)
369
- _unsupported[key] = "not a plain function (%s)" % type(owner).__name__
370
- continue
371
- elif getattr(owner, "__preman_original__", None) is None:
372
- _replace_everywhere(owner, _recording(owner_name, owner))
373
- _missing.pop(key, None)
374
- _unsupported.pop(key, None)
375
- _installed.append(key)
376
-
377
-
378
- def _tool_names(installed):
379
- return [key.split(":", 1)[1].split(".")[-1] for key in installed]
460
+ _missing.pop(key, None)
461
+ _watched_keys.append(key)
462
+ _watched.append(tool_name)
380
463
 
381
464
 
382
465
  def _begin_turn():
383
- calls = []
384
- token = _turn_calls.set(calls)
466
+ turn = {"pending": 0, "process": False, "closed": False, "open": 0,
467
+ "broken": [], "started": time.monotonic()}
468
+ token = (_turn_state.set(turn), turn)
385
469
  marker = object()
386
470
  with _open_lock:
387
471
  _open_turns[id(marker)] = time.monotonic()
388
- return token, marker, calls
472
+ return token, marker
389
473
 
390
474
 
391
- def _end_turn(token, marker, calls):
392
- _turn_calls.reset(token)
475
+ def _end_turn(token, marker):
476
+ state_token, turn = token
477
+ _turn_state.reset(state_token)
478
+ deadline = time.monotonic() + _thread_wait()
479
+ while turn["pending"] and time.monotonic() < deadline:
480
+ time.sleep(0.01)
393
481
  now = time.monotonic()
394
482
  with _open_lock:
483
+ turn["closed"] = True
484
+ still_running = turn["pending"] or turn["open"]
395
485
  started = _open_turns.pop(id(marker))
396
486
  stray = any(started <= t <= now for t in _stray_calls)
397
487
  oldest = min(_open_turns.values(), default=now)
398
488
  _stray_calls[:] = [t for t in _stray_calls if t >= oldest]
399
- # A snapshot under the same lock _install() mutates them under, so a turn
400
- # that lands between two of _install()'s steps on another thread cannot
401
- # read a torn "some tools installed, matching missing entry not popped yet"
402
- # state.
489
+ broken = list(turn["broken"])
403
490
  with _install_lock:
404
- installed = list(_installed)
491
+ watched = list(_watched)
405
492
  missing = sorted(_missing)
406
- unseen = dict(_pending)
407
- unseen.update(_unsupported)
408
493
  not_watched = dict(_missing)
409
- not_watched.update(unseen)
410
- # "recorded" only when every named tool is installed: a tool that is not
411
- # watched could have run without being seen, so a zero would not be a
412
- # confirmed zero.
413
- if not _TOOLS:
414
- state, reason = "not_visible", "discovery named no tool functions"
415
- elif not installed and not unseen:
416
- state, reason = "not_visible", "no named tool function could be found"
494
+ not_watched.update(_pending)
495
+ frameworks = sorted({fw for _, _, _, fw in _COVERED})
496
+ untraced = [fw for fw in frameworks if not _framework_traced(fw)]
497
+ if _NOT_RECORDED:
498
+ state, reason = "not_visible", _NOT_RECORDED
499
+ elif not _DECORATED and not _COVERED:
500
+ state, reason = "not_visible", "PreMan found no tool functions in this agent"
501
+ elif os.environ.get("PREMAN_EVAL_TRACE") != "1":
502
+ state, reason = "not_visible", "tracing is switched off for this run, and @preman_tool records through it"
503
+ elif _DECORATED and not os.environ.get("PREMAN_EVAL_RUN"):
504
+ state, reason = "not_visible", "PREMAN_EVAL_RUN is not set, so @preman_tool records nothing"
417
505
  elif not_watched:
418
506
  state, reason = "not_visible", "not watched: " + "; ".join(
419
507
  "%s (%s)" % (k, v) for k, v in sorted(not_watched.items())
420
508
  )
509
+ elif untraced:
510
+ state, reason = "not_visible", "%s tools are recorded by its tracing plugin, which is not switched on in this run" % ", ".join(untraced)
511
+ elif broken:
512
+ state, reason = "not_visible", "PreMan's recorder could not record: " + "; ".join(broken)
421
513
  elif stray:
422
514
  state, reason = "not_visible", "a tool ran outside any turn, in a thread the agent started"
515
+ elif turn["process"]:
516
+ state, reason = "not_visible", "work was sent to another process, where tools are not watched"
517
+ elif still_running:
518
+ state, reason = "not_visible", "a thread started in this turn was still running when the reply came back"
423
519
  else:
424
520
  state, reason = "recorded", ""
521
+ covered_names = [name for _, _, name, fw in _COVERED if fw not in untraced]
425
522
  return {
426
523
  "state": state,
427
- "source": "wrapped",
428
- "watched": _tool_names(installed),
524
+ "source": "decorated",
525
+ "watched": watched + covered_names,
429
526
  "missing": missing,
430
527
  "reason": reason,
431
528
  }
432
529
 
433
530
 
434
531
  def preman_selfcheck() -> str:
435
- # _install() first: it takes _install_lock and loads the agent itself
436
- # before touching _TOOLS. Calling _target() first would load the agent
437
- # outside that lock, racing a concurrent call's locked load.
438
532
  _install()
439
533
  _target()
440
- # Only a missing tool fails: a pending one (its module is not loaded yet)
441
- # or an unsupported one (not a plain function) is reported per turn as
442
- # "not visible" instead, and the agent still runs.
534
+ if _HIDDEN in _missing.values():
535
+ raise RuntimeError(
536
+ _HIDDEN + ". Rename that module so PreMan can record the agent's tool calls."
537
+ )
443
538
  if _missing:
444
539
  raise RuntimeError(
445
- "tool functions named by discovery were not found: "
540
+ "tool functions PreMan watches are gone or no longer carry @preman_tool: "
446
541
  + "; ".join("%s (%s)" % (k, v) for k, v in sorted(_missing.items()))
447
542
  + ". Re-run 'preman eval target'."
448
543
  )
@@ -502,15 +597,8 @@ def _text(value):
502
597
  return "" if value is None else str(value)
503
598
  `;
504
599
 
505
- /** `chat()` for the shapes that do not capture tool calls. */
506
- const PY_CHAT = String.raw`
507
-
508
- def chat(message: str, history: list | None = None) -> dict:
509
- reply, events = _turn(message, history or [])
510
- return {"response": _text(reply), "events": list(events or [])}
511
- `;
512
-
513
- /** `chat()` for `cli_subprocess`: the agent reports its own calls through a file. */
600
+ /** `chat()` for shapes whose `_turn` returns (reply, events, tool_capture): cli_subprocess,
601
+ * node_bridge and langgraph. */
514
602
  const PY_CHAT_REPORTED = String.raw`
515
603
 
516
604
  def chat(message: str, history: list | None = None) -> dict:
@@ -518,21 +606,19 @@ def chat(message: str, history: list | None = None) -> dict:
518
606
  return {"response": _text(reply), "events": list(events or []), "tool_capture": capture}
519
607
  `;
520
608
 
521
- /** `chat()` for the Python shapes: the turn runs inside a capture window. */
522
- const PY_CHAT_CAPTURING = String.raw`
609
+ /** `chat()` for the Python shapes: the turn runs inside a capture window. The
610
+ * calls themselves arrive as spans; this adds no events of its own. */
611
+ const PY_CHAT_DECORATED = String.raw`
523
612
 
524
613
  def chat(message: str, history: list | None = None) -> dict:
525
614
  _install()
526
- token, marker, calls = _begin_turn()
615
+ token, marker = _begin_turn()
527
616
  try:
528
617
  reply, events = _turn(message, history or [])
618
+ reply = _settle(reply)
529
619
  finally:
530
- capture = _end_turn(token, marker, calls)
531
- return {
532
- "response": _text(reply),
533
- "events": list(events or []) + calls,
534
- "tool_capture": capture,
535
- }
620
+ capture = _end_turn(token, marker)
621
+ return {"response": _text(reply), "events": list(events or []), "tool_capture": capture}
536
622
  `;
537
623
 
538
624
  // ── One `_turn` per shape ───────────────────────────────────────────────
@@ -601,6 +687,8 @@ def _turn(message, history):
601
687
  # \`m.type\`, which a dict does not have -- measured: passing dicts broke a
602
688
  # fixture whose reference wrapper passed objects. The fallback below carries
603
689
  # both shapes at once so a node written either way works.
690
+ import json
691
+
604
692
  try:
605
693
  from langchain_core.messages import AIMessage, HumanMessage
606
694
 
@@ -626,12 +714,109 @@ def _turn(message, history):
626
714
  messages.append(_msg(role, (item or {}).get("content", "")))
627
715
  messages.append(_msg("user", message))
628
716
  state = _target().invoke({"messages": messages})
629
- return state, []
717
+ events, capture = _graph_calls(state)
718
+ return state, events, capture
719
+
720
+
721
+ def _field(item, name):
722
+ return item.get(name) if isinstance(item, dict) else getattr(item, name, None)
723
+
724
+
725
+ def _graph_calls(state):
726
+ # A call is a tool request (AIMessage.tool_calls) paired with its result
727
+ # (ToolMessage, same tool_call_id). A request with no result is not counted:
728
+ # nothing shows it ran. The input holds only text messages, so every pair
729
+ # found here happened in this turn. Paired in one pass in message order, so
730
+ # an id the provider reuses later in the turn still pairs each result with
731
+ # the request just before it.
732
+ messages = _field(state, "messages")
733
+ if not isinstance(messages, list):
734
+ reason = "the graph returned no messages list, so its tool calls cannot be read"
735
+ return [], {"state": "not_visible", "source": "langgraph", "watched": [], "missing": [], "reason": reason,
736
+ "trace": {"links": "order_only", "reason": reason, "known": [], "unknown": {}, "calls": []}}
737
+ pending = {} # id -> (request, trace entry) still waiting for its result
738
+ events, trace = [], []
739
+ step = 0
740
+ unpaired_request = unpaired_result = shared_id = False
741
+ for m in messages:
742
+ asked = _field(m, "tool_calls") or []
743
+ if asked:
744
+ step += 1 # one model message asking for tools is one step
745
+ for call in asked:
746
+ if not (isinstance(call, dict) and call.get("id")):
747
+ unpaired_request = True # no id: nothing can pair it with a result
748
+ elif call["id"] in pending:
749
+ shared_id = True # two waiting requests, one id: results can't be told apart
750
+ else:
751
+ entry = {"id": "c%d" % (len(trace) + 1), "tool": str(call.get("name") or ""), "step": step, "event": None}
752
+ trace.append(entry)
753
+ pending[call["id"]] = (call, entry)
754
+ if _field(m, "type") != "tool" and _field(m, "role") != "tool":
755
+ continue
756
+ found = pending.pop(_field(m, "tool_call_id"), None)
757
+ if found is None:
758
+ unpaired_result = True
759
+ continue
760
+ call, entry = found
761
+ entry["event"] = len(events)
762
+ content = _field(m, "content")
763
+ if not isinstance(content, str):
764
+ content = json.dumps(content, ensure_ascii=False, default=str)
765
+ args = call.get("args")
766
+ events.append({
767
+ "role": "tool_result",
768
+ "tool_name": str(call.get("name") or _field(m, "name") or ""),
769
+ "tool_args": json.loads(json.dumps(args if isinstance(args, dict) else {}, ensure_ascii=False, default=str)),
770
+ "content": content,
771
+ })
772
+ # Only answered calls ran; ids and steps renumbered so neither has gaps.
773
+ answered = [e for e in trace if e["event"] is not None]
774
+ steps = {}
775
+ for n, entry in enumerate(answered, 1):
776
+ entry["id"] = "c%d" % n
777
+ entry["step"] = steps.setdefault(entry["step"], len(steps) + 1)
778
+ shape = {"links": "full", "known": ["step"],
779
+ "unknown": {"parent": "the graph's messages do not show a tool calling another tool"},
780
+ "calls": answered}
781
+
782
+ def outcome(state, reason=""):
783
+ return events, {"state": state, "source": "langgraph", "watched": [], "missing": [],
784
+ "reason": reason, "trace": shape}
785
+
786
+ if shared_id:
787
+ return outcome("not_visible", "the graph asked for two tools under one id at once, so their results cannot be told apart")
788
+ # Either half on its own means a tool may have run without being counted.
789
+ if unpaired_request or pending:
790
+ return outcome("not_visible", "the graph asked for a tool whose result is not in its messages")
791
+ if unpaired_result:
792
+ return outcome("not_visible", "a tool result in the graph's messages matches no request")
793
+ return outcome("recorded")
630
794
  `,
631
795
  };
632
796
 
633
797
  // ── Shapes that are not a Python import at all ──────────────────────────
634
798
 
799
+ /** Loads the CLI's recording proxy (bin/tool_proxy.py), or None. Spec §6.0.2. */
800
+ const PY_PROXY = String.raw`
801
+
802
+ def _tool_proxy():
803
+ # Shipped with the CLI rather than written into this file, so a proxy fix
804
+ # reaches every repository without re-approving it. PREMAN_CLI_BIN is set by
805
+ # every CLI launch; added here because "python -c" and PYTHONSAFEPATH both
806
+ # leave the CLI's folder off sys.path.
807
+ folder = os.environ.get("PREMAN_CLI_BIN")
808
+ if folder and folder not in sys.path:
809
+ sys.path.insert(0, folder)
810
+ try:
811
+ import tool_proxy
812
+ # Started here so a proxy that imports but cannot start (bind error, bad
813
+ # PREMAN_AGENT_TIMEOUT) is "no proxy" and the agent still runs.
814
+ tool_proxy.shared()
815
+ except Exception:
816
+ return None
817
+ return tool_proxy
818
+ `;
819
+
635
820
  /**
636
821
  * A JS/TS agent, over one long-lived node process.
637
822
  *
@@ -645,8 +830,10 @@ def _turn(message, history):
645
830
  */
646
831
  const NODE_BRIDGE = String.raw`
647
832
  import json
833
+ import os
648
834
  import shutil
649
835
  import subprocess
836
+ import sys
650
837
  import threading
651
838
 
652
839
  _REPO = Path(__file__).resolve().parent.parent
@@ -674,6 +861,8 @@ def _why() -> str:
674
861
  return ""
675
862
  if not text:
676
863
  return ""
864
+ if _channel:
865
+ text = text.replace(_channel, "<proxy>")
677
866
  for line in text.splitlines():
678
867
  stripped = line.strip()
679
868
  if "Error" in stripped and len(stripped) > 12:
@@ -700,9 +889,31 @@ def _runtime() -> str:
700
889
  )
701
890
 
702
891
 
892
+ _proxy = None
893
+ _channel = None
894
+
895
+
896
+ def _proxy_off() -> bool:
897
+ # PREMAN_TOOL_PROXY=0 runs the agent against its own model address, unproxied.
898
+ return os.environ.get("PREMAN_TOOL_PROXY") == "0"
899
+
900
+
901
+ def _proxy_channel():
902
+ global _proxy, _channel
903
+ if _channel is None and not _proxy_off():
904
+ module = _tool_proxy()
905
+ if module is not None:
906
+ _proxy = module
907
+ _channel = module.shared().channel()
908
+ return _channel
909
+
910
+
703
911
  def _bridge():
704
912
  global _proc
705
913
  if _proc is None or _proc.poll() is not None:
914
+ env = dict(os.environ)
915
+ if _proxy_channel():
916
+ env.update(_proxy.shared().env(_channel))
706
917
  _proc = subprocess.Popen(
707
918
  [_runtime(), str(_BRIDGE)],
708
919
  cwd=str(_REPO),
@@ -711,6 +922,7 @@ def _bridge():
711
922
  stderr=open(_STDERR, "w"),
712
923
  text=True,
713
924
  bufsize=1,
925
+ env=env,
714
926
  )
715
927
  return _proc
716
928
 
@@ -725,18 +937,53 @@ def preman_selfcheck() -> str:
725
937
  return %%ENTRY_SYMBOL%%
726
938
 
727
939
 
940
+ def _is_tool(event):
941
+ return str((event or {}).get("role") or "").startswith("tool") or bool((event or {}).get("tool_name"))
942
+
943
+
728
944
  def _turn(message, history):
729
945
  with _lock:
730
946
  proc = _bridge()
731
- proc.stdin.write(json.dumps({"message": message, "history": history}) + "\n")
732
- proc.stdin.flush()
733
- line = proc.stdout.readline()
947
+ if _channel:
948
+ _proxy.shared().begin(_channel)
949
+ try:
950
+ proc.stdin.write(json.dumps({"message": message, "history": history}) + "\n")
951
+ proc.stdin.flush()
952
+ line = proc.stdout.readline()
953
+ finally:
954
+ proxied = _proxy.shared().end(_channel) if _channel else None
734
955
  if not line:
735
956
  raise RuntimeError("the node bridge closed without answering%s" % _why())
736
957
  parsed = json.loads(line)
737
958
  if parsed.get("error"):
738
- raise RuntimeError(str(parsed["error"]))
739
- return parsed.get("response", ""), parsed.get("events", [])
959
+ message_text = str(parsed["error"])
960
+ raise RuntimeError(message_text.replace(_channel, "<proxy>") if _channel else message_text)
961
+ # A role-less event naming a tool is a tool result: given the role so it
962
+ # counts, displays, and is replaced by the proxy's account like one.
963
+ own = [
964
+ dict(e, role="tool_result") if isinstance(e, dict) and not e.get("role") and e.get("tool_name") else e
965
+ for e in parsed.get("events") or []
966
+ ]
967
+ # Returned events are the agent's own claim; an empty list cannot be told
968
+ # apart from an agent that does not report, so it is not a confirmed zero.
969
+ # Only tool events are a report: a reply with assistant messages alone
970
+ # says nothing about tools.
971
+ reports_tools = any(_is_tool(e) for e in own)
972
+ reported = (own, {"state": "recorded", "source": "agent", "watched": [], "missing": [], "reason": "", "proxy_version": None}) if reports_tools else None
973
+ if proxied is None:
974
+ why = "was switched off for this agent" if _proxy_off() else "could not be loaded"
975
+ capture = reported[1] if reported else {
976
+ "state": "not_visible", "source": "agent", "watched": [], "missing": [], "proxy_version": None,
977
+ "reason": "PreMan's tool proxy %s, and the agent did not report its tool calls" % why,
978
+ }
979
+ return parsed.get("response", ""), own, capture
980
+ events, capture = _proxy.choose(proxied, reported)
981
+ if capture.get("source") == "proxy":
982
+ # The proxy's account replaces only the agent's tool events; anything
983
+ # else it returned (its own messages, notes) is kept, in front.
984
+ kept = [e for e in own if not _is_tool(e)]
985
+ events = kept + list(events)
986
+ return parsed.get("response", ""), events, capture
740
987
  `;
741
988
 
742
989
  /** The node side. Also CLI-owned, for the same reason. */
@@ -790,6 +1037,7 @@ import json
790
1037
  import os
791
1038
  import shutil
792
1039
  import subprocess
1040
+ import sys
793
1041
  import tempfile
794
1042
  from pathlib import Path
795
1043
 
@@ -910,7 +1158,7 @@ def _read_events(path):
910
1158
  """
911
1159
  if not os.path.exists(path):
912
1160
  return [], _not_visible("the agent did not create PREMAN_TOOL_EVENTS_FILE, so its tool calls are not reported")
913
- events = []
1161
+ events, lines = [], []
914
1162
  with open(path, encoding="utf-8") as handle:
915
1163
  for line in handle:
916
1164
  line = line.strip()
@@ -930,15 +1178,68 @@ def _read_events(path):
930
1178
  "tool_args": args if isinstance(args, dict) else {},
931
1179
  "content": content if isinstance(content, str) else json.dumps(content, ensure_ascii=False, default=str),
932
1180
  })
933
- return events, {"state": "recorded", "source": "events_file", "watched": [], "missing": [], "reason": ""}
1181
+ lines.append(item)
1182
+ return events, {"state": "recorded", "source": "events_file", "watched": [], "missing": [], "reason": "",
1183
+ "trace": _file_trace(lines)}
1184
+
1185
+
1186
+ def _file_trace(lines):
1187
+ """The agent's own links: 'step' and 'parent' only when every line gives them.
1188
+ A link that cannot be checked makes the turn order-only; it is never guessed."""
1189
+ def plain(reason):
1190
+ return {"links": "order_only", "reason": reason, "known": [], "unknown": {},
1191
+ "calls": [{"id": "c%d" % (i + 1), "tool": str(item["tool_name"]), "event": i}
1192
+ for i, item in enumerate(lines)]}
1193
+ known = [f for f in ("step", "parent") if lines and all(f in item for item in lines)]
1194
+ if not known:
1195
+ return plain("the agent's events file gives no step or parent")
1196
+ ids = [str(item.get("id") or "") for item in lines]
1197
+ if "parent" in known:
1198
+ if not all(ids) or len(set(ids)) != len(ids):
1199
+ return plain("the agent's events file gives parents but not a unique id on every line")
1200
+ for i, item in enumerate(lines):
1201
+ if item["parent"] is not None and str(item["parent"]) not in ids[:i]:
1202
+ return plain("the agent's events file has a parent that names no earlier call")
1203
+ if "step" in known and not all(isinstance(item["step"], int) and not isinstance(item["step"], bool) and item["step"] >= 1 for item in lines):
1204
+ return plain("the agent's events file has a step that is not a whole number from 1")
1205
+ rename = {cid: "c%d" % (i + 1) for i, cid in enumerate(ids)}
1206
+ calls = []
1207
+ for i, item in enumerate(lines):
1208
+ call = {"id": "c%d" % (i + 1), "tool": str(item["tool_name"]), "event": i}
1209
+ if "step" in known:
1210
+ call["step"] = item["step"]
1211
+ if "parent" in known:
1212
+ call["parent"] = None if item["parent"] is None else rename[str(item["parent"])]
1213
+ calls.append(call)
1214
+ return {"links": "full", "known": known,
1215
+ "unknown": {f: "the agent's events file does not give it" for f in ("step", "parent") if f not in known},
1216
+ "calls": calls}
1217
+
1218
+
1219
+ #: Commands that run the agent on another machine or in a container, where
1220
+ #: 127.0.0.1 is not this machine and the proxy cannot be reached.
1221
+ _REMOTE_LAUNCHERS = ("docker", "podman", "ssh", "kubectl")
1222
+
1223
+
1224
+ def _proxy_off() -> bool:
1225
+ return os.environ.get("PREMAN_TOOL_PROXY") == "0" or os.path.basename(_ARGV[0]) in _REMOTE_LAUNCHERS
934
1226
 
935
1227
 
936
1228
  def _turn(message, history):
937
1229
  argv = [_binary()] + list(_ARGV[1:])
938
- # One file per turn: each turn is its own process, so nothing can be filed
939
- # under the wrong turn.
1230
+ # One file and one proxy channel per turn: each turn is its own process, so
1231
+ # nothing can be filed under the wrong turn.
940
1232
  folder = tempfile.mkdtemp(prefix="preman-tools-")
941
1233
  events_path = os.path.join(folder, "tool_events.jsonl")
1234
+ env = dict(os.environ, PREMAN_TOOL_EVENTS_FILE=events_path)
1235
+ switched_off = _proxy_off()
1236
+ proxy_mod = None if switched_off else _tool_proxy()
1237
+ proxy = proxy_mod.shared() if proxy_mod else None
1238
+ channel = proxy.channel() if proxy else None
1239
+ if channel:
1240
+ env.update(proxy.env(channel))
1241
+ proxy.begin(channel)
1242
+ proxied = None
942
1243
  try:
943
1244
  done = subprocess.run(
944
1245
  argv,
@@ -947,15 +1248,33 @@ def _turn(message, history):
947
1248
  text=True,
948
1249
  cwd=str(_REPO),
949
1250
  timeout=_TIMEOUT,
950
- env=dict(os.environ, PREMAN_TOOL_EVENTS_FILE=events_path),
1251
+ env=env,
951
1252
  )
1253
+ if channel:
1254
+ proxied = proxy.end(channel)
952
1255
  if done.returncode != 0:
953
- tail = (done.stderr or "").strip()[-600:]
1256
+ tail = (done.stderr or "").strip()
1257
+ if channel:
1258
+ tail = tail.replace(channel, "<proxy>")
1259
+ tail = tail[-600:]
954
1260
  raise RuntimeError("%s exited %s: %s" % (_ARGV[0], done.returncode, tail))
955
- events, capture = _read_events(events_path)
1261
+ reported = _read_events(events_path)
956
1262
  finally:
1263
+ if channel:
1264
+ proxy.drop(channel)
957
1265
  shutil.rmtree(folder, ignore_errors=True)
958
- return done.stdout.strip(), events, capture
1266
+ if proxied is None:
1267
+ events, capture = reported
1268
+ capture = dict(capture, proxy_version=None)
1269
+ if capture["state"] != "recorded":
1270
+ why = "was switched off for this agent" if switched_off else "could not be loaded"
1271
+ capture["reason"] = "PreMan's tool proxy %s; %s" % (why, capture["reason"])
1272
+ else:
1273
+ events, capture = proxy_mod.choose(proxied, reported)
1274
+ reply = done.stdout.strip()
1275
+ if channel:
1276
+ reply = reply.replace(channel, "<proxy>")
1277
+ return reply, events, capture
959
1278
  `;
960
1279
 
961
1280
  // ── Assembly ────────────────────────────────────────────────────────────
@@ -1008,7 +1327,7 @@ export function buildWrapper(found, { tree = [] } = {}) {
1008
1327
 
1009
1328
  if (shape === "cli_subprocess") {
1010
1329
  return {
1011
- shim: header + CLI_SUBPROCESS.replace("%%COMMAND%%", litList(command)) + PY_NORMALISE + PY_CHAT_REPORTED,
1330
+ shim: header + CLI_SUBPROCESS.replace("%%COMMAND%%", litList(command)) + PY_PROXY + PY_NORMALISE + PY_CHAT_REPORTED,
1012
1331
  bridge: "",
1013
1332
  };
1014
1333
  }
@@ -1027,8 +1346,9 @@ export function buildWrapper(found, { tree = [] } = {}) {
1027
1346
  + NODE_BRIDGE
1028
1347
  .replace("%%NODE_RUNTIME%%", lit(runtime))
1029
1348
  .replace("%%ENTRY_SYMBOL%%", lit(symbol))
1349
+ + PY_PROXY
1030
1350
  + PY_NORMALISE
1031
- + PY_CHAT;
1351
+ + PY_CHAT_REPORTED;
1032
1352
  const bridge = NODE_SIDE
1033
1353
  .replace(/%%NODE_ENTRY%%/g, lit("../" + nodeEntry))
1034
1354
  .replace(/%%ENTRY_SYMBOL%%/g, lit(symbol));
@@ -1049,18 +1369,31 @@ export function buildWrapper(found, { tree = [] } = {}) {
1049
1369
  .replace("%%ENTRY_SYMBOL%%", lit(symbol));
1050
1370
 
1051
1371
  // langgraph reads tool calls out of the graph's own messages (§5.1 of the
1052
- // design), not by wrapping functions, so it keeps the plain `chat()`.
1372
+ // design), not by wrapping functions, so its `_turn` returns the capture and
1373
+ // it uses `chat()` from PY_CHAT_REPORTED.
1053
1374
  if (shape === "langgraph") {
1054
- return { shim: header + loader + turn + PY_NORMALISE + PY_CHAT, bridge: "" };
1375
+ return { shim: header + loader + turn + PY_NORMALISE + PY_CHAT_REPORTED, bridge: "" };
1055
1376
  }
1056
1377
  // Checked only for the shapes that use it, so a tool path this shape never
1057
1378
  // reads cannot fail the build. Every value is checked before it is quoted
1058
1379
  // into the template, so a tool value a model invented cannot interpolate.
1059
- const tools = toolsLiteral(found?.tools);
1060
- const shim = header + loader + PY_CAPTURE.replace("%%TOOLS%%", tools) + turn + PY_NORMALISE + PY_CHAT_CAPTURING;
1380
+ const shim = header + loader + decoratedCapture(found) + turn + PY_NORMALISE + PY_CHAT_DECORATED;
1061
1381
  return { shim, bridge: "" };
1062
1382
  }
1063
1383
 
1384
+ /** PY_DECORATED with this answer's tools filled in. */
1385
+ function decoratedCapture(found) {
1386
+ const { decorated, covered } = decoratedLiterals(found?.tools);
1387
+ const notRecorded = String(found?.tools_not_recorded || "");
1388
+ if ([...notRecorded].some((ch) => ch.charCodeAt(0) < 0x20)) {
1389
+ throw new TemplateError("tools_not_recorded holds a control character");
1390
+ }
1391
+ return PY_DECORATED
1392
+ .replace("%%DECORATED%%", decorated)
1393
+ .replace("%%COVERED%%", covered)
1394
+ .replace("%%NOT_RECORDED%%", lit(notRecorded));
1395
+ }
1396
+
1064
1397
  /**
1065
1398
  * The `other` shape: the CLI still owns everything except one function body.
1066
1399
  *
@@ -1083,7 +1416,7 @@ export function buildFreeform(found, { tree = [], turnSource = "" } = {}) {
1083
1416
  if (!body.trim()) throw new TemplateError("no conversion body was returned");
1084
1417
  // Computed before any text is built, so a tool value a model invented does
1085
1418
  // not get the chance to interpolate into a template.
1086
- const tools = toolsLiteral(found?.tools);
1419
+ const capture = decoratedCapture(found);
1087
1420
 
1088
1421
  const header = HEADER
1089
1422
  .replace("%%SHAPE%%", "other")
@@ -1100,11 +1433,11 @@ export function buildFreeform(found, { tree = [], turnSource = "" } = {}) {
1100
1433
  + PY_LOADER
1101
1434
  .replace("%%ENTRY_FILE%%", lit(entryFile))
1102
1435
  .replace("%%ENTRY_SYMBOL%%", lit(symbol))
1103
- + PY_CAPTURE.replace("%%TOOLS%%", tools)
1436
+ + capture
1104
1437
  + "\n\ndef _turn(message, history):\n"
1105
1438
  + ` ${symbol} = _target()\n`
1106
1439
  + body + "\n"
1107
1440
  + PY_NORMALISE
1108
- + PY_CHAT_CAPTURING;
1441
+ + PY_CHAT_DECORATED;
1109
1442
  return { shim, bridge: "" };
1110
1443
  }