fluxloop 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.
Potentially problematic release.
This version of fluxloop might be problematic. Click here for more details.
- fluxloop/__init__.py +58 -0
- fluxloop/buffer.py +186 -0
- fluxloop/client.py +175 -0
- fluxloop/config.py +191 -0
- fluxloop/context.py +219 -0
- fluxloop/decorators.py +465 -0
- fluxloop/models.py +92 -0
- fluxloop/recording.py +205 -0
- fluxloop/schemas/__init__.py +48 -0
- fluxloop/schemas/config.py +312 -0
- fluxloop/schemas/trace.py +197 -0
- fluxloop/serialization.py +116 -0
- fluxloop/storage.py +53 -0
- fluxloop-0.1.0.dist-info/METADATA +76 -0
- fluxloop-0.1.0.dist-info/RECORD +17 -0
- fluxloop-0.1.0.dist-info/WHEEL +5 -0
- fluxloop-0.1.0.dist-info/top_level.txt +1 -0
fluxloop/context.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context management for tracing.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
import random
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any, Dict, List, Optional, Union
|
|
10
|
+
from uuid import UUID, uuid4
|
|
11
|
+
|
|
12
|
+
from .buffer import EventBuffer
|
|
13
|
+
from .config import get_config
|
|
14
|
+
from .models import ObservationData, TraceData
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Context variable for current FluxLoop context
|
|
18
|
+
_context_var: contextvars.ContextVar[Optional["FluxLoopContext"]] = contextvars.ContextVar(
|
|
19
|
+
"fluxloop_context", default=None
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FluxLoopContext:
|
|
24
|
+
"""
|
|
25
|
+
Context for managing trace and observation hierarchy.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
trace_name: str,
|
|
31
|
+
trace_id: Optional[UUID] = None,
|
|
32
|
+
session_id: Optional[UUID] = None,
|
|
33
|
+
user_id: Optional[str] = None,
|
|
34
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
35
|
+
tags: Optional[List[str]] = None,
|
|
36
|
+
trace_id_override: Optional[Union[UUID, str]] = None,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Initialize a new context.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
trace_name: Name for the trace
|
|
43
|
+
trace_id: Optional trace ID (generated if not provided)
|
|
44
|
+
session_id: Optional session ID for grouping traces
|
|
45
|
+
user_id: Optional user identifier
|
|
46
|
+
metadata: Additional metadata
|
|
47
|
+
tags: Tags for categorization
|
|
48
|
+
"""
|
|
49
|
+
self.config = get_config()
|
|
50
|
+
self.buffer = EventBuffer.get_instance()
|
|
51
|
+
|
|
52
|
+
# Create trace
|
|
53
|
+
trace_uuid = self._coerce_uuid(trace_id_override) or trace_id or uuid4()
|
|
54
|
+
|
|
55
|
+
self.trace = TraceData(
|
|
56
|
+
id=trace_uuid,
|
|
57
|
+
name=trace_name,
|
|
58
|
+
session_id=session_id,
|
|
59
|
+
user_id=user_id,
|
|
60
|
+
metadata=metadata or {},
|
|
61
|
+
tags=tags or [],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Observation stack for hierarchy
|
|
65
|
+
self.observation_stack: List[ObservationData] = []
|
|
66
|
+
self.observations: List[ObservationData] = []
|
|
67
|
+
|
|
68
|
+
# Sampling decision
|
|
69
|
+
self.is_sampled = random.random() < self.config.sample_rate
|
|
70
|
+
|
|
71
|
+
def is_enabled(self) -> bool:
|
|
72
|
+
"""Check if tracing is enabled and sampled."""
|
|
73
|
+
return self.config.enabled and self.is_sampled
|
|
74
|
+
|
|
75
|
+
def push_observation(self, observation: ObservationData) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Push a new observation onto the stack.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
observation: Observation to push
|
|
81
|
+
"""
|
|
82
|
+
if not self.is_enabled():
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Set trace ID
|
|
86
|
+
observation.trace_id = self.trace.id
|
|
87
|
+
|
|
88
|
+
# Set parent if there's an observation on the stack
|
|
89
|
+
if self.observation_stack:
|
|
90
|
+
parent = self.observation_stack[-1]
|
|
91
|
+
observation.parent_observation_id = parent.id
|
|
92
|
+
|
|
93
|
+
# Add to stack and list
|
|
94
|
+
self.observation_stack.append(observation)
|
|
95
|
+
self.observations.append(observation)
|
|
96
|
+
|
|
97
|
+
# Send to buffer if it's complete (has end_time)
|
|
98
|
+
if observation.end_time:
|
|
99
|
+
self.buffer.add_observation(self.trace.id, observation)
|
|
100
|
+
|
|
101
|
+
def pop_observation(self) -> Optional[ObservationData]:
|
|
102
|
+
"""
|
|
103
|
+
Pop the current observation from the stack.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The popped observation, or None if stack is empty
|
|
107
|
+
"""
|
|
108
|
+
if not self.is_enabled() or not self.observation_stack:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
observation = self.observation_stack.pop()
|
|
112
|
+
|
|
113
|
+
# If observation doesn't have end_time, set it now
|
|
114
|
+
if not observation.end_time:
|
|
115
|
+
observation.end_time = datetime.now(timezone.utc)
|
|
116
|
+
|
|
117
|
+
# Send to buffer
|
|
118
|
+
self.buffer.add_observation(self.trace.id, observation)
|
|
119
|
+
|
|
120
|
+
return observation
|
|
121
|
+
|
|
122
|
+
def add_metadata(self, key: str, value: Any) -> None:
|
|
123
|
+
"""Add metadata to the current trace."""
|
|
124
|
+
if self.is_enabled():
|
|
125
|
+
self.trace.metadata[key] = value
|
|
126
|
+
|
|
127
|
+
def add_tag(self, tag: str) -> None:
|
|
128
|
+
"""Add a tag to the current trace."""
|
|
129
|
+
if self.is_enabled() and tag not in self.trace.tags:
|
|
130
|
+
self.trace.tags.append(tag)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _coerce_uuid(value: Optional[Union[UUID, str]]) -> Optional[UUID]:
|
|
134
|
+
if value is None:
|
|
135
|
+
return None
|
|
136
|
+
if isinstance(value, UUID):
|
|
137
|
+
return value
|
|
138
|
+
try:
|
|
139
|
+
return UUID(str(value))
|
|
140
|
+
except Exception:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
def set_user(self, user_id: str) -> None:
|
|
144
|
+
"""Set the user ID for the trace."""
|
|
145
|
+
if self.is_enabled():
|
|
146
|
+
self.trace.user_id = user_id
|
|
147
|
+
|
|
148
|
+
def finalize(self) -> None:
|
|
149
|
+
"""Finalize the trace and send all data."""
|
|
150
|
+
if not self.is_enabled():
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
# Pop any remaining observations
|
|
154
|
+
while self.observation_stack:
|
|
155
|
+
self.pop_observation()
|
|
156
|
+
|
|
157
|
+
# Set trace end time
|
|
158
|
+
if not self.trace.end_time:
|
|
159
|
+
self.trace.end_time = datetime.now(timezone.utc)
|
|
160
|
+
|
|
161
|
+
# Send trace to buffer
|
|
162
|
+
self.buffer.add_trace(self.trace)
|
|
163
|
+
|
|
164
|
+
# Trigger flush if needed
|
|
165
|
+
self.buffer.flush_if_needed()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_current_context() -> Optional[FluxLoopContext]:
|
|
169
|
+
"""
|
|
170
|
+
Get the current FluxLoop context.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Current context or None if not in a context
|
|
174
|
+
"""
|
|
175
|
+
return _context_var.get()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@contextmanager
|
|
179
|
+
def instrument(
|
|
180
|
+
name: str,
|
|
181
|
+
session_id: Optional[UUID] = None,
|
|
182
|
+
user_id: Optional[str] = None,
|
|
183
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
184
|
+
tags: Optional[List[str]] = None,
|
|
185
|
+
trace_id: Optional[UUID] = None,
|
|
186
|
+
):
|
|
187
|
+
"""
|
|
188
|
+
Context manager for instrumenting code blocks.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
name: Name for the trace
|
|
192
|
+
session_id: Optional session ID
|
|
193
|
+
user_id: Optional user identifier
|
|
194
|
+
metadata: Additional metadata
|
|
195
|
+
tags: Tags for categorization
|
|
196
|
+
|
|
197
|
+
Example:
|
|
198
|
+
>>> with fluxloop.instrument("my_workflow"):
|
|
199
|
+
... result = my_agent.process(input_data)
|
|
200
|
+
"""
|
|
201
|
+
# Create new context
|
|
202
|
+
context = FluxLoopContext(
|
|
203
|
+
trace_name=name,
|
|
204
|
+
session_id=session_id,
|
|
205
|
+
user_id=user_id,
|
|
206
|
+
metadata=metadata,
|
|
207
|
+
tags=tags,
|
|
208
|
+
trace_id_override=trace_id,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Set as current context
|
|
212
|
+
token = _context_var.set(context)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
yield context
|
|
216
|
+
finally:
|
|
217
|
+
# Finalize and reset context
|
|
218
|
+
context.finalize()
|
|
219
|
+
_context_var.reset(token)
|
fluxloop/decorators.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decorators for instrumenting agent code.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import time
|
|
8
|
+
import traceback
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Callable, Dict, Optional, TypeVar, Union
|
|
11
|
+
from uuid import UUID, uuid4
|
|
12
|
+
|
|
13
|
+
from .context import get_current_context
|
|
14
|
+
from .models import ObservationData, ObservationType
|
|
15
|
+
|
|
16
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def agent(
|
|
20
|
+
name: Optional[str] = None,
|
|
21
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
22
|
+
capture_input: bool = True,
|
|
23
|
+
capture_output: bool = True,
|
|
24
|
+
) -> Callable[[F], F]:
|
|
25
|
+
"""
|
|
26
|
+
Decorator for agent entry points.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
name: Name for the agent trace (defaults to function name)
|
|
30
|
+
metadata: Additional metadata to attach
|
|
31
|
+
capture_input: Whether to capture function arguments
|
|
32
|
+
capture_output: Whether to capture return value
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
>>> @fluxloop.agent(name="ChatBot")
|
|
36
|
+
... def process_message(message: str) -> str:
|
|
37
|
+
... return f"Response to: {message}"
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def decorator(func: F) -> F:
|
|
41
|
+
agent_name = name or func.__name__
|
|
42
|
+
|
|
43
|
+
@functools.wraps(func)
|
|
44
|
+
def sync_wrapper(*args, **kwargs):
|
|
45
|
+
context = get_current_context()
|
|
46
|
+
if not context or not context.is_enabled():
|
|
47
|
+
return func(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
# Create observation
|
|
50
|
+
obs_id = uuid4()
|
|
51
|
+
start_time = datetime.now(timezone.utc)
|
|
52
|
+
|
|
53
|
+
# Capture input
|
|
54
|
+
input_data = None
|
|
55
|
+
if capture_input:
|
|
56
|
+
input_data = _serialize_arguments(func, args, kwargs)
|
|
57
|
+
|
|
58
|
+
# Create observation data
|
|
59
|
+
observation = ObservationData(
|
|
60
|
+
id=obs_id,
|
|
61
|
+
type=ObservationType.AGENT,
|
|
62
|
+
name=agent_name,
|
|
63
|
+
start_time=start_time,
|
|
64
|
+
input=input_data,
|
|
65
|
+
metadata=metadata or {},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Push to context
|
|
69
|
+
context.push_observation(observation)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
# Execute function
|
|
73
|
+
result = func(*args, **kwargs)
|
|
74
|
+
|
|
75
|
+
# Capture output
|
|
76
|
+
if capture_output:
|
|
77
|
+
observation.output = _serialize_value(result)
|
|
78
|
+
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
except Exception as e:
|
|
82
|
+
# Capture error
|
|
83
|
+
observation.error = str(e)
|
|
84
|
+
observation.metadata["error_type"] = type(e).__name__
|
|
85
|
+
observation.metadata["traceback"] = traceback.format_exc()
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
finally:
|
|
89
|
+
# Finalize observation
|
|
90
|
+
observation.end_time = datetime.now(timezone.utc)
|
|
91
|
+
context.pop_observation()
|
|
92
|
+
|
|
93
|
+
@functools.wraps(func)
|
|
94
|
+
async def async_wrapper(*args, **kwargs):
|
|
95
|
+
context = get_current_context()
|
|
96
|
+
if not context or not context.is_enabled():
|
|
97
|
+
return await func(*args, **kwargs)
|
|
98
|
+
|
|
99
|
+
# Create observation
|
|
100
|
+
obs_id = uuid4()
|
|
101
|
+
start_time = datetime.now(timezone.utc)
|
|
102
|
+
|
|
103
|
+
# Capture input
|
|
104
|
+
input_data = None
|
|
105
|
+
if capture_input:
|
|
106
|
+
input_data = _serialize_arguments(func, args, kwargs)
|
|
107
|
+
|
|
108
|
+
# Create observation data
|
|
109
|
+
observation = ObservationData(
|
|
110
|
+
id=obs_id,
|
|
111
|
+
type=ObservationType.AGENT,
|
|
112
|
+
name=agent_name,
|
|
113
|
+
start_time=start_time,
|
|
114
|
+
input=input_data,
|
|
115
|
+
metadata=metadata or {},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Push to context
|
|
119
|
+
context.push_observation(observation)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
# Execute function
|
|
123
|
+
result = await func(*args, **kwargs)
|
|
124
|
+
|
|
125
|
+
# Capture output
|
|
126
|
+
if capture_output:
|
|
127
|
+
observation.output = _serialize_value(result)
|
|
128
|
+
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
except Exception as e:
|
|
132
|
+
# Capture error
|
|
133
|
+
observation.error = str(e)
|
|
134
|
+
observation.metadata["error_type"] = type(e).__name__
|
|
135
|
+
observation.metadata["traceback"] = traceback.format_exc()
|
|
136
|
+
raise
|
|
137
|
+
|
|
138
|
+
finally:
|
|
139
|
+
# Finalize observation
|
|
140
|
+
observation.end_time = datetime.now(timezone.utc)
|
|
141
|
+
context.pop_observation()
|
|
142
|
+
|
|
143
|
+
# Return appropriate wrapper based on function type
|
|
144
|
+
if inspect.iscoroutinefunction(func):
|
|
145
|
+
return async_wrapper
|
|
146
|
+
else:
|
|
147
|
+
return sync_wrapper
|
|
148
|
+
|
|
149
|
+
return decorator
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def prompt(
|
|
153
|
+
name: Optional[str] = None,
|
|
154
|
+
model: Optional[str] = None,
|
|
155
|
+
capture_tokens: bool = True,
|
|
156
|
+
) -> Callable[[F], F]:
|
|
157
|
+
"""
|
|
158
|
+
Decorator for prompt/LLM generation functions.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
name: Name for the generation (defaults to function name)
|
|
162
|
+
model: Model name being used
|
|
163
|
+
capture_tokens: Whether to try to capture token usage
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
>>> @fluxloop.prompt(model="gpt-3.5-turbo")
|
|
167
|
+
... def generate_response(prompt: str) -> str:
|
|
168
|
+
... return llm.generate(prompt)
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def decorator(func: F) -> F:
|
|
172
|
+
prompt_name = name or func.__name__
|
|
173
|
+
|
|
174
|
+
@functools.wraps(func)
|
|
175
|
+
def sync_wrapper(*args, **kwargs):
|
|
176
|
+
context = get_current_context()
|
|
177
|
+
if not context or not context.is_enabled():
|
|
178
|
+
return func(*args, **kwargs)
|
|
179
|
+
|
|
180
|
+
# Create observation
|
|
181
|
+
obs_id = uuid4()
|
|
182
|
+
start_time = datetime.now(timezone.utc)
|
|
183
|
+
|
|
184
|
+
# Capture input
|
|
185
|
+
input_data = _serialize_arguments(func, args, kwargs)
|
|
186
|
+
|
|
187
|
+
# Create observation data
|
|
188
|
+
observation = ObservationData(
|
|
189
|
+
id=obs_id,
|
|
190
|
+
type=ObservationType.GENERATION,
|
|
191
|
+
name=prompt_name,
|
|
192
|
+
start_time=start_time,
|
|
193
|
+
input=input_data,
|
|
194
|
+
model=model,
|
|
195
|
+
metadata={},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Push to context
|
|
199
|
+
context.push_observation(observation)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
# Execute function
|
|
203
|
+
result = func(*args, **kwargs)
|
|
204
|
+
|
|
205
|
+
# Capture output
|
|
206
|
+
observation.output = _serialize_value(result)
|
|
207
|
+
|
|
208
|
+
# Try to extract token usage if result is a dict-like object
|
|
209
|
+
if capture_tokens and hasattr(result, "get"):
|
|
210
|
+
if "usage" in result:
|
|
211
|
+
usage = result["usage"]
|
|
212
|
+
observation.prompt_tokens = usage.get("prompt_tokens")
|
|
213
|
+
observation.completion_tokens = usage.get("completion_tokens")
|
|
214
|
+
observation.total_tokens = usage.get("total_tokens")
|
|
215
|
+
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
except Exception as e:
|
|
219
|
+
# Capture error
|
|
220
|
+
observation.error = str(e)
|
|
221
|
+
observation.metadata["error_type"] = type(e).__name__
|
|
222
|
+
observation.metadata["traceback"] = traceback.format_exc()
|
|
223
|
+
raise
|
|
224
|
+
|
|
225
|
+
finally:
|
|
226
|
+
# Finalize observation
|
|
227
|
+
observation.end_time = datetime.now(timezone.utc)
|
|
228
|
+
context.pop_observation()
|
|
229
|
+
|
|
230
|
+
@functools.wraps(func)
|
|
231
|
+
async def async_wrapper(*args, **kwargs):
|
|
232
|
+
context = get_current_context()
|
|
233
|
+
if not context or not context.is_enabled():
|
|
234
|
+
return await func(*args, **kwargs)
|
|
235
|
+
|
|
236
|
+
# Create observation
|
|
237
|
+
obs_id = uuid4()
|
|
238
|
+
start_time = datetime.now(timezone.utc)
|
|
239
|
+
|
|
240
|
+
# Capture input
|
|
241
|
+
input_data = _serialize_arguments(func, args, kwargs)
|
|
242
|
+
|
|
243
|
+
# Create observation data
|
|
244
|
+
observation = ObservationData(
|
|
245
|
+
id=obs_id,
|
|
246
|
+
type=ObservationType.GENERATION,
|
|
247
|
+
name=prompt_name,
|
|
248
|
+
start_time=start_time,
|
|
249
|
+
input=input_data,
|
|
250
|
+
model=model,
|
|
251
|
+
metadata={},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Push to context
|
|
255
|
+
context.push_observation(observation)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
# Execute function
|
|
259
|
+
result = await func(*args, **kwargs)
|
|
260
|
+
|
|
261
|
+
# Capture output
|
|
262
|
+
observation.output = _serialize_value(result)
|
|
263
|
+
|
|
264
|
+
# Try to extract token usage if result is a dict-like object
|
|
265
|
+
if capture_tokens and hasattr(result, "get"):
|
|
266
|
+
if "usage" in result:
|
|
267
|
+
usage = result["usage"]
|
|
268
|
+
observation.prompt_tokens = usage.get("prompt_tokens")
|
|
269
|
+
observation.completion_tokens = usage.get("completion_tokens")
|
|
270
|
+
observation.total_tokens = usage.get("total_tokens")
|
|
271
|
+
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
# Capture error
|
|
276
|
+
observation.error = str(e)
|
|
277
|
+
observation.metadata["error_type"] = type(e).__name__
|
|
278
|
+
observation.metadata["traceback"] = traceback.format_exc()
|
|
279
|
+
raise
|
|
280
|
+
|
|
281
|
+
finally:
|
|
282
|
+
# Finalize observation
|
|
283
|
+
observation.end_time = datetime.now(timezone.utc)
|
|
284
|
+
context.pop_observation()
|
|
285
|
+
|
|
286
|
+
# Return appropriate wrapper based on function type
|
|
287
|
+
if inspect.iscoroutinefunction(func):
|
|
288
|
+
return async_wrapper
|
|
289
|
+
else:
|
|
290
|
+
return sync_wrapper
|
|
291
|
+
|
|
292
|
+
return decorator
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def tool(
|
|
296
|
+
name: Optional[str] = None,
|
|
297
|
+
description: Optional[str] = None,
|
|
298
|
+
) -> Callable[[F], F]:
|
|
299
|
+
"""
|
|
300
|
+
Decorator for tool/function calls.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
name: Name for the tool (defaults to function name)
|
|
304
|
+
description: Description of what the tool does
|
|
305
|
+
|
|
306
|
+
Example:
|
|
307
|
+
>>> @fluxloop.tool(description="Search the web")
|
|
308
|
+
... def web_search(query: str) -> List[str]:
|
|
309
|
+
... return search_engine.search(query)
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
def decorator(func: F) -> F:
|
|
313
|
+
tool_name = name or func.__name__
|
|
314
|
+
|
|
315
|
+
@functools.wraps(func)
|
|
316
|
+
def sync_wrapper(*args, **kwargs):
|
|
317
|
+
context = get_current_context()
|
|
318
|
+
if not context or not context.is_enabled():
|
|
319
|
+
return func(*args, **kwargs)
|
|
320
|
+
|
|
321
|
+
# Create observation
|
|
322
|
+
obs_id = uuid4()
|
|
323
|
+
start_time = datetime.now(timezone.utc)
|
|
324
|
+
|
|
325
|
+
# Capture input
|
|
326
|
+
input_data = _serialize_arguments(func, args, kwargs)
|
|
327
|
+
|
|
328
|
+
# Create observation data
|
|
329
|
+
observation = ObservationData(
|
|
330
|
+
id=obs_id,
|
|
331
|
+
type=ObservationType.TOOL,
|
|
332
|
+
name=tool_name,
|
|
333
|
+
start_time=start_time,
|
|
334
|
+
input=input_data,
|
|
335
|
+
metadata={"description": description} if description else {},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Push to context
|
|
339
|
+
context.push_observation(observation)
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
# Execute function
|
|
343
|
+
result = func(*args, **kwargs)
|
|
344
|
+
|
|
345
|
+
# Capture output
|
|
346
|
+
observation.output = _serialize_value(result)
|
|
347
|
+
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
except Exception as e:
|
|
351
|
+
# Capture error
|
|
352
|
+
observation.error = str(e)
|
|
353
|
+
observation.metadata["error_type"] = type(e).__name__
|
|
354
|
+
observation.metadata["traceback"] = traceback.format_exc()
|
|
355
|
+
raise
|
|
356
|
+
|
|
357
|
+
finally:
|
|
358
|
+
# Finalize observation
|
|
359
|
+
observation.end_time = datetime.now(timezone.utc)
|
|
360
|
+
context.pop_observation()
|
|
361
|
+
|
|
362
|
+
@functools.wraps(func)
|
|
363
|
+
async def async_wrapper(*args, **kwargs):
|
|
364
|
+
context = get_current_context()
|
|
365
|
+
if not context or not context.is_enabled():
|
|
366
|
+
return await func(*args, **kwargs)
|
|
367
|
+
|
|
368
|
+
# Create observation
|
|
369
|
+
obs_id = uuid4()
|
|
370
|
+
start_time = datetime.now(timezone.utc)
|
|
371
|
+
|
|
372
|
+
# Capture input
|
|
373
|
+
input_data = _serialize_arguments(func, args, kwargs)
|
|
374
|
+
|
|
375
|
+
# Create observation data
|
|
376
|
+
observation = ObservationData(
|
|
377
|
+
id=obs_id,
|
|
378
|
+
type=ObservationType.TOOL,
|
|
379
|
+
name=tool_name,
|
|
380
|
+
start_time=start_time,
|
|
381
|
+
input=input_data,
|
|
382
|
+
metadata={"description": description} if description else {},
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Push to context
|
|
386
|
+
context.push_observation(observation)
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
# Execute function
|
|
390
|
+
result = await func(*args, **kwargs)
|
|
391
|
+
|
|
392
|
+
# Capture output
|
|
393
|
+
observation.output = _serialize_value(result)
|
|
394
|
+
|
|
395
|
+
return result
|
|
396
|
+
|
|
397
|
+
except Exception as e:
|
|
398
|
+
# Capture error
|
|
399
|
+
observation.error = str(e)
|
|
400
|
+
observation.metadata["error_type"] = type(e).__name__
|
|
401
|
+
observation.metadata["traceback"] = traceback.format_exc()
|
|
402
|
+
raise
|
|
403
|
+
|
|
404
|
+
finally:
|
|
405
|
+
# Finalize observation
|
|
406
|
+
observation.end_time = datetime.now(timezone.utc)
|
|
407
|
+
context.pop_observation()
|
|
408
|
+
|
|
409
|
+
# Return appropriate wrapper based on function type
|
|
410
|
+
if inspect.iscoroutinefunction(func):
|
|
411
|
+
return async_wrapper
|
|
412
|
+
else:
|
|
413
|
+
return sync_wrapper
|
|
414
|
+
|
|
415
|
+
return decorator
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _serialize_arguments(func: Callable, args: tuple, kwargs: dict) -> Dict[str, Any]:
|
|
419
|
+
"""Serialize function arguments for storage."""
|
|
420
|
+
sig = inspect.signature(func)
|
|
421
|
+
bound = sig.bind(*args, **kwargs)
|
|
422
|
+
bound.apply_defaults()
|
|
423
|
+
|
|
424
|
+
serialized = {}
|
|
425
|
+
for param_name, param_value in bound.arguments.items():
|
|
426
|
+
serialized[param_name] = _serialize_value(param_value)
|
|
427
|
+
|
|
428
|
+
return serialized
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _serialize_value(value: Any) -> Any:
|
|
432
|
+
"""Serialize a value for storage."""
|
|
433
|
+
# Handle common types
|
|
434
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
435
|
+
return value
|
|
436
|
+
|
|
437
|
+
# Handle UUIDs
|
|
438
|
+
if isinstance(value, UUID):
|
|
439
|
+
return str(value)
|
|
440
|
+
|
|
441
|
+
# Handle datetime
|
|
442
|
+
if isinstance(value, datetime):
|
|
443
|
+
return value.isoformat()
|
|
444
|
+
|
|
445
|
+
# Handle lists/tuples
|
|
446
|
+
if isinstance(value, (list, tuple)):
|
|
447
|
+
return [_serialize_value(v) for v in value]
|
|
448
|
+
|
|
449
|
+
# Handle dicts
|
|
450
|
+
if isinstance(value, dict):
|
|
451
|
+
return {k: _serialize_value(v) for k, v in value.items()}
|
|
452
|
+
|
|
453
|
+
# Handle objects with dict representation
|
|
454
|
+
if hasattr(value, "dict"):
|
|
455
|
+
return value.dict()
|
|
456
|
+
|
|
457
|
+
# Handle objects with model_dump (Pydantic v2)
|
|
458
|
+
if hasattr(value, "model_dump"):
|
|
459
|
+
return value.model_dump()
|
|
460
|
+
|
|
461
|
+
# Fallback to string representation
|
|
462
|
+
try:
|
|
463
|
+
return str(value)
|
|
464
|
+
except:
|
|
465
|
+
return f"<{type(value).__name__}>"
|