@pentatonic-ai/ai-agent-sdk 0.10.33 → 0.10.35

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.33";
881
+ var VERSION = "0.10.35";
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.33";
850
+ var VERSION = "0.10.35";
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.33",
3
+ "version": "0.10.35",
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",
@@ -507,6 +507,18 @@ ACTIVE_SYSTEM_PROMPT = (
507
507
  # guided-JSON traces never mix in a training export).
508
508
  SYSTEM_PROMPT_HASH = hashlib.sha256(ACTIVE_SYSTEM_PROMPT.encode()).hexdigest()[:16]
509
509
 
510
+ # Sentinel system_prompt_hash for STUDENT traces. The student runs with NO
511
+ # system prompt, so it has no natural hash — and crucially, every trace CONSUMER
512
+ # keys off the teacher's hash: build_retrain_corpus.py filters
513
+ # `system_prompt_hash = <clean teacher hash>` and redistill.py treats a trace at
514
+ # that hash as "already re-distilled clean". Tagging student traces with a
515
+ # distinct sentinel (never a 16-hex teacher hash) keeps them out of BOTH — so
516
+ # student output can never be mistaken for training gold or a clean re-distill.
517
+ # `llm_model` (= STUDENT_MODEL) also distinguishes them; the sentinel is the
518
+ # belt to that suspenders. These rows exist purely as the student side of the
519
+ # student-vs-teacher agreement corpus.
520
+ STUDENT_TRACE_PROMPT_HASH = f"student:{STUDENT_MODEL}"
521
+
510
522
 
511
523
  # --------------------------------------------------------------------
512
524
  # Content cleaner — strip HTML/CSS so email + doc styling never reaches
@@ -2296,6 +2308,8 @@ def _insert_trace(
2296
2308
  user_prompt: str,
2297
2309
  raw_response: str,
2298
2310
  llm_chunk_ms: float | None,
2311
+ llm_model: str = LLM_MODEL,
2312
+ system_prompt_hash: str = SYSTEM_PROMPT_HASH,
2299
2313
  ) -> None:
2300
2314
  """Append a (user_prompt, raw_response) pair to distillation_traces.
2301
2315
 
@@ -2303,7 +2317,12 @@ def _insert_trace(
2303
2317
  caller wraps this in a try/except and never lets a trace-insert
2304
2318
  failure poison the upsert path. Skip rows with empty raw_response
2305
2319
  (no signal to train on); the model occasionally emits a header
2306
- with no body."""
2320
+ with no body.
2321
+
2322
+ Defaults tag the row as the teacher's training gold. The student path
2323
+ passes llm_model=STUDENT_MODEL + system_prompt_hash=STUDENT_TRACE_PROMPT_HASH
2324
+ so its rows form the agreement corpus WITHOUT being pulled into the retrain
2325
+ corpus / redistill (both key off the teacher hash)."""
2307
2326
  if not raw_response.strip():
2308
2327
  return
2309
2328
  with conn.cursor() as cur:
@@ -2316,7 +2335,7 @@ def _insert_trace(
2316
2335
  """,
2317
2336
  (
2318
2337
  event_id, user_prompt, raw_response,
2319
- LLM_MODEL, SYSTEM_PROMPT_HASH, llm_chunk_ms,
2338
+ llm_model, system_prompt_hash, llm_chunk_ms,
2320
2339
  ),
2321
2340
  )
2322
2341
 
@@ -2551,19 +2570,65 @@ def claim_next_batch(conn: psycopg.Connection) -> list[dict[str, Any]]:
2551
2570
  -- increment lived here and ~3 deploys could push a row to
2552
2571
  -- attempts=MAX, making it forever-ineligible for reclaim AND never
2553
2572
  -- marked failed → orphaned in `claimed`. See gotcha #11.)
