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.
@@ -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");