jaf-py 2.5.0__py3-none-any.whl → 2.5.2__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 +183 -18
- {jaf_py-2.5.0.dist-info → jaf_py-2.5.2.dist-info}/METADATA +4 -4
- {jaf_py-2.5.0.dist-info → jaf_py-2.5.2.dist-info}/RECORD +11 -10
- {jaf_py-2.5.0.dist-info → jaf_py-2.5.2.dist-info}/WHEEL +0 -0
- {jaf_py-2.5.0.dist-info → jaf_py-2.5.2.dist-info}/entry_points.txt +0 -0
- {jaf_py-2.5.0.dist-info → jaf_py-2.5.2.dist-info}/licenses/LICENSE +0 -0
- {jaf_py-2.5.0.dist-info → jaf_py-2.5.2.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.2"
|
|
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,91 @@ 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.2",
|
|
447
|
+
httpx_client=client
|
|
345
448
|
)
|
|
449
|
+
self._httpx_client = client
|
|
346
450
|
self.active_spans: Dict[str, Any] = {}
|
|
347
451
|
self.trace_spans: Dict[TraceId, Any] = {}
|
|
348
452
|
# Track tool calls and results for each trace
|
|
349
453
|
self.trace_tool_calls: Dict[TraceId, List[Dict[str, Any]]] = {}
|
|
350
454
|
self.trace_tool_results: Dict[TraceId, List[Dict[str, Any]]] = {}
|
|
351
455
|
|
|
456
|
+
def __del__(self) -> None:
|
|
457
|
+
"""Cleanup resources on deletion."""
|
|
458
|
+
self.close()
|
|
459
|
+
|
|
460
|
+
def close(self) -> None:
|
|
461
|
+
"""Close httpx client if we own it."""
|
|
462
|
+
if self._owns_httpx_client and self._httpx_client:
|
|
463
|
+
try:
|
|
464
|
+
self._httpx_client.close()
|
|
465
|
+
except Exception as e:
|
|
466
|
+
print(f"[LANGFUSE] Warning: Failed to close httpx client: {e}")
|
|
467
|
+
|
|
352
468
|
def collect(self, event: TraceEvent) -> None:
|
|
353
469
|
"""Collect a trace event and send it to Langfuse."""
|
|
354
470
|
try:
|
|
@@ -509,19 +625,23 @@ class LangfuseTraceCollector:
|
|
|
509
625
|
"user_id": user_id or event.data.get("user_id")
|
|
510
626
|
}
|
|
511
627
|
}
|
|
512
|
-
|
|
628
|
+
|
|
629
|
+
# Extract agent_name for tagging
|
|
630
|
+
agent_name = event.data.get("agent_name") or "analytics_agent_jaf"
|
|
631
|
+
|
|
513
632
|
trace = self.langfuse.trace(
|
|
514
|
-
name=
|
|
633
|
+
name=agent_name,
|
|
515
634
|
user_id=user_id or event.data.get("user_id"),
|
|
516
635
|
session_id=event.data.get("session_id"),
|
|
517
636
|
input=trace_input,
|
|
637
|
+
tags=[agent_name], # Add agent_name as a tag for dashboard filtering
|
|
518
638
|
metadata={
|
|
519
639
|
"framework": "jaf",
|
|
520
640
|
"event_type": "run_start",
|
|
521
641
|
"trace_id": str(trace_id),
|
|
522
642
|
"user_query": user_query,
|
|
523
643
|
"user_id": user_id or event.data.get("user_id"),
|
|
524
|
-
"agent_name":
|
|
644
|
+
"agent_name": agent_name,
|
|
525
645
|
"conversation_history": conversation_history,
|
|
526
646
|
"tool_calls": [],
|
|
527
647
|
"tool_results": [],
|
|
@@ -830,21 +950,50 @@ class LangfuseTraceCollector:
|
|
|
830
950
|
pass
|
|
831
951
|
|
|
832
952
|
|
|
833
|
-
def create_composite_trace_collector(
|
|
834
|
-
|
|
835
|
-
|
|
953
|
+
def create_composite_trace_collector(
|
|
954
|
+
*collectors: TraceCollector,
|
|
955
|
+
httpx_client: Optional[httpx.Client] = None,
|
|
956
|
+
otel_session: Optional[Any] = None,
|
|
957
|
+
proxy: Optional[str] = None,
|
|
958
|
+
timeout: Optional[int] = None
|
|
959
|
+
) -> TraceCollector:
|
|
960
|
+
"""Create a composite trace collector that forwards events to multiple collectors.
|
|
961
|
+
|
|
962
|
+
Args:
|
|
963
|
+
*collectors: Variable length list of trace collectors
|
|
964
|
+
httpx_client: Optional custom httpx.Client for Langfuse API calls.
|
|
965
|
+
If provided, proxy and timeout parameters are ignored for Langfuse.
|
|
966
|
+
otel_session: Optional custom requests.Session for OTLP HTTP calls.
|
|
967
|
+
If provided, proxy parameter is ignored for OTEL.
|
|
968
|
+
proxy: Optional proxy URL for both Langfuse and OTEL (e.g., "http://proxy.example.com:8080").
|
|
969
|
+
For Langfuse: Falls back to LANGFUSE_PROXY environment variable.
|
|
970
|
+
For OTEL: Falls back to OTEL_PROXY environment variable.
|
|
971
|
+
If not set, both respect standard HTTP_PROXY/HTTPS_PROXY environment variables.
|
|
972
|
+
timeout: Optional timeout in seconds for HTTP requests (applies to both Langfuse and OTEL).
|
|
973
|
+
For Langfuse: Falls back to LANGFUSE_TIMEOUT environment variable (default: 10).
|
|
974
|
+
"""
|
|
836
975
|
collector_list = list(collectors)
|
|
837
|
-
|
|
976
|
+
|
|
838
977
|
# Automatically add OTEL collector if URL is configured
|
|
839
978
|
collector_url = os.getenv("TRACE_COLLECTOR_URL")
|
|
840
979
|
if collector_url:
|
|
841
|
-
|
|
980
|
+
# Pass proxy and timeout to OTEL setup
|
|
981
|
+
setup_otel_tracing(
|
|
982
|
+
collector_url=collector_url,
|
|
983
|
+
proxy=proxy,
|
|
984
|
+
session=otel_session,
|
|
985
|
+
timeout=timeout
|
|
986
|
+
)
|
|
842
987
|
otel_collector = OtelTraceCollector()
|
|
843
988
|
collector_list.append(otel_collector)
|
|
844
989
|
|
|
845
990
|
# Automatically add Langfuse collector if keys are configured
|
|
846
991
|
if os.getenv("LANGFUSE_PUBLIC_KEY") and os.getenv("LANGFUSE_SECRET_KEY"):
|
|
847
|
-
langfuse_collector = LangfuseTraceCollector(
|
|
992
|
+
langfuse_collector = LangfuseTraceCollector(
|
|
993
|
+
httpx_client=httpx_client,
|
|
994
|
+
proxy=proxy,
|
|
995
|
+
timeout=timeout
|
|
996
|
+
)
|
|
848
997
|
collector_list.append(langfuse_collector)
|
|
849
998
|
|
|
850
999
|
class CompositeTraceCollector:
|
|
@@ -878,5 +1027,21 @@ def create_composite_trace_collector(*collectors: TraceCollector) -> TraceCollec
|
|
|
878
1027
|
collector.clear(trace_id)
|
|
879
1028
|
except Exception as e:
|
|
880
1029
|
print(f"Warning: Failed to clear trace collector: {e}")
|
|
1030
|
+
|
|
1031
|
+
def close(self) -> None:
|
|
1032
|
+
"""Close all collectors that support cleanup."""
|
|
1033
|
+
for collector in self.collectors:
|
|
1034
|
+
if hasattr(collector, 'close'):
|
|
1035
|
+
try:
|
|
1036
|
+
collector.close()
|
|
1037
|
+
except Exception as e:
|
|
1038
|
+
print(f"Warning: Failed to close trace collector: {e}")
|
|
1039
|
+
|
|
1040
|
+
def __enter__(self):
|
|
1041
|
+
return self
|
|
1042
|
+
|
|
1043
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
1044
|
+
self.close()
|
|
1045
|
+
return False
|
|
881
1046
|
|
|
882
1047
|
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.2
|
|
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
|
|
@@ -42,13 +42,13 @@ Requires-Dist: fastmcp>=0.1.0
|
|
|
42
42
|
Requires-Dist: opentelemetry-api>=1.22.0
|
|
43
43
|
Requires-Dist: opentelemetry-sdk>=1.22.0
|
|
44
44
|
Requires-Dist: opentelemetry-exporter-otlp>=1.22.0
|
|
45
|
-
Requires-Dist: langfuse<
|
|
45
|
+
Requires-Dist: langfuse<4.0.0,>=3.6.1
|
|
46
46
|
Requires-Dist: litellm>=1.76.3
|
|
47
47
|
Provides-Extra: tracing
|
|
48
48
|
Requires-Dist: opentelemetry-api>=1.22.0; extra == "tracing"
|
|
49
49
|
Requires-Dist: opentelemetry-sdk>=1.22.0; extra == "tracing"
|
|
50
50
|
Requires-Dist: opentelemetry-exporter-otlp>=1.22.0; extra == "tracing"
|
|
51
|
-
Requires-Dist: langfuse<
|
|
51
|
+
Requires-Dist: langfuse<4.0.0,>=3.6.1; extra == "tracing"
|
|
52
52
|
Provides-Extra: attachments
|
|
53
53
|
Requires-Dist: PyPDF2>=3.0.0; extra == "attachments"
|
|
54
54
|
Requires-Dist: python-docx>=1.1.0; extra == "attachments"
|
|
@@ -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=SbhsrrcWZmeOfOlOOpIzmd1jCGYs3T4ckoJYr15Q3sQ,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=2YF266jIwRHjDd5C-D5l_9T_Ts0jfY5md20odc5ygQw,47464
|
|
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.2.dist-info/licenses/LICENSE,sha256=LXUQBJxdyr-7C4bk9cQBwvsF_xwA-UVstDTKabpcjlI,1063
|
|
91
|
+
jaf_py-2.5.2.dist-info/METADATA,sha256=bpMawyewNtufu4o4eQWuPRCwiqhLqKqw0ZZNbeEMtl8,27759
|
|
92
|
+
jaf_py-2.5.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
93
|
+
jaf_py-2.5.2.dist-info/entry_points.txt,sha256=OtIJeNJpb24kgGrqRx9szGgDx1vL9ayq8uHErmu7U5w,41
|
|
94
|
+
jaf_py-2.5.2.dist-info/top_level.txt,sha256=Xu1RZbGaM4_yQX7bpalo881hg7N_dybaOW282F15ruE,4
|
|
95
|
+
jaf_py-2.5.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|