premanmcp 1.1.7 → 1.1.9

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.
@@ -1,6 +1,6 @@
1
- """Run assert-ai against Anthropic, working around four upstream gaps.
1
+ """Run assert-ai against Anthropic, working around five upstream gaps.
2
2
 
3
- All four workarounds live here rather than in the installed package so they
3
+ All five workarounds live here rather than in the installed package so they
4
4
  survive `uv sync`, which re-downloads the pinned ASSERT rev and discards any
5
5
  edit made under .venv/.
6
6
 
@@ -48,12 +48,39 @@ edit made under .venv/.
48
48
  exactly the object the prompt describes -- so the value was always meant to
49
49
  be that document, serialised.
50
50
 
51
- All four go away upstream: (1) with a workspace-scoped API key, (2), (3) and
52
- (4) with a fix in AleWang16/ASSERT plus a bumped rev in pyproject.toml.
51
+ 5. A callable target is tool-blind to the judge. ``CallableSession.run_turn``
52
+ builds ``interaction_messages`` containing only ``user`` and ``assistant``
53
+ roles, and the inference stage emits a ``ToolCallEdit`` -- the row the judge
54
+ reads and ``missingToolEvidence`` counts -- only from a ``role: "tool"``
55
+ message. So every tool call a callable target makes is invisible to scoring,
56
+ silently: a tool-defined behaviour scores as "the agent called nothing".
57
+
58
+ ``HTTPEndpointSession`` does not have this problem, because it routes its
59
+ reply through ``_normalize_connector_response`` and
60
+ ``_serialize_connector_interaction_messages``, and the latter maps an
61
+ adapter's ``tool_result`` event to ``role: "tool"``. The two target kinds
62
+ disagree about whether tool calls exist.
63
+
64
+ So this workaround gives the callable path the endpoint path's treatment: a
65
+ callable returning an adapter-shaped ``{"response": str, "events": [...]}``
66
+ is routed through those same two helpers. That makes a local shim's return
67
+ value mean exactly what the same JSON means over HTTP, which is what lets an
68
+ agent be evaluated in-process without changing what its scores mean.
69
+
70
+ ``run_turn`` is replaced rather than wrapped. The obvious wrapper -- stash
71
+ the raw dict on the instance, let the original normalise -- is not safe: the
72
+ inference stage fans out concurrently over one session object, so anything
73
+ kept on ``self`` between the call and its use can be overwritten by another
74
+ turn. A replacement holds the payload in a local.
75
+
76
+ Workarounds (1)-(4) go away upstream: (1) with a workspace-scoped API key, (2),
77
+ (3) and (4) with a fix in AleWang16/ASSERT plus a bumped rev in pyproject.toml.
78
+ (5) goes away when ``CallableSession`` serialises tool evidence the way
79
+ ``HTTPEndpointSession`` already does.
53
80
 
54
81
  This file exists twice. ``preman-mcp/bin/eval_harness.py`` is a byte-identical
55
82
  copy, shipped in the npm package so a run leased to a paired device goes through
56
- the same three workarounds a server-side run does -- otherwise an Anthropic
83
+ the same workarounds a server-side run does -- otherwise an Anthropic
57
84
  tester would silently lose the multi-turn half of every bank on a customer's
58
85
  machine and keep it on ours, and the two runs would not be comparable while
59
86
  looking like they were. ``tests/test_eval_harness_shim.py`` fails if the copies
@@ -229,6 +256,140 @@ def _build_chat_payload_without_extra_body(model, messages, options):
229
256
  # at call time, so rebinding here reaches all of them.
230
257
  model_client._build_chat_payload = _build_chat_payload_without_extra_body
231
258
 
