premanmcp 1.1.12 → 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/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
- // Opt-in for now (docs/EVAL_AGENT_TRACING.md). Kept only for an agent that
1992
- // runs inside the harness's Python process; a node bridge or a subprocess
1993
- // is invisible to in-process tracing, and tracing it would only replace the
1994
- // adapter's reported tool calls with nothing.
1995
- if (process.env.PREMAN_EVAL_TRACE === "1" && !tracesInProcess(target)) {
1996
- say(`tracing is not available for this agent (${target.language || "?"} / ${target.shape || "?"}); running untraced`);
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
- const PROBE_TIMEOUT_MS = 60_000;
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 { ok: false, why: "it did not answer within 60 seconds" };
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")
@@ -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": {"callable": self._callable_ref},
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,
@@ -484,6 +514,7 @@ if os.environ.get("PREMAN_EVAL_TRACE") == "1":
484
514
  raise classified from exc
485
515
 
486
516
  payload, self._preman_payload = self._preman_payload, None
517
+ _attach_capture(result, payload)
487
518
  if payload is None or any(m.get("role") == "tool" for m in result.interaction_messages):
488
519
  return result # nothing reported, or the spans already carry the tools
489
520
 
@@ -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,
@@ -32,6 +32,7 @@ export const SHAPES = new Set([
32
32
  export const FREEFORM = "other";
33
33
 
34
34
  const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
35
+ const TOOL_SYMBOL = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
35
36
 
36
37
  /** Python literal for a JS string. JSON is a subset of Python for these. */
37
38
  function lit(value) {
@@ -43,6 +44,37 @@ function litList(values) {
43
44
  return `[${(values || []).map((v) => lit(v)).join(", ")}]`;
44
45
  }
45
46
 
47
+ /**
48
+ * Python literal for the named tools. Every value came from a model, so each is
49
+ * checked here; the backend filtered the same rules, and this is the second
50
+ * gate the templates promise ("every value the model supplied is checked").
51
+ * Membership in the repository is not checked here -- the caller passes only
52
+ * the entry file as `tree` -- and a file that is never loaded stays "pending",
53
+ * which every turn reports as not visible, naming it.
54
+ */
55
+ function toolsLiteral(tools) {
56
+ const list = Array.isArray(tools) ? tools : [];
57
+ const pairs = list.map((tool) => {
58
+ const file = String(tool?.file || "");
59
+ const symbol = String(tool?.symbol || "");
60
+ // The same rules as the backend's `_tools` (flowtest/services/agent_entry_search.py),
61
+ // plus no control characters. `lit()` quotes everything else safely.
62
+ if (
63
+ !file.endsWith(".py")
64
+ || file.startsWith("/")
65
+ || file.split("/").includes("..")
66
+ || [...file].some((ch) => ch.charCodeAt(0) < 0x20)
67
+ ) {
68
+ throw new TemplateError(`tool file ${JSON.stringify(file)} is not a repository .py path`);
69
+ }
70
+ if (!TOOL_SYMBOL.test(symbol)) {
71
+ throw new TemplateError(`tool symbol ${JSON.stringify(symbol)} is not a name or Class.method`);
72
+ }
73
+ return `(${lit(file)}, ${lit(symbol)})`;
74
+ });
75
+ return `[${pairs.join(", ")}]`;
76
+ }
77
+
46
78
  // ── The parts every wrapper shares ──────────────────────────────────────
47
79
 
48
80
  const HEADER = `# Generated by \`preman eval target\`. Do not edit by hand.
@@ -111,6 +143,312 @@ def preman_selfcheck() -> str:
111
143
  return _SYMBOL
112
144
  `;
113
145
 
146
+ /**
147
+ * Tool capture for the Python shapes (docs/superpowers/specs/2026-09-30-tool-call-capture-design.md §4).
148
+ *
149
+ * Each named tool function is replaced, once, by a recording version -- in its
150
+ * own module, in every loaded module holding the same object (`from x import
151
+ * f`), in module-level dicts and lists holding it, and on the class for a
152
+ * method. Replacing once rather than per turn is what keeps concurrent cases
153
+ * from racing each other's swaps.
154
+ *
155
+ * A turn's calls go into a context variable set in `chat()`. assert-ai runs a
156
+ * synchronous `chat` through `asyncio.to_thread`, which copies the context, so
157
+ * concurrent cases stay separate without a lock. A call made in a thread the
158
+ * agent starts itself sees no turn; it is not guessed onto one -- every turn
159
+ * open at that moment is reported not visible instead.
160
+ */
161
+ const PY_CAPTURE = String.raw`
162
+ import contextvars
163
+ import functools
164
+ import inspect
165
+ import json
166
+ import threading
167
+ import time
168
+
169
+ _TOOLS = %%TOOLS%%
170
+ _turn_calls = contextvars.ContextVar("preman_turn_calls", default=None)
171
+ _open_turns = {}
172
+ _open_lock = threading.Lock()
173
+ _stray_calls = []
174
+ _installed = []
175
+ _missing = {}
176
+ _pending = {}
177
+ _unsupported = {}
178
+ _install_lock = threading.Lock()
179
+
180
+
181
+ def _tool_module(file):
182
+ # Only a module that is already loaded, found by the file it came from.
183
+ # Importing it here under a name guessed from its path would run the
184
+ # customer's module-level code a second time, as a second copy the agent
185
+ # never calls -- the copy that got wrapped would then see no calls at all.
186
+ # A file that is not loaded yet is left pending and retried next turn.
187
+ if (_REPO / file).resolve() == _SRC.resolve():
188
+ return _agent()
189
+ wanted = (_REPO / file).resolve()
190
+ for mod in list(sys.modules.values()):
191
+ where = getattr(mod, "__file__", None)
192
+ if where:
193
+ try:
194
+ if Path(where).resolve() == wanted:
195
+ return mod
196
+ except (OSError, ValueError):
197
+ continue
198
+ return None
199
+
200
+
201
+ def _jsonable(value):
202
+ return json.loads(json.dumps(value, ensure_ascii=False, default=str))
203
+
204
+
205
+ def _record(name, original, args, kwargs, result):
206
+ """Build and file the event. Must never raise into the caller ('recorded()',
207
+ which is standing in for the agent's own call) -- capture failing is not
208
+ allowed to change what the agent sees. 'default=str' covers values json
209
+ does not know how to serialise, but not dict keys (a tuple key) or a
210
+ container that contains itself, both of which raise out of 'json.dumps'
211
+ regardless. Each of those falls back to 'repr()'; if even that raises, the
212
+ call is simply not recorded rather than recorded wrong or crashing the turn.
213
+ """
214
+ try:
215
+ try:
216
+ bound = inspect.signature(original).bind(*args, **kwargs).arguments
217
+ tool_args = {k: v for k, v in bound.items() if k not in ("self", "cls")}
218
+ except Exception:
219
+ tool_args = {"args": list(args), "kwargs": dict(kwargs)}
220
+ if isinstance(result, str):
221
+ content = result
222
+ elif inspect.isgenerator(result):
223
+ content = "<generator>"
224
+ else:
225
+ try:
226
+ content = json.dumps(result, ensure_ascii=False, default=str)
227
+ except Exception:
228
+ content = repr(result)
229
+ try:
230
+ args_out = _jsonable(tool_args)
231
+ except Exception:
232
+ args_out = {str(k): repr(v) for k, v in tool_args.items()}
233
+ event = {
234
+ "role": "tool_result",
235
+ "tool_name": name,
236
+ "tool_args": args_out,
237
+ "content": content,
238
+ }
239
+ except Exception:
240
+ with _open_lock:
241
+ _stray_calls.append(time.monotonic())
242
+ return
243
+
244
+ calls = _turn_calls.get()
245
+ if calls is None:
246
+ with _open_lock:
247
+ _stray_calls.append(time.monotonic())
248
+ else:
249
+ calls.append(event)
250
+
251
+
252
+ def _error_text(exc):
253
+ # str(exc) runs the agent's own __str__, which may itself raise; a new
254
+ # error here would replace the tool's exception before it is re-raised.
255
+ name = type(exc).__name__
256
+ try:
257
+ return "error: %s: %s" % (name, exc)
258
+ except Exception:
259
+ pass
260
+ try:
261
+ return "error: %s: %r" % (name, exc)
262
+ except Exception:
263
+ return "error: %s" % name
264
+
265
+
266
+ def _recording(name, original):
267
+ if inspect.iscoroutinefunction(original):
268
+ @functools.wraps(original)
269
+ async def recorded(*args, **kwargs):
270
+ try:
271
+ result = await original(*args, **kwargs)
272
+ except BaseException as exc:
273
+ _record(name, original, args, kwargs, _error_text(exc))
274
+ raise
275
+ _record(name, original, args, kwargs, result)
276
+ return result
277
+ else:
278
+ @functools.wraps(original)
279
+ def recorded(*args, **kwargs):
280
+ try:
281
+ result = original(*args, **kwargs)
282
+ except BaseException as exc:
283
+ _record(name, original, args, kwargs, _error_text(exc))
284
+ raise
285
+ _record(name, original, args, kwargs, result)
286
+ return result
287
+ recorded.__preman_original__ = original
288
+ return recorded
289
+
290
+
291
+ def _replace_everywhere(original, recorded):
292
+ for mod in list(sys.modules.values()):
293
+ try:
294
+ space = getattr(mod, "__dict__", None)
295
+ if not isinstance(space, dict):
296
+ continue
297
+ for key, value in list(space.items()):
298
+ if value is original:
299
+ space[key] = recorded
300
+ elif isinstance(value, dict):
301
+ for inner, item in list(value.items()):
302
+ if item is original:
303
+ value[inner] = recorded
304
+ elif isinstance(value, list):
305
+ for index, item in enumerate(value):
306
+ if item is original:
307
+ value[index] = recorded
308
+ except Exception:
309
+ # One odd entry in sys.modules (a proxy whose __dict__ or .items()
310
+ # raises) must not fail the whole install and take every other
311
+ # tool down with it.
312
+ continue
313
+
314
+
315
+ def _install():
316
+ # Serialized, and the agent loaded first, unconditionally, inside the
317
+ # lock -- PY_LOADER's own _agent() has no lock of its own, so two threads
318
+ # reaching it at once can each exec_module a distinct module object, and
319
+ # whichever one _turn() ends up calling is not reliably the one whose
320
+ # functions got wrapped below. Taking the lock here and loading the agent
321
+ # before touching _TOOLS means every chat() (every chat() calls _install()
322
+ # first) either does that one load-and-wrap or blocks until it is done, so
323
+ # _turn() always sees the same, already-wrapped module -- concurrent first
324
+ # calls included.
325
+ with _install_lock:
326
+ _agent()
327
+ for file, symbol in _TOOLS:
328
+ key = "%s:%s" % (file, symbol)
329
+ if key in _installed:
330
+ continue
331
+ mod = _tool_module(file)
332
+ if mod is None:
333
+ _pending[key] = "module not loaded yet"
334
+ continue
335
+ _pending.pop(key, None)
336
+ owner_name, _, method = symbol.partition(".")
337
+ owner = getattr(mod, owner_name, None)
338
+ if owner is None:
339
+ _missing[key] = "%s not defined" % owner_name
340
+ continue
341
+ if method:
342
+ if not inspect.isclass(owner):
343
+ # owner_name names an instance ('support = Support()'),
344
+ # not the class -- setattr on it would shadow the method
345
+ # with a plain function on one object, and calling it would
346
+ # raise a missing-argument TypeError the agent never had.
347
+ _missing[key] = "%s.%s is not a method on a class" % (owner_name, method)
348
+ continue
349
+ raw = inspect.getattr_static(owner, method, None)
350
+ if raw is None:
351
+ _missing[key] = "%s has no %s" % (owner_name, method)
352
+ continue
353
+ if isinstance(raw, (staticmethod, classmethod)):
354
+ wrapped = type(raw)(_recording(method, raw.__func__))
355
+ elif inspect.isfunction(raw):
356
+ wrapped = _recording(method, raw)
357
+ else:
358
+ # A property or other descriptor: replacing it with a
359
+ # plain function would change what the attribute even is.
360
+ _missing[key] = "%s.%s is not a method on a class" % (owner_name, method)
361
+ continue
362
+ setattr(owner, method, wrapped)
363
+ elif not inspect.isfunction(owner):
364
+ # A framework tool object (LangChain/crewAI '@tool', OpenAI
365
+ # Agents '@function_tool'), a callable instance or a class:
366
+ # swapping it for a plain function would remove the attributes
367
+ # and type the agent relies on. Left exactly as it is.
368
+ _missing.pop(key, None)
369
+ _unsupported[key] = "not a plain function (%s)" % type(owner).__name__
370
+ continue
371
+ elif getattr(owner, "__preman_original__", None) is None:
372
+ _replace_everywhere(owner, _recording(owner_name, owner))
373
+ _missing.pop(key, None)
374
+ _unsupported.pop(key, None)
375
+ _installed.append(key)
376
+
377
+
378
+ def _tool_names(installed):
379
+ return [key.split(":", 1)[1].split(".")[-1] for key in installed]
380
+
381
+
382
+ def _begin_turn():
383
+ calls = []
384
+ token = _turn_calls.set(calls)
385
+ marker = object()
386
+ with _open_lock:
387
+ _open_turns[id(marker)] = time.monotonic()
388
+ return token, marker, calls
389
+
390
+
391
+ def _end_turn(token, marker, calls):
392
+ _turn_calls.reset(token)
393
+ now = time.monotonic()
394
+ with _open_lock:
395
+ started = _open_turns.pop(id(marker))
396
+ stray = any(started <= t <= now for t in _stray_calls)
397
+ oldest = min(_open_turns.values(), default=now)
398
+ _stray_calls[:] = [t for t in _stray_calls if t >= oldest]
399
+ # A snapshot under the same lock _install() mutates them under, so a turn
400
+ # that lands between two of _install()'s steps on another thread cannot
401
+ # read a torn "some tools installed, matching missing entry not popped yet"
402
+ # state.
403
+ with _install_lock:
404
+ installed = list(_installed)
405
+ missing = sorted(_missing)
406
+ unseen = dict(_pending)
407
+ unseen.update(_unsupported)
408
+ not_watched = dict(_missing)
409
+ not_watched.update(unseen)
410
+ # "recorded" only when every named tool is installed: a tool that is not
411
+ # watched could have run without being seen, so a zero would not be a
412
+ # confirmed zero.
413
+ if not _TOOLS:
414
+ state, reason = "not_visible", "discovery named no tool functions"
415
+ elif not installed and not unseen:
416
+ state, reason = "not_visible", "no named tool function could be found"
417
+ elif not_watched:
418
+ state, reason = "not_visible", "not watched: " + "; ".join(
419
+ "%s (%s)" % (k, v) for k, v in sorted(not_watched.items())
420
+ )
421
+ elif stray:
422
+ state, reason = "not_visible", "a tool ran outside any turn, in a thread the agent started"
423
+ else:
424
+ state, reason = "recorded", ""
425
+ return {
426
+ "state": state,
427
+ "source": "wrapped",
428
+ "watched": _tool_names(installed),
429
+ "missing": missing,
430
+ "reason": reason,
431
+ }
432
+
433
+
434
+ def preman_selfcheck() -> str:
435
+ # _install() first: it takes _install_lock and loads the agent itself
436
+ # before touching _TOOLS. Calling _target() first would load the agent
437
+ # outside that lock, racing a concurrent call's locked load.
438
+ _install()
439
+ _target()
440
+ # Only a missing tool fails: a pending one (its module is not loaded yet)
441
+ # or an unsupported one (not a plain function) is reported per turn as
442
+ # "not visible" instead, and the agent still runs.
443
+ if _missing:
444
+ raise RuntimeError(
445
+ "tool functions named by discovery were not found: "
446
+ + "; ".join("%s (%s)" % (k, v) for k, v in sorted(_missing.items()))
447
+ + ". Re-run 'preman eval target'."
448
+ )
449
+ return _SYMBOL
450
+ `;
451
+
114
452
  /**
115
453
  * The reply, normalised.
116
454
  *
@@ -162,13 +500,41 @@ def _text(value):
162
500
  if isinstance(found, str) and found.strip():
163
501
  return found
164
502
  return "" if value is None else str(value)
503
+ `;
165
504
 
505
+ /** `chat()` for the shapes that do not capture tool calls. */
506
+ const PY_CHAT = String.raw`
166
507
 
167
508
  def chat(message: str, history: list | None = None) -> dict:
168
509
  reply, events = _turn(message, history or [])
169
510
  return {"response": _text(reply), "events": list(events or [])}
170
511
  `;
171
512
 
513
+ /** `chat()` for `cli_subprocess`: the agent reports its own calls through a file. */
514
+ const PY_CHAT_REPORTED = String.raw`
515
+
516
+ def chat(message: str, history: list | None = None) -> dict:
517
+ reply, events, capture = _turn(message, history or [])
518
+ return {"response": _text(reply), "events": list(events or []), "tool_capture": capture}
519
+ `;
520
+
521
+ /** `chat()` for the Python shapes: the turn runs inside a capture window. */
522
+ const PY_CHAT_CAPTURING = String.raw`
523
+
524
+ def chat(message: str, history: list | None = None) -> dict:
525
+ _install()
526
+ token, marker, calls = _begin_turn()
527
+ try:
528
+ reply, events = _turn(message, history or [])
529
+ finally:
530
+ capture = _end_turn(token, marker, calls)
531
+ return {
532
+ "response": _text(reply),
533
+ "events": list(events or []) + calls,
534
+ "tool_capture": capture,
535
+ }
536
+ `;
537
+
172
538
  // ── One `_turn` per shape ───────────────────────────────────────────────
173
539
 
174
540
  const TURNS = {
@@ -420,9 +786,11 @@ for await (const line of createInterface({ input: process.stdin })) {
420
786
  * and shows up in process listings.
421
787
  */
422
788
  const CLI_SUBPROCESS = String.raw`
789
+ import json
423
790
  import os
424
791
  import shutil
425
792
  import subprocess
793
+ import tempfile
426
794
  from pathlib import Path
427
795
 
428
796
  _REPO = Path(__file__).resolve().parent.parent
@@ -530,20 +898,64 @@ def _prompt(message, history):
530
898
  return "\n\n".join(lines)
531
899
 
532
900
 
901
+ def _not_visible(reason):
902
+ return {"state": "not_visible", "source": "events_file", "watched": [], "missing": [], "reason": reason}
903
+
904
+
905
+ def _read_events(path):
906
+ """The tool calls the agent wrote to PREMAN_TOOL_EVENTS_FILE, one JSON line each.
907
+
908
+ The agent creates the file even when it calls nothing, so a missing file
909
+ means "this agent does not report", never "it called no tools".
910
+ """
911
+ if not os.path.exists(path):
912
+ return [], _not_visible("the agent did not create PREMAN_TOOL_EVENTS_FILE, so its tool calls are not reported")
913
+ events = []
914
+ with open(path, encoding="utf-8") as handle:
915
+ for line in handle:
916
+ line = line.strip()
917
+ if not line:
918
+ continue
919
+ try:
920
+ item = json.loads(line)
921
+ except ValueError:
922
+ return events, _not_visible("PREMAN_TOOL_EVENTS_FILE held a line that is not JSON")
923
+ if not isinstance(item, dict) or not item.get("tool_name"):
924
+ return events, _not_visible("a line in PREMAN_TOOL_EVENTS_FILE has no tool_name")
925
+ args = item.get("tool_args")
926
+ content = item.get("content", "")
927
+ events.append({
928
+ "role": "tool_result",
929
+ "tool_name": str(item["tool_name"]),
930
+ "tool_args": args if isinstance(args, dict) else {},
931
+ "content": content if isinstance(content, str) else json.dumps(content, ensure_ascii=False, default=str),
932
+ })
933
+ return events, {"state": "recorded", "source": "events_file", "watched": [], "missing": [], "reason": ""}
934
+
935
+
533
936
  def _turn(message, history):
534
937
  argv = [_binary()] + list(_ARGV[1:])
535
- done = subprocess.run(
536
- argv,
537
- input=_prompt(message, history),
538
- capture_output=True,
539
- text=True,
540
- cwd=str(_REPO),
541
- timeout=_TIMEOUT,
542
- )
543
- if done.returncode != 0:
544
- tail = (done.stderr or "").strip()[-600:]
545
- raise RuntimeError("%s exited %s: %s" % (_ARGV[0], done.returncode, tail))
546
- return done.stdout.strip(), []
938
+ # One file per turn: each turn is its own process, so nothing can be filed
939
+ # under the wrong turn.
940
+ folder = tempfile.mkdtemp(prefix="preman-tools-")
941
+ events_path = os.path.join(folder, "tool_events.jsonl")
942
+ try:
943
+ done = subprocess.run(
944
+ argv,
945
+ input=_prompt(message, history),
946
+ capture_output=True,
947
+ text=True,
948
+ cwd=str(_REPO),
949
+ timeout=_TIMEOUT,
950
+ env=dict(os.environ, PREMAN_TOOL_EVENTS_FILE=events_path),
951
+ )
952
+ if done.returncode != 0:
953
+ tail = (done.stderr or "").strip()[-600:]
954
+ raise RuntimeError("%s exited %s: %s" % (_ARGV[0], done.returncode, tail))
955
+ events, capture = _read_events(events_path)
956
+ finally:
957
+ shutil.rmtree(folder, ignore_errors=True)
958
+ return done.stdout.strip(), events, capture
547
959
  `;
548
960
 
549
961
  // ── Assembly ────────────────────────────────────────────────────────────
@@ -596,7 +1008,7 @@ export function buildWrapper(found, { tree = [] } = {}) {
596
1008
 
597
1009
  if (shape === "cli_subprocess") {
598
1010
  return {
599
- shim: header + CLI_SUBPROCESS.replace("%%COMMAND%%", litList(command)) + PY_NORMALISE,
1011
+ shim: header + CLI_SUBPROCESS.replace("%%COMMAND%%", litList(command)) + PY_NORMALISE + PY_CHAT_REPORTED,
600
1012
  bridge: "",
601
1013
  };
602
1014
  }
@@ -615,7 +1027,8 @@ export function buildWrapper(found, { tree = [] } = {}) {
615
1027
  + NODE_BRIDGE
616
1028
  .replace("%%NODE_RUNTIME%%", lit(runtime))
617
1029
  .replace("%%ENTRY_SYMBOL%%", lit(symbol))
618
- + PY_NORMALISE;
1030
+ + PY_NORMALISE
1031
+ + PY_CHAT;
619
1032
  const bridge = NODE_SIDE
620
1033
  .replace(/%%NODE_ENTRY%%/g, lit("../" + nodeEntry))
621
1034
  .replace(/%%ENTRY_SYMBOL%%/g, lit(symbol));
@@ -631,12 +1044,20 @@ export function buildWrapper(found, { tree = [] } = {}) {
631
1044
  turn = turn.replace("%%METHOD%%", lit(method));
632
1045
  }
633
1046
 
634
- const shim = header
635
- + PY_LOADER
636
- .replace("%%ENTRY_FILE%%", lit(entryFile))
637
- .replace("%%ENTRY_SYMBOL%%", lit(symbol))
638
- + turn
639
- + PY_NORMALISE;
1047
+ const loader = PY_LOADER
1048
+ .replace("%%ENTRY_FILE%%", lit(entryFile))
1049
+ .replace("%%ENTRY_SYMBOL%%", lit(symbol));
1050
+
1051
+ // langgraph reads tool calls out of the graph's own messages (§5.1 of the
1052
+ // design), not by wrapping functions, so it keeps the plain `chat()`.
1053
+ if (shape === "langgraph") {
1054
+ return { shim: header + loader + turn + PY_NORMALISE + PY_CHAT, bridge: "" };
1055
+ }
1056
+ // Checked only for the shapes that use it, so a tool path this shape never
1057
+ // reads cannot fail the build. Every value is checked before it is quoted
1058
+ // into the template, so a tool value a model invented cannot interpolate.
1059
+ const tools = toolsLiteral(found?.tools);
1060
+ const shim = header + loader + PY_CAPTURE.replace("%%TOOLS%%", tools) + turn + PY_NORMALISE + PY_CHAT_CAPTURING;
640
1061
  return { shim, bridge: "" };
641
1062
  }
642
1063
 
@@ -660,6 +1081,9 @@ export function buildFreeform(found, { tree = [], turnSource = "" } = {}) {
660
1081
  }
661
1082
  const body = String(turnSource || "").trimEnd();
662
1083
  if (!body.trim()) throw new TemplateError("no conversion body was returned");
1084
+ // Computed before any text is built, so a tool value a model invented does
1085
+ // not get the chance to interpolate into a template.
1086
+ const tools = toolsLiteral(found?.tools);
663
1087
 
664
1088
  const header = HEADER
665
1089
  .replace("%%SHAPE%%", "other")
@@ -676,9 +1100,11 @@ export function buildFreeform(found, { tree = [], turnSource = "" } = {}) {
676
1100
  + PY_LOADER
677
1101
  .replace("%%ENTRY_FILE%%", lit(entryFile))
678
1102
  .replace("%%ENTRY_SYMBOL%%", lit(symbol))
1103
+ + PY_CAPTURE.replace("%%TOOLS%%", tools)
679
1104
  + "\n\ndef _turn(message, history):\n"
680
1105
  + ` ${symbol} = _target()\n`
681
1106
  + body + "\n"
682
- + PY_NORMALISE;
1107
+ + PY_NORMALISE
1108
+ + PY_CHAT_CAPTURING;
683
1109
  return { shim, bridge: "" };
684
1110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "premanmcp",
3
- "version": "1.1.12",
3
+ "version": "1.1.13",
4
4
  "description": "PreMan CLI and stdio proxy for PreMan's hosted MCP server",
5
5
  "type": "module",
6
6
  "bin": {