agentos-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentos/decorators.py ADDED
@@ -0,0 +1,372 @@
1
+ """Decorators for automatic tracing and event capture.
2
+
3
+ Provides ``@trace_agent``, ``@trace_llm_call``, ``@trace_tool``,
4
+ and Langfuse-compatible ``@observe``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import functools
11
+ import logging
12
+ import time
13
+ from collections.abc import Callable
14
+ from typing import Any, TypeVar
15
+
16
+ from agentos.tracing import (
17
+ get_current_context,
18
+ span,
19
+ trace,
20
+ )
21
+
22
+ logger = logging.getLogger("agentos.decorators")
23
+
24
+ F = TypeVar("F", bound=Callable[..., Any])
25
+
26
+
27
+ def _get_global_client() -> Any:
28
+ """Lazily import the global client to avoid circular imports."""
29
+ from agentos.client import get_client
30
+
31
+ return get_client()
32
+
33
+
34
+ def _is_async(fn: Callable[..., Any]) -> bool:
35
+ return asyncio.iscoroutinefunction(fn)
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # @observe — Langfuse-compatible decorator
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def observe(
44
+ *,
45
+ name: str | None = None,
46
+ as_type: str | None = None,
47
+ ) -> Callable[[F], F]:
48
+ """Langfuse-compatible decorator for automatic tracing.
49
+
50
+ Creates a span (or trace if none exists) around the decorated function.
51
+ Nesting is automatic via contextvars.
52
+
53
+ Args:
54
+ name: Span name. Defaults to the function name.
55
+ as_type: If ``"generation"``, emits an ``agent.llm_call`` event.
56
+ Otherwise emits timing as a span.
57
+ """
58
+
59
+ def decorator(fn: F) -> F:
60
+ span_name = name or fn.__name__
61
+
62
+ if _is_async(fn):
63
+
64
+ @functools.wraps(fn)
65
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
66
+ parent = get_current_context()
67
+ ctx_mgr = trace(agent_id=span_name) if parent is None else span()
68
+ start = time.monotonic()
69
+ error_info = None
70
+
71
+ with ctx_mgr as ctx:
72
+ try:
73
+ result = await fn(*args, **kwargs)
74
+ return result
75
+ except Exception as exc:
76
+ error_info = {"type": type(exc).__name__, "message": str(exc)}
77
+ raise
78
+ finally:
79
+ duration_ms = (time.monotonic() - start) * 1000
80
+ client = _get_global_client()
81
+ if client is not None and as_type == "generation":
82
+ # Pull any updates from trace_context helper
83
+ updates = ctx._observation_updates
84
+ client.llm_call(
85
+ ctx.agent_id,
86
+ model=updates.get("model", "unknown"),
87
+ system=updates.get("system", "unknown"),
88
+ duration_ms=duration_ms,
89
+ error=error_info,
90
+ trace_id=ctx.trace_id,
91
+ span_id=ctx.span_id,
92
+ parent_span_id=ctx.parent_span_id,
93
+ **{
94
+ k: v for k, v in updates.items() if k not in ("model", "system")
95
+ },
96
+ )
97
+
98
+ return async_wrapper # type: ignore[return-value]
99
+
100
+ else:
101
+
102
+ @functools.wraps(fn)
103
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
104
+ parent = get_current_context()
105
+ ctx_mgr = trace(agent_id=span_name) if parent is None else span()
106
+ start = time.monotonic()
107
+ error_info = None
108
+
109
+ with ctx_mgr as ctx:
110
+ try:
111
+ result = fn(*args, **kwargs)
112
+ return result
113
+ except Exception as exc:
114
+ error_info = {"type": type(exc).__name__, "message": str(exc)}
115
+ raise
116
+ finally:
117
+ duration_ms = (time.monotonic() - start) * 1000
118
+ client = _get_global_client()
119
+ if client is not None and as_type == "generation":
120
+ updates = ctx._observation_updates
121
+ client.llm_call(
122
+ ctx.agent_id,
123
+ model=updates.get("model", "unknown"),
124
+ system=updates.get("system", "unknown"),
125
+ duration_ms=duration_ms,
126
+ error=error_info,
127
+ trace_id=ctx.trace_id,
128
+ span_id=ctx.span_id,
129
+ parent_span_id=ctx.parent_span_id,
130
+ **{
131
+ k: v for k, v in updates.items() if k not in ("model", "system")
132
+ },
133
+ )
134
+
135
+ return sync_wrapper # type: ignore[return-value]
136
+
137
+ return decorator
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # trace_context — Langfuse-compatible context helper
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ class trace_context:
146
+ """Module-level helper for updating the current trace/observation.
147
+
148
+ Mirrors ``langfuse_context`` from the Langfuse SDK.
149
+ """
150
+
151
+ @staticmethod
152
+ def update_current_observation(
153
+ *,
154
+ model: str | None = None,
155
+ system: str | None = None,
156
+ usage: dict[str, Any] | None = None,
157
+ input: Any | None = None,
158
+ output: Any | None = None,
159
+ metadata: dict[str, Any] | None = None,
160
+ **kwargs: Any,
161
+ ) -> None:
162
+ """Update the current span/generation with additional data."""
163
+ ctx = get_current_context()
164
+ if ctx is None:
165
+ return
166
+ if model:
167
+ ctx._observation_updates["model"] = model
168
+ if system:
169
+ ctx._observation_updates["system"] = system
170
+ if usage:
171
+ if "input" in usage:
172
+ ctx._observation_updates["input_tokens"] = usage["input"]
173
+ if "output" in usage:
174
+ ctx._observation_updates["output_tokens"] = usage["output"]
175
+ if "total" in usage:
176
+ ctx._observation_updates["total_tokens"] = usage["total"]
177
+ if input is not None:
178
+ ctx._observation_updates["input"] = input
179
+ if output is not None:
180
+ ctx._observation_updates["output"] = output
181
+ ctx._observation_updates.update(kwargs)
182
+
183
+ @staticmethod
184
+ def update_current_trace(
185
+ *,
186
+ user_id: str | None = None,
187
+ session_id: str | None = None,
188
+ metadata: dict[str, Any] | None = None,
189
+ ) -> None:
190
+ """Update the current trace metadata."""
191
+ ctx = get_current_context()
192
+ if ctx is None:
193
+ return
194
+ if user_id:
195
+ ctx._trace_updates["user_id"] = user_id
196
+ if session_id:
197
+ ctx._trace_updates["session_id"] = session_id
198
+ if metadata:
199
+ ctx._trace_updates["metadata"] = metadata
200
+
201
+ @staticmethod
202
+ def score_current_trace(*, name: str, value: float, comment: str | None = None) -> None:
203
+ """Attach a score to the current trace."""
204
+ ctx = get_current_context()
205
+ if ctx is None:
206
+ return
207
+ score = {"name": name, "value": value}
208
+ if comment:
209
+ score["comment"] = comment
210
+ ctx._scores.append(score)
211
+
212
+ client = _get_global_client()
213
+ if client is not None:
214
+ client.eval(
215
+ ctx.agent_id,
216
+ eval_name=name,
217
+ score=value,
218
+ trace_id=ctx.trace_id,
219
+ span_id=ctx.span_id,
220
+ )
221
+
222
+ @staticmethod
223
+ def score_current_observation(*, name: str, value: float, comment: str | None = None) -> None:
224
+ """Attach a score to the current observation/span."""
225
+ trace_context.score_current_trace(name=name, value=value, comment=comment)
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Native decorators
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def trace_agent(
234
+ agent_id: str,
235
+ *,
236
+ task_run_id: str | None = None,
237
+ user_id: str | None = None,
238
+ session_id: str | None = None,
239
+ ) -> Callable[[F], F]:
240
+ """Decorator that wraps a function in a trace context.
241
+
242
+ Usage::
243
+
244
+ @trace_agent("my-agent")
245
+ def run_agent(query: str) -> str:
246
+ ...
247
+ """
248
+
249
+ def decorator(fn: F) -> F:
250
+ if _is_async(fn):
251
+
252
+ @functools.wraps(fn)
253
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
254
+ with trace(
255
+ agent_id=agent_id,
256
+ task_run_id=task_run_id,
257
+ user_id=user_id,
258
+ session_id=session_id,
259
+ ):
260
+ return await fn(*args, **kwargs)
261
+
262
+ return async_wrapper # type: ignore[return-value]
263
+
264
+ @functools.wraps(fn)
265
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
266
+ with trace(
267
+ agent_id=agent_id,
268
+ task_run_id=task_run_id,
269
+ user_id=user_id,
270
+ session_id=session_id,
271
+ ):
272
+ return fn(*args, **kwargs)
273
+
274
+ return sync_wrapper # type: ignore[return-value]
275
+
276
+ return decorator
277
+
278
+
279
+ def trace_tool(
280
+ tool_name: str,
281
+ ) -> Callable[[F], F]:
282
+ """Decorator that captures tool call events automatically.
283
+
284
+ Captures input (function args), output (return value), duration, and errors.
285
+
286
+ Usage::
287
+
288
+ @trace_tool("web-search")
289
+ def search(query: str) -> list:
290
+ ...
291
+ """
292
+
293
+ def decorator(fn: F) -> F:
294
+ if _is_async(fn):
295
+
296
+ @functools.wraps(fn)
297
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
298
+ with span() as ctx:
299
+ start = time.monotonic()
300
+ try:
301
+ result = await fn(*args, **kwargs)
302
+ duration_ms = (time.monotonic() - start) * 1000
303
+ client = _get_global_client()
304
+ if client:
305
+ client.tool_call(
306
+ ctx.agent_id,
307
+ tool_name=tool_name,
308
+ status="success",
309
+ input=kwargs or None,
310
+ duration_ms=duration_ms,
311
+ trace_id=ctx.trace_id,
312
+ span_id=ctx.span_id,
313
+ parent_span_id=ctx.parent_span_id,
314
+ )
315
+ return result
316
+ except Exception as exc:
317
+ duration_ms = (time.monotonic() - start) * 1000
318
+ client = _get_global_client()
319
+ if client:
320
+ client.tool_call(
321
+ ctx.agent_id,
322
+ tool_name=tool_name,
323
+ status="error",
324
+ duration_ms=duration_ms,
325
+ error={"type": type(exc).__name__, "message": str(exc)},
326
+ trace_id=ctx.trace_id,
327
+ span_id=ctx.span_id,
328
+ parent_span_id=ctx.parent_span_id,
329
+ )
330
+ raise
331
+
332
+ return async_wrapper # type: ignore[return-value]
333
+
334
+ @functools.wraps(fn)
335
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
336
+ with span() as ctx:
337
+ start = time.monotonic()
338
+ try:
339
+ result = fn(*args, **kwargs)
340
+ duration_ms = (time.monotonic() - start) * 1000
341
+ client = _get_global_client()
342
+ if client:
343
+ client.tool_call(
344
+ ctx.agent_id,
345
+ tool_name=tool_name,
346
+ status="success",
347
+ input=kwargs or None,
348
+ duration_ms=duration_ms,
349
+ trace_id=ctx.trace_id,
350
+ span_id=ctx.span_id,
351
+ parent_span_id=ctx.parent_span_id,
352
+ )
353
+ return result
354
+ except Exception as exc:
355
+ duration_ms = (time.monotonic() - start) * 1000
356
+ client = _get_global_client()
357
+ if client:
358
+ client.tool_call(
359
+ ctx.agent_id,
360
+ tool_name=tool_name,
361
+ status="error",
362
+ duration_ms=duration_ms,
363
+ error={"type": type(exc).__name__, "message": str(exc)},
364
+ trace_id=ctx.trace_id,
365
+ span_id=ctx.span_id,
366
+ parent_span_id=ctx.parent_span_id,
367
+ )
368
+ raise
369
+
370
+ return sync_wrapper # type: ignore[return-value]
371
+
372
+ return decorator