massgen 0.0.3__py3-none-any.whl → 0.1.0__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.

Potentially problematic release.


This version of massgen might be problematic. Click here for more details.

Files changed (268) hide show
  1. massgen/__init__.py +142 -8
  2. massgen/adapters/__init__.py +29 -0
  3. massgen/adapters/ag2_adapter.py +483 -0
  4. massgen/adapters/base.py +183 -0
  5. massgen/adapters/tests/__init__.py +0 -0
  6. massgen/adapters/tests/test_ag2_adapter.py +439 -0
  7. massgen/adapters/tests/test_agent_adapter.py +128 -0
  8. massgen/adapters/utils/__init__.py +2 -0
  9. massgen/adapters/utils/ag2_utils.py +236 -0
  10. massgen/adapters/utils/tests/__init__.py +0 -0
  11. massgen/adapters/utils/tests/test_ag2_utils.py +138 -0
  12. massgen/agent_config.py +329 -55
  13. massgen/api_params_handler/__init__.py +10 -0
  14. massgen/api_params_handler/_api_params_handler_base.py +99 -0
  15. massgen/api_params_handler/_chat_completions_api_params_handler.py +176 -0
  16. massgen/api_params_handler/_claude_api_params_handler.py +113 -0
  17. massgen/api_params_handler/_response_api_params_handler.py +130 -0
  18. massgen/backend/__init__.py +39 -4
  19. massgen/backend/azure_openai.py +385 -0
  20. massgen/backend/base.py +341 -69
  21. massgen/backend/base_with_mcp.py +1102 -0
  22. massgen/backend/capabilities.py +386 -0
  23. massgen/backend/chat_completions.py +577 -130
  24. massgen/backend/claude.py +1033 -537
  25. massgen/backend/claude_code.py +1203 -0
  26. massgen/backend/cli_base.py +209 -0
  27. massgen/backend/docs/BACKEND_ARCHITECTURE.md +126 -0
  28. massgen/backend/{CLAUDE_API_RESEARCH.md → docs/CLAUDE_API_RESEARCH.md} +18 -18
  29. massgen/backend/{GEMINI_API_DOCUMENTATION.md → docs/GEMINI_API_DOCUMENTATION.md} +9 -9
  30. massgen/backend/docs/Gemini MCP Integration Analysis.md +1050 -0
  31. massgen/backend/docs/MCP_IMPLEMENTATION_CLAUDE_BACKEND.md +177 -0
  32. massgen/backend/docs/MCP_INTEGRATION_RESPONSE_BACKEND.md +352 -0
  33. massgen/backend/docs/OPENAI_GPT5_MODELS.md +211 -0
  34. massgen/backend/{OPENAI_RESPONSES_API_FORMAT.md → docs/OPENAI_RESPONSE_API_TOOL_CALLS.md} +3 -3
  35. massgen/backend/docs/OPENAI_response_streaming.md +20654 -0
  36. massgen/backend/docs/inference_backend.md +257 -0
  37. massgen/backend/docs/permissions_and_context_files.md +1085 -0
  38. massgen/backend/external.py +126 -0
  39. massgen/backend/gemini.py +1850 -241
  40. massgen/backend/grok.py +40 -156
  41. massgen/backend/inference.py +156 -0
  42. massgen/backend/lmstudio.py +171 -0
  43. massgen/backend/response.py +1095 -322
  44. massgen/chat_agent.py +131 -113
  45. massgen/cli.py +1560 -275
  46. massgen/config_builder.py +2396 -0
  47. massgen/configs/BACKEND_CONFIGURATION.md +458 -0
  48. massgen/configs/README.md +559 -216
  49. massgen/configs/ag2/ag2_case_study.yaml +27 -0
  50. massgen/configs/ag2/ag2_coder.yaml +34 -0
  51. massgen/configs/ag2/ag2_coder_case_study.yaml +36 -0
  52. massgen/configs/ag2/ag2_gemini.yaml +27 -0
  53. massgen/configs/ag2/ag2_groupchat.yaml +108 -0
  54. massgen/configs/ag2/ag2_groupchat_gpt.yaml +118 -0
  55. massgen/configs/ag2/ag2_single_agent.yaml +21 -0
  56. massgen/configs/basic/multi/fast_timeout_example.yaml +37 -0
  57. massgen/configs/basic/multi/gemini_4o_claude.yaml +31 -0
  58. massgen/configs/basic/multi/gemini_gpt5nano_claude.yaml +36 -0
  59. massgen/configs/{gemini_4o_claude.yaml → basic/multi/geminicode_4o_claude.yaml} +3 -3
  60. massgen/configs/basic/multi/geminicode_gpt5nano_claude.yaml +36 -0
  61. massgen/configs/basic/multi/glm_gemini_claude.yaml +25 -0
  62. massgen/configs/basic/multi/gpt4o_audio_generation.yaml +30 -0
  63. massgen/configs/basic/multi/gpt4o_image_generation.yaml +31 -0
  64. massgen/configs/basic/multi/gpt5nano_glm_qwen.yaml +26 -0
  65. massgen/configs/basic/multi/gpt5nano_image_understanding.yaml +26 -0
  66. massgen/configs/{three_agents_default.yaml → basic/multi/three_agents_default.yaml} +8 -4
  67. massgen/configs/basic/multi/three_agents_opensource.yaml +27 -0
  68. massgen/configs/basic/multi/three_agents_vllm.yaml +20 -0
  69. massgen/configs/basic/multi/two_agents_gemini.yaml +19 -0
  70. massgen/configs/{two_agents.yaml → basic/multi/two_agents_gpt5.yaml} +14 -6
  71. massgen/configs/basic/multi/two_agents_opensource_lmstudio.yaml +31 -0
  72. massgen/configs/basic/multi/two_qwen_vllm_sglang.yaml +28 -0
  73. massgen/configs/{single_agent.yaml → basic/single/single_agent.yaml} +1 -1
  74. massgen/configs/{single_flash2.5.yaml → basic/single/single_flash2.5.yaml} +1 -2
  75. massgen/configs/basic/single/single_gemini2.5pro.yaml +16 -0
  76. massgen/configs/basic/single/single_gpt4o_audio_generation.yaml +22 -0
  77. massgen/configs/basic/single/single_gpt4o_image_generation.yaml +22 -0
  78. massgen/configs/basic/single/single_gpt4o_video_generation.yaml +24 -0
  79. massgen/configs/basic/single/single_gpt5nano.yaml +20 -0
  80. massgen/configs/basic/single/single_gpt5nano_file_search.yaml +18 -0
  81. massgen/configs/basic/single/single_gpt5nano_image_understanding.yaml +17 -0
  82. massgen/configs/basic/single/single_gptoss120b.yaml +15 -0
  83. massgen/configs/basic/single/single_openrouter_audio_understanding.yaml +15 -0
  84. massgen/configs/basic/single/single_qwen_video_understanding.yaml +15 -0
  85. massgen/configs/debug/code_execution/command_filtering_blacklist.yaml +29 -0
  86. massgen/configs/debug/code_execution/command_filtering_whitelist.yaml +28 -0
  87. massgen/configs/debug/code_execution/docker_verification.yaml +29 -0
  88. massgen/configs/debug/skip_coordination_test.yaml +27 -0
  89. massgen/configs/debug/test_sdk_migration.yaml +17 -0
  90. massgen/configs/docs/DISCORD_MCP_SETUP.md +208 -0
  91. massgen/configs/docs/TWITTER_MCP_ENESCINAR_SETUP.md +82 -0
  92. massgen/configs/providers/azure/azure_openai_multi.yaml +21 -0
  93. massgen/configs/providers/azure/azure_openai_single.yaml +19 -0
  94. massgen/configs/providers/claude/claude.yaml +14 -0
  95. massgen/configs/providers/gemini/gemini_gpt5nano.yaml +28 -0
  96. massgen/configs/providers/local/lmstudio.yaml +11 -0
  97. massgen/configs/providers/openai/gpt5.yaml +46 -0
  98. massgen/configs/providers/openai/gpt5_nano.yaml +46 -0
  99. massgen/configs/providers/others/grok_single_agent.yaml +19 -0
  100. massgen/configs/providers/others/zai_coding_team.yaml +108 -0
  101. massgen/configs/providers/others/zai_glm45.yaml +12 -0
  102. massgen/configs/{creative_team.yaml → teams/creative/creative_team.yaml} +16 -6
  103. massgen/configs/{travel_planning.yaml → teams/creative/travel_planning.yaml} +16 -6
  104. massgen/configs/{news_analysis.yaml → teams/research/news_analysis.yaml} +16 -6
  105. massgen/configs/{research_team.yaml → teams/research/research_team.yaml} +15 -7
  106. massgen/configs/{technical_analysis.yaml → teams/research/technical_analysis.yaml} +16 -6
  107. massgen/configs/tools/code-execution/basic_command_execution.yaml +25 -0
  108. massgen/configs/tools/code-execution/code_execution_use_case_simple.yaml +41 -0
  109. massgen/configs/tools/code-execution/docker_claude_code.yaml +32 -0
  110. massgen/configs/tools/code-execution/docker_multi_agent.yaml +32 -0
  111. massgen/configs/tools/code-execution/docker_simple.yaml +29 -0
  112. massgen/configs/tools/code-execution/docker_with_resource_limits.yaml +32 -0
  113. massgen/configs/tools/code-execution/multi_agent_playwright_automation.yaml +57 -0
  114. massgen/configs/tools/filesystem/cc_gpt5_gemini_filesystem.yaml +34 -0
  115. massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +68 -0
  116. massgen/configs/tools/filesystem/claude_code_flash2.5.yaml +43 -0
  117. massgen/configs/tools/filesystem/claude_code_flash2.5_gptoss.yaml +49 -0
  118. massgen/configs/tools/filesystem/claude_code_gpt5nano.yaml +31 -0
  119. massgen/configs/tools/filesystem/claude_code_single.yaml +40 -0
  120. massgen/configs/tools/filesystem/fs_permissions_test.yaml +87 -0
  121. massgen/configs/tools/filesystem/gemini_gemini_workspace_cleanup.yaml +54 -0
  122. massgen/configs/tools/filesystem/gemini_gpt5_filesystem_casestudy.yaml +30 -0
  123. massgen/configs/tools/filesystem/gemini_gpt5nano_file_context_path.yaml +43 -0
  124. massgen/configs/tools/filesystem/gemini_gpt5nano_protected_paths.yaml +45 -0
  125. massgen/configs/tools/filesystem/gpt5mini_cc_fs_context_path.yaml +31 -0
  126. massgen/configs/tools/filesystem/grok4_gpt5_gemini_filesystem.yaml +32 -0
  127. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_claude_code_filesystem_multiturn.yaml +58 -0
  128. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_gemini_filesystem_multiturn.yaml +58 -0
  129. massgen/configs/tools/filesystem/multiturn/two_claude_code_filesystem_multiturn.yaml +47 -0
  130. massgen/configs/tools/filesystem/multiturn/two_gemini_flash_filesystem_multiturn.yaml +48 -0
  131. massgen/configs/tools/mcp/claude_code_discord_mcp_example.yaml +27 -0
  132. massgen/configs/tools/mcp/claude_code_simple_mcp.yaml +35 -0
  133. massgen/configs/tools/mcp/claude_code_twitter_mcp_example.yaml +32 -0
  134. massgen/configs/tools/mcp/claude_mcp_example.yaml +24 -0
  135. massgen/configs/tools/mcp/claude_mcp_test.yaml +27 -0
  136. massgen/configs/tools/mcp/five_agents_travel_mcp_test.yaml +157 -0
  137. massgen/configs/tools/mcp/five_agents_weather_mcp_test.yaml +103 -0
  138. massgen/configs/tools/mcp/gemini_mcp_example.yaml +24 -0
  139. massgen/configs/tools/mcp/gemini_mcp_filesystem_test.yaml +23 -0
  140. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_sharing.yaml +23 -0
  141. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_single_agent.yaml +17 -0
  142. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_with_claude_code.yaml +24 -0
  143. massgen/configs/tools/mcp/gemini_mcp_test.yaml +27 -0
  144. massgen/configs/tools/mcp/gemini_notion_mcp.yaml +52 -0
  145. massgen/configs/tools/mcp/gpt5_nano_mcp_example.yaml +24 -0
  146. massgen/configs/tools/mcp/gpt5_nano_mcp_test.yaml +27 -0
  147. massgen/configs/tools/mcp/gpt5mini_claude_code_discord_mcp_example.yaml +38 -0
  148. massgen/configs/tools/mcp/gpt_oss_mcp_example.yaml +25 -0
  149. massgen/configs/tools/mcp/gpt_oss_mcp_test.yaml +28 -0
  150. massgen/configs/tools/mcp/grok3_mini_mcp_example.yaml +24 -0
  151. massgen/configs/tools/mcp/grok3_mini_mcp_test.yaml +27 -0
  152. massgen/configs/tools/mcp/multimcp_gemini.yaml +111 -0
  153. massgen/configs/tools/mcp/qwen_api_mcp_example.yaml +25 -0
  154. massgen/configs/tools/mcp/qwen_api_mcp_test.yaml +28 -0
  155. massgen/configs/tools/mcp/qwen_local_mcp_example.yaml +24 -0
  156. massgen/configs/tools/mcp/qwen_local_mcp_test.yaml +27 -0
  157. massgen/configs/tools/planning/five_agents_discord_mcp_planning_mode.yaml +140 -0
  158. massgen/configs/tools/planning/five_agents_filesystem_mcp_planning_mode.yaml +151 -0
  159. massgen/configs/tools/planning/five_agents_notion_mcp_planning_mode.yaml +151 -0
  160. massgen/configs/tools/planning/five_agents_twitter_mcp_planning_mode.yaml +155 -0
  161. massgen/configs/tools/planning/gpt5_mini_case_study_mcp_planning_mode.yaml +73 -0
  162. massgen/configs/tools/web-search/claude_streamable_http_test.yaml +43 -0
  163. massgen/configs/tools/web-search/gemini_streamable_http_test.yaml +43 -0
  164. massgen/configs/tools/web-search/gpt5_mini_streamable_http_test.yaml +43 -0
  165. massgen/configs/tools/web-search/gpt_oss_streamable_http_test.yaml +44 -0
  166. massgen/configs/tools/web-search/grok3_mini_streamable_http_test.yaml +43 -0
  167. massgen/configs/tools/web-search/qwen_api_streamable_http_test.yaml +44 -0
  168. massgen/configs/tools/web-search/qwen_local_streamable_http_test.yaml +43 -0
  169. massgen/coordination_tracker.py +708 -0
  170. massgen/docker/README.md +462 -0
  171. massgen/filesystem_manager/__init__.py +21 -0
  172. massgen/filesystem_manager/_base.py +9 -0
  173. massgen/filesystem_manager/_code_execution_server.py +545 -0
  174. massgen/filesystem_manager/_docker_manager.py +477 -0
  175. massgen/filesystem_manager/_file_operation_tracker.py +248 -0
  176. massgen/filesystem_manager/_filesystem_manager.py +813 -0
  177. massgen/filesystem_manager/_path_permission_manager.py +1261 -0
  178. massgen/filesystem_manager/_workspace_tools_server.py +1815 -0
  179. massgen/formatter/__init__.py +10 -0
  180. massgen/formatter/_chat_completions_formatter.py +284 -0
  181. massgen/formatter/_claude_formatter.py +235 -0
  182. massgen/formatter/_formatter_base.py +156 -0
  183. massgen/formatter/_response_formatter.py +263 -0
  184. massgen/frontend/__init__.py +1 -2
  185. massgen/frontend/coordination_ui.py +471 -286
  186. massgen/frontend/displays/base_display.py +56 -11
  187. massgen/frontend/displays/create_coordination_table.py +1956 -0
  188. massgen/frontend/displays/rich_terminal_display.py +1259 -619
  189. massgen/frontend/displays/simple_display.py +9 -4
  190. massgen/frontend/displays/terminal_display.py +27 -68
  191. massgen/logger_config.py +681 -0
  192. massgen/mcp_tools/README.md +232 -0
  193. massgen/mcp_tools/__init__.py +105 -0
  194. massgen/mcp_tools/backend_utils.py +1035 -0
  195. massgen/mcp_tools/circuit_breaker.py +195 -0
  196. massgen/mcp_tools/client.py +894 -0
  197. massgen/mcp_tools/config_validator.py +138 -0
  198. massgen/mcp_tools/docs/circuit_breaker.md +646 -0
  199. massgen/mcp_tools/docs/client.md +950 -0
  200. massgen/mcp_tools/docs/config_validator.md +478 -0
  201. massgen/mcp_tools/docs/exceptions.md +1165 -0
  202. massgen/mcp_tools/docs/security.md +854 -0
  203. massgen/mcp_tools/exceptions.py +338 -0
  204. massgen/mcp_tools/hooks.py +212 -0
  205. massgen/mcp_tools/security.py +780 -0
  206. massgen/message_templates.py +342 -64
  207. massgen/orchestrator.py +1515 -241
  208. massgen/stream_chunk/__init__.py +35 -0
  209. massgen/stream_chunk/base.py +92 -0
  210. massgen/stream_chunk/multimodal.py +237 -0
  211. massgen/stream_chunk/text.py +162 -0
  212. massgen/tests/mcp_test_server.py +150 -0
  213. massgen/tests/multi_turn_conversation_design.md +0 -8
  214. massgen/tests/test_azure_openai_backend.py +156 -0
  215. massgen/tests/test_backend_capabilities.py +262 -0
  216. massgen/tests/test_backend_event_loop_all.py +179 -0
  217. massgen/tests/test_chat_completions_refactor.py +142 -0
  218. massgen/tests/test_claude_backend.py +15 -28
  219. massgen/tests/test_claude_code.py +268 -0
  220. massgen/tests/test_claude_code_context_sharing.py +233 -0
  221. massgen/tests/test_claude_code_orchestrator.py +175 -0
  222. massgen/tests/test_cli_backends.py +180 -0
  223. massgen/tests/test_code_execution.py +679 -0
  224. massgen/tests/test_external_agent_backend.py +134 -0
  225. massgen/tests/test_final_presentation_fallback.py +237 -0
  226. massgen/tests/test_gemini_planning_mode.py +351 -0
  227. massgen/tests/test_grok_backend.py +7 -10
  228. massgen/tests/test_http_mcp_server.py +42 -0
  229. massgen/tests/test_integration_simple.py +198 -0
  230. massgen/tests/test_mcp_blocking.py +125 -0
  231. massgen/tests/test_message_context_building.py +29 -47
  232. massgen/tests/test_orchestrator_final_presentation.py +48 -0
  233. massgen/tests/test_path_permission_manager.py +2087 -0
  234. massgen/tests/test_rich_terminal_display.py +14 -13
  235. massgen/tests/test_timeout.py +133 -0
  236. massgen/tests/test_v3_3agents.py +11 -12
  237. massgen/tests/test_v3_simple.py +8 -13
  238. massgen/tests/test_v3_three_agents.py +11 -18
  239. massgen/tests/test_v3_two_agents.py +8 -13
  240. massgen/token_manager/__init__.py +7 -0
  241. massgen/token_manager/token_manager.py +400 -0
  242. massgen/utils.py +52 -16
  243. massgen/v1/agent.py +45 -91
  244. massgen/v1/agents.py +18 -53
  245. massgen/v1/backends/gemini.py +50 -153
  246. massgen/v1/backends/grok.py +21 -54
  247. massgen/v1/backends/oai.py +39 -111
  248. massgen/v1/cli.py +36 -93
  249. massgen/v1/config.py +8 -12
  250. massgen/v1/logging.py +43 -127
  251. massgen/v1/main.py +18 -32
  252. massgen/v1/orchestrator.py +68 -209
  253. massgen/v1/streaming_display.py +62 -163
  254. massgen/v1/tools.py +8 -12
  255. massgen/v1/types.py +9 -23
  256. massgen/v1/utils.py +5 -23
  257. massgen-0.1.0.dist-info/METADATA +1245 -0
  258. massgen-0.1.0.dist-info/RECORD +273 -0
  259. massgen-0.1.0.dist-info/entry_points.txt +2 -0
  260. massgen/frontend/logging/__init__.py +0 -9
  261. massgen/frontend/logging/realtime_logger.py +0 -197
  262. massgen-0.0.3.dist-info/METADATA +0 -568
  263. massgen-0.0.3.dist-info/RECORD +0 -76
  264. massgen-0.0.3.dist-info/entry_points.txt +0 -2
  265. /massgen/backend/{Function calling openai responses.md → docs/Function calling openai responses.md} +0 -0
  266. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/WHEEL +0 -0
  267. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/licenses/LICENSE +0 -0
  268. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/top_level.txt +0 -0
