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_harness.py
CHANGED
|
@@ -482,6 +482,106 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
|
|
|
482
482
|
_traced_open = _Traced.open
|
|
483
483
|
_traced_run_turn = _Traced.run_turn
|
|
484
484
|
|
|
485
|
+
import contextvars
|
|
486
|
+
|
|
487
|
+
# Filled by _spans_to_events_once with the trace its spans give; read by
|
|
488
|
+
# the run_turn that set it (docs/superpowers/specs/2026-09-30-tool-call-capture-design.md §7.5).
|
|
489
|
+
_span_trace = contextvars.ContextVar("preman_span_trace", default=None)
|
|
490
|
+
|
|
491
|
+
def _trace_from_spans(spans):
|
|
492
|
+
"""step / parent / times for the turn's TOOL spans. 'event' is a span's
|
|
493
|
+
position among them, the order assert-ai turns them into tool events."""
|
|
494
|
+
tools = [s for s in spans if s.kind == "TOOL"]
|
|
495
|
+
if not tools:
|
|
496
|
+
return None
|
|
497
|
+
if any(getattr(s, "convention", None) == "gen_ai" for s in spans):
|
|
498
|
+
# assert-ai builds gen_ai tool events from the model's own tool-call
|
|
499
|
+
# list, not one per TOOL span, so 'event' would not line up.
|
|
500
|
+
return None
|
|
501
|
+
by_id = {(s.trace_id, s.span_id): s for s in spans}
|
|
502
|
+
|
|
503
|
+
def depth(span):
|
|
504
|
+
n, hop = 0, by_id.get((span.trace_id, span.parent_span_id))
|
|
505
|
+
while hop is not None and n < len(spans):
|
|
506
|
+
n, hop = n + 1, by_id.get((hop.trace_id, hop.parent_span_id))
|
|
507
|
+
return n
|
|
508
|
+
|
|
509
|
+
# Start time, then depth: a call and one it makes can start in the same
|
|
510
|
+
# nanosecond, and the outer one must be numbered (and seen) first.
|
|
511
|
+
order = sorted(tools, key=lambda s: (s.start_time_ns, depth(s)))
|
|
512
|
+
cid = {(s.trace_id, s.span_id): "c%d" % (i + 1) for i, s in enumerate(order)}
|
|
513
|
+
key_of = {v: k for k, v in cid.items()}
|
|
514
|
+
base = min(s.start_time_ns for s in spans)
|
|
515
|
+
llm = sorted((s for s in spans if s.kind == "LLM"), key=lambda s: s.end_time_ns)
|
|
516
|
+
unknown, parents, steps = {}, {}, {}
|
|
517
|
+
# "The reply that finished last before the tool started" holds for one
|
|
518
|
+
# agent loop; two model calls overlapping in time could pick the wrong one.
|
|
519
|
+
if any(a.start_time_ns < b.end_time_ns and b.start_time_ns < a.end_time_ns
|
|
520
|
+
for i, a in enumerate(llm) for b in llm[i + 1:]):
|
|
521
|
+
unknown["step"] = "two model calls in this turn overlapped, so which one asked for each tool is unclear"
|
|
522
|
+
for s in order:
|
|
523
|
+
key = (s.trace_id, s.span_id)
|
|
524
|
+
hop = by_id.get((s.trace_id, s.parent_span_id))
|
|
525
|
+
while hop is not None and hop.kind != "TOOL":
|
|
526
|
+
hop = by_id.get((hop.trace_id, hop.parent_span_id))
|
|
527
|
+
if hop is None and _dangling(s, by_id):
|
|
528
|
+
unknown["parent"] = "a tool span's parent was not among this turn's spans"
|
|
529
|
+
parents[key] = cid.get((hop.trace_id, hop.span_id)) if hop is not None else None
|
|
530
|
+
if parents[key] is not None:
|
|
531
|
+
steps[key] = steps[key_of[parents[key]]] # a nested call shares its parent's step
|
|
532
|
+
continue
|
|
533
|
+
asked = [m for m in llm if m.end_time_ns <= s.start_time_ns]
|
|
534
|
+
if not asked:
|
|
535
|
+
unknown.setdefault("step", "a tool ran before any model reply in this turn")
|
|
536
|
+
steps[key] = (asked[-1].trace_id, asked[-1].span_id) if asked else None
|
|
537
|
+
numbering = {}
|
|
538
|
+
calls = []
|
|
539
|
+
for s in order:
|
|
540
|
+
key = (s.trace_id, s.span_id)
|
|
541
|
+
a = s.attributes
|
|
542
|
+
call = {
|
|
543
|
+
"id": cid[key],
|
|
544
|
+
"tool": str(a.get("tool.name", s.name)), # the name assert-ai stores
|
|
545
|
+
"event": tools.index(s),
|
|
546
|
+
"started": round((s.start_time_ns - base) / 1e9, 3),
|
|
547
|
+
"ended": round((s.end_time_ns - base) / 1e9, 3),
|
|
548
|
+
}
|
|
549
|
+
if "step" not in unknown:
|
|
550
|
+
call["step"] = numbering.setdefault(steps[key], len(numbering) + 1)
|
|
551
|
+
if "parent" not in unknown:
|
|
552
|
+
call["parent"] = parents[key]
|
|
553
|
+
calls.append(call)
|
|
554
|
+
known = [f for f in ("step", "parent") if f not in unknown] + ["started", "ended"]
|
|
555
|
+
return {"links": "full", "known": known, "unknown": unknown, "calls": calls}
|
|
556
|
+
|
|
557
|
+
def _dangling(span, by_id):
|
|
558
|
+
# True when some span above this one is missing from the turn's spans.
|
|
559
|
+
hop = span
|
|
560
|
+
while hop.parent_span_id:
|
|
561
|
+
up = by_id.get((hop.trace_id, hop.parent_span_id))
|
|
562
|
+
if up is None:
|
|
563
|
+
return True
|
|
564
|
+
hop = up
|
|
565
|
+
return False
|
|
566
|
+
|
|
567
|
+
def _use_span_trace(payload, holder):
|
|
568
|
+
# Spans see LangChain / LangGraph tools the wrapper cannot, and give
|
|
569
|
+
# parent as well as step. A wrapper trace of plain functions is kept; a
|
|
570
|
+
# LangGraph message trace is replaced only when both count the same calls.
|
|
571
|
+
capture = payload.get("tool_capture") if isinstance(payload, dict) else None
|
|
572
|
+
span_trace = next((t for t in reversed(holder) if t), None)
|
|
573
|
+
if not isinstance(capture, dict) or span_trace is None:
|
|
574
|
+
return
|
|
575
|
+
own = capture.get("trace")
|
|
576
|
+
if own is None:
|
|
577
|
+
capture["trace"] = span_trace
|
|
578
|
+
elif (
|
|
579
|
+
capture.get("source") == "langgraph"
|
|
580
|
+
and isinstance(own, dict)
|
|
581
|
+
and len(own.get("calls") or []) == len(span_trace["calls"])
|
|
582
|
+
):
|
|
583
|
+
capture["trace"] = span_trace
|
|
584
|
+
|
|
485
585
|
async def _traced_open_unpacking(self):
|
|
486
586
|
await _traced_open(self)
|
|
487
587
|
agent = self._callable
|
|
@@ -505,6 +605,8 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
|
|
|
505
605
|
|
|
506
606
|
async def _traced_run_turn_with_evidence(self, messages):
|
|
507
607
|
self._preman_payload = None
|
|
608
|
+
holder = []
|
|
609
|
+
_span_trace.set(holder)
|
|
508
610
|
try:
|
|
509
611
|
result = await _traced_run_turn(self, messages)
|
|
510
612
|
except Exception as exc:
|
|
@@ -514,6 +616,10 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
|
|
|
514
616
|
raise classified from exc
|
|
515
617
|
|
|
516
618
|
payload, self._preman_payload = self._preman_payload, None
|
|
619
|
+
try:
|
|
620
|
+
_use_span_trace(payload, holder)
|
|
621
|
+
except Exception:
|
|
622
|
+
pass # a trace that cannot be chosen must never fail the turn
|
|
517
623
|
_attach_capture(result, payload)
|
|
518
624
|
if payload is None or any(m.get("role") == "tool" for m in result.interaction_messages):
|
|
519
625
|
return result # nothing reported, or the spans already carry the tools
|
|
@@ -579,7 +685,14 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
|
|
|
579
685
|
and a.start_time_ns <= b.start_time_ns and b.end_time_ns <= a.end_time_ns
|
|
580
686
|
and (a.start_time_ns, a.end_time_ns) != (b.start_time_ns, b.end_time_ns)):
|
|
581
687
|
outer.add((a.trace_id, a.span_id))
|
|
582
|
-
|
|
688
|
+
kept = [s for s in spans if (s.trace_id, s.span_id) not in outer]
|
|
689
|
+
holder = _span_trace.get()
|
|
690
|
+
if holder is not None:
|
|
691
|
+
try:
|
|
692
|
+
holder.append(_trace_from_spans(kept))
|
|
693
|
+
except Exception:
|
|
694
|
+
pass # a trace that cannot be built must never fail the turn
|
|
695
|
+
return _spans_to_events(kept)
|
|
583
696
|
|
|
584
697
|
# Resolved at call time: otel_session imported the name into its own module.
|
|
585
698
|
_otel_session._spans_to_events = _spans_to_events_once
|
package/bin/eval_target.js
CHANGED
|
@@ -207,8 +207,9 @@ export async function discoverTarget(args, { log = () => {}, allowSearch = true
|
|
|
207
207
|
return searchForEntry({ projectPath, args, log });
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
/** Shapes
|
|
211
|
-
|
|
210
|
+
/** Shapes whose wrapper records tool calls: PY_CAPTURE, `_graph_calls`, or the
|
|
211
|
+
* recording proxy (PY_PROXY) in eval_templates.js. */
|
|
212
|
+
const CAPTURE_SHAPES = new Set(["py_history", "py_plain", "py_class", "other", "langgraph", "node_bridge", "cli_subprocess"]);
|
|
212
213
|
|
|
213
214
|
/**
|
|
214
215
|
* The approved shim, if it is still the file that was approved.
|
|
@@ -227,11 +228,14 @@ export function verifyEntry(saved) {
|
|
|
227
228
|
if (saved.sha256 && digest !== saved.sha256) return null;
|
|
228
229
|
|
|
229
230
|
// True for a wrapper the CLI generated with tool capture wired in (every
|
|
230
|
-
// shape but `node_bridge`, `cli_subprocess` and `langgraph` defines
|
|
231
|
-
// module scope -- see PY_CAPTURE in eval_templates.js
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
231
|
+
// shape but `node_bridge`, `cli_subprocess` and `langgraph` defines `_TOOLS`
|
|
232
|
+
// at module scope -- see PY_CAPTURE in eval_templates.js; `langgraph` reads
|
|
233
|
+
// calls from the graph's messages in `_graph_calls`; `node_bridge` and
|
|
234
|
+
// `cli_subprocess` load the recording proxy in `_tool_proxy`). A wrapper
|
|
235
|
+
// approved before that change has no such line and keeps matching its
|
|
236
|
+
// digest, so this is read from the file rather than the approval record.
|
|
237
|
+
const capturesTools =
|
|
238
|
+
/^_TOOLS = /m.test(source) || /^def _graph_calls\(/m.test(source) || /^def _tool_proxy\(/m.test(source);
|
|
235
239
|
|
|
236
240
|
// Every file the approval produced, not only the wrapper.
|
|
237
241
|
//
|