premanmcp 1.1.13 → 1.1.14

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.
@@ -163,11 +163,19 @@ import contextvars
163
163
  import functools
164
164
  import inspect
165
165
  import json
166
+ import math
167
+ import os
166
168
  import threading
167
169
  import time
168
170
 
169
171
  _TOOLS = %%TOOLS%%
170
172
  _turn_calls = contextvars.ContextVar("preman_turn_calls", default=None)
173
+ _turn_state = contextvars.ContextVar("preman_turn_state", default=None)
174
+ # The call running right now, so a tool called from inside it names it as parent.
175
+ # Copied into pool tasks and threads by _track, like the turn itself.
176
+ _current_call = contextvars.ContextVar("preman_current_call", default=None)
177
+ _local = threading.local()
178
+ _patched = []
171
179
  _open_turns = {}
172
180
  _open_lock = threading.Lock()
173
181
  _stray_calls = []
@@ -175,9 +183,30 @@ _installed = []
175
183
  _missing = {}
176
184
  _pending = {}
177
185
  _unsupported = {}
186
+ _traceable = set()
178
187
  _install_lock = threading.Lock()
179
188
 
180
189
 
190
+ def _is_langchain_tool(obj):
191
+ # Checked only when LangChain is already loaded: importing it here would
192
+ # load a framework the agent may not use.
193
+ tools = sys.modules.get("langchain_core.tools")
194
+ base = getattr(tools, "BaseTool", None)
195
+ return isinstance(base, type) and isinstance(obj, base)
196
+
197
+
198
+ def _langchain_traced():
199
+ # OpenInference's LangChain plugin, switched on by the run's tracing. It
200
+ # hooks LangChain's callback manager, so every '@tool' that runs through
201
+ # LangChain becomes a tool span -- it sees the calls wrapping cannot.
202
+ plugin = sys.modules.get("openinference.instrumentation.langchain")
203
+ cls = getattr(plugin, "LangChainInstrumentor", None)
204
+ try:
205
+ return bool(cls is not None and cls().is_instrumented_by_opentelemetry)
206
+ except Exception:
207
+ return False
208
+
209
+
181
210
  def _tool_module(file):
182
211
  # Only a module that is already loaded, found by the file it came from.
183
212
  # Importing it here under a name guessed from its path would run the
@@ -202,7 +231,7 @@ def _jsonable(value):
202
231
  return json.loads(json.dumps(value, ensure_ascii=False, default=str))
203
232
 
204
233
 
