jaf-py 2.5.1__py3-none-any.whl → 2.5.3__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.
- jaf/__init__.py +1 -1
- jaf/core/__init__.py +6 -0
- jaf/core/handoff.py +191 -0
- jaf/core/state.py +117 -6
- jaf/core/tracing.py +371 -73
- {jaf_py-2.5.1.dist-info → jaf_py-2.5.3.dist-info}/METADATA +2 -2
- {jaf_py-2.5.1.dist-info → jaf_py-2.5.3.dist-info}/RECORD +11 -10
- {jaf_py-2.5.1.dist-info → jaf_py-2.5.3.dist-info}/WHEEL +0 -0
- {jaf_py-2.5.1.dist-info → jaf_py-2.5.3.dist-info}/entry_points.txt +0 -0
- {jaf_py-2.5.1.dist-info → jaf_py-2.5.3.dist-info}/licenses/LICENSE +0 -0
- {jaf_py-2.5.1.dist-info → jaf_py-2.5.3.dist-info}/top_level.txt +0 -0
jaf/__init__.py
CHANGED
|
@@ -191,7 +191,7 @@ def generate_run_id() -> RunId:
|
|
|
191
191
|
"""Generate a new run ID."""
|
|
192
192
|
return create_run_id(str(uuid.uuid4()))
|
|
193
193
|
|
|
194
|
-
__version__ = "2.
|
|
194
|
+
__version__ = "2.5.3"
|
|
195
195
|
__all__ = [
|
|
196
196
|
# Core types and functions
|
|
197
197
|
"TraceId", "RunId", "ValidationResult", "Message", "ModelConfig",
|
jaf/core/__init__.py
CHANGED
|
@@ -22,6 +22,7 @@ from .parallel_agents import (
|
|
|
22
22
|
create_domain_experts_tool,
|
|
23
23
|
)
|
|
24
24
|
from .proxy import ProxyConfig, ProxyAuth, create_proxy_config, get_default_proxy_config
|
|
25
|
+
from .handoff import handoff_tool, handoff, create_handoff_tool, is_handoff_request, extract_handoff_target
|
|
25
26
|
|
|
26
27
|
__all__ = [
|
|
27
28
|
"Agent",
|
|
@@ -52,6 +53,7 @@ __all__ = [
|
|
|
52
53
|
"create_conditional_enabler",
|
|
53
54
|
"create_default_output_extractor",
|
|
54
55
|
"create_domain_experts_tool",
|
|
56
|
+
"create_handoff_tool",
|
|
55
57
|
"create_json_output_extractor",
|
|
56
58
|
"create_language_specialists_tool",
|
|
57
59
|
"create_parallel_agents_tool",
|
|
@@ -59,8 +61,12 @@ __all__ = [
|
|
|
59
61
|
"create_run_id",
|
|
60
62
|
"create_simple_parallel_tool",
|
|
61
63
|
"create_trace_id",
|
|
64
|
+
"extract_handoff_target",
|
|
62
65
|
"get_current_run_config",
|
|
63
66
|
"get_default_proxy_config",
|
|
67
|
+
"handoff",
|
|
68
|
+
"handoff_tool",
|
|
69
|
+
"is_handoff_request",
|
|
64
70
|
"require_permissions",
|
|
65
71
|
"run",
|
|
66
72
|
"set_current_run_config",
|
jaf/core/handoff.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Handoff system for JAF framework.
|
|
3
|
+
|
|
4
|
+
This module provides a simple, elegant handoff mechanism that allows agents
|
|
5
|
+
to seamlessly transfer control to other agents with clean state management.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, Optional, TypeVar
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .types import Tool, ToolSchema, ToolSource
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
except ImportError:
|
|
17
|
+
BaseModel = None
|
|
18
|
+
Field = None
|
|
19
|
+
|
|
20
|
+
Ctx = TypeVar('Ctx')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _create_handoff_json(agent_name: str, message: str = "") -> str:
|
|
24
|
+
"""Create the JSON structure for handoff requests."""
|
|
25
|
+
return json.dumps({
|
|
26
|
+
"handoff_to": agent_name,
|
|
27
|
+
"message": message or f"Handing off to {agent_name}",
|
|
28
|
+
"type": "handoff"
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if BaseModel is not None and Field is not None:
|
|
33
|
+
class _HandoffInput(BaseModel):
|
|
34
|
+
"""Input parameters for handoff tool (Pydantic model)."""
|
|
35
|
+
agent_name: str = Field(description="Name of the agent to hand off to")
|
|
36
|
+
message: str = Field(description="Message or context to pass to the target agent")
|
|
37
|
+
else:
|
|
38
|
+
class _HandoffInput(object):
|
|
39
|
+
"""Plain-Python fallback for handoff input when Pydantic is unavailable.
|
|
40
|
+
|
|
41
|
+
This class intentionally does not call Field() so it is safe to import
|
|
42
|
+
when Pydantic is not installed.
|
|
43
|
+
"""
|
|
44
|
+
agent_name: str
|
|
45
|
+
message: str
|
|
46
|
+
|
|
47
|
+
def __init__(self, agent_name: str, message: str = ""):
|
|
48
|
+
self.agent_name = agent_name
|
|
49
|
+
self.message = message
|
|
50
|
+
|
|
51
|
+
HandoffInput = _HandoffInput
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class HandoffResult:
|
|
56
|
+
"""Result of a handoff operation."""
|
|
57
|
+
target_agent: str
|
|
58
|
+
message: str
|
|
59
|
+
success: bool = True
|
|
60
|
+
error: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class HandoffTool:
|
|
64
|
+
"""A tool that enables agents to hand off to other agents."""
|
|
65
|
+
|
|
66
|
+
def __init__(self):
|
|
67
|
+
# Create schema
|
|
68
|
+
if BaseModel:
|
|
69
|
+
parameters_model = HandoffInput
|
|
70
|
+
else:
|
|
71
|
+
# Fallback schema when Pydantic is not available
|
|
72
|
+
parameters_model = {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"properties": {
|
|
75
|
+
"agent_name": {
|
|
76
|
+
"type": "string",
|
|
77
|
+
"description": "Name of the agent to hand off to"
|
|
78
|
+
},
|
|
79
|
+
"message": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"description": "Message or context to pass to the target agent"
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"required": ["agent_name", "message"]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
self.schema = ToolSchema(
|
|
88
|
+
name="handoff",
|
|
89
|
+
description="Hand off the conversation to another agent",
|
|
90
|
+
parameters=parameters_model
|
|
91
|
+
)
|
|
92
|
+
self.source = ToolSource.NATIVE
|
|
93
|
+
self.metadata = {"type": "handoff", "system": True}
|
|
94
|
+
|
|
95
|
+
async def execute(self, args: HandoffInput, context: Any) -> str:
|
|
96
|
+
"""
|
|
97
|
+
Execute the handoff.
|
|
98
|
+
|
|
99
|
+
Parameters:
|
|
100
|
+
args (HandoffInput): The handoff input arguments.
|
|
101
|
+
context (Any): Context containing current agent and run state information.
|
|
102
|
+
"""
|
|
103
|
+
# Extract arguments
|
|
104
|
+
if hasattr(args, 'agent_name'):
|
|
105
|
+
agent_name = args.agent_name
|
|
106
|
+
message = args.message
|
|
107
|
+
elif isinstance(args, dict):
|
|
108
|
+
agent_name = args.get('agent_name', '')
|
|
109
|
+
message = args.get('message', '')
|
|
110
|
+
else:
|
|
111
|
+
return json.dumps({
|
|
112
|
+
"error": "invalid_handoff_args",
|
|
113
|
+
"message": "Invalid handoff arguments provided",
|
|
114
|
+
"usage": "handoff(agent_name='target_agent', message='optional context')"
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
if not agent_name:
|
|
118
|
+
return json.dumps({
|
|
119
|
+
"error": "missing_agent_name",
|
|
120
|
+
"message": "Agent name is required for handoff",
|
|
121
|
+
"usage": "handoff(agent_name='target_agent', message='optional context')"
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
# Add agent validation if we have access to current agent info
|
|
125
|
+
if context and hasattr(context, 'current_agent'):
|
|
126
|
+
current_agent = context.current_agent
|
|
127
|
+
if current_agent.handoffs and agent_name not in current_agent.handoffs:
|
|
128
|
+
return json.dumps({
|
|
129
|
+
"error": "handoff_not_allowed",
|
|
130
|
+
"message": f"Agent {current_agent.name} cannot handoff to {agent_name}",
|
|
131
|
+
"allowed_handoffs": current_agent.handoffs
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
# Return the special handoff JSON that the engine recognizes
|
|
135
|
+
return _create_handoff_json(agent_name, message)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def create_handoff_tool() -> Tool:
|
|
139
|
+
"""Create a handoff tool that can be added to any agent."""
|
|
140
|
+
return HandoffTool()
|
|
141
|
+
|
|
142
|
+
handoff_tool = create_handoff_tool()
|
|
143
|
+
|
|
144
|
+
def handoff(agent_name: str, message: str = "") -> str:
|
|
145
|
+
"""
|
|
146
|
+
Simple function to perform a handoff (for use in agent tools).
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
agent_name: Name of the agent to hand off to
|
|
150
|
+
message: Optional message to pass to the target agent
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
JSON string that triggers a handoff
|
|
154
|
+
"""
|
|
155
|
+
return _create_handoff_json(agent_name, message)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def is_handoff_request(result: str) -> bool:
|
|
159
|
+
"""
|
|
160
|
+
Check if a tool result is a handoff request.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
result: Tool execution result
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
True if the result is a handoff request
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
parsed = json.loads(result)
|
|
170
|
+
return isinstance(parsed, dict) and "handoff_to" in parsed
|
|
171
|
+
except (json.JSONDecodeError, TypeError):
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def extract_handoff_target(result: str) -> Optional[str]:
|
|
176
|
+
"""
|
|
177
|
+
Extract the target agent name from a handoff result.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
result: Tool execution result
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Target agent name if it's a handoff, None otherwise
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
parsed = json.loads(result)
|
|
187
|
+
if isinstance(parsed, dict) and "handoff_to" in parsed:
|
|
188
|
+
return parsed["handoff_to"]
|
|
189
|
+
except (json.JSONDecodeError, TypeError):
|
|
190
|
+
pass
|
|
191
|
+
return None
|
jaf/core/state.py
CHANGED
|
@@ -5,10 +5,113 @@ This module provides functions to manage approval state transitions
|
|
|
5
5
|
and integrate with approval storage systems.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from typing import Dict, Any, Optional
|
|
8
|
+
from typing import Dict, Any, Optional, List
|
|
9
9
|
from dataclasses import replace
|
|
10
10
|
|
|
11
|
-
from .types import RunState, RunConfig, Interruption, ApprovalValue
|
|
11
|
+
from .types import RunState, RunConfig, Interruption, ApprovalValue, Message, ContentRole, Attachment
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _extract_attachments_from_messages(messages: List[Dict[str, Any]]) -> List[Attachment]:
|
|
15
|
+
"""Extract attachment objects from message data."""
|
|
16
|
+
attachments = []
|
|
17
|
+
|
|
18
|
+
for msg in messages:
|
|
19
|
+
msg_attachments = msg.get('attachments', [])
|
|
20
|
+
for att in msg_attachments:
|
|
21
|
+
try:
|
|
22
|
+
# Convert dict to Attachment object
|
|
23
|
+
attachment = Attachment(
|
|
24
|
+
kind=att.get('kind', 'image'),
|
|
25
|
+
mime_type=att.get('mime_type'),
|
|
26
|
+
name=att.get('name'),
|
|
27
|
+
url=att.get('url'),
|
|
28
|
+
data=att.get('data'),
|
|
29
|
+
format=att.get('format'),
|
|
30
|
+
use_litellm_format=att.get('use_litellm_format')
|
|
31
|
+
)
|
|
32
|
+
attachments.append(attachment)
|
|
33
|
+
except Exception as e:
|
|
34
|
+
print(f"[JAF:APPROVAL] Failed to process attachment: {e}")
|
|
35
|
+
|
|
36
|
+
return attachments
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _process_additional_context_images(additional_context: Optional[Dict[str, Any]]) -> List[Attachment]:
|
|
40
|
+
"""Process additional context and extract any image attachments."""
|
|
41
|
+
if not additional_context:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
attachments = []
|
|
45
|
+
|
|
46
|
+
# Handle messages with attachments
|
|
47
|
+
messages = additional_context.get('messages', [])
|
|
48
|
+
if messages:
|
|
49
|
+
attachments.extend(_extract_attachments_from_messages(messages))
|
|
50
|
+
|
|
51
|
+
# Handle legacy image_context format
|
|
52
|
+
image_context = additional_context.get('image_context')
|
|
53
|
+
if image_context and image_context.get('type') == 'image_url':
|
|
54
|
+
try:
|
|
55
|
+
image_url = image_context.get('image_url', {})
|
|
56
|
+
url = image_url.get('url', '')
|
|
57
|
+
|
|
58
|
+
if url.startswith('data:'):
|
|
59
|
+
# Parse data URL: data:image/png;base64,iVBORw0KGgo...
|
|
60
|
+
header, data = url.split(',', 1)
|
|
61
|
+
mime_type = header.split(':')[1].split(';')[0]
|
|
62
|
+
|
|
63
|
+
attachment = Attachment(
|
|
64
|
+
kind='image',
|
|
65
|
+
mime_type=mime_type,
|
|
66
|
+
data=data,
|
|
67
|
+
name=f"approval_image.{mime_type.split('/')[-1]}"
|
|
68
|
+
)
|
|
69
|
+
attachments.append(attachment)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
print(f"[JAF:APPROVAL] Failed to process image_context: {e}")
|
|
72
|
+
|
|
73
|
+
return attachments
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _add_approval_context_to_conversation(
|
|
77
|
+
state: RunState[Any],
|
|
78
|
+
additional_context: Optional[Dict[str, Any]]
|
|
79
|
+
) -> RunState[Any]:
|
|
80
|
+
"""Add approval context including images to the conversation."""
|
|
81
|
+
if not additional_context:
|
|
82
|
+
return state
|
|
83
|
+
|
|
84
|
+
# Extract image attachments
|
|
85
|
+
attachments = _process_additional_context_images(additional_context)
|
|
86
|
+
|
|
87
|
+
if not attachments:
|
|
88
|
+
return state
|
|
89
|
+
|
|
90
|
+
# Create approval context message
|
|
91
|
+
approval_message = "Additional context provided during approval process."
|
|
92
|
+
|
|
93
|
+
# Check if there are text messages to include
|
|
94
|
+
messages = additional_context.get('messages', [])
|
|
95
|
+
if messages:
|
|
96
|
+
text_content = []
|
|
97
|
+
for msg in messages:
|
|
98
|
+
content = msg.get('content', '')
|
|
99
|
+
if content:
|
|
100
|
+
text_content.append(content)
|
|
101
|
+
|
|
102
|
+
if text_content:
|
|
103
|
+
approval_message = f"User provided additional context: {' '.join(text_content)}"
|
|
104
|
+
|
|
105
|
+
# Create user message with attachments (using USER role for better compatibility)
|
|
106
|
+
context_message = Message(
|
|
107
|
+
role=ContentRole.USER,
|
|
108
|
+
content=approval_message,
|
|
109
|
+
attachments=attachments
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Add to conversation
|
|
113
|
+
new_messages = state.messages + [context_message]
|
|
114
|
+
return replace(state, messages=new_messages)
|
|
12
115
|
|
|
13
116
|
|
|
14
117
|
async def approve(
|
|
@@ -60,8 +163,12 @@ async def approve(
|
|
|
60
163
|
# Update in-memory state
|
|
61
164
|
new_approvals = {**state.approvals}
|
|
62
165
|
new_approvals[interruption.tool_call.id] = approval_value
|
|
63
|
-
|
|
64
|
-
|
|
166
|
+
|
|
167
|
+
# Process any image context and add to conversation
|
|
168
|
+
updated_state = replace(state, approvals=new_approvals)
|
|
169
|
+
updated_state = _add_approval_context_to_conversation(updated_state, additional_context)
|
|
170
|
+
|
|
171
|
+
return updated_state
|
|
65
172
|
|
|
66
173
|
return state
|
|
67
174
|
|
|
@@ -115,8 +222,12 @@ async def reject(
|
|
|
115
222
|
# Update in-memory state
|
|
116
223
|
new_approvals = {**state.approvals}
|
|
117
224
|
new_approvals[interruption.tool_call.id] = approval_value
|
|
118
|
-
|
|
119
|
-
|
|
225
|
+
|
|
226
|
+
# Process any image context and add to conversation
|
|
227
|
+
updated_state = replace(state, approvals=new_approvals)
|
|
228
|
+
updated_state = _add_approval_context_to_conversation(updated_state, additional_context)
|
|
229
|
+
|
|
230
|
+
return updated_state
|
|
120
231
|
|
|
121
232
|
return state
|
|
122
233
|
|
jaf/core/tracing.py
CHANGED
|
@@ -7,6 +7,7 @@ tool calls, and performance metrics.
|
|
|
7
7
|
import os
|
|
8
8
|
os.environ["LANGFUSE_ENABLE_OTEL"] = "false"
|
|
9
9
|
import json
|
|
10
|
+
import logging
|
|
10
11
|
import time
|
|
11
12
|
from datetime import datetime
|
|
12
13
|
from typing import Any, Dict, List, Optional, Protocol
|
|
@@ -18,6 +19,12 @@ from opentelemetry.sdk.resources import Resource
|
|
|
18
19
|
from opentelemetry.sdk.trace import TracerProvider
|
|
19
20
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
20
21
|
from langfuse import Langfuse
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import requests
|
|
26
|
+
except ImportError:
|
|
27
|
+
requests = None # type: ignore
|
|
21
28
|
|
|
22
29
|
from .types import TraceEvent, TraceId
|
|
23
30
|
|
|
@@ -25,8 +32,25 @@ from .types import TraceEvent, TraceId
|
|
|
25
32
|
provider = None
|
|
26
33
|
tracer = None
|
|
27
34
|
|
|
28
|
-
def setup_otel_tracing(
|
|
29
|
-
|
|
35
|
+
def setup_otel_tracing(
|
|
36
|
+
service_name: str = "jaf-agent",
|
|
37
|
+
collector_url: Optional[str] = None,
|
|
38
|
+
proxy: Optional[str] = None,
|
|
39
|
+
session: Optional[Any] = None,
|
|
40
|
+
timeout: Optional[int] = None
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Configure OpenTelemetry tracing.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
service_name: Name of the service for tracing.
|
|
46
|
+
collector_url: OTLP collector endpoint URL.
|
|
47
|
+
proxy: Optional proxy URL (e.g., "http://proxy.example.com:8080").
|
|
48
|
+
Falls back to OTEL_PROXY environment variable.
|
|
49
|
+
If not provided, respects standard HTTP_PROXY/HTTPS_PROXY env vars.
|
|
50
|
+
session: Optional custom requests.Session for advanced configuration.
|
|
51
|
+
If provided, proxy parameter is ignored.
|
|
52
|
+
timeout: Optional timeout in seconds for OTLP requests.
|
|
53
|
+
"""
|
|
30
54
|
global provider, tracer
|
|
31
55
|
if not collector_url:
|
|
32
56
|
return
|
|
@@ -34,8 +58,38 @@ def setup_otel_tracing(service_name: str = "jaf-agent", collector_url: Optional[
|
|
|
34
58
|
provider = TracerProvider(
|
|
35
59
|
resource=Resource.create({"service.name": service_name})
|
|
36
60
|
)
|
|
37
|
-
|
|
38
|
-
|
|
61
|
+
|
|
62
|
+
# Configure session with proxy if needed
|
|
63
|
+
effective_session = session
|
|
64
|
+
# Configure session with proxy if needed
|
|
65
|
+
effective_session = session
|
|
66
|
+
if effective_session is None and requests is not None:
|
|
67
|
+
effective_proxy = proxy or os.environ.get("OTEL_PROXY")
|
|
68
|
+
if effective_proxy:
|
|
69
|
+
effective_session = requests.Session()
|
|
70
|
+
effective_session.proxies = {
|
|
71
|
+
'http': effective_proxy,
|
|
72
|
+
'https': effective_proxy,
|
|
73
|
+
}
|
|
74
|
+
print(f"[OTEL] Configuring proxy: {effective_proxy}")
|
|
75
|
+
elif effective_session is None and requests is None and (proxy or os.environ.get("OTEL_PROXY")):
|
|
76
|
+
print(f"[OTEL] Warning: Proxy configuration ignored - 'requests' package not installed")
|
|
77
|
+
if effective_proxy:
|
|
78
|
+
effective_session = requests.Session()
|
|
79
|
+
effective_session.proxies = {
|
|
80
|
+
'http': effective_proxy,
|
|
81
|
+
'https': effective_proxy,
|
|
82
|
+
}
|
|
83
|
+
print(f"[OTEL] Configuring proxy: {effective_proxy}")
|
|
84
|
+
|
|
85
|
+
# Create exporter with optional session and timeout
|
|
86
|
+
exporter_kwargs = {"endpoint": collector_url}
|
|
87
|
+
if effective_session is not None:
|
|
88
|
+
exporter_kwargs["session"] = effective_session
|
|
89
|
+
if timeout is not None:
|
|
90
|
+
exporter_kwargs["timeout"] = timeout
|
|
91
|
+
|
|
92
|
+
exporter = OTLPSpanExporter(**exporter_kwargs)
|
|
39
93
|
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
40
94
|
trace.set_tracer_provider(provider)
|
|
41
95
|
tracer = trace.get_tracer(__name__)
|
|
@@ -326,29 +380,206 @@ class FileTraceCollector:
|
|
|
326
380
|
self.in_memory.clear(trace_id)
|
|
327
381
|
|
|
328
382
|
class LangfuseTraceCollector:
|
|
329
|
-
"""Langfuse trace collector using v2 SDK.
|
|
383
|
+
"""Langfuse trace collector using v2 SDK.
|
|
330
384
|
|
|
331
|
-
|
|
385
|
+
Supports proxy configuration through:
|
|
386
|
+
1. Custom httpx.Client via httpx_client parameter
|
|
387
|
+
2. Proxy URL via proxy parameter or LANGFUSE_PROXY environment variable
|
|
388
|
+
3. Standard HTTP_PROXY/HTTPS_PROXY environment variables (httpx respects these automatically)
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
def __init__(
|
|
392
|
+
self,
|
|
393
|
+
httpx_client: Optional[httpx.Client] = None,
|
|
394
|
+
proxy: Optional[str] = None,
|
|
395
|
+
timeout: Optional[int] = None
|
|
396
|
+
):
|
|
397
|
+
"""Initialize Langfuse trace collector.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
httpx_client: Optional custom httpx.Client with proxy configuration.
|
|
401
|
+
If provided, this will be used for all API calls.
|
|
402
|
+
proxy: Optional proxy URL (e.g., "http://my.proxy.example.com:8080").
|
|
403
|
+
Only used if httpx_client is not provided.
|
|
404
|
+
Falls back to LANGFUSE_PROXY environment variable.
|
|
405
|
+
timeout: Optional timeout in seconds for HTTP requests. Defaults to 10.
|
|
406
|
+
"""
|
|
332
407
|
public_key = os.environ.get("LANGFUSE_PUBLIC_KEY")
|
|
333
408
|
secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
|
|
334
409
|
host = os.environ.get("LANGFUSE_HOST")
|
|
335
|
-
|
|
410
|
+
|
|
336
411
|
print(f"[LANGFUSE] Initializing with host: {host}")
|
|
337
412
|
print(f"[LANGFUSE] Public key: {public_key[:10]}..." if public_key else "[LANGFUSE] No public key set")
|
|
338
413
|
print(f"[LANGFUSE] Secret key: {secret_key[:10]}..." if secret_key else "[LANGFUSE] No secret key set")
|
|
339
|
-
|
|
414
|
+
|
|
415
|
+
# Track if we own the client for cleanup
|
|
416
|
+
self._owns_httpx_client = False
|
|
417
|
+
|
|
418
|
+
# Configure httpx client with proxy if needed
|
|
419
|
+
client = httpx_client
|
|
420
|
+
if client is None:
|
|
421
|
+
# Use provided proxy, environment variable, or None
|
|
422
|
+
effective_proxy = proxy or os.environ.get("LANGFUSE_PROXY")
|
|
423
|
+
effective_timeout = timeout or int(os.environ.get("LANGFUSE_TIMEOUT", "10"))
|
|
424
|
+
|
|
425
|
+
if effective_proxy:
|
|
426
|
+
print(f"[LANGFUSE] Configuring proxy: {effective_proxy}")
|
|
427
|
+
try:
|
|
428
|
+
client = httpx.Client(proxy=effective_proxy, timeout=effective_timeout)
|
|
429
|
+
self._owns_httpx_client = True
|
|
430
|
+
except httpx.InvalidURL as e:
|
|
431
|
+
logger = logging.getLogger(__name__)
|
|
432
|
+
logger.error(f"[LANGFUSE] Invalid proxy URL '{effective_proxy}': {e}")
|
|
433
|
+
raise
|
|
434
|
+
except Exception as e:
|
|
435
|
+
logger = logging.getLogger(__name__)
|
|
436
|
+
logger.error(f"[LANGFUSE] Failed to create httpx.Client with proxy '{effective_proxy}': {e}")
|
|
437
|
+
raise
|
|
438
|
+
# If no proxy specified, httpx will still respect HTTP_PROXY/HTTPS_PROXY env vars
|
|
439
|
+
elif proxy:
|
|
440
|
+
print(f"[LANGFUSE] Warning: proxy parameter ignored because httpx_client is provided")
|
|
441
|
+
|
|
340
442
|
self.langfuse = Langfuse(
|
|
341
443
|
public_key=public_key,
|
|
342
444
|
secret_key=secret_key,
|
|
343
445
|
host=host,
|
|
344
|
-
release="jaf-py-v2.5.
|
|
446
|
+
release="jaf-py-v2.5.3",
|
|
447
|
+
httpx_client=client
|
|
345
448
|
)
|
|
449
|
+
self._httpx_client = client
|
|
450
|
+
|
|
451
|
+
# Detect Langfuse version (v2 has trace() method, v3 does not)
|
|
452
|
+
self._is_langfuse_v3 = not hasattr(self.langfuse, 'trace')
|
|
453
|
+
if self._is_langfuse_v3:
|
|
454
|
+
print("[LANGFUSE] Detected Langfuse v3.x - using OpenTelemetry-based API")
|
|
455
|
+
else:
|
|
456
|
+
print("[LANGFUSE] Detected Langfuse v2.x - using legacy API")
|
|
457
|
+
|
|
346
458
|
self.active_spans: Dict[str, Any] = {}
|
|
347
459
|
self.trace_spans: Dict[TraceId, Any] = {}
|
|
348
460
|
# Track tool calls and results for each trace
|
|
349
461
|
self.trace_tool_calls: Dict[TraceId, List[Dict[str, Any]]] = {}
|
|
350
462
|
self.trace_tool_results: Dict[TraceId, List[Dict[str, Any]]] = {}
|
|
351
463
|
|
|
464
|
+
def __del__(self) -> None:
|
|
465
|
+
"""Cleanup resources on deletion."""
|
|
466
|
+
self.close()
|
|
467
|
+
|
|
468
|
+
def close(self) -> None:
|
|
469
|
+
"""Close httpx client if we own it."""
|
|
470
|
+
if self._owns_httpx_client and self._httpx_client:
|
|
471
|
+
try:
|
|
472
|
+
self._httpx_client.close()
|
|
473
|
+
except Exception as e:
|
|
474
|
+
print(f"[LANGFUSE] Warning: Failed to close httpx client: {e}")
|
|
475
|
+
|
|
476
|
+
def _get_event_data(self, event: TraceEvent, key: str, default: Any = None) -> Any:
|
|
477
|
+
"""Extract data from event, handling both dict and dataclass."""
|
|
478
|
+
if not hasattr(event, 'data'):
|
|
479
|
+
return default
|
|
480
|
+
|
|
481
|
+
# Handle dict
|
|
482
|
+
if isinstance(event.data, dict):
|
|
483
|
+
return event.data.get(key, default)
|
|
484
|
+
|
|
485
|
+
# Handle dataclass/object with attributes
|
|
486
|
+
return getattr(event.data, key, default)
|
|
487
|
+
|
|
488
|
+
def _create_trace(self, trace_id: TraceId, **kwargs) -> Any:
|
|
489
|
+
"""Create a trace using the appropriate API for the Langfuse version."""
|
|
490
|
+
if self._is_langfuse_v3:
|
|
491
|
+
# Langfuse v3: Use start_span() to create a root span (creates trace implicitly)
|
|
492
|
+
# Extract parameters for v3 API
|
|
493
|
+
name = kwargs.get('name', 'trace')
|
|
494
|
+
input_data = kwargs.get('input')
|
|
495
|
+
metadata = kwargs.get('metadata', {})
|
|
496
|
+
user_id = kwargs.get('user_id')
|
|
497
|
+
session_id = kwargs.get('session_id')
|
|
498
|
+
tags = kwargs.get('tags', [])
|
|
499
|
+
|
|
500
|
+
# Add user_id, session_id, and tags to metadata for v3
|
|
501
|
+
if user_id:
|
|
502
|
+
metadata['user_id'] = user_id
|
|
503
|
+
if session_id:
|
|
504
|
+
metadata['session_id'] = session_id
|
|
505
|
+
if tags:
|
|
506
|
+
metadata['tags'] = tags
|
|
507
|
+
|
|
508
|
+
# Create root span
|
|
509
|
+
trace = self.langfuse.start_span(
|
|
510
|
+
name=name,
|
|
511
|
+
input=input_data,
|
|
512
|
+
metadata=metadata
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Update trace properties using update_trace()
|
|
516
|
+
update_params = {}
|
|
517
|
+
if user_id:
|
|
518
|
+
update_params['user_id'] = user_id
|
|
519
|
+
if session_id:
|
|
520
|
+
update_params['session_id'] = session_id
|
|
521
|
+
if tags:
|
|
522
|
+
update_params['tags'] = tags
|
|
523
|
+
|
|
524
|
+
if update_params:
|
|
525
|
+
trace.update_trace(**update_params)
|
|
526
|
+
|
|
527
|
+
return trace
|
|
528
|
+
else:
|
|
529
|
+
# Langfuse v2: Use trace() method
|
|
530
|
+
return self.langfuse.trace(**kwargs)
|
|
531
|
+
|
|
532
|
+
def _create_generation(self, parent_span: Any, **kwargs) -> Any:
|
|
533
|
+
"""Create a generation using the appropriate API for the Langfuse version."""
|
|
534
|
+
if self._is_langfuse_v3:
|
|
535
|
+
# Langfuse v3: Use start_generation() method
|
|
536
|
+
return parent_span.start_generation(**kwargs)
|
|
537
|
+
else:
|
|
538
|
+
# Langfuse v2: Use generation() method
|
|
539
|
+
return parent_span.generation(**kwargs)
|
|
540
|
+
|
|
541
|
+
def _create_span(self, parent_span: Any, **kwargs) -> Any:
|
|
542
|
+
"""Create a span using the appropriate API for the Langfuse version."""
|
|
543
|
+
if self._is_langfuse_v3:
|
|
544
|
+
# Langfuse v3: Use start_span() method
|
|
545
|
+
return parent_span.start_span(**kwargs)
|
|
546
|
+
else:
|
|
547
|
+
# Langfuse v2: Use span() method
|
|
548
|
+
return parent_span.span(**kwargs)
|
|
549
|
+
|
|
550
|
+
def _create_event(self, parent_span: Any, **kwargs) -> Any:
|
|
551
|
+
"""Create an event using the appropriate API for the Langfuse version."""
|
|
552
|
+
if self._is_langfuse_v3:
|
|
553
|
+
# Langfuse v3: Use create_event() method
|
|
554
|
+
return parent_span.create_event(**kwargs)
|
|
555
|
+
else:
|
|
556
|
+
# Langfuse v2: Use event() method
|
|
557
|
+
return parent_span.event(**kwargs)
|
|
558
|
+
|
|
559
|
+
def _end_span(self, span: Any, **kwargs) -> None:
|
|
560
|
+
"""End a span/generation using the appropriate API for the Langfuse version."""
|
|
561
|
+
if self._is_langfuse_v3:
|
|
562
|
+
# Langfuse v3: Call update() first with output/metadata, then end()
|
|
563
|
+
update_params = {}
|
|
564
|
+
end_params = {}
|
|
565
|
+
|
|
566
|
+
# Separate parameters for update() vs end()
|
|
567
|
+
for key, value in kwargs.items():
|
|
568
|
+
if key in ['output', 'metadata', 'model', 'usage']:
|
|
569
|
+
update_params[key] = value
|
|
570
|
+
elif key == 'end_time':
|
|
571
|
+
end_params[key] = value
|
|
572
|
+
|
|
573
|
+
# Update first if there are parameters
|
|
574
|
+
if update_params:
|
|
575
|
+
span.update(**update_params)
|
|
576
|
+
|
|
577
|
+
# Then end
|
|
578
|
+
span.end(**end_params)
|
|
579
|
+
else:
|
|
580
|
+
# Langfuse v2: Call end() directly with all parameters
|
|
581
|
+
span.end(**kwargs)
|
|
582
|
+
|
|
352
583
|
def collect(self, event: TraceEvent) -> None:
|
|
353
584
|
"""Collect a trace event and send it to Langfuse."""
|
|
354
585
|
try:
|
|
@@ -373,15 +604,15 @@ class LangfuseTraceCollector:
|
|
|
373
604
|
conversation_history = []
|
|
374
605
|
|
|
375
606
|
# Debug: Print the event data structure to understand what we're working with
|
|
376
|
-
if
|
|
377
|
-
context = event
|
|
607
|
+
if self._get_event_data(event, "context"):
|
|
608
|
+
context = self._get_event_data(event, "context")
|
|
378
609
|
print(f"[LANGFUSE DEBUG] Context type: {type(context)}")
|
|
379
610
|
print(f"[LANGFUSE DEBUG] Context attributes: {dir(context) if hasattr(context, '__dict__') else 'Not an object'}")
|
|
380
611
|
if hasattr(context, '__dict__'):
|
|
381
612
|
print(f"[LANGFUSE DEBUG] Context dict: {context.__dict__}")
|
|
382
613
|
|
|
383
614
|
# Try to extract from context first
|
|
384
|
-
context =
|
|
615
|
+
context = self._get_event_data(event, "context")
|
|
385
616
|
if context:
|
|
386
617
|
# Try direct attribute access
|
|
387
618
|
if hasattr(context, 'query'):
|
|
@@ -411,7 +642,7 @@ class LangfuseTraceCollector:
|
|
|
411
642
|
print(f"[LANGFUSE DEBUG] Extracted user_id from attr: {user_id}")
|
|
412
643
|
|
|
413
644
|
# Extract conversation history and current user query from messages
|
|
414
|
-
messages =
|
|
645
|
+
messages = self._get_event_data(event, "messages", [])
|
|
415
646
|
if messages:
|
|
416
647
|
print(f"[LANGFUSE DEBUG] Processing {len(messages)} messages")
|
|
417
648
|
|
|
@@ -503,20 +734,22 @@ class LangfuseTraceCollector:
|
|
|
503
734
|
trace_input = {
|
|
504
735
|
"user_query": user_query,
|
|
505
736
|
"run_id": str(trace_id),
|
|
506
|
-
"agent_name":
|
|
737
|
+
"agent_name": self._get_event_data(event, "agent_name", "analytics_agent_jaf"),
|
|
507
738
|
"session_info": {
|
|
508
|
-
"session_id":
|
|
509
|
-
"user_id": user_id or
|
|
739
|
+
"session_id": self._get_event_data(event, "session_id"),
|
|
740
|
+
"user_id": user_id or self._get_event_data(event, "user_id")
|
|
510
741
|
}
|
|
511
742
|
}
|
|
512
743
|
|
|
513
744
|
# Extract agent_name for tagging
|
|
514
|
-
agent_name =
|
|
745
|
+
agent_name = self._get_event_data(event, "agent_name") or "analytics_agent_jaf"
|
|
515
746
|
|
|
516
|
-
|
|
747
|
+
# Use compatibility layer to create trace (works with both v2 and v3)
|
|
748
|
+
trace = self._create_trace(
|
|
749
|
+
trace_id=trace_id,
|
|
517
750
|
name=agent_name,
|
|
518
|
-
user_id=user_id or
|
|
519
|
-
session_id=
|
|
751
|
+
user_id=user_id or self._get_event_data(event, "user_id"),
|
|
752
|
+
session_id=self._get_event_data(event, "session_id"),
|
|
520
753
|
input=trace_input,
|
|
521
754
|
tags=[agent_name], # Add agent_name as a tag for dashboard filtering
|
|
522
755
|
metadata={
|
|
@@ -524,17 +757,17 @@ class LangfuseTraceCollector:
|
|
|
524
757
|
"event_type": "run_start",
|
|
525
758
|
"trace_id": str(trace_id),
|
|
526
759
|
"user_query": user_query,
|
|
527
|
-
"user_id": user_id or
|
|
760
|
+
"user_id": user_id or self._get_event_data(event, "user_id"),
|
|
528
761
|
"agent_name": agent_name,
|
|
529
762
|
"conversation_history": conversation_history,
|
|
530
763
|
"tool_calls": [],
|
|
531
764
|
"tool_results": [],
|
|
532
|
-
"user_info":
|
|
765
|
+
"user_info": self._get_event_data(event, "context").user_info if self._get_event_data(event, "context") and hasattr(self._get_event_data(event, "context"), 'user_info') else None
|
|
533
766
|
}
|
|
534
767
|
)
|
|
535
768
|
self.trace_spans[trace_id] = trace
|
|
536
769
|
# Store user_id, user_query, and conversation_history for later use
|
|
537
|
-
trace._user_id = user_id or
|
|
770
|
+
trace._user_id = user_id or self._get_event_data(event, "user_id")
|
|
538
771
|
trace._user_query = user_query
|
|
539
772
|
trace._conversation_history = conversation_history
|
|
540
773
|
print(f"[LANGFUSE] Created trace with user query: {user_query[:100] if user_query else 'None'}...")
|
|
@@ -551,7 +784,7 @@ class LangfuseTraceCollector:
|
|
|
551
784
|
"trace_id": str(trace_id),
|
|
552
785
|
"user_query": getattr(self.trace_spans[trace_id], '_user_query', None),
|
|
553
786
|
"user_id": getattr(self.trace_spans[trace_id], '_user_id', None),
|
|
554
|
-
"agent_name":
|
|
787
|
+
"agent_name": self._get_event_data(event, "agent_name", "analytics_agent_jaf"),
|
|
555
788
|
"conversation_history": conversation_history,
|
|
556
789
|
"tool_calls": self.trace_tool_calls.get(trace_id, []),
|
|
557
790
|
"tool_results": self.trace_tool_results.get(trace_id, [])
|
|
@@ -579,7 +812,7 @@ class LangfuseTraceCollector:
|
|
|
579
812
|
|
|
580
813
|
elif event.type == "llm_call_start":
|
|
581
814
|
# Start a generation for LLM calls
|
|
582
|
-
model =
|
|
815
|
+
model = self._get_event_data(event, "model", "unknown")
|
|
583
816
|
print(f"[LANGFUSE] Starting generation for LLM call with model: {model}")
|
|
584
817
|
|
|
585
818
|
# Get stored user information from the trace
|
|
@@ -587,11 +820,13 @@ class LangfuseTraceCollector:
|
|
|
587
820
|
user_id = getattr(trace, '_user_id', None)
|
|
588
821
|
user_query = getattr(trace, '_user_query', None)
|
|
589
822
|
|
|
590
|
-
|
|
823
|
+
# Use compatibility layer to create generation (works with both v2 and v3)
|
|
824
|
+
generation = self._create_generation(
|
|
825
|
+
parent_span=trace,
|
|
591
826
|
name=f"llm-call-{model}",
|
|
592
|
-
input=
|
|
827
|
+
input=self._get_event_data(event, "messages"),
|
|
593
828
|
metadata={
|
|
594
|
-
"agent_name":
|
|
829
|
+
"agent_name": self._get_event_data(event, "agent_name"),
|
|
595
830
|
"model": model,
|
|
596
831
|
"user_id": user_id,
|
|
597
832
|
"user_query": user_query
|
|
@@ -607,10 +842,10 @@ class LangfuseTraceCollector:
|
|
|
607
842
|
print(f"[LANGFUSE] Ending generation for LLM call")
|
|
608
843
|
# End the generation
|
|
609
844
|
generation = self.active_spans[span_id]
|
|
610
|
-
choice =
|
|
611
|
-
|
|
845
|
+
choice = self._get_event_data(event, "choice", {})
|
|
846
|
+
|
|
612
847
|
# Extract usage from the event data
|
|
613
|
-
usage =
|
|
848
|
+
usage = self._get_event_data(event, "usage", {})
|
|
614
849
|
|
|
615
850
|
# Extract model information from choice data or event data
|
|
616
851
|
model = choice.get("model", "unknown")
|
|
@@ -636,8 +871,10 @@ class LangfuseTraceCollector:
|
|
|
636
871
|
print(f"[LANGFUSE] Usage data for automatic cost calculation: {langfuse_usage}")
|
|
637
872
|
|
|
638
873
|
# Include model information in the generation end - Langfuse will calculate costs automatically
|
|
639
|
-
|
|
640
|
-
|
|
874
|
+
# Use compatibility wrapper for ending spans/generations
|
|
875
|
+
self._end_span(
|
|
876
|
+
span=generation,
|
|
877
|
+
output=choice,
|
|
641
878
|
usage=langfuse_usage,
|
|
642
879
|
model=model, # Pass model directly for automatic cost calculation
|
|
643
880
|
metadata={
|
|
@@ -656,9 +893,9 @@ class LangfuseTraceCollector:
|
|
|
656
893
|
|
|
657
894
|
elif event.type == "tool_call_start":
|
|
658
895
|
# Start a span for tool calls with detailed input information
|
|
659
|
-
tool_name =
|
|
660
|
-
tool_args =
|
|
661
|
-
call_id =
|
|
896
|
+
tool_name = self._get_event_data(event, 'tool_name', 'unknown')
|
|
897
|
+
tool_args = self._get_event_data(event, "args", {})
|
|
898
|
+
call_id = self._get_event_data(event, "call_id")
|
|
662
899
|
if not call_id:
|
|
663
900
|
call_id = f"{tool_name}-{uuid.uuid4().hex[:8]}"
|
|
664
901
|
try:
|
|
@@ -691,7 +928,9 @@ class LangfuseTraceCollector:
|
|
|
691
928
|
"timestamp": datetime.now().isoformat()
|
|
692
929
|
}
|
|
693
930
|
|
|
694
|
-
|
|
931
|
+
# Use compatibility layer to create span (works with both v2 and v3)
|
|
932
|
+
span = self._create_span(
|
|
933
|
+
parent_span=self.trace_spans[trace_id],
|
|
695
934
|
name=f"tool-{tool_name}",
|
|
696
935
|
input=tool_input,
|
|
697
936
|
metadata={
|
|
@@ -708,9 +947,9 @@ class LangfuseTraceCollector:
|
|
|
708
947
|
elif event.type == "tool_call_end":
|
|
709
948
|
span_id = self._get_span_id(event)
|
|
710
949
|
if span_id in self.active_spans:
|
|
711
|
-
tool_name =
|
|
712
|
-
tool_result =
|
|
713
|
-
call_id =
|
|
950
|
+
tool_name = self._get_event_data(event, 'tool_name', 'unknown')
|
|
951
|
+
tool_result = self._get_event_data(event, "result")
|
|
952
|
+
call_id = self._get_event_data(event, "call_id")
|
|
714
953
|
|
|
715
954
|
print(f"[LANGFUSE] Ending span for tool call: {tool_name} ({call_id})")
|
|
716
955
|
|
|
@@ -720,9 +959,9 @@ class LangfuseTraceCollector:
|
|
|
720
959
|
"result": tool_result,
|
|
721
960
|
"call_id": call_id,
|
|
722
961
|
"timestamp": datetime.now().isoformat(),
|
|
723
|
-
"execution_status":
|
|
724
|
-
"status":
|
|
725
|
-
"tool_result":
|
|
962
|
+
"execution_status": self._get_event_data(event, "execution_status", "completed"),
|
|
963
|
+
"status": self._get_event_data(event, "execution_status", "completed"), # DEPRECATED: backward compatibility
|
|
964
|
+
"tool_result": self._get_event_data(event, "tool_result")
|
|
726
965
|
}
|
|
727
966
|
|
|
728
967
|
if trace_id not in self.trace_tool_results:
|
|
@@ -736,13 +975,15 @@ class LangfuseTraceCollector:
|
|
|
736
975
|
"result": tool_result,
|
|
737
976
|
"call_id": call_id,
|
|
738
977
|
"timestamp": datetime.now().isoformat(),
|
|
739
|
-
"execution_status":
|
|
740
|
-
"status":
|
|
978
|
+
"execution_status": self._get_event_data(event, "execution_status", "completed"),
|
|
979
|
+
"status": self._get_event_data(event, "execution_status", "completed") # DEPRECATED: backward compatibility
|
|
741
980
|
}
|
|
742
981
|
|
|
743
982
|
# End the span with detailed output
|
|
983
|
+
# Use compatibility wrapper for ending spans/generations
|
|
744
984
|
span = self.active_spans[span_id]
|
|
745
|
-
|
|
985
|
+
self._end_span(
|
|
986
|
+
span=span,
|
|
746
987
|
output=tool_output,
|
|
747
988
|
metadata={
|
|
748
989
|
"tool_name": tool_name,
|
|
@@ -762,17 +1003,21 @@ class LangfuseTraceCollector:
|
|
|
762
1003
|
elif event.type == "handoff":
|
|
763
1004
|
# Create an event for handoffs
|
|
764
1005
|
print(f"[LANGFUSE] Creating event for handoff")
|
|
765
|
-
|
|
1006
|
+
# Use compatibility layer to create event (works with both v2 and v3)
|
|
1007
|
+
self._create_event(
|
|
1008
|
+
parent_span=self.trace_spans[trace_id],
|
|
766
1009
|
name="agent-handoff",
|
|
767
|
-
input={"from":
|
|
1010
|
+
input={"from": self._get_event_data(event, "from"), "to": self._get_event_data(event, "to")},
|
|
768
1011
|
metadata=event.data
|
|
769
1012
|
)
|
|
770
1013
|
print(f"[LANGFUSE] Handoff event created")
|
|
771
|
-
|
|
1014
|
+
|
|
772
1015
|
else:
|
|
773
1016
|
# Create a generic event for other event types
|
|
774
1017
|
print(f"[LANGFUSE] Creating generic event for: {event.type}")
|
|
775
|
-
|
|
1018
|
+
# Use compatibility layer to create event (works with both v2 and v3)
|
|
1019
|
+
self._create_event(
|
|
1020
|
+
parent_span=self.trace_spans[trace_id],
|
|
776
1021
|
name=event.type,
|
|
777
1022
|
input=event.data,
|
|
778
1023
|
metadata={"framework": "jaf", "event_type": event.type}
|
|
@@ -786,20 +1031,28 @@ class LangfuseTraceCollector:
|
|
|
786
1031
|
traceback.print_exc()
|
|
787
1032
|
|
|
788
1033
|
def _get_trace_id(self, event: TraceEvent) -> Optional[TraceId]:
|
|
789
|
-
"""Extract trace ID from event data."""
|
|
790
|
-
if hasattr(event, 'data')
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
#
|
|
1034
|
+
"""Extract trace ID from event data, handling both dict and dataclass."""
|
|
1035
|
+
if not hasattr(event, 'data'):
|
|
1036
|
+
return None
|
|
1037
|
+
|
|
1038
|
+
# Try snake_case first (Python convention)
|
|
1039
|
+
trace_id = self._get_event_data(event, 'trace_id')
|
|
1040
|
+
if trace_id:
|
|
1041
|
+
return trace_id
|
|
1042
|
+
|
|
1043
|
+
run_id = self._get_event_data(event, 'run_id')
|
|
1044
|
+
if run_id:
|
|
1045
|
+
return TraceId(run_id)
|
|
1046
|
+
|
|
1047
|
+
# Fallback to camelCase (for compatibility)
|
|
1048
|
+
trace_id = self._get_event_data(event, 'traceId')
|
|
1049
|
+
if trace_id:
|
|
1050
|
+
return trace_id
|
|
1051
|
+
|
|
1052
|
+
run_id = self._get_event_data(event, 'runId')
|
|
1053
|
+
if run_id:
|
|
1054
|
+
return TraceId(run_id)
|
|
1055
|
+
|
|
803
1056
|
return None
|
|
804
1057
|
|
|
805
1058
|
def _get_span_id(self, event: TraceEvent) -> str:
|
|
@@ -808,15 +1061,15 @@ class LangfuseTraceCollector:
|
|
|
808
1061
|
|
|
809
1062
|
# Use consistent identifiers that don't depend on timestamp
|
|
810
1063
|
if event.type.startswith('tool_call'):
|
|
811
|
-
call_id =
|
|
1064
|
+
call_id = self._get_event_data(event, 'call_id') or self._get_event_data(event, 'tool_call_id')
|
|
812
1065
|
if call_id:
|
|
813
1066
|
return f"tool-{trace_id}-{call_id}"
|
|
814
|
-
tool_name =
|
|
1067
|
+
tool_name = self._get_event_data(event, 'tool_name') or self._get_event_data(event, 'toolName', 'unknown')
|
|
815
1068
|
return f"tool-{tool_name}-{trace_id}"
|
|
816
1069
|
elif event.type.startswith('llm_call'):
|
|
817
1070
|
# For LLM calls, use a simpler consistent ID that matches between start and end
|
|
818
1071
|
# Get run_id for more consistent matching
|
|
819
|
-
run_id =
|
|
1072
|
+
run_id = self._get_event_data(event, 'run_id') or self._get_event_data(event, 'runId', trace_id)
|
|
820
1073
|
return f"llm-{run_id}"
|
|
821
1074
|
else:
|
|
822
1075
|
return f"{event.type}-{trace_id}"
|
|
@@ -834,21 +1087,50 @@ class LangfuseTraceCollector:
|
|
|
834
1087
|
pass
|
|
835
1088
|
|
|
836
1089
|
|
|
837
|
-
def create_composite_trace_collector(
|
|
838
|
-
|
|
839
|
-
|
|
1090
|
+
def create_composite_trace_collector(
|
|
1091
|
+
*collectors: TraceCollector,
|
|
1092
|
+
httpx_client: Optional[httpx.Client] = None,
|
|
1093
|
+
otel_session: Optional[Any] = None,
|
|
1094
|
+
proxy: Optional[str] = None,
|
|
1095
|
+
timeout: Optional[int] = None
|
|
1096
|
+
) -> TraceCollector:
|
|
1097
|
+
"""Create a composite trace collector that forwards events to multiple collectors.
|
|
1098
|
+
|
|
1099
|
+
Args:
|
|
1100
|
+
*collectors: Variable length list of trace collectors
|
|
1101
|
+
httpx_client: Optional custom httpx.Client for Langfuse API calls.
|
|
1102
|
+
If provided, proxy and timeout parameters are ignored for Langfuse.
|
|
1103
|
+
otel_session: Optional custom requests.Session for OTLP HTTP calls.
|
|
1104
|
+
If provided, proxy parameter is ignored for OTEL.
|
|
1105
|
+
proxy: Optional proxy URL for both Langfuse and OTEL (e.g., "http://proxy.example.com:8080").
|
|
1106
|
+
For Langfuse: Falls back to LANGFUSE_PROXY environment variable.
|
|
1107
|
+
For OTEL: Falls back to OTEL_PROXY environment variable.
|
|
1108
|
+
If not set, both respect standard HTTP_PROXY/HTTPS_PROXY environment variables.
|
|
1109
|
+
timeout: Optional timeout in seconds for HTTP requests (applies to both Langfuse and OTEL).
|
|
1110
|
+
For Langfuse: Falls back to LANGFUSE_TIMEOUT environment variable (default: 10).
|
|
1111
|
+
"""
|
|
840
1112
|
collector_list = list(collectors)
|
|
841
|
-
|
|
1113
|
+
|
|
842
1114
|
# Automatically add OTEL collector if URL is configured
|
|
843
1115
|
collector_url = os.getenv("TRACE_COLLECTOR_URL")
|
|
844
1116
|
if collector_url:
|
|
845
|
-
|
|
1117
|
+
# Pass proxy and timeout to OTEL setup
|
|
1118
|
+
setup_otel_tracing(
|
|
1119
|
+
collector_url=collector_url,
|
|
1120
|
+
proxy=proxy,
|
|
1121
|
+
session=otel_session,
|
|
1122
|
+
timeout=timeout
|
|
1123
|
+
)
|
|
846
1124
|
otel_collector = OtelTraceCollector()
|
|
847
1125
|
collector_list.append(otel_collector)
|
|
848
1126
|
|
|
849
1127
|
# Automatically add Langfuse collector if keys are configured
|
|
850
1128
|
if os.getenv("LANGFUSE_PUBLIC_KEY") and os.getenv("LANGFUSE_SECRET_KEY"):
|
|
851
|
-
langfuse_collector = LangfuseTraceCollector(
|
|
1129
|
+
langfuse_collector = LangfuseTraceCollector(
|
|
1130
|
+
httpx_client=httpx_client,
|
|
1131
|
+
proxy=proxy,
|
|
1132
|
+
timeout=timeout
|
|
1133
|
+
)
|
|
852
1134
|
collector_list.append(langfuse_collector)
|
|
853
1135
|
|
|
854
1136
|
class CompositeTraceCollector:
|
|
@@ -882,5 +1164,21 @@ def create_composite_trace_collector(*collectors: TraceCollector) -> TraceCollec
|
|
|
882
1164
|
collector.clear(trace_id)
|
|
883
1165
|
except Exception as e:
|
|
884
1166
|
print(f"Warning: Failed to clear trace collector: {e}")
|
|
1167
|
+
|
|
1168
|
+
def close(self) -> None:
|
|
1169
|
+
"""Close all collectors that support cleanup."""
|
|
1170
|
+
for collector in self.collectors:
|
|
1171
|
+
if hasattr(collector, 'close'):
|
|
1172
|
+
try:
|
|
1173
|
+
collector.close()
|
|
1174
|
+
except Exception as e:
|
|
1175
|
+
print(f"Warning: Failed to close trace collector: {e}")
|
|
1176
|
+
|
|
1177
|
+
def __enter__(self):
|
|
1178
|
+
return self
|
|
1179
|
+
|
|
1180
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
1181
|
+
self.close()
|
|
1182
|
+
return False
|
|
885
1183
|
|
|
886
1184
|
return CompositeTraceCollector(collector_list)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jaf-py
|
|
3
|
-
Version: 2.5.
|
|
3
|
+
Version: 2.5.3
|
|
4
4
|
Summary: A purely functional agent framework with immutable state and composable tools - Python implementation
|
|
5
5
|
Author: JAF Contributors
|
|
6
6
|
Maintainer: JAF Contributors
|
|
@@ -82,7 +82,7 @@ Dynamic: license-file
|
|
|
82
82
|
|
|
83
83
|
<!--  -->
|
|
84
84
|
|
|
85
|
-
[](https://github.com/xynehq/jaf-py)
|
|
86
86
|
[](https://www.python.org/)
|
|
87
87
|
[](https://xynehq.github.io/jaf-py/)
|
|
88
88
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
jaf/__init__.py,sha256=
|
|
1
|
+
jaf/__init__.py,sha256=M7o--eb6Yp44HXoxq03cz650o8gY0ehAicf5J7d7nzg,8260
|
|
2
2
|
jaf/cli.py,sha256=Af4di_NZ7rZ4wFl0R4EZh611NgJ--TL03vNyZ2M1_FY,8477
|
|
3
3
|
jaf/exceptions.py,sha256=nl8JY355u7oTXB3PmC_LhnUaL8fzk2K4EaWM4fVpMPE,9196
|
|
4
4
|
jaf/a2a/__init__.py,sha256=p4YVthZH0ow1ZECqWTQ0aQl8JWySYZb25jlzZJ09na4,7662
|
|
@@ -38,22 +38,23 @@ jaf/a2a/tests/test_client.py,sha256=L5h7DtQRVlULiRhRLtrmaCoYdvmbXsgLTy3QQ6KgmNM,
|
|
|
38
38
|
jaf/a2a/tests/test_integration.py,sha256=I7LdgwN99mAOljM9kYtK7dGMMntTSWKMw_oLOcJjinU,18454
|
|
39
39
|
jaf/a2a/tests/test_protocol.py,sha256=He3vGlBfIazpppAnuSybutrvjIN3VGxEleAohrVd9hc,23287
|
|
40
40
|
jaf/a2a/tests/test_types.py,sha256=PgRjDVJrHSXuu05z0B5lsSUUY5qEdQLFJbLBIExyVgI,18384
|
|
41
|
-
jaf/core/__init__.py,sha256=
|
|
41
|
+
jaf/core/__init__.py,sha256=1VHV2-a1oJXIWcg8n5G5g2cmjw2QXv7OezncNB59KLw,1988
|
|
42
42
|
jaf/core/agent_tool.py,sha256=tfLNaTIcOZ0dR9GBP1AHLPkLExm_dLbURnVIN4R84FQ,11806
|
|
43
43
|
jaf/core/analytics.py,sha256=zFHIWqWal0bbEFCmJDc4DKeM0Ja7b_D19PqVaBI12pA,23338
|
|
44
44
|
jaf/core/composition.py,sha256=IVxRO1Q9nK7JRH32qQ4p8WMIUu66BhqPNrlTNMGFVwE,26317
|
|
45
45
|
jaf/core/engine.py,sha256=wt5RW8ntEV8Prq5IoNiTIqAe6PBZM4XLTXYrwCUONTo,58252
|
|
46
46
|
jaf/core/errors.py,sha256=5fwTNhkojKRQ4wZj3lZlgDnAsrYyjYOwXJkIr5EGNUc,5539
|
|
47
47
|
jaf/core/guardrails.py,sha256=nv7pQuCx7-9DDZrecWO1DsDqFoujL81FBDrafOsXgcI,26179
|
|
48
|
+
jaf/core/handoff.py,sha256=ttjOQ6CSl34J4T_1ejdmq78gZ-ve07_IQE_DAbz2bmo,6002
|
|
48
49
|
jaf/core/parallel_agents.py,sha256=ahwYoTnkrF4xQgV-hjc5sUaWhQWQFENMZG5riNa_Ieg,12165
|
|
49
50
|
jaf/core/performance.py,sha256=jedQmTEkrKMD6_Aw1h8PdG-5TsdYSFFT7Or6k5dmN2g,9974
|
|
50
51
|
jaf/core/proxy.py,sha256=_WM3cpRlSQLYpgSBrnY30UPMe2iZtlqDQ65kppE-WY0,4609
|
|
51
52
|
jaf/core/proxy_helpers.py,sha256=i7a5fAX9rLmO4FMBX51-yRkTFwfWedzQNgnLmeLUd_A,4370
|
|
52
|
-
jaf/core/state.py,sha256=
|
|
53
|
+
jaf/core/state.py,sha256=oNCVXPWLkqnBQObdQX10TcmZ0eOF3wKG6DtL3kF6ohw,9649
|
|
53
54
|
jaf/core/streaming.py,sha256=h_lYHQA9ee_D5QsDO9-Vhevgi7rFXPslPzd9605AJGo,17034
|
|
54
55
|
jaf/core/tool_results.py,sha256=-bTOqOX02lMyslp5Z4Dmuhx0cLd5o7kgR88qK2HO_sw,11323
|
|
55
56
|
jaf/core/tools.py,sha256=84N9A7QQ3xxcOs2eUUot3nmCnt5i7iZT9VwkuzuFBxQ,16274
|
|
56
|
-
jaf/core/tracing.py,sha256=
|
|
57
|
+
jaf/core/tracing.py,sha256=kY6qCVY2YQWWcbjU82icEdysdOEwYdoYfyM5I2YcSu4,53367
|
|
57
58
|
jaf/core/types.py,sha256=GE7Q2s6jglAFfkpc1MIgVkC9XWaWT46OOQd7L17ACxw,27955
|
|
58
59
|
jaf/core/workflows.py,sha256=Ul-82gzjIXtkhnSMSPv-8igikjkMtW1EBo9yrfodtvI,26294
|
|
59
60
|
jaf/memory/__init__.py,sha256=-L98xlvihurGAzF0DnXtkueDVvO_wV2XxxEwAWdAj50,1400
|
|
@@ -86,9 +87,9 @@ jaf/visualization/functional_core.py,sha256=zedMDZbvjuOugWwnh6SJ2stvRNQX1Hlkb9Ab
|
|
|
86
87
|
jaf/visualization/graphviz.py,sha256=WTOM6UP72-lVKwI4_SAr5-GCC3ouckxHv88ypCDQWJ0,12056
|
|
87
88
|
jaf/visualization/imperative_shell.py,sha256=GpMrAlMnLo2IQgyB2nardCz09vMvAzaYI46MyrvJ0i4,2593
|
|
88
89
|
jaf/visualization/types.py,sha256=QQcbVeQJLuAOXk8ynd08DXIS-PVCnv3R-XVE9iAcglw,1389
|
|
89
|
-
jaf_py-2.5.
|
|
90
|
-
jaf_py-2.5.
|
|
91
|
-
jaf_py-2.5.
|
|
92
|
-
jaf_py-2.5.
|
|
93
|
-
jaf_py-2.5.
|
|
94
|
-
jaf_py-2.5.
|
|
90
|
+
jaf_py-2.5.3.dist-info/licenses/LICENSE,sha256=LXUQBJxdyr-7C4bk9cQBwvsF_xwA-UVstDTKabpcjlI,1063
|
|
91
|
+
jaf_py-2.5.3.dist-info/METADATA,sha256=tKpfginwaYb7zT0aUGt8a-cJ3mm8E5KfvWCLnWhYGGY,27743
|
|
92
|
+
jaf_py-2.5.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
93
|
+
jaf_py-2.5.3.dist-info/entry_points.txt,sha256=OtIJeNJpb24kgGrqRx9szGgDx1vL9ayq8uHErmu7U5w,41
|
|
94
|
+
jaf_py-2.5.3.dist-info/top_level.txt,sha256=Xu1RZbGaM4_yQX7bpalo881hg7N_dybaOW282F15ruE,4
|
|
95
|
+
jaf_py-2.5.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|