premanmcp 1.1.11 → 1.1.13

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.
@@ -73,10 +73,39 @@ edit made under .venv/.
73
73
  kept on ``self`` between the call and its use can be overwritten by another
74
74
  turn. A replacement holds the payload in a local.
75
75
 
76
+ 6. A traced callable target -- opt-in, ``PREMAN_EVAL_TRACE=1``, set by the CLI
77
+ only for an agent that runs in this process (docs/EVAL_AGENT_TRACING.md).
78
+ With ``target.trace`` in the spec, assert-ai uses ``OTelTracedSession``,
79
+ which workaround 5 never reaches. Three things are done for it:
80
+
81
+ (a) PreMan's own model calls -- tester, judges, rubric -- run with tracing
82
+ suppressed. assert-ai files spans under a turn by time window, so with
83
+ ``concurrency`` above 1 another case's tester call lands in this agent's
84
+ trace. Their cost is still counted, by assert-ai's own accounting, into
85
+ ``metrics.json``.
86
+ (b) The traced session gets workaround 5's treatment: an adapter's
87
+ ``{"response", "events"}`` is unpacked instead of ``str()``'d into the
88
+ transcript, the tool calls it reports are kept when the spans carry
89
+ none, and a provider error is classified rather than crashing the case.
90
+ The reply is kept on the session between the call and its use, which (5)
91
+ explains is unsafe for ``CallableSession`` and is safe here: the
92
+ inference stage builds one ``OTelTracedSession`` per case and runs its
93
+ turns one after another.
94
+ (c) A model call is counted once. A framework's plugin (litellm, LangChain,
95
+ LlamaIndex) and the provider SDK's plugin each record the same call with
96
+ the same tokens, so a turn's spans are filtered before assert-ai
97
+ flattens them: a model-call span with another model-call span beneath
98
+ it is dropped, keeping the innermost -- the SDK's, which carries the
99
+ provider's response id and model name. "Beneath" is the parent link
100
+ where the plugin records one (litellm); LangChain and LlamaIndex do not,
101
+ so there it is a span with the same tokens inside its time window.
102
+
76
103
  Workarounds (1)-(4) go away upstream: (1) with a workspace-scoped API key, (2),
77
104
  (3) and (4) with a fix in AleWang16/ASSERT plus a bumped rev in pyproject.toml.
78
105
  (5) goes away when ``CallableSession`` serialises tool evidence the way
79
- ``HTTPEndpointSession`` already does.
106
+ ``HTTPEndpointSession`` already does. (6a) goes away when assert-ai stops
107
+ tracing its own calls, (6b) with (5), (6c) when assert-ai's
108
+ ``_spans_to_events`` drops nested model-call spans itself.
80
109
 
81
110
  This file exists twice. ``preman-mcp/bin/eval_harness.py`` is a byte-identical
82
111
  copy, shipped in the npm package so a run leased to a paired device goes through
@@ -275,6 +304,36 @@ def _is_adapter_payload(value) -> bool:
275
304
  return isinstance(value, dict) and isinstance(value.get("response"), str)
276
305
 
277
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
+
278
337
  async def _callable_run_turn(self, messages: list[Message]) -> TurnResult:
279
338
  user_text = ""
280
339
  for msg in reversed(messages):
@@ -351,7 +410,7 @@ async def _callable_run_turn(self, messages: list[Message]) -> TurnResult:
351
410
  response = _session._normalize_connector_response({
352
411
  "text": _session._sanitize_response_text(raw_result["response"]),
353
412
  "events": _session._sanitize_endpoint_value(raw_result.get("events")),
354
- "raw": {"callable": self._callable_ref},
413
+ "raw": _turn_raw(self._callable_ref, raw_result),
355
414
  })
