@pentatonic-ai/ai-agent-sdk 0.10.10 → 0.10.12
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/scripts/fusion_drive_fuse.py +85 -62
- package/packages/memory-engine-v2/scripts/test_dedup_master_facts.py +83 -0
- package/packages/memory-engine-v2/scripts/test_distiller_post_fn.py +70 -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.12";
|
|
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.12";
|
|
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.12",
|
|
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
|
-
|
|
62
|
-
"
|
|
63
|
-
|
|
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
|
-
"""
|
|
76
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -216,22 +213,33 @@ def _execute_entity_plan(cur, plan) -> None:
|
|
|
216
213
|
cur.execute(
|
|
217
214
|
"""INSERT INTO entity_merges (id, arena, canonical_id, deprecated_id,
|
|
218
215
|
deprecated_canonical_name, deprecated_aliases, merge_signal,
|
|
219
|
-
facts_repointed, rollback_payload)
|
|
220
|
-
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb)""",
|
|
216
|
+
facts_repointed, relationships_repointed, merged_by, rollback_payload)
|
|
217
|
+
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb)""",
|
|
221
218
|
("em_" + uuid.uuid4().hex[:20], a["arena"], a["canonical_id"], a["deprecated_id"],
|
|
222
219
|
a["deprecated_canonical_name"], a["deprecated_aliases"], a["merge_signal"],
|
|
223
220
|
len(plan.fact_subject_repoints) + len(plan.fact_object_repoints),
|
|
221
|
+
len(plan.rel_endpoint_repoints), "fusion-drive",
|
|
224
222
|
json.dumps(a["rollback_payload"], default=str)),
|
|
225
223
|
)
|
|
226
224
|
cur.execute("DELETE FROM entities WHERE id = ANY(%s)", (plan.deprecated_entity_ids,))
|
|
227
225
|
|
|
228
226
|
|
|
229
227
|
def _dedup_master_facts(cur, arena: str, master_id: str) -> int:
|
|
230
|
-
"""After repointing facts onto the master,
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
228
|
+
"""After repointing facts onto the master, collapse facts that are now
|
|
229
|
+
TRUE duplicates — same (subject, predicate, object) AND the same normalized
|
|
230
|
+
statement. These exist because the fact id is content_id(arena, statement):
|
|
231
|
+
two rows with statements differing only in case/whitespace hash to distinct
|
|
232
|
+
ids and so survived insert-time dedup; once their subject/object entities
|
|
233
|
+
are unified they are genuinely the same assertion and fuse safely.
|
|
234
|
+
|
|
235
|
+
The statement is PART OF THE KEY on purpose. Grouping on the triple alone is
|
|
236
|
+
NOT identity: a NULL object with a generic predicate (e.g. subject "said"
|
|
237
|
+
NULL) buckets together unrelated assertions, and build_fact_merge_plan would
|
|
238
|
+
keep one and DELETE the rest — destroying distinct facts (it deleted 33% of
|
|
239
|
+
one arena's facts that way before this fix). Same-triple / different-meaning
|
|
240
|
+
facts are left untouched here; the LLM semantic tier (_semantic_fact_groups
|
|
241
|
+
+ adjudicate_facts) is the only thing allowed to fuse facts whose statements
|
|
242
|
+
actually differ, and only on an affirmative same-assertion verdict."""
|
|
235
243
|
cur.execute(
|
|
236
244
|
"""SELECT id, predicate, object_entity_id, statement, confidence, provenance_event_ids
|
|
237
245
|
FROM facts
|
|
@@ -241,8 +249,12 @@ def _dedup_master_facts(cur, arena: str, master_id: str) -> int:
|
|
|
241
249
|
rows = cur.fetchall()
|
|
242
250
|
groups: dict[tuple, list[dict]] = {}
|
|
243
251
|
for r in rows:
|
|
244
|
-
#
|
|
245
|
-
|
|
252
|
+
# key = master subject anchor + predicate + object + NORMALIZED STATEMENT.
|
|
253
|
+
# statement in the key => only byte-equal-after-normalization dupes fuse.
|
|
254
|
+
groups.setdefault(
|
|
255
|
+
(master_id, r["predicate"], r["object_entity_id"], _norm(r["statement"] or "")),
|
|
256
|
+
[],
|
|
257
|
+
).append(r)
|
|
246
258
|
deduped = 0
|
|
247
259
|
for dup in groups.values():
|
|
248
260
|
plan = build_fact_merge_plan(arena=arena, dup_facts=dup)
|
|
@@ -264,11 +276,19 @@ def _dedup_master_facts(cur, arena: str, master_id: str) -> int:
|
|
|
264
276
|
return deduped
|
|
265
277
|
|
|
266
278
|
|
|
267
|
-
def
|
|
268
|
-
"""
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
279
|
+
def _cooccurrence_candidates(cur, arena: str) -> list[dict]:
|
|
280
|
+
"""ALL co-occurrence merge candidates — a junk-leaning node (looks_like_id)
|
|
281
|
+
sharing a provenance event with one OR more non-junk same-type nodes.
|
|
282
|
+
Returns [{junk, candidates:[...]}] for the LLM tier to adjudicate.
|
|
283
|
+
|
|
284
|
+
Co-occurrence is never sufficient evidence on its own (the deterministic
|
|
285
|
+
tier no longer auto-merges any of these — see _entity_dup_sets). Even the
|
|
286
|
+
single-candidate case ('1716801984' co-occurs with exactly one 'Katie
|
|
287
|
+
Cooper') must be confirmed by the adjudicator, because it's
|
|
288
|
+
indistinguishable from 'RTX 6000' co-occurring with exactly one 'Linux'.
|
|
289
|
+
The adjudicator merges only on an affirmative same-entity verdict and fails
|
|
290
|
+
closed (no merge) when the distiller is unreachable. Restricted disclosure
|
|
291
|
+
excluded."""
|
|
272
292
|
cur.execute(
|
|
273
293
|
"""SELECT id, entity_type, canonical_name, aliases, provenance_event_ids
|
|
274
294
|
FROM entities WHERE arena = %s AND disclosure_class <> 'restricted'""",
|
|
@@ -283,7 +303,7 @@ def _ambiguous_cross_run(cur, arena: str) -> list[dict]:
|
|
|
283
303
|
for members in by_event_type.values():
|
|
284
304
|
junk = [m for m in members if C.looks_like_id(m["canonical_name"])]
|
|
285
305
|
non_junk = [m for m in members if not C.looks_like_id(m["canonical_name"])]
|
|
286
|
-
if not junk or len(non_junk) <
|
|
306
|
+
if not junk or len(non_junk) < 1:
|
|
287
307
|
continue
|
|
288
308
|
for j in junk:
|
|
289
309
|
key = (j["id"], tuple(sorted(c["id"] for c in non_junk)))
|
|
@@ -366,14 +386,17 @@ def main() -> int:
|
|
|
366
386
|
merged += len(loser_ids)
|
|
367
387
|
return len(loser_ids)
|
|
368
388
|
|
|
369
|
-
# Tier 1 — deterministic
|
|
389
|
+
# Tier 1 — deterministic: exact normalized-name dupes only
|
|
390
|
+
# (case/whitespace variants). Co-occurrence is NOT auto-merged.
|
|
370
391
|
for group in _entity_dup_sets(cur, args.arena):
|
|
371
392
|
do_merge(group)
|
|
372
393
|
|
|
373
|
-
# Tier 2 — LLM adjudication via the in-VPC distiller (no egress)
|
|
394
|
+
# Tier 2 — LLM adjudication via the in-VPC distiller (no egress).
|
|
395
|
+
# ALL co-occurrence merges live here now — single- and multi-
|
|
396
|
+
# candidate alike — because co-occurrence never proves identity.
|
|
374
397
|
if post_fn:
|
|
375
|
-
# 2a.
|
|
376
|
-
for amb in
|
|
398
|
+
# 2a. co-occurrence: does the junk node match a real entity?
|
|
399
|
+
for amb in _cooccurrence_candidates(cur, args.arena):
|
|
377
400
|
j = amb["junk"]
|
|
378
401
|
jctx = _entity_context(cur, args.arena, j["id"])
|
|
379
402
|
for cand in amb["candidates"]:
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Guards _dedup_master_facts against the over-fusion that deleted 33% of an
|
|
2
|
+
arena's facts (2026-06-14): grouping post-merge facts by (subject, predicate,
|
|
3
|
+
object) alone treats a NULL object + generic predicate as identity and deletes
|
|
4
|
+
distinct assertions. The statement must be part of the dedup key so ONLY
|
|
5
|
+
byte-equal-after-normalization duplicates fuse; same-triple/different-meaning
|
|
6
|
+
facts are left for the LLM semantic tier."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import types
|
|
14
|
+
|
|
15
|
+
HERE = os.path.dirname(__file__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_fuse(monkeypatch):
|
|
19
|
+
fake_psycopg = types.ModuleType("psycopg")
|
|
20
|
+
fake_rows = types.ModuleType("psycopg.rows")
|
|
21
|
+
fake_rows.dict_row = object()
|
|
22
|
+
fake_psycopg.rows = fake_rows
|
|
23
|
+
monkeypatch.setitem(sys.modules, "psycopg", fake_psycopg)
|
|
24
|
+
monkeypatch.setitem(sys.modules, "psycopg.rows", fake_rows)
|
|
25
|
+
monkeypatch.syspath_prepend(os.path.join(HERE, "..", "fusion_drive"))
|
|
26
|
+
monkeypatch.syspath_prepend(HERE)
|
|
27
|
+
sys.modules.pop("fusion_drive_fuse", None)
|
|
28
|
+
return importlib.import_module("fusion_drive_fuse")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FakeCursor:
|
|
32
|
+
"""Returns preset fact rows on the SELECT; records ids passed to DELETE."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, rows):
|
|
35
|
+
self._rows = rows
|
|
36
|
+
self.deleted_ids = []
|
|
37
|
+
|
|
38
|
+
def execute(self, sql, params=None):
|
|
39
|
+
s = " ".join(sql.split())
|
|
40
|
+
if s.startswith("DELETE FROM facts WHERE id = ANY"):
|
|
41
|
+
# params is a 1-tuple holding the id list
|
|
42
|
+
self.deleted_ids.extend(params[0])
|
|
43
|
+
# SELECT / UPDATE / INSERT: no-op (fetchall serves the preset rows)
|
|
44
|
+
|
|
45
|
+
def fetchall(self):
|
|
46
|
+
return self._rows
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fact(fid, predicate, obj, statement, conf):
|
|
50
|
+
return {
|
|
51
|
+
"id": fid,
|
|
52
|
+
"predicate": predicate,
|
|
53
|
+
"object_entity_id": obj,
|
|
54
|
+
"statement": statement,
|
|
55
|
+
"confidence": conf,
|
|
56
|
+
"provenance_event_ids": [f"ev_{fid}"],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_distinct_statements_same_triple_are_NOT_fused(monkeypatch):
|
|
61
|
+
fuse = _load_fuse(monkeypatch)
|
|
62
|
+
rows = [
|
|
63
|
+
_fact("f1", "said", None, "Standing by", 0.9),
|
|
64
|
+
_fact("f2", "said", None, "yeah ship it", 0.8), # distinct meaning
|
|
65
|
+
_fact("f3", "said", None, "modules/deep-memory is vestigial", 0.7),
|
|
66
|
+
]
|
|
67
|
+
cur = FakeCursor(rows)
|
|
68
|
+
deleted = fuse._dedup_master_facts(cur, "arena", "m")
|
|
69
|
+
assert deleted == 0, "must not fuse same-triple facts with different statements"
|
|
70
|
+
assert cur.deleted_ids == []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_only_normalized_statement_duplicates_fuse(monkeypatch):
|
|
74
|
+
fuse = _load_fuse(monkeypatch)
|
|
75
|
+
rows = [
|
|
76
|
+
_fact("f1", "said", None, "Standing by", 0.9),
|
|
77
|
+
_fact("f2", "said", None, "standing by", 0.5), # same after _norm
|
|
78
|
+
_fact("f3", "said", None, "something else entirely", 0.7),
|
|
79
|
+
]
|
|
80
|
+
cur = FakeCursor(rows)
|
|
81
|
+
deleted = fuse._dedup_master_facts(cur, "arena", "m")
|
|
82
|
+
assert deleted == 1, "the case/whitespace duplicate should fuse"
|
|
83
|
+
assert cur.deleted_ids == ["f2"], "lower-confidence true-dupe is the one deleted"
|
|
@@ -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"
|