cortad 0.3.1-rc.3 → 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.
- package/lib/pyhook/sitecustomize.py +446 -52
- package/lib/replay.mjs +6 -1
- package/lib/text.mjs +7 -3
- package/lib/trace.cjs +308 -40
- package/local.mjs +11 -3
- package/package.json +1 -1
- package/skill/SKILL.md +1 -0
- package/skill/references/results.md +11 -0
|
@@ -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[:
|
|
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
|
-
|
|
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
|
|
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)
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
//
|
|
20
|
-
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
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)
|
|
733
|
+
const tools = toolsIn(sent);
|
|
619
734
|
const called = calledIn(sent, reply.events);
|
|
620
735
|
const passages = passagesIn(sent);
|
|
621
|
-
|
|
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
|
-
|
|
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 {
|
|
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({
|
|
@@ -910,6 +917,7 @@ if (!files.length) fail("no source files here to read.");
|
|
|
910
917
|
// Which project this is, as a hash of where it lives: the same folder coming back resumes the same
|
|
911
918
|
// connection, and the path itself never leaves this machine.
|
|
912
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 */ }
|
|
913
921
|
// The key the last connect left for this project, if any: the token face signs in with it. A connect
|
|
914
922
|
// from the screen always asks for a fresh one, since the code may belong to another account or site.
|
|
915
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.
|
|
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`.
|