centive-sdk 2.2.0.dev5__py3-none-any.whl → 2.2.0.dev8__py3-none-any.whl

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.
centive_sdk/config.py CHANGED
@@ -83,6 +83,12 @@ class ClientConfig(BaseModel):
83
83
  max_session_id_chars: int = 200
84
84
  # Concurrent websocket connections accepted per user.
85
85
  max_connections_per_user: int = 5
86
+ # How long the connect-time session trigger waits for the browser SDK to
87
+ # ask for a session itself before firing. Browser SDKs since 1.0 ask the
88
+ # moment the socket opens, and their request is the one that carries the
89
+ # page the user has open; firing first meant two Centive calls and two Anam
90
+ # tokens per connection. 0 restores the old immediate trigger.
91
+ session_trigger_grace_seconds: float = 0.5
86
92
 
87
93
  # Page-telemetry frames (type="telemetry") from the FE SDK. These mirror the
88
94
  # aria-sdk producer's own caps (MAX_EVENTS_PER_FRAME, MAX_FRAME_BYTES,
@@ -20,6 +20,14 @@ class TriggerSessionRequest(BaseModel):
20
20
  user_trigger: Optional[bool] = Field(
21
21
  None, description="optional trigger to force session creation"
22
22
  )
23
+ # The page the browser has open at the moment of the request. Centive builds
24
+ # the opening prompt from it instead of from stored page views, which travel
25
+ # through the telemetry batcher and were measured arriving 1.3 s after this
26
+ # call. Optional: an older browser SDK sends none, and the websocket server
27
+ # then falls back to the last page_view it saw on the connection.
28
+ current_page: Optional["TelemetryPage"] = Field(
29
+ None, description="page the browser reports having open when it asks for a session"
30
+ )
23
31
 
24
32
 
25
33
  class SessionMessage(BaseModel):
@@ -107,6 +115,9 @@ class TelemetryPage(BaseModel):
107
115
  referrer_path: Optional[str] = Field(default=None, max_length=TELEMETRY_MAX_PATH_CHARS)
108
116
 
109
117
 
118
+ TriggerSessionRequest.model_rebuild()
119
+
120
+
110
121
  class TelemetryEvent(BaseModel):
111
122
  """one C1 event as produced by the browser SDK; forwarded to Centive unchanged."""
112
123
 
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import inspect
2
3
  import json
3
4
  import secrets
@@ -17,6 +18,7 @@ from ..models.requests import (
17
18
  SessionMessage,
18
19
  SessionMetadata,
19
20
  TelemetryEvent,
21
+ TelemetryPage,
20
22
  TriggerSessionRequest,
21
23
  )
22
24
 
@@ -77,6 +79,18 @@ class WebSocketServer:
77
79
  self._pending_users: Deque[str] = deque() # legacy "open" mode only
78
80
  self._connection_tokens: Dict[str, Tuple[str, float]] = {} # token -> (user_id, expiry)
79
81
  self._connection_users: Dict[WebSocketServerProtocol, str] = {}
82
+ # Last page_view seen on each connection, forwarded as trigger-session's
83
+ # current_page when the FE's user_trigger frame carries no page of its
84
+ # own (older browser SDKs). Bounded by the number of live connections.
85
+ self._connection_last_page: Dict[WebSocketServerProtocol, dict] = {}
86
+ # One Centive trigger-session call per connection start, not two: the
87
+ # connect-time automatic trigger is held back for a grace period and
88
+ # stands down when the client asked itself (_auto_trigger_after_grace);
89
+ # a request that lands while another trigger for the same connection is
90
+ # in flight waits for it and receives the same frame.
91
+ self._connection_trigger_tasks: Dict[WebSocketServerProtocol, asyncio.Task] = {}
92
+ self._connection_triggered: set = set()
93
+ self._connection_trigger_inflight: Dict[WebSocketServerProtocol, asyncio.Future] = {}
80
94
  # Every session key touched by a connection, so a disconnect saves all of
81
95
  # them rather than only the most recent one.
82
96
  self._connection_sessions: Dict[WebSocketServerProtocol, Set[str]] = {}
@@ -340,6 +354,7 @@ class WebSocketServer:
340
354
  stale = [ws for ws in self._connection_users if not self._is_live(ws)]
341
355
  for ws in stale:
342
356
  self._connection_users.pop(ws, None)
357
+ self._connection_last_page.pop(ws, None)
343
358
  self._clients.discard(ws)
344
359
 
345
360
  return sum(1 for bound in self._connection_users.values() if bound == user_id)
@@ -563,7 +578,9 @@ class WebSocketServer:
563
578
  {"client": client_info, "user_id": user_id},
