raindrop-strands 0.0.1__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.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.1
2
+ Name: raindrop-strands
3
+ Version: 0.0.1
4
+ Summary: Raindrop integration for Strands Agents
5
+ License: MIT
6
+ Author: Raindrop AI
7
+ Author-email: sdk@raindrop.ai
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: raindrop-ai (>=0.0.42)
15
+ Requires-Dist: strands-agents (>=1.0.0)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # raindrop-strands
19
+
20
+ Raindrop integration for [Strands Agents](https://strandsagents.com) (Python). Automatically captures agent invocations, model calls, tool usage, and token metrics via the Strands hook system.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install raindrop-strands strands-agents
26
+ ```
27
+
28
+ `strands-agents` is a required dependency.
29
+
30
+ ## Usage
31
+
32
+ ```python
33
+ import os
34
+ from strands import Agent
35
+ from raindrop_strands import create_raindrop_strands
36
+
37
+ raindrop = create_raindrop_strands(
38
+ api_key=os.environ.get("RAINDROP_API_KEY"),
39
+ user_id="user_123",
40
+ convo_id="session_456",
41
+ )
42
+
43
+ agent = Agent(
44
+ model="us.amazon.nova-lite-v1:0",
45
+ system_prompt="You are a helpful assistant.",
46
+ )
47
+
48
+ raindrop["handler"].register_hooks(agent)
49
+
50
+ result = agent("What is the capital of France?")
51
+ print(result)
52
+
53
+ raindrop["flush"]()
54
+ ```
55
+
56
+ Omitting `api_key` disables telemetry shipping (a warning is emitted) but does not crash your application.
57
+
58
+ ## API
59
+
60
+ ### `create_raindrop_strands(api_key, user_id, convo_id)`
61
+
62
+ Returns a dict with:
63
+
64
+ - `"handler"` — `RaindropStrandsHandler` instance to register on agents
65
+ - `"flush"` — flush pending telemetry
66
+ - `"shutdown"` — flush and release resources
67
+
68
+ ## Testing
69
+
70
+ ```bash
71
+ cd packages/strands-python
72
+ poetry install --with dev
73
+ pytest
74
+ ```
75
+
76
+ ## License
77
+
78
+ MIT
79
+
@@ -0,0 +1,61 @@
1
+ # raindrop-strands
2
+
3
+ Raindrop integration for [Strands Agents](https://strandsagents.com) (Python). Automatically captures agent invocations, model calls, tool usage, and token metrics via the Strands hook system.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install raindrop-strands strands-agents
9
+ ```
10
+
11
+ `strands-agents` is a required dependency.
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ import os
17
+ from strands import Agent
18
+ from raindrop_strands import create_raindrop_strands
19
+
20
+ raindrop = create_raindrop_strands(
21
+ api_key=os.environ.get("RAINDROP_API_KEY"),
22
+ user_id="user_123",
23
+ convo_id="session_456",
24
+ )
25
+
26
+ agent = Agent(
27
+ model="us.amazon.nova-lite-v1:0",
28
+ system_prompt="You are a helpful assistant.",
29
+ )
30
+
31
+ raindrop["handler"].register_hooks(agent)
32
+
33
+ result = agent("What is the capital of France?")
34
+ print(result)
35
+
36
+ raindrop["flush"]()
37
+ ```
38
+
39
+ Omitting `api_key` disables telemetry shipping (a warning is emitted) but does not crash your application.
40
+
41
+ ## API
42
+
43
+ ### `create_raindrop_strands(api_key, user_id, convo_id)`
44
+
45
+ Returns a dict with:
46
+
47
+ - `"handler"` — `RaindropStrandsHandler` instance to register on agents
48
+ - `"flush"` — flush pending telemetry
49
+ - `"shutdown"` — flush and release resources
50
+
51
+ ## Testing
52
+
53
+ ```bash
54
+ cd packages/strands-python
55
+ poetry install --with dev
56
+ pytest
57
+ ```
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,27 @@
1
+ [tool.poetry]
2
+ name = "raindrop-strands"
3
+ version = "0.0.1"
4
+ description = "Raindrop integration for Strands Agents"
5
+ authors = ["Raindrop AI <sdk@raindrop.ai>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ packages = [{ include = "raindrop_strands" }]
9
+
10
+ [tool.poetry.dependencies]
11
+ python = ">=3.10,<4.0"
12
+ raindrop-ai = ">=0.0.42"
13
+ strands-agents = ">=1.0.0"
14
+
15
+ [tool.poetry.group.dev.dependencies]
16
+ pytest = "^8.0"
17
+
18
+ [tool.pytest.ini_options]
19
+ testpaths = ["tests"]
20
+ python_files = ["test_*.py"]
21
+ python_classes = ["Test*"]
22
+ python_functions = ["test_*"]
23
+ addopts = ["-v", "--tb=short"]
24
+
25
+ [build-system]
26
+ requires = ["poetry-core"]
27
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,3 @@
1
+ from .handler import create_raindrop_strands
2
+
3
+ __all__ = ["create_raindrop_strands"]
@@ -0,0 +1,400 @@
1
+ """
2
+ Strands Agents callback handler for Raindrop observability.
3
+
4
+ Captures agent invocations, model calls, and tool usage via the Strands
5
+ Agents hook system and ships them to the Raindrop API.
6
+
7
+ Usage:
8
+ from raindrop_strands import create_raindrop_strands
9
+
10
+ raindrop = create_raindrop_strands(api_key="rk_...")
11
+ agent = Agent(model=model)
12
+ raindrop["handler"].register_hooks(agent)
13
+ agent("Hello!")
14
+ raindrop["flush"]()
15
+
16
+ IMPORTANT: Every handler method MUST be wrapped in try/except.
17
+ Telemetry must NEVER crash the user's pipeline.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import uuid
24
+ from typing import Any, Dict, Optional
25
+
26
+ import raindrop.analytics as raindrop
27
+
28
+
29
+ def _safe_serialize(value: Any) -> str:
30
+ """Safely serialize a value to string. Never throws."""
31
+ try:
32
+ return json.dumps(value)
33
+ except Exception:
34
+ return str(value)
35
+
36
+
37
+ def _extract_text_from_message(message: Any) -> Optional[str]:
38
+ """Extract text content from a Strands Message (TypedDict with 'content' list)."""
39
+ try:
40
+ content = message.get("content") if isinstance(message, dict) else None
41
+ if not content or not isinstance(content, list):
42
+ return None
43
+
44
+ text_parts = []
45
+ for block in content:
46
+ if block is None:
47
+ continue
48
+ if isinstance(block, str):
49
+ text_parts.append(block)
50
+ elif isinstance(block, dict) and "text" in block:
51
+ text = block["text"]
52
+ if isinstance(text, str):
53
+ text_parts.append(text)
54
+ return "\n".join(text_parts) if text_parts else None
55
+ except Exception:
56
+ return None
57
+
58
+
59
+ def _extract_user_input(messages: Any) -> Optional[str]:
60
+ """Extract the last user message text from a conversation history."""
61
+ try:
62
+ if not messages or not isinstance(messages, list):
63
+ return None
64
+
65
+ # Find the last user message
66
+ for msg in reversed(messages):
67
+ if not isinstance(msg, dict):
68
+ continue
69
+ if msg.get("role") != "user":
70
+ continue
71
+ text = _extract_text_from_message(msg)
72
+ if text:
73
+ return text
74
+
75
+ # Fallback: try the last message regardless of role
76
+ if messages:
77
+ last_msg = messages[-1]
78
+ if isinstance(last_msg, dict):
79
+ return _extract_text_from_message(last_msg)
80
+
81
+ return None
82
+ except Exception:
83
+ return None
84
+
85
+
86
+ class RaindropStrandsHandler:
87
+ """Strands Agents hook handler that ships events to Raindrop."""
88
+
89
+ def __init__(
90
+ self,
91
+ user_id: Optional[str] = None,
92
+ convo_id: Optional[str] = None,
93
+ ):
94
+ self._user_id = user_id
95
+ self._convo_id = convo_id
96
+
97
+ # Map id(agent) -> invocation context dict.
98
+ # Cleaned up explicitly in on_after_invocation to prevent leaks.
99
+ self._invocations: Dict[int, Dict[str, Any]] = {}
100
+ # toolUseId -> span context for active tool calls
101
+ self._tool_spans: Dict[str, Dict[str, Any]] = {}
102
+
103
+ # Tracking counters for memory leak detection in tests
104
+ self._active_invocations = 0
105
+ self._active_tool_spans = 0
106
+
107
+ def register_hooks(self, agent: Any) -> None:
108
+ """Register all Raindrop hook callbacks on a Strands agent.
109
+
110
+ Args:
111
+ agent: A Strands Agent instance with an ``add_hook`` method.
112
+ """
113
+ from strands.hooks.events import (
114
+ AfterInvocationEvent,
115
+ AfterModelCallEvent,
116
+ AfterToolCallEvent,
117
+ BeforeInvocationEvent,
118
+ BeforeModelCallEvent,
119
+ BeforeToolCallEvent,
120
+ )
121
+
122
+ agent.add_hook(self.on_before_invocation, BeforeInvocationEvent)
123
+ agent.add_hook(self.on_after_invocation, AfterInvocationEvent)
124
+ agent.add_hook(self.on_before_model_call, BeforeModelCallEvent)
125
+ agent.add_hook(self.on_after_model_call, AfterModelCallEvent)
126
+ agent.add_hook(self.on_before_tool_call, BeforeToolCallEvent)
127
+ agent.add_hook(self.on_after_tool_call, AfterToolCallEvent)
128
+
129
+ # --- Hook callback methods ---
130
+
131
+ def on_before_invocation(self, event: Any, **kwargs: Any) -> None:
132
+ """Called before an agent invocation starts."""
133
+ try:
134
+ agent = event.agent
135
+ event_id = str(uuid.uuid4())
136
+
137
+ # Extract input from the last user message
138
+ messages = getattr(agent, "messages", None)
139
+ input_text = _extract_user_input(messages)
140
+
141
+ ctx: Dict[str, Any] = {
142
+ "event_id": event_id,
143
+ "input": input_text,
144
+ "output": None,
145
+ "model": None,
146
+ "prompt_tokens": None,
147
+ "completion_tokens": None,
148
+ "tool_use_ids": set(),
149
+ }
150
+ self._invocations[id(agent)] = ctx
151
+ self._active_invocations += 1
152
+ except Exception:
153
+ pass # telemetry must not interfere with user's pipeline
154
+
155
+ def on_after_invocation(self, event: Any, **kwargs: Any) -> None:
156
+ """Called after an agent invocation completes."""
157
+ try:
158
+ agent = event.agent
159
+ ctx = self._invocations.get(id(agent))
160
+ if not ctx:
161
+ return
162
+
163
+ # Extract output from the result if available
164
+ result = getattr(event, "result", None)
165
+ if result is not None:
166
+ message = getattr(result, "message", None)
167
+ if message is not None:
168
+ output = _extract_text_from_message(message)
169
+ if output:
170
+ ctx["output"] = output
171
+
172
+ # Extract token usage from metrics
173
+ metrics = getattr(result, "metrics", None)
174
+ if metrics is not None:
175
+ accumulated = getattr(metrics, "accumulated_usage", None)
176
+ if accumulated is not None:
177
+ input_tokens = (
178
+ accumulated.get("inputTokens")
179
+ if isinstance(accumulated, dict)
180
+ else getattr(accumulated, "inputTokens", None)
181
+ )
182
+ output_tokens = (
183
+ accumulated.get("outputTokens")
184
+ if isinstance(accumulated, dict)
185
+ else getattr(accumulated, "outputTokens", None)
186
+ )
187
+ if input_tokens is not None:
188
+ ctx["prompt_tokens"] = input_tokens
189
+ if output_tokens is not None:
190
+ ctx["completion_tokens"] = output_tokens
191
+
192
+ properties: Dict[str, Any] = {}
193
+ if ctx.get("prompt_tokens") is not None:
194
+ properties["ai.usage.prompt_tokens"] = ctx["prompt_tokens"]
195
+ if ctx.get("completion_tokens") is not None:
196
+ properties["ai.usage.completion_tokens"] = ctx["completion_tokens"]
197
+
198
+ raindrop.track_ai(
199
+ user_id=self._user_id or "",
200
+ event="ai_generation",
201
+ event_id=ctx["event_id"],
202
+ input=ctx.get("input"),
203
+ output=ctx.get("output"),
204
+ model=ctx.get("model"),
205
+ convo_id=self._convo_id,
206
+ properties=properties if properties else None,
207
+ )
208
+ except Exception:
209
+ pass # telemetry must not interfere
210
+ finally:
211
+ try:
212
+ agent = event.agent
213
+ # Get ctx BEFORE popping, for orphan tool span cleanup
214
+ ctx = self._invocations.get(id(agent))
215
+ if ctx is not None:
216
+ self._invocations.pop(id(agent), None)
217
+ self._active_invocations = max(0, self._active_invocations - 1)
218
+ # Clean up any orphaned tool spans owned by this invocation
219
+ for tool_id in ctx.get("tool_use_ids", set()):
220
+ if self._tool_spans.pop(tool_id, None) is not None:
221
+ self._active_tool_spans = max(0, self._active_tool_spans - 1)
222
+ except Exception:
223
+ pass
224
+
225
+ def on_before_model_call(self, event: Any, **kwargs: Any) -> None:
226
+ """Called before a model call within an invocation."""
227
+ try:
228
+ # No span-based tracing in Python SDK; model call data is
229
+ # captured in on_after_model_call and attached to the invocation.
230
+ pass
231
+ except Exception:
232
+ pass
233
+
234
+ def on_after_model_call(self, event: Any, **kwargs: Any) -> None:
235
+ """Called after a model call completes."""
236
+ try:
237
+ agent = event.agent
238
+ ctx = self._invocations.get(id(agent))
239
+ if not ctx:
240
+ return
241
+
242
+ # Extract output from stop response
243
+ stop_response = getattr(event, "stop_response", None)
244
+ if stop_response is not None:
245
+ message = getattr(stop_response, "message", None)
246
+ if message is not None:
247
+ output = _extract_text_from_message(message)
248
+ if output:
249
+ ctx["output"] = output
250
+
251
+ # Extract usage from stop response
252
+ usage = getattr(stop_response, "usage", None)
253
+ if usage is not None:
254
+ input_tokens = (
255
+ usage.get("inputTokens")
256
+ if isinstance(usage, dict)
257
+ else getattr(usage, "inputTokens", None)
258
+ )
259
+ output_tokens = (
260
+ usage.get("outputTokens")
261
+ if isinstance(usage, dict)
262
+ else getattr(usage, "outputTokens", None)
263
+ )
264
+ if input_tokens is not None:
265
+ ctx["prompt_tokens"] = input_tokens
266
+ if output_tokens is not None:
267
+ ctx["completion_tokens"] = output_tokens
268
+
269
+ # Extract model name
270
+ model_id = getattr(stop_response, "model", None)
271
+ if model_id is not None:
272
+ ctx["model"] = str(model_id)
273
+ except Exception:
274
+ pass # telemetry must not interfere
275
+
276
+ def on_before_tool_call(self, event: Any, **kwargs: Any) -> None:
277
+ """Called before a tool call within an invocation."""
278
+ try:
279
+ agent = getattr(event, "agent", None)
280
+ if agent is None:
281
+ return
282
+ ctx = self._invocations.get(id(agent))
283
+ if not ctx:
284
+ return
285
+
286
+ tool_use = getattr(event, "tool_use", None)
287
+ if tool_use is None:
288
+ return
289
+
290
+ tool_use_id = (
291
+ tool_use.get("toolUseId")
292
+ if isinstance(tool_use, dict)
293
+ else getattr(tool_use, "toolUseId", None)
294
+ )
295
+ tool_name = (
296
+ tool_use.get("name")
297
+ if isinstance(tool_use, dict)
298
+ else getattr(tool_use, "name", None)
299
+ )
300
+ tool_input = (
301
+ tool_use.get("input")
302
+ if isinstance(tool_use, dict)
303
+ else getattr(tool_use, "input", None)
304
+ )
305
+
306
+ if tool_use_id:
307
+ tid = str(tool_use_id)
308
+ self._tool_spans[tid] = {
309
+ "name": tool_name,
310
+ "input": _safe_serialize(tool_input) if tool_input is not None else None,
311
+ }
312
+ if "tool_use_ids" in ctx:
313
+ ctx["tool_use_ids"].add(tid)
314
+ self._active_tool_spans += 1
315
+ except Exception:
316
+ pass
317
+
318
+ def on_after_tool_call(self, event: Any, **kwargs: Any) -> None:
319
+ """Called after a tool call completes."""
320
+ try:
321
+ tool_use = getattr(event, "tool_use", None)
322
+ if tool_use is None:
323
+ return
324
+
325
+ tool_use_id = (
326
+ tool_use.get("toolUseId")
327
+ if isinstance(tool_use, dict)
328
+ else getattr(tool_use, "toolUseId", None)
329
+ )
330
+ if tool_use_id:
331
+ tid = str(tool_use_id)
332
+ deleted = self._tool_spans.pop(tid, None)
333
+ if deleted is not None:
334
+ self._active_tool_spans = max(0, self._active_tool_spans - 1)
335
+ # Also remove from invocation's tracked set
336
+ agent = getattr(event, "agent", None)
337
+ if agent is not None:
338
+ ctx = self._invocations.get(id(agent))
339
+ if ctx:
340
+ if "tool_use_ids" in ctx:
341
+ ctx["tool_use_ids"].discard(tid)
342
+ except Exception:
343
+ pass
344
+
345
+ @property
346
+ def _map_sizes(self) -> Dict[str, int]:
347
+ """Get the current sizes of internal tracking maps (for testing)."""
348
+ return {
349
+ "invocations": self._active_invocations,
350
+ "tool_spans": self._active_tool_spans,
351
+ }
352
+
353
+
354
+ def create_raindrop_strands(
355
+ api_key: Optional[str] = None,
356
+ user_id: Optional[str] = None,
357
+ convo_id: Optional[str] = None,
358
+ **kwargs: Any,
359
+ ) -> Dict[str, Any]:
360
+ """
361
+ Create a Raindrop-instrumented Strands Agents callback handler.
362
+
363
+ Args:
364
+ api_key: Raindrop API key (rk_...). Omit to disable telemetry shipping.
365
+ user_id: Associate all events with this user.
366
+ convo_id: Conversation ID to group related events.
367
+ **kwargs: Additional options (reserved for future use).
368
+
369
+ Returns:
370
+ Dict with 'handler', 'flush', and 'shutdown'.
371
+
372
+ Usage:
373
+ from raindrop_strands import create_raindrop_strands
374
+
375
+ raindrop = create_raindrop_strands(api_key="rk_...")
376
+ agent = Agent(model=model)
377
+ raindrop["handler"].register_hooks(agent)
378
+ agent("Hello!")
379
+ raindrop["flush"]()
380
+ """
381
+ import warnings
382
+
383
+ if not api_key:
384
+ warnings.warn(
385
+ "[raindrop-strands] api_key not provided; telemetry shipping is disabled",
386
+ stacklevel=2,
387
+ )
388
+
389
+ raindrop.init(api_key=api_key or "")
390
+
391
+ handler = RaindropStrandsHandler(
392
+ user_id=user_id,
393
+ convo_id=convo_id,
394
+ )
395
+
396
+ return {
397
+ "handler": handler,
398
+ "flush": raindrop.flush,
399
+ "shutdown": raindrop.shutdown,
400
+ }