rippletide-python 0.1.0__py3-none-any.whl → 0.3.0__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,33 +1,35 @@
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
13
- import secrets
16
+ import sys
14
17
  import time
15
18
  import uuid
16
19
  from hashlib import sha256
17
20
  from http.client import HTTPException
18
- from contextlib import contextmanager
19
- from contextvars import ContextVar
21
+ from contextlib import contextmanager, nullcontext
20
22
  from datetime import datetime, timezone
21
- from typing import Any, Callable, Iterator, Mapping, TypeVar
23
+ from typing import Any, Awaitable, Callable, Iterator, Mapping, TypeVar
22
24
  from urllib.error import HTTPError, URLError
23
25
  from urllib.parse import quote, unquote, urlsplit
24
26
  from urllib.request import Request, urlopen
27
+ from opentelemetry import trace
28
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
29
+ from .telemetry import Telemetry, capture_value, sanitized_text
25
30
 
26
31
  T = TypeVar("T")
27
- _CURRENT: ContextVar[dict[str, str] | None] = ContextVar("rippletide_current_span", default=None)
28
- _TRACEPARENT = re.compile(r"^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$", re.I)
29
- _SENSITIVE_KEY = re.compile(r"^(?:api[_-]?key|access[_-]?token|auth[_-]?token|authorization|client[_-]?secret|cookie|password|private[_-]?key|refresh[_-]?token|secret|session[_-]?token|set-cookie|x-api-key)$", re.I)
30
- _INLINE_AUTH = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9+/_=.,:;~-]{8,}", re.I)
32
+ _UNSET = object()
31
33
  _DEFAULT_BASE_URL = "https://agent-evalserver-production.up.railway.app"
32
34
  _STAGING_BASE_URL = "https://agent-evalserver-staging.up.railway.app"
33
35
  _SHA256 = re.compile(r"^[0-9a-f]{64}$", re.I)
@@ -38,10 +40,6 @@ def _now() -> str:
38
40
  return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
39
41
 
40
42
 
41
- def _hex(size: int) -> str:
42
- return secrets.token_hex(size)
43
-
44
-
45
43
  def _trim_url(value: str) -> str:
46
44
  return value.rstrip("/")
47
45
 
@@ -90,30 +88,6 @@ def load_rippletide_env(path: str) -> None:
90
88
  pass
91
89
 
92
90
 
93
- def _redact_text(value: str) -> str:
94
- return _INLINE_AUTH.sub(r"\1 [REDACTED]", value)[:4000]
95
-
96
-
97
- def _safe(value: Any, *, redacted: bool = False, key: str = "", depth: int = 0) -> Any:
98
- if depth > 6 or key and _SENSITIVE_KEY.match(key):
99
- return "[REDACTED]"
100
- if value is None or isinstance(value, (bool, int, float)):
101
- return "[REDACTED]" if redacted and value is not None else value
102
- if isinstance(value, str):
103
- return "[REDACTED]" if redacted else _redact_text(value)
104
- if isinstance(value, Mapping):
105
- return {str(k)[:128]: _safe(v, redacted=redacted, key=str(k), depth=depth + 1) for k, v in list(value.items())[:50]}
106
- if isinstance(value, (list, tuple)):
107
- return [_safe(item, redacted=redacted, key=key, depth=depth + 1) for item in value[:50]]
108
- return "[REDACTED]" if redacted else _redact_text(str(value))
109
-
110
-
111
- def _capture(value: Any, mode: str) -> Any | None:
112
- if mode == "metadata":
113
- return None
114
- return _safe(value, redacted=mode == "redacted")
115
-
116
-
117
91
  def _clone_policy_value(value: Any, label: str) -> Any:
118
92
  """Detach the exact JSON snapshot that policy evaluates and apply receives."""
119
93
  try:
@@ -242,59 +216,210 @@ class RippletidePolicyDecisionError(RuntimeError):
242
216
  class _Span:
243
217
  def __init__(self, client: "Rippletide", kind: str, name: str, *, root: bool = False, traceparent: str | None = None, **options: Any) -> None:
244
218
  self.client, self.kind, self.name = client, kind, name
245
- parent = _CURRENT.get()
246
- inbound = _TRACEPARENT.match(traceparent or "") if root else None
247
- self.trace_id = inbound.group(1).lower() if inbound else parent["trace_id"] if parent else _hex(16)
248
- self.span_id = _hex(8)
249
- self.parent_span_id = inbound.group(2).lower() if inbound else parent["span_id"] if parent else None
250
- self.options, self.token, self.ended = options, None, False
219
+ self.root, self.traceparent = root, traceparent
220
+ self.options = {key: value for key, value in options.items() if value is not _UNSET}
221
+ self.ended = False
222
+ self.native = trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)
251
223
  self.output: Any = None
252
224
  self.output_set = False
253
225
  self.end_attributes: dict[str, Any] = {}
254
226
 
255
227
  def __enter__(self) -> "_Span":
256
- self.token = _CURRENT.set({"trace_id": self.trace_id, "span_id": self.span_id, "trace_flags": "01"})
257
- self.client._record("span_start", self, input=self.options.get("input"))
228
+ try:
229
+ if not self.client.disabled and self.client.agent_id and self.client.runtime_traces:
230
+ if self.client._telemetry is None:
231
+ self.client._telemetry = Telemetry(self.client)
232
+ self.native = self.client._telemetry.start(self.kind, self.name, self.options, self.root, self.traceparent)
233
+ except Exception:
234
+ self.client._warn("Rippletide span capture unavailable; continuing normally")
235
+ self.trace_id = format(self.native.get_span_context().trace_id, "032x")
236
+ self.span_id = format(self.native.get_span_context().span_id, "016x")
237
+ self.scope = self._activation()
238
+ self.scope.__enter__()
258
239
  return self
259
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
+
260
249
  def set_output(self, output: Any, **attributes: Any) -> None:
261
250
  self.output, self.output_set = output, True
262
251
  self.end_attributes.update(attributes)
263
252
 
264
253
  def __exit__(self, exc_type: Any, exc: BaseException | None, _traceback: Any) -> bool:
