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 +10 -7
- package/bin/eval_entry.js +9 -2
- package/bin/eval_harness.py +146 -2
- package/bin/eval_target.js +24 -1
- package/bin/eval_templates.js +899 -29
- package/bin/shared.js +5 -0
- package/bin/tool_proxy.py +828 -0
- package/package.json +1 -1
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
|
-
//
|
|
1992
|
-
// runs inside the harness's Python process; a node bridge or a
|
|
1993
|
-
// is invisible to in-process tracing, and tracing it would only
|
|
1994
|
-
// adapter's reported tool calls with nothing.
|
|
1995
|
-
|
|
1996
|
-
|
|
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
|
-
|
|
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 {
|
|
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")
|
package/bin/eval_harness.py
CHANGED
|
@@ -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":
|
|
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
|
-
|
|
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
|
package/bin/eval_target.js
CHANGED
|
@@ -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)
|
|
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,
|