@pentatonic-ai/ai-agent-sdk 0.7.1 → 0.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/ai-agent-sdk",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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",
@@ -85,6 +85,19 @@ class SearchRequest(BaseModel):
85
85
  query: str
86
86
  limit: Optional[int] = 10
87
87
  min_score: Optional[float] = 0.001
88
+ # Tenant scope. Required for multi-tenant deployments. Forwarded to
89
+ # layers that support arena filtering natively (L6); applied as a
90
+ # post-filter on the shim for layers that don't yet (L2, L4, L5).
91
+ # When unset, search is global — same behaviour as v0.7.x; safe for
92
+ # single-tenant deployments. Multi-tenant callers MUST set this.
93
+ arena: Optional[str] = None
94
+ # Arbitrary metadata equality filters, applied as a post-filter on
95
+ # the shim. Useful for `kind`, `layer_type`, `source_repo`, etc.
96
+ # Keys not present on a result's metadata are treated as no-match.
97
+ # Each pair is exact string equality. Engine doesn't currently
98
+ # forward these to underlying stores, so over-fetch happens; the
99
+ # shim trims to the requested limit after filtering.
100
+ metadata_filter: Optional[dict[str, Any]] = None
88
101
 
89
102
 
90
103
  class ForgetRequest(BaseModel):
@@ -424,6 +437,51 @@ async def store_batch(req: StoreBatchRequest):
424
437
  }
425
438
 
426
439
 
440
+ def _apply_metadata_filters(results: list[dict[str, Any]], req: SearchRequest) -> list[dict[str, Any]]:
441
+ """Post-filter results by arena + arbitrary metadata equality.
442
+
443
+ Many layer searches don't yet honour arena/metadata at the storage
444
+ level, so the shim enforces tenant isolation here as defence in
445
+ depth. Even if the underlying layer leaks across arenas, the shim
446
+ drops cross-tenant rows before returning.
447
+ """
448
+ arena = req.arena
449
+ extra = req.metadata_filter or {}
450
+ if not arena and not extra:
451
+ return results
452
+ out: list[dict[str, Any]] = []
453
+ for item in results:
454
+ meta = item.get("metadata") or {}
455
+ if arena:
456
+ row_arena = meta.get("arena") or item.get("arena")
457
+ if row_arena and row_arena != arena:
458
+ continue
459
+ # If row has no arena tag at all, drop on multi-tenant
460
+ # safety: a row without arena predates the multi-tenant
461
+ # plumbing and could belong to anyone.
462
+ if arena and not row_arena:
463
+ continue
464
+ ok = True
465
+ for k, v in extra.items():
466
+ if str(meta.get(k, "")) != str(v):
467
+ ok = False
468
+ break
469
+ if ok:
470
+ out.append(item)
471
+ return out
472
+
473
+
474
+ def _search_overfetch(req: SearchRequest) -> int:
475
+ """Decide how many results to over-fetch from layers.
476
+
477
+ Post-filtering can drop many rows; we ask layers for more than the
478
+ user's limit so we have headroom after filtering. 5x is a balance
479
+ between accuracy and latency.
480
+ """
481
+ base = req.limit or 10
482
+ return base * 5 if (req.arena or req.metadata_filter) else base * 3
483
+
484
+
427
485
  @app.post("/search")
428
486
  async def search(req: SearchRequest):
429
487
  """
@@ -431,6 +489,12 @@ async def search(req: SearchRequest):
431
489
  queries L0 BM25, L4 vec, L5 Milvus, L6 doc-store in parallel and fuses
432
490
  the results with Reciprocal Rank Fusion. L3 KG adds entity-aware
433
491
  boosting for graph queries.
492
+
493
+ Multi-tenancy: pass `arena` to scope results to a single tenant.
494
+ Underlying layers may or may not honour arena natively (L6 does;
495
+ L2/L4/L5 don't yet — engine TODO); the shim applies arena as a
496
+ post-filter regardless, so cross-tenant leakage is prevented even
497
+ when a layer is non-compliant.
434
498
  """
435
499
  if not req.query:
436
500
  return {"results": []}
@@ -452,10 +516,19 @@ async def search(req: SearchRequest):
452
516
  import asyncio
453
517
  async def _q_l6(query: str):
454
518
  try:
519
+ params: dict[str, Any] = {
520
+ "q": query,
521
+ "limit": _search_overfetch(req),
522
+ "method": "hybrid",
523
+ }
524
+ if req.arena:
525
+ # L6 supports arena natively (l6-document-store.py:837).
526
+ # Forward it so the underlying Milvus query and FTS
527
+ # query both filter to this tenant before returning.
528
+ params["arena"] = req.arena
455
529
  r = await _client().get(
456
530
  f"{L6_DOC_URL}/search",
457
- params={"q": query, "limit": (req.limit or 10) * 3,
458
- "method": "hybrid"},
531
+ params=params,
459
532
  timeout=30.0,
460
533
  )
461
534
  r.raise_for_status()
@@ -544,11 +617,14 @@ async def search(req: SearchRequest):
544
617
  "source": item.get("source_file") or item.get("path") or "",
545
618
  "engine_layer": "+".join(sorted(set(layer_provenance.get(key, [])))),
546
619
  })
547
- return {"results": out_results}
620
+ # Defense-in-depth post-filter (arena + arbitrary metadata),
621
+ # then trim to the requested limit.
622
+ out_results = _apply_metadata_filters(out_results, req)
623
+ return {"results": out_results[: req.limit or 10]}
548
624
  try:
549
625
  r = await _client().get(
550
626
  f"{L2_PROXY_URL}/search",
551
- params={"q": req.query, "limit": req.limit or 10},
627
+ params={"q": req.query, "limit": _search_overfetch(req)},
552
628
  timeout=30.0,
553
629
  )
554
630
  r.raise_for_status()
@@ -558,7 +634,7 @@ async def search(req: SearchRequest):
558
634
  try:
559
635
  r = await _client().post(
560
636
  f"{L2_PROXY_URL}/v1/search",
561
- json={"query": req.query, "limit": req.limit or 10,
637
+ json={"query": req.query, "limit": _search_overfetch(req),
562
638
  "min_score": req.min_score or 0.001},
563
639
  timeout=30.0,
564
640
  )
@@ -567,9 +643,14 @@ async def search(req: SearchRequest):
567
643
  except Exception as exc2:
568
644
  last_err = exc2
569
645
  try:
646
+ params: dict[str, Any] = {"q": req.query, "limit": _search_overfetch(req)}
647
+ # L6 supports arena natively; forward it on the
648
+ # last-resort fallback path too.
649
+ if req.arena:
650
+ params["arena"] = req.arena
570
651
  r = await _client().get(
571
652
  f"{L6_DOC_URL}/search",
572
- params={"q": req.query, "limit": req.limit or 10},
653
+ params=params,
573
654
  timeout=10.0,
574
655
  )
575
656
  r.raise_for_status()
@@ -621,7 +702,10 @@ async def search(req: SearchRequest):
621
702
  "source": item.get("source", item.get("source_file", "")),
622
703
  "engine_layer": item.get("layer", item.get("source_layer", "")),
623
704
  })
624
- return {"results": out_results}
705
+ # Defense-in-depth post-filter (arena + arbitrary metadata) on L2/L6
706
+ # fallback paths. Same logic as the BYPASS branch above.
707
+ out_results = _apply_metadata_filters(out_results, req)
708
+ return {"results": out_results[: req.limit or 10]}
625
709
 
626
710
 
627
711
  @app.post("/forget")