infralo 0.1.0.dev1__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.
@@ -0,0 +1,381 @@
1
+ """
2
+ infralo — Agno agent framework integration.
3
+
4
+ Install
5
+ -------
6
+ pip install infralo[agno]
7
+
8
+ Quick start
9
+ -----------
10
+ from infralo import Infralo
11
+ from infralo.integrations.agno import make_agno_callbacks
12
+ from agno.agent import Agent
13
+ from agno.models.openai import OpenAIChat
14
+
15
+ infralo = Infralo(api_key="vk_...")
16
+ callbacks = make_agno_callbacks(infralo)
17
+
18
+ with infralo.start_trace(session_id="user-123") as trace:
19
+ agent = Agent(
20
+ # Point at the Infralo gateway for full LLM+tool linkage
21
+ model=OpenAIChat(id="my-deployment", base_url="http://localhost:8000/v1"),
22
+ tools=[search_web, get_weather],
23
+ **callbacks, # ← injects all four lifecycle hooks
24
+ )
25
+ agent.print_response("What is the weather in London?")
26
+
27
+ What is captured
28
+ ----------------
29
+ * Every tool invocation → a ``ToolSpan`` with input args, output, timing, status.
30
+ * LLM→tool parent links when the model routes through the Infralo gateway
31
+ (``x-infralo-span-id`` response header is read from the raw HTTP response).
32
+ * Model metadata (model name, token counts) is attached to the span as metadata
33
+ when exposed by Agno's ``ModelResponse``.
34
+
35
+ LLM span linking
36
+ ----------------
37
+ ``after_model_callback`` walks several attribute paths to find the raw HTTP
38
+ response headers returned by the gateway. If the Agno version does not expose
39
+ them, tool spans are still captured but will appear without a parent LLM span.
40
+ Use ``trace.set_parent_span_id(span_id)`` to set it manually when needed.
41
+
42
+ Thread / async safety
43
+ ---------------------
44
+ Agno runs tools in async tasks that inherit the active ``ContextVar`` from the
45
+ parent coroutine, so ``get_active_trace()`` always returns the correct trace.
46
+ For sync Agno runs the ContextVar is still set on the calling thread.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import inspect
52
+ import logging
53
+ import uuid
54
+ from typing import TYPE_CHECKING, Any
55
+
56
+ try:
57
+ import agno
58
+ except ImportError:
59
+ agno = None
60
+
61
+ from infralo.trace import _patch_httpx_once, get_active_trace
62
+
63
+ if TYPE_CHECKING:
64
+ from infralo.client import Infralo # noqa: F401
65
+
66
+ logger = logging.getLogger("infralo.integrations.agno")
67
+
68
+ # Key used to stash the in-flight ToolSpan on tool_context.session_state
69
+ # so the after_tool callback can retrieve and finalise it.
70
+ _SPAN_KEY = "__infralo_tool_span__"
71
+
72
+
73
+ def make_agno_callbacks(infralo: "Infralo") -> dict[str, Any]: # noqa: ARG001
74
+ """
75
+ Return a dict of Agno lifecycle callbacks wired to the active Infralo trace.
76
+
77
+ Pass the dict as keyword arguments to ``Agent(...)``:
78
+
79
+ callbacks = make_agno_callbacks(infralo)
80
+
81
+ with infralo.start_trace(session_id="s1") as trace:
82
+ agent = Agent(model=..., tools=[...], **callbacks)
83
+ agent.print_response("...")
84
+
85
+ Returned keys:
86
+ - For Agno 2.x: ``tool_hooks`` and ``post_hooks``.
87
+ - For Agno 1.x: ``before_tool_callback``, ``after_tool_callback``,
88
+ and ``after_model_callback``.
89
+
90
+ Args:
91
+ infralo: Your ``Infralo`` client instance. Not used at callback
92
+ call time (trace is read from the ``ContextVar`` instead),
93
+ but required for documentation parity with other integrations.
94
+ """
95
+ _patch_httpx_once()
96
+
97
+ is_agno_v2 = False
98
+ if agno is not None:
99
+ version = getattr(agno, "__version__", "1.0.0")
100
+ if version.split(".")[0] >= "2":
101
+ is_agno_v2 = True
102
+
103
+ if is_agno_v2:
104
+
105
+ class InfraloToolHooksList:
106
+ def __init__(self, sync_hook, async_hook):
107
+ self.sync_hook = sync_hook
108
+ self.async_hook = async_hook
109
+
110
+ def __iter__(self):
111
+ entrypoint_is_async = False
112
+ try:
113
+ for frame_info in inspect.stack():
114
+ frame_self = frame_info.frame.f_locals.get("self")
115
+ if (
116
+ frame_self is not None
117
+ and frame_self.__class__.__name__ == "FunctionCall"
118
+ ):
119
+ entrypoint = getattr(
120
+ getattr(frame_self, "function", None), "entrypoint", None
121
+ )
122
+ if entrypoint is not None:
123
+ if inspect.iscoroutinefunction(entrypoint):
124
+ entrypoint_is_async = True
125
+ break
126
+ except Exception:
127
+ pass
128
+
129
+ if entrypoint_is_async:
130
+ yield self.async_hook
131
+ else:
132
+ yield self.sync_hook
133
+
134
+ def __reversed__(self):
135
+ return reversed(list(self))
136
+
137
+ def pre_hook(run_input: Any, run_context: Any) -> None: # noqa: ARG001
138
+ trace = get_active_trace()
139
+ if (
140
+ trace is not None
141
+ and run_context is not None
142
+ and hasattr(run_context, "session_state")
143
+ ):
144
+ run_context.session_state["__infralo_trace__"] = trace
145
+ return None
146
+
147
+ async def async_tool_hook(
148
+ function_name: str, function_call: Any, arguments: dict, run_context: Any = None
149
+ ) -> Any:
150
+ trace = None
151
+ if run_context is not None and hasattr(run_context, "session_state"):
152
+ trace = run_context.session_state.get("__infralo_trace__")
153
+ if trace is None:
154
+ trace = get_active_trace()
155
+
156
+ if trace is None:
157
+ return await function_call(**arguments)
158
+
159
+ tool_call_id = str(uuid.uuid4())
160
+ with trace.tool(name=function_name, tool_call_id=tool_call_id) as span:
161
+ span.set_input(arguments)
162
+ span.set_metadata(framework="agno", version="2.x")
163
+ try:
164
+ result = await function_call(**arguments)
165
+ if hasattr(result, "content"):
166
+ output = {"result": str(result.content)}
167
+ elif isinstance(result, dict):
168
+ output = result
169
+ else:
170
+ output = {"result": str(result)}
171
+ span.set_output(output)
172
+ return result
173
+ except Exception as e:
174
+ raise e
175
+
176
+ def sync_tool_hook(
177
+ function_name: str, function_call: Any, arguments: dict, run_context: Any = None
178
+ ) -> Any:
179
+ trace = None
180
+ if run_context is not None and hasattr(run_context, "session_state"):
181
+ trace = run_context.session_state.get("__infralo_trace__")
182
+ if trace is None:
183
+ trace = get_active_trace()
184
+
185
+ if trace is None:
186
+ return function_call(**arguments)
187
+
188
+ tool_call_id = str(uuid.uuid4())
189
+ with trace.tool(name=function_name, tool_call_id=tool_call_id) as span:
190
+ span.set_input(arguments)
191
+ span.set_metadata(framework="agno", version="2.x")
192
+ try:
193
+ result = function_call(**arguments)
194
+ if hasattr(result, "content"):
195
+ output = {"result": str(result.content)}
196
+ elif isinstance(result, dict):
197
+ output = result
198
+ else:
199
+ output = {"result": str(result)}
200
+ span.set_output(output)
201
+ return result
202
+ except Exception as e:
203
+ raise e
204
+
205
+ def post_hook(run_output: Any, run_context: Any) -> None: # noqa: ARG001
206
+ trace = None
207
+ if run_context is not None and hasattr(run_context, "session_state"):
208
+ trace = run_context.session_state.get("__infralo_trace__")
209
+ if trace is None:
210
+ trace = get_active_trace()
211
+
212
+ if trace is None:
213
+ return None
214
+
215
+ span_id = None
216
+ try:
217
+ for msg in getattr(run_output, "messages", []):
218
+ if hasattr(msg, "response_metadata") and isinstance(
219
+ msg.response_metadata, dict
220
+ ):
221
+ span_id = msg.response_metadata.get("x-infralo-span-id")
222
+ if (
223
+ not span_id
224
+ and hasattr(msg, "provider_data")
225
+ and isinstance(msg.provider_data, dict)
226
+ ):
227
+ span_id = msg.provider_data.get("x-infralo-span-id")
228
+ except Exception:
229
+ pass
230
+
231
+ if span_id:
232
+ trace.set_parent_span_id(span_id)
233
+ return None
234
+
235
+ return {
236
+ "pre_hooks": [pre_hook],
237
+ "tool_hooks": InfraloToolHooksList(sync_tool_hook, async_tool_hook),
238
+ "post_hooks": [post_hook],
239
+ }
240
+
241
+ # ── Tool callbacks (Agno 1.x / Phidata) ───────────────────────────────────
242
+
243
+ def before_tool_callback(tool: Any, args: dict, tool_context: Any) -> None:
244
+ """
245
+ Called by Agno before each tool function runs.
246
+ Starts a ToolSpan and stashes it on ``tool_context.session_state``.
247
+ Returns ``None`` so Agno proceeds with actual tool execution.
248
+ """
249
+ trace = get_active_trace()
250
+ if trace is None:
251
+ logger.warning(
252
+ "infralo: before_tool_callback fired outside an active trace. "
253
+ "Wrap your agent run with `with infralo.start_trace(): ...`."
254
+ )
255
+ return None
256
+
257
+ # Agno's ToolExecutionContext exposes tool_call_id directly.
258
+ tool_call_id: str = getattr(tool_context, "tool_call_id", None) or str(uuid.uuid4())
259
+ tool_name: str = str(
260
+ getattr(tool, "name", None) or getattr(tool, "__name__", None) or "unknown_tool"
261
+ )
262
+
263
+ try:
264
+ span = trace.start_tool_span(name=tool_name, tool_call_id=tool_call_id)
265
+ if args:
266
+ span.set_input(args)
267
+ # Attach framework metadata to the span
268
+ agent_name = getattr(tool_context, "agent_name", None)
269
+ session_id = getattr(tool_context, "session_id", None)
270
+ meta: dict[str, Any] = {"framework": "agno"}
271
+ if agent_name:
272
+ meta["agent_name"] = agent_name
273
+ if session_id:
274
+ meta["framework_session_id"] = session_id
275
+ span.set_metadata(**meta)
276
+
277
+ # Stash for after_tool_callback
278
+ session_state = getattr(tool_context, "session_state", None)
279
+ if session_state is not None:
280
+ session_state[_SPAN_KEY] = span
281
+ else:
282
+ # Fallback: attach directly (Agno may use different attr names)
283
+ tool_context.__dict__[_SPAN_KEY] = span
284
+ except Exception:
285
+ logger.debug("infralo: error starting tool span for '%s'", tool_name, exc_info=True)
286
+
287
+ return None # always let Agno proceed with tool execution
288
+
289
+ def after_tool_callback(
290
+ tool: Any, args: dict, tool_context: Any, tool_response: Any # noqa: ARG001
291
+ ) -> None:
292
+ """
293
+ Called by Agno after each tool function completes.
294
+ Finalises the ToolSpan with output and timing.
295
+ Returns ``None`` so Agno passes the original tool_response to the LLM.
296
+ """
297
+ # Retrieve the stashed span
298
+ span = None
299
+ session_state = getattr(tool_context, "session_state", None)
300
+ if session_state is not None:
301
+ span = session_state.pop(_SPAN_KEY, None)
302
+ if span is None:
303
+ span = tool_context.__dict__.pop(_SPAN_KEY, None)
304
+
305
+ if span is None:
306
+ return None
307
+
308
+ try:
309
+ # Agno's tool_response can be a ToolResponse, string, dict, etc.
310
+ if hasattr(tool_response, "content"):
311
+ output: Any = {"result": str(tool_response.content)}
312
+ elif isinstance(tool_response, dict):
313
+ output = tool_response
314
+ else:
315
+ output = {"result": str(tool_response)}
316
+ span.end(output=output)
317
+ except Exception:
318
+ logger.debug("infralo: error ending tool span", exc_info=True)
319
+ try:
320
+ span.end()
321
+ except Exception:
322
+ pass
323
+
324
+ return None # pass response to LLM unchanged
325
+
326
+ # ── Model callbacks ─────────────────────────────────────────────────────
327
+
328
+ def after_model_callback(
329
+ agent: Any, messages: Any, model_response: Any # noqa: ARG001
330
+ ) -> None:
331
+ """
332
+ Called by Agno after each LLM response.
333
+
334
+ Attempts to extract the ``x-infralo-span-id`` header from the raw HTTP
335
+ response (available when routing through the Infralo gateway) and sets it
336
+ as the current parent span so subsequent tool spans are correctly linked.
337
+
338
+ If the header is unavailable, a debug message is logged and tool spans
339
+ remain unlinked from the LLM span.
340
+ """
341
+ trace = get_active_trace()
342
+ if trace is None:
343
+ return None
344
+
345
+ span_id: str | None = None
346
+
347
+ # Walk possible attribute paths to find raw HTTP response headers.
348
+ # Agno stores the underlying OpenAI response in model_response.response.
349
+ # Different Agno/OpenAI SDK versions may expose headers via different paths.
350
+ try:
351
+ raw = getattr(model_response, "response", None)
352
+ if raw is not None:
353
+ headers = (
354
+ getattr(raw, "headers", None)
355
+ # openai >= 1.x wraps in APIResponse, exposed via _response
356
+ or getattr(getattr(raw, "_response", None), "headers", None)
357
+ # httpx-level response
358
+ or getattr(getattr(raw, "http_response", None), "headers", None)
359
+ )
360
+ if headers:
361
+ span_id = headers.get("x-infralo-span-id")
362
+ except Exception:
363
+ logger.debug("infralo: error reading model response headers", exc_info=True)
364
+
365
+ if span_id:
366
+ trace.set_parent_span_id(span_id)
367
+ logger.debug("infralo: set parent span id from Agno model response: %s", span_id)
368
+ else:
369
+ logger.debug(
370
+ "infralo: x-infralo-span-id not found in Agno model response. "
371
+ "Tool spans will be recorded without a parent LLM span. "
372
+ "Ensure the model base_url points to the Infralo gateway for full linkage."
373
+ )
374
+
375
+ return None # return None to pass model_response through unchanged
376
+
377
+ return {
378
+ "before_tool_callback": before_tool_callback,
379
+ "after_tool_callback": after_tool_callback,
380
+ "after_model_callback": after_model_callback,
381
+ }