@pentatonic-ai/ai-agent-sdk 0.10.19 → 0.10.21
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/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/packages/memory-engine-v2/RFC-decay-and-fusion.md +122 -8
- package/packages/memory-engine-v2/compat/server.py +55 -10
- package/packages/memory-engine-v2/extractor-async/test_email_alias_guard.py +78 -0
- package/packages/memory-engine-v2/extractor-async/worker.py +52 -0
- package/packages/memory-engine-v2/scripts/build_retrain_corpus.py +240 -0
- package/packages/memory-engine-v2/scripts/fusion_defrag.py +440 -0
- package/packages/memory-engine-v2/scripts/redistill.py +236 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prompt-version-drift re-distillation — RFC-decay-and-fusion Part B′.
|
|
3
|
+
|
|
4
|
+
The third triage verb of the Fusion Drive: regenerate high-value extractions
|
|
5
|
+
that were produced by a *superseded* teacher/prompt by re-running their source
|
|
6
|
+
event through the *current clean* teacher. Fusion converges horizontally, decay
|
|
7
|
+
ages vertically; this refreshes in depth.
|
|
8
|
+
|
|
9
|
+
TRIGGER (not raw age): an event is stale when `event_distillations` shows it was
|
|
10
|
+
distilled only under a dirty `system_prompt_hash` (pre-clean: bbdaba/f1e0ff/
|
|
11
|
+
ef0647…) and never under the clean one (`6ccfe70f…`, SDK 0.10.19 = #126 + #129).
|
|
12
|
+
|
|
13
|
+
MECHANISM: re-insert the event into `distillation_queue` (status=pending). The
|
|
14
|
+
existing extractor-async worker + autoscaler do the rest, writing a fresh clean
|
|
15
|
+
extraction AND a clean trace (which `build_retrain_corpus.py` then harvests — one
|
|
16
|
+
loop repairs the graph and grows the retrain corpus).
|
|
17
|
+
|
|
18
|
+
SUPERSEDENCE (the load-bearing requirement — Part B′4): the store is
|
|
19
|
+
pure-accretion, so a naive re-enqueue makes the clean extraction land *beside*
|
|
20
|
+
the dirty one and WORSENS fragmentation. The durable fix routes supersede
|
|
21
|
+
through Fusion's tombstone machinery (RFC A2/A3), which is not yet live. This
|
|
22
|
+
tool ships the **interim event-scoped supersede** (Part B′4 Open Q): with
|
|
23
|
+
--supersede-facts it deletes only facts whose provenance set is exactly
|
|
24
|
+
{this event} under the old hash — the single-provenance majority, where no
|
|
25
|
+
corroboration is lost — after dumping a rollback payload. Multi-provenance facts
|
|
26
|
+
and ALL entities are left untouched (entity-level fragmentation is fusion/decay's
|
|
27
|
+
job; deleting an entity cascades to relationships and NULLs other facts' FKs).
|
|
28
|
+
|
|
29
|
+
SAFETY: dry-run by default; --apply gated; --arena REQUIRED (no global runs);
|
|
30
|
+
--limit caps the batch; every destructive op is dumped to a rollback JSONL first;
|
|
31
|
+
a run ledger is written per invocation. Idempotent: skips events that already
|
|
32
|
+
have a pending/claimed queue row or an existing clean-hash distillation.
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
# size the work (no writes):
|
|
36
|
+
python redistill.py --arena 'pentatonic-team%' --dry-run
|
|
37
|
+
# re-enqueue a bounded slice (non-destructive — accretes until fusion runs):
|
|
38
|
+
python redistill.py --arena 'pentatonic-team%' --limit 25 --apply
|
|
39
|
+
# re-enqueue AND interim-supersede single-provenance stale facts:
|
|
40
|
+
python redistill.py --arena 'pentatonic-team%' --limit 25 --apply --supersede-facts
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import sys
|
|
48
|
+
from datetime import datetime, timezone
|
|
49
|
+
|
|
50
|
+
CLEAN_PROMPT_HASH = "6ccfe70f1286a131" # SDK 0.10.19 (#126 + #129); verify vs worker.SYSTEM_PROMPT_HASH
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _connect(dsn: str):
|
|
54
|
+
import psycopg
|
|
55
|
+
import psycopg.rows
|
|
56
|
+
return psycopg.connect(dsn, row_factory=psycopg.rows.dict_row)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def triage(cur, arena: str, clean_hash: str, dirty_hashes: list[str] | None, limit: int,
|
|
60
|
+
order_by_recency: bool = False):
|
|
61
|
+
"""Stale events: distilled under a dirty prompt, not yet re-distilled clean.
|
|
62
|
+
|
|
63
|
+
IDEMPOTENCY SIGNAL = `distillation_traces.system_prompt_hash`, NOT
|
|
64
|
+
`event_distillations`. The teacher ALWAYS writes a trace (worker.py
|
|
65
|
+
`_insert_trace`, producer=='teacher'), but `event_distillations` is written
|
|
66
|
+
ONLY when CASCADE_ENABLED (worker.py line ~2325). In teacher-only mode a
|
|
67
|
+
re-distilled event gets a clean *trace* but its event_distillations row stays
|
|
68
|
+
stamped at the old student hash — so keying the clean-check off the ledger
|
|
69
|
+
would re-select the same events forever. Traces are the reliable per-event
|
|
70
|
+
prompt-version record here.
|
|
71
|
+
|
|
72
|
+
Dirty evidence = a dirty-hash trace OR a dirty-hash event_distillations row
|
|
73
|
+
(covers student-distilled events, which have a ledger row but no trace)."""
|
|
74
|
+
params: list = [arena, clean_hash] # arena, clean (no-clean-trace check)
|
|
75
|
+
dirty_trace = "AND t2.system_prompt_hash <> %s"
|
|
76
|
+
params_tail_dirty: list = [clean_hash]
|
|
77
|
+
if dirty_hashes:
|
|
78
|
+
dirty_trace = "AND t2.system_prompt_hash = ANY(%s)"
|
|
79
|
+
params_tail_dirty = [dirty_hashes]
|
|
80
|
+
# Build param order to match placeholders below.
|
|
81
|
+
params = [arena, clean_hash] + params_tail_dirty + [clean_hash, limit]
|
|
82
|
+
# ORDER BY received_at forces a full scan+sort of the whole arena (~270k rows
|
|
83
|
+
# for pentatonic-team) every call — pathological for bulk/loop work. Default
|
|
84
|
+
# OFF so the planner early-terminates once LIMIT stale rows are found (most
|
|
85
|
+
# arena rows ARE stale, so it scans ~LIMIT rows). Recency is only a salience
|
|
86
|
+
# proxy anyway, moot until the salience column lands (RFC Part D). Opt in with
|
|
87
|
+
# --order-by-recency for a one-off "freshest first" pass.
|
|
88
|
+
order_clause = "ORDER BY e.received_at DESC" if order_by_recency else ""
|
|
89
|
+
cur.execute(
|
|
90
|
+
f"""
|
|
91
|
+
WITH stale AS (
|
|
92
|
+
SELECT e.id AS event_id, e.received_at AS recency
|
|
93
|
+
FROM events e
|
|
94
|
+
WHERE e.arena LIKE %s
|
|
95
|
+
AND NOT EXISTS ( -- not yet re-distilled clean
|
|
96
|
+
SELECT 1 FROM distillation_traces t
|
|
97
|
+
WHERE t.event_id = e.id AND t.system_prompt_hash = %s)
|
|
98
|
+
AND NOT EXISTS ( -- not already queued (so loops/batches advance)
|
|
99
|
+
SELECT 1 FROM distillation_queue q
|
|
100
|
+
WHERE q.event_id = e.id AND q.status IN ('pending','claimed'))
|
|
101
|
+
AND (
|
|
102
|
+
EXISTS (SELECT 1 FROM distillation_traces t2 -- dirty teacher trace
|
|
103
|
+
WHERE t2.event_id = e.id {dirty_trace})
|
|
104
|
+
OR EXISTS (SELECT 1 FROM event_distillations d -- or dirty student/teacher ledger
|
|
105
|
+
WHERE d.event_id = e.id
|
|
106
|
+
AND d.system_prompt_hash IS NOT NULL
|
|
107
|
+
AND d.system_prompt_hash <> %s)
|
|
108
|
+
)
|
|
109
|
+
{order_clause}
|
|
110
|
+
LIMIT %s
|
|
111
|
+
)
|
|
112
|
+
SELECT s.event_id,
|
|
113
|
+
-- `@> ARRAY[id]` (containment) uses idx_facts_provenance (GIN);
|
|
114
|
+
-- `id = ANY(col)` does NOT and seq-scans facts per row (2 min+ at
|
|
115
|
+
-- limit 1000). Must stay @> for the bulk/loop path to be viable.
|
|
116
|
+
(SELECT count(*) FROM facts f
|
|
117
|
+
WHERE f.provenance_event_ids @> ARRAY[s.event_id]) AS fact_n,
|
|
118
|
+
(SELECT count(*) FROM facts f
|
|
119
|
+
WHERE f.provenance_event_ids @> ARRAY[s.event_id]
|
|
120
|
+
AND cardinality(f.provenance_event_ids) = 1) AS solo_fact_n,
|
|
121
|
+
EXISTS (SELECT 1 FROM distillation_queue q
|
|
122
|
+
WHERE q.event_id = s.event_id
|
|
123
|
+
AND q.status IN ('pending','claimed')) AS in_flight
|
|
124
|
+
FROM stale s
|
|
125
|
+
ORDER BY fact_n DESC
|
|
126
|
+
""",
|
|
127
|
+
params,
|
|
128
|
+
)
|
|
129
|
+
return cur.fetchall()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def dump_and_delete_solo_facts(cur, event_id: str, rollback_fh) -> int:
|
|
133
|
+
"""Delete facts whose provenance is exactly {event_id}; dump them first."""
|
|
134
|
+
solo = "provenance_event_ids @> ARRAY[%s] AND cardinality(provenance_event_ids) = 1"
|
|
135
|
+
cur.execute(f"SELECT * FROM facts WHERE {solo}", (event_id,))
|
|
136
|
+
rows = cur.fetchall()
|
|
137
|
+
for r in rows:
|
|
138
|
+
rollback_fh.write(json.dumps({"op": "delete_fact", "event_id": event_id,
|
|
139
|
+
"row": _jsonable(r)}, ensure_ascii=False) + "\n")
|
|
140
|
+
if rows:
|
|
141
|
+
cur.execute(f"DELETE FROM facts WHERE {solo}", (event_id,))
|
|
142
|
+
return len(rows)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _jsonable(row: dict) -> dict:
|
|
146
|
+
out = {}
|
|
147
|
+
for k, v in row.items():
|
|
148
|
+
out[k] = v.isoformat() if isinstance(v, datetime) else v
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main() -> int:
|
|
153
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
154
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
155
|
+
ap.add_argument("--arena", required=True, help="arena LIKE filter (REQUIRED — scope safety)")
|
|
156
|
+
ap.add_argument("--clean-hash", default=CLEAN_PROMPT_HASH)
|
|
157
|
+
ap.add_argument("--dirty-hashes", help="comma-separated hashes to target (default: any != clean)")
|
|
158
|
+
ap.add_argument("--limit", type=int, default=50, help="max events to re-enqueue (safety cap)")
|
|
159
|
+
ap.add_argument("--apply", action="store_true", help="actually write (default: dry-run)")
|
|
160
|
+
ap.add_argument("--supersede-facts", action="store_true",
|
|
161
|
+
help="also delete single-provenance stale facts (interim supersede, Part B′4)")
|
|
162
|
+
ap.add_argument("--order-by-recency", action="store_true",
|
|
163
|
+
help="freshest stale events first (forces a full arena scan+sort — slow; off by default)")
|
|
164
|
+
ap.add_argument("--rollback-dir", default=".", help="dir for rollback + ledger JSONL")
|
|
165
|
+
ap.add_argument("--pg-dsn", default=os.environ.get("PG_DSN", ""), help="Postgres DSN")
|
|
166
|
+
args = ap.parse_args()
|
|
167
|
+
|
|
168
|
+
if not args.pg_dsn:
|
|
169
|
+
print("FATAL: no --pg-dsn / PG_DSN", file=sys.stderr)
|
|
170
|
+
return 2
|
|
171
|
+
dirty = [h.strip() for h in args.dirty_hashes.split(",")] if args.dirty_hashes else None
|
|
172
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
173
|
+
|
|
174
|
+
with _connect(args.pg_dsn) as conn:
|
|
175
|
+
with conn.cursor() as cur:
|
|
176
|
+
# The org-model postgres container runs with the Docker default 64MB
|
|
177
|
+
# /dev/shm; a parallel-gather over the facts GIN exhausts the dynamic
|
|
178
|
+
# shared-memory segment ("DiskFull ... shared memory"). Disable
|
|
179
|
+
# parallel workers for this session — temp spills go to the (ample)
|
|
180
|
+
# data disk instead of shm.
|
|
181
|
+
cur.execute("SET max_parallel_workers_per_gather = 0")
|
|
182
|
+
cands = triage(cur, args.arena, args.clean_hash, dirty, args.limit,
|
|
183
|
+
order_by_recency=args.order_by_recency)
|
|
184
|
+
|
|
185
|
+
total = len(cands)
|
|
186
|
+
in_flight = sum(1 for c in cands if c["in_flight"])
|
|
187
|
+
actionable = [c for c in cands if not c["in_flight"]]
|
|
188
|
+
solo = sum(c["solo_fact_n"] for c in actionable)
|
|
189
|
+
multi = sum(c["fact_n"] - c["solo_fact_n"] for c in actionable)
|
|
190
|
+
print(json.dumps({
|
|
191
|
+
"mode": "apply" if args.apply else "dry-run",
|
|
192
|
+
"arena": args.arena, "clean_hash": args.clean_hash,
|
|
193
|
+
"candidates": total, "in_flight_skipped": in_flight,
|
|
194
|
+
"actionable": len(actionable),
|
|
195
|
+
"stale_facts_solo_provenance": solo,
|
|
196
|
+
"stale_facts_multi_provenance_LEFT_ALONE": multi,
|
|
197
|
+
"supersede_facts": args.supersede_facts,
|
|
198
|
+
}, indent=2))
|
|
199
|
+
|
|
200
|
+
if not args.apply:
|
|
201
|
+
print("\n[dry-run] no writes. Re-run with --apply to re-enqueue"
|
|
202
|
+
+ (" + supersede solo facts." if args.supersede_facts else "."))
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
rb_path = os.path.join(args.rollback_dir, f"redistill_rollback_{stamp}.jsonl")
|
|
206
|
+
ledger_path = os.path.join(args.rollback_dir, f"redistill_runs_{stamp}.jsonl")
|
|
207
|
+
enq = deleted = 0
|
|
208
|
+
with open(rb_path, "w", encoding="utf-8") as rb, \
|
|
209
|
+
open(ledger_path, "w", encoding="utf-8") as led:
|
|
210
|
+
for c in actionable:
|
|
211
|
+
eid = c["event_id"]
|
|
212
|
+
if args.supersede_facts:
|
|
213
|
+
deleted += dump_and_delete_solo_facts(cur, eid, rb)
|
|
214
|
+
cur.execute(
|
|
215
|
+
"INSERT INTO distillation_queue (event_id, status, attempts) "
|
|
216
|
+
"VALUES (%s, 'pending', 0)", (eid,))
|
|
217
|
+
enq += 1
|
|
218
|
+
led.write(json.dumps({
|
|
219
|
+
"event_id": eid, "arena": args.arena,
|
|
220
|
+
"old_hash": "dirty", "target_hash": args.clean_hash,
|
|
221
|
+
"solo_facts_superseded": c["solo_fact_n"] if args.supersede_facts else 0,
|
|
222
|
+
"enqueued_at": stamp,
|
|
223
|
+
}, ensure_ascii=False) + "\n")
|
|
224
|
+
conn.commit()
|
|
225
|
+
|
|
226
|
+
print(json.dumps({
|
|
227
|
+
"applied": True, "re_enqueued": enq, "facts_superseded": deleted,
|
|
228
|
+
"rollback": rb_path, "ledger": ledger_path,
|
|
229
|
+
}, indent=2))
|
|
230
|
+
if deleted:
|
|
231
|
+
print(f"\nROLLBACK: facts are in {rb_path} — re-INSERT them to undo.")
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
raise SystemExit(main())
|