premanmcp 1.1.12 → 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.js CHANGED
@@ -1988,13 +1988,16 @@ async function runWithToken(
1988
1988
  // import path, so the harness reads this and inserts it before handing off.
1989
1989
  process.env.PREMAN_EVAL_SYS_PATH = target.sysPath;
1990
1990
  }
1991
- // Opt-in for now (docs/EVAL_AGENT_TRACING.md). Kept only for an agent that
1992
- // runs inside the harness's Python process; a node bridge or a subprocess
1993
- // is invisible to in-process tracing, and tracing it would only replace the
1994
- // adapter's reported tool calls with nothing.
1995
- if (process.env.PREMAN_EVAL_TRACE === "1" && !tracesInProcess(target)) {
1996
- say(`tracing is not available for this agent (${target.language || "?"} / ${target.shape || "?"}); running untraced`);
1997
- process.env.PREMAN_EVAL_TRACE = "0";
1991
+ // On unless PREMAN_EVAL_TRACE=0 (docs/EVAL_AGENT_TRACING.md). Kept only for an
1992
+ // agent that runs inside the harness's Python process; a node bridge or a
1993
+ // subprocess is invisible to in-process tracing, and tracing it would only
1994
+ // replace the adapter's reported tool calls with nothing.
1995
+ const traceAsked = process.env.PREMAN_EVAL_TRACE;
1996
+ if (traceAsked !== "0") {
1997
+ process.env.PREMAN_EVAL_TRACE = tracesInProcess(target) ? "1" : "0";
1998
+ if (traceAsked === "1" && !tracesInProcess(target)) {
1999
+ say(`tracing is not available for this agent (${target.language || "?"} / ${target.shape || "?"}); running untraced`);
2000
+ }
1998
2001
  }
1999
2002
 
2000
2003
  if (adapter.translated) {
package/bin/eval_entry.js CHANGED
@@ -52,7 +52,10 @@ export const ENTRY_FILE = `${ENTRY_MODULE}.py`;
52
52
  export const BRIDGE_FILE = "agent_bridge.mjs";
53
53
 
54
54
  const SEARCH_TIMEOUT_MS = 120_000;
55
- const PROBE_TIMEOUT_MS = 60_000;
55
+ // One real turn, after importing the customer's agent. GPUScraper alone takes
56
+ // 37s to import from /mnt/c (2,227 modules), so 60s failed on the import plus
57
+ // one model call. PREMAN_PROBE_TIMEOUT_S overrides for slower repos.
58
+ const PROBE_TIMEOUT_MS = (Number(process.env.PREMAN_PROBE_TIMEOUT_S) || 180) * 1000;
56
59
  const MAX_TREE_PATHS = 4_000;
57
60
  const MAX_TREE_BYTES = 120_000;
58
61
  const MAX_SHORTLIST = 12;
@@ -392,7 +395,11 @@ export async function validateShim(file, { projectPath, log }) {
392
395
  cwd: projectPath,
393
396
  });
394
397
  if (ran.error?.code === "ETIMEDOUT") {
395
- return { ok: false, why: "it did not answer within 60 seconds" };
398
+ return {
399
+ ok: false,
400
+ why: `it did not answer within ${PROBE_TIMEOUT_MS / 1000} seconds (importing the agent ` +
401
+ `counts; set PREMAN_PROBE_TIMEOUT_S to allow longer)`,
402
+ };
396
403
  }
397
404
  const line = String(ran.stdout || "")
398
405
  .split("\n")
@@ -304,6 +304,36 @@ def _is_adapter_payload(value) -> bool:
304
304
  return isinstance(value, dict) and isinstance(value.get("response"), str)
305
305
 
306
306
 
307
+ def _turn_raw(ref, raw_result):
308
+ """The turn's raw record, plus the wrapper's capture outcome when it sent one.
309
+
310
+ Carried on ``raw`` because assert-ai stores it verbatim and owns every other
311
+ field of the artifact; the backend reads it back per turn
312
+ (docs/superpowers/specs/2026-09-30-tool-call-capture-design.md §7.2).
313
+ """
314
+ raw = {"callable": ref}
315
+ capture = raw_result.get("tool_capture")
316
+ if isinstance(capture, dict):
317
+ raw["tool_capture"] = _session._sanitize_endpoint_value(capture)
318
+ return raw
319
+
320
+
321
+ def _attach_capture(result, payload):
322
+ """Carry the wrapper's tool_capture onto a traced turn.
323
+
324
+ A traced turn's stored record is rebuilt by assert-ai from spans
325
+ (trace_events / trace_metadata / span_validation), so the record
326
+ ``_turn_raw`` writes on the untraced path never reaches it.
327
+ """
328
+ capture = payload.get("tool_capture") if isinstance(payload, dict) else None
329
+ if not isinstance(capture, dict):
330
+ return
331
+ for message in reversed(result.interaction_messages):
332
+ if message.get("role") == "assistant" and isinstance(message.get("raw"), dict):
333
+ message["raw"]["tool_capture"] = _session._sanitize_endpoint_value(capture)
334
+ return
335
+
336
+
307
337
  async def _callable_run_turn(self, messages: list[Message]) -> TurnResult:
308
338
  user_text = ""
309
339
  for msg in reversed(messages):
@@ -380,7 +410,7 @@ async def _callable_run_turn(self, messages: list[Message]) -> TurnResult:
380
410
  response = _session._normalize_connector_response({
381
411
  "text": _session._sanitize_response_text(raw_result["response"]),
382
412
  "events": _session._sanitize_endpoint_value(raw_result.get("events")),
383
- "raw": {"callable": self._callable_ref},
413
+ "raw": _turn_raw(self._callable_ref, raw_result),
384
414
  })
385
415
  interaction_messages = _session._serialize_connector_interaction_messages(
386
416
  user_text=user_text,
@@ -452,6 +482,106 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
452
482
  _traced_open = _Traced.open
453
483
  _traced_run_turn = _Traced.run_turn
454
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
+
455
585
  async def _traced_open_unpacking(self):
456
586
  await _traced_open(self)
457
587
  agent = self._callable
@@ -475,6 +605,8 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
475
605
 
476
606
  async def _traced_run_turn_with_evidence(self, messages):
477
607
  self._preman_payload = None
608
+ holder = []
609
+ _span_trace.set(holder)
478
610
  try:
479
611
  result = await _traced_run_turn(self, messages)
480
612
  except Exception as exc:
@@ -484,6 +616,11 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
484
616
  raise classified from exc
485
617
 
486
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
623
+ _attach_capture(result, payload)
487
624
  if payload is None or any(m.get("role") == "tool" for m in result.interaction_messages):
488
625
  return result # nothing reported, or the spans already carry the tools
489
626
 
@@ -548,7 +685,14 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
548
685
  and a.start_time_ns <= b.start_time_ns and b.end_time_ns <= a.end_time_ns
549
686
  and (a.start_time_ns, a.end_time_ns) != (b.start_time_ns, b.end_time_ns)):
550
687
  outer.add((a.trace_id, a.span_id))
551
- return _spans_to_events([s for s in spans if (s.trace_id, s.span_id) not in outer])
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)
552
696
 