265
- if exc:
266
- self.client._record("span_end", self, status="error", error=exc)
267
- else:
268
- self.client._record("span_end", self, status="ok", output=self.output if self.output_set else None, attributes=self.end_attributes)
269
- if self.token is not None:
270
- _CURRENT.reset(self.token)
271
- self.ended = True
254
+ try:
255
+ if self.native.is_recording():
256
+ options = {**self.options, **({"output": self.output} if self.output_set else {})}
257
+ self.native.set_attributes(self.client._telemetry.attributes(self.kind, options, self.end_attributes))
258
+ outcome = "blocked" if isinstance(exc, RippletidePolicyBlockedError) else "error" if exc else "ok"
259
+ self.native.set_attribute("rippletide.outcome", outcome)
260
+ self.native.set_status(trace.StatusCode.ERROR if outcome == "error" else trace.StatusCode.UNSET)
261
+ if exc:
262
+ details = {"exception.type": type(exc).__name__}
263
+ if self.client.capture_content:
264
+ details["exception.message"] = sanitized_text(str(exc))[:4000]
265
+ self.native.add_event("exception", details)
266
+ except Exception:
267
+ self.native.set_attribute("rippletide.capture.completeness", "capture_error")
268
+ finally:
269
+ self.native.end()
270
+ self.scope.__exit__(exc_type, exc, _traceback)
271
+ self.ended = True
272
272
  return False
273
273
 
274
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
+
275
393
  class Rippletide:
276
- """Synchronous, dependency-free client with the TypeScript SDK's core semantics."""
394
+ """Policy client with native OpenTelemetry instrumentation for synchronous and asyncio agents."""
277
395
 
278
- 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_mode: str | None = None, disabled: bool | None = None, timeout_ms: int | None = None, warn: Callable[[str], None] | None = None) -> None:
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:
279
397
  from_env = _connection_env()
280
398
  self.agent_id = agent_id or from_env["agent_id"] or None
281
399
  self.api_key = api_key or from_env["api_key"] or None
282
400
  self.base_url = _trim_url(base_url or from_env["base_url"] or _base_url_for_key(self.api_key))
283
- self.capture_mode = capture_mode or os.getenv("RIPPLETIDE_CAPTURE_MODE", "metadata")
284
- self.capture_mode = self.capture_mode if self.capture_mode in {"metadata", "redacted", "full"} else "metadata"
401
+ self.capture_content = capture_content is True
402
+ self.runtime_traces = runtime_traces is not False
285
403
  self.disabled = bool(disabled) if disabled is not None else os.getenv("RIPPLETIDE_DISABLED") == "1" or not bool(self.api_key)
286
404
  self.timeout = (timeout_ms or int(os.getenv("RIPPLETIDE_TIMEOUT_MS", "5000"))) / 1000
287
405
  self.agent_meta = {key: value for key, value in {"name": name, "description": description}.items() if value}
288
- self.events: list[dict[str, Any]] = []
406
+ self._telemetry: Telemetry | None = None
289
407
  self._registered = False
290
- self._warn = warn or (lambda _message: None)
408
+ self._warning_handler = warn
291
409
 
292
- def _request(self, method: str, path: str, body: Any | None = None) -> Any:
410
+ def _warn(self, message: str) -> None:
411
+ try:
412
+ if self._warning_handler:
413
+ self._warning_handler(message)
414
+ except Exception:
415
+ pass
416
+
417
+ def _request(self, method: str, path: str, body: Any | None = None, *, timeout: float | None = None) -> Any:
293
418
  if not self.api_key:
294
419
  raise RippletideHttpError(401, "UNAUTHENTICATED")
295
420
  request = Request(f"{self.base_url}{path}", data=json.dumps(body).encode() if body is not None else None, headers={"x-api-key": self.api_key, **({"content-type": "application/json"} if body is not None else {})}, method=method)
296
421
  try:
297
- with urlopen(request, timeout=self.timeout) as response:
422
+ with urlopen(request, timeout=self.timeout if timeout is None else timeout) as response:
298
423
  return json.loads(response.read() or b"{}")
299
424
  except HTTPError as error:
300
425
  try:
@@ -319,68 +444,73 @@ class Rippletide:
319
444
  self._warn("Rippletide registration unavailable; continuing normally")
320
445
  return False
321
446
 
322
- def _record(self, event_type: str, span: _Span, *, status: str | None = None, input: Any = None, output: Any = None, error: BaseException | None = None, attributes: Mapping[str, Any] | None = None) -> None:
323
- if self.disabled or not self.agent_id:
324
- return
325
- base: dict[str, Any] = {"eventId": str(uuid.uuid4()), "traceId": span.trace_id, "spanId": span.span_id, "kind": span.kind, "name": span.name, "occurredAt": _now(), "captureMode": self.capture_mode, "fidelity": "runtime_trace", "instrumentation": {"name": "rippletide-python", "version": "0.1.0", "language": "python"}}
326
- if span.parent_span_id:
327
- base["parentSpanId"] = span.parent_span_id
328
- for key in ("conversationId", "toolCallId", "invocationId", "decisionId"):
329
- value = span.options.get(key)
330
- if isinstance(value, str) and value:
331
- base[key] = value
332
- links = span.options.get("links")
333
- if isinstance(links, list):
334
- base["links"] = links[:32]
335
- initial_attributes = _safe(span.options.get("attributes") or {})
336
- final_attributes = _safe(attributes or {})
337
- merged_attributes = {**initial_attributes, **final_attributes}
338
- if merged_attributes:
339
- base["attributes"] = merged_attributes
340
- if event_type == "span_start":
341
- captured = _capture(input, self.capture_mode)
342
- if captured is not None:
343
- base["input"] = captured
344
- base["eventType"] = "span_start"
345
- else:
346
- base.update({"eventType": "span_end", "status": status or "ok"})
347
- captured = _capture(output, self.capture_mode)
348
- if captured is not None:
349
- base["output"] = captured
350
- if error:
351
- base["error"] = {"name": type(error).__name__, "message": _redact_text(str(error)) if self.capture_mode == "full" else "[REDACTED]"}
352
- if len(self.events) < 2000:
353
- self.events.append(base)
354
447
 
355
448
  @contextmanager
356
- def run(self, *, name: str = "run", input: Any = None, conversation_id: str | None = None, traceparent: str | None = None, attributes: Mapping[str, Any] | None = None) -> Iterator[_Span]:
357
- with _Span(self, "run", name, root=True, traceparent=traceparent, input=input, conversationId=conversation_id, attributes=attributes) as span:
449
+ def run(self, *, name: str = "run", input: Any = _UNSET, conversation_id: str | None = None, traceparent: str | None = None, tracestate: str | None = None, attributes: Mapping[str, Any] | None = None) -> Iterator[_Span]:
450
+ with _Span(self, "run", name, traceparent=traceparent, tracestate=tracestate, input=input, conversationId=conversation_id, attributes=attributes) as span:
358
451
  yield span
359
452
 
360
453
  @contextmanager
361
- def agent(self, *, name: str = "agent", input: Any = None, attributes: Mapping[str, Any] | None = None) -> Iterator[_Span]:
454
+ def agent(self, *, name: str = "agent", input: Any = _UNSET, attributes: Mapping[str, Any] | None = None) -> Iterator[_Span]:
362
455
  with _Span(self, "agent", name, input=input, attributes=attributes) as span:
