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.
- infralo/__init__.py +9 -0
- infralo/_models.py +84 -0
- infralo/client.py +251 -0
- infralo/exporter.py +154 -0
- infralo/integrations/__init__.py +23 -0
- infralo/integrations/agno.py +381 -0
- infralo/integrations/crewai.py +337 -0
- infralo/integrations/google_adk.py +238 -0
- infralo/integrations/langgraph.py +492 -0
- infralo/span.py +226 -0
- infralo/trace.py +457 -0
- infralo-0.1.0.dev1.dist-info/METADATA +480 -0
- infralo-0.1.0.dev1.dist-info/RECORD +14 -0
- infralo-0.1.0.dev1.dist-info/WHEEL +4 -0
|
@@ -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
|
+
}
|