cortad 0.3.0 → 0.3.1-rc.10
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/lib/cli.mjs +8 -0
- package/lib/data.mjs +143 -0
- package/lib/fresh.mjs +108 -0
- package/lib/home.mjs +7 -4
- package/lib/mcp.mjs +3 -3
- package/lib/pyhook/sitecustomize.py +1024 -86
- package/lib/read-text.mjs +25 -7
- package/lib/replay.mjs +25 -7
- package/lib/text.mjs +165 -33
- package/lib/trace.cjs +651 -57
- package/lib/verbs.mjs +37 -11
- package/lib/words.mjs +18 -1
- package/lib/writes.mjs +59 -0
- package/local.mjs +118 -23
- package/package.json +4 -1
- package/skill/SKILL.md +9 -6
- package/skill/references/results.md +42 -4
|
@@ -8,6 +8,101 @@ import os
|
|
|
8
8
|
|
|
9
9
|
_FILE = os.environ.get("CORTAD_TRACE_FILE")
|
|
10
10
|
_RULES = os.environ.get("CORTAD_RULES_FILE")
|
|
11
|
+
_WRITES = os.environ.get("CORTAD_WRITES_DIR")
|
|
12
|
+
_APP_ROOT = os.environ.get("CORTAD_APP_ROOT")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# What the app writes into its own folder while a run is on: each file copied once before its first
|
|
16
|
+
# write, so the command puts every one back when the run ends (lib/writes.mjs). Trials wrote carts
|
|
17
|
+
# and tickets into an app's data/*.json and the developer's own tests read them afterwards.
|
|
18
|
+
def _install_writes():
|
|
19
|
+
import builtins
|
|
20
|
+
import hashlib
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
|
|
26
|
+
roots = {os.path.realpath(_APP_ROOT), os.path.abspath(_APP_ROOT)}
|
|
27
|
+
not_data = re.compile(r"(?:^|/)(?:node_modules|\.git|\.next|\.cache|\.venv|venv|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|dist|build|coverage|\.cortad)(?:/|$)|\.(?:log|pyc|tmp|swp)$")
|
|
28
|
+
state = {"run": None, "at": -1, "seen": set()}
|
|
29
|
+
real_open = builtins.open
|
|
30
|
+
real_os_open = os.open
|
|
31
|
+
real_rename, real_replace, real_remove, real_unlink, real_truncate = os.rename, os.replace, os.remove, os.unlink, os.truncate
|
|
32
|
+
|
|
33
|
+
# The run marker names the run; a new one starts the record over, none stops it.
|
|
34
|
+
def run_now():
|
|
35
|
+
try:
|
|
36
|
+
marker = os.path.join(_WRITES, "run")
|
|
37
|
+
at = os.stat(marker).st_mtime_ns
|
|
38
|
+
if at != state["at"]:
|
|
39
|
+
state["at"] = at
|
|
40
|
+
with real_open(marker, encoding="utf-8") as f:
|
|
41
|
+
state["run"] = f.read().strip()
|
|
42
|
+
state["seen"] = set()
|
|
43
|
+
return state["run"]
|
|
44
|
+
except Exception:
|
|
45
|
+
state["run"], state["at"], state["seen"] = None, -1, set()
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def note(target):
|
|
49
|
+
try:
|
|
50
|
+
if isinstance(target, int) or target is None:
|
|
51
|
+
return
|
|
52
|
+
path = os.fspath(target)
|
|
53
|
+
if isinstance(path, bytes):
|
|
54
|
+
path = path.decode("utf-8", "replace")
|
|
55
|
+
ab = os.path.abspath(path)
|
|
56
|
+
root = next((r for r in roots if ab.startswith(r + os.sep)), None)
|
|
57
|
+
if root is None:
|
|
58
|
+
return
|
|
59
|
+
rel = os.path.relpath(ab, root)
|
|
60
|
+
if not_data.search(rel):
|
|
61
|
+
return
|
|
62
|
+
run = run_now()
|
|
63
|
+
if not run or rel in state["seen"]:
|
|
64
|
+
return
|
|
65
|
+
state["seen"].add(rel)
|
|
66
|
+
before = None
|
|
67
|
+
if os.path.isfile(ab):
|
|
68
|
+
before = os.path.join(_WRITES, "before", hashlib.sha1(rel.encode("utf-8")).hexdigest())
|
|
69
|
+
os.makedirs(os.path.dirname(before), mode=0o700, exist_ok=True)
|
|
70
|
+
shutil.copyfile(ab, before)
|
|
71
|
+
fd = real_os_open(os.path.join(_WRITES, "written.jsonl"), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
72
|
+
with os.fdopen(fd, "a", encoding="utf-8") as f:
|
|
73
|
+
f.write(json.dumps({"run": run, "path": rel, "before": before}) + "\n")
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def opened(file, mode="r", *a, **kw):
|
|
78
|
+
m = kw.get("mode", mode)
|
|
79
|
+
if isinstance(m, str) and any(c in m for c in "wax+"):
|
|
80
|
+
note(file)
|
|
81
|
+
return real_open(file, mode, *a, **kw)
|
|
82
|
+
|
|
83
|
+
def os_opened(path, flags, mode=0o777, *a, **kw):
|
|
84
|
+
if flags & (os.O_WRONLY | os.O_RDWR | os.O_APPEND | os.O_TRUNC | os.O_CREAT):
|
|
85
|
+
note(path)
|
|
86
|
+
return real_os_open(path, flags, mode, *a, **kw)
|
|
87
|
+
|
|
88
|
+
def two(orig):
|
|
89
|
+
def moved(src, dst, *a, **kw):
|
|
90
|
+
note(src)
|
|
91
|
+
note(dst)
|
|
92
|
+
return orig(src, dst, *a, **kw)
|
|
93
|
+
return moved
|
|
94
|
+
|
|
95
|
+
def one(orig):
|
|
96
|
+
def gone(path, *a, **kw):
|
|
97
|
+
note(path)
|
|
98
|
+
return orig(path, *a, **kw)
|
|
99
|
+
return gone
|
|
100
|
+
|
|
101
|
+
builtins.open = opened
|
|
102
|
+
io.open = opened
|
|
103
|
+
os.open = os_opened
|
|
104
|
+
os.rename, os.replace = two(real_rename), two(real_replace)
|
|
105
|
+
os.remove, os.unlink, os.truncate = one(real_remove), one(real_unlink), one(real_truncate)
|
|
11
106
|
|
|
12
107
|
|
|
13
108
|
def _install():
|
|
@@ -84,6 +179,28 @@ def _install():
|
|
|
84
179
|
return ""
|
|
85
180
|
return ""
|
|
86
181
|
|
|
182
|
+
def items(v):
|
|
183
|
+
return [x for x in v if isinstance(x, dict)] if isinstance(v, list) else []
|
|
184
|
+
|
|
185
|
+
# Only what came after the person's latest message belongs to this turn: a thread the app
|
|
186
|
+
# resends whole carries every earlier turn's tool answers too.
|
|
187
|
+
def since(seq, is_person):
|
|
188
|
+
at = -1
|
|
189
|
+
for i, m in enumerate(seq):
|
|
190
|
+
if is_person(m):
|
|
191
|
+
at = i
|
|
192
|
+
return seq[at + 1:]
|
|
193
|
+
|
|
194
|
+
# A user message that only hands a tool's answer back ("Observation: ...", <tool_response>) is
|
|
195
|
+
# the agent loop talking, not the person.
|
|
196
|
+
handed_back = re.compile(r"^\s*(?:Observation\s*:|<(?:tool_response|tool_result|function_results?)>)", re.I)
|
|
197
|
+
|
|
198
|
+
def person_said(m):
|
|
199
|
+
return m.get("role") == "user" and not any(isinstance(c, dict) and c.get("type") == "tool_result" for c in (m.get("content") if isinstance(m.get("content"), list) else [])) and not handed_back.match(text_of(m.get("content")))
|
|
200
|
+
|
|
201
|
+
def person_asked(c):
|
|
202
|
+
return c.get("role") == "user" and not any(p.get("functionResponse") for p in items(c.get("parts")))
|
|
203
|
+
|
|
87
204
|
def tools_in(sent):
|
|
88
205
|
body = parsed(sent) if isinstance(sent, str) else None
|
|
89
206
|
if not isinstance(body, dict):
|
|
@@ -95,82 +212,582 @@ def _install():
|
|
|
95
212
|
if t.strip() and len(out) < tools_max and all(o["text"] != t for o in out):
|
|
96
213
|
out.append({"name": str(name or "")[:80], "text": t})
|
|
97
214
|
|
|
98
|
-
messages =
|
|
215
|
+
messages = items(body.get("messages"))
|
|
99
216
|
for m in messages:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
for c in m.get("tool_calls") or []:
|
|
103
|
-
if isinstance(c, dict) and c.get("id") and isinstance(c.get("function"), dict):
|
|
217
|
+
for c in items(m.get("tool_calls")):
|
|
218
|
+
if c.get("id") and isinstance(c.get("function"), dict):
|
|
104
219
|
names[c["id"]] = c["function"].get("name")
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
for m in messages:
|
|
110
|
-
if not isinstance(m, dict):
|
|
111
|
-
continue
|
|
220
|
+
for c in items(m.get("content")):
|
|
221
|
+
if c.get("type") == "tool_use" and c.get("id"):
|
|
222
|
+
names[c["id"]] = c.get("name")
|
|
223
|
+
for m in since(messages, person_said):
|
|
112
224
|
if m.get("role") in ("tool", "function"):
|
|
113
225
|
add(m.get("name") or names.get(m.get("tool_call_id")), m.get("content"))
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if isinstance(it, dict) and it.get("type") == "function_call" and it.get("call_id"):
|
|
226
|
+
for c in items(m.get("content")):
|
|
227
|
+
if c.get("type") == "tool_result":
|
|
228
|
+
add(names.get(c.get("tool_use_id")), c.get("content"))
|
|
229
|
+
inputs = items(body.get("input"))
|
|
230
|
+
for it in inputs:
|
|
231
|
+
if it.get("type") == "function_call" and it.get("call_id"):
|
|
121
232
|
names[it["call_id"]] = it.get("name")
|
|
122
|
-
for it in
|
|
123
|
-
if
|
|
233
|
+
for it in since(inputs, lambda x: x.get("role") == "user"):
|
|
234
|
+
if it.get("type") == "function_call_output":
|
|
124
235
|
add(names.get(it.get("call_id")), it.get("output"))
|
|
125
|
-
for c in
|
|
126
|
-
for p in
|
|
127
|
-
if isinstance(p
|
|
236
|
+
for c in since(items(body.get("contents")), person_asked):
|
|
237
|
+
for p in items(c.get("parts")):
|
|
238
|
+
if isinstance(p.get("functionResponse"), dict):
|
|
128
239
|
add(p["functionResponse"].get("name"), p["functionResponse"].get("response"))
|
|
240
|
+
declared = declared_in(body)
|
|
241
|
+
for name, said in observed_in(turn_texts(body, True, declared), declared):
|
|
242
|
+
add(name, said)
|
|
129
243
|
return out or None
|
|
130
244
|
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
|
|
245
|
+
# What the model asked the app to run: chat tool_calls (a stream's pieces joined by position), a
|
|
246
|
+
# legacy function_call, a responses function_call item, an Anthropic or Bedrock tool use, a
|
|
247
|
+
# Gemini functionCall. Read off the model's reply, and off this turn's earlier calls as the
|
|
248
|
+
# prompt resends them. Names and arguments only: every value clipped, a secret never written.
|
|
249
|
+
calls_max, value_max, args_max = 12, 200, 1200
|
|
250
|
+
secret_key = re.compile(r"(?:^|_)(?:pass(?:word|phrase)?|secret|token|api_?key|authorization|cookie|session(?:_id)?|credentials?|private_key)$")
|
|
251
|
+
secret_value = re.compile(r"^(?:Bearer\s|Basic\s|sk-|pk_|rk_|ghp_|gho_|github_pat_|xox[abpr]-|AKIA|AIza|eyJ[\w-]{10,}\.)")
|
|
136
252
|
|
|
137
|
-
def
|
|
253
|
+
def clipped(v, depth=0, most=value_max):
|
|
254
|
+
if isinstance(v, str):
|
|
255
|
+
return "[secret]" if secret_value.match(v) else (v[:most] + "…" if len(v) > most else v)
|
|
256
|
+
if isinstance(v, list):
|
|
257
|
+
return [] if depth > 4 else [clipped(x, depth + 1, most) for x in v[:20]]
|
|
258
|
+
if isinstance(v, dict):
|
|
259
|
+
if depth > 4:
|
|
260
|
+
return {}
|
|
261
|
+
return {k: ("[secret]" if secret_key.search(re.sub(r"([a-z])([A-Z])", r"\1_\2", str(k)).lower()) else clipped(x, depth + 1, most)) for k, x in list(v.items())[:40]}
|
|
262
|
+
return v
|
|
263
|
+
|
|
264
|
+
def args_text(raw):
|
|
265
|
+
v = raw
|
|
266
|
+
if isinstance(raw, str):
|
|
267
|
+
v = parsed(raw) if raw.strip() else {}
|
|
268
|
+
if v is None:
|
|
269
|
+
v = raw
|
|
270
|
+
return json.dumps(clipped({} if v is None else v), ensure_ascii=False, separators=(",", ":"))[:args_max]
|
|
271
|
+
|
|
272
|
+
def calls_of(events):
|
|
273
|
+
whole, parts, n = {}, {}, [0]
|
|
274
|
+
|
|
275
|
+
def put(key, name, args):
|
|
276
|
+
if name:
|
|
277
|
+
if not key:
|
|
278
|
+
n[0] += 1
|
|
279
|
+
key = "w%d" % n[0]
|
|
280
|
+
whole[key] = (name, args)
|
|
281
|
+
|
|
282
|
+
def piece(key, name, args):
|
|
283
|
+
p = parts.setdefault(key, ["", ""])
|
|
284
|
+
if name and not p[0]:
|
|
285
|
+
p[0] = name
|
|
286
|
+
if isinstance(args, str):
|
|
287
|
+
p[1] += args
|
|
288
|
+
|
|
289
|
+
def blocks(content):
|
|
290
|
+
for b in items(content):
|
|
291
|
+
if b.get("type") == "tool_use":
|
|
292
|
+
put(b.get("id"), b.get("name"), b.get("input"))
|
|
293
|
+
if isinstance(b.get("toolUse"), dict):
|
|
294
|
+
put(b["toolUse"].get("toolUseId"), b["toolUse"].get("name"), b["toolUse"].get("input"))
|
|
295
|
+
|
|
296
|
+
for e in events:
|
|
297
|
+
if not isinstance(e, dict):
|
|
298
|
+
continue
|
|
299
|
+
for c in items(e.get("choices")):
|
|
300
|
+
m = c.get("message") if isinstance(c.get("message"), dict) else None
|
|
301
|
+
if m:
|
|
302
|
+
for t in items(m.get("tool_calls")):
|
|
303
|
+
f = t.get("function") if isinstance(t.get("function"), dict) else None
|
|
304
|
+
if f:
|
|
305
|
+
put(t.get("id"), f.get("name"), f.get("arguments"))
|
|
306
|
+
if isinstance(m.get("function_call"), dict):
|
|
307
|
+
put(None, m["function_call"].get("name"), m["function_call"].get("arguments"))
|
|
308
|
+
d = c.get("delta") if isinstance(c.get("delta"), dict) else None
|
|
309
|
+
if d:
|
|
310
|
+
for t in items(d.get("tool_calls")):
|
|
311
|
+
f = t.get("function") if isinstance(t.get("function"), dict) else {}
|
|
312
|
+
piece("c%s.%s" % (c.get("index") or 0, t.get("index", t.get("id"))), f.get("name"), f.get("arguments"))
|
|
313
|
+
if isinstance(d.get("function_call"), dict):
|
|
314
|
+
piece("f%s" % (c.get("index") or 0), d["function_call"].get("name"), d["function_call"].get("arguments"))
|
|
315
|
+
resp = e.get("response") if isinstance(e.get("response"), dict) else {}
|
|
316
|
+
for it in items(e.get("output")) + items([e.get("item")]) + items(resp.get("output")):
|
|
317
|
+
if it.get("type") == "function_call":
|
|
318
|
+
put(it.get("call_id") or it.get("id"), it.get("name"), it.get("arguments"))
|
|
319
|
+
blocks(e.get("content"))
|
|
320
|
+
if isinstance(e.get("message"), dict):
|
|
321
|
+
blocks(e["message"].get("content"))
|
|
322
|
+
if isinstance(e.get("output"), dict) and isinstance(e["output"].get("message"), dict):
|
|
323
|
+
blocks(e["output"]["message"].get("content"))
|
|
324
|
+
cb = e.get("content_block") if isinstance(e.get("content_block"), dict) else {}
|
|
325
|
+
if e.get("type") == "content_block_start" and cb.get("type") == "tool_use":
|
|
326
|
+
piece("a%s" % e.get("index"), cb.get("name"), "")
|
|
327
|
+
delta = e.get("delta") if isinstance(e.get("delta"), dict) else {}
|
|
328
|
+
if e.get("type") == "content_block_delta" and delta.get("type") == "input_json_delta":
|
|
329
|
+
piece("a%s" % e.get("index"), "", delta.get("partial_json"))
|
|
330
|
+
for c in items(e.get("candidates")):
|
|
331
|
+
for p in items((c.get("content") or {}).get("parts") if isinstance(c.get("content"), dict) else None):
|
|
332
|
+
if isinstance(p.get("functionCall"), dict):
|
|
333
|
+
put(None, p["functionCall"].get("name"), p["functionCall"].get("args"))
|
|
334
|
+
found = list(whole.values()) + [tuple(p) for p in parts.values()]
|
|
335
|
+
return [{"name": str(name)[:80], "arguments": args_text(args)} for name, args in found if name]
|
|
336
|
+
|
|
337
|
+
# This turn's earlier calls, as the prompt resends them after the person's latest message.
|
|
338
|
+
def called_before(body):
|
|
339
|
+
events = []
|
|
340
|
+
for m in since(items(body.get("messages")), person_said):
|
|
341
|
+
if m.get("role") == "assistant":
|
|
342
|
+
events.append({"choices": [{"message": m}], "content": m.get("content")})
|
|
343
|
+
events.append({"output": since(items(body.get("input")), lambda x: x.get("role") == "user")})
|
|
344
|
+
for c in since(items(body.get("contents")), person_asked):
|
|
345
|
+
if c.get("role") == "model":
|
|
346
|
+
events.append({"candidates": [{"content": c}]})
|
|
347
|
+
return calls_of(events)
|
|
348
|
+
|
|
349
|
+
# Tool use a model writes in its words instead of as a structured call. A ReAct agent (CrewAI,
|
|
350
|
+
# LangChain) writes "Action: name" then "Action Input: {...}" and is handed "Observation: ..."
|
|
351
|
+
# back in its next prompt; others write <tool_call>{...}</tool_call>, <function=name>,
|
|
352
|
+
# <invoke name="..."> or a JSON object that names the tool. A name counts only when the request
|
|
353
|
+
# declares that tool, in its tools field or in the tool list its prompt carries, so a thought, a
|
|
354
|
+
# "Final Answer" or prose that says Action is never a call.
|
|
355
|
+
final = re.compile(r"final[\s_-]*answer", re.I)
|
|
356
|
+
tool_name = re.compile(r"[A-Za-z_][\w.-]{0,79}", re.A)
|
|
357
|
+
|
|
358
|
+
def prompt_text(body):
|
|
359
|
+
inp = body.get("input")
|
|
360
|
+
return "\n".join(
|
|
361
|
+
[text_of(m.get("content")) for m in items(body.get("messages")) if m.get("role") != "assistant"]
|
|
362
|
+
+ [text_of(body.get("system")), text_of(body.get("instructions")), body.get("prompt") if isinstance(body.get("prompt"), str) else "", inp if isinstance(inp, str) else ""]
|
|
363
|
+
+ [text_of(x.get("content")) for x in items(inp) if x.get("role") and x.get("role") != "assistant"]
|
|
364
|
+
+ [text_of((body.get("systemInstruction") or {}).get("parts") if isinstance(body.get("systemInstruction"), dict) else None)]
|
|
365
|
+
+ [text_of(c.get("parts")) for c in items(body.get("contents")) if c.get("role") != "model"])
|
|
366
|
+
|
|
367
|
+
def declared_in(body):
|
|
138
368
|
out = set()
|
|
139
369
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
if
|
|
143
|
-
out.add(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
370
|
+
def add(n):
|
|
371
|
+
v = n.strip().strip("\"'`") if isinstance(n, str) else ""
|
|
372
|
+
if tool_name.fullmatch(v) and not final.fullmatch(v):
|
|
373
|
+
out.add(v)
|
|
374
|
+
|
|
375
|
+
def get(d, k):
|
|
376
|
+
return d.get(k) if isinstance(d, dict) else None
|
|
377
|
+
|
|
378
|
+
for t in items(body.get("tools")):
|
|
379
|
+
add(t.get("name"))
|
|
380
|
+
add(get(t.get("function"), "name"))
|
|
381
|
+
add(get(t.get("toolSpec"), "name"))
|
|
382
|
+
for d in items(t.get("functionDeclarations") or t.get("function_declarations")):
|
|
383
|
+
add(d.get("name"))
|
|
384
|
+
for f in items(body.get("functions")):
|
|
385
|
+
add(f.get("name"))
|
|
386
|
+
for t in items(get(body.get("toolConfig"), "tools")):
|
|
387
|
+
add(get(t.get("toolSpec"), "name"))
|
|
388
|
+
text = prompt_text(body)[:200000]
|
|
389
|
+
for m in re.finditer(r"^[ \t]*Tool Name:[ \t]*([^\n]+)", text, re.I | re.M):
|
|
390
|
+
add(m.group(1))
|
|
391
|
+
for m in re.finditer(r"\b(?:one of|name of|names? from)[ \t]*\[([^\]\n]{1,2000})\]", text, re.I):
|
|
392
|
+
for x in m.group(1).split(","):
|
|
393
|
+
add(x)
|
|
394
|
+
for m in re.finditer(r"valid \"?action\"? values?:?[ \t]*([^\n]{1,2000})", text, re.I):
|
|
395
|
+
for x in re.split(r",|\bor\b", m.group(1)):
|
|
396
|
+
add(x)
|
|
397
|
+
for m in re.finditer(r"<(tools|functions)>([\s\S]*?)</\1>", text, re.I):
|
|
398
|
+
for k in re.finditer(r"\"name\"\s*:\s*\"([^\"]+)\"", m.group(2)):
|
|
399
|
+
add(k.group(1))
|
|
400
|
+
for m in re.finditer(r"\"name\"\s*:\s*\"([^\"]+)\"\s*,\s*\"(?:description|parameters|input_schema)\"", text):
|
|
401
|
+
add(m.group(1))
|
|
402
|
+
for m in re.finditer(r"<(?:tool|function)\s+name\s*=\s*[\"']([^\"']+)[\"']", text, re.I):
|
|
403
|
+
add(m.group(1))
|
|
157
404
|
return out
|
|
158
405
|
|
|
159
|
-
|
|
160
|
-
|
|
406
|
+
react = re.compile(r"(?:^|\n)[ \t>*_#]*Action[ \t]*\d*[ \t*_]*:[ \t*_`]*([^\n`*]*?)[ \t*_`]*\n+[ \t>*_#]*Action[ \t]*\d*[ \t_]*Input[ \t*_]*:[ \t*_]*([\s\S]*?)(?=\n[ \t>*_#]*(?:Observation|Thought|Final[ \t]*Answer|Action)\b|\x00|\Z)", re.I)
|
|
407
|
+
tag_call = re.compile(r"<(tool_call|function_call|tool_use)>([\s\S]*?)</\1>", re.I)
|
|
408
|
+
fn_tag = re.compile(r"<function=([\w.-]+)>([\s\S]*?)</function>", re.I)
|
|
409
|
+
invoke = re.compile(r"<invoke\s+name\s*=\s*[\"']([^\"']+)[\"'][^>]*>([\s\S]*?)</invoke>", re.I)
|
|
410
|
+
observed = re.compile(r"(?:^|\n)[ \t>*_]*Observation[ \t*_]*:[ \t]*([\s\S]*?)(?=\n[ \t>*_#]*(?:Thought|Action|Final[ \t]*Answer)\b|\x00|\Z)|<(tool_response|tool_result|function_results?|observation)>([\s\S]*?)</\2>", re.I)
|
|
411
|
+
|
|
412
|
+
def params_of(s):
|
|
413
|
+
return {m.group(1): m.group(2).strip() for m in re.finditer(r"<parameter(?:=|\s+name\s*=\s*[\"'])([\w.-]+)[\"']?\s*>([\s\S]*?)</parameter>", s, re.I)}
|
|
414
|
+
|
|
415
|
+
# Each balanced JSON object in the text, outermost first; its insides are not read again.
|
|
416
|
+
def objects_in(text):
|
|
417
|
+
out, i = [], text.find("{")
|
|
418
|
+
while i != -1 and len(out) < 20:
|
|
419
|
+
depth, in_str, esc, end = 0, False, False, -1
|
|
420
|
+
for j in range(i, min(len(text), i + 20000)):
|
|
421
|
+
ch = text[j]
|
|
422
|
+
if in_str:
|
|
423
|
+
if esc:
|
|
424
|
+
esc = False
|
|
425
|
+
elif ch == "\\":
|
|
426
|
+
esc = True
|
|
427
|
+
elif ch == '"':
|
|
428
|
+
in_str = False
|
|
429
|
+
continue
|
|
430
|
+
if ch == '"':
|
|
431
|
+
in_str = True
|
|
432
|
+
elif ch == "{":
|
|
433
|
+
depth += 1
|
|
434
|
+
elif ch == "}":
|
|
435
|
+
depth -= 1
|
|
436
|
+
if depth == 0:
|
|
437
|
+
end = j
|
|
438
|
+
break
|
|
439
|
+
v = parsed(text[i:end + 1]) if end != -1 else None
|
|
440
|
+
if isinstance(v, dict):
|
|
441
|
+
out.append((i, v))
|
|
442
|
+
i = end
|
|
443
|
+
i = text.find("{", i + 1)
|
|
444
|
+
return out
|
|
445
|
+
|
|
446
|
+
# An Action Input that opens with a JSON object is that object: a model that runs on past it
|
|
447
|
+
# ("Observ: ...") does not put its own invention into the arguments.
|
|
448
|
+
def leading_json(s):
|
|
449
|
+
t = s.strip()
|
|
450
|
+
o = objects_in(t)[:1] if t.startswith("{") else []
|
|
451
|
+
return o[0][1] if o and o[0][0] == 0 else t
|
|
452
|
+
|
|
453
|
+
# A tool's schema carries a description; a call does not.
|
|
454
|
+
def json_call(v):
|
|
455
|
+
f = v["function"] if isinstance(v.get("function"), dict) else v
|
|
456
|
+
if "description" in f:
|
|
457
|
+
return None
|
|
458
|
+
name = next((x for x in (f.get("name"), v.get("tool"), v.get("tool_name"), v.get("action"), v.get("function") if isinstance(v.get("function"), str) else None) if isinstance(x, str)), None)
|
|
459
|
+
if not name:
|
|
161
460
|
return None
|
|
461
|
+
args = next((x for x in (f.get("arguments"), f.get("args"), f.get("parameters"), v.get("action_input"), v.get("tool_input"), v.get("input"), v.get("args"), v.get("arguments"), v.get("parameters")) if x is not None), None)
|
|
462
|
+
return name, args
|
|
463
|
+
|
|
464
|
+
def written_calls(text, declared):
|
|
465
|
+
found = []
|
|
466
|
+
if not text or not declared:
|
|
467
|
+
return found
|
|
468
|
+
s = text[:50000]
|
|
469
|
+
|
|
470
|
+
def take(at, name, args):
|
|
471
|
+
n = str(name or "").strip()
|
|
472
|
+
if n in declared:
|
|
473
|
+
found.append((at, n, args.strip() if isinstance(args, str) else args))
|
|
474
|
+
|
|
475
|
+
for m in react.finditer(s):
|
|
476
|
+
take(m.start(), m.group(1), leading_json(m.group(2)))
|
|
477
|
+
for m in tag_call.finditer(s):
|
|
478
|
+
v = parsed(m.group(2).strip())
|
|
479
|
+
c = json_call(v) if isinstance(v, dict) else None
|
|
480
|
+
if c:
|
|
481
|
+
take(m.start(), *c)
|
|
482
|
+
for m in fn_tag.finditer(s):
|
|
483
|
+
v = parsed(m.group(2).strip())
|
|
484
|
+
take(m.start(), m.group(1), v if isinstance(v, (dict, list)) else params_of(m.group(2)))
|
|
485
|
+
for m in invoke.finditer(s):
|
|
486
|
+
take(m.start(), m.group(1), params_of(m.group(2)))
|
|
487
|
+
for at, v in objects_in(s):
|
|
488
|
+
c = json_call(v)
|
|
489
|
+
if c:
|
|
490
|
+
take(at, *c)
|
|
491
|
+
return sorted(found, key=lambda c: c[0])
|
|
492
|
+
|
|
493
|
+
# What a tool answered, named by the call written just before it.
|
|
494
|
+
def observed_in(text, declared):
|
|
495
|
+
s = str(text or "")[:50000]
|
|
496
|
+
calls = written_calls(s, declared)
|
|
497
|
+
out = []
|
|
498
|
+
if calls:
|
|
499
|
+
for m in observed.finditer(s):
|
|
500
|
+
before = [c for c in calls if c[0] < m.start()]
|
|
501
|
+
if before:
|
|
502
|
+
out.append((before[-1][1], (m.group(1) if m.group(1) is not None else m.group(3)).strip()))
|
|
503
|
+
return out
|
|
504
|
+
|
|
505
|
+
# The model's own words: the text it answered, whole or streamed, in every provider's shape.
|
|
506
|
+
def reply_text(events):
|
|
507
|
+
s = []
|
|
508
|
+
for e in events:
|
|
509
|
+
if not isinstance(e, dict):
|
|
510
|
+
continue
|
|
511
|
+
for c in items(e.get("choices")):
|
|
512
|
+
if isinstance(c.get("message"), dict):
|
|
513
|
+
s.append(text_of(c["message"].get("content")))
|
|
514
|
+
if isinstance(c.get("delta"), dict) and isinstance(c["delta"].get("content"), str):
|
|
515
|
+
s.append(c["delta"]["content"])
|
|
516
|
+
if isinstance(c.get("text"), str):
|
|
517
|
+
s.append(c["text"])
|
|
518
|
+
if e.get("type") == "response.output_text.delta" and isinstance(e.get("delta"), str):
|
|
519
|
+
s.append(e["delta"])
|
|
520
|
+
resp = e.get("response") if isinstance(e.get("response"), dict) else {}
|
|
521
|
+
for it in items(e.get("output")) + items(resp.get("output")):
|
|
522
|
+
if it.get("type") == "message":
|
|
523
|
+
s.extend(p["text"] for p in items(it.get("content")) if isinstance(p.get("text"), str))
|
|
524
|
+
delta = e.get("delta") if isinstance(e.get("delta"), dict) else {}
|
|
525
|
+
if e.get("type") == "content_block_delta" and isinstance(delta.get("text"), str):
|
|
526
|
+
s.append(delta["text"])
|
|
527
|
+
msg = e.get("message") if isinstance(e.get("message"), dict) else {}
|
|
528
|
+
out_msg = e["output"].get("message") if isinstance(e.get("output"), dict) and isinstance(e["output"].get("message"), dict) else {}
|
|
529
|
+
for b in items(e.get("content")) + items(msg.get("content")) + items(out_msg.get("content")):
|
|
530
|
+
if isinstance(b.get("text"), str):
|
|
531
|
+
s.append(b["text"])
|
|
532
|
+
if isinstance(msg.get("content"), str) and not e.get("choices"):
|
|
533
|
+
s.append(msg["content"])
|
|
534
|
+
for c in items(e.get("candidates")):
|
|
535
|
+
for p in items((c.get("content") or {}).get("parts") if isinstance(c.get("content"), dict) else None):
|
|
536
|
+
if isinstance(p.get("text"), str) and not p.get("thought"):
|
|
537
|
+
s.append(p["text"])
|
|
538
|
+
if isinstance(e.get("response"), str):
|
|
539
|
+
s.append(e["response"])
|
|
540
|
+
return "".join(s)
|
|
541
|
+
|
|
542
|
+
# This turn as the prompt carries it: the model's earlier words after the person's latest
|
|
543
|
+
# message, and everything from that message on, where a single-prompt agent keeps its scratchpad.
|
|
544
|
+
# A user message right after the model wrote a tool call is the agent loop's nudge ("Analyze the
|
|
545
|
+
# tool result"), never the person: after a call, only the loop speaks.
|
|
546
|
+
def turn_texts(body, with_person, declared):
|
|
547
|
+
def since_person(seq, is_person):
|
|
548
|
+
at = -1
|
|
549
|
+
for i, m in enumerate(seq):
|
|
550
|
+
if is_person(m):
|
|
551
|
+
at = i
|
|
552
|
+
return seq[max(0, at):] if with_person else seq[at + 1:]
|
|
553
|
+
|
|
554
|
+
def ours(role):
|
|
555
|
+
return role not in ("system", "developer") if with_person else role in ("assistant", "model")
|
|
556
|
+
|
|
557
|
+
inp = body.get("input")
|
|
558
|
+
ms = items(body.get("messages"))
|
|
559
|
+
after_call = {i for i in range(1, len(ms)) if ms[i - 1].get("role") == "assistant" and written_calls(text_of(ms[i - 1].get("content")), declared)}
|
|
560
|
+
out = [text_of(m.get("content")) for m in since_person([dict(m, _i=i) for i, m in enumerate(ms)], lambda m: person_said(m) and m["_i"] not in after_call) if ours(m.get("role"))]
|
|
561
|
+
out += [text_of(x.get("content") if x.get("content") is not None else x.get("output")) for x in since_person(items(inp), lambda x: x.get("role") == "user") if ours(x.get("role"))]
|
|
562
|
+
out += [text_of(c.get("parts")) for c in since_person(items(body.get("contents")), person_asked) if ours(c.get("role"))]
|
|
563
|
+
if with_person:
|
|
564
|
+
out += [body.get("prompt") if isinstance(body.get("prompt"), str) else "", inp if isinstance(inp, str) else ""]
|
|
565
|
+
return "\x00\n".join(out)
|
|
566
|
+
|
|
567
|
+
def called_in(sent, events):
|
|
162
568
|
body = parsed(sent) if isinstance(sent, str) else None
|
|
163
|
-
|
|
569
|
+
out = []
|
|
570
|
+
declared = declared_in(body) if isinstance(body, dict) else set()
|
|
571
|
+
written = [{"name": n, "arguments": args_text(a)} for _, n, a in written_calls(turn_texts(body, False, declared) if isinstance(body, dict) else "", declared) + written_calls(reply_text(events), declared)]
|
|
572
|
+
for c in (called_before(body) if isinstance(body, dict) else []) + calls_of(events) + written:
|
|
573
|
+
if len(out) < calls_max and not any(o["name"] == c["name"] and o["arguments"] == c["arguments"] for o in out):
|
|
574
|
+
out.append(c)
|
|
575
|
+
return out or None
|
|
576
|
+
|
|
577
|
+
# What the prompt was handed besides its instructions, of two kinds. What the app retrieved: a
|
|
578
|
+
# block it labels as context, documents, knowledge, sources or search results. And its own
|
|
579
|
+
# record of the person, passed beside the ask: a profile, a résumé, an account, orders, a JSON
|
|
580
|
+
# or key: value block of their data, under any heading or none ("data"). A reply's fact taken
|
|
581
|
+
# from either was given, not made up: resumeforge put the saved profile into every prompt, and
|
|
582
|
+
# its replies' facts from it were read as invention. In the system prompt or from the person's
|
|
583
|
+
# latest message on, and Anthropic document and search_result blocks.
|
|
584
|
+
passage_text, passages_max = 3000, 6
|
|
585
|
+
material = re.compile(r"\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq|relevant)\b|检索|知识|参考资料|资料|上下文|文档|背景|相关", re.I)
|
|
586
|
+
record = re.compile(r"\b(?:profiles?|r[eé]sum[eé]s?|cv|(?<!into )accounts?|orders|purchases|records|(?:user|customer|member|patient|student|client|candidate)[ _]?(?:info\w*|data|details?|facts)|(?:order|purchase|account|medical|payment|employment|transaction|work) history)\b|个人资料|用户资料|个人信息|用户信息|简历|档案|订单|账户|账号|会员", re.I)
|
|
587
|
+
def name_of(label):
|
|
588
|
+
return re.sub(r"(?:开始|\s+(?:start|begin))$", "", re.sub(r"[#::\[\]=]", "", label).strip(), flags=re.I).strip()
|
|
589
|
+
|
|
590
|
+
# A record is named by a label that ends in its word ("User profile", "[已选个人资料开始]"): a
|
|
591
|
+
# heading of the instructions that only mentions one ("关于简历通本身与使用平台") is not it.
|
|
592
|
+
record_end = re.compile(r"(?:" + record.pattern + r")$", re.I)
|
|
593
|
+
|
|
594
|
+
def is_record(label):
|
|
595
|
+
return bool(record_end.search(name_of(label)))
|
|
596
|
+
|
|
597
|
+
def is_label(label):
|
|
598
|
+
return bool(material.search(label)) or is_record(label)
|
|
599
|
+
|
|
600
|
+
def kind_of(label):
|
|
601
|
+
return "data" if is_record(label) else None
|
|
602
|
+
|
|
603
|
+
heading = re.compile(r"^\s*(?:#{1,6}\s+[^\n]{1,80}|[^\n]{1,80}[::]\s*(?:\([^\n)]*\))?|\[[^\n\]]{1,80}\]|={2,}\s*[^\n]{1,80}?\s*={2,})\s*$")
|
|
604
|
+
# An inline label is a noun phrase that ends in the label word ("context:", "background:",
|
|
605
|
+
# "user profile:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
|
|
606
|
+
inline = re.compile(r"^\s*(?:[-*]\s+)?([A-Za-z一-鿿][A-Za-z一-鿿 '’_-]{0,29})[::]\s*\S")
|
|
607
|
+
named = re.compile(r"(?:" + material.pattern + "|" + record.pattern + r")\s*$", re.I)
|
|
608
|
+
tagged = re.compile(r"<([A-Za-z][\w-]*)[^>]*>([\s\S]*?)</\1>")
|
|
609
|
+
|
|
610
|
+
# A label's block runs on to the next heading of its own kind: "资料:" over retrieved chunks
|
|
611
|
+
# that open on markdown headings holds every chunk, where stopping at its first paragraph kept
|
|
612
|
+
# one chunk of five and the reader called the knowledge base's own 1% fee made up. A markdown
|
|
613
|
+
# label holds deeper headings. A closing paragraph that opens on a label of its own ("问题:...",
|
|
614
|
+
# "Question: ...") is the prompt's ask, not material. A long block is several passages, cut
|
|
615
|
+
# between its paragraphs, then its lines.
|
|
616
|
+
markdown = re.compile(r"^\s*(#{1,6})\s")
|
|
617
|
+
|
|
618
|
+
def ends_block(label, line):
|
|
619
|
+
if not heading.match(line):
|
|
620
|
+
return False
|
|
621
|
+
outer, inner = markdown.match(label), markdown.match(line)
|
|
622
|
+
return not inner or bool(outer and len(inner.group(1)) <= len(outer.group(1)))
|
|
623
|
+
|
|
624
|
+
def asks(para):
|
|
625
|
+
m = inline.match(para.split("\n")[0])
|
|
626
|
+
return bool(m and not named.search(m.group(1)))
|
|
627
|
+
|
|
628
|
+
# A heading inside a fenced block is the material's own text: a README chunk's "## Features".
|
|
629
|
+
fence = re.compile(r"^\s*(?:```|~~~)")
|
|
630
|
+
|
|
631
|
+
def fences(para):
|
|
632
|
+
return sum(1 for l in para.split("\n") if fence.match(l))
|
|
633
|
+
|
|
634
|
+
def pieces(p):
|
|
635
|
+
if len(p) <= passage_text:
|
|
636
|
+
return [p]
|
|
637
|
+
return [l[i:i + passage_text] for l in p.split("\n") for i in range(0, len(l), passage_text)]
|
|
638
|
+
|
|
639
|
+
def add_block(add, name, paras, kind=None):
|
|
640
|
+
text = ""
|
|
641
|
+
for para in paras:
|
|
642
|
+
for j, p in enumerate(pieces(para)):
|
|
643
|
+
if text and len(text) + len(p) + 2 > passage_text:
|
|
644
|
+
add(name, text, kind)
|
|
645
|
+
text = ""
|
|
646
|
+
text += (("\n" if j else "\n\n") if text else "") + p
|
|
647
|
+
add(name, text, kind)
|
|
648
|
+
|
|
649
|
+
# The person's data with no label word: a JSON object, or key: value lines (YAML too) under a
|
|
650
|
+
# heading. A transcript pasted as "User: ... / Assistant: ..." is the conversation; a tool's
|
|
651
|
+
# schema, an agent's Thought/Action scaffold and an answer's shape are instructions.
|
|
652
|
+
kv_line = re.compile(r"^\s*(?:[-*]\s+)?[\"']?([^\W\d][\w .'’()/-]{0,39})[\"']?\s*[::]\s*(\S.*)?$")
|
|
653
|
+
role = re.compile(r"^(?:user|assistant|human|ai|system|bot|model|agent|customer|用户|助手|客服|顾客|系统)$", re.I)
|
|
654
|
+
scaffold = re.compile(r"^(?:tool\b.*|action(?: input)?|thought|observation|final answer|question|answer|input|output)$", re.I)
|
|
655
|
+
shape = re.compile(r"format|schema|example|output|respon|return|reply|格式|示例|输出|返回", re.I)
|
|
656
|
+
tool_schema = re.compile(r"\"(?:parameters|input_schema|inputSchema)\"\s*:")
|
|
657
|
+
# "[已选岗位结束]": the closing marker an app puts after a record is not part of it.
|
|
658
|
+
closer = re.compile(r"^\s*\[[^\]\n]{1,80}\]\s*$")
|
|
659
|
+
|
|
660
|
+
def data_in(para, before):
|
|
661
|
+
lines = para.split("\n")
|
|
662
|
+
head = lines[0] if heading.match(lines[0]) else None
|
|
663
|
+
body = [l for l in (lines[1:] if head else lines) if l.strip() and not fence.match(l)]
|
|
664
|
+
if len(body) > 1 and closer.match(body[-1]):
|
|
665
|
+
body.pop()
|
|
666
|
+
name = head or (before if before and "\n" not in before and heading.match(before) else "")
|
|
667
|
+
text = "\n".join(body)
|
|
668
|
+
if shape.search(name):
|
|
164
669
|
return None
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
670
|
+
if re.match(r"^\s*[{\[]", text):
|
|
671
|
+
try:
|
|
672
|
+
v = json.loads(text)
|
|
673
|
+
except ValueError:
|
|
674
|
+
return None
|
|
675
|
+
return (name_of(name) or "data", text) if isinstance(v, (dict, list)) and v and not tool_schema.search(text) else None
|
|
676
|
+
kv = [kv_line.match(l) for l in body]
|
|
677
|
+
valued = [m.group(1).strip() for m in kv if m and m.group(2)]
|
|
678
|
+
shaped = bool(name) and all(m or re.match(r"^\s+\S|^\s*-\s", l) for m, l in zip(kv, body))
|
|
679
|
+
if shaped and len(valued) >= 2 and sum(1 for k in valued if role.match(k)) < 2 and not any(scaffold.match(k) for k in valued):
|
|
680
|
+
return (name_of(name), text)
|
|
681
|
+
return None
|
|
682
|
+
|
|
683
|
+
def labelled(text, add):
|
|
684
|
+
def lift(m):
|
|
685
|
+
label = m.group(1).replace("_", " ")
|
|
686
|
+
if is_label(label):
|
|
687
|
+
add(m.group(1), m.group(2), kind_of(label))
|
|
688
|
+
return ""
|
|
689
|
+
return m.group(0)
|
|
690
|
+
paras = re.split(r"\n\s*\n", tagged.sub(lift, str(text or "")))
|
|
691
|
+
i = 0
|
|
692
|
+
while i < len(paras):
|
|
693
|
+
lines = paras[i].split("\n")
|
|
694
|
+
head = lines[0]
|
|
695
|
+
if heading.match(head) and is_label(head):
|
|
696
|
+
block = ["\n".join(lines[1:])]
|
|
697
|
+
fenced = fences(block[0]) % 2 == 1
|
|
698
|
+
while i + 1 < len(paras) and (fenced or (not ends_block(head, paras[i + 1].split("\n")[0]) and not (i + 2 == len(paras) and asks(paras[i + 1])))):
|
|
699
|
+
i += 1
|
|
700
|
+
block.append(paras[i])
|
|
701
|
+
if fences(paras[i]) % 2 == 1:
|
|
702
|
+
fenced = not fenced
|
|
703
|
+
# A block that opens on a record's own label ("[已选个人资料开始]" under "参考资料:") is that record.
|
|
704
|
+
inner = block[0].lstrip().split("\n")[0]
|
|
705
|
+
label = inner if heading.match(inner) and is_record(inner) else head
|
|
706
|
+
add_block(add, name_of(label), block, kind_of(label))
|
|
707
|
+
else:
|
|
708
|
+
found = next((m for m in (inline.match(l) for l in lines) if m and named.search(m.group(1))), None)
|
|
709
|
+
if found:
|
|
710
|
+
add(found.group(1).strip(), paras[i], kind_of(found.group(1)))
|
|
711
|
+
else:
|
|
712
|
+
data = data_in(paras[i], paras[i - 1] if i else None)
|
|
713
|
+
if data:
|
|
714
|
+
add_block(add, data[0], [data[1]], "data")
|
|
715
|
+
i += 1
|
|
716
|
+
|
|
717
|
+
def passages_in(sent):
|
|
718
|
+
body = parsed(sent) if isinstance(sent, str) else None
|
|
719
|
+
if not isinstance(body, dict):
|
|
171
720
|
return None
|
|
172
|
-
|
|
173
|
-
|
|
721
|
+
out = []
|
|
722
|
+
|
|
723
|
+
def add(name, text, kind=None):
|
|
724
|
+
t = str(text or "").strip()[:passage_text]
|
|
725
|
+
if len(t) >= 20 and len(out) < passages_max and all(o["text"] != t for o in out):
|
|
726
|
+
out.append({"name": str(name or "")[:80], "text": t, **({"kind": kind} if kind else {})})
|
|
727
|
+
|
|
728
|
+
def scan(content):
|
|
729
|
+
if isinstance(content, str):
|
|
730
|
+
return labelled(content, add)
|
|
731
|
+
for b in items(content):
|
|
732
|
+
if b.get("type") == "document":
|
|
733
|
+
src = b.get("source") if isinstance(b.get("source"), dict) else None
|
|
734
|
+
add(b.get("title") or "document", text_of(src.get("data") if src and src.get("data") is not None else (src or {}).get("content")) if src else text_of(b.get("content")))
|
|
735
|
+
elif b.get("type") == "search_result":
|
|
736
|
+
add(b.get("title") or b.get("source") or "search result", text_of(b.get("content")))
|
|
737
|
+
elif isinstance(b.get("text"), str):
|
|
738
|
+
labelled(b["text"], add)
|
|
739
|
+
|
|
740
|
+
messages = items(body.get("messages"))
|
|
741
|
+
for m in messages:
|
|
742
|
+
if m.get("role") in ("system", "developer"):
|
|
743
|
+
scan(m.get("content"))
|
|
744
|
+
last = max([i for i, m in enumerate(messages) if person_said(m)] or [0])
|
|
745
|
+
for m in messages[last:]:
|
|
746
|
+
if m.get("role") == "user":
|
|
747
|
+
scan(m.get("content"))
|
|
748
|
+
scan(body.get("system"))
|
|
749
|
+
scan(body.get("instructions"))
|
|
750
|
+
if isinstance(body.get("systemInstruction"), dict):
|
|
751
|
+
scan(body["systemInstruction"].get("parts"))
|
|
752
|
+
inputs = [{"role": "user", "content": body["input"]}] if isinstance(body.get("input"), str) else items(body.get("input"))
|
|
753
|
+
last = max([i for i, it in enumerate(inputs) if it.get("role") == "user"] or [0])
|
|
754
|
+
for it in inputs[last:]:
|
|
755
|
+
if it.get("role") in ("user", "system", "developer"):
|
|
756
|
+
scan(it.get("content"))
|
|
757
|
+
return out or None
|
|
758
|
+
|
|
759
|
+
# The passages a retrieval call answered with: a vector store, a search index or a web search,
|
|
760
|
+
# read off its reply when it is JSON. Text fields only, bounded like the rest.
|
|
761
|
+
retrieval_host = re.compile(r"pinecone\.io|qdrant|weaviate|chroma|zilliz|milvus|turbopuffer|upstash\.io|algolia|typesense|meilisearch|elastic|opensearch|vespa|tavily\.com|exa\.ai|serper\.dev|serpapi\.com|search\.brave\.com|bing\.microsoft\.com|jina\.ai", re.I)
|
|
762
|
+
retrieval_path = re.compile(r"/(?:query|search|_search|retrieve|similarity_search|rerank|hybrid)(?:/|$)|/points/(?:search|query)|/rpc/match\w*", re.I)
|
|
763
|
+
passage_key = re.compile(r"^(?:text|content|page_?content|pageContent|chunk|snippet|passage|body|document|documents|description|answer|raw_content|highlights?|excerpt)$", re.I)
|
|
764
|
+
|
|
765
|
+
def is_retrieval(url):
|
|
766
|
+
try:
|
|
767
|
+
parts = urlsplit(str(url))
|
|
768
|
+
except ValueError:
|
|
769
|
+
return False
|
|
770
|
+
return bool(retrieval_host.search(parts.hostname or "")) or bool(retrieval_path.search(parts.path or ""))
|
|
771
|
+
|
|
772
|
+
def passages_from(raw):
|
|
773
|
+
out, nodes = [], [0]
|
|
774
|
+
|
|
775
|
+
def walk(v, key, depth):
|
|
776
|
+
nodes[0] += 1
|
|
777
|
+
if len(out) >= passages_max or depth > 7 or nodes[0] > 4000:
|
|
778
|
+
return
|
|
779
|
+
if isinstance(v, str):
|
|
780
|
+
t = v.strip()[:passage_text]
|
|
781
|
+
if passage_key.match(key) and len(t) >= 20 and all(o["text"] != t for o in out):
|
|
782
|
+
out.append({"name": key, "text": t})
|
|
783
|
+
elif isinstance(v, list):
|
|
784
|
+
for x in v:
|
|
785
|
+
walk(x, key, depth + 1)
|
|
786
|
+
elif isinstance(v, dict):
|
|
787
|
+
for k, x in v.items():
|
|
788
|
+
walk(x, str(k), depth + 1)
|
|
789
|
+
walk(parsed(raw) if isinstance(raw, str) else None, "", 0)
|
|
790
|
+
return out or None
|
|
174
791
|
|
|
175
792
|
def rules_in(sent):
|
|
176
793
|
found = rules_now()
|
|
@@ -179,23 +796,57 @@ def _install():
|
|
|
179
796
|
body = norm(sent)
|
|
180
797
|
return [rid for rid, parts in found if all(p in body for p in parts)]
|
|
181
798
|
|
|
799
|
+
# Every value this file writes passes one mask, whichever path wrote it: a key inside a value
|
|
800
|
+
# ("sk-...", a bearer token, a JWT) and what anyone wrote after "password is" or "token:". The
|
|
801
|
+
# door row's sign-in headers are kept on purpose: the command replays them from this machine.
|
|
802
|
+
secret_text = [
|
|
803
|
+
re.compile(r"""((?:pass(?:word|phrase|wd)|pwd|密码|口令)["']?\s*(?:is\b|was\b|[:=:]|是|为)\s*["']?)([^\s"'\\,;},。;、]+)""", re.I),
|
|
804
|
+
re.compile(r"""((?:secret(?:[ _-]?key)?|api[ _-]?key|apikey|access[ _-]?key|private[ _-]?key|client[ _-]?secret|(?:auth|access|refresh)[ _-]?token|token)["']?\s*[:=:]\s*["']?)([^\s"'\\,;},。;、]+)""", re.I),
|
|
805
|
+
]
|
|
806
|
+
secret_word = re.compile(r"\b(?:Bearer\s+(?=[\w.~+/=-]*\d)[\w.~+/=-]{16,}|[spr]k[-_](?=[\w-]*\d)[\w-]{8,}|gh[po]_\w{16,}|github_pat_\w{16,}|xox[abpr]-[\w-]{8,}|AKIA[0-9A-Z]{12,}|AIza[\w-]{20,}|eyJ[\w-]{10,}\.[\w-]{4,}\.[\w-]*)", re.A)
|
|
807
|
+
|
|
808
|
+
def mask_text(t):
|
|
809
|
+
for pattern in secret_text:
|
|
810
|
+
t = pattern.sub(r"\1[secret]", t)
|
|
811
|
+
return secret_word.sub("[secret]", t)
|
|
812
|
+
|
|
813
|
+
def scrub(v, depth=0):
|
|
814
|
+
if isinstance(v, str):
|
|
815
|
+
return mask_text(v)
|
|
816
|
+
if isinstance(v, (list, tuple)):
|
|
817
|
+
return [scrub(x, depth + 1) for x in v]
|
|
818
|
+
if isinstance(v, dict) and depth < 12:
|
|
819
|
+
return {k: (x if k == "headers" and depth == 0 else scrub(x, depth + 1)) for k, x in v.items()}
|
|
820
|
+
return v
|
|
821
|
+
|
|
822
|
+
# A door's body goes up to the run as JSON, so it is masked as JSON: its string values, never its shape.
|
|
823
|
+
def body_scrubbed(raw):
|
|
824
|
+
b = parsed(raw)
|
|
825
|
+
return json.dumps(scrub(b), ensure_ascii=False) if isinstance(b, (dict, list)) else mask_text(raw)
|
|
826
|
+
|
|
182
827
|
def write(row):
|
|
183
828
|
try:
|
|
829
|
+
if not (row.get("hello") or row.get("listen") or row.get("routes")):
|
|
830
|
+
out = scrub(row)
|
|
831
|
+
if isinstance(row.get("body"), str):
|
|
832
|
+
out["body"] = body_scrubbed(row["body"])
|
|
833
|
+
row = out
|
|
184
834
|
fd = os.open(_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
185
835
|
with os.fdopen(fd, "a", encoding="utf-8") as f:
|
|
186
836
|
f.write(json.dumps(row) + "\n")
|
|
187
|
-
except
|
|
837
|
+
except Exception:
|
|
188
838
|
pass
|
|
189
839
|
|
|
190
840
|
# Every other outbound call: the host and what it answered, never a byte of it. A search
|
|
191
841
|
# provider over its limit for a whole run was invisible until this row existed.
|
|
192
|
-
|
|
842
|
+
# A store on this machine (a local Qdrant or Chroma) is said too when it answered with passages.
|
|
843
|
+
def dep(url, status, code=None, passages=None, turn=None):
|
|
193
844
|
try:
|
|
194
845
|
host = (urlsplit(str(url)).hostname or "").lower()
|
|
195
|
-
if not host or host in ("localhost", "127.0.0.1", "::1"):
|
|
846
|
+
if not host or (host in ("localhost", "127.0.0.1", "::1") and not passages):
|
|
196
847
|
return
|
|
197
|
-
turn = turn_now()
|
|
198
|
-
write({"dep": {"at": int(time.time() * 1000), "host": host[:253], "status": int(status or 0), **({"code": str(code)[:40]} if code else {}), **({"turn": turn} if turn else {})}})
|
|
848
|
+
turn = turn or turn_now()
|
|
849
|
+
write({"dep": {"at": int(time.time() * 1000), "host": host[:253], "status": int(status or 0), **({"code": str(code)[:40]} if code else {}), **({"turn": turn} if turn else {}), **({"passages": passages} if passages else {})}})
|
|
199
850
|
except Exception:
|
|
200
851
|
pass
|
|
201
852
|
def is_model_call(url):
|
|
@@ -211,15 +862,18 @@ def _install():
|
|
|
211
862
|
return host.startswith("bedrock")
|
|
212
863
|
return bool(model_host.search(host)) or (bool(model_path.search(path)) and bool(re.search(r"/v\d|/api/", path)))
|
|
213
864
|
|
|
214
|
-
|
|
865
|
+
# `most`: what is read of a body. The door's row keeps 64 KB; a model call's prompt is read whole
|
|
866
|
+
# up to the reply's bound, since a prompt cut at 64 KB is not JSON and every passage, tool answer
|
|
867
|
+
# and call in it was lost (resumeforge's tool list alone pushed its prompt past the cut).
|
|
868
|
+
def text(body, most=limit):
|
|
215
869
|
if body is None:
|
|
216
870
|
return ""
|
|
217
871
|
if isinstance(body, (bytes, bytearray)):
|
|
218
|
-
return bytes(body[:
|
|
872
|
+
return bytes(body[:most]).decode("utf-8", "replace")
|
|
219
873
|
if isinstance(body, str):
|
|
220
|
-
return body[:
|
|
874
|
+
return body[:most]
|
|
221
875
|
try:
|
|
222
|
-
return json.dumps(body)[:
|
|
876
|
+
return json.dumps(body)[:most]
|
|
223
877
|
except (TypeError, ValueError):
|
|
224
878
|
return ""
|
|
225
879
|
|
|
@@ -283,7 +937,7 @@ def _install():
|
|
|
283
937
|
if isinstance(part, dict):
|
|
284
938
|
usage.update(part)
|
|
285
939
|
model = e.get("model") or msg.get("model") or resp.get("model") or e.get("modelVersion") or model
|
|
286
|
-
return tokens_of(usage or None), model
|
|
940
|
+
return tokens_of(usage or None), model, events
|
|
287
941
|
|
|
288
942
|
def asked_for(url, sent):
|
|
289
943
|
body = parsed(sent) if isinstance(sent, str) else None
|
|
@@ -306,9 +960,78 @@ def _install():
|
|
|
306
960
|
except Exception:
|
|
307
961
|
return ""
|
|
308
962
|
|
|
309
|
-
|
|
963
|
+
# Which of the app's own lines made a model call, nearest first: the run names the code path that
|
|
964
|
+
# ran by it, where the read could only name the door. Its frames on this stack outside installed
|
|
965
|
+
# packages, then, when the call runs in a task another task awaits (LangChain runs each step of a
|
|
966
|
+
# chain as its own task), the awaiting task's. Relative to the folder the app was started in.
|
|
967
|
+
root = os.getcwd().replace(os.sep, "/").rstrip("/") + "/"
|
|
968
|
+
installed = re.compile(r"/(?:site-packages|dist-packages|\.venv|venv|__pypackages__)/|/lib/python\d")
|
|
969
|
+
|
|
970
|
+
def ours(frame, out):
|
|
971
|
+
f = frame.f_code.co_filename.replace(os.sep, "/")
|
|
972
|
+
if f.startswith(root) and not installed.search(f[len(root) - 1:]):
|
|
973
|
+
at = "%s:%d" % (f[len(root):], frame.f_lineno)
|
|
974
|
+
if at not in out:
|
|
975
|
+
out.append(at)
|
|
976
|
+
return len(out) >= 3
|
|
977
|
+
|
|
978
|
+
# The task that waits on this one: its wake-up is among this one's callbacks, directly, or behind
|
|
979
|
+
# the future an asyncio.gather callback closes over.
|
|
980
|
+
def awaiting(fut, depth=0):
|
|
981
|
+
for cb in getattr(fut, "_callbacks", None) or ():
|
|
982
|
+
fn = cb[0] if isinstance(cb, tuple) else cb
|
|
983
|
+
owner = getattr(fn, "__self__", None)
|
|
984
|
+
if owner is not None and owner is not fut and hasattr(owner, "get_coro"):
|
|
985
|
+
return owner
|
|
986
|
+
for cell in (getattr(fn, "__closure__", None) or ()) if depth < 2 else ():
|
|
987
|
+
try:
|
|
988
|
+
inner = cell.cell_contents
|
|
989
|
+
except ValueError:
|
|
990
|
+
continue
|
|
991
|
+
found = awaiting(inner, depth + 1) if inner is not fut and hasattr(inner, "_callbacks") else None
|
|
992
|
+
if found is not None:
|
|
993
|
+
return found
|
|
994
|
+
return None
|
|
995
|
+
|
|
996
|
+
# An embedding call answers nobody, so where it was made names no path a reply came down.
|
|
997
|
+
embedding = re.compile(r"/embeddings$", re.I)
|
|
998
|
+
|
|
999
|
+
def callers_now(url):
|
|
1000
|
+
if embedding.search(urlsplit(str(url)).path or ""):
|
|
1001
|
+
return None
|
|
1002
|
+
out = []
|
|
1003
|
+
try:
|
|
1004
|
+
# Below the event loop's own frames is whatever started the loop, not this call's path.
|
|
1005
|
+
frame = sys._getframe(1)
|
|
1006
|
+
while frame is not None and not frame.f_code.co_filename.replace(os.sep, "/").endswith("/asyncio/events.py"):
|
|
1007
|
+
if ours(frame, out):
|
|
1008
|
+
return out
|
|
1009
|
+
frame = frame.f_back
|
|
1010
|
+
aio = sys.modules.get("asyncio")
|
|
1011
|
+
try:
|
|
1012
|
+
task = aio.current_task() if aio else None
|
|
1013
|
+
except RuntimeError:
|
|
1014
|
+
task = None
|
|
1015
|
+
for _ in range(8):
|
|
1016
|
+
task = awaiting(task) if task is not None else None
|
|
1017
|
+
if task is None:
|
|
1018
|
+
break
|
|
1019
|
+
chain, coro = [], task.get_coro()
|
|
1020
|
+
while coro is not None and len(chain) < 64:
|
|
1021
|
+
f = getattr(coro, "cr_frame", None) or getattr(coro, "gi_frame", None) or getattr(coro, "ag_frame", None)
|
|
1022
|
+
if f is not None:
|
|
1023
|
+
chain.append(f)
|
|
1024
|
+
coro = getattr(coro, "cr_await", None) or getattr(coro, "gi_yieldfrom", None) or getattr(coro, "ag_await", None)
|
|
1025
|
+
for f in reversed(chain):
|
|
1026
|
+
if ours(f, out):
|
|
1027
|
+
return out
|
|
1028
|
+
except Exception:
|
|
1029
|
+
pass
|
|
1030
|
+
return out or None
|
|
1031
|
+
|
|
1032
|
+
def meter(url, sent, status, kind, raw, turn=None, caller=None):
|
|
310
1033
|
try:
|
|
311
|
-
tokens, model = read_reply(kind, (raw or "")[:reply_max])
|
|
1034
|
+
tokens, model, events = read_reply(kind, (raw or "")[:reply_max])
|
|
312
1035
|
parts = urlsplit(str(url))
|
|
313
1036
|
row = {"at": int(time.time() * 1000), "host": parts.netloc.replace(":443", ""), "model": str(asked_for(url, sent) or model or "")[:160],
|
|
314
1037
|
"status": int(status or 0), "usage": tokens is not None}
|
|
@@ -320,14 +1043,200 @@ def _install():
|
|
|
320
1043
|
found = rules_in(as_text)
|
|
321
1044
|
if found is not None:
|
|
322
1045
|
row["rules"] = found
|
|
323
|
-
tools = tools_in(as_text)
|
|
1046
|
+
tools = tools_in(as_text)
|
|
324
1047
|
if tools:
|
|
325
1048
|
row["tools"] = tools
|
|
1049
|
+
called = called_in(as_text, events)
|
|
1050
|
+
if called:
|
|
1051
|
+
row["called"] = called
|
|
1052
|
+
passages = passages_in(as_text)
|
|
1053
|
+
if passages:
|
|
1054
|
+
row["passages"] = passages
|
|
1055
|
+
if caller:
|
|
1056
|
+
row["caller"] = caller
|
|
326
1057
|
write({"call": row})
|
|
327
1058
|
except Exception:
|
|
328
1059
|
pass
|
|
329
1060
|
|
|
1061
|
+
# The app's own functions the read names as its tools. An app whose code picks the tool itself
|
|
1062
|
+
# (a classifier answers {"intent":"order"} and the code calls query_order()) never names one on
|
|
1063
|
+
# the wire, so the function is wrapped where it is defined and wherever it was imported by name:
|
|
1064
|
+
# each call writes its name, its clipped arguments and what it returned, on the turn it ran in.
|
|
1065
|
+
# The run writes the list beside the rules, after the app has loaded, so it is applied to the
|
|
1066
|
+
# modules already loaded and looked at again when more load.
|
|
1067
|
+
tools_file = os.environ.get("CORTAD_TOOLS_FILE")
|
|
1068
|
+
ident = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
1069
|
+
targets, swept, wrappers = [[]], [None], {}
|
|
1070
|
+
unsaid = set()
|
|
1071
|
+
|
|
1072
|
+
def unwatched(t, why):
|
|
1073
|
+
if (t["file"], t["fn"]) in unsaid:
|
|
1074
|
+
return
|
|
1075
|
+
unsaid.add((t["file"], t["fn"]))
|
|
1076
|
+
try:
|
|
1077
|
+
sys.stderr.write("cortad: calls to %s in %s are not recorded: %s.\n" % (t["name"], t["file"], why))
|
|
1078
|
+
except Exception:
|
|
1079
|
+
pass
|
|
1080
|
+
|
|
1081
|
+
def plain_of(v, depth=0):
|
|
1082
|
+
if v is None or isinstance(v, (str, bool, int, float)):
|
|
1083
|
+
return v
|
|
1084
|
+
if depth > 4:
|
|
1085
|
+
return "[%s]" % type(v).__name__
|
|
1086
|
+
if isinstance(v, (list, tuple, set)):
|
|
1087
|
+
return [plain_of(x, depth + 1) for x in list(v)[:20]]
|
|
1088
|
+
if isinstance(v, dict):
|
|
1089
|
+
return {str(k): plain_of(x, depth + 1) for k, x in list(v.items())[:40]}
|
|
1090
|
+
dump = getattr(v, "model_dump", None)
|
|
1091
|
+
if callable(dump):
|
|
1092
|
+
try:
|
|
1093
|
+
return plain_of(dump(), depth + 1)
|
|
1094
|
+
except Exception:
|
|
1095
|
+
pass
|
|
1096
|
+
return "[%s]" % type(v).__name__
|
|
1097
|
+
|
|
1098
|
+
# The returned value's shape and a clipped text: a string as it is, anything else named by kind.
|
|
1099
|
+
def returned(v):
|
|
1100
|
+
if v is None:
|
|
1101
|
+
return "returned nothing"
|
|
1102
|
+
if isinstance(v, str):
|
|
1103
|
+
return v
|
|
1104
|
+
kind = "list of %d" % len(v) if isinstance(v, (list, tuple)) else "object" if isinstance(v, dict) or hasattr(v, "model_dump") else "value"
|
|
1105
|
+
return "%s: %s" % (kind, json.dumps(clipped(plain_of(v), 0, tool_text), ensure_ascii=False, separators=(",", ":")))
|
|
1106
|
+
|
|
1107
|
+
def tool_row(name, sig, a, k, value, error, turn):
|
|
1108
|
+
try:
|
|
1109
|
+
try:
|
|
1110
|
+
named = dict(sig.bind_partial(*a, **k).arguments) if sig else None
|
|
1111
|
+
except TypeError:
|
|
1112
|
+
named = None
|
|
1113
|
+
if named is None:
|
|
1114
|
+
named = {**{str(i): x for i, x in enumerate(a)}, **k}
|
|
1115
|
+
text = "raised %s: %s" % (type(error).__name__, error) if error is not None else returned(value)
|
|
1116
|
+
row = {"at": int(time.time() * 1000), "host": "in-app", "status": 200,
|
|
1117
|
+
"called": [{"name": name, "arguments": args_text(plain_of(named))}],
|
|
1118
|
+
"tools": [{"name": name, "text": str(text)[:tool_text]}]}
|
|
1119
|
+
if turn:
|
|
1120
|
+
row["turn"] = turn
|
|
1121
|
+
write({"dep": row})
|
|
1122
|
+
except Exception:
|
|
1123
|
+
pass
|
|
1124
|
+
|
|
1125
|
+
def wrap_tool(fn, name):
|
|
1126
|
+
import functools
|
|
1127
|
+
import inspect
|
|
1128
|
+
try:
|
|
1129
|
+
sig = inspect.signature(fn)
|
|
1130
|
+
except (TypeError, ValueError):
|
|
1131
|
+
sig = None
|
|
1132
|
+
if inspect.iscoroutinefunction(fn):
|
|
1133
|
+
async def wrapped(*a, **k):
|
|
1134
|
+
turn = turn_now()
|
|
1135
|
+
try:
|
|
1136
|
+
out = await fn(*a, **k)
|
|
1137
|
+
except Exception as e:
|
|
1138
|
+
tool_row(name, sig, a, k, None, e, turn)
|
|
1139
|
+
raise
|
|
1140
|
+
tool_row(name, sig, a, k, out, None, turn)
|
|
1141
|
+
return out
|
|
1142
|
+
else:
|
|
1143
|
+
def wrapped(*a, **k):
|
|
1144
|
+
turn = turn_now()
|
|
1145
|
+
try:
|
|
1146
|
+
out = fn(*a, **k)
|
|
1147
|
+
except Exception as e:
|
|
1148
|
+
tool_row(name, sig, a, k, None, e, turn)
|
|
1149
|
+
raise
|
|
1150
|
+
tool_row(name, sig, a, k, out, None, turn)
|
|
1151
|
+
return out
|
|
1152
|
+
functools.update_wrapper(wrapped, fn)
|
|
1153
|
+
wrapped.__cortad_tool__ = fn
|
|
1154
|
+
return wrapped
|
|
1155
|
+
|
|
1156
|
+
def targets_now():
|
|
1157
|
+
if not tools_file:
|
|
1158
|
+
return []
|
|
1159
|
+
try:
|
|
1160
|
+
with open(tools_file, encoding="utf-8") as f:
|
|
1161
|
+
raw = json.load(f)
|
|
1162
|
+
except Exception:
|
|
1163
|
+
return []
|
|
1164
|
+
out = []
|
|
1165
|
+
for t in raw if isinstance(raw, list) else []:
|
|
1166
|
+
if isinstance(t, dict) and isinstance(t.get("name"), str) and t["name"] and isinstance(t.get("file"), str) and t["file"].endswith(".py") and ident.match(str(t.get("function"))):
|
|
1167
|
+
out.append({"name": t["name"][:80], "file": re.sub(r"^\.?/+", "", t["file"]), "fn": t["function"]})
|
|
1168
|
+
# One function named twice (by its own name, and as "query_order (fallback)" where a router
|
|
1169
|
+
# imported it) is recorded under its own name.
|
|
1170
|
+
return sorted(out, key=lambda t: t["name"] != t["fn"])
|
|
1171
|
+
|
|
1172
|
+
def sweep_tools():
|
|
1173
|
+
if not tools_file:
|
|
1174
|
+
return
|
|
1175
|
+
try:
|
|
1176
|
+
at = os.stat(tools_file).st_mtime
|
|
1177
|
+
except OSError:
|
|
1178
|
+
return
|
|
1179
|
+
mark = (at, len(sys.modules))
|
|
1180
|
+
if swept[0] == mark:
|
|
1181
|
+
return
|
|
1182
|
+
if swept[0] is None or swept[0][0] != at:
|
|
1183
|
+
targets[0] = targets_now()
|
|
1184
|
+
swept[0] = mark
|
|
1185
|
+
if not targets[0]:
|
|
1186
|
+
return
|
|
1187
|
+
import inspect
|
|
1188
|
+
loaded = list(sys.modules.values())
|
|
1189
|
+
by_file = {}
|
|
1190
|
+
for m in loaded:
|
|
1191
|
+
f = getattr(m, "__file__", None)
|
|
1192
|
+
if isinstance(f, str) and f.endswith(".py"):
|
|
1193
|
+
by_file.setdefault(f.replace(os.sep, "/"), m)
|
|
1194
|
+
fresh = {}
|
|
1195
|
+
for t in targets[0]:
|
|
1196
|
+
m = next((mod for f, mod in by_file.items() if f == t["file"] or f.endswith("/" + t["file"])), None)
|
|
1197
|
+
if m is None:
|
|
1198
|
+
continue
|
|
1199
|
+
fn = getattr(m, t["fn"], None)
|
|
1200
|
+
if getattr(fn, "__cortad_tool__", None) is not None:
|
|
1201
|
+
continue
|
|
1202
|
+
if not (inspect.isfunction(fn) or inspect.ismethod(fn)):
|
|
1203
|
+
unwatched(t, "the file has no function named %s" % t["fn"] if fn is None else "%s there is not a function this hook can wrap" % t["fn"])
|
|
1204
|
+
continue
|
|
1205
|
+
held = wrappers.get(id(fn))
|
|
1206
|
+
w = held[1] if held is not None and held[0] is fn else wrap_tool(fn, t["name"])
|
|
1207
|
+
wrappers[id(fn)] = (fn, w)
|
|
1208
|
+
try:
|
|
1209
|
+
setattr(m, t["fn"], w)
|
|
1210
|
+
except Exception:
|
|
1211
|
+
unwatched(t, "its module does not let %s be replaced" % t["fn"])
|
|
1212
|
+
continue
|
|
1213
|
+
fresh[id(fn)] = (fn, w)
|
|
1214
|
+
if not fresh:
|
|
1215
|
+
return
|
|
1216
|
+
# Wherever the app imported it by name (from tools import query_order) or put it in a table
|
|
1217
|
+
# of its own (ROUTES = {"order": query_order}), the same function is swapped for the wrapper.
|
|
1218
|
+
here = os.getcwd().replace(os.sep, "/") + "/"
|
|
1219
|
+
for m in loaded:
|
|
1220
|
+
d = getattr(m, "__dict__", None)
|
|
1221
|
+
if not isinstance(d, dict):
|
|
1222
|
+
continue
|
|
1223
|
+
f = str(getattr(m, "__file__", "") or "").replace(os.sep, "/")
|
|
1224
|
+
ours = f.startswith(here) and "/site-packages/" not in f and "/dist-packages/" not in f
|
|
1225
|
+
for k, v in list(d.items()):
|
|
1226
|
+
hit = fresh.get(id(v))
|
|
1227
|
+
if hit is not None and hit[0] is v:
|
|
1228
|
+
d[k] = hit[1]
|
|
1229
|
+
elif ours and isinstance(v, dict):
|
|
1230
|
+
for kk, vv in list(v.items()):
|
|
1231
|
+
hit = fresh.get(id(vv))
|
|
1232
|
+
if hit is not None and hit[0] is vv:
|
|
1233
|
+
v[kk] = hit[1]
|
|
1234
|
+
|
|
330
1235
|
def started(method, path, headers):
|
|
1236
|
+
try:
|
|
1237
|
+
sweep_tools()
|
|
1238
|
+
except Exception:
|
|
1239
|
+
pass
|
|
331
1240
|
turn = headers.pop("x-cortad-turn", None)
|
|
332
1241
|
return {"method": method, "path": path, "headers": headers, "chunks": [], "size": 0, "noted": False,
|
|
333
1242
|
"turn": turn if isinstance(turn, str) and turn_ok.match(turn) else None}
|
|
@@ -647,7 +1556,7 @@ def _install():
|
|
|
647
1556
|
|
|
648
1557
|
def sent_of(request):
|
|
649
1558
|
try:
|
|
650
|
-
return text(request.content)
|
|
1559
|
+
return text(request.content, reply_max)
|
|
651
1560
|
except Exception:
|
|
652
1561
|
return ""
|
|
653
1562
|
|
|
@@ -692,22 +1601,32 @@ def _install():
|
|
|
692
1601
|
finally:
|
|
693
1602
|
self.finish()
|
|
694
1603
|
|
|
695
|
-
def watch(request, response, sent):
|
|
696
|
-
|
|
1604
|
+
def watch(request, response, sent, caller):
|
|
1605
|
+
model = is_model_call(request.url)
|
|
1606
|
+
if not model and not is_retrieval(request.url):
|
|
697
1607
|
dep(request.url, response.status_code)
|
|
698
1608
|
return response
|
|
699
1609
|
kind = response.headers.get("content-type", "")
|
|
700
1610
|
if getattr(response, "_content", None) is not None:
|
|
701
|
-
|
|
1611
|
+
if model:
|
|
1612
|
+
meter(request.url, sent, response.status_code, kind, response.content.decode("utf-8", "replace"), caller=caller)
|
|
1613
|
+
else:
|
|
1614
|
+
dep(request.url, response.status_code, passages=passages_from(response.content.decode("utf-8", "replace")))
|
|
702
1615
|
return response
|
|
703
|
-
|
|
1616
|
+
turn = turn_now()
|
|
1617
|
+
if model:
|
|
1618
|
+
done = lambda raw: meter(request.url, sent, response.status_code, kind, unpacked(raw, response.headers.get("content-encoding")), turn, caller)
|
|
1619
|
+
else:
|
|
1620
|
+
done = lambda raw: dep(request.url, response.status_code, passages=passages_from(unpacked(raw, response.headers.get("content-encoding"))), turn=turn)
|
|
704
1621
|
stream = response.stream
|
|
705
1622
|
if hasattr(stream, "__aiter__") and hasattr(stream, "aclose"):
|
|
706
1623
|
response.stream = AsyncKept(stream, done)
|
|
707
1624
|
elif hasattr(stream, "__iter__"):
|
|
708
1625
|
response.stream = SyncKept(stream, done)
|
|
1626
|
+
elif model:
|
|
1627
|
+
meter(request.url, sent, response.status_code, kind, "", caller=caller)
|
|
709
1628
|
else:
|
|
710
|
-
|
|
1629
|
+
dep(request.url, response.status_code)
|
|
711
1630
|
return response
|
|
712
1631
|
|
|
713
1632
|
for cls in (module.Client, module.AsyncClient):
|
|
@@ -715,6 +1634,7 @@ def _install():
|
|
|
715
1634
|
if cls is module.Client:
|
|
716
1635
|
def sync_send(self, request, *a, _send=send, **k):
|
|
717
1636
|
sent = sent_of(request)
|
|
1637
|
+
caller = callers_now(request.url) if is_model_call(request.url) else None
|
|
718
1638
|
try:
|
|
719
1639
|
note(request.url, sent)
|
|
720
1640
|
except Exception:
|
|
@@ -723,18 +1643,19 @@ def _install():
|
|
|
723
1643
|
response = _send(self, request, *a, **k)
|
|
724
1644
|
except Exception as err:
|
|
725
1645
|
if is_model_call(request.url):
|
|
726
|
-
meter(request.url, sent, 0, "", "")
|
|
1646
|
+
meter(request.url, sent, 0, "", "", caller=caller)
|
|
727
1647
|
else:
|
|
728
1648
|
dep(request.url, 0, type(err).__name__)
|
|
729
1649
|
raise
|
|
730
1650
|
try:
|
|
731
|
-
return watch(request, response, sent)
|
|
1651
|
+
return watch(request, response, sent, caller)
|
|
732
1652
|
except Exception:
|
|
733
1653
|
return response
|
|
734
1654
|
cls.send = sync_send
|
|
735
1655
|
else:
|
|
736
1656
|
async def async_send(self, request, *a, _send=send, **k):
|
|
737
1657
|
sent = sent_of(request)
|
|
1658
|
+
caller = callers_now(request.url) if is_model_call(request.url) else None
|
|
738
1659
|
try:
|
|
739
1660
|
note(request.url, sent)
|
|
740
1661
|
except Exception:
|
|
@@ -743,12 +1664,12 @@ def _install():
|
|
|
743
1664
|
response = await _send(self, request, *a, **k)
|
|
744
1665
|
except Exception as err:
|
|
745
1666
|
if is_model_call(request.url):
|
|
746
|
-
meter(request.url, sent, 0, "", "")
|
|
1667
|
+
meter(request.url, sent, 0, "", "", caller=caller)
|
|
747
1668
|
else:
|
|
748
1669
|
dep(request.url, 0, type(err).__name__)
|
|
749
1670
|
raise
|
|
750
1671
|
try:
|
|
751
|
-
return watch(request, response, sent)
|
|
1672
|
+
return watch(request, response, sent, caller)
|
|
752
1673
|
except Exception:
|
|
753
1674
|
return response
|
|
754
1675
|
cls.send = async_send
|
|
@@ -757,6 +1678,7 @@ def _install():
|
|
|
757
1678
|
send = module.Session.send
|
|
758
1679
|
|
|
759
1680
|
def sent(self, request, *a, **k):
|
|
1681
|
+
caller = callers_now(request.url) if is_model_call(request.url) else None
|
|
760
1682
|
try:
|
|
761
1683
|
note(request.url, request.body)
|
|
762
1684
|
except Exception:
|
|
@@ -769,11 +1691,12 @@ def _install():
|
|
|
769
1691
|
raise
|
|
770
1692
|
try:
|
|
771
1693
|
if not is_model_call(request.url):
|
|
772
|
-
|
|
1694
|
+
read = is_retrieval(request.url) and not k.get("stream")
|
|
1695
|
+
dep(request.url, response.status_code, passages=passages_from(response.content.decode("utf-8", "replace")) if read else None)
|
|
773
1696
|
if is_model_call(request.url):
|
|
774
1697
|
# A streamed reply is counted as a call whose counts were not read.
|
|
775
1698
|
raw = response.content.decode("utf-8", "replace") if getattr(response, "_content_consumed", False) else ""
|
|
776
|
-
meter(request.url, text(request.body), response.status_code, response.headers.get("content-type", ""), raw)
|
|
1699
|
+
meter(request.url, text(request.body, reply_max), response.status_code, response.headers.get("content-type", ""), raw, caller=caller)
|
|
777
1700
|
except Exception:
|
|
778
1701
|
pass
|
|
779
1702
|
return response
|
|
@@ -796,7 +1719,9 @@ def _install():
|
|
|
796
1719
|
dep(str_or_url, 0, type(err).__name__)
|
|
797
1720
|
raise
|
|
798
1721
|
if is_model_call(str_or_url):
|
|
799
|
-
response._cortad = (str_or_url, text(body), turn_now())
|
|
1722
|
+
response._cortad = (str_or_url, text(body, reply_max), turn_now(), callers_now(str_or_url))
|
|
1723
|
+
elif is_retrieval(str_or_url):
|
|
1724
|
+
response._cortad_dep = (str_or_url, turn_now())
|
|
800
1725
|
else:
|
|
801
1726
|
dep(str_or_url, response.status)
|
|
802
1727
|
return response
|
|
@@ -806,17 +1731,25 @@ def _install():
|
|
|
806
1731
|
|
|
807
1732
|
async def read_kept(self, *a, **k):
|
|
808
1733
|
raw = await read(self, *a, **k)
|
|
1734
|
+
store = getattr(self, "_cortad_dep", None)
|
|
1735
|
+
if store:
|
|
1736
|
+
self._cortad_dep = None
|
|
1737
|
+
dep(store[0], self.status, passages=passages_from((raw or b"").decode("utf-8", "replace")), turn=store[1])
|
|
809
1738
|
call = getattr(self, "_cortad", None)
|
|
810
1739
|
if call:
|
|
811
1740
|
self._cortad = None
|
|
812
|
-
meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"), call[2])
|
|
1741
|
+
meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"), call[2], call[3])
|
|
813
1742
|
return raw
|
|
814
1743
|
|
|
815
1744
|
def release_kept(self, *a, **k):
|
|
1745
|
+
store = getattr(self, "_cortad_dep", None)
|
|
1746
|
+
if store:
|
|
1747
|
+
self._cortad_dep = None
|
|
1748
|
+
dep(store[0], self.status, turn=store[1])
|
|
816
1749
|
call = getattr(self, "_cortad", None)
|
|
817
1750
|
if call:
|
|
818
1751
|
self._cortad = None
|
|
819
|
-
meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "", call[2])
|
|
1752
|
+
meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "", call[2], call[3])
|
|
820
1753
|
return release(self, *a, **k)
|
|
821
1754
|
|
|
822
1755
|
module.ClientSession._request = requested
|
|
@@ -879,3 +1812,8 @@ if _FILE:
|
|
|
879
1812
|
_install()
|
|
880
1813
|
except Exception:
|
|
881
1814
|
pass
|
|
1815
|
+
if _WRITES and _APP_ROOT:
|
|
1816
|
+
try:
|
|
1817
|
+
_install_writes()
|
|
1818
|
+
except Exception:
|
|
1819
|
+
pass
|