massgen/backend/gemini.py CHANGED
@@ -1,3 +1,4 @@
1
+ # -*- coding: utf-8 -*-
1
2
  """
2
3
  Gemini backend implementation using structured output for voting and answer submission.
3
4
 
@@ -18,11 +19,23 @@ TECHNICAL SOLUTION:
18
19
  - Maintains compatibility with existing MassGen workflow
19
20
  """
20
21
 
21
- import os
22
- import json
22
+ import asyncio
23
23
  import enum
24
- from typing import Dict, List, Any, AsyncGenerator, Optional
25
- from .base import LLMBackend, StreamChunk
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import re
28
+ import time
29
+ from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, List, Optional
30
+
31
+ from ..logger_config import (
32
+ log_backend_activity,
33
+ log_backend_agent_message,
34
+ log_stream_chunk,
35
+ log_tool_call,
36
+ logger,
37
+ )
38
+ from .base import FilesystemSupport, LLMBackend, StreamChunk
26
39
 
27
40
  try:
28
41
  from pydantic import BaseModel, Field
@@ -30,15 +43,43 @@ except ImportError:
30
43
  BaseModel = None
31
44
  Field = None
32
45
 
33
-
34
- class VoteOption(enum.Enum):
35
- """Vote options for agent selection."""
36
-
37
- AGENT1 = "agent1"
38
- AGENT2 = "agent2"
39
- AGENT3 = "agent3"
40
- AGENT4 = "agent4"
41
- AGENT5 = "agent5"
46
+ # MCP integration imports
47
+ try:
48
+ from ..mcp_tools import MCPClient, MCPConnectionError, MCPError
49
+ from ..mcp_tools.config_validator import MCPConfigValidator
50
+ from ..mcp_tools.exceptions import (
51
+ MCPConfigurationError,
52
+ MCPServerError,
53
+ MCPTimeoutError,
54
+ MCPValidationError,
55
+ )
56
+ except ImportError: # MCP not installed or import failed within mcp_tools
57
+ MCPClient = None # type: ignore[assignment]
58
+ MCPError = ImportError # type: ignore[assignment]
59
+ MCPConnectionError = ImportError # type: ignore[assignment]
60
+ MCPConfigValidator = None # type: ignore[assignment]
61
+ MCPConfigurationError = ImportError # type: ignore[assignment]
62
+ MCPValidationError = ImportError # type: ignore[assignment]
63
+ MCPTimeoutError = ImportError # type: ignore[assignment]
64
+ MCPServerError = ImportError # type: ignore[assignment]
65
+
66
+ # Import MCP backend utilities
67
+ try:
68
+ from ..mcp_tools.backend_utils import (
69
+ MCPCircuitBreakerManager,
70
+ MCPConfigHelper,
71
+ MCPErrorHandler,
72
+ MCPExecutionManager,
73
+ MCPMessageManager,
74
+ MCPSetupManager,
75
+ )
76
+ except ImportError:
77
+ MCPErrorHandler = None # type: ignore[assignment]
78
+ MCPSetupManager = None # type: ignore[assignment]
79
+ MCPMessageManager = None # type: ignore[assignment]
80
+ MCPCircuitBreakerManager = None # type: ignore[assignment]
81
+ MCPExecutionManager = None # type: ignore[assignment]
82
+ MCPConfigHelper = None # type: ignore[assignment]
42
83
 
43
84
 
44
85
  class ActionType(enum.Enum):
@@ -52,9 +93,7 @@ class VoteAction(BaseModel):
52
93
  """Structured output for voting action."""
53
94
 
54
95
  action: ActionType = Field(default=ActionType.VOTE, description="Action type")
55
- agent_id: str = Field(
56
- description="Anonymous agent ID to vote for (e.g., 'agent1', 'agent2')"
57
- )
96
+ agent_id: str = Field(description="Anonymous agent ID to vote for (e.g., 'agent1', 'agent2')")
58
97
  reason: str = Field(description="Brief reason why this agent has the best answer")
59
98
 
60
99
 
@@ -62,38 +101,804 @@ class NewAnswerAction(BaseModel):
62
101
  """Structured output for new answer action."""
63
102
 
64
103
  action: ActionType = Field(default=ActionType.NEW_ANSWER, description="Action type")
65
- content: str = Field(
66
- description="Your improved answer. If any builtin tools like search or code execution were used, include how they are used here."
67
- )
104
+ content: str = Field(description="Your improved answer. If any builtin tools like search or code execution were used, include how they are used here.")
68
105
 
69
106
 
70
107
  class CoordinationResponse(BaseModel):
71
108
  """Structured response for coordination actions."""
72
109
 
73
110
  action_type: ActionType = Field(description="Type of action to take")
