@pentatonic-ai/ai-agent-sdk 0.10.22 → 0.10.24
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/extractor-async/Dockerfile +5 -0
- package/packages/memory-engine-v2/extractor-async/backfill_structured_edges.py +221 -0
- package/packages/memory-engine-v2/extractor-async/test_backfill_structured_edges.py +57 -0
- package/packages/memory-engine-v2/extractor-async/test_structured_edges.py +471 -0
- package/packages/memory-engine-v2/extractor-async/worker.py +424 -4
- package/packages/memory-engine-v2/scripts/backfill_person_email_attr.py +216 -0
- package/packages/memory-engine-v2/scripts/test_backfill_person_email_attr.py +84 -0
package/dist/index.cjs
CHANGED
|
@@ -878,7 +878,7 @@ function fireAndForgetEmit(clientConfig, sessionOpts, messages, result, model) {
|
|
|
878
878
|
}
|
|
879
879
|
|
|
880
880
|
// src/telemetry.js
|
|
881
|
-
var VERSION = "0.10.
|
|
881
|
+
var VERSION = "0.10.24";
|
|
882
882
|
var TELEMETRY_URL = "https://sdk-telemetry.philip-134.workers.dev";
|
|
883
883
|
function machineId() {
|
|
884
884
|
const raw = typeof process !== "undefined" ? `${process.env?.USER || process.env?.USERNAME || "u"}:${process.platform || "x"}:${process.arch || "x"}` : "browser";
|
package/dist/index.js
CHANGED
|
@@ -847,7 +847,7 @@ function fireAndForgetEmit(clientConfig, sessionOpts, messages, result, model) {
|
|
|
847
847
|
}
|
|
848
848
|
|
|
849
849
|
// src/telemetry.js
|
|
850
|
-
var VERSION = "0.10.
|
|
850
|
+
var VERSION = "0.10.24";
|
|
851
851
|
var TELEMETRY_URL = "https://sdk-telemetry.philip-134.workers.dev";
|
|
852
852
|
function machineId() {
|
|
853
853
|
const raw = typeof process !== "undefined" ? `${process.env?.USER || process.env?.USERNAME || "u"}:${process.platform || "x"}:${process.arch || "x"}` : "browser";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pentatonic-ai/ai-agent-sdk",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.24",
|
|
4
4
|
"description": "TES SDK — LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -15,5 +15,10 @@ COPY worker.py .
|
|
|
15
15
|
# container crash-loop on import at startup (observed 2026-06-08 deploy).
|
|
16
16
|
# The test_*.py files are intentionally excluded; pytest only, not runtime.
|
|
17
17
|
COPY noise_filter.py confidence.py entity_id.py sensitive_filter.py extraction_schema.py source_time.py ./
|
|
18
|
+
# SEE-490 one-off backfill — a standalone script (not a worker.py import), but it
|
|
19
|
+
# does `from worker import …` so it must sit beside worker.py in /app. #141 added
|
|
20
|
+
# the script but not this COPY, so it never reached the image and the runbook's
|
|
21
|
+
# `/app/backfill_structured_edges.py` apply step had nothing to run.
|
|
22
|
+
COPY backfill_structured_edges.py ./
|
|
18
23
|
|
|
19
24
|
CMD ["python", "worker.py"]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Backfill deterministic structured edges (SEE-490) over the EXISTING corpus.
|
|
3
|
+
|
|
4
|
+
The structured-edge builders (`structured_graph_elements` in worker.py) run inside
|
|
5
|
+
the distill flow, so every NEW event gets its deterministic edges — but the
|
|
6
|
+
HISTORICAL graph does not. This one-off script closes that gap WITHOUT a re-distill.
|
|
7
|
+
|
|
8
|
+
Why a dedicated script and not a re-distill:
|
|
9
|
+
- The structured edges are pure Python (no model call), but they're wired INSIDE
|
|
10
|
+
`_apply_extraction`, so a literal re-distill would re-run the teacher LLM over
|
|
11
|
+
~700k events just to add edges that needed no model — slow and expensive.
|
|
12
|
+
- This script runs ONLY `structured_graph_elements` per event and upserts the
|
|
13
|
+
result via the SAME `upsert_entities` / `upsert_relationships` the distiller
|
|
14
|
+
uses. No LLM, fast, cheap.
|
|
15
|
+
|
|
16
|
+
Why it is SAFE (and needs NO delete-first, unlike the hard-key re-distill runbook):
|
|
17
|
+
- PURELY ADDITIVE. It changes NO existing node id — it upserts deterministic
|
|
18
|
+
edges and their hard-keyed endpoint nodes (person→email, doc→drive-file-id,
|
|
19
|
+
channel→channel-id, meeting→meeting-id) idempotently on `(arena, from, to,
|
|
20
|
+
type)`. Re-running is a no-op-shaped upsert; it cannot double the graph.
|
|
21
|
+
- Rollback is a single scoped DELETE (the edges carry a provenance stamp):
|
|
22
|
+
DELETE FROM relationships
|
|
23
|
+
WHERE arena LIKE '<scope>' AND attributes->>'derivation' = 'structured';
|
|
24
|
+
(The few email/file-id-keyed endpoint nodes it may mint are benign additions
|
|
25
|
+
that converge with the LLM/sync passes; they need no cleanup.)
|
|
26
|
+
|
|
27
|
+
Multi-tenant blast radius: scoped by `--arena` (default `pentatonic-team%`). It
|
|
28
|
+
NEVER touches `pip-agents` (legacy) or test tenants unless you widen the scope.
|
|
29
|
+
|
|
30
|
+
Usage (on the engine box, AFTER the SDK carrying SEE-490 is deployed + the
|
|
31
|
+
extractor-async container rebuilt — verify `structured_graph_elements` is present
|
|
32
|
+
in the installed package first):
|
|
33
|
+
|
|
34
|
+
python backfill_structured_edges.py --dry-run # report only, no writes
|
|
35
|
+
python backfill_structured_edges.py # apply (pentatonic-team)
|
|
36
|
+
python backfill_structured_edges.py --sources gmail,drive # one/few sources
|
|
37
|
+
python backfill_structured_edges.py --arena 'pentatonic-team%' --commit-every 500
|
|
38
|
+
|
|
39
|
+
Reads `PG_DSN` from the environment (same as the worker).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import logging
|
|
46
|
+
import os
|
|
47
|
+
import sys
|
|
48
|
+
import time
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
import psycopg
|
|
52
|
+
|
|
53
|
+
# Reuse the distiller's own functions so behaviour is byte-identical to the live
|
|
54
|
+
# path — the builders, the entity resolution/keying, and the edge upsert.
|
|
55
|
+
from worker import ( # type: ignore
|
|
56
|
+
structured_graph_elements,
|
|
57
|
+
upsert_entities,
|
|
58
|
+
upsert_relationships,
|
|
59
|
+
event_source_time,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
logging.basicConfig(
|
|
63
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
|
|
64
|
+
)
|
|
65
|
+
log = logging.getLogger("backfill-structured-edges")
|
|
66
|
+
|
|
67
|
+
# Producer labels that actually yield structured edges today — used only to narrow
|
|
68
|
+
# the scan (and to honour --sources). Keep in sync with worker._STRUCTURED_EDGE_BUILDERS.
|
|
69
|
+
_EDGE_SOURCES = {
|
|
70
|
+
"gmail": "pip-gmail-ingest",
|
|
71
|
+
"drive": "pip-drive-ingest",
|
|
72
|
+
"slack": "pip-slack-ingest",
|
|
73
|
+
"granola": "pip-granola-ingest",
|
|
74
|
+
"calendar": "pip-calendar-ingest",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
79
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
80
|
+
p.add_argument(
|
|
81
|
+
"--arena", default="pentatonic-team%",
|
|
82
|
+
help="arena LIKE filter (default 'pentatonic-team%%'). Blast-radius guard.",
|
|
83
|
+
)
|
|
84
|
+
p.add_argument(
|
|
85
|
+
"--sources", default="",
|
|
86
|
+
help="comma list of "
|
|
87
|
+
+ "/".join(_EDGE_SOURCES)
|
|
88
|
+
+ " to limit the scan (default: all edge-producing sources).",
|
|
89
|
+
)
|
|
90
|
+
p.add_argument(
|
|
91
|
+
"--dry-run", action="store_true",
|
|
92
|
+
help="compute + report edges that WOULD be created; write nothing.",
|
|
93
|
+
)
|
|
94
|
+
p.add_argument("--limit", type=int, default=0, help="cap events scanned (0 = all).")
|
|
95
|
+
p.add_argument(
|
|
96
|
+
"--commit-every", type=int, default=500,
|
|
97
|
+
help="commit the write txn every N edge-producing events (default 500).",
|
|
98
|
+
)
|
|
99
|
+
return p.parse_args(argv)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _source_filter(sources_arg: str) -> list[str]:
|
|
103
|
+
if not sources_arg.strip():
|
|
104
|
+
return list(_EDGE_SOURCES.values())
|
|
105
|
+
out: list[str] = []
|
|
106
|
+
for s in sources_arg.split(","):
|
|
107
|
+
s = s.strip().lower()
|
|
108
|
+
if s not in _EDGE_SOURCES:
|
|
109
|
+
raise SystemExit(f"unknown source {s!r}; valid: {', '.join(_EDGE_SOURCES)}")
|
|
110
|
+
out.append(_EDGE_SOURCES[s])
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main(argv: list[str]) -> int:
|
|
115
|
+
args = _parse_args(argv)
|
|
116
|
+
dsn = os.environ.get("PG_DSN")
|
|
117
|
+
if not dsn:
|
|
118
|
+
raise SystemExit("PG_DSN not set")
|
|
119
|
+
sources = _source_filter(args.sources)
|
|
120
|
+
|
|
121
|
+
log.info(
|
|
122
|
+
"backfill start: arena LIKE %r sources=%s dry_run=%s limit=%s",
|
|
123
|
+
args.arena, sources, args.dry_run, args.limit or "all",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Separate connections: a server-side (named) cursor STREAMS the read so we
|
|
127
|
+
# never load the whole events table into memory; a second connection does the
|
|
128
|
+
# batched writes. The read connection MUST NOT be autocommit — psycopg3's
|
|
129
|
+
# named server-side cursor issues DECLARE CURSOR, which requires a
|
|
130
|
+
# transaction block ("DECLARE CURSOR can only be used in transaction
|
|
131
|
+
# blocks"). It's read-only (SELECT), never committed, just closed at the end.
|
|
132
|
+
read_conn = psycopg.connect(dsn)
|
|
133
|
+
write_conn = psycopg.connect(dsn)
|
|
134
|
+
|
|
135
|
+
scanned = 0
|
|
136
|
+
with_edges = 0
|
|
137
|
+
edges_total = 0
|
|
138
|
+
ents_total = 0
|
|
139
|
+
t0 = time.perf_counter()
|
|
140
|
+
pending_in_txn = 0
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
with read_conn.cursor(name="backfill_events") as cur:
|
|
144
|
+
cur.itersize = 1000
|
|
145
|
+
sql = """
|
|
146
|
+
SELECT id, arena, attributes, participant_set, disclosure_class,
|
|
147
|
+
emitted_at
|
|
148
|
+
FROM events
|
|
149
|
+
WHERE arena LIKE %s
|
|
150
|
+
AND attributes->>'source' = ANY(%s)
|
|
151
|
+
ORDER BY received_at
|
|
152
|
+
"""
|
|
153
|
+
params: tuple[Any, ...] = (args.arena, sources)
|
|
154
|
+
if args.limit:
|
|
155
|
+
sql += " LIMIT %s"
|
|
156
|
+
params = (*params, args.limit)
|
|
157
|
+
cur.execute(sql, params)
|
|
158
|
+
|
|
159
|
+
for event_id, arena, attributes, participant_set, disclosure, emitted_at in cur:
|
|
160
|
+
scanned += 1
|
|
161
|
+
attrs = attributes or {}
|
|
162
|
+
det_ents, det_rels = structured_graph_elements(arena, attrs)
|
|
163
|
+
if not det_rels:
|
|
164
|
+
continue
|
|
165
|
+
with_edges += 1
|
|
166
|
+
edges_total += len(det_rels)
|
|
167
|
+
ents_total += len(det_ents)
|
|
168
|
+
|
|
169
|
+
if args.dry_run:
|
|
170
|
+
if with_edges <= 5:
|
|
171
|
+
log.info(
|
|
172
|
+
"would add %d edges from %s (%s): %s",
|
|
173
|
+
len(det_rels), event_id, attrs.get("source"),
|
|
174
|
+
sorted({r["type"] for r in det_rels}),
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
pset = participant_set or [arena]
|
|
178
|
+
disc = disclosure or "private"
|
|
179
|
+
event_time = event_source_time({"attributes": attrs}) or emitted_at
|
|
180
|
+
name_to_id = upsert_entities(
|
|
181
|
+
write_conn, arena, event_id, pset, disc, det_ents,
|
|
182
|
+
event_time, attrs,
|
|
183
|
+
)
|
|
184
|
+
upsert_relationships(
|
|
185
|
+
write_conn, arena, event_id, pset, disc, det_rels,
|
|
186
|
+
name_to_id, event_time,
|
|
187
|
+
)
|
|
188
|
+
pending_in_txn += 1
|
|
189
|
+
if pending_in_txn >= args.commit_every:
|
|
190
|
+
write_conn.commit()
|
|
191
|
+
pending_in_txn = 0
|
|
192
|
+
|
|
193
|
+
if with_edges % 5000 == 0 and with_edges:
|
|
194
|
+
rate = scanned / max(time.perf_counter() - t0, 1e-6)
|
|
195
|
+
log.info(
|
|
196
|
+
"progress: scanned=%d with_edges=%d edges=%d (%.0f ev/s)",
|
|
197
|
+
scanned, with_edges, edges_total, rate,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if not args.dry_run:
|
|
201
|
+
write_conn.commit()
|
|
202
|
+
except Exception:
|
|
203
|
+
if not args.dry_run:
|
|
204
|
+
write_conn.rollback()
|
|
205
|
+
log.exception("backfill failed; write txn rolled back")
|
|
206
|
+
return 1
|
|
207
|
+
finally:
|
|
208
|
+
read_conn.close()
|
|
209
|
+
write_conn.close()
|
|
210
|
+
|
|
211
|
+
dur = time.perf_counter() - t0
|
|
212
|
+
log.info(
|
|
213
|
+
"backfill %s: scanned=%d events, edge-producing=%d, edges=%d, endpoint-nodes=%d, %.1fs",
|
|
214
|
+
"DRY-RUN (no writes)" if args.dry_run else "DONE",
|
|
215
|
+
scanned, with_edges, edges_total, ents_total, dur,
|
|
216
|
+
)
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Pure-logic tests for the structured-edge backfill (SEE-490). The DB path
|
|
2
|
+
(stream read → upsert) is exercised by the live run; here we pin the CLI scope
|
|
3
|
+
parsing + the source-label mapping (the blast-radius / --sources guards)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import importlib.util
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_THIS = Path(__file__).resolve().parent
|
|
14
|
+
_SPEC = importlib.util.spec_from_file_location(
|
|
15
|
+
"backfill_structured_edges", _THIS / "backfill_structured_edges.py"
|
|
16
|
+
)
|
|
17
|
+
assert _SPEC and _SPEC.loader
|
|
18
|
+
bf = importlib.util.module_from_spec(_SPEC)
|
|
19
|
+
try:
|
|
20
|
+
_SPEC.loader.exec_module(bf)
|
|
21
|
+
except ImportError as e:
|
|
22
|
+
pytest.skip(f"extractor-async deps unavailable: {e}", allow_module_level=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_source_filter_default_is_all_edge_sources():
|
|
26
|
+
got = set(bf._source_filter(""))
|
|
27
|
+
assert got == set(bf._EDGE_SOURCES.values())
|
|
28
|
+
# all five live edge-producing producers
|
|
29
|
+
assert "pip-gmail-ingest" in got and "pip-calendar-ingest" in got
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_source_filter_named_subset():
|
|
33
|
+
assert bf._source_filter("gmail,drive") == ["pip-gmail-ingest", "pip-drive-ingest"]
|
|
34
|
+
assert bf._source_filter(" Slack , Granola ") == [
|
|
35
|
+
"pip-slack-ingest", "pip-granola-ingest",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_source_filter_rejects_unknown():
|
|
40
|
+
with pytest.raises(SystemExit):
|
|
41
|
+
bf._source_filter("teams") # not an edge source (no envelope)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_default_scope_is_pentatonic_team():
|
|
45
|
+
# The blast-radius guard: default arena filter must be tenant-scoped, never
|
|
46
|
+
# the whole multi-tenant table.
|
|
47
|
+
args = bf._parse_args([])
|
|
48
|
+
assert args.arena == "pentatonic-team%"
|
|
49
|
+
assert args.dry_run is False
|
|
50
|
+
assert args.commit_every == 500
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_dry_run_and_sources_flags():
|
|
54
|
+
args = bf._parse_args(["--dry-run", "--sources", "gmail", "--limit", "10"])
|
|
55
|
+
assert args.dry_run is True
|
|
56
|
+
assert args.limit == 10
|
|
57
|
+
assert bf._source_filter(args.sources) == ["pip-gmail-ingest"]
|