564
579
  )
565
580
 
566
- await self._trigger_and_emit(websocket, user_id, None, client_info)
581
+ self._connection_trigger_tasks[websocket] = asyncio.create_task(
582
+ self._auto_trigger_after_grace(websocket, user_id, client_info)
583
+ )
567
584
 
568
585
  try:
569
586
  async for message in websocket:
@@ -592,6 +609,12 @@ class WebSocketServer:
592
609
  # immediately after a drop).
593
610
  self._clients.discard(websocket)
594
611
  self._connection_users.pop(websocket, None)
612
+ self._connection_last_page.pop(websocket, None)
613
+ self._connection_triggered.discard(websocket)
614
+ self._connection_trigger_inflight.pop(websocket, None)
615
+ pending_trigger = self._connection_trigger_tasks.pop(websocket, None)
616
+ if pending_trigger is not None:
617
+ pending_trigger.cancel()
595
618
  self._telemetry_rate.forget(websocket)
596
619
  session_keys = self._connection_sessions.pop(websocket, set())
597
620
 
@@ -1033,6 +1056,8 @@ class WebSocketServer:
1033
1056
  }))
1034
1057
  return
1035
1058
 
1059
+ self._remember_page(websocket, data["events"])
1060
+
1036
1061
  batcher_active = self._telemetry is not None and self._config.telemetry_enabled
1037
1062
  enqueued = 0
1038
1063
  queue_dropped = 0
@@ -1394,7 +1419,75 @@ class WebSocketServer:
1394
1419
  return
1395
1420
 
1396
1421
  user_trigger = data.get("user_trigger")
1397
- await self._trigger_and_emit(websocket, user_id, user_trigger, client_info)
1422
+ # The page the browser has open right now, so Centive's opening prompt
1423
+ # starts from it rather than from a page_view still waiting in the
1424
+ # batcher. The frame's own page wins; the last page_view seen on this
1425
+ # connection stands in for browser SDKs that predate the field.
1426
+ current_page = self._coerce_page(data.get("page")) or self._connection_last_page.get(websocket)
1427
+ # The client asked itself, so the connect-time automatic trigger, if it
1428
+ # is still waiting out its grace period, has nothing left to do.
1429
+ self._connection_triggered.add(websocket)
1430
+ await self._trigger_and_emit(
1431
+ websocket, user_id, user_trigger, client_info, current_page=current_page
1432
+ )
1433
+
1434
+ async def _auto_trigger_after_grace(
1435
+ self, websocket: WebSocketServerProtocol, user_id: str, client_info: str
1436
+ ) -> None:
1437
+ """The connect-time session trigger, held back briefly.
1438
+
1439
+ Browser SDKs since 1.0 ask for a token themselves the moment the socket
1440
+ opens, and that request is the one that carries the page the user has
1441
+ open. Firing the automatic trigger first (and before the message loop
1442
+ even started reading) meant two Centive calls and two Anam tokens per
1443
+ connection, with the opening prompt built from the page-less one. Now
1444
+ the automatic trigger waits config.session_trigger_grace_seconds and
1445
+ stands down when the client has asked in the meantime. A client that
1446
+ never asks (older SDKs, custom frontends) still gets its session, half
1447
+ a second later.
1448
+ """
1449
+ try:
1450
+ grace = max(0.0, float(self._config.session_trigger_grace_seconds or 0))
1451
+ if grace:
1452
+ await asyncio.sleep(grace)
1453
+ if websocket in self._connection_triggered or not self._is_live(websocket):
1454
+ safe_log(
1455
+ self._logger,
1456
+ "info",
1457
+ "automatic session trigger stood down",
1458
+ {"client": client_info, "user_id": user_id,
1459
+ "reason": "client asked" if websocket in self._connection_triggered else "connection gone"},
1460
+ )
1461
+ return
1462
+ await self._trigger_and_emit(websocket, user_id, None, client_info)
1463
+ except asyncio.CancelledError:
1464
+ return
1465
+ finally:
1466
+ self._connection_trigger_tasks.pop(websocket, None)
1467
+
1468
+ def _remember_page(self, websocket: WebSocketServerProtocol, events: Any) -> None:
1469
+ """Keeps the newest page_view's page for this connection (see _handle_user_trigger)."""
1470
+ if not isinstance(events, list):
1471
+ return
1472
+ for event in reversed(events):
1473
+ if isinstance(event, dict) and event.get("event_type") == "page_view":
1474
+ page = self._coerce_page(event.get("page"))
1475
+ if page is not None:
1476
+ self._connection_last_page[websocket] = page
1477
+ return
1478
+
1479
+ @staticmethod
1480
+ def _coerce_page(value: Any) -> Optional[dict]:
1481
+ """A C1 page object reduced to the fields Centive accepts, or None.
1482
+
1483
+ Never raises: a malformed hint must not cost the user their session.
1484
+ """
1485
+ if not isinstance(value, dict):
1486
+ return None
1487
+ try:
1488
+ return TelemetryPage.model_validate(value).model_dump(exclude_none=True)
1489
+ except Exception:
1490
+ return None
1398
1491
 
