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.
- package/bin/eval_harness.py +114 -1
- package/bin/eval_target.js +11 -7
- package/bin/eval_templates.js +488 -44
- package/bin/shared.js +5 -0
- package/bin/tool_proxy.py +828 -0
- package/package.json +1 -1
package/bin/eval_templates.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
506
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|
|
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=
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
+
|
|
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
|
|
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 +
|
|
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
|