74
- vote_data: Optional[VoteAction] = Field(
75
- default=None, description="Vote data if action is vote"
76
- )
77
- answer_data: Optional[NewAnswerAction] = Field(
78
- default=None, description="Answer data if action is new_answer"
79
- )
111
+ vote_data: Optional[VoteAction] = Field(default=None, description="Vote data if action is vote")
112
+ answer_data: Optional[NewAnswerAction] = Field(default=None, description="Answer data if action is new_answer")
113
+
114
+
115
+ class MCPResponseTracker:
116
+ """
117
+ Tracks MCP tool responses across streaming chunks to handle deduplication.
118
+
119
+ Similar to MCPCallTracker but for tracking tool responses to avoid duplicate output.
120
+ """
121
+
122
+ def __init__(self):
123
+ """Initialize the tracker with empty storage."""
124
+ self.processed_responses = set() # Store hashes of processed responses
125
+ self.response_history = [] # Store all unique responses with timestamps
126
+
127
+ def get_response_hash(self, tool_name: str, tool_response: Any) -> str:
128
+ """
129
+ Generate a unique hash for a tool response based on name and response content.
130
+
131
+ Args:
132
+ tool_name: Name of the tool that responded
133
+ tool_response: Response from the tool
134
+
135
+ Returns:
136
+ MD5 hash string identifying this specific response
137
+ """
138
+ # Create a deterministic string representation
139
+ content = f"{tool_name}:{str(tool_response)}"
140
+ return hashlib.md5(content.encode()).hexdigest()
141
+
142
+ def is_new_response(self, tool_name: str, tool_response: Any) -> bool:
143
+ """
144
+ Check if this is a new tool response we haven't seen before.
145
+
146
+ Args:
147
+ tool_name: Name of the tool that responded
148
+ tool_response: Response from the tool
149
+
150
+ Returns:
151
+ True if this is a new response, False if already processed
152
+ """
153
+ response_hash = self.get_response_hash(tool_name, tool_response)
154
+ return response_hash not in self.processed_responses
155
+
156
+ def add_response(self, tool_name: str, tool_response: Any) -> Dict[str, Any]:
157
+ """
158
+ Add a new response to the tracker.
159
+
160
+ Args:
161
+ tool_name: Name of the tool that responded
162
+ tool_response: Response from the tool
163
+
164
+ Returns:
165
+ Dictionary containing response details and timestamp
166
+ """
167
+ response_hash = self.get_response_hash(tool_name, tool_response)
168
+ self.processed_responses.add(response_hash)
169
+
170
+ record = {
171
+ "tool_name": tool_name,
172
+ "response": tool_response,
173
+ "hash": response_hash,
174
+ "timestamp": time.time(),
175
+ }
176
+ self.response_history.append(record)
177
+ return record
178
+
179
+
180
+ class MCPCallTracker:
181
+ """
182
+ Tracks MCP tool calls across streaming chunks to handle deduplication.
183
+
184
+ Uses hashing to identify unique tool calls and timestamps to track when they occurred.
185
+ This ensures we don't double-count the same tool call appearing in multiple chunks.
186
+ """
187
+
188
+ def __init__(self):
189
+ """Initialize the tracker with empty storage."""
190
+ self.processed_calls = set() # Store hashes of processed calls
191
+ self.call_history = [] # Store all unique calls with timestamps
192
+ self.last_chunk_calls = [] # Track calls from the last chunk for deduplication
193
+ self.dedup_window = 0.5 # Time window in seconds for deduplication
194
+
195
+ def get_call_hash(self, tool_name: str, tool_args: Dict[str, Any]) -> str:
196
+ """
197
+ Generate a unique hash for a tool call based on name and arguments.
198
+
199
+ Args:
200
+ tool_name: Name of the tool being called
201
+ tool_args: Arguments passed to the tool
202
+
203
+ Returns:
204
+ MD5 hash string identifying this specific call
205
+ """
206
+ # Create a deterministic string representation
207
+ content = f"{tool_name}:{json.dumps(tool_args, sort_keys=True)}"
208
+ return hashlib.md5(content.encode()).hexdigest()
209
+
210
+ def is_new_call(self, tool_name: str, tool_args: Dict[str, Any]) -> bool:
211
+ """
212
+ Check if this is a new tool call we haven't seen before.
213
+
214
+ Uses a time-window based approach: identical calls within the dedup_window
215
+ are considered duplicates (likely from streaming chunks), while those outside
216
+ the window are considered new calls (likely intentional repeated calls).
217
+
218
+ Args:
219
+ tool_name: Name of the tool being called
220
+ tool_args: Arguments passed to the tool
221
+
222
+ Returns:
223
+ True if this is a new call, False if we've seen it before
224
+ """
225
+ call_hash = self.get_call_hash(tool_name, tool_args)
226
+ current_time = time.time()
227
+
228
+ # Check if this call exists in recent history within the dedup window
229
+ for call in self.call_history[-10:]: # Check last 10 calls for efficiency
230
+ if call.get("hash") == call_hash:
231
+ time_diff = current_time - call.get("timestamp", 0)
232
+ if time_diff < self.dedup_window:
233
+ # This is likely a duplicate from streaming chunks
234
+ return False
235
+ # If outside the window, treat as a new intentional call
236
+
237
+ # Mark as processed
238
+ self.processed_calls.add(call_hash)
239
+ return True
240
+
241
+ def add_call(self, tool_name: str, tool_args: Dict[str, Any]) -> Dict[str, Any]:
242
+ """
243
+ Add a new tool call to the history.
244
+
245
+ Args:
246
+ tool_name: Name of the tool being called
247
+ tool_args: Arguments passed to the tool
248
+
249
+ Returns:
250
+ Dictionary containing the call details with timestamp and hash
251
+ """
252
+ call_record = {
253
+ "name": tool_name,
254
+ "arguments": tool_args,
255
+ "timestamp": time.time(),
256
+ "hash": self.get_call_hash(tool_name, tool_args),
257
+ "sequence": len(self.call_history), # Add sequence number for ordering
258
+ }
259
+ self.call_history.append(call_record)
260
+
261
+ # Clean up old history to prevent memory growth
262
+ if len(self.call_history) > 100:
263
+ self.call_history = self.call_history[-50:]
264
+
265
+ return call_record
266
+
267
+ def get_summary(self) -> str:
268
+ """
269
+ Get a summary of all tracked tool calls.
270
+
271
+ Returns:
272
+ Human-readable summary of tool usage
273
+ """
274
+ if not self.call_history:
275
+ return "No MCP tools called"
276
+
277
+ tool_names = [call["name"] for call in self.call_history]
278
+ unique_tools = list(dict.fromkeys(tool_names)) # Preserve order
279
+ return f"Used {len(self.call_history)} MCP tool calls: {', '.join(unique_tools)}"
280
+
281
+
282
+ class MCPResponseExtractor:
283
+ """
284
+ Extracts MCP tool calls and responses from Gemini SDK stream chunks.
285
+
286
+ This class parses the internal SDK chunks to capture:
287
+ - function_call parts (tool invocations)
288
+ - function_response parts (tool results)
289
+ - Paired call-response data for tracking complete tool executions
290
+ """
291
+
292
+ def __init__(self):
293
+ """Initialize the extractor with empty storage."""
294
+ self.mcp_calls = [] # All tool calls
295
+ self.mcp_responses = [] # All tool responses
296
+ self.call_response_pairs = [] # Matched call-response pairs
297
+ self._pending_call = None # Track current call awaiting response
298
+
299
+ def extract_function_call(self, function_call) -> Optional[Dict[str, Any]]:
300
+ """
301
+ Extract tool call information from SDK function_call object.
302
+
303
+ Tries multiple methods to extract data from different SDK versions:
304
+ 1. Direct attributes (name, args)
305
+ 2. Dictionary-like interface (get method)
306
+ 3. __dict__ attributes
307
+ 4. Protobuf _pb attributes
308
+ """
309
+ tool_name = None
310
+ tool_args = None
311
+
312
+ # Method 1: Direct attributes
313
+ tool_name = getattr(function_call, "name", None)
314
+ tool_args = getattr(function_call, "args", None)
315
+
316
+ # Method 2: Dictionary-like object
317
+ if tool_name is None:
318
+ try:
319
+ if hasattr(function_call, "get"):
320
+ tool_name = function_call.get("name", None)
321
+ tool_args = function_call.get("args", None)
322
+ except Exception:
323
+ pass
324
+
325
+ # Method 3: __dict__ inspection
326
+ if tool_name is None:
327
+ try:
328
+ if hasattr(function_call, "__dict__"):
329
+ fc_dict = function_call.__dict__
330
+ tool_name = fc_dict.get("name", None)
331
+ tool_args = fc_dict.get("args", None)
332
+ except Exception:
333
+ pass
334
+
335
+ # Method 4: Protobuf _pb attribute
336
+ if tool_name is None:
337
+ try:
338
+ if hasattr(function_call, "_pb"):
339
+ pb = function_call._pb
340
+ if hasattr(pb, "name"):
341
+ tool_name = pb.name
342
+ if hasattr(pb, "args"):
343
+ tool_args = pb.args
344
+ except Exception:
345
+ pass
346
+
347
+ if tool_name:
348
+ call_data = {
349
+ "name": tool_name,
350
+ "arguments": tool_args or {},
351
+ "timestamp": time.time(),
352
+ "raw": str(function_call)[:200], # Truncate for logging
353
+ }
354
+ self.mcp_calls.append(call_data)
355
+ self._pending_call = call_data
356
+ return call_data
357
+
358
+ return None
359
+
360
+ def extract_function_response(self, function_response) -> Optional[Dict[str, Any]]:
361
+ """
362
+ Extract tool response information from SDK function_response object.
363
+
364
+ Uses same extraction methods as function_call for consistency.
365
+ """
366
+ tool_name = None
367
+ tool_response = None
368
+
369
+ # Method 1: Direct attributes
370
+ tool_name = getattr(function_response, "name", None)
371
+ tool_response = getattr(function_response, "response", None)
372
+
373
+ # Method 2: Dictionary-like object
374
+ if tool_name is None:
375
+ try:
376
+ if hasattr(function_response, "get"):
377
+ tool_name = function_response.get("name", None)
378
+ tool_response = function_response.get("response", None)
379
+ except Exception:
380
+ pass
381
+
382
+ # Method 3: __dict__ inspection
383
+ if tool_name is None:
384
+ try:
385
+ if hasattr(function_response, "__dict__"):
386
+ fr_dict = function_response.__dict__
387
+ tool_name = fr_dict.get("name", None)
388
+ tool_response = fr_dict.get("response", None)
389
+ except Exception:
390
+ pass
391
+
392
+ # Method 4: Protobuf _pb attribute
393
+ if tool_name is None:
394
+ try:
395
+ if hasattr(function_response, "_pb"):
396
+ pb = function_response._pb
397
+ if hasattr(pb, "name"):
398
+ tool_name = pb.name
399
+ if hasattr(pb, "response"):
400
+ tool_response = pb.response
401
+ except Exception:
402
+ pass
403
+
404
+ if tool_name:
405
+ response_data = {
406
+ "name": tool_name,
407
+ "response": tool_response or {},
408
+ "timestamp": time.time(),
409
+ "raw": str(function_response)[:500], # Truncate for logging
410
+ }
411
+ self.mcp_responses.append(response_data)
412
+
413
+ # Pair with pending call if names match
414
+ if self._pending_call and self._pending_call["name"] == tool_name:
415
+ self.call_response_pairs.append(
416
+ {
417
+ "call": self._pending_call,
418
+ "response": response_data,
419
+ "duration": response_data["timestamp"] - self._pending_call["timestamp"],
420
+ "paired_at": time.time(),
421
+ },
422
+ )
423
+ self._pending_call = None
424
+
425
+ return response_data
426
+
427
+ return None
428
+
429
+ def get_summary(self) -> Dict[str, Any]:
430
+ """
431
+ Get a summary of all extracted MCP tool interactions.
432
+ """
433
+ return {
434
+ "total_calls": len(self.mcp_calls),
435
+ "total_responses": len(self.mcp_responses),
436
+ "paired_interactions": len(self.call_response_pairs),
437
+ "pending_call": self._pending_call is not None,
438
+ "tool_names": list(set(call["name"] for call in self.mcp_calls)),
439
+ "average_duration": (sum(pair["duration"] for pair in self.call_response_pairs) / len(self.call_response_pairs) if self.call_response_pairs else 0),
440
+ }
441
+
442
+ def clear(self):
443
+ """Clear all stored data."""
444
+ self.mcp_calls.clear()
445
+ self.mcp_responses.clear()
446
+ self.call_response_pairs.clear()
447
+ self._pending_call = None
80
448
 
81
449
 
82
450
  class GeminiBackend(LLMBackend):
83
- """Google Gemini backend using structured output for coordination."""
451
+ """Google Gemini backend using structured output for coordination and MCP tool integration."""
84
452
 
85
453
  def __init__(self, api_key: Optional[str] = None, **kwargs):
86
454
  super().__init__(api_key, **kwargs)
87
- self.api_key = (
88
- api_key or os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")
89
- )
455
+ self.api_key = api_key or os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")
90
456
  self.search_count = 0
91
457
  self.code_execution_count = 0
92
458
 
