praisonaiagents 0.0.152__py3-none-any.whl → 0.0.154__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.
@@ -60,6 +60,9 @@ try:
60
60
  get_telemetry,
61
61
  enable_telemetry,
62
62
  disable_telemetry,
63
+ enable_performance_mode,
64
+ disable_performance_mode,
65
+ cleanup_telemetry_resources,
63
66
  MinimalTelemetry,
64
67
  TelemetryCollector
65
68
  )
@@ -80,22 +83,66 @@ except ImportError:
80
83
  def disable_telemetry():
81
84
  pass
82
85
 
86
+ def enable_performance_mode():
87
+ pass
88
+
89
+ def disable_performance_mode():
90
+ pass
91
+
92
+ def cleanup_telemetry_resources():
93
+ pass
94
+
83
95
  MinimalTelemetry = None
84
96
  TelemetryCollector = None
85
97
 
86
98
  # Add Agents as an alias for PraisonAIAgents
87
99
  Agents = PraisonAIAgents
88
100
 
89
- # Apply telemetry auto-instrumentation after all imports
101
+ # Enable PostHog telemetry by default with actual event posting
102
+ # PostHog events are posted by default unless explicitly disabled
103
+ # Users can:
104
+ # - Disable completely: PRAISONAI_DISABLE_TELEMETRY=true (or DO_NOT_TRACK=true)
105
+ # - Enable performance mode: PRAISONAI_PERFORMANCE_MODE=true (minimal overhead, limited events)
106
+ # - Enable full telemetry: PRAISONAI_FULL_TELEMETRY=true (detailed tracking)
107
+ # - Legacy opt-in mode: PRAISONAI_AUTO_INSTRUMENT=true
90
108
  if _telemetry_available:
91
109
  try:
92
- # Only instrument if telemetry is enabled
93
- _telemetry = get_telemetry()
94
- if _telemetry and _telemetry.enabled:
95
- from .telemetry.integration import auto_instrument_all
96
- auto_instrument_all(_telemetry)
110
+ import os
111
+
112
+ # Check for explicit disable (respects DO_NOT_TRACK and other disable flags)
113
+ telemetry_disabled = any([
114
+ os.environ.get('PRAISONAI_TELEMETRY_DISABLED', '').lower() in ('true', '1', 'yes'),
115
+ os.environ.get('PRAISONAI_DISABLE_TELEMETRY', '').lower() in ('true', '1', 'yes'),
116
+ os.environ.get('DO_NOT_TRACK', '').lower() in ('true', '1', 'yes'),
117
+ ])
118
+
119
+ # Check for performance mode (minimal overhead with limited events)
120
+ performance_mode = os.environ.get('PRAISONAI_PERFORMANCE_MODE', '').lower() in ('true', '1', 'yes')
121
+
122
+ # Check for full telemetry mode (more detailed tracking)
123
+ full_telemetry = os.environ.get('PRAISONAI_FULL_TELEMETRY', '').lower() in ('true', '1', 'yes')
124
+
125
+ # Legacy explicit auto-instrument option
126
+ explicit_auto_instrument = os.environ.get('PRAISONAI_AUTO_INSTRUMENT', '').lower() in ('true', '1', 'yes')
127
+
128
+ # Enable PostHog by default unless explicitly disabled
129
+ if not telemetry_disabled:
130
+ _telemetry = get_telemetry()
131
+ if _telemetry and _telemetry.enabled:
132
+ from .telemetry.integration import auto_instrument_all
133
+
134
+ # Default: PostHog telemetry is enabled and events are posted
135
+ # Performance mode can be explicitly enabled for minimal overhead
136
+ use_performance_mode = performance_mode and not (full_telemetry or explicit_auto_instrument)
137
+ auto_instrument_all(_telemetry, performance_mode=use_performance_mode)
138
+
139
+ # Track package import for basic usage analytics
140
+ try:
141
+ _telemetry.track_feature_usage("package_import")
142
+ except Exception:
143
+ pass
97
144
  except Exception:
98
- # Silently fail if there are any issues
145
+ # Silently fail if there are any issues - never break user applications
99
146
  pass
100
147
 
