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,828 @@
|
|
|
1
|
+
"""PreMan's recording proxy for agents run as another process (Node, Go, Rust, any CLI).
|
|
2
|
+
|
|
3
|
+
The wrapper points the agent's OPENAI_BASE_URL / ANTHROPIC_BASE_URL here. Every
|
|
4
|
+
request and reply is forwarded unchanged to the address the agent would have
|
|
5
|
+
used; a copy is read for tool calls. A call is recorded only when both halves
|
|
6
|
+
were seen: the model asking for it, and its result going back to the model.
|
|
7
|
+
|
|
8
|
+
Shipped with the CLI and imported by generated wrappers (design spec §6.0).
|
|
9
|
+
Standard library only: the interpreter running those wrappers has no
|
|
10
|
+
guaranteed openai or anthropic package.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import gzip
|
|
15
|
+
import http.client
|
|
16
|
+
import http.server
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import secrets
|
|
20
|
+
import threading
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
import zlib
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
MAX_BODY = 2 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def kind_of(provider: str, path: str) -> str:
|
|
30
|
+
"""Which model API a request path is, or "" for one that carries no turn
|
|
31
|
+
(model lists, embeddings, token counting)."""
|
|
32
|
+
tail = path.split("?", 1)[0].rstrip("/")
|
|
33
|
+
if provider == "openai":
|
|
34
|
+
if tail.endswith("/chat/completions"):
|
|
35
|
+
return "openai_chat"
|
|
36
|
+
if tail.endswith("/responses"):
|
|
37
|
+
return "openai_responses"
|
|
38
|
+
if provider == "anthropic" and tail.endswith("/messages"):
|
|
39
|
+
return "anthropic"
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _args(raw):
|
|
44
|
+
if isinstance(raw, dict):
|
|
45
|
+
return raw
|
|
46
|
+
if isinstance(raw, str):
|
|
47
|
+
try:
|
|
48
|
+
value = json.loads(raw) if raw.strip() else {}
|
|
49
|
+
except ValueError:
|
|
50
|
+
return {"raw": raw}
|
|
51
|
+
return value if isinstance(value, dict) else {"raw": value}
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _text(content) -> str:
|
|
56
|
+
if isinstance(content, str):
|
|
57
|
+
return content
|
|
58
|
+
if isinstance(content, list):
|
|
59
|
+
parts = [
|
|
60
|
+
block["text"] if isinstance(block, dict) else block
|
|
61
|
+
for block in content
|
|
62
|
+
if (isinstance(block, dict) and isinstance(block.get("text"), str)) or isinstance(block, str)
|
|
63
|
+
]
|
|
64
|
+
if parts:
|
|
65
|
+
return "".join(parts)
|
|
66
|
+
return json.dumps(content, ensure_ascii=False, default=str)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _Unreadable(Exception):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_NO_ID = "a tool call or tool result carried no id, so PreMan cannot pair them"
|
|
74
|
+
_NO_TRAFFIC = (
|
|
75
|
+
"no model request reached PreMan; the agent may set its model address in code "
|
|
76
|
+
"instead of reading OPENAI_BASE_URL or ANTHROPIC_BASE_URL"
|
|
77
|
+
)
|
|
78
|
+
_CHANNEL_CLOSED = "the turn's proxy channel was closed before the turn ended"
|
|
79
|
+
# The only outcomes where the proxy saw nothing of the turn, so the agent's own
|
|
80
|
+
# report is the better account. Any other "not visible" is something the proxy
|
|
81
|
+
# saw and could not trust, and a report cannot undo that.
|
|
82
|
+
_NO_TOOLS_OFFERED = (
|
|
83
|
+
"the agent offered the model no tools in its requests, so PreMan cannot tell whether it ran any "
|
|
84
|
+
"(for example, tools named in plain text)"
|
|
85
|
+
)
|
|
86
|
+
_UNSEEN = (_NO_TRAFFIC, _CHANNEL_CLOSED, _NO_TOOLS_OFFERED)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _call(out, cid, name, args):
|
|
90
|
+
# A call with no id cannot be paired with its result, so it may run unseen.
|
|
91
|
+
# One with no function name (a custom tool) is kept with name None: in a
|
|
92
|
+
# reply it makes the turn not visible; in history it is skipped, so its
|
|
93
|
+
# result has no readable request and the orphan rule decides.
|
|
94
|
+
if not cid:
|
|
95
|
+
raise _Unreadable(_NO_ID)
|
|
96
|
+
out.append((cid, name or None, _args(args)))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _requests_in_reply(kind, reply):
|
|
100
|
+
"""(id, name, args) for each tool the model asked for in one reply."""
|
|
101
|
+
out = []
|
|
102
|
+
if kind == "openai_chat":
|
|
103
|
+
for choice in reply.get("choices") or []:
|
|
104
|
+
message = (choice or {}).get("message") or {}
|
|
105
|
+
for call in message.get("tool_calls") or []:
|
|
106
|
+
fn = (call or {}).get("function") or {}
|
|
107
|
+
_call(out, (call or {}).get("id"), fn.get("name"), fn.get("arguments"))
|
|
108
|
+
elif kind == "openai_responses":
|
|
109
|
+
for item in reply.get("output") or []:
|
|
110
|
+
if isinstance(item, dict) and item.get("type") == "function_call":
|
|
111
|
+
_call(out, item.get("call_id"), item.get("name"), item.get("arguments"))
|
|
112
|
+
elif kind == "anthropic":
|
|
113
|
+
for block in reply.get("content") or []:
|
|
114
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
115
|
+
_call(out, block.get("id"), block.get("name"), block.get("input"))
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Tool types PreMan cannot record: local ones whose shape it does not read, and
|
|
120
|
+
# ones the provider runs itself, so no result ever passes through the agent.
|
|
121
|
+
_UNREAD_TYPES = {
|
|
122
|
+
"openai_responses": {"computer_call", "local_shell_call", "shell_call", "apply_patch_call", "custom_tool_call"},
|
|
123
|
+
}
|
|
124
|
+
_HOSTED_TYPES = {
|
|
125
|
+
"openai_responses": {"web_search_call", "file_search_call", "mcp_call", "code_interpreter_call", "image_generation_call"},
|
|
126
|
+
"anthropic": {"server_tool_use", "mcp_tool_use", "web_search_tool_result"},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _unrecorded(kind, items):
|
|
131
|
+
"""(key, reason) for each item that is a tool PreMan cannot record. The key
|
|
132
|
+
is the item's id, or "" when it has none."""
|
|
133
|
+
out = []
|
|
134
|
+
for item in items if isinstance(items, list) else []:
|
|
135
|
+
kind_of_item = item.get("type") if isinstance(item, dict) else None
|
|
136
|
+
if not isinstance(kind_of_item, str):
|
|
137
|
+
continue
|
|
138
|
+
key = str(item.get("call_id") or item.get("id") or item.get("tool_use_id") or "")
|
|
139
|
+
if kind_of_item in _UNREAD_TYPES.get(kind, ()):
|
|
140
|
+
out.append((key, "the agent ran a tool type PreMan cannot read (%s)" % kind_of_item))
|
|
141
|
+
elif kind_of_item in _HOSTED_TYPES.get(kind, ()) or (
|
|
142
|
+
kind == "anthropic" and kind_of_item.endswith("_tool_result") and kind_of_item != "tool_result"
|
|
143
|
+
):
|
|
144
|
+
out.append((key, "the model used a tool that runs on the provider's side (%s), which PreMan does not record" % kind_of_item))
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _unrecorded_in_history(kind, request):
|
|
149
|
+
if kind == "openai_responses":
|
|
150
|
+
return _unrecorded(kind, request.get("input"))
|
|
151
|
+
if kind == "anthropic":
|
|
152
|
+
out = []
|
|
153
|
+
for message in request.get("messages") or []:
|
|
154
|
+
if isinstance(message, dict):
|
|
155
|
+
out += _unrecorded(kind, message.get("content"))
|
|
156
|
+
return out
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _unrecorded_in_reply(kind, reply):
|
|
161
|
+
if kind == "openai_responses":
|
|
162
|
+
return _unrecorded(kind, reply.get("output"))
|
|
163
|
+
if kind == "anthropic":
|
|
164
|
+
return _unrecorded(kind, reply.get("content"))
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _requests_in_history(kind, request):
|
|
169
|
+
"""(id, name, args) for each tool request the agent re-sent as history."""
|
|
170
|
+
out = []
|
|
171
|
+
if kind == "openai_chat":
|
|
172
|
+
for message in request.get("messages") or []:
|
|
173
|
+
if isinstance(message, dict) and message.get("role") == "assistant":
|
|
174
|
+
out += _requests_in_reply("openai_chat", {"choices": [{"message": message}]})
|
|
175
|
+
elif kind == "openai_responses":
|
|
176
|
+
items = request.get("input")
|
|
177
|
+
out += _requests_in_reply("openai_responses", {"output": items if isinstance(items, list) else []})
|
|
178
|
+
elif kind == "anthropic":
|
|
179
|
+
for message in request.get("messages") or []:
|
|
180
|
+
if isinstance(message, dict) and message.get("role") == "assistant" and isinstance(message.get("content"), list):
|
|
181
|
+
out += _requests_in_reply("anthropic", {"content": message["content"]})
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _results_in_request(kind, request):
|
|
186
|
+
"""(id, content) for each tool result the agent sent back to the model."""
|
|
187
|
+
out = []
|
|
188
|
+
if kind == "openai_chat":
|
|
189
|
+
for idx, message in enumerate(request.get("messages") or []):
|
|
190
|
+
if isinstance(message, dict):
|
|
191
|
+
if message.get("role") == "tool":
|
|
192
|
+
if not message.get("tool_call_id"):
|
|
193
|
+
raise _Unreadable(_NO_ID)
|
|
194
|
+
out.append((message["tool_call_id"], _text(message.get("content"))))
|
|
195
|
+
elif message.get("role") == "function":
|
|
196
|
+
name = message.get("name", "function")
|
|
197
|
+
out.append(("function:%s:%d" % (name, idx), _text(message.get("content"))))
|
|
198
|
+
elif kind == "openai_responses":
|
|
199
|
+
items = request.get("input")
|
|
200
|
+
for idx, item in enumerate(items if isinstance(items, list) else []):
|
|
201
|
+
if not isinstance(item, dict) or not isinstance(item.get("type"), str):
|
|
202
|
+
continue
|
|
203
|
+
if item["type"] == "function_call_output":
|
|
204
|
+
if not item.get("call_id"):
|
|
205
|
+
raise _Unreadable(_NO_ID)
|
|
206
|
+
out.append((item["call_id"], _text(item.get("output"))))
|
|
207
|
+
elif item["type"].endswith("_call_output"):
|
|
208
|
+
# The result of a tool type PreMan cannot read (custom, shell,
|
|
209
|
+
# computer...). Keyed so it never pairs with a request: it is an
|
|
210
|
+
# orphan, and the orphan rule makes the turn not visible.
|
|
211
|
+
key = item.get("call_id") or item.get("id") or "%s:%d" % (item["type"], idx)
|
|
212
|
+
out.append(("unreadable:%s" % key, _text(item.get("output"))))
|
|
213
|
+
elif kind == "anthropic":
|
|
214
|
+
for message in request.get("messages") or []:
|
|
215
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
216
|
+
for block in content if isinstance(content, list) else []:
|
|
217
|
+
if isinstance(block, dict) and block.get("type") == "tool_result":
|
|
218
|
+
if not block.get("tool_use_id"):
|
|
219
|
+
raise _Unreadable(_NO_ID)
|
|
220
|
+
out.append((block["tool_use_id"], _text(block.get("content"))))
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _sse(raw: bytes):
|
|
225
|
+
"""The JSON payloads of a server-sent-events body, in order."""
|
|
226
|
+
for block in raw.decode("utf-8", "replace").replace("\r\n", "\n").split("\n\n"):
|
|
227
|
+
data = "\n".join(line[5:].lstrip() for line in block.split("\n") if line.startswith("data:"))
|
|
228
|
+
if not data or data == "[DONE]":
|
|
229
|
+
continue
|
|
230
|
+
try:
|
|
231
|
+
yield json.loads(data)
|
|
232
|
+
except ValueError:
|
|
233
|
+
raise _Unreadable("a streamed reply held a chunk that is not JSON")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _requests_in_stream(kind, raw: bytes):
|
|
237
|
+
if kind == "openai_chat":
|
|
238
|
+
slots = {}
|
|
239
|
+
for event in _sse(raw):
|
|
240
|
+
for choice in event.get("choices") or []:
|
|
241
|
+
delta = (choice or {}).get("delta") or {}
|
|
242
|
+
for part in delta.get("tool_calls") or []:
|
|
243
|
+
slot = slots.setdefault((choice.get("index", 0), part.get("index", 0)), {"id": "", "name": "", "arguments": ""})
|
|
244
|
+
if part.get("id"):
|
|
245
|
+
slot["id"] = part["id"]
|
|
246
|
+
fn = part.get("function") or {}
|
|
247
|
+
slot["name"] += fn.get("name") or ""
|
|
248
|
+
slot["arguments"] += fn.get("arguments") or ""
|
|
249
|
+
out = []
|
|
250
|
+
for s in slots.values():
|
|
251
|
+
_call(out, s["id"], s["name"], s["arguments"])
|
|
252
|
+
return out
|
|
253
|
+
if kind == "openai_responses":
|
|
254
|
+
done = []
|
|
255
|
+
for event in _sse(raw):
|
|
256
|
+
if event.get("type") == "response.completed" and isinstance(event.get("response"), dict):
|
|
257
|
+
return _requests_in_reply(kind, event["response"])
|
|
258
|
+
if event.get("type") == "response.output_item.done" and isinstance(event.get("item"), dict):
|
|
259
|
+
done.append(event["item"])
|
|
260
|
+
return _requests_in_reply(kind, {"output": done})
|
|
261
|
+
if kind == "anthropic":
|
|
262
|
+
slots = {}
|
|
263
|
+
for event in _sse(raw):
|
|
264
|
+
if event.get("type") == "content_block_start":
|
|
265
|
+
block = event.get("content_block") or {}
|
|
266
|
+
if block.get("type") == "tool_use":
|
|
267
|
+
slots[event.get("index")] = {"id": block.get("id"), "name": block.get("name"), "input": block.get("input"), "json": ""}
|
|
268
|
+
elif event.get("type") == "content_block_delta":
|
|
269
|
+
delta = event.get("delta") or {}
|
|
270
|
+
if delta.get("type") == "input_json_delta" and event.get("index") in slots:
|
|
271
|
+
slots[event["index"]]["json"] += delta.get("partial_json") or ""
|
|
272
|
+
out = []
|
|
273
|
+
for s in slots.values():
|
|
274
|
+
_call(out, s["id"], s["name"], s["json"] if s["json"] else s["input"])
|
|
275
|
+
return out
|
|
276
|
+
return []
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _unrecorded_in_stream(kind, raw: bytes):
|
|
280
|
+
items = []
|
|
281
|
+
for event in _sse(raw):
|
|
282
|
+
if kind == "openai_responses":
|
|
283
|
+
if event.get("type") == "response.completed" and isinstance(event.get("response"), dict):
|
|
284
|
+
items += event["response"].get("output") or []
|
|
285
|
+
elif event.get("type") in ("response.output_item.added", "response.output_item.done"):
|
|
286
|
+
items.append(event.get("item"))
|
|
287
|
+
elif kind == "anthropic" and event.get("type") == "content_block_start":
|
|
288
|
+
items.append(event.get("content_block"))
|
|
289
|
+
return _unrecorded(kind, items)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _decoded(body: bytes, encoding: str) -> bytes:
|
|
293
|
+
encoding = (encoding or "").strip().lower()
|
|
294
|
+
if encoding in ("", "identity"):
|
|
295
|
+
return body
|
|
296
|
+
if encoding == "gzip":
|
|
297
|
+
return gzip.decompress(body)
|
|
298
|
+
if encoding == "deflate":
|
|
299
|
+
return zlib.decompress(body)
|
|
300
|
+
raise _Unreadable("a model reply was compressed with %s" % encoding)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _not_visible(reason):
|
|
304
|
+
return {"state": "not_visible", "source": "proxy", "watched": [], "missing": [], "reason": reason}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class _Turn:
|
|
308
|
+
"""What one turn's model traffic showed. One per turn, discarded after it."""
|
|
309
|
+
|
|
310
|
+
def __init__(self, already=None, waiting=None):
|
|
311
|
+
self.requested = {} # id -> (name, args), history overrides
|
|
312
|
+
self.order = [] # ids in the order they were first seen
|
|
313
|
+
self.results = {} # id -> content
|
|
314
|
+
self.asked = [] # ids the model asked for in a reply during this turn
|
|
315
|
+
self.model_requests = 0
|
|
316
|
+
self.problem = ""
|
|
317
|
+
self.reused_id = False # a reply asked for an id an earlier turn already reported
|
|
318
|
+
self.reused_in_turn = False # a reply asked for an id already seen in this turn
|
|
319
|
+
self.offered_tools = False # some model request in the turn offered the model tools
|
|
320
|
+
self.last_reply_failed = False # the most recent model reply was an HTTP error
|
|
321
|
+
self.unrecorded = [] # keys of tool items PreMan cannot record, filed as reported at the end
|
|
322
|
+
self.already = already if already is not None else set()
|
|
323
|
+
# Ids asked for in an earlier turn of this channel whose result never
|
|
324
|
+
# went back, with their (name, args): a result for one arriving now
|
|
325
|
+
# counts in this turn, even when the agent does not re-send the call.
|
|
326
|
+
self.waiting = waiting if waiting is not None else {}
|
|
327
|
+
# The trace (design spec §7.5): each model reply that asks for a named
|
|
328
|
+
# call is one step; each call belongs to a conversation -- None for the
|
|
329
|
+
# top level, else the call a sub-agent ran inside.
|
|
330
|
+
self.step = 0
|
|
331
|
+
self.step_of = {}
|
|
332
|
+
self.conv_of = {}
|
|
333
|
+
self.parent_unknown = ""
|
|
334
|
+
self._counted = []
|
|
335
|
+
self._lock = threading.RLock() # the proxy serves one turn from several threads
|
|
336
|
+
|
|
337
|
+
def _note(self, cid, name, args, override=False):
|
|
338
|
+
if cid not in self.requested:
|
|
339
|
+
self.requested[cid] = (name, args)
|
|
340
|
+
self.order.append(cid)
|
|
341
|
+
elif override:
|
|
342
|
+
self.requested[cid] = (name, args)
|
|
343
|
+
|
|
344
|
+
def _fail(self, reason):
|
|
345
|
+
with self._lock:
|
|
346
|
+
self.problem = self.problem or reason
|
|
347
|
+
|
|
348
|
+
def saw_request(self, kind, body):
|
|
349
|
+
with self._lock:
|
|
350
|
+
return self._saw_request(kind, body)
|
|
351
|
+
|
|
352
|
+
def saw_reply(self, kind, status, content_type, content_encoding, body, truncated, conv=None):
|
|
353
|
+
with self._lock:
|
|
354
|
+
return self._saw_reply(kind, status, content_type, content_encoding, body, truncated, conv)
|
|
355
|
+
|
|
356
|
+
def outcome(self):
|
|
357
|
+
with self._lock:
|
|
358
|
+
events, capture = self._outcome()
|
|
359
|
+
capture["trace"] = self._trace(self._counted)
|
|
360
|
+
return events, capture
|
|
361
|
+
|
|
362
|
+
def _conversation(self, referenced):
|
|
363
|
+
"""Which conversation a model request belongs to. One that re-sends or
|
|
364
|
+
answers a call continues that call's conversation; one that references
|
|
365
|
+
none, made while exactly one call is waiting, is a sub-agent running
|
|
366
|
+
inside that call. Anything less certain leaves parent unknown."""
|
|
367
|
+
seen = {self.conv_of[c] for c in referenced if c in self.conv_of}
|
|
368
|
+
if len(seen) == 1:
|
|
369
|
+
return seen.pop()
|
|
370
|
+
if len(seen) > 1:
|
|
371
|
+
self.parent_unknown = self.parent_unknown or "a model request mixed calls from two conversations"
|
|
372
|
+
return None
|
|
373
|
+
waiting = [c for c in self.asked if c not in self.results]
|
|
374
|
+
if len(waiting) > 1:
|
|
375
|
+
self.parent_unknown = self.parent_unknown or (
|
|
376
|
+
"a model request began while several tool calls were waiting, "
|
|
377
|
+
"so which one it ran inside is unclear"
|
|
378
|
+
)
|
|
379
|
+
return None
|
|
380
|
+
return waiting[0] if waiting else None
|
|
381
|
+
|
|
382
|
+
def _trace(self, counted):
|
|
383
|
+
"""step and parent for the calls this turn counted, numbered in the order asked."""
|
|
384
|
+
at = {cid: i for i, cid in enumerate(counted)}
|
|
385
|
+
unknown = {}
|
|
386
|
+
if any(cid not in self.asked for cid in counted):
|
|
387
|
+
unknown["step"] = unknown["parent"] = "a call asked for in an earlier turn finished in this one"
|
|
388
|
+
if self.parent_unknown:
|
|
389
|
+
unknown.setdefault("parent", self.parent_unknown)
|
|
390
|
+
if any(self.conv_of.get(c) is not None and self.conv_of[c] not in at for c in counted):
|
|
391
|
+
unknown.setdefault("parent", "a call ran inside a tool call that was not counted")
|
|
392
|
+
steps = {}
|
|
393
|
+
calls = []
|
|
394
|
+
for i, cid in enumerate(counted):
|
|
395
|
+
call = {"id": "c%d" % (i + 1), "tool": self.requested[cid][0], "event": i}
|
|
396
|
+
if "step" not in unknown:
|
|
397
|
+
call["step"] = steps.setdefault(self.step_of.get(cid), len(steps) + 1)
|
|
398
|
+
if "parent" not in unknown:
|
|
399
|
+
parent = self.conv_of.get(cid)
|
|
400
|
+
call["parent"] = None if parent is None else "c%d" % (at[parent] + 1)
|
|
401
|
+
calls.append(call)
|
|
402
|
+
return {
|
|
403
|
+
"links": "full",
|
|
404
|
+
"known": [f for f in ("step", "parent") if f not in unknown],
|
|
405
|
+
"unknown": unknown,
|
|
406
|
+
"calls": calls,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
def _saw_request(self, kind, body):
|
|
410
|
+
self.model_requests += 1
|
|
411
|
+
if len(body) > MAX_BODY:
|
|
412
|
+
return self._fail("a model request was larger than 2 MB, so it was forwarded without being read")
|
|
413
|
+
try:
|
|
414
|
+
request = json.loads(body or b"{}")
|
|
415
|
+
except ValueError:
|
|
416
|
+
return self._fail("a model request was not JSON")
|
|
417
|
+
if not isinstance(request, dict):
|
|
418
|
+
return self._fail("a model request was not a JSON object")
|
|
419
|
+
if request.get("tools") or request.get("functions"):
|
|
420
|
+
self.offered_tools = True
|
|
421
|
+
try:
|
|
422
|
+
history = _requests_in_history(kind, request)
|
|
423
|
+
results = _results_in_request(kind, request)
|
|
424
|
+
unrecorded = _unrecorded_in_history(kind, request)
|
|
425
|
+
except _Unreadable as exc:
|
|
426
|
+
return self._fail(str(exc))
|
|
427
|
+
self._saw_unrecorded(unrecorded)
|
|
428
|
+
for cid, name, args in history:
|
|
429
|
+
if name is None:
|
|
430
|
+
continue # its result has no readable request: the orphan rule decides
|
|
431
|
+
self._note(cid, name, args, override=True)
|
|
432
|
+
ids = [cid for cid, _ in results]
|
|
433
|
+
if len(ids) != len(set(ids)):
|
|
434
|
+
return self._fail("two tool results shared one id, so they cannot be told apart")
|
|
435
|
+
for cid, content in results:
|
|
436
|
+
if cid in self.waiting and cid not in self.requested:
|
|
437
|
+
self._note(cid, *self.waiting[cid])
|
|
438
|
+
self.results.setdefault(cid, content)
|
|
439
|
+
return self._conversation([cid for cid, _, _ in history] + ids)
|
|
440
|
+
|
|
441
|
+
def _saw_unrecorded(self, items):
|
|
442
|
+
for key, reason in items:
|
|
443
|
+
# Re-sent history from an earlier turn was already filed there.
|
|
444
|
+
if key and "item:" + key in self.already:
|
|
445
|
+
continue
|
|
446
|
+
if key:
|
|
447
|
+
self.unrecorded.append("item:" + key)
|
|
448
|
+
self._fail(reason)
|
|
449
|
+
|
|
450
|
+
def _saw_reply(self, kind, status, content_type, content_encoding, body, truncated, conv=None):
|
|
451
|
+
if status >= 400:
|
|
452
|
+
self.last_reply_failed = True # asks for no tool; cleared if a retry succeeds
|
|
453
|
+
return
|
|
454
|
+
self.last_reply_failed = False
|
|
455
|
+
if truncated:
|
|
456
|
+
return self._fail("a model reply was larger than 2 MB, so it was forwarded without being read")
|
|
457
|
+
try:
|
|
458
|
+
raw = _decoded(body, content_encoding)
|
|
459
|
+
if "text/event-stream" in (content_type or ""):
|
|
460
|
+
calls = _requests_in_stream(kind, raw)
|
|
461
|
+
unrecorded = _unrecorded_in_stream(kind, raw)
|
|
462
|
+
else:
|
|
463
|
+
reply = json.loads(raw or b"{}")
|
|
464
|
+
calls = _requests_in_reply(kind, reply) if isinstance(reply, dict) else []
|
|
465
|
+
unrecorded = _unrecorded_in_reply(kind, reply) if isinstance(reply, dict) else []
|
|
466
|
+
except _Unreadable as exc:
|
|
467
|
+
return self._fail(str(exc))
|
|
468
|
+
except (ValueError, OSError, zlib.error):
|
|
469
|
+
return self._fail("a model reply could not be read")
|
|
470
|
+
self._saw_unrecorded(unrecorded)
|
|
471
|
+
if any(name for _, name, _ in calls):
|
|
472
|
+
self.step += 1
|
|
473
|
+
for cid, name, args in calls:
|
|
474
|
+
if name is None:
|
|
475
|
+
self._fail("the model asked for a tool call with no tool name, which PreMan cannot read")
|
|
476
|
+
continue
|
|
477
|
+
# A reply never re-sends history, so an id seen before is a new call
|
|
478
|
+
# sharing an old id: its result could pair with the wrong call.
|
|
479
|
+
# Compared with earlier replies only, not with history: the agent can
|
|
480
|
+
# re-send this very reply as history before the proxy finishes
|
|
481
|
+
# reading the stream, so history may already hold it.
|
|
482
|
+
if cid in self.already:
|
|
483
|
+
self.reused_id = True
|
|
484
|
+
elif cid in self.asked:
|
|
485
|
+
self.reused_in_turn = True
|
|
486
|
+
self._note(cid, name, args)
|
|
487
|
+
self.asked.append(cid)
|
|
488
|
+
self.step_of.setdefault(cid, self.step)
|
|
489
|
+
self.conv_of.setdefault(cid, conv)
|
|
490
|
+
|
|
491
|
+
def _outcome(self):
|
|
492
|
+
# Compute events, orphans, and unanswered first
|
|
493
|
+
events = []
|
|
494
|
+
# Only ids the model asked for in a reply this turn: a call and result
|
|
495
|
+
# the agent wrote into its first request (examples) never ran.
|
|
496
|
+
for cid in self.order:
|
|
497
|
+
if cid in self.results and (cid in self.asked or cid in self.waiting) and cid not in self.already:
|
|
498
|
+
name, args = self.requested[cid]
|
|
499
|
+
events.append({"role": "tool_result", "tool_name": name, "tool_args": args, "content": self.results[cid]})
|
|
500
|
+
orphan_results = [cid for cid in self.results if cid not in self.requested and cid not in self.already]
|
|
501
|
+
unanswered = [self.requested[cid][0] for cid in self.asked if cid not in self.results and cid not in self.already]
|
|
502
|
+
|
|
503
|
+
# Update already with event ids and orphan ids (NOT unanswered)
|
|
504
|
+
event_cids = [
|
|
505
|
+
cid for cid in self.order
|
|
506
|
+
if cid in self.results and (cid in self.asked or cid in self.waiting) and cid not in self.already
|
|
507
|
+
]
|
|
508
|
+
self._counted = list(event_cids)
|
|
509
|
+
self.waiting.update({cid: self.requested[cid] for cid in self.asked if cid not in self.results})
|
|
510
|
+
for cid in event_cids:
|
|
511
|
+
self.waiting.pop(cid, None)
|
|
512
|
+
self.already.update(event_cids)
|
|
513
|
+
self.already.update(orphan_results)
|
|
514
|
+
self.already.update(self.unrecorded)
|
|
515
|
+
|
|
516
|
+
# Choose the capture based on problems/conditions
|
|
517
|
+
if self.problem:
|
|
518
|
+
return events, _not_visible(self.problem)
|
|
519
|
+
if self.reused_id:
|
|
520
|
+
return events, _not_visible(
|
|
521
|
+
"the model reused a tool-call id from an earlier turn, "
|
|
522
|
+
"so this turn's calls cannot be told apart from earlier ones"
|
|
523
|
+
)
|
|
524
|
+
if self.reused_in_turn:
|
|
525
|
+
return events, _not_visible(
|
|
526
|
+
"the model asked for two tool calls with the same id in this turn, "
|
|
527
|
+
"so their results cannot be told apart"
|
|
528
|
+
)
|
|
529
|
+
if orphan_results:
|
|
530
|
+
return events, _not_visible("a tool result went back to the model for a call PreMan could not read")
|
|
531
|
+
if not self.model_requests:
|
|
532
|
+
return events, _not_visible(_NO_TRAFFIC)
|
|
533
|
+
if self.last_reply_failed:
|
|
534
|
+
return events, _not_visible(
|
|
535
|
+
"the model's last reply in this turn was an HTTP error, so the agent may have answered "
|
|
536
|
+
"without the tools it meant to call"
|
|
537
|
+
)
|
|
538
|
+
if unanswered:
|
|
539
|
+
return events, _not_visible(
|
|
540
|
+
"the model asked for %s, and the result never went back to the model, so whether it ran is unknown"
|
|
541
|
+
% ", ".join(sorted(set(unanswered)))
|
|
542
|
+
)
|
|
543
|
+
if not events and not self.offered_tools:
|
|
544
|
+
return events, _not_visible(_NO_TOOLS_OFFERED)
|
|
545
|
+
return events, {"state": "recorded", "source": "proxy", "watched": [], "missing": [], "reason": ""}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
DEFAULT_UPSTREAM = {"openai": "https://api.openai.com/v1", "anthropic": "https://api.anthropic.com"}
|
|
549
|
+
ENV_VAR = {"openai": "OPENAI_BASE_URL", "anthropic": "ANTHROPIC_BASE_URL"}
|
|
550
|
+
TIMEOUT = float(os.environ.get("PREMAN_AGENT_TIMEOUT", "600"))
|
|
551
|
+
_HOP = {
|
|
552
|
+
"connection", "keep-alive", "proxy-connection", "proxy-authorization", "transfer-encoding",
|
|
553
|
+
"te", "trailer", "upgrade", "host", "content-length", "accept-encoding",
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
558
|
+
def redirect_request(self, *args, **kwargs):
|
|
559
|
+
return None # a 3xx goes back to the agent unchanged; credentials never follow it elsewhere
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
_opener = urllib.request.build_opener(_NoRedirect)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class _Server(http.server.ThreadingHTTPServer):
|
|
566
|
+
daemon_threads = True
|
|
567
|
+
|
|
568
|
+
def handle_error(self, request, client_address):
|
|
569
|
+
pass # nothing is ever printed: a traceback can carry a header or a token
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def version() -> str:
|
|
573
|
+
"""The CLI version this proxy shipped with, recorded on every turn (spec §6.0.3)."""
|
|
574
|
+
try:
|
|
575
|
+
return str(json.loads((Path(__file__).resolve().parent.parent / "package.json").read_text())["version"])
|
|
576
|
+
except Exception:
|
|
577
|
+
return "unknown"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class Proxy:
|
|
581
|
+
def __init__(self, upstream=None):
|
|
582
|
+
self.upstream = upstream or {
|
|
583
|
+
provider: (os.environ.get(var) or DEFAULT_UPSTREAM[provider]).rstrip("/")
|
|
584
|
+
for provider, var in ENV_VAR.items()
|
|
585
|
+
}
|
|
586
|
+
self._channels = {}
|
|
587
|
+
self._lock = threading.Lock()
|
|
588
|
+
self._server = _Server(("127.0.0.1", 0), _handler(self))
|
|
589
|
+
self._server.daemon_threads = True
|
|
590
|
+
self.port = self._server.server_address[1]
|
|
591
|
+
threading.Thread(target=self._server.serve_forever, name="preman-tool-proxy", daemon=True).start()
|
|
592
|
+
|
|
593
|
+
def channel(self) -> str:
|
|
594
|
+
token = secrets.token_hex(16)
|
|
595
|
+
with self._lock:
|
|
596
|
+
self._channels[token] = {"turn": None, "between": False, "reported": set(), "waiting": {}}
|
|
597
|
+
return token
|
|
598
|
+
|
|
599
|
+
def env(self, token: str) -> dict:
|
|
600
|
+
env = {var: "http://127.0.0.1:%d/%s/%s" % (self.port, token, provider) for provider, var in ENV_VAR.items()}
|
|
601
|
+
# An agent behind HTTP(S)_PROXY must still reach this local proxy directly.
|
|
602
|
+
hosts = [h.strip() for h in (os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or "").split(",") if h.strip()]
|
|
603
|
+
hosts += [h for h in ("127.0.0.1", "localhost") if h not in hosts]
|
|
604
|
+
env["NO_PROXY"] = env["no_proxy"] = ",".join(hosts)
|
|
605
|
+
return env
|
|
606
|
+
|
|
607
|
+
def begin(self, token: str) -> None:
|
|
608
|
+
with self._lock:
|
|
609
|
+
channel = self._channels.get(token)
|
|
610
|
+
if channel is not None:
|
|
611
|
+
channel["turn"] = _Turn(already=channel["reported"], waiting=channel["waiting"])
|
|
612
|
+
|
|
613
|
+
def end(self, token: str):
|
|
614
|
+
with self._lock:
|
|
615
|
+
channel = self._channels.get(token)
|
|
616
|
+
if channel is None:
|
|
617
|
+
capture = _not_visible(_CHANNEL_CLOSED)
|
|
618
|
+
capture["trace"] = {"links": "order_only", "reason": _CHANNEL_CLOSED, "known": [], "unknown": {}, "calls": []}
|
|
619
|
+
capture["proxy_version"] = version()
|
|
620
|
+
return [], capture
|
|
621
|
+
turn, channel["turn"] = channel["turn"], None
|
|
622
|
+
between, channel["between"] = channel["between"], False
|
|
623
|
+
events, capture = (turn or _Turn(already=channel["reported"], waiting=channel["waiting"])).outcome()
|
|
624
|
+
if between and capture["state"] == "recorded":
|
|
625
|
+
capture = dict(
|
|
626
|
+
_not_visible("a model request arrived between turns, so it was not filed under any of them"),
|
|
627
|
+
trace=capture.get("trace"),
|
|
628
|
+
)
|
|
629
|
+
capture["proxy_version"] = version()
|
|
630
|
+
return events, capture
|
|
631
|
+
|
|
632
|
+
def drop(self, token: str) -> None:
|
|
633
|
+
with self._lock:
|
|
634
|
+
self._channels.pop(token, None)
|
|
635
|
+
|
|
636
|
+
def close(self) -> None:
|
|
637
|
+
self._server.shutdown()
|
|
638
|
+
self._server.server_close()
|
|
639
|
+
|
|
640
|
+
def _turn_for(self, token, kind):
|
|
641
|
+
with self._lock:
|
|
642
|
+
channel = self._channels.get(token)
|
|
643
|
+
if channel is None:
|
|
644
|
+
return None, False
|
|
645
|
+
if channel["turn"] is None and kind:
|
|
646
|
+
# Only a model request counts: a model list or a token count
|
|
647
|
+
# between turns carries no tool call.
|
|
648
|
+
channel["between"] = True
|
|
649
|
+
return channel["turn"], True
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _handler(proxy):
|
|
653
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
654
|
+
protocol_version = "HTTP/1.1"
|
|
655
|
+
|
|
656
|
+
def log_message(self, *args):
|
|
657
|
+
pass # the request line carries the channel token; nothing is logged
|
|
658
|
+
|
|
659
|
+
def _body(self):
|
|
660
|
+
if "chunked" in (self.headers.get("Transfer-Encoding") or "").lower():
|
|
661
|
+
data = bytearray()
|
|
662
|
+
while True:
|
|
663
|
+
size = int(self.rfile.readline().split(b";")[0].strip() or b"0", 16)
|
|
664
|
+
if size < 0:
|
|
665
|
+
raise ValueError("negative chunk size")
|
|
666
|
+
if not size:
|
|
667
|
+
self.rfile.readline()
|
|
668
|
+
return bytes(data)
|
|
669
|
+
data += self.rfile.read(size)
|
|
670
|
+
self.rfile.readline()
|
|
671
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
672
|
+
if length < 0:
|
|
673
|
+
raise ValueError("negative content length")
|
|
674
|
+
return self.rfile.read(length) if length else b""
|
|
675
|
+
|
|
676
|
+
def _refuse(self, status, text):
|
|
677
|
+
payload = json.dumps({"error": text}).encode()
|
|
678
|
+
self.send_response_only(status)
|
|
679
|
+
self.send_header("Content-Type", "application/json")
|
|
680
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
681
|
+
self.send_header("Connection", "close") # the request body may be unread
|
|
682
|
+
self.end_headers()
|
|
683
|
+
self.wfile.write(payload)
|
|
684
|
+
self.close_connection = True
|
|
685
|
+
|
|
686
|
+
def _forward(self):
|
|
687
|
+
parts = self.path.split("/", 3) # ["", token, provider, rest]
|
|
688
|
+
if len(parts) < 3 or parts[2] not in ENV_VAR:
|
|
689
|
+
return self._refuse(404, "not a PreMan proxy address")
|
|
690
|
+
token, provider = parts[1], parts[2]
|
|
691
|
+
rest = "/" + parts[3] if len(parts) > 3 else ""
|
|
692
|
+
kind = kind_of(provider, rest)
|
|
693
|
+
turn, known = proxy._turn_for(token, kind)
|
|
694
|
+
if not known:
|
|
695
|
+
return self._refuse(404, "not a PreMan proxy address")
|
|
696
|
+
try:
|
|
697
|
+
body = self._body()
|
|
698
|
+
except ValueError:
|
|
699
|
+
return self._refuse(400, "malformed request body")
|
|
700
|
+
conv = None
|
|
701
|
+
if turn is not None and kind:
|
|
702
|
+
try:
|
|
703
|
+
conv = turn.saw_request(kind, body)
|
|
704
|
+
except Exception:
|
|
705
|
+
turn._fail("a model request could not be read")
|
|
706
|
+
|
|
707
|
+
headers = {k: v for k, v in self.headers.items() if k.lower() not in _HOP}
|
|
708
|
+
request = urllib.request.Request(
|
|
709
|
+
proxy.upstream[provider] + rest,
|
|
710
|
+
data=body if body or self.command in ("POST", "PUT", "PATCH") else None,
|
|
711
|
+
headers=headers,
|
|
712
|
+
method=self.command,
|
|
713
|
+
)
|
|
714
|
+
try:
|
|
715
|
+
reply = _opener.open(request, timeout=TIMEOUT)
|
|
716
|
+
except urllib.error.HTTPError as exc:
|
|
717
|
+
reply = exc # a 4xx/5xx reply still goes back to the agent unchanged
|
|
718
|
+
except OSError:
|
|
719
|
+
self.close_connection = True # unreachable: the agent sees a dropped connection
|
|
720
|
+
return
|
|
721
|
+
|
|
722
|
+
bodyless = reply.status < 200 or reply.status in (204, 304)
|
|
723
|
+
self.send_response_only(reply.status, reply.reason)
|
|
724
|
+
for name, value in reply.headers.items():
|
|
725
|
+
if name.lower() not in _HOP:
|
|
726
|
+
self.send_header(name, value)
|
|
727
|
+
if not bodyless:
|
|
728
|
+
self.send_header("Transfer-Encoding", "chunked")
|
|
729
|
+
self.end_headers()
|
|
730
|
+
copy = bytearray()
|
|
731
|
+
cut = False
|
|
732
|
+
|
|
733
|
+
def send(chunk):
|
|
734
|
+
self.wfile.write(b"%x\r\n%s\r\n" % (len(chunk), chunk))
|
|
735
|
+
self.wfile.flush()
|
|
736
|
+
|
|
737
|
+
# One chunk behind: the reply's last chunk reaches the agent only
|
|
738
|
+
# after the proxy has read the whole reply. Otherwise an SDK that
|
|
739
|
+
# acts on "[DONE]" can start the next request -- a sub-agent's, or
|
|
740
|
+
# the turn's end -- before the proxy knows what this reply asked for.
|
|
741
|
+
held = b""
|
|
742
|
+
try:
|
|
743
|
+
while not bodyless:
|
|
744
|
+
chunk = reply.read1(65536) if hasattr(reply, "read1") else reply.read(65536)
|
|
745
|
+
if not chunk:
|
|
746
|
+
break
|
|
747
|
+
if held:
|
|
748
|
+
send(held)
|
|
749
|
+
held = chunk
|
|
750
|
+
if len(copy) <= MAX_BODY:
|
|
751
|
+
copy += chunk
|
|
752
|
+
except (OSError, http.client.HTTPException):
|
|
753
|
+
cut = True
|
|
754
|
+
self.close_connection = True # the reply broke off, from either end
|
|
755
|
+
finally:
|
|
756
|
+
reply.close()
|
|
757
|
+
if turn is not None and kind:
|
|
758
|
+
try:
|
|
759
|
+
if cut:
|
|
760
|
+
turn._fail("a model reply was cut off before it ended")
|
|
761
|
+
turn.saw_reply(
|
|
762
|
+
kind,
|
|
763
|
+
reply.status,
|
|
764
|
+
reply.headers.get("Content-Type", ""),
|
|
765
|
+
reply.headers.get("Content-Encoding", ""),
|
|
766
|
+
bytes(copy),
|
|
767
|
+
len(copy) > MAX_BODY,
|
|
768
|
+
conv,
|
|
769
|
+
)
|
|
770
|
+
except Exception:
|
|
771
|
+
turn._fail("a model reply could not be read")
|
|
772
|
+
try:
|
|
773
|
+
if held:
|
|
774
|
+
send(held)
|
|
775
|
+
if not bodyless and not cut:
|
|
776
|
+
self.wfile.write(b"0\r\n\r\n")
|
|
777
|
+
except (OSError, http.client.HTTPException):
|
|
778
|
+
self.close_connection = True # the agent hung up
|
|
779
|
+
|
|
780
|
+
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _forward
|
|
781
|
+
|
|
782
|
+
return Handler
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
_shared = None
|
|
786
|
+
_shared_lock = threading.Lock()
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def shared() -> Proxy:
|
|
790
|
+
"""The one proxy for this process, started on first use."""
|
|
791
|
+
global _shared
|
|
792
|
+
with _shared_lock:
|
|
793
|
+
if _shared is None:
|
|
794
|
+
_shared = Proxy()
|
|
795
|
+
return _shared
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def choose(proxied, reported=None):
|
|
799
|
+
"""The proxy's account when it saw the turn; otherwise (it saw no traffic, its
|
|
800
|
+
channel closed, or no tools were offered) the agent's own report if that one
|
|
801
|
+
was recorded. One source per
|
|
802
|
+
turn, never both, so a call seen both ways is counted once."""
|
|
803
|
+
if reported is not None and proxied[1].get("state") == "recorded" and reported[1].get("state") == "recorded":
|
|
804
|
+
def calls(events):
|
|
805
|
+
return sum(
|
|
806
|
+
1 for e in events or []
|
|
807
|
+
if isinstance(e, dict) and (e.get("role") == "tool_result" or (not e.get("role") and e.get("tool_name")))
|
|
808
|
+
)
|
|
809
|
+
if calls(reported[0]) > calls(proxied[0]):
|
|
810
|
+
capture = _not_visible("the agent reported more tool calls than PreMan's proxy saw")
|
|
811
|
+
capture["proxy_version"] = proxied[1].get("proxy_version", version())
|
|
812
|
+
if "trace" in proxied[1]:
|
|
813
|
+
capture["trace"] = proxied[1]["trace"]
|
|
814
|
+
return proxied[0], capture
|
|
815
|
+
if proxied[1].get("state") == "recorded" or reported is None:
|
|
816
|
+
return proxied
|
|
817
|
+
if reported[1].get("state") == "recorded" and proxied[1].get("reason") in _UNSEEN:
|
|
818
|
+
capture = dict(reported[1])
|
|
819
|
+
capture["proxy_version"] = proxied[1].get("proxy_version", version())
|
|
820
|
+
return reported[0], capture
|
|
821
|
+
# Neither recorded: keep the agent's own diagnostic next to the proxy's.
|
|
822
|
+
proxied_reason = proxied[1].get("reason") or ""
|
|
823
|
+
reported_reason = (reported[1].get("reason") or "") if reported else ""
|
|
824
|
+
if reported_reason and reported_reason != proxied_reason:
|
|
825
|
+
capture = dict(proxied[1])
|
|
826
|
+
capture["reason"] = "%s; %s" % (proxied_reason, reported_reason) if proxied_reason else reported_reason
|
|
827
|
+
return proxied[0], capture
|
|
828
|
+
return proxied
|