rippletide-python 0.2.0__py3-none-any.whl → 0.3.1__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.
rippletide/client.py CHANGED
@@ -1,22 +1,26 @@
1
- """The small synchronous Python counterpart to rippletide-package/sdk.
1
+ """The small Python counterpart to rippletide-package/sdk.
2
2
 
3
3
  The client intentionally has no provider dependency. It can surround a raw
4
- urllib/requests/httpx provider call and never owns the customer's traffic.
4
+ urllib/requests/httpx provider call, synchronous or asynchronous, streamed or
5
+ not, and never owns the customer's traffic.
5
6
  """
6
7
 
7
8
  from __future__ import annotations
8
9
 
10
+ import asyncio
11
+ import inspect
9
12
  import json
10
13
  import math
11
14
  import os
12
15
  import re
16
+ import sys
13
17
  import time
14
18
  import uuid
15
19
  from hashlib import sha256
16
20
  from http.client import HTTPException
17
21
  from contextlib import contextmanager, nullcontext
18
22
  from datetime import datetime, timezone
19
- from typing import Any, Callable, Iterator, Mapping, TypeVar
23
+ from typing import Any, Awaitable, Callable, Iterator, Mapping, TypeVar
20
24
  from urllib.error import HTTPError, URLError
21
25
  from urllib.parse import quote, unquote, urlsplit
22
26
  from urllib.request import Request, urlopen
@@ -230,10 +234,18 @@ class _Span:
230
234
  self.client._warn("Rippletide span capture unavailable; continuing normally")
231
235
  self.trace_id = format(self.native.get_span_context().trace_id, "032x")
232
236
  self.span_id = format(self.native.get_span_context().span_id, "016x")
233
- self.scope = trace.use_span(self.native, end_on_exit=False, record_exception=False, set_status_on_exception=False) if self.native.is_recording() else nullcontext()
237
+ self.scope = self._activation()
234
238
  self.scope.__enter__()
235
239
  return self
236
240
 
241
+ def _activation(self) -> Any:
242
+ return trace.use_span(self.native, end_on_exit=False, record_exception=False, set_status_on_exception=False) if self.native.is_recording() else nullcontext()
243
+
244
+ def detach(self) -> None:
245
+ """Leave the current context while the span stays open for a deferred result."""
246
+ self.scope.__exit__(None, None, None)
247
+ self.scope = nullcontext()
248
+
237
249
  def set_output(self, output: Any, **attributes: Any) -> None:
238
250
  self.output, self.output_set = output, True
239
251
  self.end_attributes.update(attributes)
@@ -260,8 +272,126 @@ class _Span:
260
272
  return False
261
273
 
262
274
 
275
+ def _span_kind(kind: str) -> str:
276
+ normalized = {"llm": "model", "other": "operation"}.get(kind, kind)
277
+ return normalized if normalized in {"run", "agent", "model", "tool", "policy", "operation"} else "operation"
278
+
279
+
280
+ def _complete(span: _Span, result: Any, after: Callable[[], None] | None = None) -> Any:
281
+ """Close the span with a plain result now, or once an awaited result settles."""
282
+ if inspect.isawaitable(result):
283
+ span.detach()
284
+ return _settle(span, result, after)
285
+ if after:
286
+ after()
287
+ span.set_output(result)
288
+ span.__exit__(None, None, None)
289
+ return result
290
+
291
+
292
+ async def _settle(span: _Span, awaitable: Awaitable[Any], after: Callable[[], None] | None = None) -> Any:
293
+ with span._activation():
294
+ try:
295
+ result = await awaitable
296
+ except BaseException:
297
+ if after:
298
+ await asyncio.to_thread(after)
299
+ span.__exit__(*sys.exc_info())
300
+ raise
301
+ if after:
302
+ await asyncio.to_thread(after)
303
+ span.set_output(result)
304
+ span.__exit__(None, None, None)
305
+ return result
306
+
307
+
308
+ class _TracedStream:
309
+ """Passes a stream through untouched and ends its span once the stream is exhausted, closed, or abandoned.
310
+
311
+ `finish(chunks, complete)` runs first when given and returns extra end attributes.
312
+ """
313
+
314
+ def __init__(self, span: _Span, stream: Any, finish: Callable[[list[Any], bool], dict[str, Any]] | None = None) -> None:
315
+ self._span, self._stream, self._finish, self._chunks, self._ended = span, stream, finish, [], False
316
+
317
+ def __getattr__(self, name: str) -> Any:
318
+ if name.startswith("_"):
319
+ raise AttributeError(name)
320
+ return getattr(self._stream, name)
321
+
322
+ def __iter__(self) -> "_TracedStream":
323
+ return self
324
+
325
+ def __aiter__(self) -> "_TracedStream":
326
+ return self
327
+
328
+ def __next__(self) -> Any:
329
+ try:
330
+ return self._collect(next(self._stream))
331
+ except BaseException as error:
332
+ self._end(isinstance(error, StopIteration), error)
333
+ raise
334
+
335
+ async def __anext__(self) -> Any:
336
+ try:
337
+ return self._collect(await self._stream.__anext__())
338
+ except BaseException as error:
339
+ self._end(isinstance(error, StopAsyncIteration), error)
340
+ raise
341
+
342
+ def __enter__(self) -> "_TracedStream":
343
+ if hasattr(self._stream, "__enter__"):
344
+ self._stream.__enter__()
345
+ return self
346
+
347
+ def __exit__(self, *exc: Any) -> Any:
348
+ self._end(False)
349
+ return self._stream.__exit__(*exc) if hasattr(self._stream, "__exit__") else False
350
+
351
+ async def __aenter__(self) -> "_TracedStream":
352
+ if hasattr(self._stream, "__aenter__"):
353
+ await self._stream.__aenter__()
354
+ return self
355
+
356
+ async def __aexit__(self, *exc: Any) -> Any:
357
+ self._end(False)
358
+ return await self._stream.__aexit__(*exc) if hasattr(self._stream, "__aexit__") else False
359
+
360
+ def close(self) -> None:
361
+ self._end(False)
362
+ if hasattr(self._stream, "close"):
363
+ self._stream.close()
364
+
365
+ async def aclose(self) -> None:
366
+ self._end(False)
367
+ if hasattr(self._stream, "aclose"):
368
+ await self._stream.aclose()
369
+
370
+ def __del__(self) -> None:
371
+ try:
372
+ self._end(False)
373
+ except Exception:
374
+ pass
375
+
376
+ def _collect(self, chunk: Any) -> Any:
377
+ if self._finish or len(self._chunks) < 51:
378
+ self._chunks.append(chunk)
379
+ return chunk
380
+
381
+ def _end(self, complete: bool, error: BaseException | None = None) -> None:
382
+ if self._ended:
383
+ return
384
+ self._ended = True
385
+ error = None if complete else error
386
+ attributes = {"streaming": True, **({} if complete else {"captureCompleteness": "stream_not_consumed"})}
387
+ if self._finish:
388
+ attributes.update(self._finish(self._chunks, complete))
389
+ self._span.set_output(self._chunks, **attributes)
390
+ self._span.__exit__(type(error) if error else None, error, getattr(error, "__traceback__", None))
391
+
392
+
263
393
  class Rippletide:
264
- """Synchronous policy client with native OpenTelemetry instrumentation."""
394
+ """Policy client with native OpenTelemetry instrumentation for synchronous and asyncio agents."""
265
395
 
266
396
  def __init__(self, *, agent_id: str | None = None, api_key: str | None = None, base_url: str | None = None, name: str | None = None, description: str | None = None, capture_content: bool = False, runtime_traces: bool = True, disabled: bool | None = None, timeout_ms: int | None = None, warn: Callable[[str], None] | None = None) -> None:
267
397
  from_env = _connection_env()
@@ -327,20 +457,56 @@ class Rippletide:
327
457
 
328
458
  @contextmanager
329
459
  def span(self, kind: str, *, name: str, input: Any = _UNSET, tool_call_id: str | None = None, invocation_id: str | None = None, decision_id: str | None = None, attributes: Mapping[str, Any] | None = None, links: list[trace.Link] | None = None) -> Iterator[_Span]:
330
- normalized = {"llm": "model", "other": "operation"}.get(kind, kind)
331
- if normalized not in {"run", "agent", "model", "tool", "policy", "operation"}:
332
- normalized = "operation"
333
- with _Span(self, normalized, name, input=input, toolCallId=tool_call_id, invocationId=invocation_id, decisionId=decision_id, attributes=attributes, links=links) as current:
460
+ with _Span(self, _span_kind(kind), name, input=input, toolCallId=tool_call_id, invocationId=invocation_id, decisionId=decision_id, attributes=attributes, links=links) as current:
334
461
  yield current
335
462
 
336
463
  def traced(self, fn: Callable[..., T], *, name: str | None = None, kind: str = "tool") -> Callable[..., T]:
464
+ """Record each call as a span; an awaited result or a returned (async) iterator keeps it open until settled or exhausted."""
465
+ label = name or getattr(fn, "__name__", None) or "anonymous"
466
+
337
467
  def wrapped(*args: Any, **kwargs: Any) -> T:
338
- with self.span(kind, name=name or fn.__name__ or "anonymous", input={"args": args, "kwargs": kwargs}) as current:
468
+ span = _Span(self, _span_kind(kind), label, input={"args": args, "kwargs": kwargs}).__enter__()
469
+ try:
339
470
  result = fn(*args, **kwargs)
340
- current.set_output(result)
341
- return result
471
+ except BaseException:
472
+ span.__exit__(*sys.exc_info())
473
+ raise
474
+ if hasattr(result, "__next__") or hasattr(result, "__anext__"):
475
+ span.detach()
476
+ return _TracedStream(span, result) # type: ignore[return-value]
477
+ return _complete(span, result)
342
478
  return wrapped
343
479
 
480
+ def _guarded(self, decide: Callable[[], tuple[str, dict[str, Any], Any, Callable[[], None] | None, Callable[[], None] | None]], callback: Callable[[Any], Any]) -> Any:
481
+ """Run the blocking policy round trip in decide(), then the callback inside the action span.
482
+
483
+ A coroutine-function callback yields a coroutine that moves the HTTP work to a
484
+ worker thread, so an asyncio agent is never blocked by decisions or receipts.
485
+ """
486
+ if not inspect.iscoroutinefunction(callback):
487
+ kind, options, argument, before, after = decide()
488
+ span = _Span(self, kind, **options).__enter__()
489
+ if before:
490
+ before()
491
+ try:
492
+ result = callback(argument)
493
+ except BaseException:
494
+ if after:
495
+ after()
496
+ span.__exit__(*sys.exc_info())
497
+ raise
498
+ return _complete(span, result, after)
499
+
500
+ async def guarded() -> Any:
501
+ kind, options, argument, before, after = await asyncio.to_thread(decide)
502
+ span = _Span(self, kind, **options).__enter__()
503
+ span.detach()
504
+ if before:
505
+ await asyncio.to_thread(before)
506
+ return await _settle(span, callback(argument), after)
507
+
508
+ return guarded()
509
+
344
510
  def trace_headers(self) -> dict[str, str]:
345
511
  headers: dict[str, str] = {}
346
512
  TraceContextTextMapPropagator().inject(headers)
@@ -500,23 +666,25 @@ class Rippletide:
500
666
  action, invocation_id = f"{quote(toolset, safe='')}/{quote(tool, safe='')}", str(uuid.uuid4())
501
667
  if self.disabled:
502
668
  return execute(params)
503
- started = time.monotonic()
504
- body = {"invocationId": invocation_id, "action": action, "params": dict(params), "context": dict(context or {})}
505
- with self.span("policy", name=f"before_action:{action}", input=body, tool_call_id=tool_call_id, invocation_id=invocation_id, attributes={"trigger": "before_action", "action": action}) as policy_span:
506
- decision = self._decide("/decide", body, action)
507
- decision_id = decision.get("decisionId") if decision else None
508
- policy_span.options["decisionId"] = decision_id
509
- elapsed = int((time.monotonic() - started) * 1000)
510
- policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
511
- if decision and decision.get("disposition") == "BLOCK":
512
- self._receipt(decision, "PREVENTED", elapsed)
513
- with self.span("tool", name=tool, input=params, links=[trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})], tool_call_id=tool_call_id, invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": False, "applicationOutcome": "PREVENTED"}):
514
- raise RippletidePolicyBlockedError(action, decision)
515
- with self.span("tool", name=tool, input=params, links=[trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})], tool_call_id=tool_call_id, invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": True, "disposition": decision.get("disposition") if decision else "FALLBACK"}) as tool_span:
516
- self._receipt(decision, "EXECUTED", elapsed)
517
- result = execute(params)
518
- tool_span.set_output(result)
519
- return result
669
+
670
+ def decide() -> tuple[str, dict[str, Any], Any, Callable[[], None] | None, Callable[[], None] | None]:
671
+ started = time.monotonic()
672
+ body = {"invocationId": invocation_id, "action": action, "params": dict(params), "context": dict(context or {})}
673
+ with self.span("policy", name=f"before_action:{action}", input=body, tool_call_id=tool_call_id, invocation_id=invocation_id, attributes={"trigger": "before_action", "action": action}) as policy_span:
674
+ decision = self._decide("/decide", body, action)
675
+ decision_id = decision.get("decisionId") if decision else None
676
+ policy_span.options["decisionId"] = decision_id
677
+ elapsed = int((time.monotonic() - started) * 1000)
678
+ policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
679
+ options: dict[str, Any] = {"name": tool, "input": params, "links": [trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})], "toolCallId": tool_call_id, "invocationId": invocation_id, "decisionId": decision_id}
680
+ if decision and decision.get("disposition") == "BLOCK":
681
+ self._receipt(decision, "PREVENTED", elapsed)
682
+ with _Span(self, "tool", **options, attributes={"action": action, "executed": False, "applicationOutcome": "PREVENTED"}):
683
+ raise RippletidePolicyBlockedError(action, decision)
684
+ options["attributes"] = {"action": action, "executed": True, "disposition": decision.get("disposition") if decision else "FALLBACK"}
685
+ return "tool", options, params, lambda: self._receipt(decision, "EXECUTED", elapsed), None
686
+
687
+ return self._guarded(decide, execute)
520
688
 
521
689
  def guard_tool_result(self, *, toolset: str, tool: str, params: Mapping[str, Any], result: Any, result_items: list[Mapping[str, Any]], apply: Callable[[Mapping[str, Any]], T], context: Mapping[str, Any] | None = None, tool_call_id: str | None = None, allow_result_export: bool = False) -> T:
522
690
  """Filter an already-completed tool result before its next local sink.