2573
+ -- FAIR-SHARE CLAIM (migration 012). Was `ORDER BY id` — global
2574
+ -- FIFO, so one tenant's 20k-event backfill head-of-line-blocked
2575
+ -- every other tenant. Now each client is a logical queue ordered
2576
+ -- by (priority, emit_ts) — priority is client-set per event
2577
+ -- (envelope attribute high/medium/low → 10/100/200, enriched onto
2578
+ -- the row by trigger) — and the batch interleaves clients
2579
+ -- round-robin: rank rn=1 of every client, then rn=2, ... so every
2580
+ -- active client gets a proportional slice of each claim.
2581
+ --
2582
+ -- Shape: per-client candidates come from a bounded LATERAL scan on
2583
+ -- idx_distillation_fair_claim (client, priority, emit_ts) — the
2584
+ -- window function never sees more than clients x BATCH_SIZE rows.
2585
+ -- FOR UPDATE cannot sit in the same query level as a window
2586
+ -- function, so the ranked ids are re-locked in an inner
2587
+ -- re-check (status/lease guards repeated) — a concurrently-taken
2588
+ -- row is simply skipped, same semantics as before (a claim may
2589
+ -- return fewer than BATCH_SIZE under contention).
2554
2590
  WHERE id IN (
2555
2591
  SELECT id FROM distillation_queue
2556
- WHERE (
2592
+ WHERE id = ANY (ARRAY(
2593
+ SELECT cand.id FROM (
2594
+ SELECT x.id,
2595
+ ROW_NUMBER() OVER (
2596
+ PARTITION BY c.client
2597
+ ORDER BY x.priority, x.emit_ts, x.id
2598
+ ) AS rn,
2599
+ x.priority, x.emit_ts
2600
+ FROM (
2601
+ SELECT DISTINCT client FROM distillation_queue
2602
+ WHERE (
2603
+ status = 'pending'
2604
+ OR (status = 'claimed' AND claim_expires_at < NOW())
2605
+ ) AND attempts < %s
2606
+ ) c
2607
+ CROSS JOIN LATERAL (
2608
+ SELECT id, COALESCE(priority, 100) AS priority, emit_ts
2609
+ FROM distillation_queue
2610
+ WHERE client IS NOT DISTINCT FROM c.client
2611
+ AND (
2612
+ status = 'pending'
2613
+ OR (status = 'claimed' AND claim_expires_at < NOW())
2614
+ ) AND attempts < %s
2615
+ ORDER BY COALESCE(priority, 100), emit_ts, id
2616
+ LIMIT %s
2617
+ ) x
2618
+ ) cand
2619
+ ORDER BY cand.rn, cand.priority, cand.emit_ts, cand.id
2620
+ LIMIT %s
2621
+ ))
2622
+ AND (
2557
2623
  status = 'pending'
2558
2624
  OR (status = 'claimed' AND claim_expires_at < NOW())
2559
2625
  ) AND attempts < %s
2560
- ORDER BY id
2561
2626
  FOR UPDATE SKIP LOCKED
2562
- LIMIT %s
2563
2627
  )
2564
2628
  RETURNING id, event_id, attempts
2565
2629
  """,
2566
- (WORKER_ID, CLAIM_TTL_SEC, MAX_ATTEMPTS, BATCH_SIZE),
2630
+ (WORKER_ID, CLAIM_TTL_SEC, MAX_ATTEMPTS, MAX_ATTEMPTS,
2631
+ BATCH_SIZE, BATCH_SIZE, MAX_ATTEMPTS),
2567
2632
  )
2568
2633
  return cur.fetchall()
2569
2634
 
@@ -2748,6 +2813,30 @@ async def _process_cascade(
2748
2813
  if escalate:
2749
2814
  escalate_items.append(item)
2750
2815
  reason_by_qid[item["id"]] = reason or "escalated"
2816
+ # Capture the student's (discarded) output on escalated events —
2817
+ # the ONLY place the teacher ALSO runs, so the ONLY place a
2818
+ # student-vs-teacher diff is possible. This is the agreement corpus
2819
+ # that makes student quality measurable (esp. the random_sample
2820
+ # subset = an unbiased paired sample of student-passing traffic, and
2821
+ # the high_value_class lane the student now carries). Tagged student
2822
+ # (model + sentinel hash) so no consumer mistakes it for teacher
2823
+ # gold. sresult is None only on student_parse_fail (nothing to
2824
+ # trace). Best-effort — never blocks escalation.
2825
+ if DISTILL_TRACE_ENABLED and event is not None and sresult is not None:
2826
+ try:
2827
+ _insert_trace(
2828
+ conn,
2829
+ event_id=item["event_id"], # same key the teacher trace uses → rows pair
2830
+ user_prompt=build_event_block(0, event),
2831
+ raw_response=sresult.get("raw_slice", ""),
2832
+ llm_chunk_ms=None,
2833
+ llm_model=STUDENT_MODEL,
2834
+ system_prompt_hash=STUDENT_TRACE_PROMPT_HASH,
2835
+ )
2836
+ except Exception as trace_exc:
2837
+ log.warning(
2838
+ f"student trace insert failed event_id={event.get('id')}: {trace_exc}"
2839
+ )
2751
2840
  else:
2752
2841
  _apply_extraction(
2753
2842
  conn, item=item, event=event, result=sresult, llm_ms=0.0,
@@ -0,0 +1,88 @@
1
+ -- 012_queue_priority_fair_share.sql — client-set priority + per-client fair
2
+ -- usage for the distillation queue.
3
+ --
4
+ -- The claim was global FIFO (ORDER BY id): one tenant's 20k-event Drive dump
5
+ -- head-of-line-blocked every other tenant — at 30-40-user onboarding scale the
6
+ -- last user's memory would go live only after everyone ahead of them drained.
7
+ --
8
+ -- Model (decided 2026-07-09): every client effectively gets their OWN queue,
9
+ -- drained round-robin across clients; within a client, items order by
10
+ -- (priority, emit timestamp). Clients set priority per event at emit via the
11
+ -- envelope attribute `priority: "high" | "medium" | "low"` (default medium).
12
+ -- Implemented logically in the one queue table (PARTITION BY client in the
13
+ -- claim) rather than physical per-client tables — identical semantics, none of
14
+ -- the per-tenant DDL/monitoring sprawl; PG LIST-partitioning by client remains
15
+ -- a drop-in physical upgrade under the same claim SQL if ever needed.
16
+ --
17
+ -- Lower priority value = drained first: high=10, medium=100, low=200 (gaps
18
+ -- leave room for future classes without re-mapping).
19
+ --
20
+ -- The queue row is enriched by a BEFORE INSERT trigger (client = arena prefix,
21
+ -- emit_ts = events.emitted_at, priority = mapped envelope attribute unless the
22
+ -- writer set one explicitly). A trigger — not writer-side code — so EVERY
23
+ -- writer (extractor-sync enqueue, the redistill gardener, backfill scripts)
24
+ -- gets consistent enrichment with no emitter/TES changes.
25
+ --
26
+ -- On an already-initialised prod DB (migrations only auto-run on a fresh
27
+ -- volume): apply the ALTERs + trigger + backfill via psql, and build the index
28
+ -- with CREATE INDEX CONCURRENTLY.
29
+
30
+ ALTER TABLE distillation_queue
31
+ ADD COLUMN IF NOT EXISTS client TEXT,
32
+ ADD COLUMN IF NOT EXISTS priority SMALLINT,
33
+ ADD COLUMN IF NOT EXISTS emit_ts TIMESTAMPTZ;
34
+
35
+ CREATE OR REPLACE FUNCTION distillation_queue_enrich() RETURNS trigger AS $$
36
+ DECLARE
37
+ ev RECORD;
38
+ BEGIN
39
+ IF NEW.client IS NULL OR NEW.emit_ts IS NULL OR NEW.priority IS NULL THEN
40
+ SELECT split_part(e.arena, ':', 1) AS client,
41
+ e.emitted_at AS emitted_at,
42
+ lower(e.attributes->>'priority') AS prio
43
+ INTO ev
44
+ FROM events e WHERE e.id = NEW.event_id;
45
+ IF FOUND THEN
46
+ NEW.client := COALESCE(NEW.client, ev.client);
47
+ NEW.emit_ts := COALESCE(NEW.emit_ts, ev.emitted_at);
48
+ -- An explicit writer-set priority wins (e.g. the redistill gardener
49
+ -- self-demoting to low); otherwise the client's envelope attribute;
50
+ -- otherwise medium.
51
+ NEW.priority := COALESCE(
52
+ NEW.priority,
53
+ CASE ev.prio WHEN 'high' THEN 10 WHEN 'medium' THEN 100 WHEN 'low' THEN 200 ELSE 100 END
54
+ );
55
+ ELSE
56
+ -- Orphan enqueue (no event row): defaults keep the row claimable.
57
+ NEW.client := COALESCE(NEW.client, 'unknown');
58
+ NEW.emit_ts := COALESCE(NEW.emit_ts, NOW());
59
+ NEW.priority := COALESCE(NEW.priority, 100);
60
+ END IF;
61
+ END IF;
62
+ RETURN NEW;
63
+ END;
64
+ $$ LANGUAGE plpgsql;
65
+
66
+ DROP TRIGGER IF EXISTS trg_distillation_queue_enrich ON distillation_queue;
67
+ CREATE TRIGGER trg_distillation_queue_enrich
68
+ BEFORE INSERT ON distillation_queue
69
+ FOR EACH ROW EXECUTE FUNCTION distillation_queue_enrich();
70
+
71
+ -- Backfill live rows (pending/claimed). done/failed rows are history — the
72
+ -- daily prune retires them and the claim never reads them.
73
+ UPDATE distillation_queue dq
74
+ SET client = COALESCE(dq.client, split_part(e.arena, ':', 1)),
75
+ emit_ts = COALESCE(dq.emit_ts, e.emitted_at),
76
+ priority = COALESCE(dq.priority,
77
+ CASE lower(e.attributes->>'priority')
78
+ WHEN 'high' THEN 10 WHEN 'medium' THEN 100 WHEN 'low' THEN 200 ELSE 100 END)
79
+ FROM events e
80
+ WHERE dq.event_id = e.id
81
+ AND dq.status IN ('pending', 'claimed')
82
+ AND (dq.client IS NULL OR dq.emit_ts IS NULL OR dq.priority IS NULL);
83
+
84
+ -- Serves both halves of the fair-share claim: the DISTINCT-client scan and
85
+ -- each client's (priority, emit_ts)-ordered candidate scan.
86
+ CREATE INDEX IF NOT EXISTS idx_distillation_fair_claim
87
+ ON distillation_queue (client, priority, emit_ts)
88
+ WHERE status = 'pending';
@@ -212,8 +212,12 @@ def main() -> int:
212
212
  if args.supersede_facts:
213
213
  deleted += dump_and_delete_solo_facts(cur, eid, rb)
214
214
  cur.execute(
215
- "INSERT INTO distillation_queue (event_id, status, attempts) "
216
- "VALUES (%s, 'pending', 0)", (eid,))
215
+ # priority=200 (low): re-distills are maintenance — they
216
+ # must never compete with live client traffic. Explicit
217
+ # value wins over the enrichment trigger's mapping
218
+ # (migration 012).
219
+ "INSERT INTO distillation_queue (event_id, status, attempts, priority) "
220
+ "VALUES (%s, 'pending', 0, 200)", (eid,))
217
221
  enq += 1
218
222
  led.write(json.dumps({
219
223
  "event_id": eid, "arena": args.arena,