205
- def _record(name, original, args, kwargs, result):
234
+ def _record(name, original, args, kwargs, result, shape=None):
206
235
  """Build and file the event. Must never raise into the caller ('recorded()',
207
236
  which is standing in for the agent's own call) -- capture failing is not
208
237
  allowed to change what the agent sees. 'default=str' covers values json
@@ -239,14 +268,54 @@ def _record(name, original, args, kwargs, result):
239
268
  except Exception:
240
269
  with _open_lock:
241
270
  _stray_calls.append(time.monotonic())
271
+ turn = _turn_state.get()
272
+ if shape is not None and turn is not None:
273
+ shape["ended"] = round(time.monotonic() - turn["started"], 3) # finished, but no event
242
274
  return
243
275
 
244
276
  calls = _turn_calls.get()
245
- if calls is None:
277
+ turn = _turn_state.get()
278
+ if calls is None or (turn is not None and turn["closed"]):
279
+ # No turn, or its turn already returned: appending would add it to a
280
+ # list nobody reads any more.
246
281
  with _open_lock:
247
282
  _stray_calls.append(time.monotonic())
248
283
  else:
249
284
  calls.append(event)
285
+ if shape is not None:
286
+ shape["event"] = event # replaced by its position when the turn ends
287
+ shape["ended"] = round(time.monotonic() - turn["started"], 3)
288
+
289
+
290
+ def _start_call(name):
291
+ """The trace entry for a call starting now, and the token to reset
292
+ _current_call with; (None, None) outside a turn."""
293
+ turn = _turn_state.get()
294
+ if turn is None or turn["closed"]:
295
+ return None, None
296
+ with _open_lock:
297
+ turn["count"] += 1
298
+ cid = "c%d" % turn["count"]
299
+ # Filed at the start, so a call still running when the turn ends is
300
+ # listed (ended null) and its children's parent still resolves.
301
+ shape = {
302
+ "id": cid,
303
+ "tool": name,
304
+ "parent": _current_call.get(),
305
+ "started": round(time.monotonic() - turn["started"], 3),
306
+ "thread": threading.current_thread().name,
307
+ }
308
+ turn["trace"].append(shape)
309
+ return shape, _current_call.set(cid)
310
+
311
+
312
+ def _thread_wait():
313
+ # How long a turn waits for threads it started that are still running.
314
+ try:
315
+ value = float(os.environ.get("PREMAN_THREAD_WAIT", "1"))
316
+ except ValueError:
317
+ return 1.0
318
+ return max(0.0, value) if math.isfinite(value) else 1.0 # "inf" would hang the case
250
319
 
251
320
 
252
321
  def _error_text(exc):
@@ -267,22 +336,30 @@ def _recording(name, original):
267
336
  if inspect.iscoroutinefunction(original):
268
337
  @functools.wraps(original)
269
338
  async def recorded(*args, **kwargs):
339
+ shape, token = _start_call(name)
270
340
  try:
271
341
  result = await original(*args, **kwargs)
272
342
  except BaseException as exc:
273
- _record(name, original, args, kwargs, _error_text(exc))
343
+ _record(name, original, args, kwargs, _error_text(exc), shape)
274
344
  raise
275
- _record(name, original, args, kwargs, result)
345
+ finally:
346
+ if token is not None:
347
+ _current_call.reset(token)
348
+ _record(name, original, args, kwargs, result, shape)
276
349
  return result
277
350
  else:
278
351
  @functools.wraps(original)
279
352
  def recorded(*args, **kwargs):
353
+ shape, token = _start_call(name)
280
354
  try:
281
355
  result = original(*args, **kwargs)
282
356
  except BaseException as exc:
283
- _record(name, original, args, kwargs, _error_text(exc))
357
+ _record(name, original, args, kwargs, _error_text(exc), shape)
284
358
  raise
285
- _record(name, original, args, kwargs, result)
359
+ finally:
360
+ if token is not None:
361
+ _current_call.reset(token)
362
+ _record(name, original, args, kwargs, result, shape)
286
363
  return result
287
364
  recorded.__preman_original__ = original
288
365
  return recorded
@@ -312,6 +389,96 @@ def _replace_everywhere(original, recorded):
312
389
  continue
313
390
 
314
391
 
392
+ def _track(fn):
393
+ # fn, set to run in a copy of the caller's context -- so a tool it calls
394
+ # files under the caller's turn -- and counted against that turn until it
395
+ # finishes, so a turn that ends with it still running is not called complete.
396
+ ctx = contextvars.copy_context()
397
+ turn = _turn_state.get()
398
+ if turn is not None:
399
+ with _open_lock:
400
+ turn["pending"] += 1
401
+ done = []
402
+
403
+ def finish():
404
+ if turn is not None and not done:
405
+ done.append(True)
406
+ with _open_lock:
407
+ turn["pending"] -= 1
408
+
409
+ def run(*args, **kwargs):
410
+ try:
411
+ return ctx.run(fn, *args, **kwargs)
412
+ finally:
413
+ finish()
414
+
415
+ run.__preman_finish__ = finish
416
+ return run
417
+
418
+
419
+ def _patch_threads():
420
+ # A new thread starts with empty context variables, so a tool run on a
421
+ # pool or a Thread saw no turn and was counted as a stray call. Patched on
422
+ # the classes, so pools the agent created at import are covered too.
423
+ import concurrent.futures
424
+ import multiprocessing.pool
425
+ import multiprocessing.process
426
+
427
+ pool_submit = concurrent.futures.ThreadPoolExecutor.submit
428
+
429
+ @functools.wraps(pool_submit)
430
+ def submit(self, fn, /, *args, **kwargs):
431
+ # Every task, not only those in a turn: a worker thread is reused, and a
432
+ # task must never run in a context an earlier task's turn left behind.
433
+ run = _track(fn)
434
+ before = getattr(_local, "in_submit", False)
435
+ _local.in_submit = True # the pool's own worker threads are not the agent's
436
+ try:
437
+ return pool_submit(self, run, *args, **kwargs)
438
+ except BaseException:
439
+ run.__preman_finish__()
440
+ raise
441
+ finally:
442
+ _local.in_submit = before
443
+
444
+ thread_start = threading.Thread.start
445
+
446
+ @functools.wraps(thread_start)
447
+ def start(self):
448
+ if _turn_calls.get() is None or getattr(_local, "in_submit", False):
449
+ return thread_start(self)
450
+ original = self.run
451
+ self.run = _track(original)
452
+ try:
453
+ return thread_start(self)
454
+ except BaseException:
455
+ self.run.__preman_finish__()
456
+ self.run = original
457
+ raise
458
+
459
+ def flagging(original):
460
+ # Work sent to another process runs where no tool is wrapped.
461
+ @functools.wraps(original)
462
+ def sent(self, *args, **kwargs):
463
+ turn = _turn_state.get()
464
+ if turn is not None and not isinstance(self, multiprocessing.pool.ThreadPool):
465
+ turn["process"] = True
466
+ return original(self, *args, **kwargs)
467
+ return sent
468
+
469
+ concurrent.futures.ThreadPoolExecutor.submit = submit
470
+ threading.Thread.start = start
471
+ for owner, name in (
472
+ (concurrent.futures.ProcessPoolExecutor, "submit"),
473
+ (multiprocessing.pool.Pool, "apply_async"),
474
+ (multiprocessing.pool.Pool, "_map_async"),
475
+ (multiprocessing.pool.Pool, "imap"),
476
+ (multiprocessing.pool.Pool, "imap_unordered"),
477
+ (multiprocessing.process.BaseProcess, "start"),
478
+ ):
479
+ setattr(owner, name, flagging(getattr(owner, name)))
480
+
481
+
315
482
  def _install():
316
483
  # Serialized, and the agent loaded first, unconditionally, inside the
317
484
  # lock -- PY_LOADER's own _agent() has no lock of its own, so two threads
@@ -323,6 +490,9 @@ def _install():
323
490
  # _turn() always sees the same, already-wrapped module -- concurrent first
324
491
  # calls included.
325
492
  with _install_lock:
493
+ if not _patched:
494
+ _patch_threads()
495
+ _patched.append(True)
326
496
  _agent()
327
497
  for file, symbol in _TOOLS:
328
498
  key = "%s:%s" % (file, symbol)
@@ -367,6 +537,8 @@ def _install():
367
537
  # and type the agent relies on. Left exactly as it is.
368
538
  _missing.pop(key, None)
369
539
  _unsupported[key] = "not a plain function (%s)" % type(owner).__name__
540
+ if _is_langchain_tool(owner):
541
+ _traceable.add(key)
370
542
  continue
371
543
  elif getattr(owner, "__preman_original__", None) is None:
372
544
  _replace_everywhere(owner, _recording(owner_name, owner))
@@ -381,7 +553,9 @@ def _tool_names(installed):
381
553
 
382
554
  def _begin_turn():
383
555
  calls = []
384
- token = _turn_calls.set(calls)
556
+ turn = {"pending": 0, "process": False, "closed": False,
557
+ "started": time.monotonic(), "count": 0, "trace": []}
558
+ token = (_turn_calls.set(calls), _turn_state.set(turn), turn)
385
559
  marker = object()
386
560
  with _open_lock:
387
561
  _open_turns[id(marker)] = time.monotonic()
@@ -389,9 +563,21 @@ def _begin_turn():
389
563
 
390
564
 
391
565
  def _end_turn(token, marker, calls):
392
- _turn_calls.reset(token)
566
+ calls_token, state_token, turn = token
567
+ _turn_calls.reset(calls_token)
568
+ _turn_state.reset(state_token)
569
+ # A thread this turn started may finish a moment after the reply: wait a
570
+ # little for it rather than call the turn not visible. Bounded, because a
571
+ # worker the agent keeps running would otherwise hang the case.
572
+ began = time.monotonic()
573
+ deadline = began + _thread_wait()
574
+ while turn["pending"] and time.monotonic() < deadline:
575
+ time.sleep(0.01)
576
+ waited = time.monotonic() - began
393
577
  now = time.monotonic()
394
578
  with _open_lock:
579
+ turn["closed"] = True
580
+ still_running = turn["pending"]
395
581
  started = _open_turns.pop(id(marker))
396
582
  stray = any(started <= t <= now for t in _stray_calls)
397
583
  oldest = min(_open_turns.values(), default=now)
@@ -407,6 +593,11 @@ def _end_turn(token, marker, calls):
407
593
  unseen.update(_unsupported)
408
594
  not_watched = dict(_missing)
409
595
  not_watched.update(unseen)
596
+ traceable = set(_traceable)
597
+ # A LangChain tool object can't be wrapped, but when the LangChain plugin
598
+ # is on, every call to it is a tool span, so it is still seen. Only when
599
+ # every unwatched tool is one: any other unwatched tool could run unseen.
600
+ traced = bool(not_watched) and set(not_watched) <= traceable and _langchain_traced()
410
601
  # "recorded" only when every named tool is installed: a tool that is not
411
602
  # watched could have run without being seen, so a zero would not be a
412
603
  # confirmed zero.
@@ -414,21 +605,42 @@ def _end_turn(token, marker, calls):
414
605
  state, reason = "not_visible", "discovery named no tool functions"
415
606
  elif not installed and not unseen:
416
607
  state, reason = "not_visible", "no named tool function could be found"
417
- elif not_watched:
608
+ elif not_watched and not traced:
418
609
  state, reason = "not_visible", "not watched: " + "; ".join(
419
610
  "%s (%s)" % (k, v) for k, v in sorted(not_watched.items())
420
611
  )
421
612
  elif stray:
422
613
  state, reason = "not_visible", "a tool ran outside any turn, in a thread the agent started"
614
+ elif turn["process"]:
615
+ state, reason = "not_visible", "work was sent to another process, where tools are not watched"
616
+ elif still_running:
617
+ state, reason = "not_visible", "a thread started in this turn was still running when the reply came back"
423
618
  else:
424
619
  state, reason = "recorded", ""
425
- return {
620
+ capture = {
426
621
  "state": state,
427
- "source": "wrapped",
428
- "watched": _tool_names(installed),
622
+ "source": "traced" if traced else "wrapped",
623
+ "watched": _tool_names(installed) + (_tool_names(sorted(not_watched)) if traced else []),
429
624
  "missing": missing,
430
625
  "reason": reason,
431
626
  }
627
+ if not traced:
628
+ # A traced turn's LangChain calls come from spans; the harness builds its trace.
629
+ with _open_lock:
630
+ started = list(turn["trace"])
631
+ calls_out = []
632
+ for call in sorted(started, key=lambda c: int(c["id"][1:])):
633
+ call = dict(call)
634
+ call.setdefault("ended", None) # still running when the turn ended
635
+ calls_out.append(call)
636
+ capture["trace"] = {
637
+ "links": "full",
638
+ "known": ["parent", "started", "ended", "thread"],
639
+ "unknown": {"step": "function wrapping sees tool calls, not the model replies that asked for them"},
640
+ "waited": round(waited, 3),
641
+ "calls": calls_out,
642
+ }
643
+ return capture
432
644
 
433
645
 
434
646
  def preman_selfcheck() -> str:
@@ -502,15 +714,8 @@ def _text(value):
502
714
  return "" if value is None else str(value)
503
715
  `;
504
716
 
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. */
717
+ /** `chat()` for shapes whose `_turn` returns (reply, events, tool_capture): cli_subprocess,
718
+ * node_bridge and langgraph. */
514
719
  const PY_CHAT_REPORTED = String.raw`
515
720
 
516
721
  def chat(message: str, history: list | None = None) -> dict:
@@ -528,11 +733,15 @@ def chat(message: str, history: list | None = None) -> dict:
528
733
  reply, events = _turn(message, history or [])
529
734
  finally:
530
735
  capture = _end_turn(token, marker, calls)
531
- return {
532
- "response": _text(reply),
533
- "events": list(events or []) + calls,
534
- "tool_capture": capture,
535
- }
736
+ events = list(events or []) + calls
737
+ trace = capture.get("trace")
738
+ if trace:
739
+ # Each call's event, as its position among this turn's tool results.
740
+ tools = [e for e in events if isinstance(e, dict) and e.get("role") == "tool_result"]
741
+ where = {id(e): i for i, e in enumerate(tools)}
742
+ for call in trace["calls"]:
743
+ call["event"] = where.get(id(call.pop("event", None)))
744
+ return {"response": _text(reply), "events": events, "tool_capture": capture}
536
745
  `;
537
746
 
538
747
  // ── One `_turn` per shape ───────────────────────────────────────────────
@@ -601,6 +810,8 @@ def _turn(message, history):
601
810
  # \`m.type\`, which a dict does not have -- measured: passing dicts broke a
602
811
  # fixture whose reference wrapper passed objects. The fallback below carries
603
812
  # both shapes at once so a node written either way works.
813
+ import json
814
+
604
815
  try:
605
816
  from langchain_core.messages import AIMessage, HumanMessage
606
817
 
@@ -626,12 +837,109 @@ def _turn(message, history):
626
837
  messages.append(_msg(role, (item or {}).get("content", "")))
627
838
  messages.append(_msg("user", message))
628
839
  state = _target().invoke({"messages": messages})
629
- return state, []
840
+ events, capture = _graph_calls(state)
841
+ return state, events, capture
842
+
843
+
844
+ def _field(item, name):
845
+ return item.get(name) if isinstance(item, dict) else getattr(item, name, None)
846
+
847
+
848
+ def _graph_calls(state):
849
+ # A call is a tool request (AIMessage.tool_calls) paired with its result
850
+ # (ToolMessage, same tool_call_id). A request with no result is not counted:
851
+ # nothing shows it ran. The input holds only text messages, so every pair
852
+ # found here happened in this turn. Paired in one pass in message order, so
853
+ # an id the provider reuses later in the turn still pairs each result with
854
+ # the request just before it.
855
+ messages = _field(state, "messages")
856
+ if not isinstance(messages, list):
857
+ reason = "the graph returned no messages list, so its tool calls cannot be read"
858
+ return [], {"state": "not_visible", "source": "langgraph", "watched": [], "missing": [], "reason": reason,
859
+ "trace": {"links": "order_only", "reason": reason, "known": [], "unknown": {}, "calls": []}}
860
+ pending = {} # id -> (request, trace entry) still waiting for its result
861
+ events, trace = [], []
862
+ step = 0
863
+ unpaired_request = unpaired_result = shared_id = False
864
+ for m in messages:
865
+ asked = _field(m, "tool_calls") or []
866
+ if asked:
867
+ step += 1 # one model message asking for tools is one step
868
+ for call in asked:
869
+ if not (isinstance(call, dict) and call.get("id")):
870
+ unpaired_request = True # no id: nothing can pair it with a result
871
+ elif call["id"] in pending:
872
+ shared_id = True # two waiting requests, one id: results can't be told apart
873
+ else:
874
+ entry = {"id": "c%d" % (len(trace) + 1), "tool": str(call.get("name") or ""), "step": step, "event": None}
875
+ trace.append(entry)
876
+ pending[call["id"]] = (call, entry)
877
+ if _field(m, "type") != "tool" and _field(m, "role") != "tool":
878
+ continue
879
+ found = pending.pop(_field(m, "tool_call_id"), None)
880
+ if found is None:
881
+ unpaired_result = True
882
+ continue
883
+ call, entry = found
884
+ entry["event"] = len(events)
885
+ content = _field(m, "content")
886
+ if not isinstance(content, str):
887
+ content = json.dumps(content, ensure_ascii=False, default=str)
888
+ args = call.get("args")
889
+ events.append({
890
+ "role": "tool_result",
891
+ "tool_name": str(call.get("name") or _field(m, "name") or ""),
892
+ "tool_args": json.loads(json.dumps(args if isinstance(args, dict) else {}, ensure_ascii=False, default=str)),
893
+ "content": content,
894
+ })
895
+ # Only answered calls ran; ids and steps renumbered so neither has gaps.
896
+ answered = [e for e in trace if e["event"] is not None]
897
+ steps = {}
898
+ for n, entry in enumerate(answered, 1):
899
+ entry["id"] = "c%d" % n
900
+ entry["step"] = steps.setdefault(entry["step"], len(steps) + 1)
901
+ shape = {"links": "full", "known": ["step"],
902
+ "unknown": {"parent": "the graph's messages do not show a tool calling another tool"},
903
+ "calls": answered}
904
+
905
+ def outcome(state, reason=""):
906
+ return events, {"state": state, "source": "langgraph", "watched": [], "missing": [],
907
+ "reason": reason, "trace": shape}
908
+
909
+ if shared_id:
910
+ return outcome("not_visible", "the graph asked for two tools under one id at once, so their results cannot be told apart")
911
+ # Either half on its own means a tool may have run without being counted.
912
+ if unpaired_request or pending:
913
+ return outcome("not_visible", "the graph asked for a tool whose result is not in its messages")
914
+ if unpaired_result:
915
+ return outcome("not_visible", "a tool result in the graph's messages matches no request")
916
+ return outcome("recorded")
630
917
  `,
631
918
  };
632
919
 
633
920
  // ── Shapes that are not a Python import at all ──────────────────────────
634
921
 
922
+ /** Loads the CLI's recording proxy (bin/tool_proxy.py), or None. Spec §6.0.2. */
923
+ const PY_PROXY = String.raw`
924
+
925
+ def _tool_proxy():
926
+ # Shipped with the CLI rather than written into this file, so a proxy fix
927
+ # reaches every repository without re-approving it. PREMAN_CLI_BIN is set by
928
+ # every CLI launch; added here because "python -c" and PYTHONSAFEPATH both
929
+ # leave the CLI's folder off sys.path.
930
+ folder = os.environ.get("PREMAN_CLI_BIN")
931
+ if folder and folder not in sys.path:
932
+ sys.path.insert(0, folder)
933
+ try:
934
+ import tool_proxy
935
+ # Started here so a proxy that imports but cannot start (bind error, bad
936
+ # PREMAN_AGENT_TIMEOUT) is "no proxy" and the agent still runs.
937
+ tool_proxy.shared()
938
+ except Exception:
939
+ return None
940
+ return tool_proxy
941
+ `;
942
+
635
943
  /**
636
944
  * A JS/TS agent, over one long-lived node process.
637
945
  *
@@ -645,8 +953,10 @@ def _turn(message, history):
645
953
  */
646
954
  const NODE_BRIDGE = String.raw`
647
955
  import json
956
+ import os
648
957
  import shutil
649
958
  import subprocess
959
+ import sys
650
960
  import threading
651
961
 
652
962
  _REPO = Path(__file__).resolve().parent.parent
@@ -674,6 +984,8 @@ def _why() -> str:
674
984
  return ""
675
985
  if not text:
676
986
  return ""
987
+ if _channel:
988
+ text = text.replace(_channel, "<proxy>")
677
989
  for line in text.splitlines():
678
990
  stripped = line.strip()
679
991
  if "Error" in stripped and len(stripped) > 12:
@@ -700,9 +1012,31 @@ def _runtime() -> str:
700
1012
  )
701
1013
 
702
1014
 
1015
+ _proxy = None
1016
+ _channel = None
1017
+
1018
+
1019
+ def _proxy_off() -> bool:
1020
+ # PREMAN_TOOL_PROXY=0 runs the agent against its own model address, unproxied.
1021
+ return os.environ.get("PREMAN_TOOL_PROXY") == "0"
1022
+
1023
+
1024
+ def _proxy_channel():
1025
+ global _proxy, _channel
1026
+ if _channel is None and not _proxy_off():
1027
+ module = _tool_proxy()
1028
+ if module is not None:
1029
+ _proxy = module
1030
+ _channel = module.shared().channel()
1031
+ return _channel
1032
+
1033
+
703
1034
  def _bridge():
704
1035
  global _proc
705
1036
  if _proc is None or _proc.poll() is not None:
1037
+ env = dict(os.environ)
1038
+ if _proxy_channel():
1039
+ env.update(_proxy.shared().env(_channel))
706
1040
  _proc = subprocess.Popen(
707
1041
  [_runtime(), str(_BRIDGE)],
708
1042
  cwd=str(_REPO),
@@ -711,6 +1045,7 @@ def _bridge():
711
1045
  stderr=open(_STDERR, "w"),
712
1046
  text=True,
713
1047
  bufsize=1,
1048
+ env=env,
714
1049
  )
715
1050
  return _proc
716
1051
 
@@ -725,18 +1060,53 @@ def preman_selfcheck() -> str:
725
1060
  return %%ENTRY_SYMBOL%%
726
1061
 
727
1062
 
1063
+ def _is_tool(event):
1064
+ return str((event or {}).get("role") or "").startswith("tool") or bool((event or {}).get("tool_name"))
1065
+
1066
+
728
1067
  def _turn(message, history):
729
1068
  with _lock:
730
1069
  proc = _bridge()
731
- proc.stdin.write(json.dumps({"message": message, "history": history}) + "\n")
732
- proc.stdin.flush()
733
- line = proc.stdout.readline()
1070
+ if _channel:
1071
+ _proxy.shared().begin(_channel)
1072
+ try:
1073
+ proc.stdin.write(json.dumps({"message": message, "history": history}) + "\n")
1074
+ proc.stdin.flush()
1075
+ line = proc.stdout.readline()
1076
+ finally:
1077
+ proxied = _proxy.shared().end(_channel) if _channel else None
734
1078
  if not line:
735
1079
  raise RuntimeError("the node bridge closed without answering%s" % _why())
736
1080
  parsed = json.loads(line)
737
1081
  if parsed.get("error"):
738
- raise RuntimeError(str(parsed["error"]))
739
- return parsed.get("response", ""), parsed.get("events", [])
1082
+ message_text = str(parsed["error"])
1083
+ raise RuntimeError(message_text.replace(_channel, "<proxy>") if _channel else message_text)
1084
+ # A role-less event naming a tool is a tool result: given the role so it
1085
+ # counts, displays, and is replaced by the proxy's account like one.
1086
+ own = [
1087
+ dict(e, role="tool_result") if isinstance(e, dict) and not e.get("role") and e.get("tool_name") else e
1088
+ for e in parsed.get("events") or []
1089
+ ]
1090
+ # Returned events are the agent's own claim; an empty list cannot be told
1091
+ # apart from an agent that does not report, so it is not a confirmed zero.
1092
+ # Only tool events are a report: a reply with assistant messages alone
1093
+ # says nothing about tools.
1094
+ reports_tools = any(_is_tool(e) for e in own)
1095
+ reported = (own, {"state": "recorded", "source": "agent", "watched": [], "missing": [], "reason": "", "proxy_version": None}) if reports_tools else None
1096
+ if proxied is None:
1097
+ why = "was switched off for this agent" if _proxy_off() else "could not be loaded"
1098
+ capture = reported[1] if reported else {
1099
+ "state": "not_visible", "source": "agent", "watched": [], "missing": [], "proxy_version": None,
1100
+ "reason": "PreMan's tool proxy %s, and the agent did not report its tool calls" % why,
1101
+ }
1102
+ return parsed.get("response", ""), own, capture
1103
+ events, capture = _proxy.choose(proxied, reported)
1104
+ if capture.get("source") == "proxy":
1105
+ # The proxy's account replaces only the agent's tool events; anything
1106
+ # else it returned (its own messages, notes) is kept, in front.
1107
+ kept = [e for e in own if not _is_tool(e)]
1108
+ events = kept + list(events)
1109
+ return parsed.get("response", ""), events, capture
740
1110
  `;
741
1111
 
742
1112
  /** The node side. Also CLI-owned, for the same reason. */
@@ -790,6 +1160,7 @@ import json
790
1160
  import os
791
1161
  import shutil
792
1162
  import subprocess
1163
+ import sys
793
1164
  import tempfile
794
1165
  from pathlib import Path
795
1166
 
@@ -910,7 +1281,7 @@ def _read_events(path):
910
1281
  """