@@ -535,75 +703,105 @@ class Rippletide:
535
703
  evaluated_result = _clone_policy_value(result, "result")
536
704
  evaluated_items = [_clone_policy_value(item, f"result_items[{index}]") for index, item in enumerate(result_items)]
537
705
  evaluated_context = _clone_policy_value(dict(context or {}), "trusted context")
538
- decision: Mapping[str, Any] | None = None
539
- elapsed = 0
540
- policy_span = None
541
706
 
542
- if allow_result_export and not self.disabled:
543
- invocation_id = str(uuid.uuid4())
544
- body = {"invocationId": invocation_id, "action": action, "params": evaluated_params, "result": evaluated_result, "context": evaluated_context}
545
- started = time.monotonic()
546
- with self.span("policy", name=f"after_action:{action}", input=body, tool_call_id=tool_call_id, invocation_id=invocation_id, attributes={"trigger": "after_action", "action": action, "rawResultExport": True}) as policy_span:
547
- decision = self._decide_result(body, action, evaluated_result, evaluated_items)
548
- policy_span.options["decisionId"] = decision.get("decisionId") if decision else None
549
- elapsed = int((time.monotonic() - started) * 1000)
550
- policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
551
-
552
- delivered_items = list(evaluated_items)
553
- excluded_items: list[dict[str, Any]] = []
554
- if decision:
555
- delivered_items = []
556
- for item, item_decision in zip(evaluated_items, decision["items"]):
557
- if item_decision["disposition"] != "BLOCK":
558
- delivered_items.append(item)
559
- continue
560
- detail = item_decision.get("decision") if isinstance(item_decision.get("decision"), Mapping) else {}
561
- excluded_items.append({
562
- "index": item_decision["index"],
563
- "itemId": item_decision["itemId"],
564
- "item": item,
565
- "reason": detail.get("reason") if isinstance(detail.get("reason"), str) else "Blocked by policy",
566
- "blockingRuleIds": list(detail.get("blockingRuleIds", [])) if isinstance(detail.get("blockingRuleIds"), list) else [],
567
- })
568
- outcome = "RESULT_FILTERED" if excluded_items else "RESULT_DELIVERED"
569
- filtered = {"result": evaluated_result, "deliveredItems": delivered_items, "excludedItems": excluded_items, "policyDecision": _clone_policy_value(decision, "policy decision") if decision else None}
570
- with self.span("operation", name="result_application", input=filtered, links=[trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})] if policy_span else [], tool_call_id=tool_call_id, invocation_id=decision.get("invocationId") if decision else None, decision_id=decision.get("decisionId") if decision else None, attributes={"action": action, "applicationOutcome": outcome, "deliveredItemCount": len(delivered_items), "excludedItemCount": len(excluded_items), "executed": True}) as result_span:
571
- try:
572
- applied = apply(filtered)
573
- result_span.set_output(applied)
574
- return applied
575
- finally:
576
- if decision:
577
- self._receipt_body(decision["decisionId"], {
578
- "outcome": outcome,
579
- "occurredAt": _now(),
580
- "resultPlanFingerprint": decision["resultPlanFingerprint"],
581
- "deliveredItemIndexes": [index for index, item in enumerate(decision["items"]) if item["disposition"] != "BLOCK"],
582
- "excludedItemIndexes": [index for index, item in enumerate(decision["items"]) if item["disposition"] == "BLOCK"],
583
- "decisionRoundtripMs": elapsed,
707
+ def decide() -> tuple[str, dict[str, Any], Any, Callable[[], None] | None, Callable[[], None] | None]:
708
+ decision: Mapping[str, Any] | None = None
709
+ elapsed = 0
710
+ policy_span = None
711
+
712
+ if allow_result_export and not self.disabled:
713
+ invocation_id = str(uuid.uuid4())
714
+ body = {"invocationId": invocation_id, "action": action, "params": evaluated_params, "result": evaluated_result, "context": evaluated_context}
715
+ started = time.monotonic()
716
+ with self.span("policy", name=f"after_action:{action}", input=body, tool_call_id=tool_call_id, invocation_id=invocation_id, attributes={"trigger": "after_action", "action": action, "rawResultExport": True}) as policy_span:
717
+ decision = self._decide_result(body, action, evaluated_result, evaluated_items)
718
+ policy_span.options["decisionId"] = decision.get("decisionId") if decision else None
719
+ elapsed = int((time.monotonic() - started) * 1000)
720
+ policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
721
+
722
+ delivered_items = list(evaluated_items)
723
+ excluded_items: list[dict[str, Any]] = []
724
+ if decision:
725
+ delivered_items = []
726
+ for item, item_decision in zip(evaluated_items, decision["items"]):
727
+ if item_decision["disposition"] != "BLOCK":
728
+ delivered_items.append(item)
729
+ continue
730
+ detail = item_decision.get("decision") if isinstance(item_decision.get("decision"), Mapping) else {}
731
+ excluded_items.append({
732
+ "index": item_decision["index"],
733
+ "itemId": item_decision["itemId"],
734
+ "item": item,
735
+ "reason": detail.get("reason") if isinstance(detail.get("reason"), str) else "Blocked by policy",
736
+ "blockingRuleIds": list(detail.get("blockingRuleIds", [])) if isinstance(detail.get("blockingRuleIds"), list) else [],
584
737
  })
738
+ outcome = "RESULT_FILTERED" if excluded_items else "RESULT_DELIVERED"
739
+ filtered = {"result": evaluated_result, "deliveredItems": delivered_items, "excludedItems": excluded_items, "policyDecision": _clone_policy_value(decision, "policy decision") if decision else None}
740
+ options = {"name": "result_application", "input": filtered, "links": [trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})] if policy_span else [], "toolCallId": tool_call_id, "invocationId": decision.get("invocationId") if decision else None, "decisionId": decision.get("decisionId") if decision else None, "attributes": {"action": action, "applicationOutcome": outcome, "deliveredItemCount": len(delivered_items), "excludedItemCount": len(excluded_items), "executed": True}}
741
+ after = (lambda: self._receipt_body(decision["decisionId"], {
742
+ "outcome": outcome,
743
+ "occurredAt": _now(),
744
+ "resultPlanFingerprint": decision["resultPlanFingerprint"],
745
+ "deliveredItemIndexes": [index for index, item in enumerate(decision["items"]) if item["disposition"] != "BLOCK"],
746
+ "excludedItemIndexes": [index for index, item in enumerate(decision["items"]) if item["disposition"] == "BLOCK"],
747
+ "decisionRoundtripMs": elapsed,
748
+ })) if decision else None
749
+ return "operation", options, filtered, None, after
750
+
751
+ return self._guarded(decide, apply)
585
752
 