259
+
260
+ from assert_ai.core import session as _session
261
+ from assert_ai.core.async_utils import invoke_callable as _invoke_callable
262
+ from assert_ai.core.model_client import _classify_llm_error
263
+ from assert_ai.core.session import CallableSession, Message, TurnResult
264
+
265
+
266
+ def _is_adapter_payload(value) -> bool:
267
+ """Whether a callable returned the endpoint contract rather than a reply.
268
+
269
+ ``response`` is the discriminator because it is the required key of that
270
+ contract and is not a key assert-ai's own normaliser looks for: it reads
271
+ ``text``, ``content`` and ``choices``. A dict carrying ``response`` would
272
+ otherwise fall through to ``str(result)`` and be scored as the repr of a
273
+ dict, so claiming it here takes nothing from the paths that already work.
274
+ """
275
+ return isinstance(value, dict) and isinstance(value.get("response"), str)
276
+
277
+
278
+ async def _callable_run_turn(self, messages: list[Message]) -> TurnResult:
279
+ user_text = ""
280
+ for msg in reversed(messages):
281
+ if msg.role == "user":
282
+ user_text = msg.text
283
+ break
284
+
285
+ kwargs = {"timeout_s": self._message_timeout_s}
286
+ if self._supports_history:
287
+ kwargs["history"] = [
288
+ {"role": msg.role, "content": msg.text}
289
+ for msg in messages
290
+ if msg.role in ("user", "assistant")
291
+ ]
292
+
293
+ try:
294
+ raw_result = await _invoke_callable(self._callable, user_text, **kwargs)
295
+ except Exception as exc:
296
+ # Upstream's reasoning, preserved: a user callable makes its own
297
+ # provider calls, so a content-filter rejection arrives as a raw
298
+ # litellm error rather than a typed LLM*Error, and the inference
299
+ # stage's per-seed isolation keys off the typed ones. Anything the
300
+ # classifier does not recognise is a genuine crash and propagates.
301
+ classified = _classify_llm_error(exc)
302
+ if classified is exc:
303
+ raise
304
+ raise classified from exc
305
+
306
+ if not _is_adapter_payload(raw_result):
307
+ # Upstream's own two paths, inlined because the original `run_turn`
308
+ # invokes the callable itself and so cannot be handed a result that
309
+ # has already been produced. Calling it would run the agent twice.
310
+ result = self._normalize_callable_result(raw_result)
311
+ if isinstance(result, _session.ModelResponse):
312
+ response_text = result.text or ""
313
+ interaction_messages = [
314
+ {"role": "user", "content": user_text},
315
+ {"role": "assistant", "content": response_text},
316
+ ]
317
+ if result.tool_calls:
318
+ interaction_messages[1]["tool_calls"] = [
319
+ tc.to_openai_dict() for tc in result.tool_calls
320
+ ]
321
+ return TurnResult(
322
+ text=response_text,
323
+ state_messages=list(messages) + [result.message],
324
+ interaction_messages=interaction_messages,
325
+ tool_traces=[
326
+ _session.ToolTrace(
327
+ tool_name=tc.name, tool_args=tc.arguments, tool_result=""
328
+ )
329
+ for tc in (result.tool_calls or [])
330
+ ],
331
+ llm_calls=[_session.build_llm_call_trace(result, source="callable")],
332
+ raw={
333
+ "callable": self._callable_ref,
334
+ "model": result.model,
335
+ "usage": _session.summarize_response(result).get("usage"),
336
+ },
337
+ )
338
+ return TurnResult(
339
+ text=result,
340
+ state_messages=list(messages) + [Message(role="assistant", content=result)],
341
+ interaction_messages=[
342
+ {"role": "user", "content": user_text},
343
+ {"role": "assistant", "content": result},
344
+ ],
345
+ raw={"callable": self._callable_ref},
346
+ )
347
+
348
+ # Sanitised on the same grounds the endpoint path gives: event content is
349
+ # agent-influenced and lands in run artifacts, so it needs the redaction
350
+ # the response text gets.
351
+ response = _session._normalize_connector_response({
352
+ "text": _session._sanitize_response_text(raw_result["response"]),
353
+ "events": _session._sanitize_endpoint_value(raw_result.get("events")),
354
+ "raw": {"callable": self._callable_ref},
355
+ })
356
+ interaction_messages = _session._serialize_connector_interaction_messages(
357
+ user_text=user_text,
358
+ response=response,
359
+ )
360
+ return TurnResult(
361
+ text=response.text,
362
+ state_messages=list(messages) + [Message(role="assistant", content=response.text)],
363
+ interaction_messages=interaction_messages,
364
+ tool_traces=[
365
+ _session.ToolTrace(
366
+ tool_name=event.tool_name or "tool",
367
+ tool_args=event.tool_args or {},
368
+ tool_result=event.content,
369
+ )
370
+ for event in (response.events or [])
371
+ if event.role == "tool_result"
372
+ ],
373
+ raw=response.raw,
374
+ )
375
+
376
+
377
+ CallableSession.run_turn = _callable_run_turn
378
+
379
+ # The customer's agent lives in their repository, not next to this file, and
380
+ # assert-ai resolves a `callable:` target only as a dotted module name -- there
381
+ # is no spec key that adds an import root, and `_temporary_sys_path` only ever
382
+ # covers the config directory and the cwd, neither of which is the repo. The
383
+ # run's cwd cannot move either: progress and artifacts are read relative to it.
384
+ #
385
+ # So the import root arrives out of band. A PreMan-specific variable rather
386
+ # than PYTHONPATH because the interpreter may be `uv run --project`, and uv's
387
+ # handling of PYTHONPATH is not ours to depend on; plain inheritance is.
388
+ for _root in os.environ.get("PREMAN_EVAL_SYS_PATH", "").split(os.pathsep):
389
+ _root = _root.strip()
390
+ if _root and os.path.isdir(_root) and _root not in sys.path:
391
+ sys.path.insert(0, _root)
392
+
232
393
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
233
394
 