363
456
  yield span
364
457
 
365
458
  @contextmanager
366
- def span(self, kind: str, *, name: str, input: Any = None, tool_call_id: str | None = None, invocation_id: str | None = None, decision_id: str | None = None, attributes: Mapping[str, Any] | None = None) -> Iterator[_Span]:
367
- normalized = {"llm": "model", "other": "operation"}.get(kind, kind)
368
- if normalized not in {"run", "agent", "model", "tool", "policy", "operation"}:
369
- normalized = "operation"
370
- with _Span(self, normalized, name, input=input, toolCallId=tool_call_id, invocationId=invocation_id, decisionId=decision_id, attributes=attributes) as current:
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]:
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:
371
461
  yield current
372
462
 
373
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
+
374
467
  def wrapped(*args: Any, **kwargs: Any) -> T:
375
- 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:
376
470
  result = fn(*args, **kwargs)
377
- current.set_output(result)
378
- 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)
379
478
  return wrapped
380
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
+
381
510
  def trace_headers(self) -> dict[str, str]:
382
- current = _CURRENT.get()
383
- return {"traceparent": f"00-{current['trace_id']}-{current['span_id']}-{current['trace_flags']}"} if current else {}
511
+ headers: dict[str, str] = {}
512
+ TraceContextTextMapPropagator().inject(headers)
513
+ return headers
384
514
 
385
515
  def heartbeat(self, **attributes: Any) -> None:
386
516
  with _Span(self, "operation", "heartbeat", root=True, attributes={"signal": "heartbeat", **attributes}):
@@ -396,7 +526,7 @@ class Rippletide:
396
526
  fallback = options.get("fallback")
397
527
  prompt_type = options.get("type") or ("chat" if isinstance(fallback, list) else "text")
398
528
  try:
399
- self._request("POST", f"/api/apps/{quote(self.agent_id, safe='')}/prompts/{quote(str(name), safe='')}", {"seed": True, "type": prompt_type, "prompt": _safe(fallback) if export_content else _withheld_prompt(prompt_type), "config": options.get("config", {}), "source": "sdk", "contentExported": export_content})
529
+ self._request("POST", f"/api/apps/{quote(self.agent_id, safe='')}/prompts/{quote(str(name), safe='')}", {"seed": True, "type": prompt_type, "prompt": capture_value(fallback)[0] if export_content else _withheld_prompt(prompt_type), "config": options.get("config", {}), "source": "sdk", "contentExported": export_content})
400
530
  except RippletideHttpError:
401
531
  self._warn(f'Rippletide prompt "{name}" unavailable; using local prompt')
402
532
 
@@ -536,23 +666,25 @@ class Rippletide:
536
666
  action, invocation_id = f"{quote(toolset, safe='')}/{quote(tool, safe='')}", str(uuid.uuid4())
537
667
  if self.disabled:
538
668
  return execute(params)
