jaf-py 2.5.1__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 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.0.0"
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
- return replace(state, approvals=new_approvals)
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
- return replace(state, approvals=new_approvals)
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(service_name: str = "jaf-agent", collector_url: Optional[str] = None) -> None:
29
- """Configure OpenTelemetry tracing."""
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
- exporter = OTLPSpanExporter(endpoint=collector_url)
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
- def __init__(self):
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.1"
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:
@@ -834,21 +950,50 @@ class LangfuseTraceCollector:
834
950
  pass
835
951
 
836
952
 
837
- def create_composite_trace_collector(*collectors: TraceCollector) -> TraceCollector:
838
- """Create a composite trace collector that forwards events to multiple collectors."""
839
-
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
+ """
840
975
  collector_list = list(collectors)
841
-
976
+
842
977
  # Automatically add OTEL collector if URL is configured
843
978
  collector_url = os.getenv("TRACE_COLLECTOR_URL")
844
979
  if collector_url:
845
- setup_otel_tracing(collector_url=collector_url)
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
+ )
846
987
  otel_collector = OtelTraceCollector()
847
988
  collector_list.append(otel_collector)
848
989
 
849
990
  # Automatically add Langfuse collector if keys are configured
850
991
  if os.getenv("LANGFUSE_PUBLIC_KEY") and os.getenv("LANGFUSE_SECRET_KEY"):
851
- langfuse_collector = LangfuseTraceCollector()
992
+ langfuse_collector = LangfuseTraceCollector(
993
+ httpx_client=httpx_client,
994
+ proxy=proxy,
995
+ timeout=timeout
996
+ )
852
997
  collector_list.append(langfuse_collector)
853
998
 
854
999
  class CompositeTraceCollector:
@@ -882,5 +1027,21 @@ def create_composite_trace_collector(*collectors: TraceCollector) -> TraceCollec
882
1027
  collector.clear(trace_id)
883
1028
  except Exception as e:
884
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
885
1046
 
886
1047
  return CompositeTraceCollector(collector_list)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jaf-py
3
- Version: 2.5.1
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<3.0.0
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<3.0.0; extra == "tracing"
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
  <!-- ![JAF Banner](docs/cover.png) -->
84
84
 
85
- [![Version](https://img.shields.io/badge/version-2.3.1-blue.svg)](https://github.com/xynehq/jaf-py)
85
+ [![Version](https://img.shields.io/badge/version-2.5.2-blue.svg)](https://github.com/xynehq/jaf-py)
86
86
  [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
87
87
  [![Docs](https://img.shields.io/badge/Docs-Live-brightgreen)](https://xynehq.github.io/jaf-py/)
88
88
 
@@ -1,4 +1,4 @@
1
- jaf/__init__.py,sha256=kNxEp8Gr_lmJheZWRbjukSFjJX9K7RBOzAzTM690N7Y,8260
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=PIGKm8n6OQ8jcXRS0Hn3_Zsl8m2qX91N80YJoLCJ4eU,1762
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=NMtYTpUYa64m1Kte6lD8LGnF2bl69HAcdgXH6f-M97c,5650
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=5-rQQdBlBHzP5tdwclWfl0Qt5Zh-9WvqNiDoPk6-y1g,40706
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.1.dist-info/licenses/LICENSE,sha256=LXUQBJxdyr-7C4bk9cQBwvsF_xwA-UVstDTKabpcjlI,1063
90
- jaf_py-2.5.1.dist-info/METADATA,sha256=SyqK3LoYF86q-MfOa7qotPlQ8kBscVX9hWLO9i3U3fY,27743
91
- jaf_py-2.5.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
92
- jaf_py-2.5.1.dist-info/entry_points.txt,sha256=OtIJeNJpb24kgGrqRx9szGgDx1vL9ayq8uHErmu7U5w,41
93
- jaf_py-2.5.1.dist-info/top_level.txt,sha256=Xu1RZbGaM4_yQX7bpalo881hg7N_dybaOW282F15ruE,4
94
- jaf_py-2.5.1.dist-info/RECORD,,
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