553
697
  # Resolved at call time: otel_session imported the name into its own module.
554
698
  _otel_session._spans_to_events = _spans_to_events_once
@@ -160,7 +160,15 @@ export async function discoverTarget(args, { log = () => {}, allowSearch = true
160
160
  const saved = savedEntry(projectPath);
161
161
  if (saved) {
162
162
  const verified = verifyEntry(saved);
163
- if (verified) return verified;
163
+ if (verified) {
164
+ if (CAPTURE_SHAPES.has(verified.shape) && !verified.capturesTools) {
165
+ log(
166
+ "this agent's wrapper was approved before PreMan recorded tool calls; " +
167
+ "run `preman eval target` again to record them"
168
+ );
169
+ }
170
+ return verified;
171
+ }
164
172
  log(
165
173
  `The approved entry point ${saved.file} has changed since it was approved, ` +
166
174
  `so it is being re-checked rather than trusted.`
@@ -199,6 +207,10 @@ export async function discoverTarget(args, { log = () => {}, allowSearch = true
199
207
  return searchForEntry({ projectPath, args, log });
200
208
  }
201
209
 
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"]);
213
+
202
214
  /**
203
215
  * The approved shim, if it is still the file that was approved.
204
216
  *
@@ -215,6 +227,16 @@ export function verifyEntry(saved) {
215
227
  const digest = createHash("sha256").update(source).digest("hex");
216
228
  if (saved.sha256 && digest !== saved.sha256) return null;
217
229
 
230
+ // True for a wrapper the CLI generated with tool capture wired in (every
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);
239
+
218
240
  // Every file the approval produced, not only the wrapper.
219
241
  //
220
242
  // A `node_bridge` target is two files: the Python wrapper and the `.mjs` it
@@ -246,6 +268,7 @@ export function verifyEntry(saved) {
246
268
  sysPath,
247
269
  sha256: digest,
248
270
  files: extra,
271
+ capturesTools,
249
272
  shape: saved.shape || "",
250
273
  language: saved.language,
251
274
  framework: saved.framework,