911
1282
  if not os.path.exists(path):
912
1283
  return [], _not_visible("the agent did not create PREMAN_TOOL_EVENTS_FILE, so its tool calls are not reported")
913
- events = []
1284
+ events, lines = [], []
914
1285
  with open(path, encoding="utf-8") as handle:
915
1286
  for line in handle:
916
1287
  line = line.strip()
@@ -930,15 +1301,68 @@ def _read_events(path):
930
1301
  "tool_args": args if isinstance(args, dict) else {},
931
1302
  "content": content if isinstance(content, str) else json.dumps(content, ensure_ascii=False, default=str),
932
1303
  })
933
- return events, {"state": "recorded", "source": "events_file", "watched": [], "missing": [], "reason": ""}
1304
+ lines.append(item)
1305
+ return events, {"state": "recorded", "source": "events_file", "watched": [], "missing": [], "reason": "",
1306
+ "trace": _file_trace(lines)}
1307
+
1308
+
1309
+ def _file_trace(lines):
1310
+ """The agent's own links: 'step' and 'parent' only when every line gives them.
1311
+ A link that cannot be checked makes the turn order-only; it is never guessed."""
1312
+ def plain(reason):
1313
+ return {"links": "order_only", "reason": reason, "known": [], "unknown": {},
1314
+ "calls": [{"id": "c%d" % (i + 1), "tool": str(item["tool_name"]), "event": i}
1315
+ for i, item in enumerate(lines)]}
1316
+ known = [f for f in ("step", "parent") if lines and all(f in item for item in lines)]
1317
+ if not known:
1318
+ return plain("the agent's events file gives no step or parent")
1319
+ ids = [str(item.get("id") or "") for item in lines]
1320
+ if "parent" in known:
1321
+ if not all(ids) or len(set(ids)) != len(ids):
1322
+ return plain("the agent's events file gives parents but not a unique id on every line")
1323
+ for i, item in enumerate(lines):
1324
+ if item["parent"] is not None and str(item["parent"]) not in ids[:i]:
1325
+ return plain("the agent's events file has a parent that names no earlier call")
1326
+ 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):
1327
+ return plain("the agent's events file has a step that is not a whole number from 1")
1328
+ rename = {cid: "c%d" % (i + 1) for i, cid in enumerate(ids)}
1329
+ calls = []
1330
+ for i, item in enumerate(lines):
1331
+ call = {"id": "c%d" % (i + 1), "tool": str(item["tool_name"]), "event": i}
1332
+ if "step" in known:
1333
+ call["step"] = item["step"]
1334
+ if "parent" in known:
1335
+ call["parent"] = None if item["parent"] is None else rename[str(item["parent"])]
1336
+ calls.append(call)
1337
+ return {"links": "full", "known": known,
1338
+ "unknown": {f: "the agent's events file does not give it" for f in ("step", "parent") if f not in known},
1339
+ "calls": calls}
1340
+
1341
+
1342
+ #: Commands that run the agent on another machine or in a container, where
1343
+ #: 127.0.0.1 is not this machine and the proxy cannot be reached.
1344
+ _REMOTE_LAUNCHERS = ("docker", "podman", "ssh", "kubectl")
1345
+
1346
+
1347
+ def _proxy_off() -> bool:
1348
+ return os.environ.get("PREMAN_TOOL_PROXY") == "0" or os.path.basename(_ARGV[0]) in _REMOTE_LAUNCHERS
934
1349
 
