premanmcp 1.1.13 → 1.1.15
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/decorate_tools.py +368 -0
- package/bin/eval.js +3 -0
- package/bin/eval_entry.js +134 -6
- package/bin/eval_harness.py +126 -1
- package/bin/eval_target.js +16 -7
- package/bin/eval_templates.js +591 -258
- package/bin/preman/__init__.py +267 -0
- package/bin/shared.js +5 -0
- package/bin/tool_proxy.py +828 -0
- package/package.json +2 -2
package/bin/eval_templates.js
CHANGED
|
@@ -44,21 +44,23 @@ function litList(values) {
|
|
|
44
44
|
return `[${(values || []).map((v) => lit(v)).join(", ")}]`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
const TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
48
|
+
/** Frameworks whose tracing plugin records their own tools (decorate_tools.py COVERED). */
|
|
49
|
+
const COVERING_FRAMEWORKS = new Set(["langchain"]);
|
|
50
|
+
|
|
47
51
|
/**
|
|
48
|
-
* Python
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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.
|
|
52
|
+
* Python literals for the tools the wrapper watches: those carrying
|
|
53
|
+
* @preman_tool, and those a framework's tracing plugin records instead. Every
|
|
54
|
+
* value came from a model or from decorate_tools.py, so each is checked before
|
|
55
|
+
* it is quoted into the template.
|
|
54
56
|
*/
|
|
55
|
-
function
|
|
56
|
-
const
|
|
57
|
-
const
|
|
57
|
+
function decoratedLiterals(tools) {
|
|
58
|
+
const decorated = [];
|
|
59
|
+
const covered = [];
|
|
60
|
+
for (const tool of Array.isArray(tools) ? tools : []) {
|
|
58
61
|
const file = String(tool?.file || "");
|
|
59
62
|
const symbol = String(tool?.symbol || "");
|
|
60
|
-
|
|
61
|
-
// plus no control characters. `lit()` quotes everything else safely.
|
|
63
|
+
const toolName = String(tool?.tool_name || "");
|
|
62
64
|
if (
|
|
63
65
|
!file.endsWith(".py")
|
|
64
66
|
|| file.startsWith("/")
|
|
@@ -70,9 +72,20 @@ function toolsLiteral(tools) {
|
|
|
70
72
|
if (!TOOL_SYMBOL.test(symbol)) {
|
|
71
73
|
throw new TemplateError(`tool symbol ${JSON.stringify(symbol)} is not a name or Class.method`);
|
|
72
74
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
if (!TOOL_NAME.test(toolName)) {
|
|
76
|
+
throw new TemplateError(`tool name ${JSON.stringify(toolName)} is not a name a model can call`);
|
|
77
|
+
}
|
|
78
|
+
const by = String(tool?.covered_by || "");
|
|
79
|
+
if (by) {
|
|
80
|
+
if (!COVERING_FRAMEWORKS.has(by)) {
|
|
81
|
+
throw new TemplateError(`no tracing plugin records ${JSON.stringify(by)} tools`);
|
|
82
|
+
}
|
|
83
|
+
covered.push(`(${lit(file)}, ${lit(symbol)}, ${lit(toolName)}, ${lit(by)})`);
|
|
84
|
+
} else {
|
|
85
|
+
decorated.push(`(${lit(file)}, ${lit(symbol)}, ${lit(toolName)})`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { decorated: `[${decorated.join(", ")}]`, covered: `[${covered.join(", ")}]` };
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
// ── The parts every wrapper shares ──────────────────────────────────────
|
|
@@ -144,46 +157,90 @@ def preman_selfcheck() -> str:
|
|
|
144
157
|
`;
|
|
145
158
|
|
|
146
159
|
/**
|
|
147
|
-
* Tool capture for the Python shapes (docs/superpowers/specs/2026-
|
|
160
|
+
* Tool capture for the Python shapes (docs/superpowers/specs/2026-10-04-tool-decorators-design.md §6).
|
|
148
161
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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.
|
|
162
|
+
* The tools record themselves: @preman_tool in the agent's own code creates a
|
|
163
|
+
* TOOL span per call, which the harness collects. This file only checks that
|
|
164
|
+
* every named tool still carries its decorator, and decides whether a turn's
|
|
165
|
+
* count can be trusted: a tool run in another process, in a thread still
|
|
166
|
+
* running when the reply comes back, or outside any turn would be missed or
|
|
167
|
+
* filed under the wrong turn, so such a turn is "not visible", never "recorded".
|
|
160
168
|
*/
|
|
161
|
-
const
|
|
169
|
+
const PY_DECORATED = String.raw`
|
|
170
|
+
import asyncio
|
|
171
|
+
import concurrent.futures
|
|
162
172
|
import contextvars
|
|
163
173
|
import functools
|
|
164
174
|
import inspect
|
|
165
|
-
import
|
|
175
|
+
import math
|
|
176
|
+
import os
|
|
166
177
|
import threading
|
|
167
178
|
import time
|
|
168
179
|
|
|
169
|
-
|
|
170
|
-
|
|
180
|
+
# (file, symbol, tool name) for each tool carrying @preman_tool, and
|
|
181
|
+
# (file, symbol, tool name, framework) for each tool a framework's tracing
|
|
182
|
+
# plugin records instead.
|
|
183
|
+
_DECORATED = %%DECORATED%%
|
|
184
|
+
_COVERED = %%COVERED%%
|
|
185
|
+
# Set when the customer declined the decorators: every turn says why.
|
|
186
|
+
_NOT_RECORDED = %%NOT_RECORDED%%
|
|
187
|
+
|
|
188
|
+
_FRAMEWORK_PLUGINS = {
|
|
189
|
+
"langchain": ("openinference.instrumentation.langchain", "LangChainInstrumentor"),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_turn_state = contextvars.ContextVar("preman_turn_state", default=None)
|
|
193
|
+
_local = threading.local()
|
|
194
|
+
_patched = []
|
|
171
195
|
_open_turns = {}
|
|
172
196
|
_open_lock = threading.Lock()
|
|
173
197
|
_stray_calls = []
|
|
174
|
-
|
|
198
|
+
_watched = []
|
|
199
|
+
_watched_keys = []
|
|
175
200
|
_missing = {}
|
|
176
201
|
_pending = {}
|
|
177
|
-
|
|
202
|
+
|
|
203
|
+
# An async entry point returns a coroutine. It runs on this one loop, kept for
|
|
204
|
+
# the whole run because an agent object can hold clients tied to the loop that
|
|
205
|
+
# created them, on its own thread so concurrent cases can all wait on it.
|
|
206
|
+
# Started here, before any turn, so the thread rules never count it as the
|
|
207
|
+
# agent's work outliving a turn.
|
|
208
|
+
_async_loop = asyncio.new_event_loop()
|
|
209
|
+
threading.Thread(target=_async_loop.run_forever, name="preman-async-entry", daemon=True).start()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _settle(value):
|
|
213
|
+
if not inspect.isawaitable(value):
|
|
214
|
+
return value
|
|
215
|
+
# The turn state is a context variable; the task gets a copy so the tools
|
|
216
|
+
# it calls are counted in this turn.
|
|
217
|
+
context = contextvars.copy_context()
|
|
218
|
+
done = concurrent.futures.Future()
|
|
219
|
+
|
|
220
|
+
async def awaited():
|
|
221
|
+
return await value
|
|
222
|
+
|
|
223
|
+
def finished(task):
|
|
224
|
+
if task.cancelled():
|
|
225
|
+
done.cancel()
|
|
226
|
+
elif task.exception() is not None:
|
|
227
|
+
done.set_exception(task.exception())
|
|
228
|
+
else:
|
|
229
|
+
done.set_result(task.result())
|
|
230
|
+
|
|
231
|
+
def start():
|
|
232
|
+
_async_loop.create_task(awaited(), context=context).add_done_callback(finished)
|
|
233
|
+
|
|
234
|
+
_async_loop.call_soon_threadsafe(start)
|
|
235
|
+
return done.result()
|
|
178
236
|
_install_lock = threading.Lock()
|
|
237
|
+
_HIDDEN = "a module named preman in this project hides PreMan's recorder"
|
|
179
238
|
|
|
180
239
|
|
|
181
240
|
def _tool_module(file):
|
|
182
241
|
# Only a module that is already loaded, found by the file it came from.
|
|
183
242
|
# Importing it here under a name guessed from its path would run the
|
|
184
|
-
# customer's module-level code a second time
|
|
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.
|
|
243
|
+
# customer's module-level code a second time.
|
|
187
244
|
if (_REPO / file).resolve() == _SRC.resolve():
|
|
188
245
|
return _agent()
|
|
189
246
|
wanted = (_REPO / file).resolve()
|
|
@@ -198,251 +255,289 @@ def _tool_module(file):
|
|
|
198
255
|
return None
|
|
199
256
|
|
|
200
257
|
|
|
201
|
-
def
|
|
202
|
-
|
|
258
|
+
def _resolve(mod, symbol):
|
|
259
|
+
owner_name, _, method = symbol.partition(".")
|
|
260
|
+
owner = getattr(mod, owner_name, None)
|
|
261
|
+
if owner is None or not method:
|
|
262
|
+
return owner
|
|
263
|
+
raw = inspect.getattr_static(owner, method, None)
|
|
264
|
+
if isinstance(raw, property):
|
|
265
|
+
raw = raw.fget
|
|
266
|
+
return getattr(raw, "__func__", raw) # staticmethod / classmethod
|
|
203
267
|
|
|
204
268
|
|
|
205
|
-
def
|
|
206
|
-
""
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
269
|
+
def _listed(preman, file, tool_name):
|
|
270
|
+
# None: no @preman_tool("tool_name") ran in that file. False: one ran above
|
|
271
|
+
# an object-returning decorator.
|
|
272
|
+
wanted = str((_REPO / file).resolve())
|
|
273
|
+
found = [placed for where, name, placed in preman.decorated() if where == wanted and name == tool_name]
|
|
274
|
+
return all(found) if found else None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _observer(event, name, detail):
|
|
278
|
+
# Called by preman.tool around each recorded call, in the caller's context.
|
|
279
|
+
turn = _turn_state.get()
|
|
280
|
+
with _open_lock:
|
|
281
|
+
if turn is None or turn["closed"]:
|
|
282
|
+
_stray_calls.append(time.monotonic())
|
|
283
|
+
return
|
|
284
|
+
if event == "start":
|
|
285
|
+
turn["open"] += 1
|
|
286
|
+
elif event == "end":
|
|
287
|
+
turn["open"] -= 1
|
|
288
|
+
elif event == "broken":
|
|
289
|
+
turn["broken"].append("%s (%s)" % (name, detail))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _framework_traced(framework):
|
|
293
|
+
module_name, cls_name = _FRAMEWORK_PLUGINS.get(framework, ("", ""))
|
|
294
|
+
cls = getattr(sys.modules.get(module_name), cls_name, None)
|
|
214
295
|
try:
|
|
215
|
-
|
|
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
|
-
}
|
|
296
|
+
return bool(cls is not None and cls().is_instrumented_by_opentelemetry)
|
|
239
297
|
except Exception:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return
|
|
298
|
+
return False
|
|
299
|
+
|
|
243
300
|
|
|
244
|
-
|
|
245
|
-
|
|
301
|
+
def _thread_wait():
|
|
302
|
+
# How long a turn waits for threads it started that are still running.
|
|
303
|
+
try:
|
|
304
|
+
value = float(os.environ.get("PREMAN_THREAD_WAIT", "1"))
|
|
305
|
+
except ValueError:
|
|
306
|
+
return 1.0
|
|
307
|
+
return max(0.0, value) if math.isfinite(value) else 1.0 # "inf" would hang the case
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _track(fn):
|
|
311
|
+
# (carried over from the earlier wrapper) fn, set to run in a copy of the caller's
|
|
312
|
+
# context -- so a tool it calls files under the caller's turn, and its span
|
|
313
|
+
# under the caller's span -- and counted against that turn until it finishes.
|
|
314
|
+
ctx = contextvars.copy_context()
|
|
315
|
+
turn = _turn_state.get()
|
|
316
|
+
if turn is not None:
|
|
246
317
|
with _open_lock:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
calls.append(event)
|
|
318
|
+
turn["pending"] += 1
|
|
319
|
+
done = []
|
|
250
320
|
|
|
321
|
+
def finish():
|
|
322
|
+
if turn is not None and not done:
|
|
323
|
+
done.append(True)
|
|
324
|
+
with _open_lock:
|
|
325
|
+
turn["pending"] -= 1
|
|
251
326
|
|
|
252
|
-
def
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
327
|
+
def run(*args, **kwargs):
|
|
328
|
+
try:
|
|
329
|
+
return ctx.run(fn, *args, **kwargs)
|
|
330
|
+
finally:
|
|
331
|
+
finish()
|
|
332
|
+
|
|
333
|
+
run.__preman_finish__ = finish
|
|
334
|
+
return run
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _repo_code(thread):
|
|
338
|
+
# NEW (hooks design §7.1): a thread whose code lives in a library
|
|
339
|
+
# (braintrust's log publisher, LangSmith, Sentry, an OTel exporter) is not
|
|
340
|
+
# the agent's work, and many run for the life of the process. Unknown code
|
|
341
|
+
# counts as the agent's. site-packages is checked because a .venv often
|
|
342
|
+
# lives inside the repository.
|
|
343
|
+
fn = getattr(thread, "_target", None) or type(thread).run
|
|
344
|
+
fn = getattr(fn, "func", fn) # functools.partial
|
|
345
|
+
fn = getattr(fn, "__func__", fn) # bound method
|
|
256
346
|
try:
|
|
257
|
-
|
|
347
|
+
fn = inspect.unwrap(fn) # @preman_tool's closure lives outside the repo
|
|
258
348
|
except Exception:
|
|
259
349
|
pass
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
350
|
+
code = getattr(fn, "__code__", None)
|
|
351
|
+
if code is None:
|
|
352
|
+
return True
|
|
353
|
+
path = Path(code.co_filename).resolve()
|
|
354
|
+
if "site-packages" in path.parts or "dist-packages" in path.parts:
|
|
355
|
+
return False
|
|
356
|
+
return _REPO in path.parents
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _patch_threads():
|
|
360
|
+
# (carried over from the earlier wrapper except where marked)
|
|
361
|
+
import concurrent.futures
|
|
362
|
+
import multiprocessing.pool
|
|
363
|
+
import multiprocessing.process
|
|
364
|
+
|
|
365
|
+
pool_submit = concurrent.futures.ThreadPoolExecutor.submit
|
|
366
|
+
|
|
367
|
+
@functools.wraps(pool_submit)
|
|
368
|
+
def submit(self, fn, /, *args, **kwargs):
|
|
369
|
+
run = _track(fn)
|
|
370
|
+
before = getattr(_local, "in_submit", False)
|
|
371
|
+
_local.in_submit = True
|
|
372
|
+
try:
|
|
373
|
+
return pool_submit(self, run, *args, **kwargs)
|
|
374
|
+
except BaseException:
|
|
375
|
+
run.__preman_finish__()
|
|
376
|
+
raise
|
|
377
|
+
finally:
|
|
378
|
+
_local.in_submit = before
|
|
379
|
+
|
|
380
|
+
thread_start = threading.Thread.start
|
|
381
|
+
|
|
382
|
+
@functools.wraps(thread_start)
|
|
383
|
+
def start(self):
|
|
384
|
+
# CHANGED: _turn_state instead of the removed _turn_calls, and library
|
|
385
|
+
# threads are left alone.
|
|
386
|
+
if (_turn_state.get() is None or getattr(_local, "in_submit", False)
|
|
387
|
+
or not _repo_code(self)):
|
|
388
|
+
return thread_start(self)
|
|
389
|
+
original = self.run
|
|
390
|
+
self.run = _track(original)
|
|
391
|
+
try:
|
|
392
|
+
return thread_start(self)
|
|
393
|
+
except BaseException:
|
|
394
|
+
self.run.__preman_finish__()
|
|
395
|
+
self.run = original
|
|
396
|
+
raise
|
|
265
397
|
|
|
266
|
-
def
|
|
267
|
-
if inspect.iscoroutinefunction(original):
|
|
398
|
+
def flagging(original):
|
|
268
399
|
@functools.wraps(original)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
400
|
+
def sent(self, *args, **kwargs):
|
|
401
|
+
turn = _turn_state.get()
|
|
402
|
+
if turn is not None and not isinstance(self, multiprocessing.pool.ThreadPool):
|
|
403
|
+
turn["process"] = True
|
|
404
|
+
return original(self, *args, **kwargs)
|
|
405
|
+
return sent
|
|
406
|
+
|
|
407
|
+
concurrent.futures.ThreadPoolExecutor.submit = submit
|
|
408
|
+
threading.Thread.start = start
|
|
409
|
+
for owner, name in (
|
|
410
|
+
(concurrent.futures.ProcessPoolExecutor, "submit"),
|
|
411
|
+
(multiprocessing.pool.Pool, "apply_async"),
|
|
412
|
+
(multiprocessing.pool.Pool, "_map_async"),
|
|
413
|
+
(multiprocessing.pool.Pool, "imap"),
|
|
414
|
+
(multiprocessing.pool.Pool, "imap_unordered"),
|
|
415
|
+
(multiprocessing.process.BaseProcess, "start"),
|
|
416
|
+
):
|
|
417
|
+
setattr(owner, name, flagging(getattr(owner, name)))
|
|
313
418
|
|
|
314
419
|
|
|
315
420
|
def _install():
|
|
316
|
-
# Serialized, and the agent loaded first
|
|
317
|
-
#
|
|
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.
|
|
421
|
+
# Serialized, and the agent loaded first inside the lock, for the reason
|
|
422
|
+
# the earlier wrapper gave: concurrent first calls must all see one loaded module.
|
|
325
423
|
with _install_lock:
|
|
424
|
+
if not _patched:
|
|
425
|
+
_patch_threads()
|
|
426
|
+
_patched.append(True)
|
|
326
427
|
_agent()
|
|
327
|
-
|
|
428
|
+
try:
|
|
429
|
+
import preman
|
|
430
|
+
except ImportError:
|
|
431
|
+
preman = None
|
|
432
|
+
# The customer's own preman.py at the top of the repo is found first.
|
|
433
|
+
hidden = preman is not None and not hasattr(preman, "_observer")
|
|
434
|
+
if preman is not None and not hidden:
|
|
435
|
+
preman._observer = _observer
|
|
436
|
+
for file, symbol, tool_name in _DECORATED:
|
|
328
437
|
key = "%s:%s" % (file, symbol)
|
|
329
|
-
if key in
|
|
438
|
+
if key in _watched_keys:
|
|
330
439
|
continue
|
|
331
440
|
mod = _tool_module(file)
|
|
332
441
|
if mod is None:
|
|
333
442
|
_pending[key] = "module not loaded yet"
|
|
334
443
|
continue
|
|
335
444
|
_pending.pop(key, None)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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)
|
|
445
|
+
fn = _resolve(mod, symbol)
|
|
446
|
+
if fn is None:
|
|
447
|
+
_missing[key] = "not defined"
|
|
448
|
+
elif preman is None:
|
|
449
|
+
_missing[key] = "PreMan's recorder (preman) could not be imported in this run"
|
|
450
|
+
elif hidden:
|
|
451
|
+
_missing[key] = _HIDDEN
|
|
452
|
+
else:
|
|
453
|
+
placed = _listed(preman, file, tool_name)
|
|
454
|
+
if placed is None:
|
|
455
|
+
_missing[key] = 'has no @preman_tool("%s")' % tool_name
|
|
456
|
+
elif not placed:
|
|
457
|
+
_missing[key] = ('@preman_tool("%s") is above a decorator that returns an object, '
|
|
458
|
+
'so calls skip it; move it directly beneath that decorator' % tool_name)
|
|
357
459
|
else:
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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]
|
|
460
|
+
_missing.pop(key, None)
|
|
461
|
+
_watched_keys.append(key)
|
|
462
|
+
_watched.append(tool_name)
|
|
380
463
|
|
|
381
464
|
|
|
382
465
|
def _begin_turn():
|
|
383
|
-
|
|
384
|
-
|
|
466
|
+
turn = {"pending": 0, "process": False, "closed": False, "open": 0,
|
|
467
|
+
"broken": [], "started": time.monotonic()}
|
|
468
|
+
token = (_turn_state.set(turn), turn)
|
|
385
469
|
marker = object()
|
|
386
470
|
with _open_lock:
|
|
387
471
|
_open_turns[id(marker)] = time.monotonic()
|
|
388
|
-
return token, marker
|
|
472
|
+
return token, marker
|
|
389
473
|
|
|
390
474
|
|
|
391
|
-
def _end_turn(token, marker
|
|
392
|
-
|
|
475
|
+
def _end_turn(token, marker):
|
|
476
|
+
state_token, turn = token
|
|
477
|
+
_turn_state.reset(state_token)
|
|
478
|
+
deadline = time.monotonic() + _thread_wait()
|
|
479
|
+
while turn["pending"] and time.monotonic() < deadline:
|
|
480
|
+
time.sleep(0.01)
|
|
393
481
|
now = time.monotonic()
|
|
394
482
|
with _open_lock:
|
|
483
|
+
turn["closed"] = True
|
|
484
|
+
still_running = turn["pending"] or turn["open"]
|
|
395
485
|
started = _open_turns.pop(id(marker))
|
|
396
486
|
stray = any(started <= t <= now for t in _stray_calls)
|
|
397
487
|
oldest = min(_open_turns.values(), default=now)
|
|
398
488
|
_stray_calls[:] = [t for t in _stray_calls if t >= oldest]
|
|
399
|
-
|
|
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.
|
|
489
|
+
broken = list(turn["broken"])
|
|
403
490
|
with _install_lock:
|
|
404
|
-
|
|
491
|
+
watched = list(_watched)
|
|
405
492
|
missing = sorted(_missing)
|
|
406
|
-
unseen = dict(_pending)
|
|
407
|
-
unseen.update(_unsupported)
|
|
408
493
|
not_watched = dict(_missing)
|
|
409
|
-
not_watched.update(
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
494
|
+
not_watched.update(_pending)
|
|
495
|
+
frameworks = sorted({fw for _, _, _, fw in _COVERED})
|
|
496
|
+
untraced = [fw for fw in frameworks if not _framework_traced(fw)]
|
|
497
|
+
if _NOT_RECORDED:
|
|
498
|
+
state, reason = "not_visible", _NOT_RECORDED
|
|
499
|
+
elif not _DECORATED and not _COVERED:
|
|
500
|
+
state, reason = "not_visible", "PreMan found no tool functions in this agent"
|
|
501
|
+
elif os.environ.get("PREMAN_EVAL_TRACE") != "1":
|
|
502
|
+
state, reason = "not_visible", "tracing is switched off for this run, and @preman_tool records through it"
|
|
503
|
+
elif _DECORATED and not os.environ.get("PREMAN_EVAL_RUN"):
|
|
504
|
+
state, reason = "not_visible", "PREMAN_EVAL_RUN is not set, so @preman_tool records nothing"
|
|
417
505
|
elif not_watched:
|
|
418
506
|
state, reason = "not_visible", "not watched: " + "; ".join(
|
|
419
507
|
"%s (%s)" % (k, v) for k, v in sorted(not_watched.items())
|
|
420
508
|
)
|
|
509
|
+
elif untraced:
|
|
510
|
+
state, reason = "not_visible", "%s tools are recorded by its tracing plugin, which is not switched on in this run" % ", ".join(untraced)
|
|
511
|
+
elif broken:
|
|
512
|
+
state, reason = "not_visible", "PreMan's recorder could not record: " + "; ".join(broken)
|
|
421
513
|
elif stray:
|
|
422
514
|
state, reason = "not_visible", "a tool ran outside any turn, in a thread the agent started"
|
|
515
|
+
elif turn["process"]:
|
|
516
|
+
state, reason = "not_visible", "work was sent to another process, where tools are not watched"
|
|
517
|
+
elif still_running:
|
|
518
|
+
state, reason = "not_visible", "a thread started in this turn was still running when the reply came back"
|
|
423
519
|
else:
|
|
424
520
|
state, reason = "recorded", ""
|
|
521
|
+
covered_names = [name for _, _, name, fw in _COVERED if fw not in untraced]
|
|
425
522
|
return {
|
|
426
523
|
"state": state,
|
|
427
|
-
"source": "
|
|
428
|
-
"watched":
|
|
524
|
+
"source": "decorated",
|
|
525
|
+
"watched": watched + covered_names,
|
|
429
526
|
"missing": missing,
|
|
430
527
|
"reason": reason,
|
|
431
528
|
}
|
|
432
529
|
|
|
433
530
|
|
|
434
531
|
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
532
|
_install()
|
|
439
533
|
_target()
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
534
|
+
if _HIDDEN in _missing.values():
|
|
535
|
+
raise RuntimeError(
|
|
536
|
+
_HIDDEN + ". Rename that module so PreMan can record the agent's tool calls."
|
|
537
|
+
)
|
|
443
538
|
if _missing:
|
|
444
539
|
raise RuntimeError(
|
|
445
|
-
"tool functions
|
|
540
|
+
"tool functions PreMan watches are gone or no longer carry @preman_tool: "
|
|
446
541
|
+ "; ".join("%s (%s)" % (k, v) for k, v in sorted(_missing.items()))
|
|
447
542
|
+ ". Re-run 'preman eval target'."
|
|
448
543
|
)
|
|
@@ -502,15 +597,8 @@ def _text(value):
|
|
|
502
597
|
return "" if value is None else str(value)
|
|
503
598
|
`;
|
|
504
599
|
|
|
505
|
-
/** `chat()` for
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
def chat(message: str, history: list | None = None) -> dict:
|
|
509
|
-
reply, events = _turn(message, history or [])
|
|
510
|
-
return {"response": _text(reply), "events": list(events or [])}
|
|
511
|
-
`;
|
|
512
|
-
|
|
513
|
-
/** `chat()` for `cli_subprocess`: the agent reports its own calls through a file. */
|
|
600
|
+
/** `chat()` for shapes whose `_turn` returns (reply, events, tool_capture): cli_subprocess,
|
|
601
|
+
* node_bridge and langgraph. */
|
|
514
602
|
const PY_CHAT_REPORTED = String.raw`
|
|
515
603
|
|
|
516
604
|
def chat(message: str, history: list | None = None) -> dict:
|
|
@@ -518,21 +606,19 @@ def chat(message: str, history: list | None = None) -> dict:
|
|
|
518
606
|
return {"response": _text(reply), "events": list(events or []), "tool_capture": capture}
|
|
519
607
|
`;
|
|
520
608
|
|
|
521
|
-
/** `chat()` for the Python shapes: the turn runs inside a capture window.
|
|
522
|
-
|
|
609
|
+
/** `chat()` for the Python shapes: the turn runs inside a capture window. The
|
|
610
|
+
* calls themselves arrive as spans; this adds no events of its own. */
|
|
611
|
+
const PY_CHAT_DECORATED = String.raw`
|
|
523
612
|
|
|
524
613
|
def chat(message: str, history: list | None = None) -> dict:
|
|
525
614
|
_install()
|
|
526
|
-
token, marker
|
|
615
|
+
token, marker = _begin_turn()
|
|
527
616
|
try:
|
|
528
617
|
reply, events = _turn(message, history or [])
|
|
618
|
+
reply = _settle(reply)
|
|
529
619
|
finally:
|
|
530
|
-
capture = _end_turn(token, marker
|
|
531
|
-
return {
|
|
532
|
-
"response": _text(reply),
|
|
533
|
-
"events": list(events or []) + calls,
|
|
534
|
-
"tool_capture": capture,
|
|
535
|
-
}
|
|
620
|
+
capture = _end_turn(token, marker)
|
|
621
|
+
return {"response": _text(reply), "events": list(events or []), "tool_capture": capture}
|
|
536
622
|
`;
|
|
537
623
|
|
|
538
624
|
// ── One `_turn` per shape ───────────────────────────────────────────────
|
|
@@ -601,6 +687,8 @@ def _turn(message, history):
|
|
|
601
687
|
# \`m.type\`, which a dict does not have -- measured: passing dicts broke a
|
|
602
688
|
# fixture whose reference wrapper passed objects. The fallback below carries
|
|
603
689
|
# both shapes at once so a node written either way works.
|
|
690
|
+
import json
|
|
691
|
+
|
|
604
692
|
try:
|
|
605
693
|
from langchain_core.messages import AIMessage, HumanMessage
|
|
606
694
|
|
|
@@ -626,12 +714,109 @@ def _turn(message, history):
|
|
|
626
714
|
messages.append(_msg(role, (item or {}).get("content", "")))
|
|
627
715
|
messages.append(_msg("user", message))
|
|
628
716
|
state = _target().invoke({"messages": messages})
|
|
629
|
-
|
|
717
|
+
events, capture = _graph_calls(state)
|
|
718
|
+
return state, events, capture
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _field(item, name):
|
|
722
|
+
return item.get(name) if isinstance(item, dict) else getattr(item, name, None)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _graph_calls(state):
|
|
726
|
+
# A call is a tool request (AIMessage.tool_calls) paired with its result
|
|
727
|
+
# (ToolMessage, same tool_call_id). A request with no result is not counted:
|
|
728
|
+
# nothing shows it ran. The input holds only text messages, so every pair
|
|
729
|
+
# found here happened in this turn. Paired in one pass in message order, so
|
|
730
|
+
# an id the provider reuses later in the turn still pairs each result with
|
|
731
|
+
# the request just before it.
|
|
732
|
+
messages = _field(state, "messages")
|
|
733
|
+
if not isinstance(messages, list):
|
|
734
|
+
reason = "the graph returned no messages list, so its tool calls cannot be read"
|
|
735
|
+
return [], {"state": "not_visible", "source": "langgraph", "watched": [], "missing": [], "reason": reason,
|
|
736
|
+
"trace": {"links": "order_only", "reason": reason, "known": [], "unknown": {}, "calls": []}}
|
|
737
|
+
pending = {} # id -> (request, trace entry) still waiting for its result
|
|
738
|
+
events, trace = [], []
|
|
739
|
+
step = 0
|
|
740
|
+
unpaired_request = unpaired_result = shared_id = False
|
|
741
|
+
for m in messages:
|
|
742
|
+
asked = _field(m, "tool_calls") or []
|
|
743
|
+
if asked:
|
|
744
|
+
step += 1 # one model message asking for tools is one step
|
|
745
|
+
for call in asked:
|
|
746
|
+
if not (isinstance(call, dict) and call.get("id")):
|
|
747
|
+
unpaired_request = True # no id: nothing can pair it with a result
|
|
748
|
+
elif call["id"] in pending:
|
|
749
|
+
shared_id = True # two waiting requests, one id: results can't be told apart
|
|
750
|
+
else:
|
|
751
|
+
entry = {"id": "c%d" % (len(trace) + 1), "tool": str(call.get("name") or ""), "step": step, "event": None}
|
|
752
|
+
trace.append(entry)
|
|
753
|
+
pending[call["id"]] = (call, entry)
|
|
754
|
+
if _field(m, "type") != "tool" and _field(m, "role") != "tool":
|
|
755
|
+
continue
|
|
756
|
+
found = pending.pop(_field(m, "tool_call_id"), None)
|
|
757
|
+
if found is None:
|
|
758
|
+
unpaired_result = True
|
|
759
|
+
continue
|
|
760
|
+
call, entry = found
|
|
761
|
+
entry["event"] = len(events)
|
|
762
|
+
content = _field(m, "content")
|
|
763
|
+
if not isinstance(content, str):
|
|
764
|
+
content = json.dumps(content, ensure_ascii=False, default=str)
|
|
765
|
+
args = call.get("args")
|
|
766
|
+
events.append({
|
|
767
|
+
"role": "tool_result",
|
|
768
|
+
"tool_name": str(call.get("name") or _field(m, "name") or ""),
|
|
769
|
+
"tool_args": json.loads(json.dumps(args if isinstance(args, dict) else {}, ensure_ascii=False, default=str)),
|
|
770
|
+
"content": content,
|
|
771
|
+
})
|
|
772
|
+
# Only answered calls ran; ids and steps renumbered so neither has gaps.
|
|
773
|
+
answered = [e for e in trace if e["event"] is not None]
|
|
774
|
+
steps = {}
|
|
775
|
+
for n, entry in enumerate(answered, 1):
|
|
776
|
+
entry["id"] = "c%d" % n
|
|
777
|
+
entry["step"] = steps.setdefault(entry["step"], len(steps) + 1)
|
|
778
|
+
shape = {"links": "full", "known": ["step"],
|
|
779
|
+
"unknown": {"parent": "the graph's messages do not show a tool calling another tool"},
|
|
780
|
+
"calls": answered}
|
|
781
|
+
|
|
782
|
+
def outcome(state, reason=""):
|
|
783
|
+
return events, {"state": state, "source": "langgraph", "watched": [], "missing": [],
|
|
784
|
+
"reason": reason, "trace": shape}
|
|
785
|
+
|
|
786
|
+
if shared_id:
|
|
787
|
+
return outcome("not_visible", "the graph asked for two tools under one id at once, so their results cannot be told apart")
|
|
788
|
+
# Either half on its own means a tool may have run without being counted.
|
|
789
|
+
if unpaired_request or pending:
|
|
790
|
+
return outcome("not_visible", "the graph asked for a tool whose result is not in its messages")
|
|
791
|
+
if unpaired_result:
|
|
792
|
+
return outcome("not_visible", "a tool result in the graph's messages matches no request")
|
|
793
|
+
return outcome("recorded")
|
|
630
794
|
`,
|
|
631
795
|
};
|
|
632
796
|
|
|
633
797
|
// ── Shapes that are not a Python import at all ──────────────────────────
|
|
634
798
|
|
|
799
|
+
/** Loads the CLI's recording proxy (bin/tool_proxy.py), or None. Spec §6.0.2. */
|
|
800
|
+
const PY_PROXY = String.raw`
|
|
801
|
+
|
|
802
|
+
def _tool_proxy():
|
|
803
|
+
# Shipped with the CLI rather than written into this file, so a proxy fix
|
|
804
|
+
# reaches every repository without re-approving it. PREMAN_CLI_BIN is set by
|
|
805
|
+
# every CLI launch; added here because "python -c" and PYTHONSAFEPATH both
|
|
806
|
+
# leave the CLI's folder off sys.path.
|
|
807
|
+
folder = os.environ.get("PREMAN_CLI_BIN")
|
|
808
|
+
if folder and folder not in sys.path:
|
|
809
|
+
sys.path.insert(0, folder)
|
|
810
|
+
try:
|
|
811
|
+
import tool_proxy
|
|
812
|
+
# Started here so a proxy that imports but cannot start (bind error, bad
|
|
813
|
+
# PREMAN_AGENT_TIMEOUT) is "no proxy" and the agent still runs.
|
|
814
|
+
tool_proxy.shared()
|
|
815
|
+
except Exception:
|
|
816
|
+
return None
|
|
817
|
+
return tool_proxy
|
|
818
|
+
`;
|
|
819
|
+
|
|
635
820
|
/**
|
|
636
821
|
* A JS/TS agent, over one long-lived node process.
|
|
637
822
|
*
|
|
@@ -645,8 +830,10 @@ def _turn(message, history):
|
|
|
645
830
|
*/
|
|
646
831
|
const NODE_BRIDGE = String.raw`
|
|
647
832
|
import json
|
|
833
|
+
import os
|
|
648
834
|
import shutil
|
|
649
835
|
import subprocess
|
|
836
|
+
import sys
|
|
650
837
|
import threading
|
|
651
838
|
|
|
652
839
|
_REPO = Path(__file__).resolve().parent.parent
|
|
@@ -674,6 +861,8 @@ def _why() -> str:
|
|
|
674
861
|
return ""
|
|
675
862
|
if not text:
|
|
676
863
|
return ""
|
|
864
|
+
if _channel:
|
|
865
|
+
text = text.replace(_channel, "<proxy>")
|
|
677
866
|
for line in text.splitlines():
|
|
678
867
|
stripped = line.strip()
|
|
679
868
|
if "Error" in stripped and len(stripped) > 12:
|
|
@@ -700,9 +889,31 @@ def _runtime() -> str:
|
|
|
700
889
|
)
|
|
701
890
|
|
|
702
891
|
|
|
892
|
+
_proxy = None
|
|
893
|
+
_channel = None
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _proxy_off() -> bool:
|
|
897
|
+
# PREMAN_TOOL_PROXY=0 runs the agent against its own model address, unproxied.
|
|
898
|
+
return os.environ.get("PREMAN_TOOL_PROXY") == "0"
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _proxy_channel():
|
|
902
|
+
global _proxy, _channel
|
|
903
|
+
if _channel is None and not _proxy_off():
|
|
904
|
+
module = _tool_proxy()
|
|
905
|
+
if module is not None:
|
|
906
|
+
_proxy = module
|
|
907
|
+
_channel = module.shared().channel()
|
|
908
|
+
return _channel
|
|
909
|
+
|
|
910
|
+
|
|
703
911
|
def _bridge():
|
|
704
912
|
global _proc
|
|
705
913
|
if _proc is None or _proc.poll() is not None:
|
|
914
|
+
env = dict(os.environ)
|
|
915
|
+
if _proxy_channel():
|
|
916
|
+
env.update(_proxy.shared().env(_channel))
|
|
706
917
|
_proc = subprocess.Popen(
|
|
707
918
|
[_runtime(), str(_BRIDGE)],
|
|
708
919
|
cwd=str(_REPO),
|
|
@@ -711,6 +922,7 @@ def _bridge():
|
|
|
711
922
|
stderr=open(_STDERR, "w"),
|
|
712
923
|
text=True,
|
|
713
924
|
bufsize=1,
|
|
925
|
+
env=env,
|
|
714
926
|
)
|
|
715
927
|
return _proc
|
|
716
928
|
|
|
@@ -725,18 +937,53 @@ def preman_selfcheck() -> str:
|
|
|
725
937
|
return %%ENTRY_SYMBOL%%
|
|
726
938
|
|
|
727
939
|
|
|
940
|
+
def _is_tool(event):
|
|
941
|
+
return str((event or {}).get("role") or "").startswith("tool") or bool((event or {}).get("tool_name"))
|
|
942
|
+
|
|
943
|
+
|
|
728
944
|
def _turn(message, history):
|
|
729
945
|
with _lock:
|
|
730
946
|
proc = _bridge()
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
947
|
+
if _channel:
|
|
948
|
+
_proxy.shared().begin(_channel)
|
|
949
|
+
try:
|
|
950
|
+
proc.stdin.write(json.dumps({"message": message, "history": history}) + "\n")
|
|
951
|
+
proc.stdin.flush()
|
|
952
|
+
line = proc.stdout.readline()
|
|
953
|
+
finally:
|
|
954
|
+
proxied = _proxy.shared().end(_channel) if _channel else None
|
|
734
955
|
if not line:
|
|
735
956
|
raise RuntimeError("the node bridge closed without answering%s" % _why())
|
|
736
957
|
parsed = json.loads(line)
|
|
737
958
|
if parsed.get("error"):
|
|
738
|
-
|
|
739
|
-
|
|
959
|
+
message_text = str(parsed["error"])
|
|
960
|
+
raise RuntimeError(message_text.replace(_channel, "<proxy>") if _channel else message_text)
|
|
961
|
+
# A role-less event naming a tool is a tool result: given the role so it
|
|
962
|
+
# counts, displays, and is replaced by the proxy's account like one.
|
|
963
|
+
own = [
|
|
964
|
+
dict(e, role="tool_result") if isinstance(e, dict) and not e.get("role") and e.get("tool_name") else e
|
|
965
|
+
for e in parsed.get("events") or []
|
|
966
|
+
]
|
|
967
|
+
# Returned events are the agent's own claim; an empty list cannot be told
|
|
968
|
+
# apart from an agent that does not report, so it is not a confirmed zero.
|
|
969
|
+
# Only tool events are a report: a reply with assistant messages alone
|
|
970
|
+
# says nothing about tools.
|
|
971
|
+
reports_tools = any(_is_tool(e) for e in own)
|
|
972
|
+
reported = (own, {"state": "recorded", "source": "agent", "watched": [], "missing": [], "reason": "", "proxy_version": None}) if reports_tools else None
|
|
973
|
+
if proxied is None:
|
|
974
|
+
why = "was switched off for this agent" if _proxy_off() else "could not be loaded"
|
|
975
|
+
capture = reported[1] if reported else {
|
|
976
|
+
"state": "not_visible", "source": "agent", "watched": [], "missing": [], "proxy_version": None,
|
|
977
|
+
"reason": "PreMan's tool proxy %s, and the agent did not report its tool calls" % why,
|
|
978
|
+
}
|
|
979
|
+
return parsed.get("response", ""), own, capture
|
|
980
|
+
events, capture = _proxy.choose(proxied, reported)
|
|
981
|
+
if capture.get("source") == "proxy":
|
|
982
|
+
# The proxy's account replaces only the agent's tool events; anything
|
|
983
|
+
# else it returned (its own messages, notes) is kept, in front.
|
|
984
|
+
kept = [e for e in own if not _is_tool(e)]
|
|
985
|
+
events = kept + list(events)
|
|
986
|
+
return parsed.get("response", ""), events, capture
|
|
740
987
|
`;
|
|
741
988
|
|
|
742
989
|
/** The node side. Also CLI-owned, for the same reason. */
|
|
@@ -790,6 +1037,7 @@ import json
|
|
|
790
1037
|
import os
|
|
791
1038
|
import shutil
|
|
792
1039
|
import subprocess
|
|
1040
|
+
import sys
|
|
793
1041
|
import tempfile
|
|
794
1042
|
from pathlib import Path
|
|
795
1043
|
|
|
@@ -910,7 +1158,7 @@ def _read_events(path):
|
|
|
910
1158
|
"""
|
|
911
1159
|
if not os.path.exists(path):
|
|
912
1160
|
return [], _not_visible("the agent did not create PREMAN_TOOL_EVENTS_FILE, so its tool calls are not reported")
|
|
913
|
-
events = []
|
|
1161
|
+
events, lines = [], []
|
|
914
1162
|
with open(path, encoding="utf-8") as handle:
|
|
915
1163
|
for line in handle:
|
|
916
1164
|
line = line.strip()
|
|
@@ -930,15 +1178,68 @@ def _read_events(path):
|
|
|
930
1178
|
"tool_args": args if isinstance(args, dict) else {},
|
|
931
1179
|
"content": content if isinstance(content, str) else json.dumps(content, ensure_ascii=False, default=str),
|
|
932
1180
|
})
|
|
933
|
-
|
|
1181
|
+
lines.append(item)
|
|
1182
|
+
return events, {"state": "recorded", "source": "events_file", "watched": [], "missing": [], "reason": "",
|
|
1183
|
+
"trace": _file_trace(lines)}
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _file_trace(lines):
|
|
1187
|
+
"""The agent's own links: 'step' and 'parent' only when every line gives them.
|
|
1188
|
+
A link that cannot be checked makes the turn order-only; it is never guessed."""
|
|
1189
|
+
def plain(reason):
|
|
1190
|
+
return {"links": "order_only", "reason": reason, "known": [], "unknown": {},
|
|
1191
|
+
"calls": [{"id": "c%d" % (i + 1), "tool": str(item["tool_name"]), "event": i}
|
|
1192
|
+
for i, item in enumerate(lines)]}
|
|
1193
|
+
known = [f for f in ("step", "parent") if lines and all(f in item for item in lines)]
|
|
1194
|
+
if not known:
|
|
1195
|
+
return plain("the agent's events file gives no step or parent")
|
|
1196
|
+
ids = [str(item.get("id") or "") for item in lines]
|
|
1197
|
+
if "parent" in known:
|
|
1198
|
+
if not all(ids) or len(set(ids)) != len(ids):
|
|
1199
|
+
return plain("the agent's events file gives parents but not a unique id on every line")
|
|
1200
|
+
for i, item in enumerate(lines):
|
|
1201
|
+
if item["parent"] is not None and str(item["parent"]) not in ids[:i]:
|
|
1202
|
+
return plain("the agent's events file has a parent that names no earlier call")
|
|
1203
|
+
if "step" in known and not all(isinstance(item["step"], int) and not isinstance(item["step"], bool) and item["step"] >= 1 for item in lines):
|
|
1204
|
+
return plain("the agent's events file has a step that is not a whole number from 1")
|
|
1205
|
+
rename = {cid: "c%d" % (i + 1) for i, cid in enumerate(ids)}
|
|
1206
|
+
calls = []
|
|
1207
|
+
for i, item in enumerate(lines):
|
|
1208
|
+
call = {"id": "c%d" % (i + 1), "tool": str(item["tool_name"]), "event": i}
|
|
1209
|
+
if "step" in known:
|
|
1210
|
+
call["step"] = item["step"]
|
|
1211
|
+
if "parent" in known:
|
|
1212
|
+
call["parent"] = None if item["parent"] is None else rename[str(item["parent"])]
|
|
1213
|
+
calls.append(call)
|
|
1214
|
+
return {"links": "full", "known": known,
|
|
1215
|
+
"unknown": {f: "the agent's events file does not give it" for f in ("step", "parent") if f not in known},
|
|
1216
|
+
"calls": calls}
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
#: Commands that run the agent on another machine or in a container, where
|
|
1220
|
+
#: 127.0.0.1 is not this machine and the proxy cannot be reached.
|
|
1221
|
+
_REMOTE_LAUNCHERS = ("docker", "podman", "ssh", "kubectl")
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _proxy_off() -> bool:
|
|
1225
|
+
return os.environ.get("PREMAN_TOOL_PROXY") == "0" or os.path.basename(_ARGV[0]) in _REMOTE_LAUNCHERS
|
|
934
1226
|
|
|
935
1227
|
|
|
936
1228
|
def _turn(message, history):
|
|
937
1229
|
argv = [_binary()] + list(_ARGV[1:])
|
|
938
|
-
# One file per turn: each turn is its own process, so
|
|
939
|
-
# under the wrong turn.
|
|
1230
|
+
# One file and one proxy channel per turn: each turn is its own process, so
|
|
1231
|
+
# nothing can be filed under the wrong turn.
|
|
940
1232
|
folder = tempfile.mkdtemp(prefix="preman-tools-")
|
|
941
1233
|
events_path = os.path.join(folder, "tool_events.jsonl")
|
|
1234
|
+
env = dict(os.environ, PREMAN_TOOL_EVENTS_FILE=events_path)
|
|
1235
|
+
switched_off = _proxy_off()
|
|
1236
|
+
proxy_mod = None if switched_off else _tool_proxy()
|
|
1237
|
+
proxy = proxy_mod.shared() if proxy_mod else None
|
|
1238
|
+
channel = proxy.channel() if proxy else None
|
|
1239
|
+
if channel:
|
|
1240
|
+
env.update(proxy.env(channel))
|
|
1241
|
+
proxy.begin(channel)
|
|
1242
|
+
proxied = None
|
|
942
1243
|
try:
|
|
943
1244
|
done = subprocess.run(
|
|
944
1245
|
argv,
|
|
@@ -947,15 +1248,33 @@ def _turn(message, history):
|
|
|
947
1248
|
text=True,
|
|
948
1249
|
cwd=str(_REPO),
|
|
949
1250
|
timeout=_TIMEOUT,
|
|
950
|
-
env=
|
|
1251
|
+
env=env,
|
|
951
1252
|
)
|
|
1253
|
+
if channel:
|
|
1254
|
+
proxied = proxy.end(channel)
|
|
952
1255
|
if done.returncode != 0:
|
|
953
|
-
tail = (done.stderr or "").strip()
|
|
1256
|
+
tail = (done.stderr or "").strip()
|
|
1257
|
+
if channel:
|
|
1258
|
+
tail = tail.replace(channel, "<proxy>")
|
|
1259
|
+
tail = tail[-600:]
|
|
954
1260
|
raise RuntimeError("%s exited %s: %s" % (_ARGV[0], done.returncode, tail))
|
|
955
|
-
|
|
1261
|
+
reported = _read_events(events_path)
|
|
956
1262
|
finally:
|
|
1263
|
+
if channel:
|
|
1264
|
+
proxy.drop(channel)
|
|
957
1265
|
shutil.rmtree(folder, ignore_errors=True)
|
|
958
|
-
|
|
1266
|
+
if proxied is None:
|
|
1267
|
+
events, capture = reported
|
|
1268
|
+
capture = dict(capture, proxy_version=None)
|
|
1269
|
+
if capture["state"] != "recorded":
|
|
1270
|
+
why = "was switched off for this agent" if switched_off else "could not be loaded"
|
|
1271
|
+
capture["reason"] = "PreMan's tool proxy %s; %s" % (why, capture["reason"])
|
|
1272
|
+
else:
|
|
1273
|
+
events, capture = proxy_mod.choose(proxied, reported)
|
|
1274
|
+
reply = done.stdout.strip()
|
|
1275
|
+
if channel:
|
|
1276
|
+
reply = reply.replace(channel, "<proxy>")
|
|
1277
|
+
return reply, events, capture
|
|
959
1278
|
`;
|
|
960
1279
|
|
|
961
1280
|
// ── Assembly ────────────────────────────────────────────────────────────
|
|
@@ -1008,7 +1327,7 @@ export function buildWrapper(found, { tree = [] } = {}) {
|
|
|
1008
1327
|
|
|
1009
1328
|
if (shape === "cli_subprocess") {
|
|
1010
1329
|
return {
|
|
1011
|
-
shim: header + CLI_SUBPROCESS.replace("%%COMMAND%%", litList(command)) + PY_NORMALISE + PY_CHAT_REPORTED,
|
|
1330
|
+
shim: header + CLI_SUBPROCESS.replace("%%COMMAND%%", litList(command)) + PY_PROXY + PY_NORMALISE + PY_CHAT_REPORTED,
|
|
1012
1331
|
bridge: "",
|
|
1013
1332
|
};
|
|
1014
1333
|
}
|
|
@@ -1027,8 +1346,9 @@ export function buildWrapper(found, { tree = [] } = {}) {
|
|
|
1027
1346
|
+ NODE_BRIDGE
|
|
1028
1347
|
.replace("%%NODE_RUNTIME%%", lit(runtime))
|
|
1029
1348
|
.replace("%%ENTRY_SYMBOL%%", lit(symbol))
|
|
1349
|
+
+ PY_PROXY
|
|
1030
1350
|
+ PY_NORMALISE
|
|
1031
|
-
+
|
|
1351
|
+
+ PY_CHAT_REPORTED;
|
|
1032
1352
|
const bridge = NODE_SIDE
|
|
1033
1353
|
.replace(/%%NODE_ENTRY%%/g, lit("../" + nodeEntry))
|
|
1034
1354
|
.replace(/%%ENTRY_SYMBOL%%/g, lit(symbol));
|
|
@@ -1049,18 +1369,31 @@ export function buildWrapper(found, { tree = [] } = {}) {
|
|
|
1049
1369
|
.replace("%%ENTRY_SYMBOL%%", lit(symbol));
|
|
1050
1370
|
|
|
1051
1371
|
// langgraph reads tool calls out of the graph's own messages (§5.1 of the
|
|
1052
|
-
// design), not by wrapping functions, so
|
|
1372
|
+
// design), not by wrapping functions, so its `_turn` returns the capture and
|
|
1373
|
+
// it uses `chat()` from PY_CHAT_REPORTED.
|
|
1053
1374
|
if (shape === "langgraph") {
|
|
1054
|
-
return { shim: header + loader + turn + PY_NORMALISE +
|
|
1375
|
+
return { shim: header + loader + turn + PY_NORMALISE + PY_CHAT_REPORTED, bridge: "" };
|
|
1055
1376
|
}
|
|
1056
1377
|
// Checked only for the shapes that use it, so a tool path this shape never
|
|
1057
1378
|
// reads cannot fail the build. Every value is checked before it is quoted
|
|
1058
1379
|
// into the template, so a tool value a model invented cannot interpolate.
|
|
1059
|
-
const
|
|
1060
|
-
const shim = header + loader + PY_CAPTURE.replace("%%TOOLS%%", tools) + turn + PY_NORMALISE + PY_CHAT_CAPTURING;
|
|
1380
|
+
const shim = header + loader + decoratedCapture(found) + turn + PY_NORMALISE + PY_CHAT_DECORATED;
|
|
1061
1381
|
return { shim, bridge: "" };
|
|
1062
1382
|
}
|
|
1063
1383
|
|
|
1384
|
+
/** PY_DECORATED with this answer's tools filled in. */
|
|
1385
|
+
function decoratedCapture(found) {
|
|
1386
|
+
const { decorated, covered } = decoratedLiterals(found?.tools);
|
|
1387
|
+
const notRecorded = String(found?.tools_not_recorded || "");
|
|
1388
|
+
if ([...notRecorded].some((ch) => ch.charCodeAt(0) < 0x20)) {
|
|
1389
|
+
throw new TemplateError("tools_not_recorded holds a control character");
|
|
1390
|
+
}
|
|
1391
|
+
return PY_DECORATED
|
|
1392
|
+
.replace("%%DECORATED%%", decorated)
|
|
1393
|
+
.replace("%%COVERED%%", covered)
|
|
1394
|
+
.replace("%%NOT_RECORDED%%", lit(notRecorded));
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1064
1397
|
/**
|
|
1065
1398
|
* The `other` shape: the CLI still owns everything except one function body.
|
|
1066
1399
|
*
|
|
@@ -1083,7 +1416,7 @@ export function buildFreeform(found, { tree = [], turnSource = "" } = {}) {
|
|
|
1083
1416
|
if (!body.trim()) throw new TemplateError("no conversion body was returned");
|
|
1084
1417
|
// Computed before any text is built, so a tool value a model invented does
|
|
1085
1418
|
// not get the chance to interpolate into a template.
|
|
1086
|
-
const
|
|
1419
|
+
const capture = decoratedCapture(found);
|
|
1087
1420
|
|
|
1088
1421
|
const header = HEADER
|
|
1089
1422
|
.replace("%%SHAPE%%", "other")
|
|
@@ -1100,11 +1433,11 @@ export function buildFreeform(found, { tree = [], turnSource = "" } = {}) {
|
|
|
1100
1433
|
+ PY_LOADER
|
|
1101
1434
|
.replace("%%ENTRY_FILE%%", lit(entryFile))
|
|
1102
1435
|
.replace("%%ENTRY_SYMBOL%%", lit(symbol))
|
|
1103
|
-
+
|
|
1436
|
+
+ capture
|
|
1104
1437
|
+ "\n\ndef _turn(message, history):\n"
|
|
1105
1438
|
+ ` ${symbol} = _target()\n`
|
|
1106
1439
|
+ body + "\n"
|
|
1107
1440
|
+ PY_NORMALISE
|
|
1108
|
-
+
|
|
1441
|
+
+ PY_CHAT_DECORATED;
|
|
1109
1442
|
return { shim, bridge: "" };
|
|
1110
1443
|
}
|