101
148
  __all__ = [
@@ -135,6 +182,9 @@ __all__ = [
135
182
  'get_telemetry',
136
183
  'enable_telemetry',
137
184
  'disable_telemetry',
185
+ 'enable_performance_mode',
186
+ 'disable_performance_mode',
187
+ 'cleanup_telemetry_resources',
138
188
  'MinimalTelemetry',
139
189
  'TelemetryCollector'
140
190
  ]
@@ -207,6 +207,7 @@ class Agent:
207
207
  use_system_prompt: Optional[bool] = True,
208
208
  markdown: bool = True,
209
209
  stream: bool = False,
210
+ metrics: bool = False,
210
211
  self_reflect: bool = False,
211
212
  max_reflect: int = 3,
212
213
  min_reflect: int = 1,
@@ -283,6 +284,8 @@ class Agent:
283
284
  readability and structure. Defaults to True.
284
285
  stream (bool, optional): Enable streaming responses from the language model for real-time
285
286
  output when using Agent.start() method. Defaults to False for backward compatibility.
287
+ metrics (bool, optional): Enable automatic token usage tracking and display summary
288
+ when tasks complete. Simplifies token monitoring for cost optimization. Defaults to False.
286
289
  self_reflect (bool, optional): Enable self-reflection capabilities where the agent
287
290
  evaluates and improves its own responses. Defaults to False.
288
291
  max_reflect (int, optional): Maximum number of self-reflection iterations to prevent
@@ -380,6 +383,7 @@ class Agent:
380
383
  llm_config['base_url'] = base_url
381
384
  if api_key:
382
385
  llm_config['api_key'] = api_key
386
+ llm_config['metrics'] = metrics
383
387
  self.llm_instance = LLM(**llm_config)
384
388
  else:
385
389
  # Create LLM with model string and base_url
@@ -387,7 +391,8 @@ class Agent:
387
391
  self.llm_instance = LLM(
388
392
  model=model_name,
389
393
  base_url=base_url,
390
- api_key=api_key
394
+ api_key=api_key,
395
+ metrics=metrics
391
396
  )
392
397
  self._using_custom_llm = True
393
398
  except ImportError as e:
@@ -403,6 +408,9 @@ class Agent:
403
408
  if api_key and 'api_key' not in llm:
404
409
  llm = llm.copy()
405
410
  llm['api_key'] = api_key
411
+ # Add metrics parameter
412
+ llm = llm.copy()
413
+ llm['metrics'] = metrics
406
414
  self.llm_instance = LLM(**llm) # Pass all dict items as kwargs
407
415
  self._using_custom_llm = True
408
416
  except ImportError as e:
@@ -418,6 +426,7 @@ class Agent:
418
426
  llm_params = {'model': llm}
419
427
  if api_key:
420
428
  llm_params['api_key'] = api_key
429
+ llm_params['metrics'] = metrics
421
430
  self.llm_instance = LLM(**llm_params)
422
431
  self._using_custom_llm = True
423
432
 
@@ -465,6 +474,7 @@ class Agent:
465
474
  self.chat_history = []
466
475
  self.markdown = markdown
467
476
  self.stream = stream
477
+ self.metrics = metrics
468
478
  self.max_reflect = max_reflect
469
479
  self.min_reflect = min_reflect
470
480
  self.reflect_prompt = reflect_prompt
@@ -15,6 +15,12 @@ import asyncio
15
15
  import uuid
16
16
  from enum import Enum
17
17
 
18
+ # Import token tracking
19
+ try:
20
+ from ..telemetry.token_collector import _token_collector
21
+ except ImportError:
22
+ _token_collector = None
23
+
18
24
  # Task status constants
19
25
  class TaskStatus(Enum):
20
26
  """Enumeration for task status values to ensure consistency"""
@@ -305,6 +311,11 @@ class PraisonAIAgents:
305
311
  task.status = "in progress"
306
312
 
307
313
  executor_agent = task.agent
314
+
315
+ # Set current agent for token tracking
316
+ llm = getattr(executor_agent, 'llm', None) or getattr(executor_agent, 'llm_instance', None)
317
+ if llm and hasattr(llm, 'set_current_agent'):
318
+ llm.set_current_agent(executor_agent.name)
308
319
 
309
320
  # Ensure tools are available from both task and agent
310
321
  tools = task.tools or []
@@ -401,6 +412,12 @@ Context:
401
412
  agent=executor_agent.name,
402
413
  output_format="RAW"
403
414
  )
