@pentatonic-ai/ai-agent-sdk 0.10.10 → 0.10.11

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.10";
881
+ var VERSION = "0.10.11";
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.10";
850
+ var VERSION = "0.10.11";
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.10",
3
+ "version": "0.10.11",
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",
@@ -56,12 +56,29 @@ def _distiller_post_fn(endpoint: str, model: str):
56
56
  """Build a post_fn(messages)->str hitting the distiller's OpenAI
57
57
  /v1/chat/completions (temperature 0, thinking off — same shape the worker
58
58
  uses). In-VPC: memory content never leaves the network. Raises on failure
59
- so the adjudicator fails closed (treats it as 'unsure', never merges)."""
59
+ so the adjudicator fails closed (treats it as 'unsure', never merges).
60
+
61
+ Granular timeout: a short connect deadline so a scaled-to-zero distiller
62
+ fails in seconds, not 60s, but a generous read deadline so a live-but-busy
63
+ model still gets time to generate. Plus a circuit breaker — once a connect
64
+ fails, every later call short-circuits immediately, so a down distiller
65
+ costs one timeout for the whole run instead of one per candidate pair (the
66
+ autoscaler scales the distiller on the distill queue, not on fusion need,
67
+ so it is routinely down at sweep time)."""
68
+ timeout = httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0)
69
+ state = {"down": False}
70
+
60
71
  def post(messages):
61
- r = httpx.post(endpoint, timeout=60, json={
62
- "model": model, "messages": messages, "temperature": 0.0,
63
- "max_tokens": 120, "chat_template_kwargs": {"enable_thinking": False},
64
- })
72
+ if state["down"]:
73
+ raise RuntimeError("distiller circuit open (earlier connect failed)")
74
+ try:
75
+ r = httpx.post(endpoint, timeout=timeout, json={
76
+ "model": model, "messages": messages, "temperature": 0.0,
77
+ "max_tokens": 120, "chat_template_kwargs": {"enable_thinking": False},
78
+ })
79
+ except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
80
+ state["down"] = True
81
+ raise RuntimeError(f"distiller unreachable: {e}") from e
65
82
  r.raise_for_status()
66
83
  return r.json()["choices"][0]["message"]["content"]
67
84
  return post
@@ -72,8 +89,23 @@ def _norm(s: str) -> str:
72
89
 
73
90
 
74
91
  def _entity_dup_sets(cur, arena: str) -> list[list[dict]]:
75
- """Group same-(type) entities that are exact normalized-name dupes OR
76
- share a provenance event with a junk-leaning twin. Returns groups of >=2."""
92
+ """Tier 1, DETERMINISTIC auto-merge: same-(type) entities whose names are
93
+ exact normalized-name dupes (case/whitespace variants like 'PR'/'pr',
94
+ 'USDC'/'usdc', or two rows of the literally identical string). Returns
95
+ groups of >=2.
96
+
97
+ Co-occurrence (shared-provenance) is DELIBERATELY NOT here. A single event
98
+ routinely mentions a real entity AND a digit-heavy real entity that are
99
+ different things ("running Linux on the RTX 6000" — Linux and RTX 6000
100
+ share that event but are not the same node). looks_like_id flags any
101
+ digit-ratio>0.5 name as junk, which catches GPU models (RTX 6000, B2000),
102
+ protocol names (x402), and standards (EN18031) — all real. The legitimate
103
+ cross-run target ('1716801984' == 'Katie Cooper' across runs) is
104
+ INDISTINGUISHABLE from that false positive using only (type, shared-event,
105
+ digit-ratio), so co-occurrence merges CANNOT be made deterministically.
106
+ They are routed to the LLM-adjudicated tier (_cooccurrence_candidates) and
107
+ simply do not happen when the distiller is unreachable. (Was a Tier-1
108
+ auto-merge; the first prod dry-run merged RTX 6000→Linux, x402→USDC etc.)"""
77
109
  cur.execute(
78
110
  """SELECT id, entity_type, canonical_name, aliases, provenance_event_ids, disclosure_class