93
- if BaseModel is None:
94
- raise ImportError(
95
- "pydantic is required for Gemini backend. Install with: pip install pydantic"
459
+ # MCP integration (filesystem MCP server may have been injected by base class)
460
+ self.mcp_servers = self.config.get("mcp_servers", [])
461
+ self.allowed_tools = kwargs.pop("allowed_tools", None)
462
+ self.exclude_tools = kwargs.pop("exclude_tools", None)
463
+ self._mcp_client: Optional[MCPClient] = None
464
+ self._mcp_initialized = False
465
+
466
+ # MCP tool execution monitoring
467
+ self._mcp_tool_calls_count = 0
468
+ self._mcp_tool_failures = 0
469
+ self._mcp_tool_successes = 0
470
+
471
+ # MCP Response Extractor for capturing tool interactions
472
+ self.mcp_extractor = MCPResponseExtractor()
473
+
474
+ # Limit for message history growth within MCP execution loop
475
+ self._max_mcp_message_history = kwargs.pop("max_mcp_message_history", 200)
476
+ self._mcp_connection_retries = 0
477
+
478
+ # Circuit breaker configuration
479
+ self._circuit_breakers_enabled = kwargs.pop("circuit_breaker_enabled", True)
480
+ self._mcp_tools_circuit_breaker = None
481
+
482
+ # Initialize agent_id for use throughout the class
483
+ self.agent_id = kwargs.get("agent_id", None)
484
+
485
+ # Initialize circuit breaker if enabled
486
+ if self._circuit_breakers_enabled:
487
+ # Fail fast if required utilities are missing
488
+ if MCPCircuitBreakerManager is None:
489
+ raise RuntimeError("Circuit breakers enabled but MCPCircuitBreakerManager is not available")
490
+
491
+ try:
492
+ from ..mcp_tools.circuit_breaker import MCPCircuitBreaker
493
+
494
+ # Use shared utility to build circuit breaker configuration
495
+ if MCPConfigHelper is not None:
496
+ mcp_tools_config = MCPConfigHelper.build_circuit_breaker_config("mcp_tools", backend_name="gemini")
497
+ else:
498
+ mcp_tools_config = None
499
+ if mcp_tools_config:
500
+ self._mcp_tools_circuit_breaker = MCPCircuitBreaker(mcp_tools_config, backend_name="gemini", agent_id=self.agent_id)
501
+ log_backend_activity(
502
+ "gemini",
503
+ "Circuit breaker initialized for MCP tools",
504
+ {"enabled": True},
505
+ agent_id=self.agent_id,
506
+ )
507
+ else:
508
+ log_backend_activity(
509
+ "gemini",
510
+ "Circuit breaker config unavailable",
511
+ {"fallback": "disabled"},
512
+ agent_id=self.agent_id,
513
+ )
514
+ self._circuit_breakers_enabled = False
515
+ except ImportError:
516
+ log_backend_activity(
517
+ "gemini",
518
+ "Circuit breaker import failed",
519
+ {"fallback": "disabled"},
520
+ agent_id=self.agent_id,
521
+ )
522
+ self._circuit_breakers_enabled = False
523
+
524
+ def _setup_permission_hooks(self):
525
+ """Override base class - Gemini uses session-based permissions, not function hooks."""
526
+ logger.debug("[Gemini] Using session-based permissions, skipping function hook setup")
527
+
528
+ async def _setup_mcp_with_status_stream(self, agent_id: Optional[str] = None) -> AsyncGenerator[StreamChunk, None]:
529
+ """Initialize MCP client with status streaming."""
530
+ status_queue: asyncio.Queue[StreamChunk] = asyncio.Queue()
531
+
532
+ async def status_callback(status: str, details: Dict[str, Any]) -> None:
533
+ """Callback to queue status updates as StreamChunks."""
534
+ chunk = StreamChunk(
535
+ type="mcp_status",
536
+ status=status,
537
+ content=details.get("message", ""),
538
+ source="mcp_tools",
539
+ )
540
+ await status_queue.put(chunk)
541
+
542
+ # Start the actual setup in background
543
+ setup_task = asyncio.create_task(self._setup_mcp_tools_internal(agent_id, status_callback))
544
+
545
+ # Yield status updates while setup is running
546
+ while not setup_task.done():
547
+ try:
548
+ chunk = await asyncio.wait_for(status_queue.get(), timeout=0.1)
549
+ yield chunk
550
+ except asyncio.TimeoutError:
551
+ continue
552
+
553
+ # Wait for setup to complete and handle any final errors
554
+ try:
555
+ await setup_task
556
+ except Exception as e:
557
+ yield StreamChunk(
558
+ type="mcp_status",
559
+ status="error",
560
+ content=f"MCP setup failed: {e}",
561
+ source="mcp_tools",
562
+ )
563
+
564
+ async def _setup_mcp_tools(self, agent_id: Optional[str] = None) -> None:
565
+ """Initialize MCP client (sessions only) - backward compatibility."""
566
+ if not self.mcp_servers or self._mcp_initialized:
567
+ return
568
+ # Consume status updates without yielding them
569
+ async for _ in self._setup_mcp_with_status_stream(agent_id):
570
+ pass
571
+
572
+ async def _setup_mcp_tools_internal(
573
+ self,
574
+ agent_id: Optional[str] = None,
575
+ status_callback: Optional[Callable[[str, Dict[str, Any]], Awaitable[None]]] = None,
576
+ ) -> None:
577
+ """Internal MCP setup logic."""
578
+ if not self.mcp_servers or self._mcp_initialized:
579
+ return
580
+
581
+ if MCPClient is None:
582
+ reason = "MCP import failed - MCPClient not available"
583
+ log_backend_activity(
584
+ "gemini",
585
+ "MCP import failed",
586
+ {"reason": reason, "fallback": "workflow_tools"},
587
+ agent_id=agent_id,
96
588
  )
589
+ if status_callback:
590
+ await status_callback(
591
+ "error",
592
+ {"message": "MCP import failed - falling back to workflow tools"},
593
+ )
594
+ # Clear MCP servers to prevent further attempts
595
+ self.mcp_servers = []
596
+ return
597
+
598
+ try:
599
+ # Validate MCP configuration before initialization
600
+ validated_config = {
601
+ "mcp_servers": self.mcp_servers,
602
+ "allowed_tools": self.allowed_tools,
603
+ "exclude_tools": self.exclude_tools,
604
+ }
605
+
606
+ if MCPConfigValidator is not None:
607
+ try:
608
+ backend_config = {
609
+ "mcp_servers": self.mcp_servers,
610
+ "allowed_tools": self.allowed_tools,
611
+ "exclude_tools": self.exclude_tools,
612
+ }
613
+ # Use the comprehensive validator class for enhanced validation
614
+ validator = MCPConfigValidator()
615
+ validated_config = validator.validate_backend_mcp_config(backend_config)
616
+ self.mcp_servers = validated_config.get("mcp_servers", self.mcp_servers)
617
+ log_backend_activity(
618
+ "gemini",
619
+ "MCP configuration validated",
620
+ {"server_count": len(self.mcp_servers)},
621
+ agent_id=agent_id,
622
+ )
623
+ if status_callback:
624
+ await status_callback(
625
+ "info",
626
+ {"message": f"MCP configuration validated: {len(self.mcp_servers)} servers"},
627
+ )
628
+
629
+ # Log validated server names for debugging
630
+ if True:
631
+ server_names = [server.get("name", "unnamed") for server in self.mcp_servers]
632
+ log_backend_activity(
633
+ "gemini",
634
+ "MCP servers validated",
635
+ {"servers": server_names},
636
+ agent_id=agent_id,
637
+ )
638
+ except MCPConfigurationError as e:
639
+ log_backend_activity(
640
+ "gemini",
641
+ "MCP configuration validation failed",
642
+ {"error": e.original_message},
643
+ agent_id=agent_id,
644
+ )
645
+ if status_callback:
646
+ await status_callback(
647
+ "error",
648
+ {"message": f"Invalid MCP configuration: {e.original_message}"},
649
+ )
650
+ self._mcp_client = None # Clear client state for consistency
651
+ raise RuntimeError(f"Invalid MCP configuration: {e.original_message}") from e
652
+ except MCPValidationError as e:
653
+ log_backend_activity(
654
+ "gemini",
655
+ "MCP validation failed",
656
+ {"error": e.original_message},
657
+ agent_id=agent_id,
658
+ )
659
+ if status_callback:
660
+ await status_callback(
661
+ "error",
662
+ {"message": f"MCP validation error: {e.original_message}"},
663
+ )
664
+ self._mcp_client = None # Clear client state for consistency
665
+ raise RuntimeError(f"MCP validation error: {e.original_message}") from e
666
+ except Exception as e:
667
+ if isinstance(e, (ImportError, AttributeError)):
668
+ log_backend_activity(
669
+ "gemini",
670
+ "MCP validation unavailable",
671
+ {"reason": str(e)},
672
+ agent_id=agent_id,
673
+ )
674
+ # Don't clear client for import errors - validation just unavailable
675
+ else:
676
+ log_backend_activity(
677
+ "gemini",
678
+ "MCP validation error",
679
+ {"error": str(e)},
680
+ agent_id=agent_id,
681
+ )
682
+ self._mcp_client = None # Clear client state for consistency
683
+ raise RuntimeError(f"MCP configuration validation failed: {e}") from e
684
+ else:
685
+ log_backend_activity(
686
+ "gemini",
687
+ "MCP validation skipped",
688
+ {"reason": "validator_unavailable"},
689
+ agent_id=agent_id,
690
+ )
691
+
692
+ # Instead of the current fallback logic
693
+ normalized_servers = MCPSetupManager.normalize_mcp_servers(self.mcp_servers)
694
+ log_backend_activity(
695
+ "gemini",
696
+ "Setting up MCP sessions",
697
+ {"server_count": len(normalized_servers)},
698
+ agent_id=agent_id,
699
+ )
700
+ if status_callback:
701
+ await status_callback(
702
+ "info",
703
+ {"message": f"Setting up MCP sessions for {len(normalized_servers)} servers"},
704
+ )
705
+
706
+ # Apply circuit breaker filtering before connection attempts
707
+ if self._circuit_breakers_enabled and self._mcp_tools_circuit_breaker:
708
+ filtered_servers = MCPCircuitBreakerManager.apply_circuit_breaker_filtering(
709
+ normalized_servers,
710
+ self._mcp_tools_circuit_breaker,
711
+ backend_name="gemini",
712
+ agent_id=agent_id,
713
+ )
714
+ else:
715
+ filtered_servers = normalized_servers
716
+ if not filtered_servers:
717
+ log_backend_activity(
718
+ "gemini",
719
+ "All MCP servers blocked by circuit breaker",
720
+ {},
721
+ agent_id=agent_id,
722
+ )
723
+ if status_callback:
724
+ await status_callback(
725
+ "warning",
726
+ {"message": "All MCP servers blocked by circuit breaker"},
727
+ )
728
+ return
729
+
730
+ if len(filtered_servers) < len(normalized_servers):
731
+ log_backend_activity(
732
+ "gemini",
733
+ "Circuit breaker filtered servers",
734
+ {"filtered_count": len(normalized_servers) - len(filtered_servers)},
735
+ agent_id=agent_id,
736
+ )
737
+ if status_callback:
738
+ await status_callback(
739
+ "warning",
740
+ {"message": f"Circuit breaker filtered {len(normalized_servers) - len(filtered_servers)} servers"},
741
+ )
742
+
743
+ # Extract tool filtering parameters from validated config
744
+ allowed_tools = validated_config.get("allowed_tools")
745
+ exclude_tools = validated_config.get("exclude_tools")
746
+
747
+ # Log tool filtering if configured
748
+ if allowed_tools:
749
+ log_backend_activity(
750
+ "gemini",
751
+ "MCP tool filtering configured",
752
+ {"allowed_tools": allowed_tools},
753
+ agent_id=agent_id,
754
+ )
755
+ if exclude_tools:
756
+ log_backend_activity(
757
+ "gemini",
758
+ "MCP tool filtering configured",
759
+ {"exclude_tools": exclude_tools},
760
+ agent_id=agent_id,
761
+ )
762
+
763
+ # Create client with status callback and hooks
764
+ self._mcp_client = MCPClient(
765
+ filtered_servers,
766
+ timeout_seconds=30,
767
+ allowed_tools=allowed_tools,
768
+ exclude_tools=exclude_tools,
769
+ status_callback=status_callback,
770
+ hooks=self.filesystem_manager.get_pre_tool_hooks() if self.filesystem_manager else {},
771
+ )
772
+
773
+ # Connect the client
774
+ await self._mcp_client.connect()
775
+
776
+ # Determine which servers actually connected
777
+ try:
778
+ connected_server_names = self._mcp_client.get_server_names()
779
+ except Exception:
780
+ connected_server_names = []
781
+
782
+ if not connected_server_names:
783
+ # Treat as connection failure: no active servers
784
+ if self._circuit_breakers_enabled and self._mcp_tools_circuit_breaker:
785
+ await MCPCircuitBreakerManager.record_event(
786
+ filtered_servers,
787
+ self._mcp_tools_circuit_breaker,
788
+ "failure",
789
+ error_message="No servers connected",
790
+ backend_name="gemini",
791
+ agent_id=agent_id,
792
+ )
793
+
794
+ log_backend_activity(
795
+ "gemini",
796
+ "MCP connection failed: no servers connected",
797
+ {},
798
+ agent_id=agent_id,
799
+ )
800
+ if status_callback:
801
+ await status_callback(
802
+ "error",
803
+ {"message": "MCP connection failed: no servers connected"},
804
+ )
805
+ self._mcp_client = None
806
+ return
807
+
808
+ # Record success ONLY for servers that actually connected
809
+ connected_server_configs = [server for server in filtered_servers if server.get("name") in connected_server_names]
810
+ if connected_server_configs:
811
+ if self._circuit_breakers_enabled and self._mcp_tools_circuit_breaker:
812
+ await MCPCircuitBreakerManager.record_event(
813
+ connected_server_configs,
814
+ self._mcp_tools_circuit_breaker,
815
+ "success",
816
+ backend_name="gemini",
817
+ agent_id=agent_id,
818
+ )
819
+
820
+ self._mcp_initialized = True
821
+ log_backend_activity("gemini", "MCP sessions initialized successfully", {}, agent_id=agent_id)
822
+ if status_callback:
823
+ await status_callback(
824
+ "success",
825
+ {"message": f"MCP sessions initialized successfully with {len(connected_server_names)} servers"},
826
+ )
827
+
828
+ except Exception as e:
829
+ # Record failure for circuit breaker
830
+ if self._circuit_breakers_enabled and self._mcp_tools_circuit_breaker:
831
+ servers = MCPSetupManager.normalize_mcp_servers(self.mcp_servers)
832
+ await MCPCircuitBreakerManager.record_event(
833
+ servers,
834
+ self._mcp_tools_circuit_breaker,
835
+ "failure",
836
+ error_message=str(e),
837
+ backend_name="gemini",
838
+ agent_id=agent_id,
839
+ )
840
+
841
+ # Enhanced error handling for different MCP error types
842
+ if isinstance(e, RuntimeError) and "MCP configuration" in str(e):
843
+ raise
844
+ elif isinstance(e, MCPConnectionError):
845
+ log_backend_activity(
846
+ "gemini",
847
+ "MCP connection failed during setup",
848
+ {"error": str(e)},
849
+ agent_id=agent_id,
850
+ )
851
+ if status_callback:
852
+ await status_callback(
853
+ "error",
854
+ {"message": f"Failed to establish MCP connections: {e}"},
855
+ )
856
+ self._mcp_client = None
857
+ raise RuntimeError(f"Failed to establish MCP connections: {e}") from e
858
+ elif isinstance(e, MCPTimeoutError):
859
+ log_backend_activity(
860
+ "gemini",
861
+ "MCP connection timeout during setup",
862
+ {"error": str(e)},
863
+ agent_id=agent_id,
864
+ )
865
+ if status_callback:
866
+ await status_callback("error", {"message": f"MCP connection timeout: {e}"})
867
+ self._mcp_client = None
868
+ raise RuntimeError(f"MCP connection timeout: {e}") from e
869
+ elif isinstance(e, MCPServerError):
870
+ log_backend_activity(
871
+ "gemini",
872
+ "MCP server error during setup",
873
+ {"error": str(e)},
874
+ agent_id=agent_id,
875
+ )
876
+ if status_callback:
877
+ await status_callback("error", {"message": f"MCP server error: {e}"})
878
+ self._mcp_client = None
879
+ raise RuntimeError(f"MCP server error: {e}") from e
880
+ elif isinstance(e, MCPError):
881
+ log_backend_activity(
882
+ "gemini",
883
+ "MCP error during setup",
884
+ {"error": str(e)},
885
+ agent_id=agent_id,
886
+ )
887
+ if status_callback:
888
+ await status_callback("error", {"message": f"MCP error during setup: {e}"})
889
+ self._mcp_client = None
890
+ return
891
+
892
+ else:
893
+ log_backend_activity(
894
+ "gemini",
895
+ "MCP session setup failed",
896
+ {"error": str(e)},
897
+ agent_id=agent_id,
898
+ )
899
+ if status_callback:
900
+ await status_callback("error", {"message": f"MCP session setup failed: {e}"})
901
+ self._mcp_client = None
97
902
 
98
903
  def detect_coordination_tools(self, tools: List[Dict[str, Any]]) -> bool:
99
904
  """Detect if tools contain vote/new_answer coordination tools."""
@@ -110,9 +915,7 @@ class GeminiBackend(LLMBackend):
110
915
 
111
916
  return "vote" in tool_names and "new_answer" in tool_names
112
917
 
113
- def build_structured_output_prompt(
114
- self, base_content: str, valid_agent_ids: Optional[List[str]] = None
115
- ) -> str:
918
+ def build_structured_output_prompt(self, base_content: str, valid_agent_ids: Optional[List[str]] = None) -> str:
116
919
  """Build prompt that encourages structured output for coordination."""
117
920
  agent_list = ""
118
921
  if valid_agent_ids:
@@ -127,14 +930,14 @@ If you want to VOTE for an existing agent's answer:
127
930
  "action_type": "vote",
128
931
  "vote_data": {{
129
932
  "action": "vote",
130
- "agent_id": "agent1", // Choose from: {agent_list or 'agent1, agent2, agent3, etc.'}
933
+ "agent_id": "agent1", // Choose from: {agent_list or "agent1, agent2, agent3, etc."}
131
934
  "reason": "Brief reason for your vote"
132
935
  }}
133
936
  }}
134
937
 
135
938
  If you want to provide a NEW ANSWER:
136
939
  {{
137
- "action_type": "new_answer",
940
+ "action_type": "new_answer",
138
941
  "answer_data": {{
139
942
  "action": "new_answer",
140
943
  "content": "Your complete improved answer here"
@@ -143,18 +946,12 @@ If you want to provide a NEW ANSWER:
143
946
 
144
947
  Make your decision and include the JSON at the very end of your response."""
145
948
 
146
- def extract_structured_response(
147
- self, response_text: str
148
- ) -> Optional[Dict[str, Any]]:
949
+ def extract_structured_response(self, response_text: str) -> Optional[Dict[str, Any]]:
149
950
  """Extract structured JSON response from model output."""
150
951
  try:
151
- import re
152
-
153
952
  # Strategy 0: Look for JSON inside markdown code blocks first
154
953
  markdown_json_pattern = r"```json\s*(\{.*?\})\s*```"
155
- markdown_matches = re.findall(
156
- markdown_json_pattern, response_text, re.DOTALL
157
- )
954
+ markdown_matches = re.findall(markdown_json_pattern, response_text, re.DOTALL)
158
955
 
159
956
  for match in reversed(markdown_matches):
160
957
  try:
@@ -231,9 +1028,7 @@ Make your decision and include the JSON at the very end of your response."""
231
1028
  except Exception:
232
1029
  return None
233
1030
 
234
- def convert_structured_to_tool_calls(
235
- self, structured_response: Dict[str, Any]
236
- ) -> List[Dict[str, Any]]:
1031
+ def convert_structured_to_tool_calls(self, structured_response: Dict[str, Any]) -> List[Dict[str, Any]]:
237
1032
  """Convert structured response to tool call format."""
238
1033
  action_type = structured_response.get("action_type")
239
1034
 
@@ -241,7 +1036,7 @@ Make your decision and include the JSON at the very end of your response."""
241
1036
  vote_data = structured_response.get("vote_data", {})
242
1037
  return [
243
1038
  {
244
- "id": f"vote_{hash(str(vote_data)) % 10000}",
1039
+ "id": f"vote_{abs(hash(str(vote_data))) % 10000 + 1}",
245
1040
  "type": "function",
246
1041
  "function": {
247
1042
  "name": "vote",
@@ -250,39 +1045,198 @@ Make your decision and include the JSON at the very end of your response."""
250
1045
  "reason": vote_data.get("reason", ""),
251
1046
  },
252
1047
  },
253
- }
1048
+ },
254
1049
  ]
255
1050
 
256
1051
  elif action_type == "new_answer":
257
1052
  answer_data = structured_response.get("answer_data", {})
258
1053
  return [
259
1054
  {
260
- "id": f"new_answer_{hash(str(answer_data)) % 10000}",
1055
+ "id": f"new_answer_{abs(hash(str(answer_data))) % 10000 + 1}",
261
1056
  "type": "function",
262
1057
  "function": {
263
1058
  "name": "new_answer",
264
1059
  "arguments": {"content": answer_data.get("content", "")},
265
1060
  },
266
- }
1061
+ },
267
1062
  ]
268
1063
 
269
1064
  return []
270
1065
 