356
415
  interaction_messages = _session._serialize_connector_interaction_messages(
357
416
  user_text=user_text,
@@ -392,5 +451,140 @@ for _root in os.environ.get("PREMAN_EVAL_SYS_PATH", "").split(os.pathsep):
392
451
 
393
452
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
394
453
 
395
- _cli = next(e for e in entry_points(group="console_scripts") if e.name == "assert-ai")
396
- sys.exit(_cli.load()())
454
+ from eval_generation_debug import install as _install_generation_debug
455
+ from eval_generation_status import install as _install_generation_status
456
+
457
+ _install_generation_debug()
458
+ _install_generation_status()
459
+
460
+ # Workaround 6 -- see the module docstring.
461
+ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
462
+ import functools
463
+ import inspect
464
+
465
+ try:
466
+ from openinference.instrumentation import suppress_tracing as _suppress_tracing
467
+ except ImportError: # no plugin installed: nothing would record PreMan's calls anyway
468
+ _suppress_tracing = None
469
+
470
+ if _suppress_tracing is not None:
471
+ _with_retries = model_client._with_retries
472
+
473
+ async def _with_retries_untraced(call_fn, *args, **kwargs):
474
+ with _suppress_tracing():
475
+ return await _with_retries(call_fn, *args, **kwargs)
476
+
477
+ # Rebound as a module attribute: every call site resolves it at call time.
478
+ model_client._with_retries = _with_retries_untraced
479
+
480
+ from assert_ai.core.otel_session import OTelTracedSession as _Traced
481
+
482
+ _traced_open = _Traced.open
483
+ _traced_run_turn = _Traced.run_turn
484
+
485
+ async def _traced_open_unpacking(self):
486
+ await _traced_open(self)
487
+ agent = self._callable
488
+
489
+ def _unpack(result):
490
+ if _is_adapter_payload(result):
491
+ self._preman_payload = result
492
+ return _session._sanitize_response_text(result["response"])
493
+ return result
494
+
495
+ if inspect.iscoroutinefunction(agent):
496
+ @functools.wraps(agent)
497
+ async def _agent(*args, **kwargs):
498
+ return _unpack(await agent(*args, **kwargs))
499
+ else:
500
+ @functools.wraps(agent)
501
+ def _agent(*args, **kwargs):
502
+ return _unpack(agent(*args, **kwargs))
503
+
504
+ self._callable = _agent
505
+
506
+ async def _traced_run_turn_with_evidence(self, messages):
507
+ self._preman_payload = None
508
+ try:
509
+ result = await _traced_run_turn(self, messages)
510
+ except Exception as exc:
511
+ classified = _classify_llm_error(exc)
512
+ if classified is exc:
513
+ raise
514
+ raise classified from exc
515
+
516
+ payload, self._preman_payload = self._preman_payload, None
517
+ _attach_capture(result, payload)
518
+ if payload is None or any(m.get("role") == "tool" for m in result.interaction_messages):
519
+ return result # nothing reported, or the spans already carry the tools
520
+
521
+ response = _session._normalize_connector_response({
522
+ "text": result.text,
523
+ "events": _session._sanitize_endpoint_value(payload.get("events")),
524
+ "raw": {"callable": self._callable_ref},
525
+ })
526
+ reported = [
527
+ m
528
+ for m in _session._serialize_connector_interaction_messages(
529
+ user_text=result.interaction_messages[0].get("content", ""), response=response
530
+ )
531
+ if m.get("role") == "tool" or (m.get("role") == "assistant" and m.get("tool_calls"))
532
+ ]
533
+ result.interaction_messages[1:1] = reported
534
+ result.tool_traces = [
535
+ _session.ToolTrace(
536
+ tool_name=event.tool_name or "tool",
537
+ tool_args=event.tool_args or {},
538
+ tool_result=event.content,
539
+ )
540
+ for event in (response.events or [])
541
+ if event.role == "tool_result"
542
+ ]
543
+ return result
544
+
545
+ _Traced.open = _traced_open_unpacking
546
+ _Traced.run_turn = _traced_run_turn_with_evidence
547
+
548
+ from assert_ai.core import otel_session as _otel_session
549
+
550
+ _spans_to_events = _otel_session._spans_to_events
551
+
552
+ def _span_tokens(span):
553
+ a = span.attributes
554
+ return (a.get("llm.token_count.prompt", a.get("gen_ai.usage.input_tokens")),
555
+ a.get("llm.token_count.completion", a.get("gen_ai.usage.output_tokens")))
556
+
557
+ def _spans_to_events_once(spans):
558
+ by_id = {(s.trace_id, s.span_id): s for s in spans}
559
+ llm = [s for s in spans if s.kind == "LLM"]
560
+ outer = set()
561
+ for span in llm:
562
+ seen, ancestors = set(), []
563
+ parent = by_id.get((span.trace_id, span.parent_span_id))
564
+ while parent is not None and parent is not span and parent.span_id not in seen:
565
+ seen.add(parent.span_id)
566
+ if parent.kind == "LLM":
567
+ ancestors.append((parent.trace_id, parent.span_id))
568
+ parent = by_id.get((parent.trace_id, parent.parent_span_id))
569
+ if parent is not span: # a chain that loops back to this span proves no nesting
570
+ outer.update(ancestors)
571
+ # LangChain and LlamaIndex never make their span the current context, so
572
+ # the SDK's span is not linked beneath it: match by tokens and time instead.
573
+ for a in llm:
574
+ tokens = _span_tokens(a)
575
+ if None in tokens:
576
+ continue
577
+ for b in llm:
578
+ if (b is not a and _span_tokens(b) == tokens
579
+ and a.start_time_ns <= b.start_time_ns and b.end_time_ns <= a.end_time_ns
580
+ and (a.start_time_ns, a.end_time_ns) != (b.start_time_ns, b.end_time_ns)):
581
+ 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])
583
+
584
+ # Resolved at call time: otel_session imported the name into its own module.
585
+ _otel_session._spans_to_events = _spans_to_events_once
586
+
587
+
588
+ if __name__ == "__main__":
589
+ _cli = next(e for e in entry_points(group="console_scripts") if e.name == "assert-ai")
590
+ sys.exit(_cli.load()())
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Starting an eval from a terminal: check, quote, ask, run, report.
3
+ *
4
+ * Separate from `eval.js` because the two halves are opposite roles wearing one
5
+ * word. `eval.js` is the machine volunteering itself as a worker -- redeem a
6
+ * token, pair a repository, wait for queued work. This is a person asking for
7
+ * their agent to be measured. They share plumbing and share nothing else, and
8
+ * keeping them in one file is what made `eval run` and `test --agent` read as
9
+ * synonyms when they are opposites.
10
+ *
11
+ * Nothing here words a card, a price or a refusal. The server does that, and
12
+ * this prints what comes back. Two surfaces composing the same sentence from
13
+ * the same numbers is how they end up disagreeing about what somebody is about
14
+ * to pay for -- and the one that disagrees is the one they approved.
15
+ */
16
+
17
+ import {
18
+ callBackendJson,
19
+ cliInvocation,
20
+ describeFailure,
21
+ promptText,
22
+ resolveApiKey,
23
+ } from "./shared.js";
24
+
25
+ /** Ran, and the pass rate cleared the threshold (or none was asked for). */
26
+ export const EXIT_OK = 0;
27
+ /** Ran, and the pass rate did not clear `--fail-on`. */
28
+ export const EXIT_BELOW_THRESHOLD = 1;
29
+ /**
30
+ * Nothing ran: not ready, declined, refused, out of budget.
31
+ *
32
+ * Distinct from 1 because `preman eval && deploy` has to tell "the agent
33
+ * behaved" apart from "we never found out", and today both are a non-zero
34
+ * exit with no way to know which happened.
35
+ */
36
+ export const EXIT_DID_NOT_RUN = 2;
37
+
38
+ /**
39
+ * The account's four states, as the server sees them.
40
+ *
41
+ * Read rather than worked out here. The CLI has enough local knowledge to guess
42
+ * -- it can see whether a daemon is running on this box -- and a guess that
43
+ * disagrees with what the dashboard says about the same machine is worse than
44
+ * no answer, because the two are usually read minutes apart by one person.
45
+ */
46
+ export async function accountReadiness(args, key) {
47
+ const found = await callBackendJson(args, "GET", "/eval-sessions/runner/readiness", {
48
+ token: key,
49
+ });
50
+ if (!found.ok) {
51
+ throw new Error(
52
+ `could not read whether a run could start (${found.status_code}): ` +
53
+ `${describeFailure(found)}`
54
+ );
55
+ }
56
+ return found;
57
+ }
58
+
59
+ /**
60
+ * What this selection costs, where it would run, and how long it usually takes.
61
+ *
62
+ * `surface: "cli"` changes exactly one clause -- the sentence about where the
63
+ * result will appear -- because a terminal has no conversation for the worker
64
+ * to append to, and that is the one promise on the card that is not true
65
+ * everywhere it is read.
66
+ */
67
+ export async function quoteRun(args, key, { behaviorIds = [], endpoint = "", deviceId = "" } = {}) {
68
+ const quoted = await callBackendJson(args, "POST", "/eval-sessions/quote", {
69
+ token: key,
70
+ json: {
71
+ behavior_ids: behaviorIds,
72
+ endpoint: endpoint || null,
73
+ device_id: deviceId || null,
74
+ surface: "cli",
75
+ },
76
+ });
77
+ if (!quoted.ok) {
78
+ throw new Error(
79
+ `could not price this run (${quoted.status_code}): ${describeFailure(quoted)}`
80
+ );
81
+ }
82
+ return quoted;
83
+ }
84
+
85
+ /**
86
+ * Show the card and get an answer.
87
+ *
88
+ * Default no. A prompt that proceeds on a bare Enter is a prompt that spends
89
+ * money when somebody leans on the keyboard, and every other confirmation in
90
+ * this CLI that guards an irreversible charge reads the same way.
91
+ */
92
+ export async function confirmSpend(
93
+ quote,
94
+ { assumeYes = false, say = () => {}, ask = promptText } = {}
95
+ ) {
96
+ say("");
97
+ say(quote.summary);
98
+ say("");
99
+ if (assumeYes) {
100
+ say("Starting: --yes was given.");
101
+ return true;
102
+ }
103
+ const answer = String((await ask("Start this run? [y/N] ")) || "").trim().toLowerCase();
104
+ return answer === "y" || answer === "yes";
105
+ }
106
+
107
+ /**
108
+ * Say why a run cannot start, in the server's words, and stop.
109
+ *
110
+ * One next step, printed as given. `readiness` already guarantees a single
111
+ * command per state; picking a second one to add here is how a refusal turns
112
+ * into a list, and a list reads as "it is broken" rather than "do this".
113
+ */
114
+ export function reportNotReady(readiness, { say = () => {} } = {}) {
115
+ say(readiness.note);
116
+ if (readiness.next_step) {
117
+ say("");
118
+ say(` ${readiness.next_step}`);
119
+ }
120
+ return EXIT_DID_NOT_RUN;
121
+ }
122
+
123
+ /**
124
+ * The fraction of cases that passed, or null when the summary cannot be read.
125
+ *
126
+ * Null rather than zero. A run whose summary did not parse has not told us it
127
+ * failed, and treating silence as failure would break a build over a reporting
128
+ * bug.
129
+ */
130
+ export function passRate(summary) {
131
+ const cases = Number(summary?.cases);
132
+ const passed = Number(summary?.passed);
133
+ if (!Number.isFinite(cases) || !Number.isFinite(passed) || cases <= 0) return null;
134
+ return passed / cases;
135
+ }
136
+
137
+ /**
138
+ * Which exit code a finished run deserves.
139
+ *
140
+ * `--fail-on` defaults to off so that adding this to an existing pipeline
141
+ * cannot start failing builds that were passing yesterday. Opting in is the
142
+ * whole point of a gate.
143
+ */
144
+ export function exitCodeFor(summary, { failOn = null } = {}) {
145
+ if (failOn === null || failOn === undefined || failOn === "") return EXIT_OK;
146
+ const rate = passRate(summary);
147
+ if (rate === null) return EXIT_OK;
148
+ const threshold = Number(failOn);
149
+ if (!Number.isFinite(threshold)) return EXIT_OK;
150
+ // A threshold given as a percentage is the likelier reading of `--fail-on 80`
151
+ // than a ratio of 80, and nobody means "eighty times as many passes as cases".
152
+ const wanted = threshold > 1 ? threshold / 100 : threshold;
153
+ return rate + 1e-9 >= wanted ? EXIT_OK : EXIT_BELOW_THRESHOLD;
154
+ }
155
+
156
+ /**
157
+ * Which behaviours this run will cover, resolved against what is switched on.
158
+ *
159
+ * Naming the ones that are on beats "not found": the usual cause of a miss is a
160
+ * behaviour that exists but is switched off, which is a different fix from a
161
+ * typo.
162
+ */
163
+ export async function chooseBehaviors(args, key, { only = "" } = {}) {
164
+ const catalog = await callBackendJson(args, "GET", "/eval-behaviors", { token: key });
165
+ if (!catalog.ok) {
166
+ throw new Error(
167
+ `could not read what this account measures (${catalog.status_code}): ` +
168
+ `${describeFailure(catalog)}`
169
+ );
170
+ }
171
+ const enabled = (catalog.behaviors || []).filter((entry) => entry.enabled);
172
+ const wanted = String(only || "").trim();
173
+ const chosen = wanted
174
+ ? enabled.filter((entry) => entry.id === wanted || entry.name === wanted)
175
+ : enabled;
176
+
177
+ if (wanted && chosen.length === 0) {
178
+ const names = enabled.map((entry) => entry.id).join(", ") || "nothing";
179
+ throw new Error(`${wanted} is not switched on. Currently on: ${names}`);
180
+ }
181
+ if (chosen.length === 0) {
182
+ throw new Error(
183
+ "nothing is switched on for this account, so there is nothing to measure. " +
184
+ "Turn a behaviour on under Agent eval → Settings, or describe one in your " +
185
+ "own words there."
186
+ );
187
+ }
188
+ return chosen;
189
+ }
190
+
191
+ /**
192
+ * The key, or a message naming the command that gets one.
193
+ */
194
+ export function requireKey(args) {
195
+ const key = resolveApiKey(args);
196
+ if (!key) {
197
+ throw new Error(
198
+ `no PreMan API key on this machine, so there is no account to read the ` +
199
+ `switched-on behaviours from. Run \`${cliInvocation()} login\`.`
200
+ );
201
+ }
202
+ return key;
203
+ }
@@ -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,9 @@ export async function discoverTarget(args, { log = () => {}, allowSearch = true
199
207
  return searchForEntry({ projectPath, args, log });
200
208
  }
201
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"]);
212
+
202
213
  /**
203
214
  * The approved shim, if it is still the file that was approved.
204
215
  *
@@ -215,6 +226,13 @@ export function verifyEntry(saved) {
215
226
  const digest = createHash("sha256").update(source).digest("hex");
216
227
  if (saved.sha256 && digest !== saved.sha256) return null;
217
228
 
229
+ // 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);
235
+
218
236
  // Every file the approval produced, not only the wrapper.
219
237
  //
220
238
  // A `node_bridge` target is two files: the Python wrapper and the `.mjs` it
@@ -246,6 +264,7 @@ export function verifyEntry(saved) {
246
264
  sysPath,
247
265
  sha256: digest,
248
266
  files: extra,
267
+ capturesTools,
249
268
  shape: saved.shape || "",
250
269
  language: saved.language,
251
270
  framework: saved.framework,