935
1350
 
936
1351
  def _turn(message, history):
937
1352
  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.
1353
+ # One file and one proxy channel per turn: each turn is its own process, so
1354
+ # nothing can be filed under the wrong turn.
940
1355
  folder = tempfile.mkdtemp(prefix="preman-tools-")
941
1356
  events_path = os.path.join(folder, "tool_events.jsonl")
1357
+ env = dict(os.environ, PREMAN_TOOL_EVENTS_FILE=events_path)
1358
+ switched_off = _proxy_off()
1359
+ proxy_mod = None if switched_off else _tool_proxy()
1360
+ proxy = proxy_mod.shared() if proxy_mod else None
1361
+ channel = proxy.channel() if proxy else None
1362
+ if channel:
1363
+ env.update(proxy.env(channel))
1364
+ proxy.begin(channel)
1365
+ proxied = None
942
1366
  try:
943
1367
  done = subprocess.run(
944
1368
  argv,
@@ -947,15 +1371,33 @@ def _turn(message, history):
947
1371
  text=True,
948
1372
  cwd=str(_REPO),
949
1373
  timeout=_TIMEOUT,
950
- env=dict(os.environ, PREMAN_TOOL_EVENTS_FILE=events_path),
1374
+ env=env,
951
1375
  )
1376
+ if channel:
1377
+ proxied = proxy.end(channel)
952
1378
  if done.returncode != 0:
