cortad 0.3.1-rc.2 → 0.3.1-rc.4

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.
@@ -96,8 +96,12 @@ def _install():
96
96
  at = i
97
97
  return seq[at + 1:]
98
98
 
99
+ # A user message that only hands a tool's answer back ("Observation: ...", <tool_response>) is
100
+ # the agent loop talking, not the person.
101
+ handed_back = re.compile(r"^\s*(?:Observation\s*:|<(?:tool_response|tool_result|function_results?)>)", re.I)
102
+
99
103
  def person_said(m):
100
- 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 []))
104
+ 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")))
101
105
 
102
106
  def person_asked(c):
103
107
  return c.get("role") == "user" and not any(p.get("functionResponse") for p in items(c.get("parts")))
@@ -138,6 +142,9 @@ def _install():
138
142
  for p in items(c.get("parts")):
139
143
  if isinstance(p.get("functionResponse"), dict):
140
144
  add(p["functionResponse"].get("name"), p["functionResponse"].get("response"))
145
+ declared = declared_in(body)
146
+ for name, said in observed_in(turn_texts(body, True, declared), declared):
147
+ add(name, said)
141
148
  return out or None
142
149
 
143
150
  # What the model asked the app to run: chat tool_calls (a stream's pieces joined by position), a
@@ -148,15 +155,15 @@ def _install():
148
155
  secret_key = re.compile(r"(?:^|_)(?:pass(?:word|phrase)?|secret|token|api_?key|authorization|cookie|session(?:_id)?|credentials?|private_key)$")
149
156
  secret_value = re.compile(r"^(?:Bearer\s|Basic\s|sk-|pk_|rk_|ghp_|gho_|github_pat_|xox[abpr]-|AKIA|AIza|eyJ[\w-]{10,}\.)")
150
157
 
151
- def clipped(v, depth=0):
158
+ def clipped(v, depth=0, most=value_max):
152
159
  if isinstance(v, str):
153
- return "[secret]" if secret_value.match(v) else (v[:value_max] + "…" if len(v) > value_max else v)
160
+ return "[secret]" if secret_value.match(v) else (v[:most] + "…" if len(v) > most else v)
154
161
  if isinstance(v, list):
155
- return [] if depth > 4 else [clipped(x, depth + 1) for x in v[:20]]
162
+ return [] if depth > 4 else [clipped(x, depth + 1, most) for x in v[:20]]
156
163
  if isinstance(v, dict):
157
164
  if depth > 4:
158
165
  return {}
159
- 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)) for k, x in list(v.items())[:40]}
166
+ 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]}
160
167
  return v
161
168
 
162
169
  def args_text(raw):
@@ -244,10 +251,230 @@ def _install():
244
251
  events.append({"candidates": [{"content": c}]})
245
252
  return calls_of(events)
246
253
 
254
+ # Tool use a model writes in its words instead of as a structured call. A ReAct agent (CrewAI,
255
+ # LangChain) writes "Action: name" then "Action Input: {...}" and is handed "Observation: ..."
256
+ # back in its next prompt; others write <tool_call>{...}</tool_call>, <function=name>,
257
+ # <invoke name="..."> or a JSON object that names the tool. A name counts only when the request
258
+ # declares that tool, in its tools field or in the tool list its prompt carries, so a thought, a
259
+ # "Final Answer" or prose that says Action is never a call.
260
+ final = re.compile(r"final[\s_-]*answer", re.I)
261
+ tool_name = re.compile(r"[A-Za-z_][\w.-]{0,79}", re.A)
262
+
263
+ def prompt_text(body):
264
+ inp = body.get("input")
265
+ return "\n".join(
266
+ [text_of(m.get("content")) for m in items(body.get("messages")) if m.get("role") != "assistant"]
267
+ + [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 ""]
268
+ + [text_of(x.get("content")) for x in items(inp) if x.get("role") and x.get("role") != "assistant"]
269
+ + [text_of((body.get("systemInstruction") or {}).get("parts") if isinstance(body.get("systemInstruction"), dict) else None)]
270
+ + [text_of(c.get("parts")) for c in items(body.get("contents")) if c.get("role") != "model"])
271
+
272
+ def declared_in(body):
273
+ out = set()
274
+
275
+ def add(n):
276
+ v = n.strip().strip("\"'`") if isinstance(n, str) else ""
277
+ if tool_name.fullmatch(v) and not final.fullmatch(v):
278
+ out.add(v)
279
+
280
+ def get(d, k):
281
+ return d.get(k) if isinstance(d, dict) else None
282
+
283
+ for t in items(body.get("tools")):
284
+ add(t.get("name"))
285
+ add(get(t.get("function"), "name"))
286
+ add(get(t.get("toolSpec"), "name"))
287
+ for d in items(t.get("functionDeclarations") or t.get("function_declarations")):
288
+ add(d.get("name"))
289
+ for f in items(body.get("functions")):
290
+ add(f.get("name"))
291
+ for t in items(get(body.get("toolConfig"), "tools")):
292
+ add(get(t.get("toolSpec"), "name"))
293
+ text = prompt_text(body)[:200000]
294
+ for m in re.finditer(r"^[ \t]*Tool Name:[ \t]*([^\n]+)", text, re.I | re.M):
295
+ add(m.group(1))
296
+ for m in re.finditer(r"\b(?:one of|name of|names? from)[ \t]*\[([^\]\n]{1,2000})\]", text, re.I):
297
+ for x in m.group(1).split(","):
298
+ add(x)
299
+ for m in re.finditer(r"valid \"?action\"? values?:?[ \t]*([^\n]{1,2000})", text, re.I):
300
+ for x in re.split(r",|\bor\b", m.group(1)):
301
+ add(x)
302
+ for m in re.finditer(r"<(tools|functions)>([\s\S]*?)</\1>", text, re.I):
303
+ for k in re.finditer(r"\"name\"\s*:\s*\"([^\"]+)\"", m.group(2)):
304
+ add(k.group(1))
305
+ for m in re.finditer(r"\"name\"\s*:\s*\"([^\"]+)\"\s*,\s*\"(?:description|parameters|input_schema)\"", text):
306
+ add(m.group(1))
307
+ for m in re.finditer(r"<(?:tool|function)\s+name\s*=\s*[\"']([^\"']+)[\"']", text, re.I):
308
+ add(m.group(1))
309
+ return out
310
+
311
+ 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)
312
+ tag_call = re.compile(r"<(tool_call|function_call|tool_use)>([\s\S]*?)</\1>", re.I)
313
+ fn_tag = re.compile(r"<function=([\w.-]+)>([\s\S]*?)</function>", re.I)
314
+ invoke = re.compile(r"<invoke\s+name\s*=\s*[\"']([^\"']+)[\"'][^>]*>([\s\S]*?)</invoke>", re.I)
315
+ 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)
316
+
317
+ def params_of(s):
318
+ 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)}
319
+
320
+ # Each balanced JSON object in the text, outermost first; its insides are not read again.
321
+ def objects_in(text):
322
+ out, i = [], text.find("{")
323
+ while i != -1 and len(out) < 20:
324
+ depth, in_str, esc, end = 0, False, False, -1
325
+ for j in range(i, min(len(text), i + 20000)):
326
+ ch = text[j]
327
+ if in_str:
328
+ if esc:
329
+ esc = False
330
+ elif ch == "\\":
331
+ esc = True
332
+ elif ch == '"':
333
+ in_str = False
334
+ continue
335
+ if ch == '"':
336
+ in_str = True
337
+ elif ch == "{":
338
+ depth += 1
339
+ elif ch == "}":
340
+ depth -= 1
341
+ if depth == 0:
342
+ end = j
343
+ break
344
+ v = parsed(text[i:end + 1]) if end != -1 else None
345
+ if isinstance(v, dict):
346
+ out.append((i, v))
347
+ i = end
348
+ i = text.find("{", i + 1)
349
+ return out
350
+
351
+ # An Action Input that opens with a JSON object is that object: a model that runs on past it
352
+ # ("Observ: ...") does not put its own invention into the arguments.
353
+ def leading_json(s):
354
+ t = s.strip()
355
+ o = objects_in(t)[:1] if t.startswith("{") else []
356
+ return o[0][1] if o and o[0][0] == 0 else t
357
+
358
+ # A tool's schema carries a description; a call does not.
359
+ def json_call(v):
360
+ f = v["function"] if isinstance(v.get("function"), dict) else v
361
+ if "description" in f:
362
+ return None
363
+ 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)
364
+ if not name:
365
+ return None
366
+ 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)
367
+ return name, args
368
+
369
+ def written_calls(text, declared):
370
+ found = []
371
+ if not text or not declared:
372
+ return found
373
+ s = text[:50000]
374
+
375
+ def take(at, name, args):
376
+ n = str(name or "").strip()
377
+ if n in declared:
378
+ found.append((at, n, args.strip() if isinstance(args, str) else args))
379
+
380
+ for m in react.finditer(s):
381
+ take(m.start(), m.group(1), leading_json(m.group(2)))
382
+ for m in tag_call.finditer(s):
383
+ v = parsed(m.group(2).strip())
384
+ c = json_call(v) if isinstance(v, dict) else None
385
+ if c:
386
+ take(m.start(), *c)
387
+ for m in fn_tag.finditer(s):
388
+ v = parsed(m.group(2).strip())
389
+ take(m.start(), m.group(1), v if isinstance(v, (dict, list)) else params_of(m.group(2)))
390
+ for m in invoke.finditer(s):
391
+ take(m.start(), m.group(1), params_of(m.group(2)))
392
+ for at, v in objects_in(s):
393
+ c = json_call(v)
394
+ if c:
395
+ take(at, *c)
396
+ return sorted(found, key=lambda c: c[0])
397
+
398
+ # What a tool answered, named by the call written just before it.
399
+ def observed_in(text, declared):
400
+ s = str(text or "")[:50000]
401
+ calls = written_calls(s, declared)
402
+ out = []
403
+ if calls:
404
+ for m in observed.finditer(s):
405
+ before = [c for c in calls if c[0] < m.start()]
406
+ if before:
407
+ out.append((before[-1][1], (m.group(1) if m.group(1) is not None else m.group(3)).strip()))
408
+ return out
409
+
410
+ # The model's own words: the text it answered, whole or streamed, in every provider's shape.
411
+ def reply_text(events):
412
+ s = []
413
+ for e in events:
414
+ if not isinstance(e, dict):
415
+ continue
416
+ for c in items(e.get("choices")):
417
+ if isinstance(c.get("message"), dict):
418
+ s.append(text_of(c["message"].get("content")))
419
+ if isinstance(c.get("delta"), dict) and isinstance(c["delta"].get("content"), str):
420
+ s.append(c["delta"]["content"])
421
+ if isinstance(c.get("text"), str):
422
+ s.append(c["text"])
423
+ if e.get("type") == "response.output_text.delta" and isinstance(e.get("delta"), str):
424
+ s.append(e["delta"])
425
+ resp = e.get("response") if isinstance(e.get("response"), dict) else {}
426
+ for it in items(e.get("output")) + items(resp.get("output")):
427
+ if it.get("type") == "message":
428
+ s.extend(p["text"] for p in items(it.get("content")) if isinstance(p.get("text"), str))
429
+ delta = e.get("delta") if isinstance(e.get("delta"), dict) else {}
430
+ if e.get("type") == "content_block_delta" and isinstance(delta.get("text"), str):
431
+ s.append(delta["text"])
432
+ msg = e.get("message") if isinstance(e.get("message"), dict) else {}
433
+ out_msg = e["output"].get("message") if isinstance(e.get("output"), dict) and isinstance(e["output"].get("message"), dict) else {}
434
+ for b in items(e.get("content")) + items(msg.get("content")) + items(out_msg.get("content")):
435
+ if isinstance(b.get("text"), str):
436
+ s.append(b["text"])
437
+ if isinstance(msg.get("content"), str) and not e.get("choices"):
438
+ s.append(msg["content"])
439
+ for c in items(e.get("candidates")):
440
+ for p in items((c.get("content") or {}).get("parts") if isinstance(c.get("content"), dict) else None):
441
+ if isinstance(p.get("text"), str) and not p.get("thought"):
442
+ s.append(p["text"])
443
+ if isinstance(e.get("response"), str):
444
+ s.append(e["response"])
445
+ return "".join(s)
446
+
447
+ # This turn as the prompt carries it: the model's earlier words after the person's latest
448
+ # message, and everything from that message on, where a single-prompt agent keeps its scratchpad.
449
+ # A user message right after the model wrote a tool call is the agent loop's nudge ("Analyze the
450
+ # tool result"), never the person: after a call, only the loop speaks.
451
+ def turn_texts(body, with_person, declared):
452
+ def since_person(seq, is_person):
453
+ at = -1
454
+ for i, m in enumerate(seq):
455
+ if is_person(m):
456
+ at = i
457
+ return seq[max(0, at):] if with_person else seq[at + 1:]
458
+
459
+ def ours(role):
460
+ return role not in ("system", "developer") if with_person else role in ("assistant", "model")
461
+
462
+ inp = body.get("input")
463
+ ms = items(body.get("messages"))
464
+ 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)}
465
+ 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"))]
466
+ 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"))]
467
+ out += [text_of(c.get("parts")) for c in since_person(items(body.get("contents")), person_asked) if ours(c.get("role"))]
468
+ if with_person:
469
+ out += [body.get("prompt") if isinstance(body.get("prompt"), str) else "", inp if isinstance(inp, str) else ""]
470
+ return "\x00\n".join(out)
471
+
247
472
  def called_in(sent, events):