415
+
416
+ # Add token metrics if available
417
+ if llm and hasattr(llm, 'last_token_metrics'):
418
+ token_metrics = llm.last_token_metrics
419
+ if token_metrics:
420
+ task_output.token_metrics = token_metrics
404
421
 
405
422
  if task.output_json:
406
423
  cleaned = self.clean_json_output(agent_output)
@@ -633,6 +650,11 @@ Context:
633
650
  task.status = "in progress"
634
651
 
635
652
  executor_agent = task.agent
653
+
654
+ # Set current agent for token tracking
655
+ llm = getattr(executor_agent, 'llm', None) or getattr(executor_agent, 'llm_instance', None)
656
+ if llm and hasattr(llm, 'set_current_agent'):
657
+ llm.set_current_agent(executor_agent.name)
636
658
 
637
659
  task_prompt = f"""
638
660
  You need to do the following task: {task.description}.
@@ -749,6 +771,12 @@ Context:
749
771
  agent=executor_agent.name,
750
772
  output_format="RAW"
751
773
  )
774
+
775
+ # Add token metrics if available
776
+ if llm and hasattr(llm, 'last_token_metrics'):
777
+ token_metrics = llm.last_token_metrics
778
+ if token_metrics:
779
+ task_output.token_metrics = token_metrics
752
780
 
753
781
  if task.output_json:
754
782
  cleaned = self.clean_json_output(agent_output)
@@ -905,6 +933,18 @@ Context:
905
933
  # Run tasks as before
906
934
  self.run_all_tasks()
907
935
 
936
+ # Auto-display token metrics if any agent has metrics=True
937
+ metrics_enabled = any(getattr(agent, 'metrics', False) for agent in self.agents)
938
+ if metrics_enabled:
939
+ try:
940
+ self.display_token_usage()
941
+ except (ImportError, AttributeError) as e:
942
+ # Token tracking not available or not properly configured
943
+ logging.debug(f"Could not auto-display token usage: {e}")
944
+ except Exception as e:
945
+ # Log unexpected errors for debugging
946
+ logging.debug(f"Unexpected error in token metrics display: {e}")
947
+
908
948
  # Get results
909
949
  results = {
910
950
  "task_status": self.get_all_tasks_status(),
@@ -924,6 +964,10 @@ Context:
924
964
  # Return full results dict if return_dict is True or if no final result was found
925
965
  return results
926
966
 
967
+ def run(self, content=None, return_dict=False, **kwargs):
968
+ """Alias for start() method to provide consistent API with Agent class"""
969
+ return self.start(content=content, return_dict=return_dict, **kwargs)
970
+
927
971
  def set_state(self, key: str, value: Any) -> None:
928
972
  """Set a state value"""
929
973
  self._state[key] = value
@@ -1038,6 +1082,77 @@ Context:
1038
1082
  return True
1039
1083
 
1040
1084
  return False
1085
+
1086
+ def get_token_usage_summary(self) -> Dict[str, Any]:
1087
+ """Get a summary of token usage across all agents and tasks."""
1088
+ if not _token_collector:
1089
+ return {"error": "Token tracking not available"}
1090
+
1091
+ return _token_collector.get_session_summary()
1092
+
1093
+ def get_detailed_token_report(self) -> Dict[str, Any]:
1094
+ """Get a detailed token usage report."""
1095
+ if not _token_collector:
1096
+ return {"error": "Token tracking not available"}
1097
+
1098
+ summary = _token_collector.get_session_summary()
1099
+ recent = _token_collector.get_recent_interactions(limit=20)
1100
+
1101
+ # Calculate cost estimates (example rates)
1102
+ cost_per_1k_input = 0.0005 # $0.0005 per 1K input tokens
1103
+ cost_per_1k_output = 0.0015 # $0.0015 per 1K output tokens
1104
+
1105
+ total_metrics = summary.get("total_metrics", {})
1106
+ input_cost = (total_metrics.get("input_tokens", 0) / 1000) * cost_per_1k_input
1107
+ output_cost = (total_metrics.get("output_tokens", 0) / 1000) * cost_per_1k_output
1108
+ total_cost = input_cost + output_cost
1109
+
1110
+ return {
1111
+ "summary": summary,
1112
+ "recent_interactions": recent,
1113
+ "cost_estimate": {
1114
+ "input_cost": f"${input_cost:.4f}",
1115
+ "output_cost": f"${output_cost:.4f}",
1116
+ "total_cost": f"${total_cost:.4f}",
1117
+ "note": "Cost estimates based on example rates"
1118
+ }
1119
+ }
1120
+
1121
+ def display_token_usage(self):
1122
+ """Display token usage in a formatted table."""
1123
+ if not _token_collector:
1124
+ print("Token tracking not available")
1125
+ return
1126
+
1127
+ summary = _token_collector.get_session_summary()
1128
+
1129
+ print("\n" + "="*50)
1130
+ print("TOKEN USAGE SUMMARY")
1131
+ print("="*50)
1132
+
1133
+ total_metrics = summary.get("total_metrics", {})
1134
+ print(f"\nTotal Interactions: {summary.get('total_interactions', 0)}")
1135
+ print(f"Total Tokens: {total_metrics.get('total_tokens', 0):,}")
1136
+ print(f" - Input Tokens: {total_metrics.get('input_tokens', 0):,}")
1137
+ print(f" - Output Tokens: {total_metrics.get('output_tokens', 0):,}")
1138
+ print(f" - Cached Tokens: {total_metrics.get('cached_tokens', 0):,}")
1139
+ print(f" - Reasoning Tokens: {total_metrics.get('reasoning_tokens', 0):,}")
1140
+
1141
+ # By model
1142
+ by_model = summary.get("by_model", {})
1143
+ if by_model:
1144
+ print("\nUsage by Model:")
1145
+ for model, metrics in by_model.items():
1146
+ print(f" {model}: {metrics.get('total_tokens', 0):,} tokens")
1147
+
1148
+ # By agent
1149
+ by_agent = summary.get("by_agent", {})
1150
+ if by_agent:
1151
+ print("\nUsage by Agent:")
1152
+ for agent, metrics in by_agent.items():
1153
+ print(f" {agent}: {metrics.get('total_tokens', 0):,} tokens")
1154
+
1155
+ print("="*50 + "\n")
1041
1156
 
1042
1157
  def launch(self, path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = "http"):
1043
1158
  """
