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.
@@ -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 = body.get("messages") if isinstance(body.get("messages"), list) else []
215
+ messages = items(body.get("messages"))
99
216
  for m in messages:
100
- if not isinstance(m, dict):
101
- continue
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
- if isinstance(m.get("content"), list):
106
- for c in m["content"]:
107
- if isinstance(c, dict) and c.get("type") == "tool_use" and c.get("id"):
108
- names[c["id"]] = c.get("name")
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
- if isinstance(m.get("content"), list):
115
- for c in m["content"]:
116
- if isinstance(c, dict) and c.get("type") == "tool_result":
117
- add(names.get(c.get("tool_use_id")), c.get("content"))
118
- items = body.get("input") if isinstance(body.get("input"), list) else []
119
- for it in items:
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 items:
123
- if isinstance(it, dict) and it.get("type") == "function_call_output":
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 body.get("contents") if isinstance(body.get("contents"), list) else []:
126
- for p in c.get("parts") if isinstance(c, dict) and isinstance(c.get("parts"), list) else []:
127
- if isinstance(p, dict) and isinstance(p.get("functionResponse"), dict):
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
- # A framework that folds its tools' answers into the next prompt as plain text sends no tool
132
- # message to find: within one turn, what a later call's prompt carries that the call before did
133
- # not, outside the model's own words, is that material. The first call holds the customer's
134
- # message and the system prompt, present in every later call, so neither is ever counted.
135
- seen_by_turn = {}
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 texts_of(body):
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 take(v):
141
- t = text_of(v).strip()
142
- if t:
143
- out.add(t[:tool_text])
144
- for m in body.get("messages") if isinstance(body.get("messages"), list) else []:
145
- if isinstance(m, dict) and m.get("role") != "assistant":
146
- take(m.get("content"))
147
- for it in body.get("input") if isinstance(body.get("input"), list) else []:
148
- if isinstance(it, dict) and it.get("role") != "assistant" and it.get("type") != "function_call":
149
- take(it.get("content") if it.get("content") is not None else it.get("output"))
150
- if isinstance(body.get("system"), str):
151
- take(body["system"])
152
- for c in body.get("contents") if isinstance(body.get("contents"), list) else []:
153
- if isinstance(c, dict) and c.get("role") != "model":
154
- for p in c.get("parts") if isinstance(c.get("parts"), list) else []:
155
- if isinstance(p, dict) and isinstance(p.get("text"), str):
156
- take(p["text"])
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
- def material_in(sent, turn):
160
- if not turn:
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
- if not isinstance(body, dict):
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
- now = texts_of(body)
166
- before = seen_by_turn.get(turn)
167
- seen_by_turn[turn] = now
168
- if len(seen_by_turn) > 200:
169
- seen_by_turn.pop(next(iter(seen_by_turn)))
170
- if before is None:
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
- fresh = [{"name": "", "text": t} for t in now if t not in before][:tools_max]
173
- return fresh or None
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 OSError:
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
- def dep(url, status, code=None):
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
- def text(body):
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[:limit]).decode("utf-8", "replace")
872
+ return bytes(body[:most]).decode("utf-8", "replace")
219
873
  if isinstance(body, str):
220
- return body[:limit]
874
+ return body[:most]
221
875
  try:
222
- return json.dumps(body)[:limit]
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
- def meter(url, sent, status, kind, raw, turn=None):
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) or material_in(as_text, turn)
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
- if not is_model_call(request.url):
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
- meter(request.url, sent, response.status_code, kind, response.content.decode("utf-8", "replace"))
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
- done = lambda raw: meter(request.url, sent, response.status_code, kind, unpacked(raw, response.headers.get("content-encoding")))
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
- meter(request.url, sent, response.status_code, kind, "")
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
- dep(request.url, response.status_code)
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