953
- tail = (done.stderr or "").strip()[-600:]
1379
+ tail = (done.stderr or "").strip()
1380
+ if channel:
1381
+ tail = tail.replace(channel, "<proxy>")
1382
+ tail = tail[-600:]
954
1383
  raise RuntimeError("%s exited %s: %s" % (_ARGV[0], done.returncode, tail))
955
- events, capture = _read_events(events_path)
1384
+ reported = _read_events(events_path)
956
1385
  finally:
1386
+ if channel:
1387
+ proxy.drop(channel)
957
1388
  shutil.rmtree(folder, ignore_errors=True)
958
- return done.stdout.strip(), events, capture
1389
+ if proxied is None:
1390
+ events, capture = reported
1391
+ capture = dict(capture, proxy_version=None)
1392
+ if capture["state"] != "recorded":
1393
+ why = "was switched off for this agent" if switched_off else "could not be loaded"
1394
+ capture["reason"] = "PreMan's tool proxy %s; %s" % (why, capture["reason"])
1395
+ else:
1396
+ events, capture = proxy_mod.choose(proxied, reported)
1397
+ reply = done.stdout.strip()
1398
+ if channel:
1399
+ reply = reply.replace(channel, "<proxy>")
1400
+ return reply, events, capture
959
1401
  `;
960
1402
 
961
1403
  // ── Assembly ────────────────────────────────────────────────────────────
@@ -1008,7 +1450,7 @@ export function buildWrapper(found, { tree = [] } = {}) {
1008
1450
 
1009
1451
  if (shape === "cli_subprocess") {
1010
1452
  return {
1011
- shim: header + CLI_SUBPROCESS.replace("%%COMMAND%%", litList(command)) + PY_NORMALISE + PY_CHAT_REPORTED,
1453
+ shim: header + CLI_SUBPROCESS.replace("%%COMMAND%%", litList(command)) + PY_PROXY + PY_NORMALISE + PY_CHAT_REPORTED,
1012
1454
  bridge: "",
1013
1455
  };
1014
1456
  }
@@ -1027,8 +1469,9 @@ export function buildWrapper(found, { tree = [] } = {}) {
1027
1469
  + NODE_BRIDGE
1028
1470
  .replace("%%NODE_RUNTIME%%", lit(runtime))
1029
1471
  .replace("%%ENTRY_SYMBOL%%", lit(symbol))
1472
+ + PY_PROXY
1030
1473
  + PY_NORMALISE
1031
- + PY_CHAT;
1474
+ + PY_CHAT_REPORTED;
1032
1475
  const bridge = NODE_SIDE
1033
1476
  .replace(/%%NODE_ENTRY%%/g, lit("../" + nodeEntry))
1034
1477
  .replace(/%%ENTRY_SYMBOL%%/g, lit(symbol));
@@ -1049,9 +1492,10 @@ export function buildWrapper(found, { tree = [] } = {}) {
1049
1492
  .replace("%%ENTRY_SYMBOL%%", lit(symbol));
1050
1493
 
1051
1494
  // 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()`.
1495
+ // design), not by wrapping functions, so its `_turn` returns the capture and
1496
+ // it uses `chat()` from PY_CHAT_REPORTED.
1053
1497
  if (shape === "langgraph") {
1054
- return { shim: header + loader + turn + PY_NORMALISE + PY_CHAT, bridge: "" };
1498
+ return { shim: header + loader + turn + PY_NORMALISE + PY_CHAT_REPORTED, bridge: "" };
1055
1499
  }
1056
1500
  // Checked only for the shapes that use it, so a tool path this shape never
1057
1501
  // reads cannot fail the build. Every value is checked before it is quoted