586
753
  def deliver_response(self, *, request: Any, response: Any, deliver: Callable[[Any], T], context: Mapping[str, Any] | None = None) -> T:
754
+ """Decide on the complete candidate, then deliver it.
755
+
756
+ A sync or async iterator as `response` is streamed delivery: chunks pass
757
+ through to `deliver` untouched, and the decision runs on the assembled
758
+ text once the stream ends. It is recorded, never enforced: the outcome is
759
+ always DELIVERED and a BLOCK is marked as decided after delivery.
760
+ """
587
761
  invocation_id, action = str(uuid.uuid4()), "rippletide/response"
588
762
  if self.disabled:
589
763
  return deliver(response)
590
- started = time.monotonic()
591
- body = {"invocationId": invocation_id, "request": request, "response": response, "context": dict(context or {})}
592
- with self.span("policy", name="before_response:rippletide/response", input=body, invocation_id=invocation_id, attributes={"trigger": "before_response", "action": action}) as policy_span:
593
- decision = self._decide("/decide-response", body, action)
594
- decision_id = decision.get("decisionId") if decision else None
595
- policy_span.options["decisionId"] = decision_id
596
- elapsed = int((time.monotonic() - started) * 1000)
597
- policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
598
- if decision and decision.get("disposition") == "BLOCK":
599
- self._receipt(decision, "SUPPRESSED", elapsed)
600
- with self.span("operation", name="response_delivery", input=response, links=[trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})], invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": False, "applicationOutcome": "SUPPRESSED"}):
601
- raise RippletidePolicyBlockedError(action, decision)
602
- with self.span("operation", name="response_delivery", input=response, links=[trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})], invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": True, "applicationOutcome": "DELIVERED", "disposition": decision.get("disposition") if decision else "FALLBACK"}) as delivery_span:
603
- self._receipt(decision, "DELIVERED", elapsed)
604
- result = deliver(response)
605
- delivery_span.set_output(result)
606
- return result
764
+ if hasattr(response, "__next__") or hasattr(response, "__anext__"):
765
+ span = _Span(self, "operation", "response_delivery", invocationId=invocation_id, attributes={"action": action, "executed": True, "applicationOutcome": "DELIVERED", "decidedAfterDelivery": True}).__enter__()
766
+ span.detach()
767
+
768
+ def finish(chunks: list[Any], _complete: bool) -> dict[str, Any]:
769
+ text = "".join(chunk for chunk in chunks if isinstance(chunk, str))
770
+ started = time.monotonic()
771
+ body = {"invocationId": invocation_id, "request": request, "response": text if text or not chunks else [capture_value(chunk)[0] for chunk in chunks], "context": dict(context or {})}
772
+ links = [trace.Link(span.native.get_span_context(), {"rippletide.link.type": "follows_from"})]
773
+ with self.span("policy", name="before_response:rippletide/response", input=body, invocation_id=invocation_id, links=links, attributes={"trigger": "before_response", "action": action, "decidedAfterDelivery": True}) as policy_span:
774
+ try:
775
+ decision = self._decide("/decide-response", body, action)
776
+ except RippletidePolicyDecisionError as error:
777
+ self._warn(f"{error}; streamed response already delivered")
778
+ decision = None
779
+ policy_span.options["decisionId"] = span.options["decisionId"] = decision.get("decisionId") if decision else None
780
+ elapsed = int((time.monotonic() - started) * 1000)
781
+ policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
782
+ self._receipt(decision, "DELIVERED", elapsed)
783
+ return {"disposition": decision.get("disposition") if decision else "FALLBACK"}
784
+
785
+ return deliver(_TracedStream(span, response, finish)) # type: ignore[arg-type]
786
+
787
+ def decide() -> tuple[str, dict[str, Any], Any, Callable[[], None] | None, Callable[[], None] | None]:
788
+ started = time.monotonic()
789
+ body = {"invocationId": invocation_id, "request": request, "response": response, "context": dict(context or {})}
790
+ with self.span("policy", name="before_response:rippletide/response", input=body, invocation_id=invocation_id, attributes={"trigger": "before_response", "action": action}) as policy_span:
791
+ decision = self._decide("/decide-response", body, action)
792
+ decision_id = decision.get("decisionId") if decision else None
793
+ policy_span.options["decisionId"] = decision_id
794
+ elapsed = int((time.monotonic() - started) * 1000)
795
+ policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
796
+ options: dict[str, Any] = {"name": "response_delivery", "input": response, "links": [trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})], "invocationId": invocation_id, "decisionId": decision_id}
797
+ if decision and decision.get("disposition") == "BLOCK":
798
+ self._receipt(decision, "SUPPRESSED", elapsed)
799
+ with _Span(self, "operation", **options, attributes={"action": action, "executed": False, "applicationOutcome": "SUPPRESSED"}):
800
+ raise RippletidePolicyBlockedError(action, decision)
801
+ options["attributes"] = {"action": action, "executed": True, "applicationOutcome": "DELIVERED", "disposition": decision.get("disposition") if decision else "FALLBACK"}
802
+ return "operation", options, response, lambda: self._receipt(decision, "DELIVERED", elapsed), None
803
+
804
+ return self._guarded(decide, deliver)
607
805
 