1399
1492
  async def _trigger_and_emit(
1400
1493
  self,
@@ -1402,19 +1495,65 @@ class WebSocketServer:
1402
1495
  user_id: str,
1403
1496
  user_trigger: Optional[bool],
1404
1497
  client_info: str,
1498
+ current_page: Optional[dict] = None,
1405
1499
  ) -> None:
1406
- """calls trigger_session API and emits response to FE."""
1500
+ """calls trigger_session API and emits response to FE.
1501
+
1502
+ Two triggers for the same connection at once (the automatic one mid
1503
+ call when the client's own request lands) share one Centive call: the
1504
+ second waits for the first and re-emits its frame.
1505
+ """
1506
+ inflight = self._connection_trigger_inflight.get(websocket)
1507
+ if inflight is not None:
1508
+ safe_log(
1509
+ self._logger,
1510
+ "info",
1511
+ "session trigger coalesced with one in flight",
1512
+ {"client": client_info, "user_id": user_id},
1513
+ )
1514
+ try:
1515
+ frame = await asyncio.shield(inflight)
1516
+ except Exception: # noqa: BLE001 - the first caller already reported it
1517
+ frame = None
1518
+ if frame is not None:
1519
+ try:
1520
+ await websocket.send(frame)
1521
+ except Exception: # noqa: BLE001
1522
+ pass
1523
+ return
1524
+
1525
+ done: asyncio.Future = asyncio.get_running_loop().create_future()
1526
+ self._connection_trigger_inflight[websocket] = done
1527
+ emitted: Optional[str] = None
1528
+ try:
1529
+ emitted = await self._trigger_once(websocket, user_id, user_trigger, client_info, current_page)
1530
+ finally:
1531
+ self._connection_trigger_inflight.pop(websocket, None)
1532
+ if not done.done():
1533
+ done.set_result(emitted)
1534
+
1535
+ async def _trigger_once(
1536
+ self,
1537
+ websocket: WebSocketServerProtocol,
1538
+ user_id: str,
1539
+ user_trigger: Optional[bool],
1540
+ client_info: str,
1541
+ current_page: Optional[dict],
1542
+ ) -> Optional[str]:
1543
+ """One trigger-session call. Returns the success frame it sent, if any."""
1407
1544
  safe_log(
1408
1545
  self._logger,
1409
1546
  "info",
1410
1547
  "triggering session",
1411
- {"client": client_info, "user_id": user_id, "user_trigger": user_trigger},
1548
+ {"client": client_info, "user_id": user_id, "user_trigger": user_trigger,
1549
+ "has_page": current_page is not None},
1412
1550
  )
1413
1551
 
1414
1552
  try:
1415
1553
  request = TriggerSessionRequest(
1416
1554
  user_id=user_id,
1417
1555
  user_trigger=user_trigger,
1556
+ current_page=current_page,
1418
1557
  )
1419
1558
 
1420
1559
  response = await self._sessions.trigger_session(request)
@@ -1438,7 +1577,7 @@ class WebSocketServer:
1438
1577
  "message": "failed to trigger session",
1439
1578
  "error": "UPSTREAM_TRIGGER_FAILED",
1440
1579
  }))
1441
- return
1580
+ return None
1442
1581
 
1443
1582
  response_data = {
1444
1583
  "status": response.status,
@@ -1447,7 +1586,8 @@ class WebSocketServer:
1447
1586
  "time_taken": response.time_taken,
1448
1587
  }
1449
1588
 
1450
- await websocket.send(json.dumps(response_data))
1589
+ frame = json.dumps(response_data)
1590
+ await websocket.send(frame)
1451
1591
 
1452
1592
  safe_log(
1453
1593
  self._logger,
@@ -1460,6 +1600,7 @@ class WebSocketServer:
1460
1600
  "has_session_data": response.session_data is not None,
1461
1601
  },
1462
1602
  )
1603
+ return frame
1463
1604
 
1464
1605
  except Exception as e:
1465
1606
  # Generic on the wire, detailed in the server's own logs: upstream
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: centive-sdk
3
- Version: 2.2.0.dev5
3
+ Version: 2.2.0.dev8
4
4
  Summary: Python SDK for Centive backend integration
