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.
@@ -482,6 +482,118 @@ 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 (
574
+ span_trace is None
575
+ and isinstance(capture, dict)
576
+ and capture.get("source") == "decorated"
577
+ and capture.get("state") == "recorded"
578
+ and capture.get("trace") is None
579
+ ):
580
+ # Every watched tool records itself, so no span is a confirmed
581
+ # zero, not a turn whose calls carry no trace.
582
+ capture["trace"] = {"links": "full", "known": ["step", "parent", "started", "ended"],
583
+ "unknown": {}, "calls": []}
584
+ return
585
+ if not isinstance(capture, dict) or span_trace is None:
586
+ return
587
+ own = capture.get("trace")
588
+ if own is None:
589
+ capture["trace"] = span_trace
590
+ elif (
591
+ capture.get("source") == "langgraph"
592
+ and isinstance(own, dict)
593
+ and len(own.get("calls") or []) == len(span_trace["calls"])
594
+ ):
595
+ capture["trace"] = span_trace
596
+
485
597
  async def _traced_open_unpacking(self):
486
598
  await _traced_open(self)
487
599
  agent = self._callable
@@ -505,6 +617,8 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
505
617
 
506
618
  async def _traced_run_turn_with_evidence(self, messages):
507
619
  self._preman_payload = None
620
+ holder = []
621
+ _span_trace.set(holder)
508
622
  try:
509
623
  result = await _traced_run_turn(self, messages)
510
624
  except Exception as exc:
@@ -514,6 +628,10 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
514
628
  raise classified from exc
515
629
 
516
630
  payload, self._preman_payload = self._preman_payload, None
631
+ try:
632
+ _use_span_trace(payload, holder)
633
+ except Exception:
634
+ pass # a trace that cannot be chosen must never fail the turn
517
635
  _attach_capture(result, payload)
518
636
  if payload is None or any(m.get("role") == "tool" for m in result.interaction_messages):
519
637
  return result # nothing reported, or the spans already carry the tools
@@ -579,7 +697,14 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
579
697
  and a.start_time_ns <= b.start_time_ns and b.end_time_ns <= a.end_time_ns
580
698
  and (a.start_time_ns, a.end_time_ns) != (b.start_time_ns, b.end_time_ns)):
581
699
  outer.add((a.trace_id, a.span_id))
582
- return _spans_to_events([s for s in spans if (s.trace_id, s.span_id) not in outer])
700
+ kept = [s for s in spans if (s.trace_id, s.span_id) not in outer]
701
+ holder = _span_trace.get()
702
+ if holder is not None:
703
+ try:
704
+ holder.append(_trace_from_spans(kept))
705
+ except Exception:
706
+ pass # a trace that cannot be built must never fail the turn
707
+ return _spans_to_events(kept)
583
708
 
584
709
  # Resolved at call time: otel_session imported the name into its own module.
585
710
  _otel_session._spans_to_events = _spans_to_events_once
@@ -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 the tool-capture wrapper applies to (PY_CAPTURE in eval_templates.js). */
211
- const CAPTURE_SHAPES = new Set(["py_history", "py_plain", "py_class", "other"]);
210
+ /** Shapes whose wrapper records tool calls: PY_DECORATED, `_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,19 @@ 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 this at
231
- // module scope -- see PY_CAPTURE in eval_templates.js). A wrapper approved
232
- // before that change has no such line and keeps matching its digest, so this
233
- // is read from the file rather than the approval record.
234
- const capturesTools = /^_TOOLS = /m.test(source);
231
+ // shape but `node_bridge`, `cli_subprocess` and `langgraph` defines
232
+ // `_DECORATED` at module scope -- see PY_DECORATED in eval_templates.js;
233
+ // `_TOOLS` is still recognised for wrappers approved before decorators;
234
+ // `langgraph` reads calls from the graph's messages in `_graph_calls`;
235
+ // `node_bridge` and `cli_subprocess` load the recording proxy in
236
+ // `_tool_proxy`). A wrapper approved before that change has no such line and
237
+ // keeps matching its digest, so this is read from the file rather than the
238
+ // approval record.
239
+ const capturesTools =
240
+ /^_DECORATED = /m.test(source) ||
241
+ /^_TOOLS = /m.test(source) ||
242
+ /^def _graph_calls\(/m.test(source) ||
243
+ /^def _tool_proxy\(/m.test(source);
235
244
 
236
245
  // Every file the approval produced, not only the wrapper.
237
246
  //