271
- async def stream_with_tools(
272
- self, messages: List[Dict[str, Any]], tools: List[Dict[str, Any]], **kwargs
1066
+ async def _handle_mcp_retry_error(self, error: Exception, retry_count: int, max_retries: int) -> tuple[bool, AsyncGenerator[StreamChunk, None]]:
1067
+ """Handle MCP retry errors with specific messaging and fallback logic.
1068
+
1069
+ Returns:
1070
+ tuple: (should_continue_retrying, error_chunks_generator)
1071
+ """
1072
+ log_type, user_message, _ = MCPErrorHandler.get_error_details(error, None, log=False)
1073
+
1074
+ # Log the retry attempt
1075
+ log_backend_activity(
1076
+ "gemini",
1077
+ f"MCP {log_type} on retry",
1078
+ {"attempt": retry_count, "error": str(error)},
1079
+ agent_id=self.agent_id,
1080
+ )
1081
+
1082
+ # Check if we've exhausted retries
1083
+ if retry_count >= max_retries:
1084
+
1085
+ async def error_chunks():
1086
+ yield StreamChunk(
1087
+ type="content",
1088
+ content=f"\n⚠️ {user_message} after {max_retries} attempts; falling back to workflow tools\n",
1089
+ )
1090
+
1091
+ return False, error_chunks()
1092
+
1093
+ # Continue retrying
1094
+ async def empty_chunks():
1095
+ # Empty generator - just return without yielding anything
1096
+ if False: # Make this a generator without actually yielding
1097
+ yield
1098
+
1099
+ return True, empty_chunks()
1100
+
1101
+ async def _handle_mcp_error_and_fallback(
1102
+ self,
1103
+ error: Exception,
273
1104
  ) -> AsyncGenerator[StreamChunk, None]:
274
- """Stream response using Gemini API with structured output for coordination."""
1105
+ """Handle MCP errors with specific messaging"""
1106
+ self._mcp_tool_failures += 1
1107
+
1108
+ log_type, user_message, _ = MCPErrorHandler.get_error_details(error, None, log=False)
1109
+
1110
+ # Log with specific error type
1111
+ log_backend_activity(
1112
+ "gemini",
1113
+ "MCP tool call failed",
1114
+ {
1115
+ "call_number": self._mcp_tool_calls_count,
1116
+ "error_type": log_type,
1117
+ "error": str(error),
1118
+ },
1119
+ agent_id=self.agent_id,
1120
+ )
1121
+
1122
+ # Yield user-friendly error message
1123
+ yield StreamChunk(
1124
+ type="content",
1125
+ content=f"\n⚠️ {user_message} ({error}); continuing without MCP tools\n",
1126
+ )
1127
+
1128
+ async def _execute_mcp_function_with_retry(self, function_name: str, args: Dict[str, Any], agent_id: Optional[str] = None) -> Any:
1129
+ """Execute MCP function with exponential backoff retry logic."""
1130
+ if MCPExecutionManager is None:
1131
+ raise RuntimeError("MCPExecutionManager is not available - MCP backend utilities are missing")
1132
+
1133
+ # Stats callback for tracking
1134
+ async def stats_callback(action: str) -> int:
1135
+ if action == "increment_calls":
1136
+ self._mcp_tool_calls_count += 1
1137
+ return self._mcp_tool_calls_count
1138
+ elif action == "increment_failures":
1139
+ self._mcp_tool_failures += 1
1140
+ return self._mcp_tool_failures
1141
+ return 0
1142
+
1143
+ # Circuit breaker callback
1144
+ async def circuit_breaker_callback(event: str, error_msg: str) -> None:
1145
+ if event == "failure":
1146
+ if self._circuit_breakers_enabled and self._mcp_tools_circuit_breaker:
1147
+ servers = MCPSetupManager.normalize_mcp_servers(self.mcp_servers)
1148
+ await MCPCircuitBreakerManager.record_event(
1149
+ servers,
1150
+ self._mcp_tools_circuit_breaker,
1151
+ "failure",
1152
+ error_message=error_msg,
1153
+ backend_name="gemini",
1154
+ agent_id=agent_id,
1155
+ )
1156
+ else:
1157
+ # Record success only for currently connected servers
1158
+ connected_names: List[str] = []
1159
+ try:
1160
+ if self._mcp_client:
1161
+ connected_names = self._mcp_client.get_server_names()
1162
+ except Exception:
1163
+ connected_names = []
1164
+
1165
+ if connected_names:
1166
+ servers_to_record = [{"name": name} for name in connected_names]
1167
+ if self._circuit_breakers_enabled and self._mcp_tools_circuit_breaker:
1168
+ await MCPCircuitBreakerManager.record_event(
1169
+ servers_to_record,
1170
+ self._mcp_tools_circuit_breaker,
1171
+ "success",
1172
+ backend_name="gemini",
1173
+ agent_id=agent_id,
1174
+ )
1175
+
1176
+ return await MCPExecutionManager.execute_function_with_retry(
1177
+ function_name=function_name,
1178
+ args=args,
1179
+ functions=self.functions,
1180
+ max_retries=3,
1181
+ stats_callback=stats_callback,
1182
+ circuit_breaker_callback=circuit_breaker_callback,
1183
+ logger_instance=logger,
1184
+ )
1185
+
1186
+ async def stream_with_tools(self, messages: List[Dict[str, Any]], tools: List[Dict[str, Any]], **kwargs) -> AsyncGenerator[StreamChunk, None]:
1187
+ """Stream response using Gemini API with structured output for coordination and MCP tool support."""
1188
+ # Use instance agent_id (from __init__) or get from kwargs if not set
1189
+ agent_id = self.agent_id or kwargs.get("agent_id", None)
1190
+ client = None
1191
+ stream = None
1192
+
1193
+ log_backend_activity(
1194
+ "gemini",
1195
+ "Starting stream_with_tools",
1196
+ {"num_messages": len(messages), "num_tools": len(tools) if tools else 0},
1197
+ agent_id=agent_id,
1198
+ )
1199
+
1200
+ # Only trim when MCP tools will be used
1201
+ if self.mcp_servers and MCPMessageManager is not None and hasattr(self, "_max_mcp_message_history") and self._max_mcp_message_history > 0:
1202
+ original_count = len(messages)
1203
+ messages = MCPMessageManager.trim_message_history(messages, self._max_mcp_message_history)
1204
+ if len(messages) < original_count:
1205
+ log_backend_activity(
1206
+ "gemini",
1207
+ "Trimmed MCP message history",
1208
+ {
1209
+ "original": original_count,
1210
+ "trimmed": len(messages),
1211
+ "limit": self._max_mcp_message_history,
1212
+ },
1213
+ agent_id=agent_id,
1214
+ )
1215
+
275
1216
  try:
276
1217
  from google import genai
277
1218
 
278
- # Extract parameters
279
- model_name = kwargs.get("model", "gemini-2.5-flash")
280
- temperature = kwargs.get("temperature", 0.1)
281
- enable_web_search = kwargs.get("enable_web_search", False)
282
- enable_code_execution = kwargs.get("enable_code_execution", False)
1219
+ # Setup MCP with status streaming if not already initialized
1220
+ if not self._mcp_initialized and self.mcp_servers:
1221
+ async for chunk in self._setup_mcp_with_status_stream(agent_id):
1222
+ yield chunk
1223
+ elif not self._mcp_initialized:
1224
+ # Setup MCP without streaming for backward compatibility
1225
+ await self._setup_mcp_tools(agent_id)
1226
+
1227
+ # Merge constructor config with stream kwargs (stream kwargs take priority)
1228
+ all_params = {**self.config, **kwargs}
1229
+
1230
+ # Extract framework-specific parameters
1231
+ enable_web_search = all_params.get("enable_web_search", False)
1232
+ enable_code_execution = all_params.get("enable_code_execution", False)
1233
+
1234
+ # Always use SDK MCP sessions when mcp_servers are configured
1235
+ using_sdk_mcp = bool(self.mcp_servers)
283
1236
 
284
- # Check if this is a coordination request
1237
+ # Analyze tool types
285
1238
  is_coordination = self.detect_coordination_tools(tools)
1239
+
286
1240
  valid_agent_ids = None
287
1241
 
288
1242
  if is_coordination:
@@ -291,32 +1245,31 @@ Make your decision and include the JSON at the very end of your response."""
291
1245
  if tool.get("type") == "function":
292
1246
  func_def = tool.get("function", {})
293
1247
  if func_def.get("name") == "vote":
294
- agent_id_param = (
295
- func_def.get("parameters", {})
296
- .get("properties", {})
297
- .get("agent_id", {})
298
- )
1248
+ agent_id_param = func_def.get("parameters", {}).get("properties", {}).get("agent_id", {})
299
1249
  if "enum" in agent_id_param:
300
1250
  valid_agent_ids = agent_id_param["enum"]
301
1251
  break
302
1252
 
303
- # Build content string from messages
1253
+ # Build content string from messages (include tool results for multi-turn tool calling)
304
1254
  conversation_content = ""
305
1255
  system_message = ""
306
1256
 
307
1257
  for msg in messages:
308
- if msg.get("role") == "system":
1258
+ role = msg.get("role")
1259
+ if role == "system":
309
1260
  system_message = msg.get("content", "")
310
- elif msg.get("role") == "user":
1261
+ elif role == "user":
311
1262
  conversation_content += f"User: {msg.get('content', '')}\n"
312
- elif msg.get("role") == "assistant":
1263
+ elif role == "assistant":
313
1264
  conversation_content += f"Assistant: {msg.get('content', '')}\n"
1265
+ elif role == "tool":
1266
+ # Ensure tool outputs are visible to the model on the next turn
1267
+ tool_output = msg.get("content", "")
1268
+ conversation_content += f"Tool Result: {tool_output}\n"
314
1269
 
315
1270
  # For coordination requests, modify the prompt to use structured output
316
1271
  if is_coordination:
317
- conversation_content = self.build_structured_output_prompt(
318
- conversation_content, valid_agent_ids
319
- )
1272
+ conversation_content = self.build_structured_output_prompt(conversation_content, valid_agent_ids)
320
1273
 
321
1274
  # Combine system message and conversation
322
1275
  full_content = ""
@@ -327,7 +1280,7 @@ Make your decision and include the JSON at the very end of your response."""
327
1280
  # Use google-genai package
328
1281
  client = genai.Client(api_key=self.api_key)
329
1282
 
330
- # Setup builtin tools
1283
+ # Setup builtin tools (only when not using SDK MCP sessions)
331
1284
  builtin_tools = []
332
1285
  if enable_web_search:
333
1286
  try:
@@ -353,82 +1306,534 @@ Make your decision and include the JSON at the very end of your response."""
353
1306
  content="\n⚠️ Code execution requires google.genai.types\n",
354
1307
  )
355
1308
 
356
- config = {
357
- "temperature": temperature,
358
- "max_output_tokens": kwargs.get("max_tokens", 8192),
1309
+ # Build config with direct parameter passthrough
1310
+ config = {}
1311
+
1312
+ # Direct passthrough of all parameters except those handled separately
1313
+ excluded_params = self.get_base_excluded_config_params() | {
1314
+ # Gemini specific exclusions
1315
+ "enable_web_search",
1316
+ "enable_code_execution",
1317
+ "use_multi_mcp",
1318
+ "mcp_sdk_auto",
1319
+ "allowed_tools",
1320
+ "exclude_tools",
359
1321
  }
1322
+ for key, value in all_params.items():
1323
+ if key not in excluded_params and value is not None:
1324
+ # Handle Gemini-specific parameter mappings
1325
+ if key == "max_tokens":
1326
+ config["max_output_tokens"] = value
1327
+ elif key == "model":
1328
+ model_name = value
1329
+ else:
1330
+ config[key] = value
1331
+
1332
+ # Setup tools configuration (builtins only when not using sessions)
1333
+ all_tools = []
1334
+
1335
+ # Branch 1: SDK auto-calling via MCP sessions (reuse existing MCPClient sessions)
1336
+ if using_sdk_mcp and self.mcp_servers:
1337
+ if not self._mcp_client or not getattr(self._mcp_client, "is_connected", lambda: False)():
1338
+ # Retry MCP connection up to 5 times before falling back
1339
+ max_mcp_retries = 5
1340
+ mcp_connected = False
1341
+
1342
+ for retry_count in range(1, max_mcp_retries + 1):
1343
+ try:
1344
+ # Track retry attempts
1345
+ self._mcp_connection_retries = retry_count
1346
+
1347
+ if retry_count > 1:
1348
+ log_backend_activity(
1349
+ "gemini",
1350
+ "MCP connection retry",
1351
+ {
1352
+ "attempt": retry_count,
1353
+ "max_retries": max_mcp_retries,
1354
+ },
1355
+ agent_id=agent_id,
1356
+ )
1357
+ # Yield retry status
1358
+ yield StreamChunk(
1359
+ type="mcp_status",
1360
+ status="mcp_retry",
1361
+ content=f"Retrying MCP connection (attempt {retry_count}/{max_mcp_retries})",
1362
+ source="mcp_tools",
1363
+ )
1364
+ # Brief delay between retries
1365
+ await asyncio.sleep(0.5 * retry_count) # Progressive backoff
1366
+
1367
+ # Apply circuit breaker filtering before retry attempts
1368
+ if self._circuit_breakers_enabled and self._mcp_tools_circuit_breaker:
1369
+ filtered_retry_servers = MCPCircuitBreakerManager.apply_circuit_breaker_filtering(
1370
+ self.mcp_servers,
1371
+ self._mcp_tools_circuit_breaker,
1372
+ backend_name="gemini",
1373
+ agent_id=agent_id,
1374
+ )
1375
+ else:
1376
+ filtered_retry_servers = self.mcp_servers
1377
+ if not filtered_retry_servers:
1378
+ log_backend_activity(
1379
+ "gemini",
1380
+ "All MCP servers blocked during retry",
1381
+ {},
1382
+ agent_id=agent_id,
1383
+ )
1384
+ # Yield blocked status
1385
+ yield StreamChunk(
1386
+ type="mcp_status",
1387
+ status="mcp_blocked",
1388
+ content="All MCP servers blocked by circuit breaker",
1389
+ source="mcp_tools",
1390
+ )
1391
+ using_sdk_mcp = False
1392
+ break
1393
+
1394
+ # Get validated config for tool filtering parameters
1395
+ backend_config = {"mcp_servers": self.mcp_servers}
1396
+ if MCPConfigValidator is not None:
1397
+ try:
1398
+ validator = MCPConfigValidator()
1399
+ validated_config_retry = validator.validate_backend_mcp_config(backend_config)
1400
+ allowed_tools_retry = validated_config_retry.get("allowed_tools")
1401
+ exclude_tools_retry = validated_config_retry.get("exclude_tools")
1402
+ except Exception:
1403
+ allowed_tools_retry = None
1404
+ exclude_tools_retry = None
1405
+ else:
1406
+ allowed_tools_retry = None
1407
+ exclude_tools_retry = None
1408
+
1409
+ self._mcp_client = await MCPClient.create_and_connect(
1410
+ filtered_retry_servers,
1411
+ timeout_seconds=30,
1412
+ allowed_tools=allowed_tools_retry,
1413
+ exclude_tools=exclude_tools_retry,
1414
+ )
360
1415
 
361
- # Add builtin tools to config
362
- if builtin_tools:
363
- config["tools"] = builtin_tools
1416
+ # Record success for circuit breaker
1417
+ if self._circuit_breakers_enabled and self._mcp_tools_circuit_breaker:
1418
+ await MCPCircuitBreakerManager.record_event(
1419
+ filtered_retry_servers,
1420
+ self._mcp_tools_circuit_breaker,
1421
+ "success",
1422
+ backend_name="gemini",
1423
+ agent_id=agent_id,
1424
+ )
1425
+ mcp_connected = True
1426
+ log_backend_activity(
1427
+ "gemini",
1428
+ "MCP connection successful on retry",
1429
+ {"attempt": retry_count},
1430
+ agent_id=agent_id,
1431
+ )
1432
+ # Yield success status
1433
+ yield StreamChunk(
1434
+ type="mcp_status",
1435
+ status="mcp_connected",
1436
+ content=f"MCP connection successful on attempt {retry_count}",
1437
+ source="mcp_tools",
1438
+ )
1439
+ break
364
1440
 
365
- # For coordination requests, use JSON response format (may conflict with builtin tools)
366
- if is_coordination and not builtin_tools:
367
- config["response_mime_type"] = "application/json"
368
- config["response_schema"] = CoordinationResponse.model_json_schema()
369
- elif is_coordination and builtin_tools:
370
- # Cannot use structured output with builtin tools - fallback to text parsing
371
- pass
1441
+ except (
1442
+ MCPConnectionError,
1443
+ MCPTimeoutError,
1444
+ MCPServerError,
1445
+ MCPError,
1446
+ Exception,
1447
+ ) as e:
1448
+ # Record failure for circuit breaker
1449
+ if self._circuit_breakers_enabled and self._mcp_tools_circuit_breaker:
1450
+ servers = MCPSetupManager.normalize_mcp_servers(self.mcp_servers)
1451
+ await MCPCircuitBreakerManager.record_event(
1452
+ servers,
1453
+ self._mcp_tools_circuit_breaker,
1454
+ "failure",
1455
+ error_message=str(e),
1456
+ backend_name="gemini",
1457
+ agent_id=agent_id,
1458
+ )
1459
+
1460
+ (
1461
+ should_continue,
1462
+ error_chunks,
1463
+ ) = await self._handle_mcp_retry_error(e, retry_count, max_mcp_retries)
1464
+ if not should_continue:
1465
+ async for chunk in error_chunks:
1466
+ yield chunk
1467
+ using_sdk_mcp = False
1468
+
1469
+ # If all retries failed, ensure we fall back gracefully
1470
+ if not mcp_connected:
1471
+ using_sdk_mcp = False
1472
+ self._mcp_client = None
1473
+
1474
+ if not using_sdk_mcp:
1475
+ all_tools.extend(builtin_tools)
1476
+ if all_tools:
1477
+ config["tools"] = all_tools
1478
+
1479
+ # For coordination requests, use JSON response format (may conflict with tools/sessions)
1480
+ if is_coordination:
1481
+ # Only request JSON schema when no tools are present
1482
+ if (not using_sdk_mcp) and (not all_tools):
1483
+ config["response_mime_type"] = "application/json"
1484
+ config["response_schema"] = CoordinationResponse.model_json_schema()
1485
+ else:
1486
+ # Tools or sessions are present; fallback to text parsing
1487
+ pass
1488
+ # Log messages being sent after builtin_tools is defined
1489
+ log_backend_agent_message(
1490
+ agent_id or "default",
1491
+ "SEND",
1492
+ {
1493
+ "content": full_content,
1494
+ "builtin_tools": len(builtin_tools) if builtin_tools else 0,
1495
+ },
1496
+ backend_name="gemini",
1497
+ )
372
1498
 
373
1499
  # Use streaming for real-time response
374
1500
  full_content_text = ""
375
1501
  final_response = None
1502
+ if using_sdk_mcp and self.mcp_servers:
1503
+ # Reuse active sessions from MCPClient
1504
+ try:
1505
+ if not self._mcp_client:
1506
+ raise RuntimeError("MCP client not initialized")
1507
+ mcp_sessions = self._mcp_client.get_active_sessions()
1508
+ if not mcp_sessions:
1509
+ raise RuntimeError("No active MCP sessions available")
1510
+
1511
+ # Convert sessions to permission sessions if filesystem manager is available
1512
+ if self.filesystem_manager:
1513
+ logger.info(f"[Gemini] Converting {len(mcp_sessions)} MCP sessions to permission sessions")
1514
+ try:
1515
+ from ..mcp_tools.hooks import (
1516
+ convert_sessions_to_permission_sessions,
1517
+ )
376
1518
 
377
- for chunk in client.models.generate_content_stream(
378
- model=model_name, contents=full_content, config=config
379
- ):
380
- if hasattr(chunk, "text") and chunk.text:
381
- chunk_text = chunk.text
382
- full_content_text += chunk_text
383
- yield StreamChunk(type="content", content=chunk_text)
384
-
385
- # Keep track of the final response for tool processing
386
- if hasattr(chunk, "candidates"):
387
- final_response = chunk
388
-
389
- # Check for tools used in each chunk for real-time detection
390
- if builtin_tools and hasattr(chunk, "candidates") and chunk.candidates:
391
- candidate = chunk.candidates[0]
392
-
393
- # Check for code execution in this chunk
394
- if (
395
- enable_code_execution
396
- and hasattr(candidate, "content")
397
- and hasattr(candidate.content, "parts")
398
- ):
399
- for part in candidate.content.parts:
400
- if (
401
- hasattr(part, "executable_code")
402
- and part.executable_code
403
- ):
404
- code_content = getattr(
405
- part.executable_code,
406
- "code",
407
- str(part.executable_code),
408
- )
409
- yield StreamChunk(
410
- type="content",
411
- content=f"\n💻 [Code Executed]\n```python\n{code_content}\n```\n",
1519
+ mcp_sessions = convert_sessions_to_permission_sessions(mcp_sessions, self.filesystem_manager.path_permission_manager)
1520
+ except Exception as e:
1521
+ logger.error(f"[Gemini] Failed to convert sessions to permission sessions: {e}")
1522
+ # Continue with regular sessions on error
1523
+ else:
1524
+ logger.debug("[Gemini] No filesystem manager found, using standard sessions")
1525
+
1526
+ # Apply sessions as tools, do not mix with builtin or function_declarations
1527
+ session_config = dict(config)
1528
+
1529
+ # Get available tools from MCP client for logging
1530
+ available_tools = []
1531
+ if self._mcp_client:
1532
+ available_tools = list(self._mcp_client.tools.keys())
1533
+
1534
+ # Check planning mode - block MCP tools during coordination phase
1535
+ if self.is_planning_mode_enabled():
1536
+ logger.info("[Gemini] Planning mode enabled - blocking MCP tools during coordination")
1537
+ # Don't set tools, which prevents automatic function calling
1538
+ log_backend_activity(
1539
+ "gemini",
1540
+ "MCP tools blocked in planning mode",
1541
+ {
1542
+ "blocked_tools": len(available_tools),
1543
+ "session_count": len(mcp_sessions),
1544
+ },
1545
+ agent_id=agent_id,
1546
+ )
1547
+ else:
1548
+ # Log session types for debugging if needed
1549
+ logger.debug(f"[Gemini] Passing {len(mcp_sessions)} sessions to SDK: {[type(s).__name__ for s in mcp_sessions]}")
1550
+
1551
+ session_config["tools"] = mcp_sessions
1552
+
1553
+ # Track MCP tool usage attempt
1554
+ self._mcp_tool_calls_count += 1
1555
+
1556
+ log_backend_activity(
1557
+ "gemini",
1558
+ "MCP tool call initiated",
1559
+ {
1560
+ "call_number": self._mcp_tool_calls_count,
1561
+ "session_count": len(mcp_sessions),
1562
+ "available_tools": available_tools[:], # Log first 10 tools for brevity
1563
+ "total_tools": len(available_tools),
1564
+ },
1565
+ agent_id=agent_id,
1566
+ )
1567
+
1568
+ # Log MCP tool usage (SDK handles actual tool calling automatically)
1569
+ log_tool_call(
1570
+ agent_id,
1571
+ "mcp_session_tools",
1572
+ {
1573
+ "session_count": len(mcp_sessions),
1574
+ "call_number": self._mcp_tool_calls_count,
1575
+ "available_tools": available_tools,
1576
+ },
1577
+ backend_name="gemini",
1578
+ )
1579
+
1580
+ # Yield detailed MCP status as StreamChunk
1581
+ tools_info = f" ({len(available_tools)} tools available)" if available_tools else ""
1582
+ yield StreamChunk(
1583
+ type="mcp_status",
1584
+ status="mcp_tools_initiated",
1585
+ content=f"MCP tool call initiated (call #{self._mcp_tool_calls_count}){tools_info}: {', '.join(available_tools[:5])}{'...' if len(available_tools) > 5 else ''}",
1586
+ source="mcp_tools",
1587
+ )
1588
+
1589
+ # Use async streaming call with sessions (SDK supports auto-calling MCP here)
1590
+ # The SDK's session feature will still handle tool calling automatically
1591
+ stream = await client.aio.models.generate_content_stream(model=model_name, contents=full_content, config=session_config)
1592
+
1593
+ # Initialize MCPCallTracker and MCPResponseTracker for deduplication across chunks
1594
+ mcp_tracker = MCPCallTracker()
1595
+ mcp_response_tracker = MCPResponseTracker()
1596
+ mcp_tools_used = [] # Keep for backward compatibility
1597
+
1598
+ # Iterate over the asynchronous stream to get chunks as they arrive
1599
+ async for chunk in stream:
1600
+ # ============================================
1601
+ # 1. Process MCP function calls/responses
1602
+ # ============================================
1603
+ if hasattr(chunk, "automatic_function_calling_history") and chunk.automatic_function_calling_history:
1604
+ for history_item in chunk.automatic_function_calling_history:
1605
+ if hasattr(history_item, "parts") and history_item.parts is not None:
1606
+ for part in history_item.parts:
1607
+ # Check for function_call part
1608
+ if hasattr(part, "function_call") and part.function_call:
1609
+ # Use MCPResponseExtractor to extract call data
1610
+ call_data = self.mcp_extractor.extract_function_call(part.function_call)
1611
+
1612
+ if call_data:
1613
+ tool_name = call_data["name"]
1614
+ tool_args = call_data["arguments"]
1615
+
1616
+ # Check if this is a new call using the tracker
1617
+ if mcp_tracker.is_new_call(tool_name, tool_args):
1618
+ # Add to tracker history
1619
+ call_record = mcp_tracker.add_call(tool_name, tool_args)
1620
+
1621
+ # Add to legacy list for compatibility
1622
+ mcp_tools_used.append(
1623
+ {
1624
+ "name": tool_name,
1625
+ "arguments": tool_args,
1626
+ "timestamp": call_record["timestamp"],
1627
+ },
1628
+ )
1629
+
1630
+ # Format timestamp for display
1631
+ timestamp_str = time.strftime(
1632
+ "%H:%M:%S",
1633
+ time.localtime(call_record["timestamp"]),
1634
+ )
1635
+
1636
+ # Yield detailed MCP tool call information
1637
+ yield StreamChunk(
1638
+ type="mcp_status",
1639
+ status="mcp_tool_called",
1640
+ content=f"🔧 MCP Tool Called: {tool_name} at {timestamp_str} with args: {json.dumps(tool_args, indent=2)}",
1641
+ source="mcp_tools",
1642
+ )
1643
+
1644
+ # Log the specific tool call
1645
+ log_tool_call(
1646
+ agent_id,
1647
+ tool_name,
1648
+ tool_args,
1649
+ backend_name="gemini",
1650
+ )
1651
+
1652
+ # Check for function_response part
1653
+ elif hasattr(part, "function_response") and part.function_response:
1654
+ # Use MCPResponseExtractor to extract response data
1655
+ response_data = self.mcp_extractor.extract_function_response(part.function_response)
1656
+
1657
+ if response_data:
1658
+ tool_name = response_data["name"]
1659
+ tool_response = response_data["response"]
1660
+
1661
+ # Check if this is a new response using the tracker
1662
+ if mcp_response_tracker.is_new_response(tool_name, tool_response):
1663
+ # Add to tracker history
1664
+ response_record = mcp_response_tracker.add_response(tool_name, tool_response)
1665
+
1666
+ # Extract text content from CallToolResult
1667
+ response_text = None
1668
+ if isinstance(tool_response, dict) and "result" in tool_response:
1669
+ result = tool_response["result"]
1670
+ # Check if result has content attribute (CallToolResult object)
1671
+ if hasattr(result, "content") and result.content:
1672
+ # Get the first content item (TextContent object)
1673
+ first_content = result.content[0]
1674
+ # Extract the text attribute
1675
+ if hasattr(first_content, "text"):
1676
+ response_text = first_content.text
1677
+
1678
+ # Use extracted text or fallback to string representation
1679
+ if response_text is None:
1680
+ response_text = str(tool_response)
1681
+
1682
+ # Format timestamp for display
1683
+ timestamp_str = time.strftime(
1684
+ "%H:%M:%S",
1685
+ time.localtime(response_record["timestamp"]),
1686
+ )
1687
+
1688
+ # Yield MCP tool response information
1689
+ yield StreamChunk(
1690
+ type="mcp_status",
1691
+ status="mcp_tool_response",
1692
+ content=f"✅ MCP Tool Response from {tool_name} at {timestamp_str}: {response_text}",
1693
+ source="mcp_tools",
1694
+ )
1695
+
1696
+ # Log the tool response
1697
+ log_backend_activity(
1698
+ "gemini",
1699
+ "MCP tool response received",
1700
+ {
1701
+ "tool_name": tool_name,
1702
+ "response_preview": str(tool_response)[:],
1703
+ },
1704
+ agent_id=agent_id,
1705
+ )
1706
+
1707
+ # Track successful MCP tool execution (only on first chunk with MCP history)
1708
+ if not hasattr(self, "_mcp_stream_started"):
1709
+ self._mcp_tool_successes += 1
1710
+ self._mcp_stream_started = True
1711
+ log_backend_activity(
1712
+ "gemini",
1713
+ "MCP tool call succeeded",
1714
+ {"call_number": self._mcp_tool_calls_count},
1715
+ agent_id=agent_id,
412
1716
  )
413
- elif (
414
- hasattr(part, "code_execution_result")
415
- and part.code_execution_result
416
- ):
417
- result_content = getattr(
418
- part.code_execution_result,
419
- "output",
420
- str(part.code_execution_result),
1717
+
1718
+ # Log MCP tool success as a tool call event
1719
+ log_tool_call(
1720
+ agent_id,
1721
+ "mcp_session_tools",
1722
+ {
1723
+ "session_count": len(mcp_sessions),
1724
+ "call_number": self._mcp_tool_calls_count,
1725
+ },
1726
+ result="success",
1727
+ backend_name="gemini",
421
1728
  )
1729
+
1730
+ # Yield MCP success status as StreamChunk
422
1731
  yield StreamChunk(
423
- type="content",
424
- content=f"📊 [Result] {result_content}\n",
1732
+ type="mcp_status",
1733
+ status="mcp_tools_success",
1734
+ content=f"MCP tool call succeeded (call #{self._mcp_tool_calls_count})",
1735
+ source="mcp_tools",
425
1736
  )
426
1737
 
1738
+ # ============================================
1739
+ # 2. Process text content
1740
+ # ============================================
1741
+ if hasattr(chunk, "text") and chunk.text:
1742
+ chunk_text = chunk.text
1743
+ full_content_text += chunk_text
1744
+ log_backend_agent_message(
1745
+ agent_id,
1746
+ "RECV",
1747
+ {"content": chunk_text},
1748
+ backend_name="gemini",
1749
+ )
1750
+ log_stream_chunk("backend.gemini", "content", chunk_text, agent_id)
1751
+ yield StreamChunk(type="content", content=chunk_text)
1752
+
1753
+ # Reset stream tracking
1754
+ if hasattr(self, "_mcp_stream_started"):
1755
+ delattr(self, "_mcp_stream_started")
1756
+
1757
+ # Add MCP usage indicator with detailed summary using tracker
1758
+ tools_summary = mcp_tracker.get_summary()
1759
+ if not tools_summary or tools_summary == "No MCP tools called":
1760
+ tools_summary = "MCP session completed (no tools explicitly called)"
1761
+ else:
1762
+ tools_summary = f"MCP session complete - {tools_summary}"
1763
+
1764
+ log_stream_chunk("backend.gemini", "mcp_indicator", tools_summary, agent_id)
1765
+ yield StreamChunk(
1766
+ type="mcp_status",
1767
+ status="mcp_session_complete",
1768
+ content=f"MCP session complete - {tools_summary}",
1769
+ source="mcp_tools",
1770
+ )
1771
+ except (
1772
+ MCPConnectionError,
1773
+ MCPTimeoutError,
1774
+ MCPServerError,
1775
+ MCPError,
1776
+ Exception,
1777
+ ) as e:
1778
+ log_stream_chunk("backend.gemini", "mcp_error", str(e), agent_id)
1779
+
1780
+ # Emit user-friendly error message
1781
+ async for chunk in self._handle_mcp_error_and_fallback(e):
1782
+ yield chunk
1783
+
1784
+ # Fallback to non-MCP streaming with manual configuration
1785
+ manual_config = dict(config)
1786
+ if all_tools:
1787
+ manual_config["tools"] = all_tools
1788
+
1789
+ # Need to create a new stream for fallback since stream is None
1790
+ stream = await client.aio.models.generate_content_stream(model=model_name, contents=full_content, config=manual_config)
1791
+
1792
+ async for chunk in stream:
1793
+ # Process text content
1794
+ if hasattr(chunk, "text") and chunk.text:
1795
+ chunk_text = chunk.text
1796
+ full_content_text += chunk_text
1797
+ # Log fallback content chunks
1798
+ log_stream_chunk(
1799
+ "backend.gemini",
1800
+ "fallback_content",
1801
+ chunk_text,
1802
+ agent_id,
1803
+ )
1804
+ yield StreamChunk(type="content", content=chunk_text)
1805
+
1806
+ else:
1807
+ # Non-MCP path (existing behavior)
1808
+ # Create stream for non-MCP path
1809
+ stream = await client.aio.models.generate_content_stream(model=model_name, contents=full_content, config=config)
1810
+
1811
+ async for chunk in stream:
1812
+ # ============================================
1813
+ # 1. Process text content
1814
+ # ============================================
1815
+ if hasattr(chunk, "text") and chunk.text:
1816
+ chunk_text = chunk.text
1817
+ full_content_text += chunk_text
1818
+
1819
+ # Enhanced logging for non-MCP streaming chunks
1820
+ log_stream_chunk("backend.gemini", "content", chunk_text, agent_id)
1821
+ log_backend_agent_message(
1822
+ agent_id,
1823
+ "RECV",
1824
+ {"content": chunk_text},
1825
+ backend_name="gemini",
1826
+ )
1827
+
1828
+ yield StreamChunk(type="content", content=chunk_text)
1829
+
427
1830
  content = full_content_text
428
1831
 
429
- # Process coordination FIRST (before adding tool indicators that might confuse parsing)
430
- tool_calls_detected = []
431
- if is_coordination and content.strip():
1832
+ # Process tool calls - only coordination tool calls (MCP manual mode removed)
1833
+ tool_calls_detected: List[Dict[str, Any]] = []
1834
+
1835
+ # Then, process coordination tools if present
1836
+ if is_coordination and content.strip() and not tool_calls_detected:
432
1837
  # For structured output mode, the entire content is JSON
433
1838
  structured_response = None
434
1839
  # Try multiple parsing strategies
@@ -439,47 +1844,43 @@ Make your decision and include the JSON at the very end of your response."""
439
1844
  # Strategy 2: Extract JSON from mixed text content (handles markdown-wrapped JSON)
440
1845
  structured_response = self.extract_structured_response(content)
441
1846
 
442
- if (
443
- structured_response
444
- and isinstance(structured_response, dict)
445
- and "action_type" in structured_response
446
- ):
1847
+ if structured_response and isinstance(structured_response, dict) and "action_type" in structured_response:
447
1848
  # Convert to tool calls
448
- tool_calls = self.convert_structured_to_tool_calls(
449
- structured_response
450
- )
1849
+ tool_calls = self.convert_structured_to_tool_calls(structured_response)
451
1850
  if tool_calls:
452
1851
  tool_calls_detected = tool_calls
1852
+ # Log conversion to tool calls (summary)
1853
+ log_stream_chunk("backend.gemini", "tool_calls", tool_calls, agent_id)
1854
+
1855
+ # Log each coordination tool call for analytics/debugging
1856
+ try:
1857
+ for tool_call in tool_calls:
1858
+ log_tool_call(
1859
+ agent_id,
1860
+ tool_call.get("function", {}).get("name", "unknown_coordination_tool"),
1861
+ tool_call.get("function", {}).get("arguments", {}),
1862
+ result="coordination_tool_called",
1863
+ backend_name="gemini",
1864
+ )
1865
+ except Exception:
1866
+ # Ensure logging does not interrupt flow
1867
+ pass
453
1868
 
454
1869
  # Process builtin tool results if any tools were used
455
- builtin_tool_results = []
456
- if (
457
- builtin_tools
458
- and final_response
459
- and hasattr(final_response, "candidates")
460
- and final_response.candidates
461
- ):
1870
+ if builtin_tools and final_response and hasattr(final_response, "candidates") and final_response.candidates:
462
1871
  # Check for grounding or code execution results
463
1872
  candidate = final_response.candidates[0]
464
1873
 
465
1874
  # Check for web search results - only show if actually used
466
- if (
467
- hasattr(candidate, "grounding_metadata")
468
- and candidate.grounding_metadata
469
- ):
1875
+ if hasattr(candidate, "grounding_metadata") and candidate.grounding_metadata:
470
1876
  # Check if web search was actually used by looking for queries or chunks
471
1877
  search_actually_used = False
472
1878
  search_queries = []
473
1879
 
474
1880
  # Look for web search queries
475
- if (
476
- hasattr(candidate.grounding_metadata, "web_search_queries")
477
- and candidate.grounding_metadata.web_search_queries
478
- ):
1881
+ if hasattr(candidate.grounding_metadata, "web_search_queries") and candidate.grounding_metadata.web_search_queries:
479
1882
  try:
480
- for (
481
- query
482
- ) in candidate.grounding_metadata.web_search_queries:
1883
+ for query in candidate.grounding_metadata.web_search_queries:
483
1884
  if query and query.strip():
484
1885
  search_queries.append(query.strip())
485
1886
  search_actually_used = True
@@ -487,10 +1888,7 @@ Make your decision and include the JSON at the very end of your response."""
487
1888
  pass
488
1889
 
489
1890
  # Look for grounding chunks (indicates actual search results)
490
- if (
491
- hasattr(candidate.grounding_metadata, "grounding_chunks")
492
- and candidate.grounding_metadata.grounding_chunks
493
- ):
1891
+ if hasattr(candidate.grounding_metadata, "grounding_chunks") and candidate.grounding_metadata.grounding_chunks:
494
1892
  try:
495
1893
  if len(candidate.grounding_metadata.grounding_chunks) > 0:
496
1894
  search_actually_used = True
@@ -499,6 +1897,23 @@ Make your decision and include the JSON at the very end of your response."""
499
1897
 
500
1898
  # Only show indicators if search was actually used
501
1899
  if search_actually_used:
1900
+ # Enhanced web search logging
1901
+ log_stream_chunk(
1902
+ "backend.gemini",
1903
+ "web_search_result",
1904
+ {"queries": search_queries, "results_integrated": True},
1905
+ agent_id,
1906
+ )
1907
+ log_tool_call(
1908
+ agent_id,
1909
+ "google_search_retrieval",
1910
+ {
1911
+ "queries": search_queries,
1912
+ "chunks_found": len(candidate.grounding_metadata.grounding_chunks) if hasattr(candidate.grounding_metadata, "grounding_chunks") else 0,
1913
+ },
1914
+ result="search_completed",
1915
+ backend_name="gemini",
1916
+ )
502
1917
  yield StreamChunk(
503
1918
  type="content",
504
1919
  content="🔍 [Builtin Tool: Web Search] Results integrated\n",
@@ -506,37 +1921,25 @@ Make your decision and include the JSON at the very end of your response."""
506
1921
 
507
1922
  # Show search queries
508
1923
  for query in search_queries:
509
- yield StreamChunk(
510
- type="content", content=f"🔍 [Search Query] '{query}'\n"
1924
+ log_stream_chunk(
1925
+ "backend.gemini",
1926
+ "web_search_result",
1927
+ {"queries": search_queries, "results_integrated": True},
1928
+ agent_id,
511
1929
  )
1930
+ yield StreamChunk(type="content", content=f"🔍 [Search Query] '{query}'\n")
512
1931
 
513
- builtin_result = {
514
- "id": f"web_search_{hash(str(candidate.grounding_metadata)) % 10000}",
515
- "tool_type": "google_search_retrieval",
516
- "status": "completed",
517
- "metadata": str(candidate.grounding_metadata),
518
- }
519
- builtin_tool_results.append(builtin_result)
520
1932
  self.search_count += 1
521
1933
 
522
1934
  # Check for code execution in the response parts
523
- if (
524
- enable_code_execution
525
- and hasattr(candidate, "content")
526
- and hasattr(candidate.content, "parts")
527
- ):
1935
+ if enable_code_execution and hasattr(candidate, "content") and hasattr(candidate.content, "parts"):
528
1936
  # Look for executable_code and code_execution_result parts
529
1937
  code_parts = []
530
1938
  for part in candidate.content.parts:
531
1939
  if hasattr(part, "executable_code") and part.executable_code:
532
- code_content = getattr(
533
- part.executable_code, "code", str(part.executable_code)
534
- )
1940
+ code_content = getattr(part.executable_code, "code", str(part.executable_code))
535
1941
  code_parts.append(f"Code: {code_content}")
536
- elif (
537
- hasattr(part, "code_execution_result")
538
- and part.code_execution_result
539
- ):
1942
+ elif hasattr(part, "code_execution_result") and part.code_execution_result:
540
1943
  result_content = getattr(
541
1944
  part.code_execution_result,
542
1945
  "output",
@@ -546,6 +1949,25 @@ Make your decision and include the JSON at the very end of your response."""
546
1949
 
547
1950
  if code_parts:
548
1951
  # Code execution was actually used
1952
+ log_stream_chunk(
1953
+ "backend.gemini",
1954
+ "code_execution",
1955
+ "Code executed",
1956
+ agent_id,
1957
+ )
1958
+
1959
+ # Log code execution as a tool call event
1960
+ try:
1961
+ log_tool_call(
1962
+ agent_id,
1963
+ "code_execution",
1964
+ {"code_parts_count": len(code_parts)},
1965
+ result="code_executed",
1966
+ backend_name="gemini",
1967
+ )
1968
+ except Exception:
1969
+ pass
1970
+
549
1971
  yield StreamChunk(
550
1972
  type="content",
551
1973
  content="💻 [Builtin Tool: Code Execution] Code executed\n",
@@ -554,36 +1976,51 @@ Make your decision and include the JSON at the very end of your response."""
554
1976
  for part in code_parts:
555
1977
  if part.startswith("Code: "):
556
1978
  code_content = part[6:] # Remove "Code: " prefix
1979
+ log_stream_chunk(
1980
+ "backend.gemini",
1981
+ "code_execution_result",
1982
+ {
1983
+ "code_parts": len(code_parts),
1984
+ "execution_successful": True,
1985
+ "snippet": code_content,
1986
+ },
1987
+ agent_id,
1988
+ )
557
1989
  yield StreamChunk(
558
1990
  type="content",
559
1991
  content=f"💻 [Code Executed]\n```python\n{code_content}\n```\n",
560
1992
  )
561
1993
  elif part.startswith("Result: "):
562
1994
  result_content = part[8:] # Remove "Result: " prefix
1995
+ log_stream_chunk(
1996
+ "backend.gemini",
1997
+ "code_execution_result",
1998
+ {
1999
+ "code_parts": len(code_parts),
2000
+ "execution_successful": True,
2001
+ "result": result_content,
2002
+ },
2003
+ agent_id,
2004
+ )
563
2005
  yield StreamChunk(
564
2006
  type="content",
565
2007
  content=f"📊 [Result] {result_content}\n",
566
2008
  )
567
2009
 
568
- builtin_result = {
569
- "id": f"code_execution_{hash(str(code_parts)) % 10000}",
570
- "tool_type": "code_execution",
571
- "status": "completed",
572
- "code_parts": code_parts,
573
- "output": "; ".join(code_parts),
574
- }
575
- builtin_tool_results.append(builtin_result)
576
2010
  self.code_execution_count += 1
577
2011
 
578
- # Yield builtin tool results
579
- if builtin_tool_results:
580
- yield StreamChunk(
581
- type="builtin_tool_results",
582
- builtin_tool_results=builtin_tool_results,
583
- )
584
-
585
2012
  # Yield coordination tool calls if detected
586
2013
  if tool_calls_detected:
2014
+ # Enhanced tool calls summary logging
2015
+ log_stream_chunk(
2016
+ "backend.gemini",
2017
+ "tool_calls_yielded",
2018
+ {
2019
+ "tool_count": len(tool_calls_detected),
2020
+ "tool_names": [tc.get("function", {}).get("name") for tc in tool_calls_detected],
2021
+ },
2022
+ agent_id,
2023
+ )
587
2024
  yield StreamChunk(type="tool_calls", tool_calls=tool_calls_detected)
588
2025
 
589
2026
  # Build complete message
@@ -591,62 +2028,234 @@ Make your decision and include the JSON at the very end of your response."""
591
2028
  if tool_calls_detected:
592
2029
  complete_message["tool_calls"] = tool_calls_detected
593
2030
 
594
- yield StreamChunk(
595
- type="complete_message", complete_message=complete_message
2031
+ # Enhanced complete message logging with metadata
2032
+ log_stream_chunk(
2033
+ "backend.gemini",
2034
+ "complete_message",
2035
+ {
2036
+ "content_length": len(content.strip()),
2037
+ "has_tool_calls": bool(tool_calls_detected),
2038
+ },
2039
+ agent_id,
596
2040
  )
2041
+ yield StreamChunk(type="complete_message", complete_message=complete_message)
2042
+ log_stream_chunk("backend.gemini", "done", None, agent_id)
597
2043
  yield StreamChunk(type="done")
598
2044
 
599
2045
  except Exception as e:
600
- yield StreamChunk(type="error", error=f"Gemini API error: {e}")
2046
+ error_msg = f"Gemini API error: {e}"
2047
+ # Enhanced error logging with structured details
2048
+ log_stream_chunk(
2049
+ "backend.gemini",
2050
+ "stream_error",
2051
+ {"error_type": type(e).__name__, "error_message": str(e)},
2052
+ agent_id,
2053
+ )
2054
+ yield StreamChunk(type="error", error=error_msg)
2055
+ finally:
2056
+ # Cleanup resources
2057
+ await self._cleanup_resources(stream, client)
2058
+ # Ensure context manager exit for MCP cleanup
2059
+ try:
2060
+ await self.__aexit__(None, None, None)
2061
+ except Exception as e:
2062
+ log_backend_activity(
2063
+ "gemini",
2064
+ "MCP cleanup failed",
2065
+ {"error": str(e)},
2066
+ agent_id=self.agent_id,
2067
+ )
601
2068
 
602
2069
  def get_provider_name(self) -> str:
603
2070
  """Get the provider name."""
604
2071
  return "Gemini"
605
2072
 
2073
+ def get_filesystem_support(self) -> FilesystemSupport:
2074
+ """Gemini supports filesystem through MCP servers."""
2075
+ return FilesystemSupport.MCP
2076
+
606
2077
  def get_supported_builtin_tools(self) -> List[str]:
607
2078
  """Get list of builtin tools supported by Gemini."""
608
2079
  return ["google_search_retrieval", "code_execution"]
609
2080
 
610
- def estimate_tokens(self, text: str) -> int:
611
- """Estimate token count for text (Gemini uses ~4 chars per token)."""
612
- return len(text) // 4
613
-
614
- def calculate_cost(
615
- self, input_tokens: int, output_tokens: int, model: str
616
- ) -> float:
617
- """Calculate cost for Gemini token usage (2025 pricing)."""
618
- model_lower = model.lower()
619
-
620
- if "gemini-2.5-pro" in model_lower:
621
- # Gemini 2.5 Pro pricing
622
- input_cost = (input_tokens / 1_000_000) * 1.25
623
- output_cost = (output_tokens / 1_000_000) * 5.0
624
- elif "gemini-2.5-flash" in model_lower:
625
- if "lite" in model_lower:
626
- # Gemini 2.5 Flash-Lite pricing
627
- input_cost = (input_tokens / 1_000_000) * 0.075
628
- output_cost = (output_tokens / 1_000_000) * 0.30
629
- else:
630
- # Gemini 2.5 Flash pricing
631
- input_cost = (input_tokens / 1_000_000) * 0.15
632
- output_cost = (output_tokens / 1_000_000) * 0.60
633
- else:
634
- # Default fallback (assume Flash pricing)
635
- input_cost = (input_tokens / 1_000_000) * 0.15
636
- output_cost = (output_tokens / 1_000_000) * 0.60
637
-
638
- # Add tool usage costs (estimates)
639
- tool_costs = 0.0
640
- if self.search_count > 0:
641
- tool_costs += self.search_count * 0.01 # Estimated search cost
642
-
643
- if self.code_execution_count > 0:
644
- tool_costs += self.code_execution_count * 0.005 # Estimated execution cost
645
-
646
- return input_cost + output_cost + tool_costs
2081
+ def get_mcp_results(self) -> Dict[str, Any]:
2082
+ """
2083
+ Get all captured MCP tool calls and responses.
2084
+
2085
+ Returns:
2086
+ Dict containing:
2087
+ - calls: List of all MCP tool calls
2088
+ - responses: List of all MCP tool responses
2089
+ - pairs: List of matched call-response pairs
2090
+ - summary: Statistical summary of interactions
2091
+ """
2092
+ return {
2093
+ "calls": self.mcp_extractor.mcp_calls,
2094
+ "responses": self.mcp_extractor.mcp_responses,
2095
+ "pairs": self.mcp_extractor.call_response_pairs,
2096
+ "summary": self.mcp_extractor.get_summary(),
2097
+ }
2098
+
2099
+ def get_mcp_paired_results(self) -> List[Dict[str, Any]]:
2100
+ """
2101
+ Get only the paired MCP tool calls and responses.
2102
+
2103
+ Returns:
2104
+ List of dictionaries containing matched call-response pairs
2105
+ """
2106
+ return self.mcp_extractor.call_response_pairs
2107
+
2108
+ def get_mcp_summary(self) -> Dict[str, Any]:
2109
+ """
2110
+ Get a summary of MCP tool interactions.
2111
+
2112
+ Returns:
2113
+ Dictionary with statistics about MCP tool usage
2114
+ """
2115
+ return self.mcp_extractor.get_summary()
2116
+
2117
+ def clear_mcp_results(self):
2118
+ """Clear all stored MCP interaction data."""
2119
+ self.mcp_extractor.clear()
647
2120
 
648
2121
  def reset_tool_usage(self):
649
2122
  """Reset tool usage tracking."""
650
2123
  self.search_count = 0
651
2124
  self.code_execution_count = 0
2125
+ # Reset MCP monitoring metrics
2126
+ self._mcp_tool_calls_count = 0
2127
+ self._mcp_tool_failures = 0
2128
+ self._mcp_tool_successes = 0
2129
+ self._mcp_connection_retries = 0
2130
+ # Clear MCP extractor data
2131
+ self.mcp_extractor.clear()
652
2132
  super().reset_token_usage()
2133
+
2134
+ async def cleanup_mcp(self):
2135
+ """Cleanup MCP connections."""
2136
+ if self._mcp_client:
2137
+ try:
2138
+ await self._mcp_client.disconnect()
2139
+ log_backend_activity("gemini", "MCP client disconnected", {}, agent_id=self.agent_id)
2140
+ except (
2141
+ MCPConnectionError,
2142
+ MCPTimeoutError,
2143
+ MCPServerError,
2144
+ MCPError,
2145
+ Exception,
2146
+ ) as e:
2147
+ MCPErrorHandler.get_error_details(e, "disconnect", log=True)
2148
+ finally:
2149
+ self._mcp_client = None
2150
+ self._mcp_initialized = False
2151
+
2152
+ async def _cleanup_resources(self, stream, client):
2153
+ """Cleanup google-genai resources to avoid unclosed aiohttp sessions."""
2154
+ # Close stream
2155
+ try:
2156
+ if stream is not None:
2157
+ close_fn = getattr(stream, "aclose", None) or getattr(stream, "close", None)
2158
+ if close_fn is not None:
2159
+ maybe = close_fn()
2160
+ if hasattr(maybe, "__await__"):
2161
+ await maybe
2162
+ except Exception as e:
2163
+ log_backend_activity(
2164
+ "gemini",
2165
+ "Stream cleanup failed",
2166
+ {"error": str(e)},
2167
+ agent_id=self.agent_id,
2168
+ )
2169
+ # Close internal aiohttp session held by google-genai BaseApiClient
2170
+ try:
2171
+ if client is not None:
2172
+ base_client = getattr(client, "_api_client", None)
2173
+ if base_client is not None:
2174
+ session = getattr(base_client, "_aiohttp_session", None)
2175
+ if session is not None and hasattr(session, "close"):
2176
+ if not session.closed:
2177
+ await session.close()
2178
+ log_backend_activity(
2179
+ "gemini",
2180
+ "Closed google-genai aiohttp session",
2181
+ {},
2182
+ agent_id=self.agent_id,
2183
+ )
2184
+ base_client._aiohttp_session = None
2185
+ # Yield control to allow connector cleanup
2186
+ await asyncio.sleep(0)
2187
+ except Exception as e:
2188
+ log_backend_activity(
2189
+ "gemini",
2190
+ "Failed to close google-genai aiohttp session",
2191
+ {"error": str(e)},
2192
+ agent_id=self.agent_id,
2193
+ )
2194
+ # Close internal async transport if exposed
2195
+ try:
2196
+ if client is not None and hasattr(client, "aio") and client.aio is not None:
2197
+ aio_obj = client.aio
2198
+ for method_name in ("close", "stop"):
2199
+ method = getattr(aio_obj, method_name, None)
2200
+ if method:
2201
+ maybe = method()
2202
+ if hasattr(maybe, "__await__"):
2203
+ await maybe
2204
+ break
2205
+ except Exception as e:
2206
+ log_backend_activity(
2207
+ "gemini",
2208
+ "Client AIO cleanup failed",
2209
+ {"error": str(e)},
2210
+ agent_id=self.agent_id,
2211
+ )
2212
+
2213
+ # Close client
2214
+ try:
2215
+ if client is not None:
2216
+ for method_name in ("aclose", "close"):
2217
+ method = getattr(client, method_name, None)
2218
+ if method:
2219
+ maybe = method()
2220
+ if hasattr(maybe, "__await__"):
2221
+ await maybe
2222
+ break
2223
+ except Exception as e:
2224
+ log_backend_activity(
2225
+ "gemini",
2226
+ "Client cleanup failed",
2227
+ {"error": str(e)},
2228
+ agent_id=self.agent_id,
2229
+ )
2230
+
2231
+ async def __aenter__(self) -> "GeminiBackend":
2232
+ """Async context manager entry."""
2233
+ try:
2234
+ await self._setup_mcp_tools(agent_id=self.agent_id)
2235
+ except Exception as e:
2236
+ log_backend_activity(
2237
+ "gemini",
2238
+ "MCP setup failed during context entry",
2239
+ {"error": str(e)},
2240
+ agent_id=self.agent_id,
2241
+ )
2242
+ return self
2243
+
2244
+ async def __aexit__(
2245
+ self,
2246
+ exc_type: Optional[type],
2247
+ exc_val: Optional[BaseException],
2248
+ exc_tb: Optional[object],
2249
+ ) -> None:
2250
+ """Async context manager exit with automatic resource cleanup."""
2251
+ # Parameters are required by context manager protocol but not used
2252
+ _ = (exc_type, exc_val, exc_tb)
2253
+ try:
2254
+ await self.cleanup_mcp()
2255
+ except Exception as e:
2256
+ log_backend_activity(
2257
+ "gemini",
2258
+ "Backend cleanup error",
2259
+ {"error": str(e)},
2260
+ agent_id=self.agent_id,
2261
+ )