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.
- praisonaiagents/__init__.py +57 -7
- praisonaiagents/agent/agent.py +11 -1
- praisonaiagents/agents/agents.py +116 -1
- praisonaiagents/llm/llm.py +77 -0
- praisonaiagents/main.py +7 -0
- praisonaiagents/mcp/mcp_http_stream.py +151 -4
- praisonaiagents/telemetry/__init__.py +31 -3
- praisonaiagents/telemetry/integration.py +385 -84
- praisonaiagents/telemetry/performance_monitor.py +162 -1
- praisonaiagents/telemetry/performance_utils.py +35 -7
- praisonaiagents/telemetry/telemetry.py +145 -42
- praisonaiagents/telemetry/token_collector.py +170 -0
- praisonaiagents/telemetry/token_telemetry.py +89 -0
- {praisonaiagents-0.0.152.dist-info → praisonaiagents-0.0.154.dist-info}/METADATA +1 -1
- {praisonaiagents-0.0.152.dist-info → praisonaiagents-0.0.154.dist-info}/RECORD +17 -15
- {praisonaiagents-0.0.152.dist-info → praisonaiagents-0.0.154.dist-info}/WHEEL +0 -0
- {praisonaiagents-0.0.152.dist-info → praisonaiagents-0.0.154.dist-info}/top_level.txt +0 -0
praisonaiagents/__init__.py
CHANGED
@@ -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
|
-
#
|
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
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
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
|
]
|
praisonaiagents/agent/agent.py
CHANGED
@@ -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
|
praisonaiagents/agents/agents.py
CHANGED
@@ -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
|
praisonaiagents/llm/llm.py
CHANGED
@@ -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
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
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
|