5
5
  Project-URL: Homepage, https://github.com/TheAgenticAI/centive-backend-sdk
6
6
  Project-URL: Repository, https://github.com/TheAgenticAI/centive-backend-sdk
@@ -4,11 +4,11 @@ centive_sdk/_logging.py,sha256=z8Kdi4vJdko0nkMeySGSHwPkEB2E7ucklc9FeJCbyOw,3518
4
4
  centive_sdk/_retry.py,sha256=Q1cIFZyimj0RS4x14FEOseqpORCSydvJyzGiIPfDn7E,1755
5
5
  centive_sdk/async_client.py,sha256=RRoCJrvdeLwQz5UJv2EjmlgTq7vntSV9ELMWHyNwJ5I,9355
6
6
  centive_sdk/client.py,sha256=Evtsng8cqqRSoV54MI2IKbEW1q0vPOkxlz9zM0evH7k,3249
7
- centive_sdk/config.py,sha256=HX0N7UCu9QLS5bICW_iME1LeBBG83Za2-cYoV-9VgLA,6187
7
+ centive_sdk/config.py,sha256=1GoHkwJebL1HknSa-y4oFhxDAaG0boq0JY0csNV-pfE,6612
8
8
  centive_sdk/exceptions.py,sha256=wFy-hU0TJ0dHa6TEDI7FIQu9Lbg5aMeWxW9E-IMMUbI,1841
9
9
  centive_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  centive_sdk/models/__init__.py,sha256=-ledNpbOi1Q14ELhvlDbGPF37N3yv5XJ-QsH2I6UMVQ,598
11
- centive_sdk/models/requests.py,sha256=6KW5KEjyMAy-DAppTssu5b-LSf1doKtMnOfng5O3tNc,7153
11
+ centive_sdk/models/requests.py,sha256=_SqXf-8wejbAVQOMf6Lk3W0q3F3yu401mFvB8dMhwp4,7736
12
12
  centive_sdk/models/responses.py,sha256=zCYPB0aL2DXn-z0Febkuxo3DdZ_5sdpdGy2Jtf8Ubs8,2519
13
13
  centive_sdk/resources/__init__.py,sha256=GLutz38X4F4FwPfSZy35EkWTV7l7t9RgcBKwXmz2X_Y,179
14
14
  centive_sdk/resources/async_sessions.py,sha256=m0_gj2wLabs3BTe6rZuo9WGgSwEMJLmdKW8_s4T7KRM,21500
@@ -16,8 +16,8 @@ centive_sdk/resources/async_telemetry.py,sha256=wT12g9rG2dp_ml5YpAYCxobLlhfcsyy-
16
16
  centive_sdk/resources/message_accumulator.py,sha256=Or3sMGkXG9upeQ9sm8YWL_V5vvsxtnE9tu52VBUUGgY,19261
17
17
  centive_sdk/resources/sessions.py,sha256=Ipg4bpmpejA5WoALbT49ah0c3I7x4OgPn9tSmfW8JAw,16741
18
18
  centive_sdk/resources/telemetry_batcher.py,sha256=OkOv2Iir6E_9mdtAKvelol3RaveGSlH-ckIoE2cQYCc,14629
19
- centive_sdk/resources/websocket_server.py,sha256=iGS48oB4myV-sI0nArlBE3Y0fe_GlfBClFtVAPzrvdU,59760
20
- centive_sdk-2.2.0.dev5.dist-info/METADATA,sha256=6hTQ3pohxlZnB5dEEwBLq5vngFLK5LhX7NXgPMp9HOM,36427
21
- centive_sdk-2.2.0.dev5.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
22
- centive_sdk-2.2.0.dev5.dist-info/licenses/LICENSE,sha256=9dFibPr7z9srhWwd35lQL2MX1Kx2qivEZdVQb9L8Q-Q,1064
23
- centive_sdk-2.2.0.dev5.dist-info/RECORD,,
19
+ centive_sdk/resources/websocket_server.py,sha256=RTkM1YyD7afNWs4KpoboCo-NI2TFgk8IkzaH0KAKR5A,66518
20
+ centive_sdk-2.2.0.dev8.dist-info/METADATA,sha256=5DM9IFShg7CIqzt5jQ8goRocZXmOCJtELTUWVUgVSZY,36427
21
+ centive_sdk-2.2.0.dev8.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
22
+ centive_sdk-2.2.0.dev8.dist-info/licenses/LICENSE,sha256=9dFibPr7z9srhWwd35lQL2MX1Kx2qivEZdVQb9L8Q-Q,1064
23
+ centive_sdk-2.2.0.dev8.dist-info/RECORD,,