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.
- package/bin/account.js +48 -0
- package/bin/cli.js +73 -14
- package/bin/connect/configs.js +20 -6
- package/bin/desktop.js +557 -79
- package/bin/eval.js +328 -50
- package/bin/eval_entry.js +9 -2
- package/bin/eval_generation_contract.py +72 -0
- package/bin/eval_generation_debug.py +279 -0
- package/bin/eval_generation_status.py +284 -0
- package/bin/eval_harness.py +198 -4
- package/bin/eval_start.js +203 -0
- package/bin/eval_target.js +20 -1
- package/bin/eval_templates.js +447 -21
- package/bin/hook.js +21 -1
- package/bin/integrations.js +20 -4
- package/bin/probe.js +323 -0
- package/bin/runner.js +23 -0
- package/bin/shared.js +331 -14
- package/package.json +2 -2
package/bin/eval_harness.py
CHANGED
|
@@ -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":
|
|
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
|
-
|
|
396
|
-
|
|
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
|
+
}
|
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,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,
|