608
806
  def flush(self) -> bool:
609
807
  if self.disabled or not self.agent_id or not self.runtime_traces or self._telemetry is None:
@@ -611,7 +809,13 @@ class Rippletide:
611
809
  self._ensure_registered()
612
810
  return self._telemetry.flush()
613
811
 
812
+ async def aflush(self) -> bool:
813
+ return await asyncio.to_thread(self.flush)
814
+
614
815
  def shutdown(self) -> None:
615
816
  self.flush()
616
817
  if self._telemetry:
617
818
  self._telemetry.shutdown()
819
+
820
+ async def ashutdown(self) -> None:
821
+ await asyncio.to_thread(self.shutdown)
rippletide/telemetry.py CHANGED
@@ -36,7 +36,8 @@ INDEXED = {
36
36
  "streaming": "rippletide.model.streaming", "toolCallIds": "rippletide.model.tool_call_ids", "toolCallCount": "rippletide.model.tool_call_count",
37
37
  "captureCompleteness": "rippletide.capture.completeness", "trigger": "rippletide.policy.trigger", "action": "rippletide.policy.action",
38
38
  "disposition": "rippletide.policy.disposition", "decisionRoundtripMs": "rippletide.policy.roundtrip_ms", "failOpen": "rippletide.policy.fail_open",
39
- "policyFallback": "rippletide.policy.fallback", "executed": "rippletide.action.executed", "applicationOutcome": "rippletide.action.application_outcome",
39
+ "policyFallback": "rippletide.policy.fallback", "decidedAfterDelivery": "rippletide.policy.decided_after_delivery",
40
+ "executed": "rippletide.action.executed", "applicationOutcome": "rippletide.action.application_outcome",
40
41
  "deliveredItemCount": "rippletide.result.delivered_item_count", "excludedItemCount": "rippletide.result.excluded_item_count",
41
42
  "signal": "rippletide.signal", "source": "rippletide.source", "standalone": "rippletide.standalone",
42
43
  }