248
473
  body = parsed(sent) if isinstance(sent, str) else None
249
474
  out = []
250
- for c in (called_before(body) if isinstance(body, dict) else []) + calls_of(events):
475
+ declared = declared_in(body) if isinstance(body, dict) else set()
476
+ 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)]
477
+ for c in (called_before(body) if isinstance(body, dict) else []) + calls_of(events) + written:
251
478
  if len(out) < calls_max and not any(o["name"] == c["name"] and o["arguments"] == c["arguments"] for o in out):
252
479
  out.append(c)
253
480
  return out or None
@@ -365,50 +592,6 @@ def _install():
365
592
  walk(parsed(raw) if isinstance(raw, str) else None, "", 0)
366
593
  return out or None
367
594
 
368
- # A framework that folds its tools' answers into the next prompt as plain text sends no tool
369
- # message to find: within one turn, what a later call's prompt carries that the call before did
370
- # not, outside the model's own words, is that material. The first call holds the customer's
371
- # message and the system prompt, present in every later call, so neither is ever counted.
372
- seen_by_turn = {}
373
-
374
- def texts_of(body):
375
- out = set()
376
-
377
- def take(v):
378
- t = text_of(v).strip()
379
- if t:
380
- out.add(t[:tool_text])
381
- for m in body.get("messages") if isinstance(body.get("messages"), list) else []:
382
- if isinstance(m, dict) and m.get("role") != "assistant":
383
- take(m.get("content"))
384
- for it in body.get("input") if isinstance(body.get("input"), list) else []:
385
- if isinstance(it, dict) and it.get("role") != "assistant" and it.get("type") != "function_call":
386
- take(it.get("content") if it.get("content") is not None else it.get("output"))
387
- if isinstance(body.get("system"), str):
388
- take(body["system"])
389
- for c in body.get("contents") if isinstance(body.get("contents"), list) else []:
390
- if isinstance(c, dict) and c.get("role") != "model":
391
- for p in c.get("parts") if isinstance(c.get("parts"), list) else []:
392
- if isinstance(p, dict) and isinstance(p.get("text"), str):
393
- take(p["text"])
394
- return out
395
-
396
- def material_in(sent, turn):
397
- if not turn:
398
- return None
399
- body = parsed(sent) if isinstance(sent, str) else None
400
- if not isinstance(body, dict):
401
- return None
402
- now = texts_of(body)
403
- before = seen_by_turn.get(turn)
404
- seen_by_turn[turn] = now
405
- if len(seen_by_turn) > 200:
406
- seen_by_turn.pop(next(iter(seen_by_turn)))
407
- if before is None:
408
- return None
409
- fresh = [{"name": "", "text": t} for t in now if t not in before][:tools_max]
410
- return fresh or None
411
-
412
595
  def rules_in(sent):
413
596
  found = rules_now()
414
597
  if found is None:
@@ -416,12 +599,45 @@ def _install():
416
599
  body = norm(sent)
417
600
  return [rid for rid, parts in found if all(p in body for p in parts)]
418
601
 
602
+ # Every value this file writes passes one mask, whichever path wrote it: a key inside a value
603
+ # ("sk-...", a bearer token, a JWT) and what anyone wrote after "password is" or "token:". The
604
+ # door row's sign-in headers are kept on purpose: the command replays them from this machine.
605
+ secret_text = [
606
+ re.compile(r"""((?:pass(?:word|phrase|wd)|pwd|密码|口令)["']?\s*(?:is\b|was\b|[:=:]|是|为)\s*["']?)([^\s"'\\,;},。;、]+)""", re.I),
607
+ re.compile(r"""((?:secret(?:[ _-]?key)?|api[ _-]?key|apikey|access[ _-]?key|private[ _-]?key|client[ _-]?secret|(?:auth|access|refresh)[ _-]?token|token)["']?\s*[:=:]\s*["']?)([^\s"'\\,;},。;、]+)""", re.I),
608
+ ]
609
+ 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)
610
+
611
+ def mask_text(t):
612
+ for pattern in secret_text:
613
+ t = pattern.sub(r"\1[secret]", t)
614
+ return secret_word.sub("[secret]", t)
615
+
616
+ def scrub(v, depth=0):
617
+ if isinstance(v, str):
618
+ return mask_text(v)
619
+ if isinstance(v, (list, tuple)):
620
+ return [scrub(x, depth + 1) for x in v]
621
+ if isinstance(v, dict) and depth < 12:
622
+ return {k: (x if k == "headers" and depth == 0 else scrub(x, depth + 1)) for k, x in v.items()}
623
+ return v
624
+
625
+ # A door's body goes up to the run as JSON, so it is masked as JSON: its string values, never its shape.
626
+ def body_scrubbed(raw):
627
+ b = parsed(raw)
628
+ return json.dumps(scrub(b), ensure_ascii=False) if isinstance(b, (dict, list)) else mask_text(raw)
629
+
419
630
  def write(row):