539
- started = time.monotonic()
540
- body = {"invocationId": invocation_id, "action": action, "params": dict(params), "context": dict(context or {})}
541
- 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:
542
- decision = self._decide("/decide", body, action)
543
- decision_id = decision.get("decisionId") if decision else None
544
- policy_span.options["decisionId"] = decision_id
545
- elapsed = int((time.monotonic() - started) * 1000)
546
- policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
547
- if decision and decision.get("disposition") == "BLOCK":
548
- self._receipt(decision, "PREVENTED", elapsed)
549
- with self.span("tool", name=tool, input=params, tool_call_id=tool_call_id, invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": False, "applicationOutcome": "PREVENTED"}):
550
- raise RippletidePolicyBlockedError(action, decision)
551
- with self.span("tool", name=tool, input=params, 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:
552
- self._receipt(decision, "EXECUTED", elapsed)
553
- result = execute(params)
554
- tool_span.set_output(result)
555
- 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)
556
688
 
557
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:
558
690
  """Filter an already-completed tool result before its next local sink.
@@ -571,84 +703,119 @@ class Rippletide:
571
703
  evaluated_result = _clone_policy_value(result, "result")
572
704
  evaluated_items = [_clone_policy_value(item, f"result_items[{index}]") for index, item in enumerate(result_items)]
573
705
  evaluated_context = _clone_policy_value(dict(context or {}), "trusted context")
574
- decision: Mapping[str, Any] | None = None
575
- elapsed = 0
576
-
577
- if allow_result_export and not self.disabled:
578
- invocation_id = str(uuid.uuid4())
579
- body = {"invocationId": invocation_id, "action": action, "params": evaluated_params, "result": evaluated_result, "context": evaluated_context}
580
- started = time.monotonic()
581
- 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:
582
- decision = self._decide_result(body, action, evaluated_result, evaluated_items)
583
- elapsed = int((time.monotonic() - started) * 1000)
584
- policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
585
706
 
586
- delivered_items = list(evaluated_items)
587
- excluded_items: list[dict[str, Any]] = []
588
- if decision:
589
- delivered_items = []
590
- for item, item_decision in zip(evaluated_items, decision["items"]):
591
- if item_decision["disposition"] != "BLOCK":
592
- delivered_items.append(item)
593
- continue
594
- detail = item_decision.get("decision") if isinstance(item_decision.get("decision"), Mapping) else {}
595
- excluded_items.append({
596
- "index": item_decision["index"],
597
- "itemId": item_decision["itemId"],
598
- "item": item,
599
- "reason": detail.get("reason") if isinstance(detail.get("reason"), str) else "Blocked by policy",
600
- "blockingRuleIds": list(detail.get("blockingRuleIds", [])) if isinstance(detail.get("blockingRuleIds"), list) else [],
601
- })
602
- outcome = "RESULT_FILTERED" if excluded_items else "RESULT_DELIVERED"
603
- filtered = {"result": evaluated_result, "deliveredItems": delivered_items, "excludedItems": excluded_items, "policyDecision": _clone_policy_value(decision, "policy decision") if decision else None}
604
- with self.span("operation", name="result_application", input=filtered, 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:
605
- try:
606
- applied = apply(filtered)
607
- result_span.set_output(applied)
608
- return applied
609
- finally:
610
- if decision:
611
- self._receipt_body(decision["decisionId"], {
612
- "outcome": outcome,
613
- "occurredAt": _now(),
614
- "resultPlanFingerprint": decision["resultPlanFingerprint"],
615
- "deliveredItemIndexes": [index for index, item in enumerate(decision["items"]) if item["disposition"] != "BLOCK"],
616
- "excludedItemIndexes": [index for index, item in enumerate(decision["items"]) if item["disposition"] == "BLOCK"],
617
- "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 [],
618
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)
619
752
 
620
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
+ """
621
761
  invocation_id, action = str(uuid.uuid4()), "rippletide/response"
622
762
  if self.disabled:
623
763
  return deliver(response)
624
- started = time.monotonic()
625
- body = {"invocationId": invocation_id, "request": request, "response": response, "context": dict(context or {})}
626
- with self.span("policy", name="before_response:rippletide/response", input=body, invocation_id=invocation_id, attributes={"trigger": "before_response", "action": action}) as policy_span:
627
- decision = self._decide("/decide-response", body, action)
628
- decision_id = decision.get("decisionId") if decision else None
629
- policy_span.options["decisionId"] = decision_id
630
- elapsed = int((time.monotonic() - started) * 1000)
631
- policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
632
- if decision and decision.get("disposition") == "BLOCK":
633
- self._receipt(decision, "SUPPRESSED", elapsed)
634
- with self.span("operation", name="response_delivery", input=response, invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": False, "applicationOutcome": "SUPPRESSED"}):
635
- raise RippletidePolicyBlockedError(action, decision)
636
- with self.span("operation", name="response_delivery", input=response, 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:
637
- self._receipt(decision, "DELIVERED", elapsed)
638
- result = deliver(response)
639
- delivery_span.set_output(result)
640
- 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)
641
805
 
642
806
  def flush(self) -> bool:
643
- if self.disabled or not self.agent_id or not self.events:
807
+ if self.disabled or not self.agent_id or not self.runtime_traces or self._telemetry is None:
644
808
  return False
645
809
  self._ensure_registered()
646
- all_sent = True
647
- while self.events:
648
- batch, self.events = self.events[:50], self.events[50:]
649
- try:
650
- self._request("POST", f"/v1/runtime/traces/{quote(self.agent_id, safe='')}", {"schemaVersion": "runtime_trace.v1", "events": batch})
651
- except RippletideHttpError:
652
- all_sent = False
653
- self._warn("Rippletide runtime export unavailable; dropped trace batch")
654
- return all_sent
810
+ return self._telemetry.flush()
811
+
812
+ async def aflush(self) -> bool:
813
+ return await asyncio.to_thread(self.flush)
814
+
815
+ def shutdown(self) -> None:
816
+ self.flush()
817
+ if self._telemetry:
818
+ self._telemetry.shutdown()
819
+
820
+ async def ashutdown(self) -> None:
821
+ await asyncio.to_thread(self.shutdown)
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import math
6
+ import re
7
+ import time
8
+ import uuid
9
+ from collections import deque
10
+ from collections.abc import Mapping
11
+ from importlib.metadata import PackageNotFoundError, version
12
+ from threading import Lock
13
+ from typing import Any
14
+
15
+ from google.protobuf.json_format import MessageToDict
16
+ from opentelemetry import context, trace
17
+ from opentelemetry._logs import LogRecord
18
+ from opentelemetry.exporter.otlp.proto.common._log_encoder import encode_logs
19
+ from opentelemetry.exporter.otlp.proto.common.trace_encoder import encode_spans
20
+ from opentelemetry.sdk._logs import LoggerProvider
21
+ from opentelemetry.sdk._logs.export import LogRecordExporter, LogRecordExportResult, SimpleLogRecordProcessor
22
+ from opentelemetry.sdk.resources import Resource
23
+ from opentelemetry.sdk.trace import SpanLimits, TracerProvider
24
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
25
+ from opentelemetry.sdk.trace.sampling import ALWAYS_ON
26
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
27
+
28
+ SCHEMA_URL = "https://opentelemetry.io/schemas/1.37.0"
29
+ SENSITIVE = re.compile(r"^(api[_-]?key|access[_-]?token|auth[_-]?token|authorization|client[_-]?secret|cookie|password|private[_-]?key|proxy-authorization|refresh[_-]?token|secret|session[_-]?token|set-cookie|x-api-key)$", re.I)
30
+ INLINE_AUTH = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9+/_=.,:;~-]{8,}", re.I)
31
+ INLINE_SECRET = re.compile(r'''(["']?(?:api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|password|private[_-]?key|refresh[_-]?token|secret|session[_-]?token)["']?\s*[:=]\s*)(["']?)[^\s,;}"']{4,}(["']?)''', re.I)
32
+ CORRELATIONS = {"conversationId": "gen_ai.conversation.id", "toolCallId": "gen_ai.tool.call.id", "invocationId": "rippletide.invocation.id", "decisionId": "rippletide.policy.decision.id"}
33
+ INDEXED = {
34
+ "provider": "gen_ai.provider.name", "model": "gen_ai.request.model", "responseId": "gen_ai.response.id",
35
+ "inputTokens": "gen_ai.usage.input_tokens", "outputTokens": "gen_ai.usage.output_tokens", "totalTokens": "rippletide.model.total_tokens",
36
+ "streaming": "rippletide.model.streaming", "toolCallIds": "rippletide.model.tool_call_ids", "toolCallCount": "rippletide.model.tool_call_count",
37
+ "captureCompleteness": "rippletide.capture.completeness", "trigger": "rippletide.policy.trigger", "action": "rippletide.policy.action",
38
+ "disposition": "rippletide.policy.disposition", "decisionRoundtripMs": "rippletide.policy.roundtrip_ms", "failOpen": "rippletide.policy.fail_open",
39
+ "policyFallback": "rippletide.policy.fallback", "decidedAfterDelivery": "rippletide.policy.decided_after_delivery",
40
+ "executed": "rippletide.action.executed", "applicationOutcome": "rippletide.action.application_outcome",
41
+ "deliveredItemCount": "rippletide.result.delivered_item_count", "excludedItemCount": "rippletide.result.excluded_item_count",
42
+ "signal": "rippletide.signal", "source": "rippletide.source", "standalone": "rippletide.standalone",
43
+ }
44
+
45
+
46
+ def sanitized_text(value: str) -> str:
47
+ value = INLINE_SECRET.sub(r"\1\2[REDACTED]\3", INLINE_AUTH.sub(r"\1 [REDACTED]", value))
48
+ return value.replace("\0", "\ufffd").encode("utf-8", errors="replace").decode("utf-8")
49
+
50
+
51
+ def capture_value(value: Any) -> tuple[Any, bool]:
52
+ remaining, truncated, ancestors = 200, False, set()
53
+
54
+ def visit(item: Any, depth: int = 0, key: str = "") -> Any:
55
+ nonlocal remaining, truncated
56
+ if depth > 6 or remaining <= 0:
57
+ truncated = True
58
+ return "…"
59
+ remaining -= 1
60
+ if SENSITIVE.match(key):
61
+ return "[REDACTED]"
62
+ if item is None or isinstance(item, (bool, int)):
63
+ return item
64
+ if isinstance(item, float):
65
+ return item if math.isfinite(item) else str(item)
66
+ if isinstance(item, str):
67
+ clean = sanitized_text(item)
68
+ truncated |= len(clean) > 4000
69
+ return clean[:4000]
70
+ if isinstance(item, (Mapping, list, tuple)):
71
+ if id(item) in ancestors:
72
+ truncated = True
73
+ return "[Circular]"
74
+ ancestors.add(id(item))
75
+ truncated |= len(item) > 50
76
+ if isinstance(item, Mapping):
77
+ result = {sanitized_text(str(child_key))[:128]: visit(child, depth + 1, str(child_key)) for child_key, child in list(item.items())[:50]}
78
+ else:
79
+ result = [visit(child, depth + 1) for child in item[:50]]
80
+ ancestors.remove(id(item))
81
+ return result
82
+ return visit(str(item), depth)
83
+
84
+ captured = visit(value)
85
+ encoded = json.dumps(captured, ensure_ascii=False, separators=(",", ":"))
86
+ if len(encoded) > 8000:
87
+ return {"truncated": True, "preview": encoded[:3980]}, True
88
+ return captured, truncated
89
+
90
+
91
+ def otlp_json(message: Any) -> dict:
92
+ value = MessageToDict(message, use_integers_for_enums=True)
93
+
94
+ def normalize(item: Any) -> Any:
95
+ if isinstance(item, list):
96
+ return [normalize(child) for child in item]
97
+ if isinstance(item, dict):
98
+ return {key: base64.b64decode(child).hex() if key in {"traceId", "spanId", "parentSpanId"} and isinstance(child, str) else normalize(child) for key, child in item.items()}
99
+ return item
100
+
101
+ return normalize(value)
102
+
103
+
104
+ class _SpanExporter(SpanExporter):
105
+ def __init__(self, telemetry: "Telemetry") -> None:
106
+ self.telemetry = telemetry
107
+
108
+ def export(self, spans: Any) -> SpanExportResult:
109
+ self.telemetry.enqueue("traces", otlp_json(encode_spans(spans)))
110
+ return SpanExportResult.SUCCESS
111
+
112
+ def shutdown(self) -> None:
113
+ pass
114
+
115
+
116
+ class _LogExporter(LogRecordExporter):
117
+ def __init__(self, telemetry: "Telemetry") -> None:
118
+ self.telemetry = telemetry
119
+
120
+ def export(self, records: Any) -> LogRecordExportResult:
121
+ self.telemetry.enqueue("logs", otlp_json(encode_logs(records)))
122
+ return LogRecordExportResult.SUCCESS
123
+
124
+ def shutdown(self) -> None:
125
+ pass
126
+
127
+
128
+ class Telemetry:
129
+ def __init__(self, client: Any) -> None:
130
+ self.client = client
131
+ self.pending = {"traces": deque(maxlen=2000), "logs": deque(maxlen=2000)}
132
+ self.lock = Lock()
133
+ resource = Resource.create({"service.name": "rippletide-instrumented-agent"})
134
+ self.provider = TracerProvider(resource=resource, sampler=ALWAYS_ON, span_limits=SpanLimits(max_attributes=128, max_events=128, max_links=32), shutdown_on_exit=False)
135
+ self.provider.add_span_processor(SimpleSpanProcessor(_SpanExporter(self)))
136
+ try:
137
+ sdk_version = version("rippletide-python")
138
+ except PackageNotFoundError:
139
+ sdk_version = None
140
+ self.tracer = self.provider.get_tracer("rippletide-python", sdk_version, SCHEMA_URL)
141
+ self.logger_provider = LoggerProvider(resource=resource, shutdown_on_exit=False)
142
+ self.logger_provider.add_log_record_processor(SimpleLogRecordProcessor(_LogExporter(self)))
143
+ self.logger = self.logger_provider.get_logger("rippletide-python", sdk_version, SCHEMA_URL)
144
+
145
+ def enqueue(self, signal: str, packet: dict) -> None:
146
+ with self.lock:
147
+ self.pending[signal].append(packet)
148
+
149
+ def attributes(self, kind: str, options: dict, extra: dict | None = None) -> dict:
150
+ raw = {**(options.get("attributes") or {}), **(extra or {})}
151
+ captured, truncated = capture_value(raw if self.client.capture_content else {key: value for key, value in raw.items() if key in INDEXED or key == "finishReason"})
152
+ result = {"rippletide.schema.version": "1", "rippletide.agent.id": self.client.agent_id, "rippletide.span.kind": kind, "rippletide.capture.mode": "full", "rippletide.evidence.fidelity": "runtime_trace"}
153
+ loss = []
154
+ for source, target in {**INDEXED, **CORRELATIONS}.items():
155
+ value = options.get(source) if source in CORRELATIONS else raw.get(source)
156
+ if isinstance(value, str):
157
+ clean = sanitized_text(value)
158
+ result[target] = clean[:512]
159
+ if len(clean) > 512:
160
+ loss.append(target)
161
+ elif isinstance(value, (bool, int, float)) and (not isinstance(value, (int, float)) or math.isfinite(value)):
162
+ result[target] = value
163
+ elif isinstance(value, list) and all(isinstance(item, str) for item in value):
164
+ result[target] = [sanitized_text(item)[:512] for item in value[:50]]
165
+ if len(value) > 50 or any(len(item) > 512 for item in value[:50]):
166
+ loss.append(target)
167
+ if isinstance(raw.get("finishReason"), str):
168
+ result["gen_ai.response.finish_reasons"] = [sanitized_text(raw["finishReason"])[:512]]
169
+ if self.client.capture_content:
170
+ result["rippletide.attributes.json"] = json.dumps(captured, ensure_ascii=False, separators=(",", ":"))
171
+ if truncated:
172
+ loss.append("rippletide.attributes.json")
173
+ for field in ("input", "output"):
174
+ if field in options:
175
+ value, cut = capture_value(options[field])
176
+ result[f"rippletide.{field}.json"] = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
177
+ if cut:
178
+ loss.append(f"rippletide.{field}.json")
179
+ completeness = raw.get("captureCompleteness")
180
+ result["rippletide.capture.completeness"] = completeness if isinstance(completeness, str) and completeness in {"capture_error", "stream_not_consumed"} else "complete" if self.client.capture_content else "not_captured"
181
+ if loss:
182
+ result["rippletide.capture.completeness"] = "truncated"
183
+ if loss:
184
+ result["rippletide.capture.truncated_fields"] = loss
185
+ return result
186
+
187
+ def start(self, kind: str, name: str, options: dict, root: bool, traceparent: str | None) -> Any:
188
+ parent = TraceContextTextMapPropagator().extract({"traceparent": traceparent, "tracestate": options.get("tracestate") or ""}) if traceparent else context.Context() if root else context.get_current()
189
+ attributes = self.attributes(kind, options)
190
+ if kind == "tool":
191
+ attributes["gen_ai.tool.name"] = sanitized_text(name)[:512]
192
+ if kind == "agent":
193
+ attributes["gen_ai.agent.name"] = sanitized_text(name)[:512]
194
+ links = [trace.Link(link.context, {"rippletide.link.type": link.attributes["rippletide.link.type"]})
195
+ for link in (options.get("links") or [])[:32]
196
+ if isinstance(link, trace.Link) and link.context.is_valid and (link.attributes or {}).get("rippletide.link.type") in {"depends_on", "follows_from"}]
197
+ span = self.tracer.start_span(sanitized_text(name)[:512], context=parent, kind=trace.SpanKind.CLIENT if kind in {"policy", "model"} else trace.SpanKind.INTERNAL, attributes=attributes, links=links)
198
+ span.add_event("rippletide.span.started", attributes, span.start_time)
199
+ span_context = span.get_span_context()
200
+ start_attributes = {**attributes, "rippletide.event.id": str(uuid.uuid4())}
201
+ if links:
202
+ start_attributes["rippletide.start.links.json"] = json.dumps([{
203
+ "traceId": format(link.context.trace_id, "032x"), "spanId": format(link.context.span_id, "016x"),
204
+ "flags": int(link.context.trace_flags), "traceState": link.context.trace_state.to_header(),
205
+ "attributes": [{"key": key, "value": {"stringValue": str(value)}} for key, value in (link.attributes or {}).items()],
206
+ } for link in links], separators=(",", ":"))
207
+ parent_span = trace.get_current_span(parent).get_span_context()
208
+ if parent_span.is_valid:
209
+ start_attributes["rippletide.parent.span_id"] = format(parent_span.span_id, "016x")
210
+ self.logger.emit(LogRecord(timestamp=span.start_time, trace_id=span_context.trace_id, span_id=span_context.span_id, trace_flags=span_context.trace_flags, event_name="rippletide.span.started", body=span.name, attributes=start_attributes))
211
+ return span
212
+
213
+ def flush(self) -> bool:
214
+ success = True
215
+ deadline = time.monotonic() + self.client.timeout
216
+ for signal, queue in self.pending.items():
217
+ while queue and time.monotonic() < deadline:
218
+ with self.lock:
219
+ batch = [queue.popleft() for _ in range(min(4, len(queue)))]
220
+ field = "resourceSpans" if signal == "traces" else "resourceLogs"
221
+ packet = {field: [resource for item in batch for resource in item.get(field, [])]}
222
+ for attempt in range(3):
223
+ remaining = deadline - time.monotonic()
224
+ if remaining <= 0:
225
+ success = False
226
+ break
227
+ try:
228
+ response = self.client._request("POST", f"/v1/{signal}", packet, timeout=remaining)
229
+ rejected = int(response.get("partialSuccess", {}).get("rejectedSpans" if signal == "traces" else "rejectedLogRecords", 0))
230
+ success &= rejected == 0
231
+ break
232
+ except Exception as error:
233
+ if getattr(error, "status", None) not in {429, 502, 503, 504} or attempt == 2:
234
+ success = False
235
+ self.client._warn("Rippletide runtime export unavailable; dropped batch")
236
+ break
237
+ time.sleep(min(0.05 * 2 ** attempt, max(0, deadline - time.monotonic())))
238
+ return success and not any(self.pending.values())
239
+
240
+ def shutdown(self) -> None:
241
+ self.provider.shutdown()
242
+ self.logger_provider.shutdown()
@@ -0,0 +1,186 @@
1
+ Metadata-Version: 2.4
2
+ Name: rippletide-python
3
+ Version: 0.3.0
4
+ Summary: Monitor-first Python SDK for Rippletide-connected agents
5
+ License: UNLICENSED
6
+ Requires-Python: >=3.10
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
10
+
11
+ # Rippletide Python
12
+
13
+ `rippletide-python` connects a code-owned Python agent to Rippletide without
14
+ taking ownership of its model traffic. It has no provider dependencies: wrap a
15
+ hand-written provider or internal HTTP call at the existing model seam.
16
+
17
+ ## Install and connect
18
+
19
+ The current release is `rippletide-python==0.3.0`. It requires
20
+ the platform's OTLP ingestion/readers and does not provide V1 trace compatibility.
21
+ Until it is published, validate it by installing a locally built wheel; do not
22
+ assume the unversioned registry install below contains candidate changes.
23
+
24
+ Requires Python 3.10 or later. From the existing agent repository, use its Python
25
+ environment, or create one if it has none:
26
+
27
+ ```bash
28
+ python3 -m venv .venv
29
+ source .venv/bin/activate
30
+ python -m pip install rippletide-python
31
+ ```
32
+
33
+ On Windows, activate with `.venv\Scripts\Activate.ps1` in PowerShell instead.
34
+ Keep the dependency in the project's requirements or package-manager manifest.
35
+
36
+ In Rippletide, choose **Connect an agent → Agent (Python)**. Save the generated
37
+ credentials in the repository's git-ignored `.env`, then apply the generated
38
+ prompt with your coding agent in that repository. The CLI also supports
39
+ `rippletide connect --kind agent --language python --print-prompt`.
40
+
41
+ Run a real turn that uses a tool and delivers a response. Confirm **Runtime
42
+ verified (5/5)** in Fleet and the run's tool calls and **Delivered** outcome in
43
+ Activity. Copying a prompt or storing credentials alone does not prove a
44
+ connection.
45
+
46
+ ## Instrument existing call sites
47
+
48
+ The following fragment belongs inside the existing request handler; reuse the
49
+ agent's own provider, messages, tools, context, and response sink.
50
+
51
+ ```python
52
+ from rippletide import Rippletide, load_rippletide_env
53
+
54
+ # Keep these values aligned with the agent's existing provider call.
55
+ PROVIDER_NAME = "existing-provider"
56
+ PROVIDER_API_KEY_ENV = "EXISTING_PROVIDER_API_KEY"
57
+ MODEL_OPERATION = "existing-provider.request"
58
+
59
+ load_rippletide_env("/path/to/connected-repo/.env")
60
+ rippletide = Rippletide(name="Support agent")
61
+
62
+ try:
63
+ rippletide.register_prompts({"system": {"fallback": SYSTEM_PROMPT}}) # prompt text is withheld by default
64
+ rippletide.tools("core", TOOLS)
65
+ rippletide.connection(PROVIDER_NAME, env={"api_key": PROVIDER_API_KEY_ENV})
66
+ with rippletide.run(name="inbound_request", input=request, conversation_id=conversation_id):
67
+ with rippletide.agent(name="orchestrator"):
68
+ call_model = rippletide.traced(raw_model_call, name=MODEL_OPERATION, kind="model")
69
+ candidate = call_model(messages)
70
+ return rippletide.deliver_response(
71
+ request=request, response=candidate, context=trusted_context,
72
+ deliver=send_to_caller,
73
+ )
74
+ finally:
75
+ rippletide.flush()
76
+ ```
77
+
78
+ The CLI writes an agent-scoped `RIPPLETIDE=rippletide://…` connection string
79
+ into the connected repository's `.env`. `load_rippletide_env()` loads it without
80
+ overriding the process environment. The SDK also accepts the legacy
81
+ `RIPPLETIDE_API_KEY`, `RIPPLETIDE_AGENT_ID` (or `RIPPLETIDE_APP`), and optional
82
+ `RIPPLETIDE_BASE_URL` generated by the Connection page. A valid `RIPPLETIDE`
83
+ connection takes precedence over those legacy values; explicit constructor
84
+ arguments take precedence over either format. Never use a Platform key in the
85
+ agent or commit the `.env`.
86
+
87
+ ## Async and streaming agents
88
+
89
+ The same client serves synchronous and asyncio code. `guard_tool_call()`,
90
+ `guard_tool_result()` and `deliver_response()` accept `async def` callbacks and
91
+ then return a coroutine to await; the policy round trip and receipts run in a
92
+ worker thread, so the event loop is never blocked. `run()`, `agent()` and
93
+ `span()` are plain `with` blocks in both cases. `traced()` wraps sync
94
+ functions, coroutine functions, and calls that return a provider stream: the
95
+ stream is passed through untouched and its span ends once the stream is
96
+ consumed, closed, or abandoned, in which case the span is marked
97
+ `stream_not_consumed`. Policy decides on the complete response: pass the text
98
+ the agent has assembled from the stream to `deliver_response()` before the
99
+ existing sink delivers it. When the tokens must reach the caller as they arrive,
100
+ pass the stream itself as `response`: chunks flow through to `deliver` untouched
101
+ and the decision is recorded on the assembled text once the stream ends, with no
102
+ suppression possible, so a BLOCK is marked as decided after delivery.
103
+ Use `await rippletide.aflush()` and
104
+ `await rippletide.ashutdown()` from asyncio code.
105
+
106
+ ```python
107
+ async def handle(request):
108
+ with rippletide.run(name="inbound_request", input=request):
109
+ stream = rippletide.traced(open_model_stream, name=MODEL_OPERATION, kind="model")(messages)
110
+ candidate = "".join([chunk.text async for chunk in stream])
111
+ await rippletide.guard_tool_call(toolset="core", tool="lookup", params=args, execute=lookup_async)
112
+ return await rippletide.deliver_response(request=request, response=candidate, deliver=send_async)
113
+ ```
114
+
115
+ ## Policy failures and capture
116
+
117
+ The client starts disabled when no Connection key is present. For
118
+ `guard_tool_call()` and `deliver_response()`:
119
+
120
+ - Network failures, 5xx responses, rate limits, and malformed or interrupted HTTP
121
+ responses fail open: the protected callback can still run.
122
+ - An enforce-mode `BLOCK` raises `RippletidePolicyBlockedError` before the
123
+ protected callback runs. An observe-only `WOULD_BLOCK` permits execution.
124
+ - HTTP `400`, `401`, `403`, or `404` from the policy decision endpoint raises
125
+ `RippletidePolicyDecisionError` before the callback runs. These request,
126
+ authentication, authorization, or routing failures are not swallowed as
127
+ telemetry outages.
128
+
129
+ Result filtering has its own fallback rules, described below. Application input
130
+ validation and exceptions from the agent's own callbacks can also propagate.
131
+
132
+ Runtime instrumentation uses native OpenTelemetry spans and start logs, exported as
133
+ OTLP/HTTP JSON to `/v1/traces` and `/v1/logs`. Providers are local to the client;
134
+ the SDK does not replace application providers. The only profile is `full`, but
135
+ content is omitted unless `Rippletide(capture_content=True)` is explicit. Captured
136
+ content is bounded and credential-redacted, including nested Python mappings
137
+ such as `UserDict` and `MappingProxyType`, not generally PII-filtered. The old
138
+ capture modes are unsupported. `runtime_traces=False` disables evidence export
139
+ without disabling policy checks. Always call `flush()` before a short-lived
140
+ worker exits, and `shutdown()` when the client is no longer needed.
141
+
142
+ Tool and response policy spans include the invocation ID; once a decision arrives, their end
143
+ events and the protected action spans include its decision ID, including in
144
+ the default content-withheld posture. These IDs correlate the action with its policy receipt.
145
+
146
+ Prompt inventory is recorded with its content withheld by default. Pass
147
+ `allow_content_export=True` to `register_prompts(...)` only when the prompt
148
+ owner has explicitly approved storing the prompt text in Rippletide.
149
+
150
+ ## Filter a completed tool result (explicit opt-in)
151
+
152
+ For an eligible collection returned by a tool, declare its result contract and
153
+ pass `allow_result_export=True`. This evaluates the complete result transiently
154
+ at Rippletide; do this only with the owner's explicit approval. It is false by
155
+ default. Transient policy failures and malformed or mismatched plans forward
156
+ every item unchanged. A missing result endpoint (`404` with no error code or
157
+ `NOT_FOUND`) also uses this fallback. Other deterministic request or permission
158
+ rejections raise `RippletidePolicyDecisionError` before `apply` runs.
159
+
160
+ ```python
161
+ rippletide.tools("catalog", TOOLS, results={
162
+ "list_products": {
163
+ "resultSchema": {"type": "object", "properties": {"products": {"type": "array"}}},
164
+ "resultCollection": {"itemsPath": ["products"], "itemIdPath": ["id"]},
165
+ },
166
+ })
167
+
168
+ result = list_products(params)
169
+ return rippletide.guard_tool_result(
170
+ toolset="catalog", tool="list_products", params=params,
171
+ result=result, result_items=result["products"], context=trusted_context,
172
+ allow_result_export=True,
173
+ apply=lambda guarded: {**guarded["result"], "products": guarded["deliveredItems"]},
174
+ )
175
+ ```
176
+
177
+ `ALLOW` and observe-only `WOULD_BLOCK` items are delivered; only enforce-mode
178
+ `BLOCK` items are excluded. The callback receives the original result snapshot,
179
+ the delivered and excluded items, and the decision. It is responsible for the
180
+ native response shape, and the SDK records the applied outcome as a receipt.
181
+ Result fingerprints use the server's ECMAScript JSON encoding, including its
182
+ number formatting and UTF-16 object-key ordering.
183
+
184
+ Python MCP servers remain unsupported. A response that is streamed to the
185
+ caller before it is complete cannot be protected by `deliver_response()`; the
186
+ 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=l4bia3FBdqbARXPwSdVcxDMCL1cJOb8n6VhLrzcFcFk,13801
4
+ rippletide_python-0.3.0.dist-info/METADATA,sha256=KmhuIYdBuA5wzDjCjDPX5zkPxmlV6sGH9Jweoem1YIo,9225
5
+ rippletide_python-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ rippletide_python-0.3.0.dist-info/top_level.txt,sha256=NAEYCFwQDE0G96JlVKq00OiKb0YLZ_Er8rcAhOiYHzo,11
7
+ rippletide_python-0.3.0.dist-info/RECORD,,
@@ -1,101 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: rippletide-python
3
- Version: 0.1.0
4
- Summary: Monitor-first Python SDK for Rippletide-connected agents
5
- License: UNLICENSED
6
- Requires-Python: >=3.10
7
- Description-Content-Type: text/markdown
8
-
9
- # Rippletide Python
10
-
11
- `rippletide-python` connects a code-owned Python agent to Rippletide without
12
- taking ownership of its model traffic. It has no provider dependencies: wrap a
13
- hand-written provider or internal HTTP call at the existing model seam.
14
-
15
- ```python
16
- from rippletide import Rippletide, load_rippletide_env
17
-
18
- # Keep these values aligned with the agent's existing provider call.
19
- PROVIDER_NAME = "existing-provider"
20
- PROVIDER_API_KEY_ENV = "EXISTING_PROVIDER_API_KEY"
21
- MODEL_OPERATION = "existing-provider.request"
22
-
23
- load_rippletide_env("/path/to/connected-repo/.env")
24
- rippletide = Rippletide(name="Support agent")
25
-
26
- try:
27
- rippletide.register_prompts({"system": {"fallback": SYSTEM_PROMPT}}) # prompt text is withheld by default
28
- rippletide.tools("core", TOOLS)
29
- rippletide.connection(PROVIDER_NAME, env={"api_key": PROVIDER_API_KEY_ENV})
30
- with rippletide.run(name="inbound_request", input=request, conversation_id=conversation_id):
31
- with rippletide.agent(name="orchestrator"):
32
- call_model = rippletide.traced(raw_model_call, name=MODEL_OPERATION, kind="model")
33
- candidate = call_model(messages)
34
- return rippletide.deliver_response(
35
- request=request, response=candidate, context=trusted_context,
36
- deliver=send_to_caller,
37
- )
38
- finally:
39
- rippletide.flush()
40
- ```
41
-
42
- The CLI writes an agent-scoped `RIPPLETIDE=rippletide://…` connection string
43
- into the connected repository's `.env`. `load_rippletide_env()` loads it without
44
- overriding the process environment. The SDK also accepts the legacy
45
- `RIPPLETIDE_API_KEY`, `RIPPLETIDE_AGENT_ID` (or `RIPPLETIDE_APP`), and optional
46
- `RIPPLETIDE_BASE_URL` generated by the Connection page. A valid `RIPPLETIDE`
47
- connection takes precedence over those legacy values; explicit constructor
48
- arguments take precedence over either format. Never use a Platform key in the
49
- agent or commit the `.env`.
50
-
51
- The client starts disabled when no Connection key is present. Network, 5xx, and
52
- rate-limit failures fail open; a valid enforce-mode policy `BLOCK` is the only
53
- case that prevents a protected callback. Runtime capture defaults to metadata;
54
- use `RIPPLETIDE_CAPTURE_MODE=redacted` or explicitly opt into `full` when the
55
- privacy posture allows it. Always call `flush()` before a short-lived worker,
56
- CLI, or serverless invocation exits.
57
-
58
- Malformed or interrupted HTTP responses also fail open.
59
-
60
- Tool and response policy spans include the invocation ID; once a decision arrives, their end
61
- events and the protected action spans include its decision ID, including in
62
- metadata capture mode. These IDs correlate the action with its policy receipt.
63
-
64
- Prompt inventory is recorded with its content withheld by default. Pass
65
- `allow_content_export=True` to `register_prompts(...)` only when the prompt
66
- owner has explicitly approved storing the prompt text in Rippletide.
67
-
68
- ## Filter a completed tool result (explicit opt-in)
69
-
70
- For an eligible collection returned by a tool, declare its result contract and
71
- pass `allow_result_export=True`. This evaluates the complete result transiently
72
- at Rippletide; do this only with the owner's explicit approval. It is false by
73
- default, and unavailable policy service forwards every item unchanged.
74
-
75
- ```python
76
- rippletide.tools("catalog", TOOLS, results={
77
- "list_products": {
78
- "resultSchema": {"type": "object", "properties": {"products": {"type": "array"}}},
79
- "resultCollection": {"itemsPath": ["products"], "itemIdPath": ["id"]},
80
- },
81
- })
82
-
83
- result = list_products(params)
84
- return rippletide.guard_tool_result(
85
- toolset="catalog", tool="list_products", params=params,
86
- result=result, result_items=result["products"], context=trusted_context,
87
- allow_result_export=True,
88
- apply=lambda guarded: {**guarded["result"], "products": guarded["deliveredItems"]},
89
- )
90
- ```
91
-
92
- `ALLOW` and observe-only `WOULD_BLOCK` items are delivered; only enforce-mode
93
- `BLOCK` items are excluded. The callback receives the original result snapshot,
94
- the delivered and excluded items, and the decision. It is responsible for the
95
- native response shape, and the SDK records the applied outcome as a receipt.
96
- Result fingerprints use the server's ECMAScript JSON encoding, including its
97
- number formatting and UTF-16 object-key ordering.
98
-
99
- This first release supports synchronous Python call sites. Python MCP servers,
100
- async generators, and response streaming remain unsupported rather than being
101
- silently represented as complete response-delivery protection.
@@ -1,6 +0,0 @@
1
- rippletide/__init__.py,sha256=lPw2FwwCVSYhMQdQlgZufRLZLZrECKYPvtrMELUxYdI,304
2
- rippletide/client.py,sha256=kb1x4AlOx-20ABVpuyEiXAFyq6OmKVnoFlEcA94WKOM,38176
3
- rippletide_python-0.1.0.dist-info/METADATA,sha256=JjBGhn3keRNNGmOUDtT4BtaK0I6Xwtmy3KCREVSZgK8,4704
4
- rippletide_python-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
- rippletide_python-0.1.0.dist-info/top_level.txt,sha256=NAEYCFwQDE0G96JlVKq00OiKb0YLZ_Er8rcAhOiYHzo,11
6
- rippletide_python-0.1.0.dist-info/RECORD,,