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
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Record an agent's tool calls during a PreMan eval, and do nothing otherwise.
|
|
2
|
+
|
|
3
|
+
PreMan's `eval target` writes this into each file it decorates:
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from preman import tool as preman_tool
|
|
7
|
+
except ImportError: # PreMan's eval recorder; without it, decorate with nothing
|
|
8
|
+
def preman_tool(name):
|
|
9
|
+
return lambda fn: fn
|
|
10
|
+
|
|
11
|
+
@preman_tool("query_prices") # the name the model sees for this tool
|
|
12
|
+
def query_prices(gpu: str) -> list[dict]:
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
This package ships inside PreMan's CLI and is importable only inside an eval,
|
|
16
|
+
so everywhere else the fallback leaves the function untouched. Inside an eval
|
|
17
|
+
with ``PREMAN_EVAL_RUN`` set, each call runs through OpenInference's tool
|
|
18
|
+
helper, which creates the OpenTelemetry TOOL span the eval collects.
|
|
19
|
+
|
|
20
|
+
Recording must never change what the agent sees. If recording fails, the tool
|
|
21
|
+
still runs exactly once and the failure is reported to the eval instead.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import functools
|
|
27
|
+
import inspect
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Callable, Optional
|
|
34
|
+
|
|
35
|
+
__all__ = ["tool", "recording_errors", "decorated"]
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
_EVAL_VAR = "PREMAN_EVAL_RUN"
|
|
39
|
+
|
|
40
|
+
#: Set by PreMan's eval wrapper (.preman/preman_agent_entry.py) and called as
|
|
41
|
+
#: observer(event, tool_name, detail), event one of "start", "end", "broken".
|
|
42
|
+
#: None everywhere else.
|
|
43
|
+
_observer: Optional[Callable[[str, str, str], None]] = None
|
|
44
|
+
|
|
45
|
+
_lock = threading.Lock()
|
|
46
|
+
_errors: list[tuple[float, str, str]] = []
|
|
47
|
+
_tracer: list[Any] = []
|
|
48
|
+
#: (file, tool name, placed) for each @preman_tool applied in this process.
|
|
49
|
+
#: placed is False when it was handed something other than a function: it sits
|
|
50
|
+
#: above a decorator that returned an object, and calls can go around it.
|
|
51
|
+
_decorated: list[tuple[str, str, bool]] = []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def recording_errors(since: float = 0.0) -> list[tuple[float, str, str]]:
|
|
55
|
+
"""Recording failures as (time.monotonic(), tool name, message), oldest first."""
|
|
56
|
+
with _lock:
|
|
57
|
+
return [e for e in _errors if e[0] >= since]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decorated() -> list[tuple[str, str, bool]]:
|
|
61
|
+
"""Every @preman_tool applied so far, as (resolved file, tool name, placed)."""
|
|
62
|
+
with _lock:
|
|
63
|
+
return list(_decorated)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _register(name: str, fn: Any) -> None:
|
|
67
|
+
# Never raises: a caller it cannot read only leaves the tool off the list.
|
|
68
|
+
try:
|
|
69
|
+
file = str(Path(sys._getframe(2).f_code.co_filename).resolve())
|
|
70
|
+
placed = inspect.isroutine(inspect.unwrap(fn))
|
|
71
|
+
except Exception:
|
|
72
|
+
return
|
|
73
|
+
with _lock:
|
|
74
|
+
_decorated.append((file, name, placed))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _notify(event: str, name: str, detail: str = "") -> None:
|
|
78
|
+
observer = _observer
|
|
79
|
+
if observer is None:
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
observer(event, name, detail)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _describe(exc: BaseException) -> str:
|
|
88
|
+
# str(exc) runs the exception's own __str__, which may itself raise.
|
|
89
|
+
try:
|
|
90
|
+
return "%s: %s" % (type(exc).__name__, exc)
|
|
91
|
+
except Exception:
|
|
92
|
+
return type(exc).__name__
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _broken(name: str, exc: BaseException | str) -> None:
|
|
96
|
+
message = exc if isinstance(exc, str) else _describe(exc)
|
|
97
|
+
with _lock:
|
|
98
|
+
_errors.append((time.monotonic(), name, message))
|
|
99
|
+
_notify("broken", name, message)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _unrecorded() -> str:
|
|
103
|
+
"""Why the current tool span records nothing, or "" when it records.
|
|
104
|
+
|
|
105
|
+
Without an SDK TracerProvider (or with OTEL_SDK_DISABLED) the tool span is
|
|
106
|
+
a no-op, so the call would go unrecorded with no error to show for it.
|
|
107
|
+
Reported once the call is over, so one call gives at most one error."""
|
|
108
|
+
try:
|
|
109
|
+
from opentelemetry import trace
|
|
110
|
+
|
|
111
|
+
if trace.get_current_span().is_recording():
|
|
112
|
+
return ""
|
|
113
|
+
return "no span collector is listening in this run, so the call was not recorded"
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
return _describe(exc)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _oi_tracer() -> Any:
|
|
119
|
+
# Imported on first recorded call, so nothing outside a recording eval
|
|
120
|
+
# loads OpenTelemetry's SDK or OpenInference because of this package.
|
|
121
|
+
if not _tracer:
|
|
122
|
+
from openinference.instrumentation import OITracer, TraceConfig
|
|
123
|
+
from opentelemetry import trace
|
|
124
|
+
|
|
125
|
+
_tracer.append(OITracer(trace.get_tracer("preman-trace"), config=TraceConfig()))
|
|
126
|
+
return _tracer[0]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _target(fn: Callable[..., Any], args: tuple) -> tuple[Callable[..., Any], tuple]:
|
|
130
|
+
"""The callable whose arguments are recorded, and its positional arguments.
|
|
131
|
+
A method's self/cls is bound first so it is not recorded as an argument."""
|
|
132
|
+
params = list(inspect.signature(fn).parameters)
|
|
133
|
+
if params and params[0] in ("self", "cls") and args:
|
|
134
|
+
return functools.partial(fn, args[0]), args[1:]
|
|
135
|
+
return fn, args
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _stand_in(fn: Callable[..., Any], target: Callable[..., Any], state: dict, is_async: bool):
|
|
139
|
+
# A fresh stand-in per call, so 'entered', 'done' and 'error' belong to
|
|
140
|
+
# this call only. Its signature is the target's, so OpenInference records
|
|
141
|
+
# arguments by name; its name and docstring are the tool's.
|
|
142
|
+
name = state["name"]
|
|
143
|
+
if is_async:
|
|
144
|
+
async def inner(*a, **k):
|
|
145
|
+
state["entered"] = True
|
|
146
|
+
_notify("start", name)
|
|
147
|
+
state["unrecorded"] = _unrecorded()
|
|
148
|
+
try:
|
|
149
|
+
result = await target(*a, **k)
|
|
150
|
+
except BaseException as exc:
|
|
151
|
+
state["error"] = exc
|
|
152
|
+
raise
|
|
153
|
+
finally:
|
|
154
|
+
_notify("end", name)
|
|
155
|
+
state["done"], state["result"] = True, result
|
|
156
|
+
return result
|
|
157
|
+
else:
|
|
158
|
+
def inner(*a, **k):
|
|
159
|
+
state["entered"] = True
|
|
160
|
+
_notify("start", name)
|
|
161
|
+
state["unrecorded"] = _unrecorded()
|
|
162
|
+
try:
|
|
163
|
+
result = target(*a, **k)
|
|
164
|
+
except BaseException as exc:
|
|
165
|
+
state["error"] = exc
|
|
166
|
+
raise
|
|
167
|
+
finally:
|
|
168
|
+
_notify("end", name)
|
|
169
|
+
state["done"], state["result"] = True, result
|
|
170
|
+
return result
|
|
171
|
+
inner.__signature__ = inspect.signature(target)
|
|
172
|
+
inner.__name__ = getattr(fn, "__name__", "tool")
|
|
173
|
+
inner.__qualname__ = getattr(fn, "__qualname__", inner.__name__)
|
|
174
|
+
inner.__doc__ = fn.__doc__
|
|
175
|
+
return inner
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def tool(name: str):
|
|
179
|
+
"""Record each call of the decorated function as the tool ``name``."""
|
|
180
|
+
if not isinstance(name, str) or not name.strip():
|
|
181
|
+
raise TypeError('preman.tool needs the tool name the model sees, e.g. @preman_tool("query_prices")')
|
|
182
|
+
|
|
183
|
+
def decorate(fn):
|
|
184
|
+
if inspect.isgeneratorfunction(fn) or inspect.isasyncgenfunction(fn):
|
|
185
|
+
raise TypeError("preman.tool cannot record a generator function (%s)" % getattr(fn, "__qualname__", fn))
|
|
186
|
+
_register(name, fn)
|
|
187
|
+
is_async = inspect.iscoroutinefunction(fn)
|
|
188
|
+
|
|
189
|
+
def prepare(args, kwargs):
|
|
190
|
+
# None means "call the tool directly": either the call does not fit
|
|
191
|
+
# its signature (so it fails exactly as it would), or the call
|
|
192
|
+
# cannot be prepared for recording, which is reported.
|
|
193
|
+
try:
|
|
194
|
+
signature = inspect.signature(fn)
|
|
195
|
+
except Exception as exc: # e.g. ValueError: a builtin with no signature
|
|
196
|
+
_broken(name, exc)
|
|
197
|
+
return None
|
|
198
|
+
try:
|
|
199
|
+
signature.bind(*args, **kwargs)
|
|
200
|
+
except TypeError:
|
|
201
|
+
return None
|
|
202
|
+
try:
|
|
203
|
+
target, rest = _target(fn, args)
|
|
204
|
+
state = {"name": name, "entered": False, "done": False, "result": None, "error": None,
|
|
205
|
+
"unrecorded": ""}
|
|
206
|
+
return state, rest, _stand_in(fn, target, state, is_async)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
_broken(name, exc)
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
if is_async:
|
|
212
|
+
@functools.wraps(fn)
|
|
213
|
+
async def recorded(*args, **kwargs):
|
|
214
|
+
if not os.environ.get(_EVAL_VAR):
|
|
215
|
+
return await fn(*args, **kwargs)
|
|
216
|
+
prepared = prepare(args, kwargs)
|
|
217
|
+
if prepared is None:
|
|
218
|
+
return await fn(*args, **kwargs)
|
|
219
|
+
state, rest, inner = prepared
|
|
220
|
+
try:
|
|
221
|
+
result = await _oi_tracer().tool(name=name)(inner)(*rest, **kwargs)
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
if state["done"]:
|
|
224
|
+
_broken(name, exc)
|
|
225
|
+
return state["result"]
|
|
226
|
+
if state["error"] is not None:
|
|
227
|
+
if exc is not state["error"]:
|
|
228
|
+
_broken(name, exc)
|
|
229
|
+
elif state["unrecorded"]:
|
|
230
|
+
_broken(name, state["unrecorded"])
|
|
231
|
+
raise state["error"]
|
|
232
|
+
_broken(name, exc)
|
|
233
|
+
return await fn(*args, **kwargs)
|
|
234
|
+
if state["unrecorded"]:
|
|
235
|
+
_broken(name, state["unrecorded"])
|
|
236
|
+
return result
|
|
237
|
+
else:
|
|
238
|
+
@functools.wraps(fn)
|
|
239
|
+
def recorded(*args, **kwargs):
|
|
240
|
+
if not os.environ.get(_EVAL_VAR):
|
|
241
|
+
return fn(*args, **kwargs)
|
|
242
|
+
prepared = prepare(args, kwargs)
|
|
243
|
+
if prepared is None:
|
|
244
|
+
return fn(*args, **kwargs)
|
|
245
|
+
state, rest, inner = prepared
|
|
246
|
+
try:
|
|
247
|
+
result = _oi_tracer().tool(name=name)(inner)(*rest, **kwargs)
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
if state["done"]:
|
|
250
|
+
_broken(name, exc)
|
|
251
|
+
return state["result"]
|
|
252
|
+
if state["error"] is not None:
|
|
253
|
+
if exc is not state["error"]:
|
|
254
|
+
_broken(name, exc)
|
|
255
|
+
elif state["unrecorded"]:
|
|
256
|
+
_broken(name, state["unrecorded"])
|
|
257
|
+
raise state["error"]
|
|
258
|
+
_broken(name, exc)
|
|
259
|
+
return fn(*args, **kwargs)
|
|
260
|
+
if state["unrecorded"]:
|
|
261
|
+
_broken(name, state["unrecorded"])
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
recorded.__preman_tool__ = name
|
|
265
|
+
return recorded
|
|
266
|
+
|
|
267
|
+
return decorate
|
package/bin/shared.js
CHANGED
|
@@ -23,6 +23,11 @@ import path from "node:path";
|
|
|
23
23
|
import { createInterface } from "node:readline/promises";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
25
|
|
|
26
|
+
// Generated wrappers import tool_proxy.py from this folder (design spec §6.0.2).
|
|
27
|
+
// Set here because every entry point imports this module, and the wrapper check
|
|
28
|
+
// (`python -c`) and PYTHONSAFEPATH both drop the script folder from sys.path.
|
|
29
|
+
process.env.PREMAN_CLI_BIN = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
|
|
26
31
|
export const DEFAULT_BACKEND = "https://api.preman.live";
|
|
27
32
|
export const DEFAULT_FRONTEND = "https://app.preman.live";
|
|
28
33
|
export const CREDENTIALS_DIR = path.join(os.homedir(), ".preman");
|