@@ -1416,4 +1531,4 @@ Context:
1416
1531
  return None
1417
1532
  else:
1418
1533
  display_error(f"Invalid protocol: {protocol}. Choose 'http' or 'mcp'.")
1419
- return None
1534
+ return None
@@ -20,6 +20,13 @@ from ..main import (
20
20
  from rich.console import Console
21
21
  from rich.live import Live
22
22
 
23
+ # Import token tracking
24
+ try:
25
+ from ..telemetry.token_collector import TokenMetrics, _token_collector
26
+ except ImportError:
27
+ TokenMetrics = None
28
+ _token_collector = None
29
+
23
30
  # Logging is already configured in _logging.py via __init__.py
24
31
 
25
32
  # TODO: Include in-build tool calling in LLM class
@@ -253,6 +260,12 @@ class LLM:
253
260
  self.max_reflect = extra_settings.get('max_reflect', 3)
254
261
  self.min_reflect = extra_settings.get('min_reflect', 1)
255
262
  self.reasoning_steps = extra_settings.get('reasoning_steps', False)
263
+ self.metrics = extra_settings.get('metrics', False)
264
+
265
+ # Token tracking
266
+ self.last_token_metrics: Optional[TokenMetrics] = None
267
+ self.session_token_metrics: Optional[TokenMetrics] = None
268
+ self.current_agent_name: Optional[str] = None
256
269
 
257
270
  # Enable error dropping for cleaner output
258
271
  litellm.drop_params = True
@@ -941,6 +954,10 @@ class LLM:
941
954
  response_text = resp["choices"][0]["message"]["content"]
942
955
  final_response = resp
943
956
 
957
+ # Track token usage
958
+ if self.metrics:
959
+ self._track_token_usage(final_response, model)
960
+
944
961
  # Execute callbacks and display based on verbose setting
945
962
  generation_time_val = time.time() - current_time
946
963
  response_content = f"Reasoning:\n{reasoning_content}\n\nAnswer:\n{response_text}" if reasoning_content else response_text
@@ -1118,6 +1135,10 @@ class LLM:
1118
1135
  # Handle None content from Gemini
1119
1136
  response_content = final_response["choices"][0]["message"].get("content")
1120
1137
  response_text = response_content if response_content is not None else ""
1138
+
1139
+ # Track token usage
1140
+ if self.metrics:
1141
+ self._track_token_usage(final_response, self.model)
1121
1142
 
1122
1143
  # Execute callbacks and display based on verbose setting
1123
1144
  if verbose and not interaction_displayed:
@@ -1264,6 +1285,10 @@ class LLM:
1264
1285
  # Handle None content from Gemini
1265
1286
  response_content = final_response["choices"][0]["message"].get("content")
1266
1287
  response_text = response_content if response_content is not None else ""
1288
+
1289
+ # Track token usage
1290
+ if self.metrics:
1291
+ self._track_token_usage(final_response, self.model)
1267
1292
 
1268
1293
  # Execute callbacks and display based on verbose setting
1269
1294
  if verbose and not interaction_displayed:
@@ -2861,6 +2886,58 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
2861
2886
 
2862
2887
  litellm.callbacks = events
2863
2888
 
2889
+ def _track_token_usage(self, response: Dict[str, Any], model: str) -> Optional[TokenMetrics]:
2890
+ """Extract and track token usage from LLM response."""
2891
+ if not TokenMetrics or not _token_collector:
2892
+ return None
2893
+
2894
+ # Note: metrics check moved to call sites for performance
2895
+ # This method should only be called when self.metrics=True
2896
+
2897
+ try:
2898
+ usage = response.get("usage", {})
2899
+ if not usage:
2900
+ return None
2901
+
2902
+ # Extract token counts
2903
+ metrics = TokenMetrics(
2904
+ input_tokens=usage.get("prompt_tokens", 0),
2905
+ output_tokens=usage.get("completion_tokens", 0),
2906
+ cached_tokens=usage.get("cached_tokens", 0),
2907
+ reasoning_tokens=usage.get("reasoning_tokens", 0),
2908
+ audio_input_tokens=usage.get("audio_input_tokens", 0),
2909
+ audio_output_tokens=usage.get("audio_output_tokens", 0)
2910
+ )
2911
+
2912
+ # Store metrics
2913
+ self.last_token_metrics = metrics
2914
+
2915
+ # Update session metrics
2916
+ if not self.session_token_metrics:
2917
+ self.session_token_metrics = TokenMetrics()
2918
+ self.session_token_metrics = self.session_token_metrics + metrics
2919
+
2920
+ # Track in global collector
2921
+ _token_collector.track_tokens(
2922
+ model=model,
2923
+ agent=self.current_agent_name,
2924
+ metrics=metrics,
2925
+ metadata={
2926
+ "provider": self.provider,
2927
+ "stream": False
2928
+ }
2929
+ )
2930
+
2931
+ return metrics
2932
+
2933
+ except Exception as e:
2934
+ if self.verbose:
2935
+ logging.warning(f"Failed to track token usage: {e}")
2936
+ return None
2937
+
2938
+ def set_current_agent(self, agent_name: Optional[str]):
2939
+ """Set the current agent name for token tracking."""
2940
+ self.current_agent_name = agent_name
2864
2941
 
2865
2942
  def _build_completion_params(self, **override_params) -> Dict[str, Any]:
2866
2943
  """Build parameters for litellm completion calls with all necessary config"""
praisonaiagents/main.py CHANGED
@@ -12,6 +12,12 @@ from rich.markdown import Markdown
12
12
  from rich.live import Live
13
13
  import asyncio
14
14
 
15
+ # Import token metrics if available
16
+ try:
17
+ from .telemetry.token_collector import TokenMetrics
18
+ except ImportError:
19
+ TokenMetrics = None
20
+
15
21
  # Logging is already configured in _logging.py via __init__.py
16
22
 
17
23
  # Global list to store error logs
@@ -415,6 +421,7 @@ class TaskOutput(BaseModel):
415
421
  json_dict: Optional[Dict[str, Any]] = None
416
422
  agent: str
417
423
  output_format: Literal["RAW", "JSON", "Pydantic"] = "RAW"
424
+ token_metrics: Optional['TokenMetrics'] = None # Add token metrics field
418
425
 
419
426
  def json(self) -> Optional[str]:
420
427
  if self.output_format == "JSON" and self.json_dict:
@@ -5,12 +5,14 @@ over HTTP Stream transport, implementing the Streamable HTTP transport protocol.
5
5
  """
6
6
 
7
7
  import asyncio
8
+ import atexit
8
9
  import logging
9
10
  import threading
10
11
  import inspect
11
12
  import json
12
13
  import time
13
14
  import uuid
15
+ import weakref
14
16
  from typing import List, Dict, Any, Optional, Callable, Iterable, Union
15
17
  from urllib.parse import urlparse, urljoin
16
18
 
@@ -25,6 +27,10 @@ logger = logging.getLogger("mcp-http-stream")
25
27
  # Global event loop for async operations
26
28
  _event_loop = None
27
29
 
30
+ # Global registry of active clients for cleanup
31
+ _active_clients = weakref.WeakSet()
32
+ _cleanup_registered = False
33
+
28
34
  def get_event_loop():
29
35
  """Get or create a global event loop."""
30
36
  global _event_loop
@@ -34,6 +40,31 @@ def get_event_loop():
34
40
  return _event_loop
35
41
 
36
42
 
43
+ def _cleanup_all_clients():
44
+ """Clean up all active clients at program exit."""
45
+ if not _active_clients:
46
+ return
47
+
48
+ # Create a copy to avoid modification during iteration
49
+ clients_to_cleanup = list(_active_clients)
50
+
51
+ for client in clients_to_cleanup:
52
+ try:
53
+ if hasattr(client, '_force_cleanup'):
54
+ client._force_cleanup()
55
+ except Exception:
56
+ # Ignore exceptions during cleanup
57
+ pass
58
+
59
+
60
+ def _register_cleanup():
61
+ """Register the cleanup function to run at program exit."""
62
+ global _cleanup_registered
63
+ if not _cleanup_registered:
64
+ atexit.register(_cleanup_all_clients)
65
+ _cleanup_registered = True
66
+
67
+
37
68
  class HTTPStreamMCPTool:
38
69
  """A wrapper for an MCP tool that can be used with praisonaiagents."""
39
70
 
@@ -192,14 +223,20 @@ class HTTPStreamTransport:
192
223
  self._message_queue = asyncio.Queue()
193
224
  self._pending_requests = {}
194
225
  self._closing = False
226
+ self._closed = False
195
227
 
196
228
  async def __aenter__(self):
197
229
  self._session = aiohttp.ClientSession()
198
230
  return self
199
231
 
200
232
  async def __aexit__(self, exc_type, exc_val, exc_tb):
233
+ # Prevent double closing
234
+ if self._closed:
235
+ return
236
+
201
237
  # Set closing flag to stop listener gracefully
202
238
  self._closing = True
239
+ self._closed = True
203
240
 
204
241
  if self._sse_task:
205
242
  self._sse_task.cancel()
@@ -210,6 +247,13 @@ class HTTPStreamTransport:
210
247
  if self._session:
211
248
  await self._session.close()
212
249
 
250
+ def __del__(self):
251
+ """Lightweight cleanup during garbage collection."""
252
+ # Note: We cannot safely run async cleanup in __del__
253
+ # The best practice is to use async context managers or explicit close() calls
254
+ pass
255
+
256
+
213
257
  async def send_request(self, request: Dict[str, Any]) -> Union[Dict[str, Any], None]:
214
258
  """Send a request to the HTTP Stream endpoint."""
215
259
  if not self._session:
@@ -375,6 +419,7 @@ class HTTPStreamMCPClient:
375
419
  self.session = None
376
420
  self.tools = []
377
421
  self.transport = None
422
+ self._closed = False
378
423
 
379
424
  # Set up logging
380
425
  if debug:
@@ -383,6 +428,10 @@ class HTTPStreamMCPClient:
383
428
  # Set to WARNING by default to hide INFO messages
384
429
  logger.setLevel(logging.WARNING)
385
430
 
431
+ # Register this client for cleanup and setup exit handler
432
+ _active_clients.add(self)
433
+ _register_cleanup()
434
+
386
435
  self._initialize()
387
436
 
388
437
  def _initialize(self):
@@ -443,6 +492,10 @@ class HTTPStreamMCPClient:
443
492
  timeout=self.timeout
444
493
  )
445
494
  tools.append(wrapper)
495
+
496
+ # Set up cleanup finalizer now that transport and session are created
497
+ self._finalizer = weakref.finalize(self, self._static_cleanup,
498
+ self.transport, self._session_context)
446
499
 
447
500
  return tools
448
501
 
@@ -460,7 +513,101 @@ class HTTPStreamMCPClient:
460
513
 
461
514
  async def __aexit__(self, exc_type, exc_val, exc_tb):
462
515
  """Async context manager exit."""
463
- if self.transport:
464
- await self.transport.__aexit__(exc_type, exc_val, exc_tb)
465
- if hasattr(self, '_session_context') and self._session_context:
466
- await self._session_context.__aexit__(exc_type, exc_val, exc_tb)
516
+ await self.aclose()
517
+
518
+ async def aclose(self):
519
+ """Async cleanup method to close all resources."""
520
+ if self._closed:
521
+ return
522
+
523
+ self._closed = True
524
+
525
+ try:
526
+ if hasattr(self, '_session_context') and self._session_context:
527
+ await self._session_context.__aexit__(None, None, None)
528
+ except Exception:
529
+ pass
530
+
531
+ try:
532
+ if self.transport:
533
+ await self.transport.__aexit__(None, None, None)
534
+ except Exception:
535
+ pass
536
+
537
+ def close(self):
538
+ """Synchronous cleanup method to close all resources."""
539
+ if self._closed:
540
+ return
541
+
542
+ try:
543
+ # Use the global event loop for non-blocking cleanup
544
+ loop = get_event_loop()
545
+ if not loop.is_closed():
546
+ # Schedule cleanup without blocking - add callback for fallback
547
+ future = asyncio.run_coroutine_threadsafe(self.aclose(), loop)
548
+
549
+ # Add a completion callback for fallback cleanup if async fails
550
+ def _cleanup_callback(fut):
551
+ try:
552
+ fut.result() # This will raise if aclose() failed
553
+ except Exception:
554
+ # If async cleanup failed, try force cleanup
555
+ try:
556
+ self._force_cleanup()
557
+ except Exception:
558
+ pass
559
+
560
+ future.add_done_callback(_cleanup_callback)
561
+ else:
562
+ # Event loop is closed, use force cleanup immediately
563
+ self._force_cleanup()
564
+ except Exception:
565
+ # If async scheduling fails, try force cleanup
566
+ self._force_cleanup()
567
+
568
+ def _force_cleanup(self):
569
+ """Force cleanup of resources synchronously (for emergencies)."""
570
+ if self._closed:
571
+ return
572
+
573
+ self._closed = True
574
+
575
+ # Force close transport session if it exists
576
+ try:
577
+ if self.transport and hasattr(self.transport, '_session') and self.transport._session:
578
+ session = self.transport._session
579
+ if not session.closed:
580
+ # Force close the aiohttp session
581
+ if hasattr(session, '_connector') and session._connector:
582
+ try:
583
+ # Close connector directly
584
+ session._connector.close()
585
+ except Exception:
586
+ pass
587
+ except Exception:
588
+ pass
589
+
590
+ @staticmethod
591
+ def _static_cleanup(transport, session_context):
592
+ """Static cleanup method for weakref finalizer."""
593
+ try:
594
+ # This is called by weakref finalizer, so we can't do async operations
595
+ # Just ensure any session is closed if possible
596
+ if transport and hasattr(transport, '_session') and transport._session:
597
+ session = transport._session
598
+ if not session.closed and hasattr(session, '_connector'):
599
+ try:
600
+ session._connector.close()
601
+ except Exception:
602
+ pass
603
+ except Exception:
604
+ pass
605
+
606
+ def __del__(self):
607
+ """Cleanup when object is garbage collected."""
608
+ try:
609
+ if not self._closed:
610
+ self._force_cleanup()
611
+ except Exception:
612
+ # Never raise exceptions in __del__
613
+ pass