@@ -120,6 +121,9 @@ class _LogExporter(LogRecordExporter):
120
121
  self.telemetry.enqueue("logs", otlp_json(encode_logs(records)))
121
122
  return LogRecordExportResult.SUCCESS
122
123
 
124
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
125
+ return True
126
+
123
127
  def shutdown(self) -> None:
124
128
  pass
125
129
 
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rippletide-python
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Monitor-first Python SDK for Rippletide-connected agents
5
5
  License: UNLICENSED
6
6
  Requires-Python: >=3.10
7
7
  Description-Content-Type: text/markdown
8
- Requires-Dist: opentelemetry-sdk==1.40.0
9
- Requires-Dist: opentelemetry-exporter-otlp-proto-common==1.40.0
8
+ Requires-Dist: opentelemetry-api<2,>=1.45.1
9
+ Requires-Dist: opentelemetry-sdk<2,>=1.45.1
10
+ Requires-Dist: opentelemetry-exporter-otlp-proto-common<2,>=1.45.1
10
11
 
11
12
  # Rippletide Python
12
13
 
@@ -16,8 +17,11 @@ hand-written provider or internal HTTP call at the existing model seam.
16
17
 
17
18
  ## Install and connect
18
19
 
19
- The native OTel release candidate is `rippletide-python==0.2.0`. It requires
20
+ The current release is `rippletide-python==0.3.1`. It requires
20
21
  the platform's OTLP ingestion/readers and does not provide V1 trace compatibility.
