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 +361 -194
- rippletide/telemetry.py +242 -0
- rippletide_python-0.3.0.dist-info/METADATA +186 -0
- rippletide_python-0.3.0.dist-info/RECORD +7 -0
- rippletide_python-0.1.0.dist-info/METADATA +0 -101
- rippletide_python-0.1.0.dist-info/RECORD +0 -6
- {rippletide_python-0.1.0.dist-info → rippletide_python-0.3.0.dist-info}/WHEEL +0 -0
- {rippletide_python-0.1.0.dist-info → rippletide_python-0.3.0.dist-info}/top_level.txt +0 -0
rippletide/client.py
CHANGED
|
@@ -1,33 +1,35 @@
|
|
|
1
|
-
"""The small
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
self.
|
|
248
|
-
self.
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
266
|
-
self.
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
"""
|
|
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,
|
|
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.
|
|
284
|
-
self.
|
|
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.
|
|
406
|
+
self._telemetry: Telemetry | None = None
|
|
289
407
|
self._registered = False
|
|
290
|
-
self.
|
|
408
|
+
self._warning_handler = warn
|
|
291
409
|
|
|
292
|
-
def
|
|
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 =
|
|
357
|
-
with _Span(self, "run", name,
|
|
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 =
|
|
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 =
|
|
367
|
-
|
|
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
|
-
|
|
468
|
+
span = _Span(self, _span_kind(kind), label, input={"args": args, "kwargs": kwargs}).__enter__()
|
|
469
|
+
try:
|
|
376
470
|
result = fn(*args, **kwargs)
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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":
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
return
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
"
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
"
|
|
613
|
-
"
|
|
614
|
-
"
|
|
615
|
-
"
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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.
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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)
|
rippletide/telemetry.py
ADDED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|