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 @@
|
|
|
1
|
+
rippletide
|