22
+ It depends on OpenTelemetry Python `>=1.45.1,<2` (API, SDK and OTLP proto
23
+ common), so it does not hold back the agent's own OpenTelemetry or FastAPI
24
+ upgrades.
21
25
  Until it is published, validate it by installing a locally built wheel; do not
22
26
  assume the unversioned registry install below contains candidate changes.
23
27
 
@@ -41,8 +45,7 @@ prompt with your coding agent in that repository. The CLI also supports
41
45
  Run a real turn that uses a tool and delivers a response. Confirm **Runtime
42
46
  verified (5/5)** in Fleet and the run's tool calls and **Delivered** outcome in
43
47
  Activity. Copying a prompt or storing credentials alone does not prove a
44
- connection. The SDK supports synchronous call sites. Async execution and
45
- streaming are not supported.
48
+ connection.
46
49
 
47
50
  ## Instrument existing call sites
48
51
 
@@ -85,6 +88,34 @@ connection takes precedence over those legacy values; explicit constructor
85
88
  arguments take precedence over either format. Never use a Platform key in the
86
89
  agent or commit the `.env`.
87
90
 
91
+ ## Async and streaming agents
92
+
93
+ The same client serves synchronous and asyncio code. `guard_tool_call()`,
94
+ `guard_tool_result()` and `deliver_response()` accept `async def` callbacks and
95
+ then return a coroutine to await; the policy round trip and receipts run in a
96
+ worker thread, so the event loop is never blocked. `run()`, `agent()` and
97
+ `span()` are plain `with` blocks in both cases. `traced()` wraps sync
98
+ functions, coroutine functions, and calls that return a provider stream: the
99
+ stream is passed through untouched and its span ends once the stream is
100
+ consumed, closed, or abandoned, in which case the span is marked
101
+ `stream_not_consumed`. Policy decides on the complete response: pass the text
102
+ the agent has assembled from the stream to `deliver_response()` before the
103
+ existing sink delivers it. When the tokens must reach the caller as they arrive,
104
+ pass the stream itself as `response`: chunks flow through to `deliver` untouched
105
+ and the decision is recorded on the assembled text once the stream ends, with no
106
+ suppression possible, so a BLOCK is marked as decided after delivery.
107
+ Use `await rippletide.aflush()` and
108
+ `await rippletide.ashutdown()` from asyncio code.
109
+
110
+ ```python
111
+ async def handle(request):
112
+ with rippletide.run(name="inbound_request", input=request):
113
+ stream = rippletide.traced(open_model_stream, name=MODEL_OPERATION, kind="model")(messages)
114
+ candidate = "".join([chunk.text async for chunk in stream])
115
+ await rippletide.guard_tool_call(toolset="core", tool="lookup", params=args, execute=lookup_async)
116
+ return await rippletide.deliver_response(request=request, response=candidate, deliver=send_async)
117
+ ```
118
+
88
119
  ## Policy failures and capture