79
111
  FROM entities WHERE arena = %s AND disclosure_class <> 'restricted'""",
@@ -81,45 +113,10 @@ def _entity_dup_sets(cur, arena: str) -> list[list[dict]]:
81
113
  )
82
114
  ents = cur.fetchall()
83
115
  groups: dict[tuple, list[dict]] = {}
84
- # 1. exact normalized-name within (type)
85
116
  for e in ents:
86
117
  key = (e["entity_type"], _norm(e["canonical_name"]))
87
118
  groups.setdefault(key, []).append(e)
88
- exact = [g for g in groups.values() if len(g) > 1]
89
-
90
- # 2. cross-run shared-provenance: same type + same event in provenance,
91
- # where some members are junk-leaning (looks-like-id) — catches
92
- # name-divergent dupes like "1716801984" vs "Katie Cooper" that never
93
- # block on name.
94
- #
95
- # OVER-MERGE GUARD: a single event can legitimately mention several
96
- # distinct same-type entities (an email naming Alice, Bob, AND a
97
- # numeric-ID node). Merging the whole co-occurrence group would
98
- # conflate Alice and Bob. So the no-LLM tier ONLY proposes when the
99
- # group has EXACTLY ONE non-junk member: we fold the junk node(s) into
100
- # that unambiguous real master. Groups with 0 or >=2 non-junk members
101
- # are ambiguous and deferred to the LLM-adjudicated tier
102
- # (entity_resolution_v2.py) rather than auto-merged.
103
- by_event_type: dict[tuple, list[dict]] = {}
104
- for e in ents:
105
- for ev in (e["provenance_event_ids"] or []):
106
- by_event_type.setdefault((e["entity_type"], ev), []).append(e)
107
- cross = []
108
- seen_ids: set[str] = set()
109
- for members in by_event_type.values():
110
- if len(members) < 2:
111
- continue
112
- junk = [m for m in members if C.looks_like_id(m["canonical_name"])]
113
- non_junk = [m for m in members if not C.looks_like_id(m["canonical_name"])]
114
- if not junk or len(non_junk) != 1:
115
- continue # need junk to clean AND exactly one unambiguous master
116
- group = non_junk + junk
117
- ids = tuple(sorted(m["id"] for m in group))
118
- if ids in seen_ids:
119
- continue
120
- seen_ids.add(ids)
121
- cross.append(group)
122
- return exact + cross
119
+ return [g for g in groups.values() if len(g) > 1]
123
120
 
124
121
 
125
122
  def _authority_signals(cur, arena: str, entity_ids: list[str], current_model: str) -> dict:
@@ -264,11 +261,19 @@ def _dedup_master_facts(cur, arena: str, master_id: str) -> int:
264
261
  return deduped
265
262
 
266
263
 
267
- def _ambiguous_cross_run(cur, arena: str) -> list[dict]:
268
- """Co-occurrence groups the deterministic tier PUNTED on: a junk-leaning
269
- node sharing an event with MULTIPLE non-junk candidates (so which real
270
- entity it belongs to is ambiguous). Returns [{junk, candidates:[...]}] for
271
- the LLM tier to adjudicate. Restricted disclosure excluded."""
264
+ def _cooccurrence_candidates(cur, arena: str) -> list[dict]:
265
+ """ALL co-occurrence merge candidates a junk-leaning node (looks_like_id)
266
+ sharing a provenance event with one OR more non-junk same-type nodes.
267
+ Returns [{junk, candidates:[...]}] for the LLM tier to adjudicate.
268
+
269
+ Co-occurrence is never sufficient evidence on its own (the deterministic
270
+ tier no longer auto-merges any of these — see _entity_dup_sets). Even the
271
+ single-candidate case ('1716801984' co-occurs with exactly one 'Katie
272
+ Cooper') must be confirmed by the adjudicator, because it's
273
+ indistinguishable from 'RTX 6000' co-occurring with exactly one 'Linux'.
274
+ The adjudicator merges only on an affirmative same-entity verdict and fails
275
+ closed (no merge) when the distiller is unreachable. Restricted disclosure
276
+ excluded."""
272
277
  cur.execute(
273
278
  """SELECT id, entity_type, canonical_name, aliases, provenance_event_ids
274
279
  FROM entities WHERE arena = %s AND disclosure_class <> 'restricted'""",
@@ -283,7 +288,7 @@ def _ambiguous_cross_run(cur, arena: str) -> list[dict]:
283
288
  for members in by_event_type.values():
284
289
  junk = [m for m in members if C.looks_like_id(m["canonical_name"])]
285
290
  non_junk = [m for m in members if not C.looks_like_id(m["canonical_name"])]
286
- if not junk or len(non_junk) < 2:
291
+ if not junk or len(non_junk) < 1:
287
292
  continue
288
293
  for j in junk:
289
294
  key = (j["id"], tuple(sorted(c["id"] for c in non_junk)))
@@ -366,14 +371,17 @@ def main() -> int:
366
371
  merged += len(loser_ids)
367
372
  return len(loser_ids)
368
373
 
369
- # Tier 1 — deterministic (exact-name + unambiguous cross-run)
374
+ # Tier 1 — deterministic: exact normalized-name dupes only
375
+ # (case/whitespace variants). Co-occurrence is NOT auto-merged.
370
376
  for group in _entity_dup_sets(cur, args.arena):
371
377
  do_merge(group)
372
378
 
373
- # Tier 2 — LLM adjudication via the in-VPC distiller (no egress)
379
+ # Tier 2 — LLM adjudication via the in-VPC distiller (no egress).
380
+ # ALL co-occurrence merges live here now — single- and multi-
381
+ # candidate alike — because co-occurrence never proves identity.
374
382
  if post_fn:
375
- # 2a. ambiguous cross-run: which real entity does the junk match?
376
- for amb in _ambiguous_cross_run(cur, args.arena):
383
+ # 2a. co-occurrence: does the junk node match a real entity?
384
+ for amb in _cooccurrence_candidates(cur, args.arena):
377
385
  j = amb["junk"]
378
386
  jctx = _entity_context(cur, args.arena, j["id"])
379
387
  for cand in amb["candidates"]:
@@ -0,0 +1,70 @@
1
+ """Guards the distiller circuit breaker in fusion_drive_fuse._distiller_post_fn.
2
+
3
+ When the distiller is scaled to zero (the autoscaler scales it on the distill
4
+ queue, not on fusion need, so it is routinely down at sweep time) the first
5
+ connect must fail fast AND open the circuit so every later candidate pair
6
+ short-circuits instead of eating another connect timeout. A regression here
7
+ reintroduces the >20min dry-run grind that motivated the fix."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ import os
13
+ import sys
14
+ import types
15
+
16
+ import httpx
17
+ import pytest
18
+
19
+ HERE = os.path.dirname(__file__)
20
+
21
+
22
+ def _load_fuse(monkeypatch):
23
+ # The script imports psycopg + adjudicate/canonical/merge at module level;
24
+ # stub psycopg (not installed in the unit env) and put the sibling package
25
+ # on the path so the real adjudicate/canonical/merge import cleanly.
26
+ fake_psycopg = types.ModuleType("psycopg")
27
+ fake_rows = types.ModuleType("psycopg.rows")
28
+ fake_rows.dict_row = object()
29
+ fake_psycopg.rows = fake_rows
30
+ monkeypatch.setitem(sys.modules, "psycopg", fake_psycopg)
31
+ monkeypatch.setitem(sys.modules, "psycopg.rows", fake_rows)
32
+ monkeypatch.syspath_prepend(os.path.join(HERE, "..", "fusion_drive"))
33
+ monkeypatch.syspath_prepend(HERE)
34
+ sys.modules.pop("fusion_drive_fuse", None)
35
+ return importlib.import_module("fusion_drive_fuse")
36
+
37
+
38
+ def test_circuit_opens_after_first_connect_failure(monkeypatch):
39
+ fuse = _load_fuse(monkeypatch)
40
+ calls = {"n": 0}
41
+
42
+ def boom(*a, **k):
43
+ calls["n"] += 1
44
+ raise httpx.ConnectError("connection refused")
45
+
46
+ monkeypatch.setattr(httpx, "post", boom)
47
+ post = fuse._distiller_post_fn("http://10.0.0.1:8005/v1/chat/completions", "m")
48
+
49
+ with pytest.raises(RuntimeError, match="distiller unreachable"):
50
+ post([{"role": "user", "content": "a"}])
51
+ # second call must NOT hit the network again — circuit is open
52
+ with pytest.raises(RuntimeError, match="circuit open"):
53
+ post([{"role": "user", "content": "b"}])
54
+
55
+ assert calls["n"] == 1, "circuit breaker should make exactly one network attempt"
56
+
57
+
58
+ def test_happy_path_returns_content(monkeypatch):
59
+ fuse = _load_fuse(monkeypatch)
60
+
61
+ class FakeResp:
62
+ def raise_for_status(self):
63
+ pass
64
+
65
+ def json(self):
66
+ return {"choices": [{"message": {"content": "yes"}}]}
67
+
68
+ monkeypatch.setattr(httpx, "post", lambda *a, **k: FakeResp())
69
+ post = fuse._distiller_post_fn("http://10.0.0.1:8005/v1/chat/completions", "m")
70
+ assert post([{"role": "user", "content": "a"}]) == "yes"