234
395
  _cli = next(e for e in entry_points(group="console_scripts") if e.name == "assert-ai")
@@ -0,0 +1,156 @@
1
+ /**
2
+ * PreMan's own Python for running an eval, fetched once and kept.
3
+ *
4
+ * The problem this solves: an eval executes on the customer's machine, so that
5
+ * machine needs an interpreter that can `import assert_ai`. Until now the CLI
6
+ * borrowed one, and which one it borrowed depended on the working directory —
7
+ * `uv run --project <cwd>` when a `pyproject.toml` happened to be there, else
8
+ * whatever `python3` resolved to. A customer pasting the dashboard's command
9
+ * into a Node or Go repo matches neither and is refused; worse, a `python3` that
10
+ * can import `assert_ai` but cannot complete a run is silently preferred over a
11
+ * known-good one. That happened: a run picked up a conda interpreter, spent five
12
+ * and a half minutes, wrote no artifacts, and reported `ok`.
13
+ *
14
+ * So this is a runtime that belongs to PreMan rather than to the customer's
15
+ * project, keyed by the assert-ai revision it contains and cached under
16
+ * `~/.preman/runtime`. Fetched on first use because the user asked for an eval,
17
+ * which is also the whole of the consent: nothing is installed into their Python
18
+ * or their project, and nothing is written outside `~/.preman`.
19
+ *
20
+ * Two hard rules, both about not being worse than doing nothing:
21
+ *
22
+ * - Never throw. Every failure path returns `""` so the caller falls back to
23
+ * the interpreters it already knew about. A machine that already has a
24
+ * working assert-ai must not be blocked by a proxy or a bad digest.
25
+ * - Never leave a partial tree where a whole one is expected. The extract
26
+ * lands in a temp directory and is renamed into place, because the cache
27
+ * check is "does the interpreter exist" and a half-unpacked bundle would
28
+ * pass it.
29
+ *
30
+ * Deliberately *not* the right interpreter for every run. A `callable` target
31
+ * has the harness import the customer's own agent in-process, and this bundle
32
+ * cannot import their dependencies — see `resolveInterpreter` in eval.js, which
33
+ * is where that choice is made. See docs/EVAL_SELF_CONTAINED_RUNTIME.md.
34
+ */
35
+
36
+ import { spawnSync } from "node:child_process";
37
+ import { createHash } from "node:crypto";
38
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from "node:fs";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+
42
+ /** Everything PreMan keeps that is not one customer's data. */
43
+ const RUNTIME_HOME = path.join(os.homedir(), ".preman", "runtime");
44
+
45
+ /**
46
+ * The assert-ai revision this bundle contains, truncated.
47
+ *
48
+ * Part of the cache path rather than a version file beside it, so changing the
49
+ * pin fetches a new tree next to the old one instead of mutating one in place —
50
+ * and so a run can never use a bundle that does not match what this CLI expects.
51
+ */
52
+ export const RUNTIME_PIN = "14f4e2136ab7";
53
+
54
+ /** sha256 of the bundle, pinned here rather than fetched beside it.
55
+ *
56
+ * A digest served from the same host as the artifact proves only that the host
57
+ * is internally consistent. Pinning it in the source that was published through
58
+ * npm means the two would have to be compromised together.
59
+ */
60
+ export const RUNTIME_SHA256 =
61
+ "f92becee5252262594326b533bd86a4e8b6a4a97080af0626804eb49c4e7222c";
62
+
63
+ const RUNTIME_BASE =
64
+ process.env.PREMAN_EVAL_RUNTIME_BASE ||
65
+ "https://github.com/PreMan-Inc/PreMan-Desktop/releases/download/eval-runtime-1";
66
+
67
+ /** The same rule `suiteHome` applies, for the same reason: this becomes a directory name. */
68
+ const SAFE_SEGMENT = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
69
+
70
+ /**
71
+ * Which bundle this machine can run, or `""` for one we do not build.
72
+ *
73
+ * Proof of concept: Linux x64 only. Everything else falls through to the
74
+ * interpreters the CLI already knew how to find, with no network call — a
75
+ * Windows or macOS user must not pay a download to learn there isn't one.
76
+ */
77
+ export function runtimePlatform({ platform = process.platform, arch = process.arch } = {}) {
78
+ return platform === "linux" && arch === "x64" ? "linux-x64" : "";
79
+ }
80
+
81
+ /** Where this machine's interpreter lives, whether or not it is there yet. */
82
+ export function runtimeInterpreter({ pin = RUNTIME_PIN, platform = runtimePlatform(), home = RUNTIME_HOME } = {}) {
83
+ if (!platform || !SAFE_SEGMENT.test(pin) || !SAFE_SEGMENT.test(platform)) return "";
84
+ return path.join(home, pin, platform, "python", "bin", "python3");
85
+ }
86
+
87
+ function sha256Hex(file) {
88
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
89
+ }
90
+
91
+ /**
92
+ * The cached interpreter, fetching it first if this is the first run.
93
+ *
94
+ * Returns a path, or `""` — never throws, and never the path to something it
95
+ * could not finish installing.
96
+ */
97
+ export async function ensureRuntime({
98
+ log = () => {},
99
+ home = RUNTIME_HOME,
100
+ pin = RUNTIME_PIN,
101
+ digest = RUNTIME_SHA256,
102
+ base = RUNTIME_BASE,
103
+ download,
104
+ progress,
105
+ } = {}) {
106
+ const platform = runtimePlatform();
107
+ if (!platform) return "";
108
+
109
+ const target = runtimeInterpreter({ pin, platform, home });
110
+ if (!target) return "";
111
+ if (existsSync(target)) return target;
112
+ if (!download) return "";
113
+
114
+ const name = `preman-eval-runtime-${pin}-${platform}.tar.gz`;
115
+ const url = `${base}/${name}`;
116
+ let work = "";
117
+ const bar = progress ? progress() : { tick: () => {}, clear: () => {} };
118
+ try {
119
+ mkdirSync(home, { recursive: true, mode: 0o700 });
120
+ work = mkdtempSync(path.join(home, ".fetch-"));
121
+ const archive = path.join(work, name);
122
+
123
+ log(`Preparing the eval runtime — first run only`);
124
+ await download(url, archive, bar.tick);
125
+ bar.clear();
126
+
127
+ // Refuse rather than run something we cannot identify. An unset digest is a
128
+ // build that was never pinned, which is a mistake to surface, not to ignore.
129
+ if (!digest) throw new Error("this build has no runtime digest pinned, so the download cannot be verified");
130
+ const got = sha256Hex(archive);
131
+ if (got !== digest) throw new Error(`digest mismatch: expected ${digest}, got ${got}`);
132
+
133
+ const unpacked = path.join(work, "tree");
134
+ mkdirSync(unpacked, { recursive: true, mode: 0o700 });
135
+ const tar = spawnSync("tar", ["xzf", archive, "-C", unpacked], { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8" });
136
+ if (tar.status !== 0) throw new Error(`could not unpack the runtime: ${(tar.stderr || "").trim() || tar.status}`);
137
+
138
+ const slot = path.join(home, pin, platform);
139
+ mkdirSync(path.dirname(slot), { recursive: true, mode: 0o700 });
140
+ // Last, and atomic: until this lands there is nothing at the path the cache
141
+ // check looks at, so an interrupted fetch leaves a miss rather than a ruin.
142
+ renameSync(unpacked, slot);
143
+
144
+ if (!existsSync(target)) throw new Error(`the runtime unpacked without an interpreter at ${target}`);
145
+ log(` eval runtime ready (${path.join(home, pin, platform)})`);
146
+ return target;
147
+ } catch (error) {
148
+ bar.clear();
149
+ // Logged, not thrown. The caller has other interpreters to try and this
150
+ // machine may well have a perfectly good one.
151
+ log(` could not prepare the eval runtime (${error.message}); looking for a Python already here`);
152
+ return "";
153
+ } finally {
154
+ if (work) rmSync(work, { recursive: true, force: true });
155
+ }
156
+ }