420
631
  try:
632
+ if not (row.get("hello") or row.get("listen") or row.get("routes")):
633
+ out = scrub(row)
634
+ if isinstance(row.get("body"), str):
635
+ out["body"] = body_scrubbed(row["body"])
636
+ row = out
421
637
  fd = os.open(_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
422
638
  with os.fdopen(fd, "a", encoding="utf-8") as f:
423
639
  f.write(json.dumps(row) + "\n")
424
- except OSError:
640
+ except Exception:
425
641
  pass
426
642
 
427
643
  # Every other outbound call: the host and what it answered, never a byte of it. A search
@@ -558,7 +774,7 @@ def _install():
558
774
  found = rules_in(as_text)
559
775
  if found is not None:
560
776
  row["rules"] = found
561
- tools = tools_in(as_text) or material_in(as_text, turn)
777
+ tools = tools_in(as_text)
562
778
  if tools:
563
779
  row["tools"] = tools
564
780
  called = called_in(as_text, events)
@@ -571,7 +787,185 @@ def _install():
571
787
  except Exception:
572
788
  pass
573
789
 
790
+ # The app's own functions the read names as its tools. An app whose code picks the tool itself
791
+ # (a classifier answers {"intent":"order"} and the code calls query_order()) never names one on
792
+ # the wire, so the function is wrapped where it is defined and wherever it was imported by name:
793
+ # each call writes its name, its clipped arguments and what it returned, on the turn it ran in.
794
+ # The run writes the list beside the rules, after the app has loaded, so it is applied to the
795
+ # modules already loaded and looked at again when more load.
796
+ tools_file = os.environ.get("CORTAD_TOOLS_FILE")
797
+ ident = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
798
+ targets, swept, wrappers = [[]], [None], {}
799
+ unsaid = set()
800
+
801
+ def unwatched(t, why):
802
+ if (t["file"], t["fn"]) in unsaid:
803
+ return
804
+ unsaid.add((t["file"], t["fn"]))
805
+ try:
806
+ sys.stderr.write("cortad: calls to %s in %s are not recorded: %s.\n" % (t["name"], t["file"], why))
807
+ except Exception:
808
+ pass
809
+
810
+ def plain_of(v, depth=0):
811
+ if v is None or isinstance(v, (str, bool, int, float)):
812
+ return v
813
+ if depth > 4:
814
+ return "[%s]" % type(v).__name__
815
+ if isinstance(v, (list, tuple, set)):
816
+ return [plain_of(x, depth + 1) for x in list(v)[:20]]
817
+ if isinstance(v, dict):
818
+ return {str(k): plain_of(x, depth + 1) for k, x in list(v.items())[:40]}
819
+ dump = getattr(v, "model_dump", None)
820
+ if callable(dump):
821
+ try:
822
+ return plain_of(dump(), depth + 1)
823
+ except Exception:
824
+ pass
825
+ return "[%s]" % type(v).__name__
826
+
827
+ # The returned value's shape and a clipped text: a string as it is, anything else named by kind.
828
+ def returned(v):
829
+ if v is None:
830
+ return "returned nothing"
831
+ if isinstance(v, str):
832
+ return v
833
+ kind = "list of %d" % len(v) if isinstance(v, (list, tuple)) else "object" if isinstance(v, dict) or hasattr(v, "model_dump") else "value"
834
+ return "%s: %s" % (kind, json.dumps(clipped(plain_of(v), 0, tool_text), ensure_ascii=False, separators=(",", ":")))
835
+
836
+ def tool_row(name, sig, a, k, value, error, turn):
837
+ try:
838
+ try:
839
+ named = dict(sig.bind_partial(*a, **k).arguments) if sig else None
840
+ except TypeError:
841
+ named = None
842
+ if named is None:
843
+ named = {**{str(i): x for i, x in enumerate(a)}, **k}
844
+ text = "raised %s: %s" % (type(error).__name__, error) if error is not None else returned(value)
845
+ row = {"at": int(time.time() * 1000), "host": "in-app", "status": 200,
846
+ "called": [{"name": name, "arguments": args_text(plain_of(named))}],
847
+ "tools": [{"name": name, "text": str(text)[:tool_text]}]}
848
+ if turn:
849
+ row["turn"] = turn
850
+ write({"dep": row})
851
+ except Exception:
852
+ pass
853
+
854
+ def wrap_tool(fn, name):
855
+ import functools
856
+ import inspect
857
+ try:
858
+ sig = inspect.signature(fn)
859
+ except (TypeError, ValueError):
860
+ sig = None
861
+ if inspect.iscoroutinefunction(fn):
862
+ async def wrapped(*a, **k):
863
+ turn = turn_now()
864
+ try:
865
+ out = await fn(*a, **k)
866
+ except Exception as e:
867
+ tool_row(name, sig, a, k, None, e, turn)
868
+ raise
869
+ tool_row(name, sig, a, k, out, None, turn)
870
+ return out
871
+ else:
872
+ def wrapped(*a, **k):
873
+ turn = turn_now()
874
+ try:
875
+ out = fn(*a, **k)
876
+ except Exception as e:
877
+ tool_row(name, sig, a, k, None, e, turn)
878
+ raise
879
+ tool_row(name, sig, a, k, out, None, turn)
880
+ return out
881
+ functools.update_wrapper(wrapped, fn)
882
+ wrapped.__cortad_tool__ = fn
883
+ return wrapped
884
+
885
+ def targets_now():
886
+ if not tools_file:
887
+ return []
888
+ try:
889
+ with open(tools_file, encoding="utf-8") as f:
890
+ raw = json.load(f)
891
+ except Exception:
892
+ return []
893
+ out = []
894
+ for t in raw if isinstance(raw, list) else []:
895
+ 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"))):
896
+ out.append({"name": t["name"][:80], "file": re.sub(r"^\.?/+", "", t["file"]), "fn": t["function"]})
897
+ # One function named twice (by its own name, and as "query_order (fallback)" where a router
898
+ # imported it) is recorded under its own name.
899
+ return sorted(out, key=lambda t: t["name"] != t["fn"])
900
+
901
+ def sweep_tools():
902
+ if not tools_file:
903
+ return
904
+ try:
905
+ at = os.stat(tools_file).st_mtime
906
+ except OSError:
907
+ return
908
+ mark = (at, len(sys.modules))
909
+ if swept[0] == mark:
910
+ return
911
+ if swept[0] is None or swept[0][0] != at:
912
+ targets[0] = targets_now()
913
+ swept[0] = mark
914
+ if not targets[0]:
915
+ return
916
+ import inspect
917
+ loaded = list(sys.modules.values())
918
+ by_file = {}
919
+ for m in loaded:
920
+ f = getattr(m, "__file__", None)
921
+ if isinstance(f, str) and f.endswith(".py"):
922
+ by_file.setdefault(f.replace(os.sep, "/"), m)
923
+ fresh = {}
924
+ for t in targets[0]:
925
+ m = next((mod for f, mod in by_file.items() if f == t["file"] or f.endswith("/" + t["file"])), None)
926
+ if m is None:
927
+ continue
928
+ fn = getattr(m, t["fn"], None)
929
+ if getattr(fn, "__cortad_tool__", None) is not None:
930
+ continue
931
+ if not (inspect.isfunction(fn) or inspect.ismethod(fn)):
932
+ 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"])
933
+ continue
934
+ held = wrappers.get(id(fn))
935
+ w = held[1] if held is not None and held[0] is fn else wrap_tool(fn, t["name"])
936
+ wrappers[id(fn)] = (fn, w)
937
+ try:
938
+ setattr(m, t["fn"], w)
939
+ except Exception:
940
+ unwatched(t, "its module does not let %s be replaced" % t["fn"])
941
+ continue
942
+ fresh[id(fn)] = (fn, w)
943
+ if not fresh:
944
+ return
945
+ # Wherever the app imported it by name (from tools import query_order) or put it in a table
946
+ # of its own (ROUTES = {"order": query_order}), the same function is swapped for the wrapper.
947
+ here = os.getcwd().replace(os.sep, "/") + "/"
948
+ for m in loaded:
949
+ d = getattr(m, "__dict__", None)
950
+ if not isinstance(d, dict):
951
+ continue
952
+ f = str(getattr(m, "__file__", "") or "").replace(os.sep, "/")
953
+ ours = f.startswith(here) and "/site-packages/" not in f and "/dist-packages/" not in f
954
+ for k, v in list(d.items()):
955
+ hit = fresh.get(id(v))
956
+ if hit is not None and hit[0] is v:
957
+ d[k] = hit[1]
958
+ elif ours and isinstance(v, dict):
959
+ for kk, vv in list(v.items()):
960
+ hit = fresh.get(id(vv))
961
+ if hit is not None and hit[0] is vv:
962
+ v[kk] = hit[1]
963
+
574
964
  def started(method, path, headers):
965
+ try:
966
+ sweep_tools()
967
+ except Exception:
968
+ pass
575
969
  turn = headers.pop("x-cortad-turn", None)
576
970
  return {"method": method, "path": path, "headers": headers, "chunks": [], "size": 0, "noted": False,
577
971
  "turn": turn if isinstance(turn, str) and turn_ok.match(turn) else None}
package/lib/replay.mjs CHANGED
@@ -35,6 +35,9 @@ export function makeCapture({ work, keepSecret, onDoor }) {
35
35
  // The run writes the customer's rule sentences here (the engine's /tmp/rules.json lands in this
36
36
  // folder), and the hook reads which of them each model call's prompt carried.
37
37
  CORTAD_RULES_FILE: join(work, "rules.json"),
38
+ // The read's tools by name, file and function, written by the run the same way: the hook wraps
39
+ // those functions so a tool your own code runs is seen even when no model named it.
40
+ CORTAD_TOOLS_FILE: join(work, "tools.json"),
38
41
  NODE_OPTIONS: `${base.NODE_OPTIONS ?? ""} --require ${JSON.stringify(HOOK)}`.trim(),
39
42
  BUN_OPTIONS: `${base.BUN_OPTIONS ?? ""} --preload=${bunHook}`.trim(),
40
43
  PYTHONPATH: [PYHOOK, base.PYTHONPATH].filter(Boolean).join(":"),
@@ -130,10 +133,12 @@ const meter = (() => {
130
133
  if (rows.length > ROWS) rows.splice(0, rows.length - ROWS);
131
134
  },
132
135
  // A service their settings name: only its setting, host and status travel, never a byte of it.
136
+ // A tool function of the app's own ("in-app") carries its call and what it returned, as a
137
+ // model call's row does.
133
138
  dep(d) {
134
139
  if (!d || typeof d.host !== "string" || !d.host) return;
135
140
  const env = typeof d.env === "string" && /^[A-Z_][A-Z0-9_]*$/.test(d.env) ? d.env : undefined;
136
- deps.push({ at: count(d.at), ...(env ? { env } : {}), host: d.host.slice(0, 253), status: count(d.status), ...(typeof d.code === "string" ? { code: d.code.slice(0, 40) } : {}), ...turnOf(d.turn), ...passagesOf(d.passages) });
141
+ deps.push({ at: count(d.at), ...(env ? { env } : {}), host: d.host.slice(0, 253), status: count(d.status), ...(typeof d.code === "string" ? { code: d.code.slice(0, 40) } : {}), ...turnOf(d.turn), ...passagesOf(d.passages), ...toolsOf(d.tools), ...calledOf(d.called) });
137
142
  if (deps.length > ROWS) deps.splice(0, deps.length - ROWS);
138
143
  },
139
144
  report() {
package/lib/text.mjs CHANGED
@@ -138,10 +138,13 @@ function stoppedLine(d) {
138
138
 
139
139
  const moveText = (m) => `held ${kn(m.before)} readings before, ${kn(m.after)} after; ${m.moved}, ${signed(m.point)} points${interval(m)}${m.insideNoise ? ", inside the noise" : ""}.`;
140
140
 
141
+ // Over zero pairs there is no move to print: the line says verification is unavailable and why,
142
+ // never "no change, 0 points" measured on nothing. The visible side's reason is the said line.
141
143
  function verifyLines(v) {
142
144
  const lines = [`Verify of ${v.findingId}${v.path ? ` at ${at(v.path, v.line)}` : ""}.`];
143
- if (v.visible) lines.push(`Visible trials: ${moveText(v.visible)}`);
144
- if (v.holdout) lines.push(`Held-out trials: ${moveText(v.holdout)}`);
145
+ if (v.visible && !v.visible.unavailable) lines.push(`Visible trials: ${moveText(v.visible)}`);
146
+ if (v.holdout?.unavailable) lines.push(`Held-out trials: verification unavailable: ${v.holdout.unavailable}.`);
147
+ else if (v.holdout) lines.push(`Held-out trials: ${moveText(v.holdout)}`);
145
148
  else if (v.holdout === null) lines.push("Held-out trials: no pair for this question.");
146
149
  if (v.overfit) lines.push("Overfit: the visible trials moved and the held-out trials did not.");
147
150
  if (v.said) lines.push(v.said);
@@ -190,7 +193,8 @@ function ledgerLines(l) {
190
193
  }
191
194
  if (l.fixed) {
192
195
  lines.push(`${plural(l.fixed.length, "fix", "fixes")} verified since that run${l.fixed.length ? ":" : "."}`);
193
- for (const f of l.fixed) lines.push(` ${at(f.path, f.line)} (${f.findingId}): held ${kn(f.before)} readings before, ${kn(f.after)} after; ${f.moved}, ${signed(f.move)} points${interval(f)}.`);
196
+ // A fix on a finding with no file (a crash, a standard) is named by its id, never "undefined".
197
+ for (const f of l.fixed) lines.push(` ${f.path ? `${at(f.path, f.line)} (${f.findingId})` : f.findingId}: held ${kn(f.before)} readings before, ${kn(f.after)} after; ${f.moved}, ${signed(f.move)} points${interval(f)}.`);
194
198
  }
195
199
  const n = l.nextRun;
196
200
  if (n) lines.push(`The next run would play ${[plural(n.trials, "trial"), has(n.heldOut) && `${num(n.heldOut)} held out`, has(n.newFromChanges) && `${num(n.newFromChanges)} new from the changes`].filter(Boolean).join(", ")}.`);
package/lib/trace.cjs CHANGED
@@ -16,9 +16,30 @@ if (FILE) {
16
16
  const http = require("node:http");
17
17
  const https = require("node:https");
18
18
  const als = new AsyncLocalStorage();
19
- // Said once, so the command knows a message sent to this app can be seen arriving.
20
- const row = (value) => { try { fs.appendFileSync(FILE, JSON.stringify(value) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ } };
19
+ // Every value this file writes passes one mask, whichever path wrote it: a key inside a value
20
+ // ("sk-...", a bearer token, a JWT) and what anyone wrote after "password is" or "token:". The
21
+ // door row's sign-in headers are kept on purpose: the command replays them from this machine.
22
+ const SECRET_TEXT = [
23
+ /((?:pass(?:word|phrase|wd)|pwd|密码|口令)["']?\s*(?:is\b|was\b|[:=:]|是|为)\s*["']?)([^\s"'\\,;},。;、]+)/gi,
24
+ /((?:secret(?:[ _-]?key)?|api[ _-]?key|apikey|access[ _-]?key|private[ _-]?key|client[ _-]?secret|(?:auth|access|refresh)[ _-]?token|token)["']?\s*[:=:]\s*["']?)([^\s"'\\,;},。;、]+)/gi,
25
+ ];
26
+ const SECRET_WORD = /\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-]*)/g;
27
+ const maskText = (s) => SECRET_TEXT.reduce((t, re) => t.replace(re, "$1[secret]"), s).replace(SECRET_WORD, "[secret]");
28
+ const scrub = (v, depth = 0) => (typeof v === "string" ? maskText(v)
29
+ : Array.isArray(v) ? v.map((x) => scrub(x, depth + 1))
30
+ : v && typeof v === "object" && depth < 12 ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, k === "headers" && depth === 0 ? x : scrub(x, depth + 1)])) : v);
31
+ // A door's body goes up to the run as JSON, so it is masked as JSON: its string values, never its shape.
32
+ const bodyScrubbed = (text) => { try { const b = JSON.parse(text); return b && typeof b === "object" ? JSON.stringify(scrub(b)) : maskText(text); } catch { return maskText(text); } };
33
+ const row = (value) => {
34
+ try {
35
+ const plain = value.hello || value.listen || value.routes;
36
+ const out = plain ? value : scrub(value);
37
+ if (!plain && typeof value.body === "string") out.body = bodyScrubbed(value.body);
38
+ fs.appendFileSync(FILE, JSON.stringify(out) + "\n", { mode: 0o600 });
39
+ } catch { /* the command is gone */ }
40
+ };
21
41
  const isBun = typeof Bun !== "undefined" && typeof Bun.serve === "function";
42
+ // Said once, so the command knows a message sent to this app can be seen arriving.
22
43
  row({ hello: isBun ? "bun" : "node", pid: process.pid });
23
44
  // Which port this process serves. Every process the start command spawns loads this file, turbo's
24
45
  // own launcher included, so "loaded" is not "watching your app": only a listener on the app's port is.
@@ -249,7 +270,10 @@ if (FILE) {
249
270
  // resends whole carries every earlier turn's tool answers too.
250
271
  const list = (v) => (Array.isArray(v) ? v.filter((x) => x && typeof x === "object") : []);
251
272
  const since = (items, isPerson) => { let at = -1; items.forEach((m, i) => { if (isPerson(m)) at = i; }); return items.slice(at + 1); };
252
- const personSaid = (m) => m.role === "user" && !(Array.isArray(m.content) && m.content.some((c) => c && c.type === "tool_result"));
273
+ // A user message that only hands a tool's answer back ("Observation: ...", <tool_response>) is
274
+ // the agent loop talking, not the person.
275
+ const HANDED_BACK = /^\s*(?:Observation\s*:|<(?:tool_response|tool_result|function_results?)>)/i;
276
+ const personSaid = (m) => m.role === "user" && !(Array.isArray(m.content) && m.content.some((c) => c && c.type === "tool_result")) && !HANDED_BACK.test(textOf(m.content));
253
277
  const personAsked = (c) => c.role === "user" && !list(c.parts).some((p) => p.functionResponse);
254
278
  const toolsIn = (sent) => {
255
279
  let body; try { body = JSON.parse(sent); } catch { return undefined; }
@@ -269,6 +293,8 @@ if (FILE) {
269
293
  for (const it of items) if (it.type === "function_call" && it.call_id) names.set(it.call_id, it.name);
270
294
  for (const it of since(items, (x) => x.role === "user")) if (it.type === "function_call_output") add(names.get(it.call_id), it.output);
271
295
  for (const c of since(list(body.contents), personAsked)) for (const p of list(c.parts)) if (p.functionResponse) add(p.functionResponse.name, p.functionResponse.response);
296
+ const declared = declaredIn(body);
297
+ for (const o of observedIn(turnTexts(body, true, declared), declared)) add(o.name, o.text);
272
298
  return out.length ? out : undefined;
273
299
  };
274
300
 
@@ -279,13 +305,13 @@ if (FILE) {
279
305
  const CALLS_MAX = 12, VALUE_MAX = 200, ARGS_MAX = 1200;
280
306
  const SECRET_KEY = /(?:^|_)(?:pass(?:word|phrase)?|secret|token|api_?key|authorization|cookie|session(?:_id)?|credentials?|private_key)$/;
281
307
  const SECRET_VALUE = /^(?:Bearer\s|Basic\s|sk-|pk_|rk_|ghp_|gho_|github_pat_|xox[abpr]-|AKIA|AIza|eyJ[\w-]{10,}\.)/;
282
- const clipped = (v, depth) => {
283
- if (typeof v === "string") return SECRET_VALUE.test(v) ? "[secret]" : v.length > VALUE_MAX ? v.slice(0, VALUE_MAX) + "…" : v;
284
- if (Array.isArray(v)) return depth > 4 ? [] : v.slice(0, 20).map((x) => clipped(x, depth + 1));
308
+ const clipped = (v, depth, max = VALUE_MAX) => {
309
+ if (typeof v === "string") return SECRET_VALUE.test(v) ? "[secret]" : v.length > max ? v.slice(0, max) + "…" : v;
310
+ if (Array.isArray(v)) return depth > 4 ? [] : v.slice(0, 20).map((x) => clipped(x, depth + 1, max));
285
311
  if (!v || typeof v !== "object") return v;
286
312
  const o = {};
287
313
  if (depth > 4) return o;
288
- for (const [k, x] of Object.entries(v).slice(0, 40)) o[k] = SECRET_KEY.test(k.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()) ? "[secret]" : clipped(x, depth + 1);
314
+ for (const [k, x] of Object.entries(v).slice(0, 40)) o[k] = SECRET_KEY.test(k.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()) ? "[secret]" : clipped(x, depth + 1, max);
289
315
  return o;
290
316
  };
291
317
  const argsText = (raw) => {
@@ -342,10 +368,125 @@ if (FILE) {
342
368
  { output: since(list(body.input), (x) => x.role === "user") },
343
369
  ...since(list(body.contents), personAsked).filter((c) => c.role === "model").map((c) => ({ candidates: [{ content: c }] })),
344
370
  ]);
371
+ // Tool use a model writes in its words instead of as a structured call. A ReAct agent (CrewAI,
372
+ // LangChain) writes "Action: name" then "Action Input: {...}" and is handed "Observation: ..."
373
+ // back in its next prompt; others write <tool_call>{...}</tool_call>, <function=name>,
374
+ // <invoke name="..."> or a JSON object that names the tool. A name counts only when the request
375
+ // declares that tool, in its tools field or in the tool list its prompt carries, so a thought, a
376
+ // "Final Answer" or prose that says Action is never a call.
377
+ const FINAL = /^final[\s_-]*answer$/i;
378
+ const TOOL_NAME = /^[A-Za-z_][\w.-]{0,79}$/;
379
+ const promptText = (body) => [
380
+ ...list(body.messages).filter((m) => m.role !== "assistant").map((m) => textOf(m.content)),
381
+ textOf(body.system), textOf(body.instructions), typeof body.prompt === "string" ? body.prompt : "", typeof body.input === "string" ? body.input : "",
382
+ ...list(body.input).filter((x) => x.role && x.role !== "assistant").map((x) => textOf(x.content)),
383
+ textOf(body.systemInstruction && body.systemInstruction.parts), ...list(body.contents).filter((c) => c.role !== "model").map((c) => textOf(c.parts)),
384
+ ].join("\n");
385
+ const declaredIn = (body) => {
386
+ const out = new Set();
387
+ const add = (n) => { const s = typeof n === "string" ? n.trim().replace(/^["'`]+|["'`]+$/g, "") : ""; if (TOOL_NAME.test(s) && !FINAL.test(s)) out.add(s); };
388
+ for (const t of list(body.tools)) { add(t.name); add(t.function && t.function.name); add(t.toolSpec && t.toolSpec.name); for (const d of list(t.functionDeclarations || t.function_declarations)) add(d.name); }
389
+ for (const f of list(body.functions)) add(f.name);
390
+ for (const t of list(body.toolConfig && body.toolConfig.tools)) add(t.toolSpec && t.toolSpec.name);
391
+ const text = promptText(body).slice(0, 200000);
392
+ for (const m of text.matchAll(/^[ \t]*Tool Name:[ \t]*([^\n]+)/gim)) add(m[1]);
393
+ for (const m of text.matchAll(/\b(?:one of|name of|names? from)[ \t]*\[([^\]\n]{1,2000})\]/gi)) for (const x of m[1].split(",")) add(x);
394
+ for (const m of text.matchAll(/valid "?action"? values?:?[ \t]*([^\n]{1,2000})/gi)) for (const x of m[1].split(/,|\bor\b/)) add(x);
395
+ for (const m of text.matchAll(/<(tools|functions)>([\s\S]*?)<\/\1>/gi)) for (const k of m[2].matchAll(/"name"\s*:\s*"([^"]+)"/g)) add(k[1]);
396
+ for (const m of text.matchAll(/"name"\s*:\s*"([^"]+)"\s*,\s*"(?:description|parameters|input_schema)"/g)) add(m[1]);
397
+ for (const m of text.matchAll(/<(?:tool|function)\s+name\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
398
+ return out;
399
+ };
400
+ const REACT = /(?:^|\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|\u0000|$)/gi;
401
+ const TAG_CALL = /<(tool_call|function_call|tool_use)>([\s\S]*?)<\/\1>/gi;
402
+ const FN_TAG = /<function=([\w.-]+)>([\s\S]*?)<\/function>/gi;
403
+ const INVOKE = /<invoke\s+name\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/invoke>/gi;
404
+ const OBSERVED = /(?:^|\n)[ \t>*_]*Observation[ \t*_]*:[ \t]*([\s\S]*?)(?=\n[ \t>*_#]*(?:Thought|Action|Final[ \t]*Answer)\b|\u0000|$)|<(tool_response|tool_result|function_results?|observation)>([\s\S]*?)<\/\2>/gi;
405
+ const paramsOf = (s) => { const o = {}; for (const m of s.matchAll(/<parameter(?:=|\s+name\s*=\s*["'])([\w.-]+)["']?\s*>([\s\S]*?)<\/parameter>/gi)) o[m[1]] = m[2].trim(); return o; };
406
+ // Each balanced JSON object in the text, outermost first; its insides are not read again.
407
+ const objectsIn = (text) => {
408
+ const out = [];
409
+ for (let i = text.indexOf("{"); i !== -1 && out.length < 20; i = text.indexOf("{", i + 1)) {
410
+ let depth = 0, str = false, esc = false, end = -1;
411
+ for (let j = i; j < text.length && j < i + 20000; j++) {
412
+ const ch = text[j];
413
+ if (str) { if (esc) esc = false; else if (ch === "\\") esc = true; else if (ch === '"') str = false; continue; }
414
+ if (ch === '"') str = true; else if (ch === "{") depth++; else if (ch === "}" && --depth === 0) { end = j; break; }
415
+ }
416
+ const v = end === -1 ? null : parse(text.slice(i, end + 1));
417
+ if (v && typeof v === "object") { out.push({ at: i, v }); i = end; }
418
+ }
419
+ return out;
420
+ };
421
+ // An Action Input that opens with a JSON object is that object: a model that runs on past it
422
+ // ("Observ: ...") does not put its own invention into the arguments.
423
+ const leadingJson = (s) => { const t = s.trim(); const o = t[0] === "{" ? objectsIn(t)[0] : null; return o && o.at === 0 ? o.v : t; };
424
+ // A tool's schema carries a description; a call does not.
425
+ const jsonCall = (v) => {
426
+ const f = v.function && typeof v.function === "object" ? v.function : v;
427
+ if ("description" in f) return null;
428
+ const name = [f.name, v.tool, v.tool_name, v.action, typeof v.function === "string" ? v.function : undefined].find((x) => typeof x === "string");
429
+ return name ? { name, args: [f.arguments, f.args, f.parameters, v.action_input, v.tool_input, v.input, v.args, v.arguments, v.parameters].find((x) => x !== undefined) } : null;
430
+ };
431
+ const writtenCalls = (text, declared) => {
432
+ const found = [];
433
+ if (!text || !declared.size) return found;
434
+ const s = text.slice(0, 50000);
435
+ const take = (at, name, args) => { const n = String(name || "").trim(); if (declared.has(n)) found.push({ at, name: n, args: typeof args === "string" ? args.trim() : args }); };
436
+ for (const m of s.matchAll(REACT)) take(m.index, m[1], leadingJson(m[2]));
437
+ for (const m of s.matchAll(TAG_CALL)) { const v = parse(m[2].trim()); const c = v && typeof v === "object" && jsonCall(v); if (c) take(m.index, c.name, c.args); }
438
+ for (const m of s.matchAll(FN_TAG)) { const v = parse(m[2].trim()); take(m.index, m[1], v && typeof v === "object" ? v : paramsOf(m[2])); }
439
+ for (const m of s.matchAll(INVOKE)) take(m.index, m[1], paramsOf(m[2]));
440
+ for (const { at, v } of objectsIn(s)) { const c = jsonCall(v); if (c) take(at, c.name, c.args); }
441
+ return found.sort((a, b) => a.at - b.at);
442
+ };
443
+ // What a tool answered, named by the call written just before it.
444
+ const observedIn = (text, declared) => {
445
+ const s = String(text || "").slice(0, 50000);
446
+ const calls = writtenCalls(s, declared);
447
+ const out = [];
448
+ if (calls.length) for (const m of s.matchAll(OBSERVED)) { const call = calls.filter((c) => c.at < m.index).pop(); if (call) out.push({ name: call.name, text: (m[1] ?? m[3]).trim() }); }
449
+ return out;
450
+ };
451
+ // The model's own words: the text it answered, whole or streamed, in every provider's shape.
452
+ const replyText = (events) => {
453
+ let s = "";
454
+ for (const e of events) {
455
+ if (!e || typeof e !== "object") continue;
456
+ for (const c of list(e.choices)) { if (c.message) s += textOf(c.message.content); if (c.delta && typeof c.delta.content === "string") s += c.delta.content; if (typeof c.text === "string") s += c.text; }
457
+ if (e.type === "response.output_text.delta" && typeof e.delta === "string") s += e.delta;
458
+ for (const it of [...list(e.output), ...list(e.response && e.response.output)]) if (it.type === "message") for (const p of list(it.content)) if (typeof p.text === "string") s += p.text;
459
+ if (e.type === "content_block_delta" && e.delta && typeof e.delta.text === "string") s += e.delta.text;
460
+ for (const b of [...list(e.content), ...list(e.message && e.message.content), ...list(e.output && e.output.message && e.output.message.content)]) if (typeof b.text === "string") s += b.text;
461
+ if (e.message && typeof e.message.content === "string" && !e.choices) s += e.message.content;
462
+ for (const c of list(e.candidates)) for (const p of list(c.content && c.content.parts)) if (typeof p.text === "string" && !p.thought) s += p.text;
463
+ if (typeof e.response === "string") s += e.response;
464
+ }
465
+ return s;
466
+ };
467
+ // This turn as the prompt carries it: the model's earlier words after the person's latest
468
+ // message, and everything from that message on, where a single-prompt agent keeps its scratchpad.
469
+ // A user message right after the model wrote a tool call is the agent loop's nudge ("Analyze the
470
+ // tool result"), never the person: after a call, only the loop speaks.
471
+ const turnTexts = (body, withPerson, declared) => {
472
+ const from = (items, isPerson) => { const at = items.findLastIndex(isPerson); return items.slice(withPerson ? Math.max(0, at) : at + 1); };
473
+ const ours = (r) => withPerson ? r !== "system" && r !== "developer" : r === "assistant" || r === "model";
474
+ const ms = list(body.messages);
475
+ const person = (m) => personSaid(m) && !(ms[ms.indexOf(m) - 1] && ms[ms.indexOf(m) - 1].role === "assistant" && writtenCalls(textOf(ms[ms.indexOf(m) - 1].content), declared).length);
476
+ return [
477
+ ...from(ms, person).filter((m) => ours(m.role)).map((m) => textOf(m.content)),
478
+ ...from(list(body.input), (x) => x.role === "user").filter((x) => ours(x.role)).map((x) => textOf(x.content ?? x.output)),
479
+ ...from(list(body.contents), personAsked).filter((c) => ours(c.role)).map((c) => textOf(c.parts)),
480
+ ...(withPerson ? [typeof body.prompt === "string" ? body.prompt : "", typeof body.input === "string" ? body.input : ""] : []),
481
+ ].join("\u0000\n");
482
+ };
483
+
345
484
  const calledIn = (sent, events) => {
346
485
  const body = parse(sent);
347
486
  const out = [];
348
- for (const c of [...(body && typeof body === "object" ? calledBefore(body) : []), ...callsOf(events)]) {
487
+ const declared = body && typeof body === "object" ? declaredIn(body) : new Set();
488
+ const written = [...writtenCalls(body && typeof body === "object" ? turnTexts(body, false, declared) : "", declared), ...writtenCalls(replyText(events), declared)].map((c) => ({ name: c.name, arguments: argsText(c.args) }));
489
+ for (const c of [...(body && typeof body === "object" ? calledBefore(body) : []), ...callsOf(events), ...written]) {
349
490
  if (out.length < CALLS_MAX && !out.some((o) => o.name === c.name && o.arguments === c.arguments)) out.push(c);
350
491
  }
351
492
  return out.length ? out : undefined;
@@ -422,33 +563,6 @@ if (FILE) {
422
563
  walk(parse(raw), "", 0);
423
564
  return out.length ? out : undefined;
424
565
  };
425
- // A framework that folds its tools' answers into the next prompt as plain text (an agent loop
426
- // writing "Observation: ..." into a user message) sends no tool message to find. Within one
427
- // turn, what the second call's prompt carries that the first one did not, outside the model's
428
- // own words, is that material. The first call of a turn holds the customer's message and the
429
- // system prompt, and both are in every later call, so neither is ever counted.
430
- const textsOf = (body) => {
431
- const out = new Set();
432
- const take = (v) => { const t = textOf(v).trim(); if (t) out.add(t.slice(0, TOOL_TEXT)); };
433
- for (const m of Array.isArray(body.messages) ? body.messages : []) if (m && typeof m === "object" && m.role !== "assistant") take(m.content);
434
- for (const it of Array.isArray(body.input) ? body.input : []) if (it && typeof it === "object" && it.role !== "assistant" && it.type !== "function_call") take(it.content ?? it.output);
435
- if (typeof body.system === "string") take(body.system);
436
- for (const c of Array.isArray(body.contents) ? body.contents : []) if (c && c.role !== "model") for (const p of Array.isArray(c.parts) ? c.parts : []) if (p && typeof p.text === "string") take(p.text);
437
- return out;
438
- };
439
- const seenByTurn = new Map();
440
- const materialIn = (sent, turn) => {
441
- if (!turn) return undefined;
442
- let body; try { body = JSON.parse(sent); } catch { return undefined; }
443
- if (!body || typeof body !== "object") return undefined;
444
- const now = textsOf(body);
445
- const before = seenByTurn.get(turn);
446
- seenByTurn.set(turn, now);
447
- if (seenByTurn.size > 200) seenByTurn.delete(seenByTurn.keys().next().value);
448
- if (!before) return undefined;
449
- const fresh = [...now].filter((t) => !before.has(t)).slice(0, TOOLS_MAX).map((t) => ({ name: "", text: t }));
450
- return fresh.length ? fresh : undefined;
451
- };
452
566
  const rulesIn = (sent) => {
453
567
  const list = rulesNow();
454
568
  if (!list) return undefined;
@@ -482,7 +596,7 @@ if (FILE) {
482
596
  const emit = http.Server.prototype.emit;
483
597
  http.Server.prototype.emit = function (type, req, ...rest) {
484
598
  if (type === "listening") { try { listening(this.address()?.port); } catch { /* not a TCP server */ } }
485
- if (type === "request") recheckRoutes();
599
+ if (type === "request") { recheckRoutes(); sweepTools(); }
486
600
  if (type !== "request" || !req || !req.method || /^(?:GET|HEAD|OPTIONS)$/.test(req.method)) return emit.call(this, type, req, ...rest);
487
601
  const { "x-cortad-turn": _turn, ...headers } = req.headers;
488
602
  const ctx = { method: req.method, path: req.url || "/", headers, chunks: [], size: 0, noted: false, turn: turnOf(req.headers) };
@@ -518,6 +632,7 @@ if (FILE) {
518
632
  if (isBun) {
519
633
  const inside = (handler, self) => function (req, server) {
520
634
  recheckRoutes();
635
+ sweepTools();
521
636
  if (!req || /^(?:GET|HEAD|OPTIONS)$/.test(req.method)) return handler.call(self, req, server);
522
637
  let path = "/";
523
638
  try { const u = new URL(req.url); path = u.pathname + u.search; } catch { /* keep "/" */ }
@@ -615,11 +730,10 @@ if (FILE) {
615
730
  try {
616
731
  const reply = readReply(type, String(body || "").slice(0, REPLY_MAX));
617
732
  const rules = rulesIn(sent);
618
- const tools = toolsIn(sent) ?? materialIn(sent, turn);
733
+ const tools = toolsIn(sent);
619
734
  const called = calledIn(sent, reply.events);
620
735
  const passages = passagesIn(sent);
621
- const row = { at: Date.now(), host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}) };
622
- fs.appendFileSync(FILE, JSON.stringify({ call: row }) + "\n", { mode: 0o600 });
736
+ row({ call: { at: Date.now(), host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}) } });
623
737
  } catch { /* the command is gone */ }
624
738
  };
625
739
 
@@ -645,7 +759,161 @@ if (FILE) {
645
759
  } catch { return null; }
646
760
  };
647
761
  const depRow = (dep, status, code, passages, turn = (als.getStore() || {}).turn) => {
648
- try { fs.appendFileSync(FILE, JSON.stringify({ dep: { at: Date.now(), ...(dep.env ? { env: dep.env } : {}), host: dep.host, status, code: code ? String(code).slice(0, 40) : undefined, ...(turn ? { turn } : {}), ...(passages ? { passages } : {}) } }) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ }
762
+ row({ dep: { at: Date.now(), ...(dep.env ? { env: dep.env } : {}), host: dep.host, status, code: code ? String(code).slice(0, 40) : undefined, ...(turn ? { turn } : {}), ...(passages ? { passages } : {}) } });
763
+ };
764
+
765
+ // The app's own functions the read names as its tools. An app whose code picks the tool itself
766
+ // (a classifier answers {"intent":"order"} and the code calls queryOrder) never names one on the
767
+ // wire, so the function is wrapped where it is defined: each call writes its name, its clipped
768
+ // arguments and what it returned, on the turn it ran in. The run writes the list beside the rules
769
+ // and the command keeps the last one, so an app started again has it before its code loads.
770
+ const TOOLS_FILE = process.env.CORTAD_TOOLS_FILE;
771
+ const CODE_FILE = /\.(?:[cm]?[jt]sx?)$/;
772
+ const IDENT = /^[A-Za-z_$][\w$]*$/;
773
+ let targets = [], targetsAt = -1;
774
+ const targetsNow = () => {
775
+ if (!TOOLS_FILE) return targets;
776
+ try {
777
+ const at = fs.statSync(TOOLS_FILE).mtimeMs;
778
+ if (at !== targetsAt) {
779
+ targetsAt = at;
780
+ const raw = JSON.parse(fs.readFileSync(TOOLS_FILE, "utf8"));
781
+ targets = list(raw).filter((t) => typeof t.name === "string" && t.name && typeof t.file === "string" && CODE_FILE.test(t.file) && IDENT.test(String(t.function)) && !(t.function in globalThis))
782
+ .map((t) => ({ name: t.name.slice(0, 80), file: t.file.replace(/^\.?\/+/, ""), fn: t.function }))
783
+ // One function named twice (by its own name, and as "queryOrder (fallback)" where a
784
+ // router imported it) is recorded under its own name.
785
+ .sort((a, b) => (a.name !== a.fn) - (b.name !== b.fn));
786
+ }
787
+ } catch { /* not written yet */ }
788
+ return targets;
789
+ };
790
+ const targetsIn = (path) => targetsNow().filter((t) => path === t.file || path.endsWith("/" + t.file));
791
+ const unsaid = new Set();
792
+ const unwatched = (t, why) => {
793
+ const key = `${t.file}#${t.fn}`;
794
+ if (unsaid.has(key)) return;
795
+ unsaid.add(key);
796
+ try { process.stderr.write(`cortad: calls to ${t.name} in ${t.file} are not recorded: ${why}.\n`); } catch { /* no stderr */ }
797
+ };
798
+ // What a value is, as JSON can say it: a class instance (a client, a model) by its name only.
799
+ const plainOf = (v, depth = 0) => {
800
+ if (v === null || ["string", "number", "boolean"].includes(typeof v)) return v;
801
+ if (typeof v === "bigint") return String(v);
802
+ if (typeof v !== "object" || depth > 4) return v === undefined ? null : `[${typeof v}]`;
803
+ if (Array.isArray(v)) return v.slice(0, 20).map((x) => plainOf(x, depth + 1));
804
+ if (typeof v.toJSON === "function") { try { return plainOf(v.toJSON(), depth + 1); } catch { return "[object]"; } }
805
+ const proto = Object.getPrototypeOf(v);
806
+ if (proto !== Object.prototype && proto !== null) return `[${(v.constructor && v.constructor.name) || "object"}]`;
807
+ return Object.fromEntries(Object.entries(v).slice(0, 40).map(([k, x]) => [k, plainOf(x, depth + 1)]));
808
+ };
809
+ // The returned value's shape and a clipped text: a string as it is, anything else named by kind.
810
+ const returned = (v) => {
811
+ if (v === undefined || v === null) return "returned nothing";
812
+ if (typeof v === "string") return v;
813
+ const p = clipped(plainOf(v), 0, TOOL_TEXT);
814
+ const kind = Array.isArray(v) ? `list of ${v.length}` : typeof v === "object" ? "object" : "value";
815
+ return `${kind}: ${JSON.stringify(p)}`;
816
+ };
817
+ const paramNames = (fn) => {
818
+ let src = "";
819
+ try { src = Function.prototype.toString.call(fn); } catch { return []; }
820
+ const m = /^[^(=]*\(([^)]*)\)/.exec(src) || /^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*=>/.exec(src);
821
+ if (!m || /[{[(]/.test(m[1])) return [];
822
+ return m[1].split(",").map((p) => p.replace(/=[\s\S]*$/, "").replace(/^\s*\.\.\./, "").replace(/:[\s\S]*$/, "").trim());
823
+ };
824
+ const toolRow = (name, names, args, value, error, turn) => {
825
+ const named = {};
826
+ args.forEach((a, i) => { named[IDENT.test(names[i] || "") ? names[i] : String(i)] = plainOf(a); });
827
+ let text;
828
+ try { text = error ? `raised ${(error && error.name) || "an error"}: ${(error && error.message) || String(error)}` : returned(value); } catch { text = "returned a value this hook cannot read"; }
829
+ row({ dep: { at: Date.now(), host: "in-app", status: 200, ...(turn ? { turn } : {}), called: [{ name, arguments: argsText(named) }], tools: [{ name, text: String(text).slice(0, TOOL_TEXT) }] } });
830
+ };
831
+ const TOOL = Symbol.for("cortad.wrapped");
832
+ const wrappers = new WeakMap();
833
+ const wrapTool = (fn, name) => {
834
+ if (typeof fn !== "function" || fn[TOOL]) return fn;
835
+ if (wrappers.has(fn)) return wrappers.get(fn);
836
+ try { if (/^class\b/.test(Function.prototype.toString.call(fn))) return fn; } catch { return fn; }
837
+ const names = paramNames(fn);
838
+ const w = function (...args) {
839
+ const turn = (als.getStore() || {}).turn;
840
+ let out;
841
+ try { out = new.target ? Reflect.construct(fn, args, new.target) : fn.apply(this, args); }
842
+ catch (e) { toolRow(name, names, args, undefined, e, turn); throw e; }
843
+ // A promise is followed, and the one handed back is a new one, so a rejection nobody
844
+ // handles still reaches the app as unhandled.
845
+ if (out instanceof Promise) return out.then((v) => { toolRow(name, names, args, v, undefined, turn); return v; }, (e) => { toolRow(name, names, args, undefined, e, turn); throw e; });
846
+ toolRow(name, names, args, out, undefined, turn);
847
+ return out;
848
+ };
849
+ try {
850
+ Object.defineProperty(w, "name", { value: fn.name });
851
+ Object.defineProperty(w, "length", { value: fn.length });
852
+ Object.setPrototypeOf(w, Object.getPrototypeOf(fn));
853
+ if (fn.prototype) w.prototype = fn.prototype;
854
+ for (const k of Object.keys(fn)) w[k] = fn[k];
855
+ } catch { /* the wrapper still calls through */ }
856
+ w[TOOL] = fn;
857
+ wrappers.set(fn, w);
858
+ return w;
859
+ };
860
+ // Called from the lines added to the end of a tool's file: the function by its own binding
861
+ // (an ES module's importers follow that binding) and by its CommonJS export.
862
+ const take = (fn, set, mod, t) => {
863
+ let done = false;
864
+ if (typeof fn === "function") { try { set(wrapTool(fn, t.name)); done = true; } catch { /* a binding that cannot change */ } }
865
+ const ex = mod && mod.exports;
866
+ if (ex && (typeof ex === "object" || typeof ex === "function") && typeof ex[t.fn] === "function") { try { ex[t.fn] = wrapTool(ex[t.fn], t.name); done = true; } catch { /* read-only */ } }
867
+ if (!done) unwatched(t, fn === undefined && !(ex && t.fn in ex) ? `the file has no function named ${t.fn}` : `${t.fn} there is not a function this hook can wrap`);
868
+ };
869
+ Object.defineProperty(globalThis, Symbol.for("cortad.tool"), { value: take, configurable: true });
870
+ const trailer = (text, mine) => {
871
+ let src = text;
872
+ // A const binding cannot be changed from inside the file; a let can, and nothing else differs.
873
+ for (const t of mine) src = src.replace(new RegExp(`(^|\\n)([ \\t]*(?:export[ \\t]+)?)const([ \\t]+${t.fn.replace(/\$/g, "\\$")}[ \\t]*[=:])`), "$1$2let$3");
874
+ const hook = 'globalThis[Symbol.for("cortad.tool")]';
875
+ return `${src}\n;${mine.map((t) => `try{${hook}(typeof ${t.fn}==="undefined"?undefined:${t.fn},(f)=>{${t.fn}=f},typeof module==="object"&&module?module:undefined,${JSON.stringify(t)})}catch{}`).join("")}\n`;
876
+ };
877
+ // Loading: a tool's file gets the lines above before it runs. Every other file is untouched.
878
+ const seenFiles = new Set();
879
+ const loadedFiles = new Set();
880
+ const { fileURLToPath } = require("node:url");
881
+ if (TOOLS_FILE && typeof Module.registerHooks === "function") {
882
+ try {
883
+ Module.registerHooks({
884
+ load(url, context, nextLoad) {
885
+ const out = nextLoad(url, context);
886
+ try {
887
+ if (typeof url !== "string" || !url.startsWith("file:") || url.includes("/node_modules/")) return out;
888
+ const path = fileURLToPath(url);
889
+ loadedFiles.add(path);
890
+ const mine = targetsIn(path);
891
+ if (!mine.length || out.source == null) return out;
892
+ seenFiles.add(path);
893
+ return { ...out, source: trailer(typeof out.source === "string" ? out.source : Buffer.from(out.source).toString("utf8"), mine) };
894
+ } catch { return out; }
895
+ },
896
+ });
897
+ } catch { /* this runtime has no module hooks */ }
898
+ }
899
+ // A list that arrives after the app loaded its tools: a CommonJS export is wrapped in place, which
900
+ // reaches code that calls it through the module, but a function already taken by name cannot be
901
+ // reached, so it is said once in the app's log. The command keeps the list for the next start.
902
+ let sweptAt = -1;
903
+ const sweepTools = () => {
904
+ if (!TOOLS_FILE) return;
905
+ const now = targetsNow();
906
+ if (targetsAt === sweptAt) return;
907
+ sweptAt = targetsAt;
908
+ const cache = require.cache || {};
909
+ for (const t of now) {
910
+ const mine = (k) => (k === t.file || k.endsWith("/" + t.file)) && !k.includes("/node_modules/");
911
+ const path = Object.keys(cache).find(mine) || [...loadedFiles].find(mine);
912
+ if (!path || seenFiles.has(path)) continue;
913
+ const ex = cache[path] && cache[path].exports;
914
+ if (ex && typeof ex[t.fn] === "function") { try { ex[t.fn] = wrapTool(ex[t.fn], t.name); } catch { /* read-only */ } }
915
+ unwatched(t, "the file was loaded before the list of tools arrived, and they are recorded from the app's next start");
916
+ }
649
917
  };
650
918
 
651
919
  // Outbound: the model call your app makes while it handles that request.
package/local.mjs CHANGED
@@ -15,13 +15,13 @@
15
15
  import { holdsKeys, secretEnvValues } from "./lib/keys.mjs";
16
16
  import { spawn, execFile, execFileSync } from "node:child_process";
17
17
  import { createHash } from "node:crypto";
18
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
18
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
19
19
  import { homedir, hostname, tmpdir } from "node:os";
20
20
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
21
21
  import { createInterface } from "node:readline";
22
22
  import { promisify } from "node:util";
23
23
  import { COMMANDS, main as face } from "./lib/cli.mjs";
24
- import { clearRunner, projectOf, readToken, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
24
+ import { clearRunner, homeOf, projectOf, readToken, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
25
25
  import { registerAll } from "./lib/register.mjs";
26
26
  import { finished } from "./lib/text.mjs";
27
27
  import { lockHolds, makeLock } from "./lib/lock.mjs";
@@ -420,7 +420,14 @@ async function verb(job) {
420
420
  // machine writes your files; the agent that edits them is your own.
421
421
  const mine = scratch(b.path);
422
422
  if (!mine) return { success: false, stderr: "this program does not write your files" };
423
- try { mkdirSync(dirname(mine), { recursive: true }); if (b.append) appendFileSync(mine, bytes); else writeFileSync(mine, bytes); return { success: true, stderr: "" }; }
423
+ try {
424
+ mkdirSync(dirname(mine), { recursive: true });
425
+ if (b.append) appendFileSync(mine, bytes); else writeFileSync(mine, bytes);
426
+ // The read's tools, kept for this project too: the next start hands them to the hook before
427
+ // the app loads its code, which a Node app needs to have its tool functions wrapped.
428
+ if (mine === join(work, "tools.json")) { try { mkdirSync(homeOf(project), { recursive: true, mode: 0o700 }); writeFileSync(join(homeOf(project), "tools.json"), bytes, { mode: 0o600 }); } catch { /* kept for this session only */ } }
429
+ return { success: true, stderr: "" };
430
+ }
424
431
  catch (e) { return { success: false, stderr: String(e.message) }; }
425
432
  }
426
433
  case "mint": return identities ? JSON.parse(mask(JSON.stringify(await mintAcross({
@@ -813,11 +820,20 @@ async function restartNow() {
813
820
  if (!launched || !child) return { error: "You started this app yourself, so restart it in your own terminal. Most dev servers reload on save." };
814
821
  const port = app.port;
815
822
  await stopApp(child.pid);
816
- for (let i = 0; i < 25 && (await answers(port)); i++) await new Promise((r) => setTimeout(r, 200));
823
+ // Until the port is free, not merely silent: a listener still closing does not answer and still
824
+ // holds the bind, and the restarted app was moved to the next port while the run kept knocking on
825
+ // this one (resumeforge's verify played seven trials against nothing).
826
+ for (let i = 0; i < 75 && (await listenerOn(port)); i++) await new Promise((r) => setTimeout(r, 200));
817
827
  const up = await launch(90_000);
818
828
  if (!up.port) return { error: up.exited !== null ? `Your app exited ${up.exited} on restart.\n${up.tail}` : "Your app was restarted but did not answer within 90 seconds." };
819
- if (up.port !== port) return { error: `Your app came back on port ${up.port}, not ${port}. Run the command again to reconnect.` };
820
- return { restarted: true, port };
829
+ if (up.port !== port) {
830
+ // It moved anyway: the world follows the app, never the other way round.
831
+ app = up;
832
+ const told = await announce().catch(() => null);
833
+ if (!told?.ok) return { error: `Your app came back on port ${up.port}, not ${port}. Run the command again to reconnect.` };
834
+ say(`your app came back on port ${up.port}; the run follows it there`);
835
+ }
836
+ return { restarted: true, port: up.port };
821
837
  }
822
838
  // TCP ports your app is listening on: every process descended from the one this program started.
823
839
  // By descent, not by process group: nodemon, pm2 and concurrently put the real server in a group of
@@ -901,6 +917,7 @@ if (!files.length) fail("no source files here to read.");
901
917
  // Which project this is, as a hash of where it lives: the same folder coming back resumes the same
902
918
  // connection, and the path itself never leaves this machine.
903
919
  const project = projectOf(root);
920
+ try { copyFileSync(join(homeOf(project), "tools.json"), join(work, "tools.json")); } catch { /* no run has read this project's tools yet */ }
904
921
  // The key the last connect left for this project, if any: the token face signs in with it. A connect
905
922
  // from the screen always asks for a fresh one, since the code may belong to another account or site.
906
923
  const stored = readToken(project);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.1-rc.2",
3
+ "version": "0.3.1-rc.4",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"
package/skill/SKILL.md CHANGED
@@ -56,6 +56,7 @@ Take the person through them worst first, each with its file and line. A long li
56
56
  - `no change` or `regressed`: the file goes back to how it was.
57
57
  - `inside the noise` or `unsettled`: the trials cannot tell yet, which is not a verdict against the change. `verify` again to add readings; a change the app's own tests support may stay, said as not yet verified by Cortad.
58
58
  - A verify that played no trial says so and compares nothing; the file stays as it was left.
59
+ - `Verification unavailable:` the two runs read the question on no common trial, and the line says why (the replay took another turn, the trial halted, the question's condition did not hold). It is not a verdict: the file stays, said as not yet verified by Cortad.
59
60
  - An `Overfit:` line: the visible trials moved and the held-out trials stayed where they were, so the change fits the trials it could see. The file goes back, and the next change aims at the behavior the question asks about.
60
61
  - `regressed`: the file goes back.
61
62
  4. Then the next finding.
@@ -155,6 +155,17 @@ For the person: the report is at https://cortad.com/lab
155
155
  next: findings
156
156
  ```
157
157
 
158
+ A verify with no pair: the two runs read the finding's question on no common trial, so there is no move to print, and the line says why. It is not a verdict on the change.
159
+
160
+ ```
161
+ Verify 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a: finished, 1 of 1 trial played.
162
+ Verify of finding:2 at backend/tools/refunds.py.
163
+ Held-out trials: no pair for this question.
164
+ Verification unavailable: the replay took a different turn at reply 2, and the question was not asked on the replay because its condition did not hold (the customer has reached this step of the journey: Agent states amount, asks consent). This is not a verdict on the change either way: the two runs share no reading of this question to compare.
165
+ For the person: the report is at https://cortad.com/lab
166
+ next: findings
167
+ ```
168
+
158
169
  ## findings
159
170
 
160
171
  Worst first, grouped by the line the rule lives at. A list longer than one page ends with `page 1 of 3, call findings with page 2`.