@pentatonic-ai/ai-agent-sdk 0.10.22 → 0.10.23

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 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.22";
881
+ var VERSION = "0.10.23";
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.22";
850
+ var VERSION = "0.10.23";
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.22",
3
+ "version": "0.10.23",
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",
@@ -0,0 +1,218 @@
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. Read connection is autocommit (no long-lived read txn).
129
+ read_conn = psycopg.connect(dsn, autocommit=True)
130
+ write_conn = psycopg.connect(dsn)
131
+
132
+ scanned = 0
133
+ with_edges = 0
134
+ edges_total = 0
135
+ ents_total = 0
136
+ t0 = time.perf_counter()
137
+ pending_in_txn = 0
138
+
139
+ try:
140
+ with read_conn.cursor(name="backfill_events") as cur:
141
+ cur.itersize = 1000
142
+ sql = """
143
+ SELECT id, arena, attributes, participant_set, disclosure_class,
144
+ emitted_at
145
+ FROM events
146
+ WHERE arena LIKE %s
147
+ AND attributes->>'source' = ANY(%s)
148
+ ORDER BY received_at
149
+ """
150
+ params: tuple[Any, ...] = (args.arena, sources)
151
+ if args.limit:
152
+ sql += " LIMIT %s"
153
+ params = (*params, args.limit)
154
+ cur.execute(sql, params)
155
+
156
+ for event_id, arena, attributes, participant_set, disclosure, emitted_at in cur:
157
+ scanned += 1
158
+ attrs = attributes or {}
159
+ det_ents, det_rels = structured_graph_elements(arena, attrs)
160
+ if not det_rels:
161
+ continue
162
+ with_edges += 1
163
+ edges_total += len(det_rels)
164
+ ents_total += len(det_ents)
165
+
166
+ if args.dry_run:
167
+ if with_edges <= 5:
168
+ log.info(
169
+ "would add %d edges from %s (%s): %s",
170
+ len(det_rels), event_id, attrs.get("source"),
171
+ sorted({r["type"] for r in det_rels}),
172
+ )
173
+ else:
174
+ pset = participant_set or [arena]
175
+ disc = disclosure or "private"
176
+ event_time = event_source_time({"attributes": attrs}) or emitted_at
177
+ name_to_id = upsert_entities(
178
+ write_conn, arena, event_id, pset, disc, det_ents,
179
+ event_time, attrs,
180
+ )
181
+ upsert_relationships(
182
+ write_conn, arena, event_id, pset, disc, det_rels,
183
+ name_to_id, event_time,
184
+ )
185
+ pending_in_txn += 1
186
+ if pending_in_txn >= args.commit_every:
187
+ write_conn.commit()
188
+ pending_in_txn = 0
189
+
190
+ if with_edges % 5000 == 0 and with_edges:
191
+ rate = scanned / max(time.perf_counter() - t0, 1e-6)
192
+ log.info(
193
+ "progress: scanned=%d with_edges=%d edges=%d (%.0f ev/s)",
194
+ scanned, with_edges, edges_total, rate,
195
+ )
196
+
197
+ if not args.dry_run:
198
+ write_conn.commit()
199
+ except Exception:
200
+ if not args.dry_run:
201
+ write_conn.rollback()
202
+ log.exception("backfill failed; write txn rolled back")
203
+ return 1
204
+ finally:
205
+ read_conn.close()
206
+ write_conn.close()
207
+
208
+ dur = time.perf_counter() - t0
209
+ log.info(
210
+ "backfill %s: scanned=%d events, edge-producing=%d, edges=%d, endpoint-nodes=%d, %.1fs",
211
+ "DRY-RUN (no writes)" if args.dry_run else "DONE",
212
+ scanned, with_edges, edges_total, ents_total, dur,
213
+ )
214
+ return 0
215
+
216
+
217
+ if __name__ == "__main__":
218
+ 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"]