premanmcp 1.1.14 → 1.1.16

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.
@@ -309,11 +309,19 @@ def _turn_raw(ref, raw_result):
309
309
 
310
310
  Carried on ``raw`` because assert-ai stores it verbatim and owns every other
311
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).
312
+ (docs/superpowers/specs/2026-09-30-tool-call-capture-design.md §7.2). A
313
+ proxied agent's token counts become model-call nodes here, as spans would
314
+ (docs/superpowers/specs/2026-10-08-proxy-tokens-ts-design.md §5).
313
315
  """
314
316
  raw = {"callable": ref}
315
317
  capture = raw_result.get("tool_capture")
316
318
  if isinstance(capture, dict):
319
+ try:
320
+ built = _proxy_nodes(capture)
321
+ if built is not None:
322
+ _add_nodes(raw, *built)
323
+ except Exception:
324
+ capture.pop("model_calls", None)
317
325
  raw["tool_capture"] = _session._sanitize_endpoint_value(capture)
318
326
  return raw
319
327
 
@@ -334,6 +342,79 @@ def _attach_capture(result, payload):
334
342
  return
335
343
 
336
344
 
345
+ def _span_tokens_in(events) -> bool:
346
+ return any(isinstance(e, dict) and isinstance((e.get("raw") or {}).get("_tokens"), dict) for e in events or [])
347
+
348
+
349
+ def _proxy_nodes(capture, spans_have_tokens=False):
350
+ """The proxy's token records for one turn, as the model-call nodes spans would give.
351
+
352
+ Pops ``model_calls`` so the stored capture never carries them. All or
353
+ nothing: one call without counts leaves the turn with none and
354
+ ``tokens_missing`` saying why, because a partial sum reads as a complete
355
+ number. Returns (nodes, totals) or None.
356
+ """
357
+ if not isinstance(capture, dict):
358
+ return None
359
+ calls = capture.pop("model_calls", None)
360
+ if not calls or spans_have_tokens:
361
+ return None
362
+ if not isinstance(calls, list) or not all(isinstance(c, dict) for c in calls):
363
+ capture["tokens_missing"] = "a model call's token counts could not be read"
364
+ return None
365
+ gap = next((c for c in calls if not (isinstance(c.get("input"), int) and isinstance(c.get("output"), int))), None)
366
+ if gap is not None:
367
+ capture["tokens_missing"] = gap.get("missing") or "a model call's token counts could not be read"
368
+ return None
369
+ nodes = [
370
+ {
371
+ "view": ["target", "combined"],
372
+ "actor": "target",
373
+ "edit": {"type": "add_message", "message": {"role": "assistant", "content": ""}},
374
+ "raw": {
375
+ "_node": c.get("kind") or "model",
376
+ "_model": c.get("model") or "",
377
+ "_tokens": {"input": c["input"], "output": c["output"]},
378
+ "_latency_ms": c.get("ms"),
379
+ "_source": "proxy",
380
+ },
381
+ }
382
+ for c in calls
383
+ ]
384
+ totals = {
385
+ "total_tokens": {"input": sum(c["input"] for c in calls), "output": sum(c["output"] for c in calls)},
386
+ "total_latency_ms": round(sum(c["ms"] for c in calls if isinstance(c.get("ms"), (int, float))), 1),
387
+ "llm_call_count": len(calls),
388
+ }
389
+ return nodes, totals
390
+
391
+
392
+ def _add_nodes(raw, nodes, totals):
393
+ raw["trace_events"] = list(raw.get("trace_events") or []) + nodes
394
+ meta = dict(raw.get("trace_metadata") or {"nodes_visited": [], "tools_called": []})
395
+ meta.update(totals)
396
+ raw["trace_metadata"] = meta
397
+
398
+
399
+ def _attach_proxy_nodes(result, payload):
400
+ """Token nodes for a traced turn whose spans carried no tokens (a proxied agent)."""
401
+ capture = payload.get("tool_capture") if isinstance(payload, dict) else None
402
+ if not isinstance(capture, dict):
403
+ return
404
+ try:
405
+ raw = result.raw if isinstance(result.raw, dict) else None
406
+ built = _proxy_nodes(capture, spans_have_tokens=_span_tokens_in((raw or {}).get("trace_events")))
407
+ if built is None or raw is None:
408
+ return
409
+ _add_nodes(raw, *built)
410
+ for message in reversed(result.interaction_messages):
411
+ if message.get("role") == "assistant" and isinstance(message.get("raw"), dict):
412
+ _add_nodes(message["raw"], *built)
413
+ return
414
+ except Exception:
415
+ capture.pop("model_calls", None) # never fail the turn, never store the raw records
416
+
417
+
337
418
  async def _callable_run_turn(self, messages: list[Message]) -> TurnResult:
338
419
  user_text = ""
339
420
  for msg in reversed(messages):
@@ -570,6 +651,18 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
570
651
  # LangGraph message trace is replaced only when both count the same calls.
571
652
  capture = payload.get("tool_capture") if isinstance(payload, dict) else None
572
653
  span_trace = next((t for t in reversed(holder) if t), None)
654
+ if (
655
+ span_trace is None
656
+ and isinstance(capture, dict)
657
+ and capture.get("source") == "decorated"
658
+ and capture.get("state") == "recorded"
659
+ and capture.get("trace") is None
660
+ ):
661
+ # Every watched tool records itself, so no span is a confirmed
662
+ # zero, not a turn whose calls carry no trace.
663
+ capture["trace"] = {"links": "full", "known": ["step", "parent", "started", "ended"],
664
+ "unknown": {}, "calls": []}
665
+ return
573
666
  if not isinstance(capture, dict) or span_trace is None:
574
667
  return
575
668
  own = capture.get("trace")
@@ -620,6 +713,7 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
620
713
  _use_span_trace(payload, holder)
621
714
  except Exception:
622
715
  pass # a trace that cannot be chosen must never fail the turn
716
+ _attach_proxy_nodes(result, payload)
623
717
  _attach_capture(result, payload)
624
718
  if payload is None or any(m.get("role") == "tool" for m in result.interaction_messages):
625
719
  return result # nothing reported, or the spans already carry the tools
@@ -207,7 +207,7 @@ export async function discoverTarget(args, { log = () => {}, allowSearch = true
207
207
  return searchForEntry({ projectPath, args, log });
208
208
  }
209
209
 
210
- /** Shapes whose wrapper records tool calls: PY_CAPTURE, `_graph_calls`, or the
210
+ /** Shapes whose wrapper records tool calls: PY_DECORATED, `_graph_calls`, or the
211
211
  * recording proxy (PY_PROXY) in eval_templates.js. */
212
212
  const CAPTURE_SHAPES = new Set(["py_history", "py_plain", "py_class", "other", "langgraph", "node_bridge", "cli_subprocess"]);
213
213
 
@@ -228,14 +228,19 @@ export function verifyEntry(saved) {
228
228
  if (saved.sha256 && digest !== saved.sha256) return null;
229
229
 
230
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.
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.
237
239
  const capturesTools =
238
- /^_TOOLS = /m.test(source) || /^def _graph_calls\(/m.test(source) || /^def _tool_proxy\(/m.test(source);
240
+ /^_DECORATED = /m.test(source) ||
241
+ /^_TOOLS = /m.test(source) ||
242
+ /^def _graph_calls\(/m.test(source) ||
243
+ /^def _tool_proxy\(/m.test(source);
239
244
 
240
245
  // Every file the approval produced, not only the wrapper.
241
246
  //