89
120
 
90
121
  The client starts disabled when no Connection key is present. For
@@ -154,6 +185,6 @@ native response shape, and the SDK records the applied outcome as a receipt.
154
185
  Result fingerprints use the server's ECMAScript JSON encoding, including its
155
186
  number formatting and UTF-16 object-key ordering.
156
187
 
157
- This first release supports synchronous Python call sites. Python MCP servers,
158
- async generators, and response streaming remain unsupported rather than being
159
- silently represented as complete response-delivery protection.
188
+ Python MCP servers remain unsupported. A response that is streamed to the
189
+ caller before it is complete cannot be protected by `deliver_response()`; the
190
+ SDK does not represent such a path as complete response-delivery protection.
@@ -0,0 +1,7 @@
1
+ rippletide/__init__.py,sha256=lPw2FwwCVSYhMQdQlgZufRLZLZrECKYPvtrMELUxYdI,304
2
+ rippletide/client.py,sha256=3qFew8n5s-Dknjxs_waqh_ixP_t5XiF3GH36POVt4rM,45301
3
+ rippletide/telemetry.py,sha256=riGE6TcdpW0TNpiEW1K4sbVYeYR_kDM23YwoKWBAVVQ,13886
4
+ rippletide_python-0.3.1.dist-info/METADATA,sha256=xTxLnG_8faC51boSa2IN4M1OoE5RJEsrdzGt3E4jh9k,9434
5
+ rippletide_python-0.3.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ rippletide_python-0.3.1.dist-info/top_level.txt,sha256=NAEYCFwQDE0G96JlVKq00OiKb0YLZ_Er8rcAhOiYHzo,11
7
+ rippletide_python-0.3.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,7 +0,0 @@
1
- rippletide/__init__.py,sha256=lPw2FwwCVSYhMQdQlgZufRLZLZrECKYPvtrMELUxYdI,304
2
- rippletide/client.py,sha256=gAxI38pg9a1kNvyV6s_lmfauCNgeGrwOXXM-CEz-cl8,36623
3
- rippletide/telemetry.py,sha256=oG6YaRZLwUvIshl3egXeMN5ViD5mDMMVWClCvH3nnM0,13729
4
- rippletide_python-0.2.0.dist-info/METADATA,sha256=k8jWdR_NzOpRbHK4dOhgstK1AiwLsaK65YMcX3gNFoQ,7659
5
- rippletide_python-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
- rippletide_python-0.2.0.dist-info/top_level.txt,sha256=NAEYCFwQDE0G96JlVKq00OiKb0YLZ_Er8rcAhOiYHzo,11
7
- rippletide_python-0.2.0.dist-info/RECORD,,