rippletide-python 0.1.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/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Monitor-first tracing and policy guards for code-owned Python agents."""
2
+
3
+ from .client import Rippletide, RippletidePolicyBlockedError, RippletidePolicyDecisionError, load_rippletide_env
4
+
5
+ __all__ = ["Rippletide", "RippletidePolicyBlockedError", "RippletidePolicyDecisionError", "load_rippletide_env"]
rippletide/client.py ADDED
@@ -0,0 +1,654 @@
1
+ """The small synchronous Python counterpart to rippletide-package/sdk.
2
+
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.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ import os
12
+ import re
13
+ import secrets
14
+ import time
15
+ import uuid
16
+ from hashlib import sha256
17
+ from http.client import HTTPException
18
+ from contextlib import contextmanager
19
+ from contextvars import ContextVar
20
+ from datetime import datetime, timezone
21
+ from typing import Any, Callable, Iterator, Mapping, TypeVar
22
+ from urllib.error import HTTPError, URLError
23
+ from urllib.parse import quote, unquote, urlsplit
24
+ from urllib.request import Request, urlopen
25
+
26
+ 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)
31
+ _DEFAULT_BASE_URL = "https://agent-evalserver-production.up.railway.app"
32
+ _STAGING_BASE_URL = "https://agent-evalserver-staging.up.railway.app"
33
+ _SHA256 = re.compile(r"^[0-9a-f]{64}$", re.I)
34
+ _MAX_RESULT_ITEMS = 500
35
+
36
+
37
+ def _now() -> str:
38
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
39
+
40
+
41
+ def _hex(size: int) -> str:
42
+ return secrets.token_hex(size)
43
+
44
+
45
+ def _trim_url(value: str) -> str:
46
+ return value.rstrip("/")
47
+
48
+
49
+ def _base_url_for_key(key: str | None) -> str:
50
+ return _STAGING_BASE_URL if key and key.startswith("rt_staging_") else _DEFAULT_BASE_URL
51
+
52
+
53
+ def _connection_env() -> dict[str, str]:
54
+ """Decode the CLI's single-env connection, falling back to legacy values."""
55
+ try:
56
+ url = urlsplit(os.getenv("RIPPLETIDE", "").strip())
57
+ api_key = unquote(url.username or "", errors="strict")
58
+ agent_id = unquote(url.path.lstrip("/").split("/")[0], errors="strict")
59
+ hostname = url.hostname
60
+ port = url.port # Validate the port before using the authority.
61
+ if url.scheme == "rippletide" and api_key and agent_id and hostname:
62
+ host = f"[{hostname}]" if ":" in hostname else hostname
63
+ if port is not None:
64
+ host += f":{port}"
65
+ scheme = "http" if hostname in {"localhost", "127.0.0.1", "0.0.0.0"} else "https"
66
+ return {"agent_id": agent_id, "api_key": api_key, "base_url": f"{scheme}://{host}"}
67
+ except (ValueError, UnicodeError):
68
+ pass
69
+ return {
70
+ "agent_id": os.getenv("RIPPLETIDE_AGENT_ID") or os.getenv("RIPPLETIDE_APP", ""),
71
+ "api_key": os.getenv("RIPPLETIDE_API_KEY", ""),
72
+ "base_url": os.getenv("RIPPLETIDE_BASE_URL", ""),
73
+ }
74
+
75
+
76
+ def load_rippletide_env(path: str) -> None:
77
+ """Load only RIPPLETIDE and RIPPLETIDE_* values from an explicit, git-ignored .env path.
78
+
79
+ This deliberately does not override a host-provided environment and never
80
+ raises. Call it from the connected repository's real entry module, not from
81
+ a package-local working directory.
82
+ """
83
+ try:
84
+ with open(path, encoding="utf-8") as source:
85
+ for raw in source:
86
+ key, separator, value = raw.strip().partition("=")
87
+ if separator and (key == "RIPPLETIDE" or key.startswith("RIPPLETIDE_")) and key not in os.environ:
88
+ os.environ[key] = value.strip().strip('"').strip("'")
89
+ except OSError:
90
+ pass
91
+
92
+
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
+ def _clone_policy_value(value: Any, label: str) -> Any:
118
+ """Detach the exact JSON snapshot that policy evaluates and apply receives."""
119
+ try:
120
+ encoded = json.dumps(value, allow_nan=False, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
121
+ return json.loads(encoded)
122
+ except (TypeError, ValueError) as error:
123
+ raise TypeError(f"{label} must be JSON-compatible.") from error
124
+
125
+
126
+ def _policy_json(value: Any) -> str:
127
+ """Match the server's stableStringifyPolicyJson (ECMAScript JSON)."""
128
+ if value is None:
129
+ return "null"
130
+ if isinstance(value, bool):
131
+ return "true" if value else "false"
132
+ if isinstance(value, (int, float)):
133
+ # The server parses all JSON numbers as IEEE-754 doubles. Python's
134
+ # shortest round-trip representation uses the same significant digits,
135
+ # but different fixed/exponent thresholds and preserves negative zero.
136
+ number = float(value)
137
+ if not math.isfinite(number):
138
+ raise ValueError("Policy JSON numbers must be finite.")
139
+ if number == 0:
140
+ return "0"
141
+ sign = "-" if number < 0 else ""
142
+ mantissa, _, exponent = repr(abs(number)).partition("e")
143
+ whole, _, fraction = mantissa.partition(".")
144
+ point = len(whole) + int(exponent or "0")
145
+ digits = whole + fraction
146
+ leading = len(digits) - len(digits.lstrip("0"))
147
+ digits = digits.lstrip("0").rstrip("0")
148
+ point -= leading
149
+ if 0 < point <= 21:
150
+ encoded = (digits + "0" * max(0, point - len(digits)))[:point]
151
+ if len(digits) > point:
152
+ encoded += "." + digits[point:]
153
+ elif -6 < point <= 0:
154
+ encoded = "0." + "0" * -point + digits
155
+ else:
156
+ encoded = digits[0] + ("." + digits[1:] if len(digits) > 1 else "")
157
+ encoded += f"e{point - 1:+d}" if point > 0 else f"e{point - 1}"
158
+ return sign + encoded
159
+ if isinstance(value, str):
160
+ # JSON.stringify escapes lone UTF-16 surrogates, not ordinary Unicode.
161
+ encoded = json.dumps(value, ensure_ascii=False)
162
+ return re.sub(r"[\ud800-\udfff]", lambda match: f"\\u{ord(match[0]):04x}", encoded)
163
+ if isinstance(value, list):
164
+ return "[" + ",".join(_policy_json(item) for item in value) + "]"
165
+ if isinstance(value, Mapping):
166
+ keys = sorted(value, key=lambda key: key.encode("utf-16-be", "surrogatepass"))
167
+ return "{" + ",".join(_policy_json(key) + ":" + _policy_json(value[key]) for key in keys) + "}"
168
+ raise TypeError("Policy values must be JSON-compatible.")
169
+
170
+
171
+ def _policy_fingerprint(value: Any) -> str:
172
+ return sha256(_policy_json(value).encode()).hexdigest()
173
+
174
+
175
+ def _withheld_prompt(prompt_type: str) -> Any:
176
+ if prompt_type == "chat":
177
+ return [{"role": "system", "content": "[Prompt content withheld]"}]
178
+ return "[Prompt content withheld]"
179
+
180
+
181
+ def _result_contracts(value: Mapping[str, Mapping[str, Any]] | None, tool_names: set[str]) -> dict[str, dict[str, Any]]:
182
+ """Validate the small contract surface needed by result policy evaluation."""
183
+ if value is None:
184
+ return {}
185
+ if not isinstance(value, Mapping):
186
+ raise TypeError("tool result contracts must be a mapping.")
187
+ contracts: dict[str, dict[str, Any]] = {}
188
+ for tool_name, contract in value.items():
189
+ if not isinstance(tool_name, str) or tool_name not in tool_names:
190
+ raise TypeError(f"Unknown tool result contract: {tool_name}.")
191
+ if not isinstance(contract, Mapping) or set(contract) - {"resultSchema", "resultCollection"}:
192
+ raise TypeError(f'result contract for "{tool_name}" contains an unknown field.')
193
+ schema = contract.get("resultSchema")
194
+ if not isinstance(schema, Mapping):
195
+ raise TypeError(f'resultSchema for "{tool_name}" must be a JSON object.')
196
+ normalized: dict[str, Any] = {"resultSchema": _clone_policy_value(schema, f'resultSchema for "{tool_name}"')}
197
+ collection = contract.get("resultCollection")
198
+ if collection is not None:
199
+ if not isinstance(collection, Mapping) or set(collection) - {"itemsPath", "itemIdPath"}:
200
+ raise TypeError(f'resultCollection for "{tool_name}" contains an unknown field.')
201
+ paths: dict[str, list[str]] = {}
202
+ for key, allow_empty in (("itemsPath", True), ("itemIdPath", False)):
203
+ path = collection.get(key)
204
+ if not isinstance(path, list) or len(path) > 16 or (not allow_empty and not path) or not all(
205
+ isinstance(segment, str)
206
+ and 0 < len(segment) <= 128
207
+ and segment == segment.strip()
208
+ and segment not in {"__proto__", "constructor", "prototype"}
209
+ for segment in path
210
+ ):
211
+ raise TypeError(f'{key} for "{tool_name}" must be a safe property path.')
212
+ paths[key] = list(path)
213
+ normalized["resultCollection"] = paths
214
+ contracts[tool_name] = normalized
215
+ return contracts
216
+
217
+
218
+ class RippletideHttpError(RuntimeError):
219
+ def __init__(self, status: int, code: str | None = None) -> None:
220
+ super().__init__(f"HTTP {status}" + (f" ({code})" if code else ""))
221
+ self.status, self.code = status, code
222
+
223
+
224
+ class RippletidePolicyBlockedError(RuntimeError):
225
+ code = "RIPPLETIDE_POLICY_BLOCKED"
226
+
227
+ def __init__(self, action: str, decision: Mapping[str, Any]) -> None:
228
+ detail = decision.get("decision") if isinstance(decision.get("decision"), Mapping) else {}
229
+ reason = detail.get("reason") if isinstance(detail, Mapping) else None
230
+ super().__init__(f'Action "{action}" was blocked by policy' + (f": {reason}" if isinstance(reason, str) else "") + ".")
231
+ self.action, self.decision_id = action, decision.get("decisionId")
232
+
233
+
234
+ class RippletidePolicyDecisionError(RuntimeError):
235
+ code = "RIPPLETIDE_POLICY_DECISION_REJECTED"
236
+
237
+ def __init__(self, action: str, status: int, server_code: str | None = None) -> None:
238
+ super().__init__(f'Action "{action}" was rejected by policy with HTTP {status}' + (f" ({server_code})" if server_code else "") + ".")
239
+ self.action, self.status, self.server_code = action, status, server_code
240
+
241
+
242
+ class _Span:
243
+ def __init__(self, client: "Rippletide", kind: str, name: str, *, root: bool = False, traceparent: str | None = None, **options: Any) -> None:
244
+ 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
251
+ self.output: Any = None
252
+ self.output_set = False
253
+ self.end_attributes: dict[str, Any] = {}
254
+
255
+ 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"))
258
+ return self
259
+
260
+ def set_output(self, output: Any, **attributes: Any) -> None:
261
+ self.output, self.output_set = output, True
262
+ self.end_attributes.update(attributes)
263
+
264
+ 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
272
+ return False
273
+
274
+
275
+ class Rippletide:
276
+ """Synchronous, dependency-free client with the TypeScript SDK's core semantics."""
277
+
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:
279
+ from_env = _connection_env()
280
+ self.agent_id = agent_id or from_env["agent_id"] or None
281
+ self.api_key = api_key or from_env["api_key"] or None
282
+ 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"
285
+ self.disabled = bool(disabled) if disabled is not None else os.getenv("RIPPLETIDE_DISABLED") == "1" or not bool(self.api_key)
286
+ self.timeout = (timeout_ms or int(os.getenv("RIPPLETIDE_TIMEOUT_MS", "5000"))) / 1000
287
+ self.agent_meta = {key: value for key, value in {"name": name, "description": description}.items() if value}
288
+ self.events: list[dict[str, Any]] = []
289
+ self._registered = False
290
+ self._warn = warn or (lambda _message: None)
291
+
292
+ def _request(self, method: str, path: str, body: Any | None = None) -> Any:
293
+ if not self.api_key:
294
+ raise RippletideHttpError(401, "UNAUTHENTICATED")
295
+ 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
+ try:
297
+ with urlopen(request, timeout=self.timeout) as response:
298
+ return json.loads(response.read() or b"{}")
299
+ except HTTPError as error:
300
+ try:
301
+ payload = json.loads(error.read() or b"{}")
302
+ except (ValueError, OSError, HTTPException):
303
+ payload = {}
304
+ finally:
305
+ error.close()
306
+ raise RippletideHttpError(error.code, payload.get("error") if isinstance(payload, Mapping) else None) from error
307
+ except (URLError, OSError, HTTPException, ValueError) as error:
308
+ raise RippletideHttpError(503, "UNAVAILABLE") from error
309
+
310
+ def _ensure_registered(self) -> bool:
311
+ if self.disabled or not self.agent_id or self._registered:
312
+ return self._registered
313
+ try:
314
+ self._request("POST", f"/api/apps/{quote(self.agent_id, safe='')}/agent", {"seed": True, **self.agent_meta})
315
+ self._registered = True
316
+ self.heartbeat(source="sdk")
317
+ return True
318
+ except RippletideHttpError:
319
+ self._warn("Rippletide registration unavailable; continuing normally")
320
+ return False
321
+
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
+
355
+ @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:
358
+ yield span
359
+
360
+ @contextmanager
361
+ def agent(self, *, name: str = "agent", input: Any = None, attributes: Mapping[str, Any] | None = None) -> Iterator[_Span]:
362
+ with _Span(self, "agent", name, input=input, attributes=attributes) as span:
363
+ yield span
364
+
365
+ @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:
371
+ yield current
372
+
373
+ def traced(self, fn: Callable[..., T], *, name: str | None = None, kind: str = "tool") -> Callable[..., T]:
374
+ 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:
376
+ result = fn(*args, **kwargs)
377
+ current.set_output(result)
378
+ return result
379
+ return wrapped
380
+
381
+ 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 {}
384
+
385
+ def heartbeat(self, **attributes: Any) -> None:
386
+ with _Span(self, "operation", "heartbeat", root=True, attributes={"signal": "heartbeat", **attributes}):
387
+ pass
388
+
389
+ def register_prompts(self, prompts: Mapping[str, Any], *, allow_content_export: bool = False) -> None:
390
+ """Declare prompts without exporting their text unless explicitly approved."""
391
+ if self.disabled or not self.agent_id or not self._ensure_registered():
392
+ return
393
+ export_content = allow_content_export is True
394
+ for name, value in prompts.items():
395
+ options = value if isinstance(value, Mapping) and "fallback" in value else {"fallback": value}
396
+ fallback = options.get("fallback")
397
+ prompt_type = options.get("type") or ("chat" if isinstance(fallback, list) else "text")
398
+ 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})
400
+ except RippletideHttpError:
401
+ self._warn(f'Rippletide prompt "{name}" unavailable; using local prompt')
402
+
403
+ def tools(self, set_name: str, tools: list[Mapping[str, Any]], *, results: Mapping[str, Mapping[str, Any]] | None = None) -> list[Mapping[str, Any]]:
404
+ """Declare tools and, optionally, result contracts for after-action guards."""
405
+ if self.disabled or not self.agent_id or not tools or not self._ensure_registered():
406
+ return tools
407
+ normalized = []
408
+ for tool in tools:
409
+ source = tool.get("function") if isinstance(tool.get("function"), Mapping) else tool
410
+ if not isinstance(source, Mapping) or not isinstance(source.get("name"), str):
411
+ continue
412
+ normalized.append({"name": source["name"], "description": source.get("description", ""), "parameters": source.get("parameters", source.get("input_schema", {})), "source": "sdk"})
413
+ try:
414
+ contracts = _result_contracts(results, {tool["name"] for tool in normalized})
415
+ payload = {"tools": [{**tool, **contracts.get(tool["name"], {})} for tool in normalized]}
416
+ self._request("POST", f"/api/apps/{quote(self.agent_id, safe='')}/toolsets/{quote(set_name, safe='')}/sync", payload)
417
+ except (RippletideHttpError, TypeError):
418
+ self._warn(f'Rippletide toolset "{set_name}" unavailable; using local tools')
419
+ return tools
420
+
421
+ def connection(self, provider: str, *, env: Mapping[str, str] | None = None, config: Mapping[str, str] | None = None) -> dict[str, Any]:
422
+ values = {key: os.getenv(env_name, "") for key, env_name in (env or {}).items()}
423
+ if not self.disabled and self.agent_id and self._ensure_registered():
424
+ try:
425
+ self._request("POST", f"/api/apps/{quote(self.agent_id, safe='')}/connections/{quote(provider, safe='')}", {"seed": True, "config": dict(config or {}), "secrets": {key: "configured" if value else "" for key, value in values.items()}})
426
+ except RippletideHttpError:
427
+ self._warn(f'Rippletide connection "{provider}" unavailable; using local configuration')
428
+ return {"provider": provider, "config": dict(config or {}), "secrets": values}
429
+
430
+ def _decide(self, path: str, body: Mapping[str, Any], action: str) -> Mapping[str, Any] | None:
431
+ try:
432
+ response = self._request("POST", f"/v1/policy{path}", body)
433
+ if not isinstance(response, Mapping) or not isinstance(response.get("decisionId"), str):
434
+ raise RippletideHttpError(503, "INVALID_RESPONSE")
435
+ return response
436
+ except RippletideHttpError as error:
437
+ if error.status in {401, 400, 403, 404}:
438
+ raise RippletidePolicyDecisionError(action, error.status, error.code) from error
439
+ self._warn("Rippletide policy decision unavailable; proceeding")
440
+ return None
441
+
442
+ def _receipt(self, decision: Mapping[str, Any] | None, outcome: str, elapsed_ms: int) -> None:
443
+ if not decision or not isinstance(decision.get("decisionId"), str):
444
+ return
445
+ self._receipt_body(decision["decisionId"], {"outcome": outcome, "occurredAt": _now(), "decisionRoundtripMs": elapsed_ms})
446
+
447
+ def _receipt_body(self, decision_id: str, body: Mapping[str, Any]) -> None:
448
+ try:
449
+ self._request("POST", f"/v1/policy/decisions/{quote(decision_id, safe='')}/receipt", dict(body))
450
+ except RippletideHttpError:
451
+ self._warn("Rippletide receipt unavailable; applied action is unchanged")
452
+
453
+ def _decide_result(self, body: Mapping[str, Any], action: str, result: Any, result_items: list[Mapping[str, Any]]) -> Mapping[str, Any] | None:
454
+ """Return only a plan that matches the immutable result snapshot.
455
+
456
+ Any unavailable, unsupported, or malformed plan fails open by returning
457
+ None. A deterministic client/configuration error remains visible before
458
+ the host applies a result it expected us to evaluate.
459
+ """
460
+ try:
461
+ response = self._request("POST", "/v1/policy/decide-result", body)
462
+ except RippletideHttpError as error:
463
+ unsupported = error.status == 404 and error.code in {None, "NOT_FOUND"}
464
+ transient = error.status >= 500 or error.status in {408, 425, 429} or (error.status == 409 and error.code == "POLICY_SNAPSHOT_CHANGED")
465
+ if not unsupported and not transient:
466
+ raise RippletidePolicyDecisionError(action, error.status, error.code) from error
467
+ self._warn(f'Result policy decision unavailable for "{action}"; forwarding every item')
468
+ return None
469
+
470
+ if not isinstance(response, Mapping):
471
+ self._warn(f'Result policy decision was malformed for "{action}"; forwarding every item')
472
+ return None
473
+ result_fingerprint = _policy_fingerprint(result)
474
+ items = response.get("items")
475
+ expected_disposition = "ALLOW"
476
+ if not isinstance(items, list) or len(items) != len(result_items) or len(items) > _MAX_RESULT_ITEMS:
477
+ self._warn(f'Result policy plan did not match "{action}"; forwarding every item')
478
+ return None
479
+ item_ids: set[str] = set()
480
+ delivered = excluded = would_exclude = 0
481
+ for index, item in enumerate(items):
482
+ if not isinstance(item, Mapping) or item.get("index") != index:
483
+ self._warn(f'Result policy plan did not match "{action}"; forwarding every item')
484
+ return None
485
+ item_id, disposition = item.get("itemId"), item.get("disposition")
486
+ if not isinstance(item_id, str) or not item_id.strip() or item_id != item_id.strip() or item_id in item_ids:
487
+ self._warn(f'Result policy plan did not match "{action}"; forwarding every item')
488
+ return None
489
+ if item.get("itemFingerprint") != _policy_fingerprint(result_items[index]) or disposition not in {"ALLOW", "WOULD_BLOCK", "BLOCK"}:
490
+ self._warn(f'Result policy plan did not match "{action}"; forwarding every item')
491
+ return None
492
+ item_ids.add(item_id)
493
+ if disposition == "BLOCK":
494
+ excluded += 1
495
+ else:
496
+ delivered += 1
497
+ if disposition == "WOULD_BLOCK":
498
+ would_exclude += 1
499
+ expected_disposition = "FILTER" if excluded else "WOULD_FILTER" if would_exclude else "ALLOW"
500
+ summary = response.get("summary")
501
+ try:
502
+ uuid.UUID(str(response.get("decisionId")))
503
+ except (TypeError, ValueError, AttributeError):
504
+ self._warn(f'Result policy plan did not match "{action}"; forwarding every item')
505
+ return None
506
+ expected_plan_fingerprint = _policy_fingerprint({
507
+ "formatVersion": 1,
508
+ "resultFingerprint": result_fingerprint,
509
+ "catalogFingerprint": response.get("catalogFingerprint"),
510
+ "evaluatedRuleReleases": response.get("evaluatedRuleReleases"),
511
+ "items": items,
512
+ })
513
+ if (
514
+ not isinstance(response.get("decisionId"), str)
515
+ or response.get("invocationId") != body["invocationId"]
516
+ or response.get("action") != action
517
+ or response.get("resultFingerprint") != result_fingerprint
518
+ or not isinstance(response.get("catalogFingerprint"), str)
519
+ or not _SHA256.match(response["catalogFingerprint"])
520
+ or not isinstance(response.get("resultPlanFingerprint"), str)
521
+ or not _SHA256.match(response["resultPlanFingerprint"])
522
+ or response["resultPlanFingerprint"] != expected_plan_fingerprint
523
+ or not isinstance(response.get("evaluatedRuleReleases"), list)
524
+ or response.get("disposition") != expected_disposition
525
+ or not isinstance(summary, Mapping)
526
+ or summary.get("total") != len(items)
527
+ or summary.get("delivered") != delivered
528
+ or summary.get("excluded") != excluded
529
+ or summary.get("wouldExclude") != would_exclude
530
+ ):
531
+ self._warn(f'Result policy plan did not match "{action}"; forwarding every item')
532
+ return None
533
+ return response
534
+
535
+ def guard_tool_call(self, *, toolset: str, tool: str, params: Mapping[str, Any], execute: Callable[[Mapping[str, Any]], T], context: Mapping[str, Any] | None = None, tool_call_id: str | None = None) -> T:
536
+ action, invocation_id = f"{quote(toolset, safe='')}/{quote(tool, safe='')}", str(uuid.uuid4())
537
+ if self.disabled:
538
+ 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
556
+
557
+ 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
+ """Filter an already-completed tool result before its next local sink.
559
+
560
+ `allow_result_export` is deliberately false by default: result-policy
561
+ evaluation transmits the complete result to Rippletide. The callback is
562
+ always invoked with a detached snapshot; with no consent or an
563
+ unavailable service, it contains every item and `policyDecision=None`.
564
+ """
565
+ if not callable(apply):
566
+ raise TypeError("Rippletide guard_tool_result requires an apply function.")
567
+ if not isinstance(result_items, list) or len(result_items) > _MAX_RESULT_ITEMS or not all(isinstance(item, Mapping) for item in result_items):
568
+ raise TypeError("result_items must contain at most 500 JSON objects.")
569
+ action = f"{quote(toolset, safe='')}/{quote(tool, safe='')}"
570
+ evaluated_params = _clone_policy_value(dict(params), "params")
571
+ evaluated_result = _clone_policy_value(result, "result")
572
+ evaluated_items = [_clone_policy_value(item, f"result_items[{index}]") for index, item in enumerate(result_items)]
573
+ 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
+
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,
618
+ })
619
+
620
+ def deliver_response(self, *, request: Any, response: Any, deliver: Callable[[Any], T], context: Mapping[str, Any] | None = None) -> T:
621
+ invocation_id, action = str(uuid.uuid4()), "rippletide/response"
622
+ if self.disabled:
623
+ 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
641
+
642
+ def flush(self) -> bool:
643
+ if self.disabled or not self.agent_id or not self.events:
644
+ return False
645
+ 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
@@ -0,0 +1,101 @@
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.
@@ -0,0 +1,6 @@
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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ rippletide