rippletide-python 0.2.0__tar.gz → 0.3.0__tar.gz
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_python-0.2.0 → rippletide_python-0.3.0}/PKG-INFO +34 -7
- {rippletide_python-0.2.0 → rippletide_python-0.3.0}/README.md +33 -6
- {rippletide_python-0.2.0 → rippletide_python-0.3.0}/pyproject.toml +1 -1
- {rippletide_python-0.2.0 → rippletide_python-0.3.0}/rippletide/client.py +295 -91
- {rippletide_python-0.2.0 → rippletide_python-0.3.0}/rippletide/telemetry.py +2 -1
- {rippletide_python-0.2.0 → rippletide_python-0.3.0}/rippletide_python.egg-info/PKG-INFO +34 -7
- {rippletide_python-0.2.0 → rippletide_python-0.3.0}/tests/test_client.py +134 -1
- {rippletide_python-0.2.0 → rippletide_python-0.3.0}/rippletide/__init__.py +0 -0
- {rippletide_python-0.2.0 → rippletide_python-0.3.0}/rippletide_python.egg-info/SOURCES.txt +0 -0
- {rippletide_python-0.2.0 → rippletide_python-0.3.0}/rippletide_python.egg-info/dependency_links.txt +0 -0
- {rippletide_python-0.2.0 → rippletide_python-0.3.0}/rippletide_python.egg-info/requires.txt +0 -0
- {rippletide_python-0.2.0 → rippletide_python-0.3.0}/rippletide_python.egg-info/top_level.txt +0 -0
- {rippletide_python-0.2.0 → rippletide_python-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rippletide-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Monitor-first Python SDK for Rippletide-connected agents
|
|
5
5
|
License: UNLICENSED
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -16,7 +16,7 @@ hand-written provider or internal HTTP call at the existing model seam.
|
|
|
16
16
|
|
|
17
17
|
## Install and connect
|
|
18
18
|
|
|
19
|
-
The
|
|
19
|
+
The current release is `rippletide-python==0.3.0`. It requires
|
|
20
20
|
the platform's OTLP ingestion/readers and does not provide V1 trace compatibility.
|
|
21
21
|
Until it is published, validate it by installing a locally built wheel; do not
|
|
22
22
|
assume the unversioned registry install below contains candidate changes.
|
|
@@ -41,8 +41,7 @@ prompt with your coding agent in that repository. The CLI also supports
|
|
|
41
41
|
Run a real turn that uses a tool and delivers a response. Confirm **Runtime
|
|
42
42
|
verified (5/5)** in Fleet and the run's tool calls and **Delivered** outcome in
|
|
43
43
|
Activity. Copying a prompt or storing credentials alone does not prove a
|
|
44
|
-
connection.
|
|
45
|
-
streaming are not supported.
|
|
44
|
+
connection.
|
|
46
45
|
|
|
47
46
|
## Instrument existing call sites
|
|
48
47
|
|
|
@@ -85,6 +84,34 @@ connection takes precedence over those legacy values; explicit constructor
|
|
|
85
84
|
arguments take precedence over either format. Never use a Platform key in the
|
|
86
85
|
agent or commit the `.env`.
|
|
87
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
|
+
|
|
88
115
|
## Policy failures and capture
|
|
89
116
|
|
|
90
117
|
The client starts disabled when no Connection key is present. For
|
|
@@ -154,6 +181,6 @@ native response shape, and the SDK records the applied outcome as a receipt.
|
|
|
154
181
|
Result fingerprints use the server's ECMAScript JSON encoding, including its
|
|
155
182
|
number formatting and UTF-16 object-key ordering.
|
|
156
183
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
@@ -6,7 +6,7 @@ hand-written provider or internal HTTP call at the existing model seam.
|
|
|
6
6
|
|
|
7
7
|
## Install and connect
|
|
8
8
|
|
|
9
|
-
The
|
|
9
|
+
The current release is `rippletide-python==0.3.0`. It requires
|
|
10
10
|
the platform's OTLP ingestion/readers and does not provide V1 trace compatibility.
|
|
11
11
|
Until it is published, validate it by installing a locally built wheel; do not
|
|
12
12
|
assume the unversioned registry install below contains candidate changes.
|
|
@@ -31,8 +31,7 @@ prompt with your coding agent in that repository. The CLI also supports
|
|
|
31
31
|
Run a real turn that uses a tool and delivers a response. Confirm **Runtime
|
|
32
32
|
verified (5/5)** in Fleet and the run's tool calls and **Delivered** outcome in
|
|
33
33
|
Activity. Copying a prompt or storing credentials alone does not prove a
|
|
34
|
-
connection.
|
|
35
|
-
streaming are not supported.
|
|
34
|
+
connection.
|
|
36
35
|
|
|
37
36
|
## Instrument existing call sites
|
|
38
37
|
|
|
@@ -75,6 +74,34 @@ connection takes precedence over those legacy values; explicit constructor
|
|
|
75
74
|
arguments take precedence over either format. Never use a Platform key in the
|
|
76
75
|
agent or commit the `.env`.
|
|
77
76
|
|
|
77
|
+
## Async and streaming agents
|
|
78
|
+
|
|
79
|
+
The same client serves synchronous and asyncio code. `guard_tool_call()`,
|
|
80
|
+
`guard_tool_result()` and `deliver_response()` accept `async def` callbacks and
|
|
81
|
+
then return a coroutine to await; the policy round trip and receipts run in a
|
|
82
|
+
worker thread, so the event loop is never blocked. `run()`, `agent()` and
|
|
83
|
+
`span()` are plain `with` blocks in both cases. `traced()` wraps sync
|
|
84
|
+
functions, coroutine functions, and calls that return a provider stream: the
|
|
85
|
+
stream is passed through untouched and its span ends once the stream is
|
|
86
|
+
consumed, closed, or abandoned, in which case the span is marked
|
|
87
|
+
`stream_not_consumed`. Policy decides on the complete response: pass the text
|
|
88
|
+
the agent has assembled from the stream to `deliver_response()` before the
|
|
89
|
+
existing sink delivers it. When the tokens must reach the caller as they arrive,
|
|
90
|
+
pass the stream itself as `response`: chunks flow through to `deliver` untouched
|
|
91
|
+
and the decision is recorded on the assembled text once the stream ends, with no
|
|
92
|
+
suppression possible, so a BLOCK is marked as decided after delivery.
|
|
93
|
+
Use `await rippletide.aflush()` and
|
|
94
|
+
`await rippletide.ashutdown()` from asyncio code.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
async def handle(request):
|
|
98
|
+
with rippletide.run(name="inbound_request", input=request):
|
|
99
|
+
stream = rippletide.traced(open_model_stream, name=MODEL_OPERATION, kind="model")(messages)
|
|
100
|
+
candidate = "".join([chunk.text async for chunk in stream])
|
|
101
|
+
await rippletide.guard_tool_call(toolset="core", tool="lookup", params=args, execute=lookup_async)
|
|
102
|
+
return await rippletide.deliver_response(request=request, response=candidate, deliver=send_async)
|
|
103
|
+
```
|
|
104
|
+
|
|
78
105
|
## Policy failures and capture
|
|
79
106
|
|
|
80
107
|
The client starts disabled when no Connection key is present. For
|
|
@@ -144,6 +171,6 @@ native response shape, and the SDK records the applied outcome as a receipt.
|
|
|
144
171
|
Result fingerprints use the server's ECMAScript JSON encoding, including its
|
|
145
172
|
number formatting and UTF-16 object-key ordering.
|
|
146
173
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
174
|
+
Python MCP servers remain unsupported. A response that is streamed to the
|
|
175
|
+
caller before it is complete cannot be protected by `deliver_response()`; the
|
|
176
|
+
SDK does not represent such a path as complete response-delivery protection.
|
|
@@ -1,22 +1,26 @@
|
|
|
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
|
|
16
|
+
import sys
|
|
13
17
|
import time
|
|
14
18
|
import uuid
|
|
15
19
|
from hashlib import sha256
|
|
16
20
|
from http.client import HTTPException
|
|
17
21
|
from contextlib import contextmanager, nullcontext
|
|
18
22
|
from datetime import datetime, timezone
|
|
19
|
-
from typing import Any, Callable, Iterator, Mapping, TypeVar
|
|
23
|
+
from typing import Any, Awaitable, Callable, Iterator, Mapping, TypeVar
|
|
20
24
|
from urllib.error import HTTPError, URLError
|
|
21
25
|
from urllib.parse import quote, unquote, urlsplit
|
|
22
26
|
from urllib.request import Request, urlopen
|
|
@@ -230,10 +234,18 @@ class _Span:
|
|
|
230
234
|
self.client._warn("Rippletide span capture unavailable; continuing normally")
|
|
231
235
|
self.trace_id = format(self.native.get_span_context().trace_id, "032x")
|
|
232
236
|
self.span_id = format(self.native.get_span_context().span_id, "016x")
|
|
233
|
-
self.scope =
|
|
237
|
+
self.scope = self._activation()
|
|
234
238
|
self.scope.__enter__()
|
|
235
239
|
return self
|
|
236
240
|
|
|
241
|
+
def _activation(self) -> Any:
|
|
242
|
+
return trace.use_span(self.native, end_on_exit=False, record_exception=False, set_status_on_exception=False) if self.native.is_recording() else nullcontext()
|
|
243
|
+
|
|
244
|
+
def detach(self) -> None:
|
|
245
|
+
"""Leave the current context while the span stays open for a deferred result."""
|
|
246
|
+
self.scope.__exit__(None, None, None)
|
|
247
|
+
self.scope = nullcontext()
|
|
248
|
+
|
|
237
249
|
def set_output(self, output: Any, **attributes: Any) -> None:
|
|
238
250
|
self.output, self.output_set = output, True
|
|
239
251
|
self.end_attributes.update(attributes)
|
|
@@ -260,8 +272,126 @@ class _Span:
|
|
|
260
272
|
return False
|
|
261
273
|
|
|
262
274
|
|
|
275
|
+
def _span_kind(kind: str) -> str:
|
|
276
|
+
normalized = {"llm": "model", "other": "operation"}.get(kind, kind)
|
|
277
|
+
return normalized if normalized in {"run", "agent", "model", "tool", "policy", "operation"} else "operation"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _complete(span: _Span, result: Any, after: Callable[[], None] | None = None) -> Any:
|
|
281
|
+
"""Close the span with a plain result now, or once an awaited result settles."""
|
|
282
|
+
if inspect.isawaitable(result):
|
|
283
|
+
span.detach()
|
|
284
|
+
return _settle(span, result, after)
|
|
285
|
+
if after:
|
|
286
|
+
after()
|
|
287
|
+
span.set_output(result)
|
|
288
|
+
span.__exit__(None, None, None)
|
|
289
|
+
return result
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def _settle(span: _Span, awaitable: Awaitable[Any], after: Callable[[], None] | None = None) -> Any:
|
|
293
|
+
with span._activation():
|
|
294
|
+
try:
|
|
295
|
+
result = await awaitable
|
|
296
|
+
except BaseException:
|
|
297
|
+
if after:
|
|
298
|
+
await asyncio.to_thread(after)
|
|
299
|
+
span.__exit__(*sys.exc_info())
|
|
300
|
+
raise
|
|
301
|
+
if after:
|
|
302
|
+
await asyncio.to_thread(after)
|
|
303
|
+
span.set_output(result)
|
|
304
|
+
span.__exit__(None, None, None)
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class _TracedStream:
|
|
309
|
+
"""Passes a stream through untouched and ends its span once the stream is exhausted, closed, or abandoned.
|
|
310
|
+
|
|
311
|
+
`finish(chunks, complete)` runs first when given and returns extra end attributes.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(self, span: _Span, stream: Any, finish: Callable[[list[Any], bool], dict[str, Any]] | None = None) -> None:
|
|
315
|
+
self._span, self._stream, self._finish, self._chunks, self._ended = span, stream, finish, [], False
|
|
316
|
+
|
|
317
|
+
def __getattr__(self, name: str) -> Any:
|
|
318
|
+
if name.startswith("_"):
|
|
319
|
+
raise AttributeError(name)
|
|
320
|
+
return getattr(self._stream, name)
|
|
321
|
+
|
|
322
|
+
def __iter__(self) -> "_TracedStream":
|
|
323
|
+
return self
|
|
324
|
+
|
|
325
|
+
def __aiter__(self) -> "_TracedStream":
|
|
326
|
+
return self
|
|
327
|
+
|
|
328
|
+
def __next__(self) -> Any:
|
|
329
|
+
try:
|
|
330
|
+
return self._collect(next(self._stream))
|
|
331
|
+
except BaseException as error:
|
|
332
|
+
self._end(isinstance(error, StopIteration), error)
|
|
333
|
+
raise
|
|
334
|
+
|
|
335
|
+
async def __anext__(self) -> Any:
|
|
336
|
+
try:
|
|
337
|
+
return self._collect(await self._stream.__anext__())
|
|
338
|
+
except BaseException as error:
|
|
339
|
+
self._end(isinstance(error, StopAsyncIteration), error)
|
|
340
|
+
raise
|
|
341
|
+
|
|
342
|
+
def __enter__(self) -> "_TracedStream":
|
|
343
|
+
if hasattr(self._stream, "__enter__"):
|
|
344
|
+
self._stream.__enter__()
|
|
345
|
+
return self
|
|
346
|
+
|
|
347
|
+
def __exit__(self, *exc: Any) -> Any:
|
|
348
|
+
self._end(False)
|
|
349
|
+
return self._stream.__exit__(*exc) if hasattr(self._stream, "__exit__") else False
|
|
350
|
+
|
|
351
|
+
async def __aenter__(self) -> "_TracedStream":
|
|
352
|
+
if hasattr(self._stream, "__aenter__"):
|
|
353
|
+
await self._stream.__aenter__()
|
|
354
|
+
return self
|
|
355
|
+
|
|
356
|
+
async def __aexit__(self, *exc: Any) -> Any:
|
|
357
|
+
self._end(False)
|
|
358
|
+
return await self._stream.__aexit__(*exc) if hasattr(self._stream, "__aexit__") else False
|
|
359
|
+
|
|
360
|
+
def close(self) -> None:
|
|
361
|
+
self._end(False)
|
|
362
|
+
if hasattr(self._stream, "close"):
|
|
363
|
+
self._stream.close()
|
|
364
|
+
|
|
365
|
+
async def aclose(self) -> None:
|
|
366
|
+
self._end(False)
|
|
367
|
+
if hasattr(self._stream, "aclose"):
|
|
368
|
+
await self._stream.aclose()
|
|
369
|
+
|
|
370
|
+
def __del__(self) -> None:
|
|
371
|
+
try:
|
|
372
|
+
self._end(False)
|
|
373
|
+
except Exception:
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
def _collect(self, chunk: Any) -> Any:
|
|
377
|
+
if self._finish or len(self._chunks) < 51:
|
|
378
|
+
self._chunks.append(chunk)
|
|
379
|
+
return chunk
|
|
380
|
+
|
|
381
|
+
def _end(self, complete: bool, error: BaseException | None = None) -> None:
|
|
382
|
+
if self._ended:
|
|
383
|
+
return
|
|
384
|
+
self._ended = True
|
|
385
|
+
error = None if complete else error
|
|
386
|
+
attributes = {"streaming": True, **({} if complete else {"captureCompleteness": "stream_not_consumed"})}
|
|
387
|
+
if self._finish:
|
|
388
|
+
attributes.update(self._finish(self._chunks, complete))
|
|
389
|
+
self._span.set_output(self._chunks, **attributes)
|
|
390
|
+
self._span.__exit__(type(error) if error else None, error, getattr(error, "__traceback__", None))
|
|
391
|
+
|
|
392
|
+
|
|
263
393
|
class Rippletide:
|
|
264
|
-
"""
|
|
394
|
+
"""Policy client with native OpenTelemetry instrumentation for synchronous and asyncio agents."""
|
|
265
395
|
|
|
266
396
|
def __init__(self, *, agent_id: str | None = None, api_key: str | None = None, base_url: str | None = None, name: str | None = None, description: str | None = None, capture_content: bool = False, runtime_traces: bool = True, disabled: bool | None = None, timeout_ms: int | None = None, warn: Callable[[str], None] | None = None) -> None:
|
|
267
397
|
from_env = _connection_env()
|
|
@@ -327,20 +457,56 @@ class Rippletide:
|
|
|
327
457
|
|
|
328
458
|
@contextmanager
|
|
329
459
|
def span(self, kind: str, *, name: str, input: Any = _UNSET, tool_call_id: str | None = None, invocation_id: str | None = None, decision_id: str | None = None, attributes: Mapping[str, Any] | None = None, links: list[trace.Link] | None = None) -> Iterator[_Span]:
|
|
330
|
-
|
|
331
|
-
if normalized not in {"run", "agent", "model", "tool", "policy", "operation"}:
|
|
332
|
-
normalized = "operation"
|
|
333
|
-
with _Span(self, normalized, name, input=input, toolCallId=tool_call_id, invocationId=invocation_id, decisionId=decision_id, attributes=attributes, links=links) as current:
|
|
460
|
+
with _Span(self, _span_kind(kind), name, input=input, toolCallId=tool_call_id, invocationId=invocation_id, decisionId=decision_id, attributes=attributes, links=links) as current:
|
|
334
461
|
yield current
|
|
335
462
|
|
|
336
463
|
def traced(self, fn: Callable[..., T], *, name: str | None = None, kind: str = "tool") -> Callable[..., T]:
|
|
464
|
+
"""Record each call as a span; an awaited result or a returned (async) iterator keeps it open until settled or exhausted."""
|
|
465
|
+
label = name or getattr(fn, "__name__", None) or "anonymous"
|
|
466
|
+
|
|
337
467
|
def wrapped(*args: Any, **kwargs: Any) -> T:
|
|
338
|
-
|
|
468
|
+
span = _Span(self, _span_kind(kind), label, input={"args": args, "kwargs": kwargs}).__enter__()
|
|
469
|
+
try:
|
|
339
470
|
result = fn(*args, **kwargs)
|
|
340
|
-
|
|
341
|
-
|
|
471
|
+
except BaseException:
|
|
472
|
+
span.__exit__(*sys.exc_info())
|
|
473
|
+
raise
|
|
474
|
+
if hasattr(result, "__next__") or hasattr(result, "__anext__"):
|
|
475
|
+
span.detach()
|
|
476
|
+
return _TracedStream(span, result) # type: ignore[return-value]
|
|
477
|
+
return _complete(span, result)
|
|
342
478
|
return wrapped
|
|
343
479
|
|
|
480
|
+
def _guarded(self, decide: Callable[[], tuple[str, dict[str, Any], Any, Callable[[], None] | None, Callable[[], None] | None]], callback: Callable[[Any], Any]) -> Any:
|
|
481
|
+
"""Run the blocking policy round trip in decide(), then the callback inside the action span.
|
|
482
|
+
|
|
483
|
+
A coroutine-function callback yields a coroutine that moves the HTTP work to a
|
|
484
|
+
worker thread, so an asyncio agent is never blocked by decisions or receipts.
|
|
485
|
+
"""
|
|
486
|
+
if not inspect.iscoroutinefunction(callback):
|
|
487
|
+
kind, options, argument, before, after = decide()
|
|
488
|
+
span = _Span(self, kind, **options).__enter__()
|
|
489
|
+
if before:
|
|
490
|
+
before()
|
|
491
|
+
try:
|
|
492
|
+
result = callback(argument)
|
|
493
|
+
except BaseException:
|
|
494
|
+
if after:
|
|
495
|
+
after()
|
|
496
|
+
span.__exit__(*sys.exc_info())
|
|
497
|
+
raise
|
|
498
|
+
return _complete(span, result, after)
|
|
499
|
+
|
|
500
|
+
async def guarded() -> Any:
|
|
501
|
+
kind, options, argument, before, after = await asyncio.to_thread(decide)
|
|
502
|
+
span = _Span(self, kind, **options).__enter__()
|
|
503
|
+
span.detach()
|
|
504
|
+
if before:
|
|
505
|
+
await asyncio.to_thread(before)
|
|
506
|
+
return await _settle(span, callback(argument), after)
|
|
507
|
+
|
|
508
|
+
return guarded()
|
|
509
|
+
|
|
344
510
|
def trace_headers(self) -> dict[str, str]:
|
|
345
511
|
headers: dict[str, str] = {}
|
|
346
512
|
TraceContextTextMapPropagator().inject(headers)
|
|
@@ -500,23 +666,25 @@ class Rippletide:
|
|
|
500
666
|
action, invocation_id = f"{quote(toolset, safe='')}/{quote(tool, safe='')}", str(uuid.uuid4())
|
|
501
667
|
if self.disabled:
|
|
502
668
|
return execute(params)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
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)
|
|
520
688
|
|
|
521
689
|
def guard_tool_result(self, *, toolset: str, tool: str, params: Mapping[str, Any], result: Any, result_items: list[Mapping[str, Any]], apply: Callable[[Mapping[str, Any]], T], context: Mapping[str, Any] | None = None, tool_call_id: str | None = None, allow_result_export: bool = False) -> T:
|
|
522
690
|
"""Filter an already-completed tool result before its next local sink.
|
|
@@ -535,75 +703,105 @@ class Rippletide:
|
|
|
535
703
|
evaluated_result = _clone_policy_value(result, "result")
|
|
536
704
|
evaluated_items = [_clone_policy_value(item, f"result_items[{index}]") for index, item in enumerate(result_items)]
|
|
537
705
|
evaluated_context = _clone_policy_value(dict(context or {}), "trusted context")
|
|
538
|
-
decision: Mapping[str, Any] | None = None
|
|
539
|
-
elapsed = 0
|
|
540
|
-
policy_span = None
|
|
541
706
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
applied = apply(filtered)
|
|
573
|
-
result_span.set_output(applied)
|
|
574
|
-
return applied
|
|
575
|
-
finally:
|
|
576
|
-
if decision:
|
|
577
|
-
self._receipt_body(decision["decisionId"], {
|
|
578
|
-
"outcome": outcome,
|
|
579
|
-
"occurredAt": _now(),
|
|
580
|
-
"resultPlanFingerprint": decision["resultPlanFingerprint"],
|
|
581
|
-
"deliveredItemIndexes": [index for index, item in enumerate(decision["items"]) if item["disposition"] != "BLOCK"],
|
|
582
|
-
"excludedItemIndexes": [index for index, item in enumerate(decision["items"]) if item["disposition"] == "BLOCK"],
|
|
583
|
-
"decisionRoundtripMs": elapsed,
|
|
707
|
+
def decide() -> tuple[str, dict[str, Any], Any, Callable[[], None] | None, Callable[[], None] | None]:
|
|
708
|
+
decision: Mapping[str, Any] | None = None
|
|
709
|
+
elapsed = 0
|
|
710
|
+
policy_span = None
|
|
711
|
+
|
|
712
|
+
if allow_result_export and not self.disabled:
|
|
713
|
+
invocation_id = str(uuid.uuid4())
|
|
714
|
+
body = {"invocationId": invocation_id, "action": action, "params": evaluated_params, "result": evaluated_result, "context": evaluated_context}
|
|
715
|
+
started = time.monotonic()
|
|
716
|
+
with self.span("policy", name=f"after_action:{action}", input=body, tool_call_id=tool_call_id, invocation_id=invocation_id, attributes={"trigger": "after_action", "action": action, "rawResultExport": True}) as policy_span:
|
|
717
|
+
decision = self._decide_result(body, action, evaluated_result, evaluated_items)
|
|
718
|
+
policy_span.options["decisionId"] = decision.get("decisionId") if decision else None
|
|
719
|
+
elapsed = int((time.monotonic() - started) * 1000)
|
|
720
|
+
policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
|
|
721
|
+
|
|
722
|
+
delivered_items = list(evaluated_items)
|
|
723
|
+
excluded_items: list[dict[str, Any]] = []
|
|
724
|
+
if decision:
|
|
725
|
+
delivered_items = []
|
|
726
|
+
for item, item_decision in zip(evaluated_items, decision["items"]):
|
|
727
|
+
if item_decision["disposition"] != "BLOCK":
|
|
728
|
+
delivered_items.append(item)
|
|
729
|
+
continue
|
|
730
|
+
detail = item_decision.get("decision") if isinstance(item_decision.get("decision"), Mapping) else {}
|
|
731
|
+
excluded_items.append({
|
|
732
|
+
"index": item_decision["index"],
|
|
733
|
+
"itemId": item_decision["itemId"],
|
|
734
|
+
"item": item,
|
|
735
|
+
"reason": detail.get("reason") if isinstance(detail.get("reason"), str) else "Blocked by policy",
|
|
736
|
+
"blockingRuleIds": list(detail.get("blockingRuleIds", [])) if isinstance(detail.get("blockingRuleIds"), list) else [],
|
|
584
737
|
})
|
|
738
|
+
outcome = "RESULT_FILTERED" if excluded_items else "RESULT_DELIVERED"
|
|
739
|
+
filtered = {"result": evaluated_result, "deliveredItems": delivered_items, "excludedItems": excluded_items, "policyDecision": _clone_policy_value(decision, "policy decision") if decision else None}
|
|
740
|
+
options = {"name": "result_application", "input": filtered, "links": [trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})] if policy_span else [], "toolCallId": tool_call_id, "invocationId": decision.get("invocationId") if decision else None, "decisionId": decision.get("decisionId") if decision else None, "attributes": {"action": action, "applicationOutcome": outcome, "deliveredItemCount": len(delivered_items), "excludedItemCount": len(excluded_items), "executed": True}}
|
|
741
|
+
after = (lambda: self._receipt_body(decision["decisionId"], {
|
|
742
|
+
"outcome": outcome,
|
|
743
|
+
"occurredAt": _now(),
|
|
744
|
+
"resultPlanFingerprint": decision["resultPlanFingerprint"],
|
|
745
|
+
"deliveredItemIndexes": [index for index, item in enumerate(decision["items"]) if item["disposition"] != "BLOCK"],
|
|
746
|
+
"excludedItemIndexes": [index for index, item in enumerate(decision["items"]) if item["disposition"] == "BLOCK"],
|
|
747
|
+
"decisionRoundtripMs": elapsed,
|
|
748
|
+
})) if decision else None
|
|
749
|
+
return "operation", options, filtered, None, after
|
|
750
|
+
|
|
751
|
+
return self._guarded(decide, apply)
|
|
585
752
|
|
|
586
753
|
def deliver_response(self, *, request: Any, response: Any, deliver: Callable[[Any], T], context: Mapping[str, Any] | None = None) -> T:
|
|
754
|
+
"""Decide on the complete candidate, then deliver it.
|
|
755
|
+
|
|
756
|
+
A sync or async iterator as `response` is streamed delivery: chunks pass
|
|
757
|
+
through to `deliver` untouched, and the decision runs on the assembled
|
|
758
|
+
text once the stream ends. It is recorded, never enforced: the outcome is
|
|
759
|
+
always DELIVERED and a BLOCK is marked as decided after delivery.
|
|
760
|
+
"""
|
|
587
761
|
invocation_id, action = str(uuid.uuid4()), "rippletide/response"
|
|
588
762
|
if self.disabled:
|
|
589
763
|
return deliver(response)
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
764
|
+
if hasattr(response, "__next__") or hasattr(response, "__anext__"):
|
|
765
|
+
span = _Span(self, "operation", "response_delivery", invocationId=invocation_id, attributes={"action": action, "executed": True, "applicationOutcome": "DELIVERED", "decidedAfterDelivery": True}).__enter__()
|
|
766
|
+
span.detach()
|
|
767
|
+
|
|
768
|
+
def finish(chunks: list[Any], _complete: bool) -> dict[str, Any]:
|
|
769
|
+
text = "".join(chunk for chunk in chunks if isinstance(chunk, str))
|
|
770
|
+
started = time.monotonic()
|
|
771
|
+
body = {"invocationId": invocation_id, "request": request, "response": text if text or not chunks else [capture_value(chunk)[0] for chunk in chunks], "context": dict(context or {})}
|
|
772
|
+
links = [trace.Link(span.native.get_span_context(), {"rippletide.link.type": "follows_from"})]
|
|
773
|
+
with self.span("policy", name="before_response:rippletide/response", input=body, invocation_id=invocation_id, links=links, attributes={"trigger": "before_response", "action": action, "decidedAfterDelivery": True}) as policy_span:
|
|
774
|
+
try:
|
|
775
|
+
decision = self._decide("/decide-response", body, action)
|
|
776
|
+
except RippletidePolicyDecisionError as error:
|
|
777
|
+
self._warn(f"{error}; streamed response already delivered")
|
|
778
|
+
decision = None
|
|
779
|
+
policy_span.options["decisionId"] = span.options["decisionId"] = decision.get("decisionId") if decision else None
|
|
780
|
+
elapsed = int((time.monotonic() - started) * 1000)
|
|
781
|
+
policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
|
|
782
|
+
self._receipt(decision, "DELIVERED", elapsed)
|
|
783
|
+
return {"disposition": decision.get("disposition") if decision else "FALLBACK"}
|
|
784
|
+
|
|
785
|
+
return deliver(_TracedStream(span, response, finish)) # type: ignore[arg-type]
|
|
786
|
+
|
|
787
|
+
def decide() -> tuple[str, dict[str, Any], Any, Callable[[], None] | None, Callable[[], None] | None]:
|
|
788
|
+
started = time.monotonic()
|
|
789
|
+
body = {"invocationId": invocation_id, "request": request, "response": response, "context": dict(context or {})}
|
|
790
|
+
with self.span("policy", name="before_response:rippletide/response", input=body, invocation_id=invocation_id, attributes={"trigger": "before_response", "action": action}) as policy_span:
|
|
791
|
+
decision = self._decide("/decide-response", body, action)
|
|
792
|
+
decision_id = decision.get("decisionId") if decision else None
|
|
793
|
+
policy_span.options["decisionId"] = decision_id
|
|
794
|
+
elapsed = int((time.monotonic() - started) * 1000)
|
|
795
|
+
policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
|
|
796
|
+
options: dict[str, Any] = {"name": "response_delivery", "input": response, "links": [trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})], "invocationId": invocation_id, "decisionId": decision_id}
|
|
797
|
+
if decision and decision.get("disposition") == "BLOCK":
|
|
798
|
+
self._receipt(decision, "SUPPRESSED", elapsed)
|
|
799
|
+
with _Span(self, "operation", **options, attributes={"action": action, "executed": False, "applicationOutcome": "SUPPRESSED"}):
|
|
800
|
+
raise RippletidePolicyBlockedError(action, decision)
|
|
801
|
+
options["attributes"] = {"action": action, "executed": True, "applicationOutcome": "DELIVERED", "disposition": decision.get("disposition") if decision else "FALLBACK"}
|
|
802
|
+
return "operation", options, response, lambda: self._receipt(decision, "DELIVERED", elapsed), None
|
|
803
|
+
|
|
804
|
+
return self._guarded(decide, deliver)
|
|
607
805
|
|
|
608
806
|
def flush(self) -> bool:
|
|
609
807
|
if self.disabled or not self.agent_id or not self.runtime_traces or self._telemetry is None:
|
|
@@ -611,7 +809,13 @@ class Rippletide:
|
|
|
611
809
|
self._ensure_registered()
|
|
612
810
|
return self._telemetry.flush()
|
|
613
811
|
|
|
812
|
+
async def aflush(self) -> bool:
|
|
813
|
+
return await asyncio.to_thread(self.flush)
|
|
814
|
+
|
|
614
815
|
def shutdown(self) -> None:
|
|
615
816
|
self.flush()
|
|
616
817
|
if self._telemetry:
|
|
617
818
|
self._telemetry.shutdown()
|
|
819
|
+
|
|
820
|
+
async def ashutdown(self) -> None:
|
|
821
|
+
await asyncio.to_thread(self.shutdown)
|
|
@@ -36,7 +36,8 @@ INDEXED = {
|
|
|
36
36
|
"streaming": "rippletide.model.streaming", "toolCallIds": "rippletide.model.tool_call_ids", "toolCallCount": "rippletide.model.tool_call_count",
|
|
37
37
|
"captureCompleteness": "rippletide.capture.completeness", "trigger": "rippletide.policy.trigger", "action": "rippletide.policy.action",
|
|
38
38
|
"disposition": "rippletide.policy.disposition", "decisionRoundtripMs": "rippletide.policy.roundtrip_ms", "failOpen": "rippletide.policy.fail_open",
|
|
39
|
-
"policyFallback": "rippletide.policy.fallback", "
|
|
39
|
+
"policyFallback": "rippletide.policy.fallback", "decidedAfterDelivery": "rippletide.policy.decided_after_delivery",
|
|
40
|
+
"executed": "rippletide.action.executed", "applicationOutcome": "rippletide.action.application_outcome",
|
|
40
41
|
"deliveredItemCount": "rippletide.result.delivered_item_count", "excludedItemCount": "rippletide.result.excluded_item_count",
|
|
41
42
|
"signal": "rippletide.signal", "source": "rippletide.source", "standalone": "rippletide.standalone",
|
|
42
43
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rippletide-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Monitor-first Python SDK for Rippletide-connected agents
|
|
5
5
|
License: UNLICENSED
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -16,7 +16,7 @@ hand-written provider or internal HTTP call at the existing model seam.
|
|
|
16
16
|
|
|
17
17
|
## Install and connect
|
|
18
18
|
|
|
19
|
-
The
|
|
19
|
+
The current release is `rippletide-python==0.3.0`. It requires
|
|
20
20
|
the platform's OTLP ingestion/readers and does not provide V1 trace compatibility.
|
|
21
21
|
Until it is published, validate it by installing a locally built wheel; do not
|
|
22
22
|
assume the unversioned registry install below contains candidate changes.
|
|
@@ -41,8 +41,7 @@ prompt with your coding agent in that repository. The CLI also supports
|
|
|
41
41
|
Run a real turn that uses a tool and delivers a response. Confirm **Runtime
|
|
42
42
|
verified (5/5)** in Fleet and the run's tool calls and **Delivered** outcome in
|
|
43
43
|
Activity. Copying a prompt or storing credentials alone does not prove a
|
|
44
|
-
connection.
|
|
45
|
-
streaming are not supported.
|
|
44
|
+
connection.
|
|
46
45
|
|
|
47
46
|
## Instrument existing call sites
|
|
48
47
|
|
|
@@ -85,6 +84,34 @@ connection takes precedence over those legacy values; explicit constructor
|
|
|
85
84
|
arguments take precedence over either format. Never use a Platform key in the
|
|
86
85
|
agent or commit the `.env`.
|
|
87
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
|
+
|
|
88
115
|
## Policy failures and capture
|
|
89
116
|
|
|
90
117
|
The client starts disabled when no Connection key is present. For
|
|
@@ -154,6 +181,6 @@ native response shape, and the SDK records the applied outcome as a receipt.
|
|
|
154
181
|
Result fingerprints use the server's ECMAScript JSON encoding, including its
|
|
155
182
|
number formatting and UTF-16 object-key ordering.
|
|
156
183
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import json
|
|
4
5
|
import sys
|
|
5
6
|
import unittest
|
|
@@ -74,6 +75,7 @@ class PlatformHandler(BaseHTTPRequestHandler):
|
|
|
74
75
|
requests: list[tuple[str, dict]] = []
|
|
75
76
|
headers_by_path: dict[str, dict[str, str]] = {}
|
|
76
77
|
disposition = "ALLOW"
|
|
78
|
+
response_disposition: str | None = None
|
|
77
79
|
result_dispositions: list[str] = ["ALLOW"]
|
|
78
80
|
result_status = 200
|
|
79
81
|
|
|
@@ -96,7 +98,7 @@ class PlatformHandler(BaseHTTPRequestHandler):
|
|
|
96
98
|
payload = {
|
|
97
99
|
"decisionId": "00000000-0000-4000-8000-000000000099",
|
|
98
100
|
"invocationId": body["invocationId"],
|
|
99
|
-
"disposition": type(self).disposition,
|
|
101
|
+
"disposition": (type(self).response_disposition if self.path.endswith("-response") else None) or type(self).disposition,
|
|
100
102
|
"decision": {"reason": "Test policy"},
|
|
101
103
|
}
|
|
102
104
|
else:
|
|
@@ -148,6 +150,7 @@ class ClientTests(unittest.TestCase):
|
|
|
148
150
|
PlatformHandler.requests = []
|
|
149
151
|
PlatformHandler.headers_by_path = {}
|
|
150
152
|
PlatformHandler.disposition = "ALLOW"
|
|
153
|
+
PlatformHandler.response_disposition = None
|
|
151
154
|
PlatformHandler.result_dispositions = ["ALLOW"]
|
|
152
155
|
PlatformHandler.result_status = 200
|
|
153
156
|
self.client = Rippletide(agent_id=AGENT_ID, api_key="test-connection-key", base_url=self.base_url, name="Raw HTTP agent")
|
|
@@ -429,6 +432,136 @@ class ClientTests(unittest.TestCase):
|
|
|
429
432
|
)
|
|
430
433
|
self.assertEqual(applications, [])
|
|
431
434
|
|
|
435
|
+
def test_async_callbacks_keep_action_spans_open_until_they_settle(self):
|
|
436
|
+
async def turn():
|
|
437
|
+
async def lookup(params):
|
|
438
|
+
await asyncio.sleep(0)
|
|
439
|
+
inner = self.client.traced(lambda: {"plan": "pro"}, name="db.query")
|
|
440
|
+
return {"account": params["id"], **inner()}
|
|
441
|
+
|
|
442
|
+
async def send(value):
|
|
443
|
+
await asyncio.sleep(0)
|
|
444
|
+
return f"sent:{value}"
|
|
445
|
+
|
|
446
|
+
with self.client.run(name="inbound_request"):
|
|
447
|
+
result = await self.client.guard_tool_call(toolset="core", tool="lookup", params={"id": "a-1"}, execute=lookup)
|
|
448
|
+
delivered = await self.client.deliver_response(request={}, response="answer", deliver=send)
|
|
449
|
+
return result, delivered, await self.client.aflush()
|
|
450
|
+
|
|
451
|
+
self.assertEqual(asyncio.run(turn()), ({"account": "a-1", "plan": "pro"}, "sent:answer", True))
|
|
452
|
+
receipts = [body["outcome"] for path, body in PlatformHandler.requests if path.endswith("/receipt")]
|
|
453
|
+
self.assertEqual(receipts, ["EXECUTED", "DELIVERED"])
|
|
454
|
+
events = {(event["name"], event["eventType"]): event for event in runtime_facts(PlatformHandler.requests)}
|
|
455
|
+
self.assertEqual(events[("lookup", "span_start")]["parentSpanId"], events[("inbound_request", "span_start")]["spanId"])
|
|
456
|
+
self.assertEqual(events[("db.query", "span_start")]["parentSpanId"], events[("lookup", "span_start")]["spanId"])
|
|
457
|
+
self.assertEqual(events[("lookup", "span_end")]["decisionId"], events[("before_action:core/lookup", "span_end")]["decisionId"])
|
|
458
|
+
self.assertEqual(events[("response_delivery", "span_end")]["attributes"]["applicationOutcome"], "DELIVERED")
|
|
459
|
+
|
|
460
|
+
PlatformHandler.disposition = "BLOCK"
|
|
461
|
+
|
|
462
|
+
async def blocked():
|
|
463
|
+
with self.client.run():
|
|
464
|
+
await self.client.guard_tool_call(toolset="core", tool="delete", params={}, execute=lookup)
|
|
465
|
+
|
|
466
|
+
async def lookup(_params):
|
|
467
|
+
self.fail("blocked tool executed")
|
|
468
|
+
|
|
469
|
+
with self.assertRaises(RippletidePolicyBlockedError):
|
|
470
|
+
asyncio.run(blocked())
|
|
471
|
+
|
|
472
|
+
def test_traced_streams_end_the_model_span_when_consumed_closed_or_awaited(self):
|
|
473
|
+
self.client.capture_content = True
|
|
474
|
+
|
|
475
|
+
class ProviderStream:
|
|
476
|
+
response = "raw-http-response"
|
|
477
|
+
|
|
478
|
+
def __init__(self, chunks):
|
|
479
|
+
self.chunks, self.closed = iter(chunks), False
|
|
480
|
+
|
|
481
|
+
def __iter__(self):
|
|
482
|
+
return self
|
|
483
|
+
|
|
484
|
+
def __next__(self):
|
|
485
|
+
return next(self.chunks)
|
|
486
|
+
|
|
487
|
+
def close(self):
|
|
488
|
+
self.closed = True
|
|
489
|
+
|
|
490
|
+
async def provider_stream():
|
|
491
|
+
for chunk in ("a", "b"):
|
|
492
|
+
yield chunk
|
|
493
|
+
|
|
494
|
+
async def async_model():
|
|
495
|
+
await asyncio.sleep(0)
|
|
496
|
+
return {"content": "awaited"}
|
|
497
|
+
|
|
498
|
+
stream = self.client.traced(lambda: ProviderStream(["x", "y", "z"]), name="model.stream", kind="model")
|
|
499
|
+
with self.client.run(name="consumed"):
|
|
500
|
+
consumed = stream()
|
|
501
|
+
self.assertEqual(consumed.response, "raw-http-response")
|
|
502
|
+
self.assertEqual(list(consumed), ["x", "y", "z"])
|
|
503
|
+
with self.client.run(name="abandoned"):
|
|
504
|
+
abandoned = stream()
|
|
505
|
+
self.assertEqual(next(abandoned), "x")
|
|
506
|
+
abandoned.close()
|
|
507
|
+
self.assertTrue(abandoned.closed)
|
|
508
|
+
|
|
509
|
+
async def turn():
|
|
510
|
+
with self.client.run(name="async"):
|
|
511
|
+
chunks = [chunk async for chunk in self.client.traced(provider_stream, kind="model")()]
|
|
512
|
+
return chunks, await self.client.traced(async_model, kind="model")()
|
|
513
|
+
|
|
514
|
+
self.assertEqual(asyncio.run(turn()), (["a", "b"], {"content": "awaited"}))
|
|
515
|
+
self.client.flush()
|
|
516
|
+
ends = {}
|
|
517
|
+
for event in runtime_facts(PlatformHandler.requests):
|
|
518
|
+
if event["eventType"] == "span_end" and event["kind"] == "model":
|
|
519
|
+
ends[event["name"], event["attributes"].get("captureCompleteness")] = event
|
|
520
|
+
self.assertEqual(ends["model.stream", "complete"]["output"], ["x", "y", "z"])
|
|
521
|
+
self.assertEqual(ends["model.stream", "stream_not_consumed"]["output"], ["x"])
|
|
522
|
+
self.assertEqual(ends["provider_stream", "complete"]["output"], ["a", "b"])
|
|
523
|
+
self.assertEqual(ends["async_model", "complete"]["output"], {"content": "awaited"})
|
|
524
|
+
self.assertTrue(all(event["attributes"]["streaming"] for (name, _), event in ends.items() if name != "async_model"))
|
|
525
|
+
runs = {event["name"]: event["spanId"] for event in runtime_facts(PlatformHandler.requests) if event["kind"] == "run" and event["eventType"] == "span_start"}
|
|
526
|
+
self.assertEqual(ends["model.stream", "stream_not_consumed"]["parentSpanId"], runs["abandoned"])
|
|
527
|
+
self.assertEqual(ends["async_model", "complete"]["parentSpanId"], runs["async"])
|
|
528
|
+
|
|
529
|
+
def test_streamed_delivery_records_the_decision_after_the_answer_reached_the_caller(self):
|
|
530
|
+
async def model_stream():
|
|
531
|
+
for token in ("Hel", "lo"):
|
|
532
|
+
yield token
|
|
533
|
+
|
|
534
|
+
async def lookup(_params):
|
|
535
|
+
return {"plan": "pro"}
|
|
536
|
+
|
|
537
|
+
async def send(stream):
|
|
538
|
+
return "".join([chunk async for chunk in stream])
|
|
539
|
+
|
|
540
|
+
async def turn():
|
|
541
|
+
with self.client.run(name="streamed_turn"):
|
|
542
|
+
tokens = [token async for token in self.client.traced(model_stream, kind="model")()]
|
|
543
|
+
await self.client.guard_tool_call(toolset="core", tool="lookup", params={}, execute=lookup)
|
|
544
|
+
|
|
545
|
+
async def answer():
|
|
546
|
+
for token in tokens:
|
|
547
|
+
yield token
|
|
548
|
+
return await self.client.deliver_response(request={"q": "hi"}, response=answer(), deliver=send)
|
|
549
|
+
|
|
550
|
+
for disposition in ("ALLOW", "BLOCK"):
|
|
551
|
+
with self.subTest(disposition=disposition):
|
|
552
|
+
self.setUp()
|
|
553
|
+
PlatformHandler.response_disposition = disposition
|
|
554
|
+
self.assertEqual(asyncio.run(turn()), "Hello")
|
|
555
|
+
self.client.flush()
|
|
556
|
+
decide = next(body for path, body in PlatformHandler.requests if path == "/v1/policy/decide-response")
|
|
557
|
+
self.assertEqual(decide["response"], "Hello")
|
|
558
|
+
self.assertEqual([body["outcome"] for path, body in PlatformHandler.requests if path.endswith("/receipt")], ["EXECUTED", "DELIVERED"])
|
|
559
|
+
ends = {event["name"]: event for event in runtime_facts(PlatformHandler.requests) if event["eventType"] == "span_end"}
|
|
560
|
+
self.assertEqual({ends[name]["traceId"] for name in ("model_stream", "lookup", "before_response:rippletide/response", "response_delivery")}, {ends["streamed_turn"]["traceId"]})
|
|
561
|
+
delivery = ends["response_delivery"]
|
|
562
|
+
self.assertEqual(delivery["decisionId"], ends["before_response:rippletide/response"]["decisionId"])
|
|
563
|
+
self.assertEqual({key: delivery["attributes"][key] for key in ("streaming", "applicationOutcome", "disposition", "decidedAfterDelivery", "captureCompleteness")}, {"streaming": True, "applicationOutcome": "DELIVERED", "disposition": disposition, "decidedAfterDelivery": True, "captureCompleteness": "not_captured"})
|
|
564
|
+
|
|
432
565
|
def test_open_source_manual_agent_uses_raw_anthropic_http_and_exports_the_complete_turn(self):
|
|
433
566
|
example = Path(__file__).parents[3] / "examples/python-anthropic-manual-agent/agent.py"
|
|
434
567
|
spec = spec_from_file_location("manual_agent", example)
|
|
File without changes
|
|
File without changes
|
{rippletide_python-0.2.0 → rippletide_python-0.3.0}/rippletide_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
{rippletide_python-0.2.0 → rippletide_python-0.3.0}/rippletide_python.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|