massgen 0.1.4__py3-none-any.whl → 0.1.6__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 (84) hide show
  1. massgen/__init__.py +1 -1
  2. massgen/backend/base_with_custom_tool_and_mcp.py +453 -23
  3. massgen/backend/capabilities.py +39 -0
  4. massgen/backend/chat_completions.py +111 -197
  5. massgen/backend/claude.py +210 -181
  6. massgen/backend/gemini.py +1015 -1559
  7. massgen/backend/grok.py +3 -2
  8. massgen/backend/response.py +160 -220
  9. massgen/chat_agent.py +340 -20
  10. massgen/cli.py +399 -25
  11. massgen/config_builder.py +20 -54
  12. massgen/config_validator.py +931 -0
  13. massgen/configs/README.md +95 -10
  14. massgen/configs/memory/gpt5mini_gemini_baseline_research_to_implementation.yaml +94 -0
  15. massgen/configs/memory/gpt5mini_gemini_context_window_management.yaml +187 -0
  16. massgen/configs/memory/gpt5mini_gemini_research_to_implementation.yaml +127 -0
  17. massgen/configs/memory/gpt5mini_high_reasoning_gemini.yaml +107 -0
  18. massgen/configs/memory/single_agent_compression_test.yaml +64 -0
  19. massgen/configs/tools/custom_tools/claude_code_custom_tool_with_mcp_example.yaml +1 -0
  20. massgen/configs/tools/custom_tools/claude_custom_tool_example_no_path.yaml +1 -1
  21. massgen/configs/tools/custom_tools/claude_custom_tool_with_mcp_example.yaml +1 -0
  22. massgen/configs/tools/custom_tools/computer_use_browser_example.yaml +1 -1
  23. massgen/configs/tools/custom_tools/computer_use_docker_example.yaml +1 -1
  24. massgen/configs/tools/custom_tools/gemini_custom_tool_with_mcp_example.yaml +1 -0
  25. massgen/configs/tools/custom_tools/gpt5_nano_custom_tool_with_mcp_example.yaml +1 -0
  26. massgen/configs/tools/custom_tools/gpt_oss_custom_tool_with_mcp_example.yaml +1 -0
  27. massgen/configs/tools/custom_tools/grok3_mini_custom_tool_with_mcp_example.yaml +1 -0
  28. massgen/configs/tools/custom_tools/interop/ag2_and_langgraph_lesson_planner.yaml +65 -0
  29. massgen/configs/tools/custom_tools/interop/ag2_and_openai_assistant_lesson_planner.yaml +65 -0
  30. massgen/configs/tools/custom_tools/interop/ag2_lesson_planner_example.yaml +48 -0
  31. massgen/configs/tools/custom_tools/interop/agentscope_lesson_planner_example.yaml +48 -0
  32. massgen/configs/tools/custom_tools/interop/langgraph_lesson_planner_example.yaml +49 -0
  33. massgen/configs/tools/custom_tools/interop/openai_assistant_lesson_planner_example.yaml +50 -0
  34. massgen/configs/tools/custom_tools/interop/smolagent_lesson_planner_example.yaml +49 -0
  35. massgen/configs/tools/custom_tools/qwen_api_custom_tool_with_mcp_example.yaml +1 -0
  36. massgen/configs/tools/custom_tools/two_models_with_tools_example.yaml +44 -0
  37. massgen/formatter/_gemini_formatter.py +61 -15
  38. massgen/memory/README.md +277 -0
  39. massgen/memory/__init__.py +26 -0
  40. massgen/memory/_base.py +193 -0
  41. massgen/memory/_compression.py +237 -0
  42. massgen/memory/_context_monitor.py +211 -0
  43. massgen/memory/_conversation.py +255 -0
  44. massgen/memory/_fact_extraction_prompts.py +333 -0
  45. massgen/memory/_mem0_adapters.py +257 -0
  46. massgen/memory/_persistent.py +687 -0
  47. massgen/memory/docker-compose.qdrant.yml +36 -0
  48. massgen/memory/docs/DESIGN.md +388 -0
  49. massgen/memory/docs/QUICKSTART.md +409 -0
  50. massgen/memory/docs/SUMMARY.md +319 -0
  51. massgen/memory/docs/agent_use_memory.md +408 -0
  52. massgen/memory/docs/orchestrator_use_memory.md +586 -0
  53. massgen/memory/examples.py +237 -0
  54. massgen/orchestrator.py +207 -7
  55. massgen/tests/memory/test_agent_compression.py +174 -0
  56. massgen/tests/memory/test_context_window_management.py +286 -0
  57. massgen/tests/memory/test_force_compression.py +154 -0
  58. massgen/tests/memory/test_simple_compression.py +147 -0
  59. massgen/tests/test_ag2_lesson_planner.py +223 -0
  60. massgen/tests/test_agent_memory.py +534 -0
  61. massgen/tests/test_config_validator.py +1156 -0
  62. massgen/tests/test_conversation_memory.py +382 -0
  63. massgen/tests/test_langgraph_lesson_planner.py +223 -0
  64. massgen/tests/test_orchestrator_memory.py +620 -0
  65. massgen/tests/test_persistent_memory.py +435 -0
  66. massgen/token_manager/token_manager.py +6 -0
  67. massgen/tool/__init__.py +2 -9
  68. massgen/tool/_decorators.py +52 -0
  69. massgen/tool/_extraframework_agents/ag2_lesson_planner_tool.py +251 -0
  70. massgen/tool/_extraframework_agents/agentscope_lesson_planner_tool.py +303 -0
  71. massgen/tool/_extraframework_agents/langgraph_lesson_planner_tool.py +275 -0
  72. massgen/tool/_extraframework_agents/openai_assistant_lesson_planner_tool.py +247 -0
  73. massgen/tool/_extraframework_agents/smolagent_lesson_planner_tool.py +180 -0
  74. massgen/tool/_manager.py +102 -16
  75. massgen/tool/_registered_tool.py +3 -0
  76. massgen/tool/_result.py +3 -0
  77. {massgen-0.1.4.dist-info → massgen-0.1.6.dist-info}/METADATA +138 -77
  78. {massgen-0.1.4.dist-info → massgen-0.1.6.dist-info}/RECORD +82 -37
  79. massgen/backend/gemini_mcp_manager.py +0 -545
  80. massgen/backend/gemini_trackers.py +0 -344
  81. {massgen-0.1.4.dist-info → massgen-0.1.6.dist-info}/WHEEL +0 -0
  82. {massgen-0.1.4.dist-info → massgen-0.1.6.dist-info}/entry_points.txt +0 -0
  83. {massgen-0.1.4.dist-info → massgen-0.1.6.dist-info}/licenses/LICENSE +0 -0
  84. {massgen-0.1.4.dist-info → massgen-0.1.6.dist-info}/top_level.txt +0 -0
massgen/backend/gemini.py CHANGED
@@ -19,10 +19,10 @@ TECHNICAL SOLUTION:
19
19
  - Maintains compatibility with existing MassGen workflow
20
20
  """
21
21
 
22
+ import asyncio
22
23
  import json
23
24
  import logging
24
25
  import os
25
- import time
26
26
  from typing import Any, AsyncGenerator, Dict, List, Optional
27
27
 
28
28
  from ..api_params_handler._gemini_api_params_handler import GeminiAPIParamsHandler
@@ -35,10 +35,12 @@ from ..logger_config import (
35
35
  logger,
36
36
  )
37
37
  from .base import FilesystemSupport, StreamChunk
38
- from .base_with_custom_tool_and_mcp import CustomToolAndMCPBackend
39
- from .gemini_mcp_manager import GeminiMCPManager
40
- from .gemini_trackers import MCPCallTracker, MCPResponseExtractor, MCPResponseTracker
41
- from .gemini_utils import CoordinationResponse
38
+ from .base_with_custom_tool_and_mcp import (
39
+ CustomToolAndMCPBackend,
40
+ CustomToolChunk,
41
+ ToolExecutionConfig,
42
+ )
43
+ from .gemini_utils import CoordinationResponse, PostEvaluationResponse
42
44
 
43
45
 
44
46
  # Suppress Gemini SDK logger warning about non-text parts in response
@@ -53,51 +55,31 @@ class NoFunctionCallWarning(logging.Filter):
53
55
 
54
56
  logging.getLogger("google_genai.types").addFilter(NoFunctionCallWarning())
55
57
 
56
- try:
57
- from pydantic import BaseModel, Field
58
- except ImportError:
59
- BaseModel = None
60
- Field = None
61
-
62
58
  # MCP integration imports
63
59
  try:
64
60
  from ..mcp_tools import (
65
- MCPClient,
66
- MCPConfigurationError,
67
- MCPConfigValidator,
68
61
  MCPConnectionError,
69
62
  MCPError,
70
63
  MCPServerError,
71
64
  MCPTimeoutError,
72
- MCPValidationError,
73
65
  )
74
66
  except ImportError: # MCP not installed or import failed within mcp_tools
75
- MCPClient = None # type: ignore[assignment]
76
67
  MCPError = ImportError # type: ignore[assignment]
77
68
  MCPConnectionError = ImportError # type: ignore[assignment]
78
- MCPConfigValidator = None # type: ignore[assignment]
79
- MCPConfigurationError = ImportError # type: ignore[assignment]
80
- MCPValidationError = ImportError # type: ignore[assignment]
81
69
  MCPTimeoutError = ImportError # type: ignore[assignment]
82
70
  MCPServerError = ImportError # type: ignore[assignment]
83
71
 
84
72
  # Import MCP backend utilities
85
73
  try:
86
74
  from ..mcp_tools.backend_utils import (
87
- MCPCircuitBreakerManager,
88
- MCPConfigHelper,
89
75
  MCPErrorHandler,
90
- MCPExecutionManager,
91
76
  MCPMessageManager,
92
- MCPSetupManager,
77
+ MCPResourceManager,
93
78
  )
94
79
  except ImportError:
95
80
  MCPErrorHandler = None # type: ignore[assignment]
96
- MCPSetupManager = None # type: ignore[assignment]
97
81
  MCPMessageManager = None # type: ignore[assignment]
98
- MCPCircuitBreakerManager = None # type: ignore[assignment]
99
- MCPExecutionManager = None # type: ignore[assignment]
100
- MCPConfigHelper = None # type: ignore[assignment]
82
+ MCPResourceManager = None # type: ignore[assignment]
101
83
 
102
84
 
103
85
  def format_tool_response_as_json(response_text: str) -> str:
@@ -145,11 +127,8 @@ class GeminiBackend(CustomToolAndMCPBackend):
145
127
  self._mcp_tool_successes = 0
146
128
  self._mcp_connection_retries = 0
147
129
 
148
- # MCP Response Extractor for capturing tool interactions (Gemini-specific)
149
- self.mcp_extractor = MCPResponseExtractor()
150
-
151
- # Initialize Gemini MCP manager after all attributes are ready
152
- self.mcp_manager = GeminiMCPManager(self)
130
+ # Active tool result capture during manual tool execution
131
+ self._active_tool_result_store: Optional[Dict[str, str]] = None
153
132
 
154
133
  def _setup_permission_hooks(self):
155
134
  """Override base class - Gemini uses session-based permissions, not function hooks."""
@@ -166,10 +145,10 @@ class GeminiBackend(CustomToolAndMCPBackend):
166
145
 
167
146
  async def _setup_mcp_tools(self) -> None:
168
147
  """
169
- Override parent class - Gemini uses GeminiMCPManager for MCP setup.
148
+ Override parent class - Use base class MCP setup for manual execution pattern.
170
149
  This method is called by the parent class's __aenter__() context manager.
171
150
  """
172
- await self.mcp_manager.setup_mcp_tools(agent_id=self.agent_id)
151
+ await super()._setup_mcp_tools()
173
152
 
174
153
  def supports_upload_files(self) -> bool:
175
154
  """
@@ -178,13 +157,46 @@ class GeminiBackend(CustomToolAndMCPBackend):
178
157
  """
179
158
  return False
180
159
 
160
+ def _create_client(self, **kwargs):
161
+ pass
162
+
163
+ async def _stream_with_custom_and_mcp_tools(
164
+ self,
165
+ current_messages: List[Dict[str, Any]],
166
+ tools: List[Dict[str, Any]],
167
+ client,
168
+ **kwargs,
169
+ ) -> AsyncGenerator[StreamChunk, None]:
170
+ yield StreamChunk(type="error", error="Not implemented")
171
+
181
172
  async def stream_with_tools(self, messages: List[Dict[str, Any]], tools: List[Dict[str, Any]], **kwargs) -> AsyncGenerator[StreamChunk, None]:
182
- """Stream response using Gemini API with structured output for coordination and MCP tool support."""
173
+ """Stream response using Gemini API with manual MCP execution pattern.
174
+
175
+ Tool Execution Behavior:
176
+ - Custom tools: Always executed (not blocked by planning mode or circuit breaker)
177
+ - MCP tools: Blocked by planning mode during coordination, blocked by circuit breaker when servers fail
178
+ - Provider tools (vote/new_answer): Emitted as StreamChunks but not executed (handled by orchestrator)
179
+ """
183
180
  # Use instance agent_id (from __init__) or get from kwargs if not set
184
181
  agent_id = self.agent_id or kwargs.get("agent_id", None)
185
182
  client = None
186
183
  stream = None
187
184
 
185
+ # Build execution context for tools (generic, not tool-specific)
186
+ # This is required for custom tool execution
187
+ from .base_with_custom_tool_and_mcp import ExecutionContext
188
+
189
+ self._execution_context = ExecutionContext(
190
+ messages=messages,
191
+ agent_system_message=kwargs.get("system_message", None),
192
+ agent_id=self.agent_id,
193
+ backend_name="gemini",
194
+ current_stage=self.coordination_stage,
195
+ )
196
+
197
+ # Track whether MCP tools were actually used in this turn
198
+ mcp_used = False
199
+
188
200
  log_backend_activity(
189
201
  "gemini",
190
202
  "Starting stream_with_tools",
@@ -192,7 +204,7 @@ class GeminiBackend(CustomToolAndMCPBackend):
192
204
  agent_id=agent_id,
193
205
  )
194
206
 
195
- # Only trim when MCP tools will be used
207
+ # Trim message history for MCP if needed
196
208
  if self.mcp_servers and MCPMessageManager is not None and hasattr(self, "_max_mcp_message_history") and self._max_mcp_message_history > 0:
197
209
  original_count = len(messages)
198
210
  messages = MCPMessageManager.trim_message_history(messages, self._max_mcp_message_history)
@@ -210,29 +222,26 @@ class GeminiBackend(CustomToolAndMCPBackend):
210
222
 
211
223
  try:
212
224
  from google import genai
225
+ from google.genai import types
213
226
 
214
- # Setup MCP with status streaming via manager if not already initialized
227
+ # Setup MCP using base class if not already initialized
215
228
  if not self._mcp_initialized and self.mcp_servers:
216
- async for chunk in self.mcp_manager.setup_mcp_with_status_stream(agent_id):
217
- yield chunk
218
- elif not self._mcp_initialized:
219
- # Setup MCP without streaming for backward compatibility
220
- await self.mcp_manager.setup_mcp_tools(agent_id)
229
+ await self._setup_mcp_tools()
230
+ if self._mcp_initialized:
231
+ yield StreamChunk(
232
+ type="mcp_status",
233
+ status="mcp_initialized",
234
+ content="✅ [MCP] Tools initialized",
235
+ source="mcp_tools",
236
+ )
221
237
 
222
- # Merge constructor config with stream kwargs (stream kwargs take priority)
238
+ # Merge constructor config with stream kwargs
223
239
  all_params = {**self.config, **kwargs}
224
240
 
225
- # Extract framework-specific parameters
226
- all_params.get("enable_web_search", False)
227
- enable_code_execution = all_params.get("enable_code_execution", False)
228
-
229
- # Always use SDK MCP sessions when mcp_servers are configured
230
- using_sdk_mcp = bool(self.mcp_servers)
231
-
232
- # Custom tool handling - add custom tools if any
241
+ # Detect custom tools
233
242
  using_custom_tools = bool(self.custom_tool_manager and len(self._custom_tool_names) > 0)
234
243
 
235
- # Analyze tool types
244
+ # Detect coordination mode
236
245
  is_coordination = self.formatter.has_coordination_tools(tools)
237
246
  is_post_evaluation = self.formatter.has_post_evaluation_tools(tools)
238
247
 
@@ -258,1355 +267,502 @@ class GeminiBackend(CustomToolAndMCPBackend):
258
267
  # For post-evaluation, modify prompt to use structured output
259
268
  full_content = self.formatter.build_post_evaluation_prompt(full_content)
260
269
 
261
- # Use google-genai package
270
+ # Create Gemini client
262
271
  client = genai.Client(api_key=self.api_key)
263
272
 
264
- # Setup builtin tools via API params handler (SDK Tool objects)
273
+ # Setup builtin tools via API params handler
265
274
  builtin_tools = self.api_params_handler.get_provider_tools(all_params)
266
- # Build config via API params handler (maps params, excludes backend-managed ones)
275
+
276
+ # Build config via API params handler
267
277
  config = await self.api_params_handler.build_api_params(messages, tools, all_params)
268
- # Extract model name (not included in config)
278
+
279
+ # Extract model name
269
280
  model_name = all_params.get("model")
270
281
 
271
- # Setup tools configuration (builtins only when not using sessions)
272
- all_tools = []
273
-
274
- # Branch 1: SDK auto-calling via MCP sessions (reuse existing MCPClient sessions)
275
- if using_sdk_mcp and self.mcp_servers:
276
- if not self._mcp_client or not getattr(self._mcp_client, "is_connected", lambda: False)():
277
- mcp_connected, status_chunks = await self.mcp_manager.setup_mcp_sessions_with_retry(agent_id, max_retries=5)
278
- async for chunk in status_chunks:
279
- yield chunk
280
- if not mcp_connected:
281
- using_sdk_mcp = False
282
- self._mcp_client = None
283
-
284
- if not using_sdk_mcp and not using_custom_tools:
285
- all_tools.extend(builtin_tools)
286
- if all_tools:
287
- config["tools"] = all_tools
288
-
289
- # For coordination requests, use JSON response format (may conflict with tools/sessions)
290
- if is_coordination:
291
- # Only request JSON schema when no tools are present
292
- if (not using_sdk_mcp) and (not using_custom_tools) and (not all_tools):
293
- config["response_mime_type"] = "application/json"
294
- config["response_schema"] = CoordinationResponse.model_json_schema()
295
- else:
296
- # Tools or sessions are present; fallback to text parsing
297
- pass
298
- elif is_post_evaluation:
299
- # For post-evaluation, use JSON response format for structured decisions
300
- from .gemini_utils import PostEvaluationResponse
282
+ # ====================================================================
283
+ # Tool Registration Phase: Convert and register tools for manual execution
284
+ # ====================================================================
285
+ tools_to_apply = []
301
286
 
302
- if (not using_sdk_mcp) and (not using_custom_tools) and (not all_tools):
303
- config["response_mime_type"] = "application/json"
304
- config["response_schema"] = PostEvaluationResponse.model_json_schema()
305
- else:
306
- # Tools or sessions are present; fallback to text parsing
307
- pass
308
- # Log messages being sent after builtin_tools is defined
309
- log_backend_agent_message(
310
- agent_id or "default",
311
- "SEND",
312
- {
313
- "content": full_content,
314
- "builtin_tools": len(builtin_tools) if builtin_tools else 0,
315
- },
316
- backend_name="gemini",
317
- )
318
-
319
- # Use streaming for real-time response
320
- full_content_text = ""
321
- final_response = None
322
- # Buffer the last response chunk that contains candidate metadata so we can
323
- # inspect builtin tool usage (grounding/code execution) after streaming
324
- last_response_with_candidates = None
325
- if (using_sdk_mcp and self.mcp_servers) or using_custom_tools:
326
- # Process MCP and/or custom tools
287
+ # Add custom tools if available
288
+ if using_custom_tools:
327
289
  try:
328
- # ====================================================================
329
- # Preparation phase: Initialize MCP and custom tools
330
- # ====================================================================
331
- mcp_sessions = []
332
- mcp_error = None
333
- custom_tools_functions = []
334
- custom_tools_error = None
335
-
336
- # Try to initialize MCP sessions
337
- if using_sdk_mcp and self.mcp_servers:
338
- try:
339
- if not self._mcp_client:
340
- raise RuntimeError("MCP client not initialized")
341
- mcp_sessions = self.mcp_manager.get_active_mcp_sessions(
342
- convert_to_permission_sessions=bool(self.filesystem_manager),
343
- )
344
- if not mcp_sessions:
345
- # If no MCP sessions, record error but don't interrupt (may still have custom tools)
346
- mcp_error = RuntimeError("No active MCP sessions available")
347
- logger.warning(f"[Gemini] MCP sessions unavailable: {mcp_error}")
348
- except Exception as e:
349
- mcp_error = e
350
- logger.warning(f"[Gemini] Failed to initialize MCP sessions: {e}")
351
-
352
- # Try to initialize custom tools
353
- if using_custom_tools:
354
- try:
355
- # Get custom tools schemas (in OpenAI format)
356
- custom_tools_schemas = self._get_custom_tools_schemas()
357
- if custom_tools_schemas:
358
- # Convert to Gemini SDK format using formatter
359
- # formatter handles: OpenAI format -> Gemini dict -> FunctionDeclaration objects
360
- custom_tools_functions = self.formatter.format_custom_tools(
361
- custom_tools_schemas,
362
- return_sdk_objects=True,
363
- )
364
-
365
- if custom_tools_functions:
366
- logger.debug(
367
- f"[Gemini] Loaded {len(custom_tools_functions)} custom tools " f"as FunctionDeclarations",
368
- )
369
- else:
370
- custom_tools_error = RuntimeError("Custom tools conversion failed")
371
- logger.warning(f"[Gemini] Custom tools unavailable: {custom_tools_error}")
372
- else:
373
- custom_tools_error = RuntimeError("No custom tools available")
374
- logger.warning(f"[Gemini] Custom tools unavailable: {custom_tools_error}")
375
- except Exception as e:
376
- custom_tools_error = e
377
- logger.warning(f"[Gemini] Failed to initialize custom tools: {e}")
378
-
379
- # Check if at least one tool system is available
380
- has_mcp = bool(mcp_sessions and not mcp_error)
381
- has_custom_tools = bool(custom_tools_functions and not custom_tools_error)
382
-
383
- if not has_mcp and not has_custom_tools:
384
- # Both failed, raise error to enter fallback
385
- raise RuntimeError(
386
- f"Both MCP and custom tools unavailable. " f"MCP error: {mcp_error}. Custom tools error: {custom_tools_error}",
290
+ # Get custom tools schemas (in OpenAI format)
291
+ custom_tools_schemas = self._get_custom_tools_schemas()
292
+ if custom_tools_schemas:
293
+ # Convert to Gemini SDK format using formatter
294
+ custom_tools_functions = self.formatter.format_custom_tools(
295
+ custom_tools_schemas,
296
+ return_sdk_objects=True,
387
297
  )
388
298
 
389
- # ====================================================================
390
- # Configuration phase: Build session_config
391
- # ====================================================================
392
- session_config = dict(config)
393
-
394
- # Collect all available tool information
395
- available_mcp_tools = []
396
- if has_mcp and self._mcp_client:
397
- available_mcp_tools = list(self._mcp_client.tools.keys())
398
-
399
- available_custom_tool_names = list(self._custom_tool_names) if has_custom_tools else []
400
-
401
- # Apply tools to config
402
- tools_to_apply = []
403
- sessions_applied = False
404
- custom_tools_applied = False
405
-
406
- # Add MCP sessions (if available and not blocked by planning mode)
407
- if has_mcp:
408
- if not self.mcp_manager.should_block_mcp_tools_in_planning_mode(
409
- self.is_planning_mode_enabled(),
410
- available_mcp_tools,
411
- ):
412
- logger.debug(
413
- f"[Gemini] Passing {len(mcp_sessions)} MCP sessions to SDK: " f"{[type(s).__name__ for s in mcp_sessions]}",
414
- )
415
- tools_to_apply.extend(mcp_sessions)
416
- sessions_applied = True
417
-
418
- if self.is_planning_mode_enabled():
419
- blocked_tools = self.get_planning_mode_blocked_tools()
420
-
421
- if not blocked_tools:
422
- # Empty set means block ALL MCP tools (backward compatible)
423
- logger.info("[Gemini] Planning mode enabled - blocking ALL MCP tools during coordination")
424
- # Don't set tools at all - this prevents any MCP tool execution
425
- log_backend_activity(
426
- "gemini",
427
- "All MCP tools blocked in planning mode",
428
- {
429
- "blocked_tools": len(available_mcp_tools),
430
- "session_count": len(mcp_sessions),
431
- },
432
- agent_id=agent_id,
433
- )
434
- else:
435
- # Selective blocking - allow non-blocked tools to be called
436
- # The execution layer (_execute_mcp_function_with_retry) will enforce blocking
437
- # but we still register all tools so non-blocked ones can be used
438
- logger.info(f"[Gemini] Planning mode enabled - allowing non-blocked MCP tools, blocking {len(blocked_tools)} specific tools")
439
-
440
- # Pass all sessions - the backend's is_mcp_tool_blocked() will handle selective blocking
441
- session_config["tools"] = mcp_sessions
442
-
443
- log_backend_activity(
444
- "gemini",
445
- "Selective MCP tools blocked in planning mode",
446
- {
447
- "total_tools": len(available_mcp_tools),
448
- "blocked_tools": len(blocked_tools),
449
- "allowed_tools": len(available_mcp_tools) - len(blocked_tools),
450
- },
451
- agent_id=agent_id,
452
- )
453
-
454
- # Add custom tools (if available)
455
- if has_custom_tools:
456
- # Wrap FunctionDeclarations in a Tool object for Gemini SDK
457
- try:
458
- from google.genai import types
459
-
460
- # Create a Tool object containing all custom function declarations
299
+ if custom_tools_functions:
300
+ # Wrap FunctionDeclarations in a Tool object for Gemini SDK
461
301
  custom_tool = types.Tool(function_declarations=custom_tools_functions)
462
-
463
- logger.debug(
464
- f"[Gemini] Wrapped {len(custom_tools_functions)} custom tools " f"in Tool object for SDK",
465
- )
466
302
  tools_to_apply.append(custom_tool)
467
- custom_tools_applied = True
468
- except Exception as e:
469
- logger.error(f"[Gemini] Failed to wrap custom tools in Tool object: {e}")
470
- custom_tools_error = e
471
-
472
- # Apply tool configuration
473
- if tools_to_apply:
474
- session_config["tools"] = tools_to_apply
475
303
 
476
- # Disable automatic function calling for custom tools
477
- # MassGen uses declarative mode: SDK should return function call requests
478
- # instead of automatically executing them
479
- if has_custom_tools:
480
- from google.genai import types
304
+ logger.debug(f"[Gemini] Registered {len(custom_tools_functions)} custom tools for manual execution")
481
305
 
482
- session_config["automatic_function_calling"] = types.AutomaticFunctionCallingConfig(
483
- disable=True,
306
+ yield StreamChunk(
307
+ type="custom_tool_status",
308
+ status="custom_tools_registered",
309
+ content=f"🔧 [Custom Tools] Registered {len(custom_tools_functions)} tools",
310
+ source="custom_tools",
484
311
  )
485
- logger.debug("[Gemini] Disabled automatic function calling for custom tools")
486
-
487
- # ====================================================================
488
- # Logging and status output
489
- # ====================================================================
490
- if sessions_applied:
491
- # Track MCP tool usage attempt
492
- self._mcp_tool_calls_count += 1
493
-
494
- log_backend_activity(
495
- "gemini",
496
- "MCP tool call initiated",
497
- {
498
- "call_number": self._mcp_tool_calls_count,
499
- "session_count": len(mcp_sessions),
500
- "available_tools": available_mcp_tools[:],
501
- "total_tools": len(available_mcp_tools),
502
- },
503
- agent_id=agent_id,
504
- )
312
+ except Exception as e:
313
+ logger.warning(f"[Gemini] Failed to register custom tools: {e}")
505
314
 
506
- log_tool_call(
507
- agent_id,
508
- "mcp_session_tools",
509
- {
510
- "session_count": len(mcp_sessions),
511
- "call_number": self._mcp_tool_calls_count,
512
- "available_tools": available_mcp_tools,
513
- },
514
- backend_name="gemini",
515
- )
315
+ # Add MCP tools if available (unless blocked by planning mode)
316
+ if self._mcp_initialized and self._mcp_functions:
317
+ # Check planning mode
318
+ if self.is_planning_mode_enabled():
319
+ blocked_tools = self.get_planning_mode_blocked_tools()
516
320
 
517
- tools_info = f" ({len(available_mcp_tools)} tools available)" if available_mcp_tools else ""
321
+ if not blocked_tools:
322
+ # Empty set means block ALL MCP tools (backward compatible)
323
+ logger.info("[Gemini] Planning mode enabled - blocking ALL MCP tools during coordination")
518
324
  yield StreamChunk(
519
325
  type="mcp_status",
520
- status="mcp_tools_initiated",
521
- content=f"MCP tool call initiated (call #{self._mcp_tool_calls_count}){tools_info}: {', '.join(available_mcp_tools[:5])}{'...' if len(available_mcp_tools) > 5 else ''}",
522
- source="mcp_tools",
326
+ status="planning_mode_blocked",
327
+ content="🚫 [MCP] Planning mode active - all MCP tools blocked during coordination",
328
+ source="planning_mode",
523
329
  )
524
330
 
525
- if custom_tools_applied:
526
- # Track custom tool usage attempt
527
- log_backend_activity(
528
- "gemini",
529
- "Custom tools initiated",
530
- {
531
- "tool_count": len(custom_tools_functions),
532
- "available_tools": available_custom_tool_names,
533
- },
534
- agent_id=agent_id,
535
- )
331
+ else:
332
+ # Selective blocking - register all MCP tools, execution layer will block specific ones
333
+ logger.info(f"[Gemini] Planning mode enabled - registering all MCP tools, will block {len(blocked_tools)} at execution")
334
+ try:
335
+ # Convert MCP tools using formatter
336
+ mcp_tools_functions = self.formatter.format_mcp_tools(self._mcp_functions, return_sdk_objects=True)
536
337
 
537
- tools_preview = ", ".join(available_custom_tool_names[:5])
538
- tools_suffix = "..." if len(available_custom_tool_names) > 5 else ""
539
- yield StreamChunk(
540
- type="custom_tool_status",
541
- status="custom_tools_initiated",
542
- content=f"Custom tools initiated ({len(custom_tools_functions)} tools available): {tools_preview}{tools_suffix}",
543
- source="custom_tools",
544
- )
338
+ if mcp_tools_functions:
339
+ # Wrap in Tool object
340
+ mcp_tool = types.Tool(function_declarations=mcp_tools_functions)
341
+ tools_to_apply.append(mcp_tool)
545
342
 
546
- # ====================================================================
547
- # Streaming phase
548
- # ====================================================================
549
- # Use async streaming call with sessions/tools
550
- stream = await client.aio.models.generate_content_stream(
551
- model=model_name,
552
- contents=full_content,
553
- config=session_config,
554
- )
343
+ # Mark MCP as used since tools are registered (even with selective blocking)
344
+ mcp_used = True
555
345
 
556
- # Initialize trackers for both MCP and custom tools
557
- mcp_tracker = MCPCallTracker()
558
- mcp_response_tracker = MCPResponseTracker()
559
- custom_tracker = MCPCallTracker() # Reuse MCPCallTracker for custom tools
560
- custom_response_tracker = MCPResponseTracker() # Reuse for custom tools
346
+ logger.debug(f"[Gemini] Registered {len(mcp_tools_functions)} MCP tools for selective blocking")
561
347
 
562
- mcp_tools_used = [] # Keep for backward compatibility
563
- custom_tools_used = [] # Track custom tool usage
348
+ yield StreamChunk(
349
+ type="mcp_status",
350
+ status="mcp_tools_registered",
351
+ content=f"🔧 [MCP] Registered {len(mcp_tools_functions)} tools (selective blocking enabled)",
352
+ source="mcp_tools",
353
+ )
354
+ except Exception as e:
355
+ logger.warning(f"[Gemini] Failed to register MCP tools: {e}")
356
+ else:
357
+ # No planning mode - register all MCP tools
358
+ try:
359
+ # Convert MCP tools using formatter
360
+ mcp_tools_functions = self.formatter.format_mcp_tools(self._mcp_functions, return_sdk_objects=True)
361
+
362
+ if mcp_tools_functions:
363
+ # Wrap in Tool object
364
+ mcp_tool = types.Tool(function_declarations=mcp_tools_functions)
365
+ tools_to_apply.append(mcp_tool)
366
+
367
+ # Mark MCP as used since tools are registered
368
+ mcp_used = True
369
+
370
+ logger.debug(f"[Gemini] Registered {len(mcp_tools_functions)} MCP tools for manual execution")
371
+
372
+ yield StreamChunk(
373
+ type="mcp_status",
374
+ status="mcp_tools_registered",
375
+ content=f"🔧 [MCP] Registered {len(mcp_tools_functions)} tools",
376
+ source="mcp_tools",
377
+ )
378
+ except Exception as e:
379
+ logger.warning(f"[Gemini] Failed to register MCP tools: {e}")
380
+
381
+ # Apply tools to config
382
+ if tools_to_apply:
383
+ config["tools"] = tools_to_apply
384
+ # Disable automatic function calling for manual execution
385
+ config["automatic_function_calling"] = types.AutomaticFunctionCallingConfig(disable=True)
386
+ logger.debug("[Gemini] Disabled automatic function calling for manual execution")
387
+ else:
388
+ # No custom/MCP tools, add builtin tools if any
389
+ if builtin_tools:
390
+ config["tools"] = builtin_tools
564
391
 
565
- # Iterate over the asynchronous stream to get chunks as they arrive
566
- async for chunk in stream:
567
- # ============================================
568
- # 1. Process function calls/responses
569
- # ============================================
392
+ # For coordination/post-evaluation requests, use JSON response format when no tools present
393
+ if not tools_to_apply and not builtin_tools:
394
+ if is_coordination:
395
+ config["response_mime_type"] = "application/json"
396
+ config["response_schema"] = CoordinationResponse.model_json_schema()
397
+ elif is_post_evaluation:
398
+ config["response_mime_type"] = "application/json"
399
+ config["response_schema"] = PostEvaluationResponse.model_json_schema()
570
400
 
571
- # First check for function calls in the current chunk's candidates
572
- # (this is where custom tool calls appear, not in automatic_function_calling_history)
573
- if hasattr(chunk, "candidates") and chunk.candidates:
574
- for candidate in chunk.candidates:
575
- if hasattr(candidate, "content") and candidate.content:
576
- if hasattr(candidate.content, "parts") and candidate.content.parts:
577
- for part in candidate.content.parts:
578
- # Check for function_call part
579
- if hasattr(part, "function_call") and part.function_call:
580
- # Extract call data
581
- call_data = self.mcp_extractor.extract_function_call(part.function_call)
582
-
583
- if call_data:
584
- tool_name = call_data["name"]
585
- tool_args = call_data["arguments"]
586
-
587
- # DEBUG: Log tool matching
588
- logger.info(f"🔍 [DEBUG] Function call detected: tool_name='{tool_name}'")
589
- logger.info(f"🔍 [DEBUG] Available MCP tools: {available_mcp_tools}")
590
- logger.info(f"🔍 [DEBUG] Available custom tools: {list(self._custom_tool_names) if has_custom_tools else []}")
591
-
592
- # Determine if it's MCP tool or custom tool
593
- # MCP tools may come from SDK without prefix, so we need to check both:
594
- # 1. Direct match (tool_name in list)
595
- # 2. Prefixed match (mcp__server__tool_name in list)
596
- is_mcp_tool = False
597
- if has_mcp:
598
- # Direct match
599
- if tool_name in available_mcp_tools:
600
- is_mcp_tool = True
601
- else:
602
- # Try matching with MCP prefix format: mcp__<server>__<tool>
603
- # Check if any available MCP tool ends with the current tool_name
604
- for mcp_tool in available_mcp_tools:
605
- # Format: mcp__server__toolname
606
- if mcp_tool.startswith("mcp__") and mcp_tool.endswith(f"__{tool_name}"):
607
- is_mcp_tool = True
608
- logger.info(f"🔍 [DEBUG] Matched MCP tool: {tool_name} -> {mcp_tool}")
609
- break
610
-
611
- is_custom_tool = has_custom_tools and tool_name in self._custom_tool_names
612
-
613
- logger.info(f"🔍 [DEBUG] Tool matching result: is_mcp_tool={is_mcp_tool}, is_custom_tool={is_custom_tool}")
614
-
615
- if is_custom_tool:
616
- # Process custom tool call
617
- if custom_tracker.is_new_call(tool_name, tool_args):
618
- call_record = custom_tracker.add_call(tool_name, tool_args)
619
-
620
- custom_tools_used.append(
621
- {
622
- "name": tool_name,
623
- "arguments": tool_args,
624
- "timestamp": call_record["timestamp"],
625
- },
626
- )
627
-
628
- timestamp_str = time.strftime(
629
- "%H:%M:%S",
630
- time.localtime(call_record["timestamp"]),
631
- )
632
-
633
- yield StreamChunk(
634
- type="custom_tool_status",
635
- status="custom_tool_called",
636
- content=f"🔧 Custom Tool Called: {tool_name} at {timestamp_str} with args: {json.dumps(tool_args, indent=2)}",
637
- source="custom_tools",
638
- )
639
-
640
- log_tool_call(
641
- agent_id,
642
- tool_name,
643
- tool_args,
644
- backend_name="gemini",
645
- )
646
- elif is_mcp_tool:
647
- # Process MCP tool call
648
- if mcp_tracker.is_new_call(tool_name, tool_args):
649
- call_record = mcp_tracker.add_call(tool_name, tool_args)
650
-
651
- mcp_tools_used.append(
652
- {
653
- "name": tool_name,
654
- "arguments": tool_args,
655
- "timestamp": call_record["timestamp"],
656
- },
657
- )
658
-
659
- timestamp_str = time.strftime(
660
- "%H:%M:%S",
661
- time.localtime(call_record["timestamp"]),
662
- )
663
-
664
- yield StreamChunk(
665
- type="mcp_status",
666
- status="mcp_tool_called",
667
- content=f"🔧 MCP Tool Called: {tool_name} at {timestamp_str} with args: {json.dumps(tool_args, indent=2)}",
668
- source="mcp_tools",
669
- )
670
-
671
- log_tool_call(
672
- agent_id,
673
- tool_name,
674
- tool_args,
675
- backend_name="gemini",
676
- )
677
-
678
- # Then check automatic_function_calling_history (for MCP tools that were auto-executed)
679
- if hasattr(chunk, "automatic_function_calling_history") and chunk.automatic_function_calling_history:
680
- for history_item in chunk.automatic_function_calling_history:
681
- if hasattr(history_item, "parts") and history_item.parts is not None:
682
- for part in history_item.parts:
683
- # Check for function_call part
684
- if hasattr(part, "function_call") and part.function_call:
685
- # Use MCPResponseExtractor to extract call data
686
- call_data = self.mcp_extractor.extract_function_call(part.function_call)
687
-
688
- if call_data:
689
- tool_name = call_data["name"]
690
- tool_args = call_data["arguments"]
691
-
692
- # DEBUG: Log tool matching (from automatic_function_calling_history)
693
- logger.info(f"🔍 [DEBUG-AUTO] Function call in history: tool_name='{tool_name}'")
694
- logger.info(f"🔍 [DEBUG-AUTO] Available MCP tools: {available_mcp_tools}")
695
- logger.info(f"🔍 [DEBUG-AUTO] Available custom tools: {list(self._custom_tool_names) if has_custom_tools else []}")
696
-
697
- # Determine if it's MCP tool or custom tool
698
- # MCP tools may come from SDK without prefix, so we need to check both
699
- is_mcp_tool = False
700
- if has_mcp:
701
- if tool_name in available_mcp_tools:
702
- is_mcp_tool = True
703
- else:
704
- # Try matching with MCP prefix format
705
- for mcp_tool in available_mcp_tools:
706
- if mcp_tool.startswith("mcp__") and mcp_tool.endswith(f"__{tool_name}"):
707
- is_mcp_tool = True
708
- logger.info(f"🔍 [DEBUG-AUTO] Matched MCP tool: {tool_name} -> {mcp_tool}")
709
- break
710
-
711
- is_custom_tool = has_custom_tools and tool_name in self._custom_tool_names
712
-
713
- logger.info(f"🔍 [DEBUG-AUTO] Tool matching result: is_mcp_tool={is_mcp_tool}, is_custom_tool={is_custom_tool}")
714
-
715
- if is_mcp_tool:
716
- # Process MCP tool call
717
- if mcp_tracker.is_new_call(tool_name, tool_args):
718
- call_record = mcp_tracker.add_call(tool_name, tool_args)
719
-
720
- mcp_tools_used.append(
721
- {
722
- "name": tool_name,
723
- "arguments": tool_args,
724
- "timestamp": call_record["timestamp"],
725
- },
726
- )
727
-
728
- timestamp_str = time.strftime(
729
- "%H:%M:%S",
730
- time.localtime(call_record["timestamp"]),
731
- )
732
-
733
- yield StreamChunk(
734
- type="mcp_status",
735
- status="mcp_tool_called",
736
- content=f"🔧 MCP Tool Called: {tool_name} at {timestamp_str} with args: {json.dumps(tool_args, indent=2)}",
737
- source="mcp_tools",
738
- )
739
-
740
- log_tool_call(
741
- agent_id,
742
- tool_name,
743
- tool_args,
744
- backend_name="gemini",
745
- )
746
-
747
- elif is_custom_tool:
748
- # Process custom tool call
749
- if custom_tracker.is_new_call(tool_name, tool_args):
750
- call_record = custom_tracker.add_call(tool_name, tool_args)
751
-
752
- custom_tools_used.append(
753
- {
754
- "name": tool_name,
755
- "arguments": tool_args,
756
- "timestamp": call_record["timestamp"],
757
- },
758
- )
759
-
760
- timestamp_str = time.strftime(
761
- "%H:%M:%S",
762
- time.localtime(call_record["timestamp"]),
763
- )
764
-
765
- yield StreamChunk(
766
- type="custom_tool_status",
767
- status="custom_tool_called",
768
- content=f"🔧 Custom Tool Called: {tool_name} at {timestamp_str} with args: {json.dumps(tool_args, indent=2)}",
769
- source="custom_tools",
770
- )
771
-
772
- log_tool_call(
773
- agent_id,
774
- tool_name,
775
- tool_args,
776
- backend_name="gemini",
777
- )
778
-
779
- # Check for function_response part
780
- elif hasattr(part, "function_response") and part.function_response:
781
- response_data = self.mcp_extractor.extract_function_response(part.function_response)
782
-
783
- if response_data:
784
- tool_name = response_data["name"]
785
- tool_response = response_data["response"]
786
-
787
- # Determine if it's MCP tool or custom tool
788
- # MCP tools may come from SDK without prefix
789
- is_mcp_tool = False
790
- if has_mcp:
791
- if tool_name in available_mcp_tools:
792
- is_mcp_tool = True
793
- else:
794
- # Try matching with MCP prefix format
795
- for mcp_tool in available_mcp_tools:
796
- if mcp_tool.startswith("mcp__") and mcp_tool.endswith(f"__{tool_name}"):
797
- is_mcp_tool = True
798
- break
799
-
800
- is_custom_tool = has_custom_tools and tool_name in self._custom_tool_names
801
-
802
- if is_mcp_tool:
803
- # Process MCP tool response
804
- if mcp_response_tracker.is_new_response(tool_name, tool_response):
805
- response_record = mcp_response_tracker.add_response(tool_name, tool_response)
806
-
807
- # Extract text content from CallToolResult
808
- response_text = None
809
- if isinstance(tool_response, dict) and "result" in tool_response:
810
- result = tool_response["result"]
811
- if hasattr(result, "content") and result.content:
812
- first_content = result.content[0]
813
- if hasattr(first_content, "text"):
814
- response_text = first_content.text
815
-
816
- if response_text is None:
817
- response_text = str(tool_response)
818
-
819
- timestamp_str = time.strftime(
820
- "%H:%M:%S",
821
- time.localtime(response_record["timestamp"]),
822
- )
823
-
824
- # Format response as JSON if possible
825
- formatted_response = format_tool_response_as_json(response_text)
826
-
827
- yield StreamChunk(
828
- type="mcp_status",
829
- status="mcp_tool_response",
830
- content=f"✅ MCP Tool Response from {tool_name} at {timestamp_str}: {formatted_response}",
831
- source="mcp_tools",
832
- )
833
-
834
- log_backend_activity(
835
- "gemini",
836
- "MCP tool response received",
837
- {
838
- "tool_name": tool_name,
839
- "response_preview": str(tool_response)[:],
840
- },
841
- agent_id=agent_id,
842
- )
843
-
844
- elif is_custom_tool:
845
- # Process custom tool response
846
- if custom_response_tracker.is_new_response(tool_name, tool_response):
847
- response_record = custom_response_tracker.add_response(tool_name, tool_response)
848
-
849
- # Extract text from response
850
- response_text = str(tool_response)
851
-
852
- timestamp_str = time.strftime(
853
- "%H:%M:%S",
854
- time.localtime(response_record["timestamp"]),
855
- )
856
-
857
- # Format response as JSON if possible
858
- formatted_response = format_tool_response_as_json(response_text)
859
-
860
- yield StreamChunk(
861
- type="custom_tool_status",
862
- status="custom_tool_response",
863
- content=f"✅ Custom Tool Response from {tool_name} at {timestamp_str}: {formatted_response}",
864
- source="custom_tools",
865
- )
866
-
867
- log_backend_activity(
868
- "gemini",
869
- "Custom tool response received",
870
- {
871
- "tool_name": tool_name,
872
- "response_preview": str(tool_response),
873
- },
874
- agent_id=agent_id,
875
- )
876
-
877
- # ============================================
878
- # 2. Process text content
879
- # ============================================
880
- if hasattr(chunk, "text") and chunk.text:
881
- chunk_text = chunk.text
882
- full_content_text += chunk_text
883
- log_backend_agent_message(
884
- agent_id,
885
- "RECV",
886
- {"content": chunk_text},
887
- backend_name="gemini",
888
- )
889
- log_stream_chunk("backend.gemini", "content", chunk_text, agent_id)
890
- yield StreamChunk(type="content", content=chunk_text)
401
+ # Log messages being sent
402
+ log_backend_agent_message(
403
+ agent_id or "default",
404
+ "SEND",
405
+ {
406
+ "content": full_content,
407
+ "custom_tools": len(tools_to_apply) if tools_to_apply else 0,
408
+ },
409
+ backend_name="gemini",
410
+ )
891
411
 
892
- # ============================================
893
- # 3. Buffer last chunk with candidates
894
- # ============================================
895
- if hasattr(chunk, "candidates") and chunk.candidates:
896
- last_response_with_candidates = chunk
897
-
898
- # Reset stream tracking
899
- if hasattr(self, "_mcp_stream_started"):
900
- delattr(self, "_mcp_stream_started")
901
-
902
- # ====================================================================
903
- # Tool execution loop: Execute tools until model stops calling them
904
- # ====================================================================
905
- # Note: When automatic_function_calling is disabled, BOTH custom and MCP tools
906
- # need to be manually executed. The model may make multiple rounds of tool calls
907
- # (e.g., call custom tool first, then MCP tool after seeing the result).
908
-
909
- executed_tool_calls = set() # Track which tools we've already executed
910
- max_tool_rounds = 10 # Prevent infinite loops
911
- tool_round = 0
912
-
913
- while tool_round < max_tool_rounds:
914
- # Find new tool calls that haven't been executed yet
915
- new_custom_tools = []
916
- new_mcp_tools = []
917
-
918
- for tool_call in custom_tools_used:
919
- call_signature = f"custom_{tool_call['name']}_{json.dumps(tool_call['arguments'], sort_keys=True)}"
920
- if call_signature not in executed_tool_calls:
921
- new_custom_tools.append(tool_call)
922
- executed_tool_calls.add(call_signature)
923
-
924
- for tool_call in mcp_tools_used:
925
- call_signature = f"mcp_{tool_call['name']}_{json.dumps(tool_call['arguments'], sort_keys=True)}"
926
- if call_signature not in executed_tool_calls:
927
- new_mcp_tools.append(tool_call)
928
- executed_tool_calls.add(call_signature)
929
-
930
- # If no new tools to execute, break the loop
931
- if not new_custom_tools and not new_mcp_tools:
932
- break
412
+ # ====================================================================
413
+ # Streaming Phase: Stream with simple function call detection
414
+ # ====================================================================
415
+ stream = await client.aio.models.generate_content_stream(
416
+ model=model_name,
417
+ contents=full_content,
418
+ config=config,
419
+ )
933
420
 
934
- tool_round += 1
935
- logger.debug(f"[Gemini] Tool execution round {tool_round}: {len(new_custom_tools)} custom, {len(new_mcp_tools)} MCP")
421
+ # Simple list accumulation for function calls (no trackers)
422
+ captured_function_calls = []
423
+ full_content_text = ""
424
+ last_response_with_candidates = None
936
425
 
937
- # Execute tools and collect results for this round
938
- tool_responses = []
426
+ # Stream chunks and capture function calls
427
+ async for chunk in stream:
428
+ # Detect function calls in candidates
429
+ if hasattr(chunk, "candidates") and chunk.candidates:
430
+ for candidate in chunk.candidates:
431
+ if hasattr(candidate, "content") and candidate.content:
432
+ if hasattr(candidate.content, "parts") and candidate.content.parts:
433
+ for part in candidate.content.parts:
434
+ # Check for function_call part
435
+ if hasattr(part, "function_call") and part.function_call:
436
+ # Extract call data
437
+ tool_name = part.function_call.name
438
+ tool_args = dict(part.function_call.args) if part.function_call.args else {}
439
+
440
+ # Create call record
441
+ call_id = f"call_{len(captured_function_calls)}"
442
+ captured_function_calls.append(
443
+ {
444
+ "call_id": call_id,
445
+ "name": tool_name,
446
+ "arguments": json.dumps(tool_args),
447
+ },
448
+ )
449
+
450
+ logger.info(f"[Gemini] Function call detected: {tool_name}")
451
+
452
+ # Process text content
453
+ if hasattr(chunk, "text") and chunk.text:
454
+ chunk_text = chunk.text
455
+ full_content_text += chunk_text
456
+ log_backend_agent_message(
457
+ agent_id,
458
+ "RECV",
459
+ {"content": chunk_text},
460
+ backend_name="gemini",
461
+ )
462
+ log_stream_chunk("backend.gemini", "content", chunk_text, agent_id)
463
+ yield StreamChunk(type="content", content=chunk_text)
939
464
 
940
- # Execute custom tools
941
- for tool_call in new_custom_tools:
942
- tool_name = tool_call["name"]
943
- tool_args = tool_call["arguments"]
465
+ # Buffer last chunk with candidates
466
+ if hasattr(chunk, "candidates") and chunk.candidates:
467
+ last_response_with_candidates = chunk
944
468
 
945
- try:
946
- # Execute the custom tool
947
- result_str = await self._execute_custom_tool(
948
- {
949
- "name": tool_name,
950
- "arguments": json.dumps(tool_args) if isinstance(tool_args, dict) else tool_args,
951
- },
952
- )
469
+ # ====================================================================
470
+ # Structured Coordination Output Parsing
471
+ # ====================================================================
472
+ # Check for structured coordination output when no function calls captured
473
+ if is_coordination and not captured_function_calls and full_content_text:
474
+ # Try to parse structured response from text content
475
+ parsed = self.formatter.extract_structured_response(full_content_text)
953
476
 
954
- # Format result as JSON if possible
955
- formatted_result = format_tool_response_as_json(result_str)
477
+ if parsed and isinstance(parsed, dict):
478
+ # Convert structured response to tool calls
479
+ tool_calls = self.formatter.convert_structured_to_tool_calls(parsed)
956
480
 
957
- # Yield execution status
958
- yield StreamChunk(
959
- type="custom_tool_status",
960
- status="custom_tool_executed",
961
- content=f"✅ Custom Tool Executed: {tool_name} -> {formatted_result}",
962
- source="custom_tools",
963
- )
481
+ if tool_calls:
482
+ # Categorize the tool calls
483
+ mcp_calls, custom_calls, provider_calls = self._categorize_tool_calls(tool_calls)
484
+
485
+ # Handle provider (workflow) calls - these are coordination actions
486
+ # We yield StreamChunk entries but do NOT execute them
487
+ if provider_calls:
488
+ # Convert provider calls to tool_calls format for orchestrator
489
+ workflow_tool_calls = []
490
+ for call in provider_calls:
491
+ tool_name = call.get("name", "")
492
+ tool_args_str = call.get("arguments", "{}")
493
+
494
+ # Parse arguments if they're a string
495
+ if isinstance(tool_args_str, str):
496
+ try:
497
+ tool_args = json.loads(tool_args_str)
498
+ except json.JSONDecodeError:
499
+ tool_args = {}
500
+ else:
501
+ tool_args = tool_args_str
964
502
 
965
- # Build function response in Gemini format
966
- tool_responses.append(
967
- {
968
- "name": tool_name,
969
- "response": {"result": result_str},
970
- },
503
+ # Log the coordination action
504
+ logger.info(f"[Gemini] Structured coordination action: {tool_name}")
505
+ log_tool_call(
506
+ agent_id,
507
+ tool_name,
508
+ tool_args,
509
+ None,
510
+ backend_name="gemini",
971
511
  )
972
512
 
973
- except Exception as e:
974
- error_msg = f"Error executing custom tool {tool_name}: {str(e)}"
975
- logger.error(error_msg)
976
- yield StreamChunk(
977
- type="custom_tool_status",
978
- status="custom_tool_error",
979
- content=f"❌ {error_msg}",
980
- source="custom_tools",
981
- )
982
- # Add error response
983
- tool_responses.append(
513
+ # Build tool call in standard format
514
+ workflow_tool_calls.append(
984
515
  {
985
- "name": tool_name,
986
- "response": {"error": str(e)},
516
+ "id": call.get("call_id", f"call_{len(workflow_tool_calls)}"),
517
+ "type": "function",
518
+ "function": {
519
+ "name": tool_name,
520
+ "arguments": tool_args,
521
+ },
987
522
  },
988
523
  )
989
524
 
990
- # Execute MCP tools manually (since automatic_function_calling is disabled)
991
- for tool_call in new_mcp_tools:
992
- tool_name = tool_call["name"]
993
- tool_args = tool_call["arguments"]
994
-
995
- try:
996
- # Execute the MCP tool via MCP client
997
- if not self._mcp_client:
998
- raise RuntimeError("MCP client not initialized")
999
-
1000
- # Convert tool name to prefixed format if needed
1001
- # MCP client expects: mcp__server__toolname
1002
- # Gemini SDK returns: toolname (without prefix)
1003
- prefixed_tool_name = tool_name
1004
- if not tool_name.startswith("mcp__"):
1005
- # Find the matching prefixed tool name
1006
- for mcp_tool in available_mcp_tools:
1007
- if mcp_tool.startswith("mcp__") and mcp_tool.endswith(f"__{tool_name}"):
1008
- prefixed_tool_name = mcp_tool
1009
- logger.info(f"🔧 [DEBUG] Converting tool name for execution: {tool_name} -> {prefixed_tool_name}")
1010
- break
1011
-
1012
- mcp_result = await self._mcp_client.call_tool(prefixed_tool_name, tool_args)
1013
-
1014
- # Extract text from CallToolResult object
1015
- result_str = None
1016
- if mcp_result:
1017
- if hasattr(mcp_result, "content") and mcp_result.content:
1018
- first_content = mcp_result.content[0]
1019
- if hasattr(first_content, "text"):
1020
- result_str = first_content.text
1021
-
1022
- if result_str is None:
1023
- result_str = str(mcp_result) if mcp_result else "None"
1024
-
1025
- # Format result as JSON if possible
1026
- formatted_result = format_tool_response_as_json(result_str)
1027
- result_preview = formatted_result
1028
-
1029
- # Yield execution status
525
+ # Emit tool_calls chunk for orchestrator to process
526
+ if workflow_tool_calls:
527
+ log_stream_chunk("backend.gemini", "tool_calls", workflow_tool_calls, agent_id)
1030
528
  yield StreamChunk(
1031
- type="mcp_status",
1032
- status="mcp_tool_executed",
1033
- content=f"✅ MCP Tool Executed: {tool_name} -> {result_preview}{'...' if len(formatted_result) > 200 else ''}",
1034
- source="mcp_tools",
529
+ type="tool_calls",
530
+ tool_calls=workflow_tool_calls,
531
+ source="gemini",
1035
532
  )
1036
533
 
1037
- # Build function response in Gemini format
1038
- tool_responses.append(
1039
- {
1040
- "name": tool_name,
1041
- "response": {"result": mcp_result},
1042
- },
1043
- )
1044
-
1045
- except Exception as e:
1046
- error_msg = f"Error executing MCP tool {tool_name}: {str(e)}"
1047
- logger.error(error_msg)
534
+ # Do not execute workflow tools - just return after yielding
535
+ # The orchestrator will handle these coordination actions
536
+ if provider_calls:
537
+ # Emit completion status if MCP was actually used
538
+ if mcp_used:
1048
539
  yield StreamChunk(
1049
540
  type="mcp_status",
1050
- status="mcp_tool_error",
1051
- content=f" {error_msg}",
541
+ status="mcp_session_complete",
542
+ content=" [MCP] Session completed",
1052
543
  source="mcp_tools",
1053
544
  )
1054
- # Add error response
1055
- tool_responses.append(
1056
- {
1057
- "name": tool_name,
1058
- "response": {"error": str(e)},
1059
- },
1060
- )
1061
545
 
1062
- # Make continuation call with tool results from this round
1063
- if tool_responses:
546
+ yield StreamChunk(type="done")
547
+ return
548
+
549
+ # ====================================================================
550
+ # Tool Execution Phase: Execute captured function calls using base class
551
+ # ====================================================================
552
+ if captured_function_calls:
553
+ # Categorize function calls using base class helper
554
+ mcp_calls, custom_calls, provider_calls = self._categorize_tool_calls(captured_function_calls)
555
+
556
+ # ====================================================================
557
+ # Handle provider (workflow) calls - emit as StreamChunks but do NOT execute
558
+ # ====================================================================
559
+ if provider_calls:
560
+ # Convert provider calls to tool_calls format for orchestrator
561
+ workflow_tool_calls = []
562
+ for call in provider_calls:
563
+ tool_name = call.get("name", "")
564
+ tool_args_str = call.get("arguments", "{}")
565
+
566
+ # Parse arguments if they're a string
567
+ if isinstance(tool_args_str, str):
1064
568
  try:
1065
- from google.genai import types
1066
-
1067
- # Build conversation history for continuation
1068
- # Track all function calls from this round
1069
- round_function_calls = new_custom_tools + new_mcp_tools
1070
-
1071
- # Build conversation history
1072
- conversation_history = []
1073
-
1074
- # Add original user content
1075
- conversation_history.append(
1076
- types.Content(
1077
- parts=[types.Part(text=full_content)],
1078
- role="user",
1079
- ),
1080
- )
1081
-
1082
- # Add model's function call response (tools from THIS round)
1083
- model_parts = []
1084
- for tool_call in round_function_calls:
1085
- model_parts.append(
1086
- types.Part.from_function_call(
1087
- name=tool_call["name"],
1088
- args=tool_call["arguments"],
1089
- ),
1090
- )
1091
-
1092
- conversation_history.append(
1093
- types.Content(
1094
- parts=model_parts,
1095
- role="model",
1096
- ),
1097
- )
1098
-
1099
- # Add function response (as user message with function_response parts)
1100
- response_parts = []
1101
- for resp in tool_responses:
1102
- response_parts.append(
1103
- types.Part.from_function_response(
1104
- name=resp["name"],
1105
- response=resp["response"],
1106
- ),
1107
- )
1108
-
1109
- conversation_history.append(
1110
- types.Content(
1111
- parts=response_parts,
1112
- role="user",
1113
- ),
1114
- )
1115
-
1116
- # Make continuation call
1117
- yield StreamChunk(
1118
- type="custom_tool_status",
1119
- status="continuation_call",
1120
- content=f"🔄 Making continuation call with {len(tool_responses)} tool results...",
1121
- source="custom_tools",
1122
- )
1123
-
1124
- # Use same session_config as before
1125
- continuation_stream = await client.aio.models.generate_content_stream(
1126
- model=model_name,
1127
- contents=conversation_history,
1128
- config=session_config,
1129
- )
569
+ tool_args = json.loads(tool_args_str)
570
+ except json.JSONDecodeError:
571
+ tool_args = {}
572
+ else:
573
+ tool_args = tool_args_str
1130
574
 
1131
- # Process continuation stream (same processing as main stream)
1132
- async for chunk in continuation_stream:
1133
- # ============================================
1134
- # Process function calls/responses in continuation
1135
- # ============================================
1136
- # Check for function calls in current chunk's candidates
1137
- if hasattr(chunk, "candidates") and chunk.candidates:
1138
- for candidate in chunk.candidates:
1139
- if hasattr(candidate, "content") and candidate.content:
1140
- if hasattr(candidate.content, "parts") and candidate.content.parts:
1141
- for part in candidate.content.parts:
1142
- # Check for function_call part
1143
- if hasattr(part, "function_call") and part.function_call:
1144
- call_data = self.mcp_extractor.extract_function_call(part.function_call)
1145
-
1146
- if call_data:
1147
- tool_name = call_data["name"]
1148
- tool_args = call_data["arguments"]
1149
-
1150
- # Determine if it's MCP tool or custom tool
1151
- # MCP tools may come from SDK without prefix
1152
- is_mcp_tool = False
1153
- if has_mcp:
1154
- if tool_name in available_mcp_tools:
1155
- is_mcp_tool = True
1156
- else:
1157
- # Try matching with MCP prefix format
1158
- for mcp_tool in available_mcp_tools:
1159
- if mcp_tool.startswith("mcp__") and mcp_tool.endswith(f"__{tool_name}"):
1160
- is_mcp_tool = True
1161
- break
1162
-
1163
- is_custom_tool = has_custom_tools and tool_name in self._custom_tool_names
1164
-
1165
- if is_custom_tool:
1166
- # Process custom tool call
1167
- if custom_tracker.is_new_call(tool_name, tool_args):
1168
- call_record = custom_tracker.add_call(tool_name, tool_args)
1169
-
1170
- custom_tools_used.append(
1171
- {
1172
- "name": tool_name,
1173
- "arguments": tool_args,
1174
- "timestamp": call_record["timestamp"],
1175
- },
1176
- )
1177
-
1178
- timestamp_str = time.strftime(
1179
- "%H:%M:%S",
1180
- time.localtime(call_record["timestamp"]),
1181
- )
1182
-
1183
- yield StreamChunk(
1184
- type="custom_tool_status",
1185
- status="custom_tool_called",
1186
- content=f"🔧 Custom Tool Called: {tool_name} at {timestamp_str} with args: {json.dumps(tool_args, indent=2)}",
1187
- source="custom_tools",
1188
- )
1189
-
1190
- log_tool_call(
1191
- agent_id,
1192
- tool_name,
1193
- tool_args,
1194
- backend_name="gemini",
1195
- )
1196
- elif is_mcp_tool:
1197
- # Process MCP tool call
1198
- if mcp_tracker.is_new_call(tool_name, tool_args):
1199
- call_record = mcp_tracker.add_call(tool_name, tool_args)
1200
-
1201
- mcp_tools_used.append(
1202
- {
1203
- "name": tool_name,
1204
- "arguments": tool_args,
1205
- "timestamp": call_record["timestamp"],
1206
- },
1207
- )
1208
-
1209
- timestamp_str = time.strftime(
1210
- "%H:%M:%S",
1211
- time.localtime(call_record["timestamp"]),
1212
- )
1213
-
1214
- yield StreamChunk(
1215
- type="mcp_status",
1216
- status="mcp_tool_called",
1217
- content=f"🔧 MCP Tool Called: {tool_name} at {timestamp_str} with args: {json.dumps(tool_args, indent=2)}",
1218
- source="mcp_tools",
1219
- )
1220
-
1221
- log_tool_call(
1222
- agent_id,
1223
- tool_name,
1224
- tool_args,
1225
- backend_name="gemini",
1226
- )
1227
-
1228
- # Check automatic_function_calling_history (for auto-executed MCP tools)
1229
- if hasattr(chunk, "automatic_function_calling_history") and chunk.automatic_function_calling_history:
1230
- for history_item in chunk.automatic_function_calling_history:
1231
- if hasattr(history_item, "parts") and history_item.parts is not None:
1232
- for part in history_item.parts:
1233
- # Check for function_call part
1234
- if hasattr(part, "function_call") and part.function_call:
1235
- call_data = self.mcp_extractor.extract_function_call(part.function_call)
1236
-
1237
- if call_data:
1238
- tool_name = call_data["name"]
1239
- tool_args = call_data["arguments"]
1240
-
1241
- # Determine if it's MCP tool or custom tool
1242
- # MCP tools may come from SDK without prefix
1243
- is_mcp_tool = False
1244
- if has_mcp:
1245
- if tool_name in available_mcp_tools:
1246
- is_mcp_tool = True
1247
- else:
1248
- # Try matching with MCP prefix format
1249
- for mcp_tool in available_mcp_tools:
1250
- if mcp_tool.startswith("mcp__") and mcp_tool.endswith(f"__{tool_name}"):
1251
- is_mcp_tool = True
1252
- break
1253
-
1254
- is_custom_tool = has_custom_tools and tool_name in self._custom_tool_names
1255
-
1256
- if is_mcp_tool:
1257
- # Process MCP tool call
1258
- if mcp_tracker.is_new_call(tool_name, tool_args):
1259
- call_record = mcp_tracker.add_call(tool_name, tool_args)
1260
-
1261
- mcp_tools_used.append(
1262
- {
1263
- "name": tool_name,
1264
- "arguments": tool_args,
1265
- "timestamp": call_record["timestamp"],
1266
- },
1267
- )
1268
-
1269
- timestamp_str = time.strftime(
1270
- "%H:%M:%S",
1271
- time.localtime(call_record["timestamp"]),
1272
- )
1273
-
1274
- yield StreamChunk(
1275
- type="mcp_status",
1276
- status="mcp_tool_called",
1277
- content=f"🔧 MCP Tool Called: {tool_name} at {timestamp_str} with args: {json.dumps(tool_args, indent=2)}",
1278
- source="mcp_tools",
1279
- )
1280
-
1281
- log_tool_call(
1282
- agent_id,
1283
- tool_name,
1284
- tool_args,
1285
- backend_name="gemini",
1286
- )
1287
-
1288
- elif is_custom_tool:
1289
- # Process custom tool call
1290
- if custom_tracker.is_new_call(tool_name, tool_args):
1291
- call_record = custom_tracker.add_call(tool_name, tool_args)
1292
-
1293
- custom_tools_used.append(
1294
- {
1295
- "name": tool_name,
1296
- "arguments": tool_args,
1297
- "timestamp": call_record["timestamp"],
1298
- },
1299
- )
1300
-
1301
- timestamp_str = time.strftime(
1302
- "%H:%M:%S",
1303
- time.localtime(call_record["timestamp"]),
1304
- )
1305
-
1306
- yield StreamChunk(
1307
- type="custom_tool_status",
1308
- status="custom_tool_called",
1309
- content=f"🔧 Custom Tool Called: {tool_name} at {timestamp_str} with args: {json.dumps(tool_args, indent=2)}",
1310
- source="custom_tools",
1311
- )
1312
-
1313
- log_tool_call(
1314
- agent_id,
1315
- tool_name,
1316
- tool_args,
1317
- backend_name="gemini",
1318
- )
1319
-
1320
- # Check for function_response part
1321
- elif hasattr(part, "function_response") and part.function_response:
1322
- response_data = self.mcp_extractor.extract_function_response(part.function_response)
1323
-
1324
- if response_data:
1325
- tool_name = response_data["name"]
1326
- tool_response = response_data["response"]
1327
-
1328
- # Determine if it's MCP tool or custom tool
1329
- # MCP tools may come from SDK without prefix
1330
- is_mcp_tool = False
1331
- if has_mcp:
1332
- if tool_name in available_mcp_tools:
1333
- is_mcp_tool = True
1334
- else:
1335
- # Try matching with MCP prefix format
1336
- for mcp_tool in available_mcp_tools:
1337
- if mcp_tool.startswith("mcp__") and mcp_tool.endswith(f"__{tool_name}"):
1338
- is_mcp_tool = True
1339
- break
1340
-
1341
- is_custom_tool = has_custom_tools and tool_name in self._custom_tool_names
1342
-
1343
- if is_mcp_tool:
1344
- # Process MCP tool response
1345
- if mcp_response_tracker.is_new_response(tool_name, tool_response):
1346
- response_record = mcp_response_tracker.add_response(tool_name, tool_response)
1347
-
1348
- # Extract text content from CallToolResult
1349
- response_text = None
1350
- if isinstance(tool_response, dict) and "result" in tool_response:
1351
- result = tool_response["result"]
1352
- if hasattr(result, "content") and result.content:
1353
- first_content = result.content[0]
1354
- if hasattr(first_content, "text"):
1355
- response_text = first_content.text
1356
-
1357
- if response_text is None:
1358
- response_text = str(tool_response)
1359
-
1360
- timestamp_str = time.strftime(
1361
- "%H:%M:%S",
1362
- time.localtime(response_record["timestamp"]),
1363
- )
1364
-
1365
- # Format response as JSON if possible
1366
- formatted_response = format_tool_response_as_json(response_text)
1367
-
1368
- yield StreamChunk(
1369
- type="mcp_status",
1370
- status="mcp_tool_response",
1371
- content=f"✅ MCP Tool Response from {tool_name} at {timestamp_str}: {formatted_response}",
1372
- source="mcp_tools",
1373
- )
1374
-
1375
- log_backend_activity(
1376
- "gemini",
1377
- "MCP tool response received",
1378
- {
1379
- "tool_name": tool_name,
1380
- "response_preview": str(tool_response)[:],
1381
- },
1382
- agent_id=agent_id,
1383
- )
1384
-
1385
- elif is_custom_tool:
1386
- # Process custom tool response
1387
- if custom_response_tracker.is_new_response(tool_name, tool_response):
1388
- response_record = custom_response_tracker.add_response(tool_name, tool_response)
1389
-
1390
- # Extract text from response
1391
- response_text = str(tool_response)
1392
-
1393
- timestamp_str = time.strftime(
1394
- "%H:%M:%S",
1395
- time.localtime(response_record["timestamp"]),
1396
- )
1397
-
1398
- # Format response as JSON if possible
1399
- formatted_response = format_tool_response_as_json(response_text)
1400
-
1401
- yield StreamChunk(
1402
- type="custom_tool_status",
1403
- status="custom_tool_response",
1404
- content=f"✅ Custom Tool Response from {tool_name} at {timestamp_str}: {formatted_response}",
1405
- source="custom_tools",
1406
- )
1407
-
1408
- log_backend_activity(
1409
- "gemini",
1410
- "Custom tool response received",
1411
- {
1412
- "tool_name": tool_name,
1413
- "response_preview": str(tool_response),
1414
- },
1415
- agent_id=agent_id,
1416
- )
1417
-
1418
- # ============================================
1419
- # Process text content
1420
- # ============================================
1421
- if hasattr(chunk, "text") and chunk.text:
1422
- chunk_text = chunk.text
1423
- full_content_text += chunk_text
1424
- log_stream_chunk("backend.gemini", "continuation_content", chunk_text, agent_id)
1425
- yield StreamChunk(type="content", content=chunk_text)
1426
-
1427
- # ============================================
1428
- # Buffer last chunk
1429
- # ============================================
1430
- if hasattr(chunk, "candidates") and chunk.candidates:
1431
- last_response_with_candidates = chunk
1432
-
1433
- except Exception as e:
1434
- error_msg = f"Error in continuation call: {str(e)}"
1435
- logger.error(error_msg)
1436
- yield StreamChunk(
1437
- type="custom_tool_status",
1438
- status="continuation_error",
1439
- content=f"❌ {error_msg}",
1440
- source="custom_tools",
1441
- )
575
+ # Log the coordination action
576
+ logger.info(f"[Gemini] Function call coordination action: {tool_name}")
577
+ log_tool_call(
578
+ agent_id,
579
+ tool_name,
580
+ tool_args,
581
+ None,
582
+ backend_name="gemini",
583
+ )
1442
584
 
1443
- # ====================================================================
1444
- # Completion phase: Output summary
1445
- # ====================================================================
585
+ # Build tool call in standard format
586
+ workflow_tool_calls.append(
587
+ {
588
+ "id": call.get("call_id", f"call_{len(workflow_tool_calls)}"),
589
+ "type": "function",
590
+ "function": {
591
+ "name": tool_name,
592
+ "arguments": tool_args,
593
+ },
594
+ },
595
+ )
1446
596
 
1447
- # Add MCP usage indicator with detailed summary
1448
- if has_mcp:
1449
- mcp_summary = mcp_tracker.get_summary()
1450
- if not mcp_summary or mcp_summary == "No MCP tools called":
1451
- mcp_summary = "MCP session completed (no tools explicitly called)"
1452
- else:
1453
- mcp_summary = f"MCP session complete - {mcp_summary}"
597
+ # Emit tool_calls chunk for orchestrator to process
598
+ if workflow_tool_calls:
599
+ log_stream_chunk("backend.gemini", "tool_calls", workflow_tool_calls, agent_id)
600
+ yield StreamChunk(
601
+ type="tool_calls",
602
+ tool_calls=workflow_tool_calls,
603
+ source="gemini",
604
+ )
1454
605
 
1455
- log_stream_chunk("backend.gemini", "mcp_indicator", mcp_summary, agent_id)
606
+ if mcp_used:
1456
607
  yield StreamChunk(
1457
608
  type="mcp_status",
1458
609
  status="mcp_session_complete",
1459
- content=mcp_summary,
610
+ content="✅ [MCP] Session completed",
1460
611
  source="mcp_tools",
1461
612
  )
1462
613
 
1463
- # Add custom tool usage indicator with detailed summary
1464
- if has_custom_tools:
1465
- custom_summary = custom_tracker.get_summary()
1466
- if not custom_summary or custom_summary == "No MCP tools called":
1467
- custom_summary = "Custom tools session completed (no tools explicitly called)"
1468
- else:
1469
- # Replace "MCP tool" with "Custom tool"
1470
- custom_summary = custom_summary.replace("MCP tool", "Custom tool")
1471
- custom_summary = f"Custom tools session complete - {custom_summary}"
614
+ yield StreamChunk(type="done")
615
+ return
616
+
617
+ # Initialize for execution
618
+ updated_messages = messages.copy()
619
+ processed_call_ids = set()
620
+
621
+ # Configuration for custom tool execution
622
+ CUSTOM_TOOL_CONFIG = ToolExecutionConfig(
623
+ tool_type="custom",
624
+ chunk_type="custom_tool_status",
625
+ emoji_prefix="🔧 [Custom Tool]",
626
+ success_emoji="✅ [Custom Tool]",
627
+ error_emoji="❌ [Custom Tool Error]",
628
+ source_prefix="custom_",
629
+ status_called="custom_tool_called",
630
+ status_response="custom_tool_response",
631
+ status_error="custom_tool_error",
632
+ execution_callback=self._execute_custom_tool,
633
+ )
1472
634
 
1473
- log_stream_chunk("backend.gemini", "custom_tools_indicator", custom_summary, agent_id)
1474
- yield StreamChunk(
1475
- type="custom_tool_status",
1476
- status="custom_tools_session_complete",
1477
- content=custom_summary,
1478
- source="custom_tools",
1479
- )
635
+ # Configuration for MCP tool execution
636
+ MCP_TOOL_CONFIG = ToolExecutionConfig(
637
+ tool_type="mcp",
638
+ chunk_type="mcp_status",
639
+ emoji_prefix="🔧 [MCP Tool]",
640
+ success_emoji="✅ [MCP Tool]",
641
+ error_emoji="❌ [MCP Tool Error]",
642
+ source_prefix="mcp_",
643
+ status_called="mcp_tool_called",
644
+ status_response="mcp_tool_response",
645
+ status_error="mcp_tool_error",
646
+ execution_callback=self._execute_mcp_function_with_retry,
647
+ )
1480
648
 
1481
- except (
1482
- MCPConnectionError,
1483
- MCPTimeoutError,
1484
- MCPServerError,
1485
- MCPError,
1486
- Exception,
1487
- ) as e:
1488
- log_stream_chunk("backend.gemini", "tools_error", str(e), agent_id)
1489
-
1490
- # ====================================================================
1491
- # Error handling: Distinguish MCP and custom tools errors
1492
- # ====================================================================
1493
-
1494
- # Determine error type
1495
- is_mcp_error = isinstance(e, (MCPConnectionError, MCPTimeoutError, MCPServerError, MCPError))
1496
- is_custom_tool_error = not is_mcp_error and using_custom_tools
1497
-
1498
- # Emit user-friendly error message
1499
- if is_mcp_error:
1500
- async for chunk in self.mcp_manager.handle_mcp_error_and_fallback(e):
649
+ # Capture tool execution results for continuation loop
650
+ tool_results: Dict[str, str] = {}
651
+ self._active_tool_result_store = tool_results
652
+
653
+ try:
654
+ # Execute custom tools
655
+ for call in custom_calls:
656
+ async for chunk in self._execute_tool_with_logging(
657
+ call,
658
+ CUSTOM_TOOL_CONFIG,
659
+ updated_messages,
660
+ processed_call_ids,
661
+ ):
1501
662
  yield chunk
1502
- elif is_custom_tool_error:
1503
- yield StreamChunk(
1504
- type="custom_tool_status",
1505
- status="custom_tools_error",
1506
- content=f"⚠️ [Custom Tools] Error: {str(e)}; falling back to non-custom-tool mode",
1507
- source="custom_tools",
1508
- )
1509
- else:
663
+
664
+ # Check circuit breaker before MCP tool execution
665
+ if mcp_calls and not await self._check_circuit_breaker_before_execution():
666
+ logger.warning("[Gemini] All MCP servers blocked by circuit breaker")
1510
667
  yield StreamChunk(
1511
668
  type="mcp_status",
1512
- status="tools_error",
1513
- content=f"⚠️ [Tools] Error: {str(e)}; falling back",
1514
- source="tools",
669
+ status="mcp_blocked",
670
+ content="⚠️ [MCP] All servers blocked by circuit breaker",
671
+ source="circuit_breaker",
1515
672
  )
673
+ # Clear mcp_calls to skip execution
674
+ mcp_calls = []
675
+
676
+ # Execute MCP tools
677
+ for call in mcp_calls:
678
+ # Mark MCP as used when at least one MCP call is about to be executed
679
+ mcp_used = True
680
+
681
+ async for chunk in self._execute_tool_with_logging(
682
+ call,
683
+ MCP_TOOL_CONFIG,
684
+ updated_messages,
685
+ processed_call_ids,
686
+ ):
687
+ yield chunk
688
+ finally:
689
+ self._active_tool_result_store = None
1516
690
 
1517
- # Fallback configuration
1518
- manual_config = dict(config)
691
+ executed_calls = custom_calls + mcp_calls
1519
692
 
1520
- # Decide fallback configuration based on error type
1521
- if is_mcp_error and using_custom_tools:
1522
- # MCP error but custom tools available: exclude MCP, keep custom tools
1523
- try:
1524
- custom_tools_schemas = self._get_custom_tools_schemas()
1525
- if custom_tools_schemas:
1526
- # Convert to Gemini format using formatter
1527
- custom_tools_functions = self.formatter.format_custom_tools(
1528
- custom_tools_schemas,
1529
- return_sdk_objects=True,
1530
- )
1531
- # Wrap FunctionDeclarations in a Tool object for Gemini SDK
1532
- from google.genai import types
693
+ # Build initial conversation history using SDK Content objects
694
+ conversation_history: List[types.Content] = [
695
+ types.Content(parts=[types.Part(text=full_content)], role="user"),
696
+ ]
1533
697
 
1534
- custom_tool = types.Tool(function_declarations=custom_tools_functions)
1535
- manual_config["tools"] = [custom_tool]
1536
- logger.info("[Gemini] Fallback: using custom tools only (MCP failed)")
1537
- else:
1538
- # Custom tools also unavailable, use builtin tools
1539
- if all_tools:
1540
- manual_config["tools"] = all_tools
1541
- logger.info("[Gemini] Fallback: using builtin tools only (both MCP and custom tools failed)")
1542
- except Exception:
1543
- if all_tools:
1544
- manual_config["tools"] = all_tools
1545
- logger.info("[Gemini] Fallback: using builtin tools only (custom tools also failed)")
1546
-
1547
- elif is_custom_tool_error and using_sdk_mcp:
1548
- # Custom tools error but MCP available: exclude custom tools, keep MCP
1549
- try:
1550
- if self._mcp_client:
1551
- mcp_sessions = self.mcp_manager.get_active_mcp_sessions(
1552
- convert_to_permission_sessions=bool(self.filesystem_manager),
1553
- )
1554
- if mcp_sessions:
1555
- manual_config["tools"] = mcp_sessions
1556
- logger.info("[Gemini] Fallback: using MCP only (custom tools failed)")
1557
- else:
1558
- if all_tools:
1559
- manual_config["tools"] = all_tools
1560
- logger.info("[Gemini] Fallback: using builtin tools only (both custom tools and MCP failed)")
1561
- except Exception:
1562
- if all_tools:
1563
- manual_config["tools"] = all_tools
1564
- logger.info("[Gemini] Fallback: using builtin tools only (MCP also failed)")
698
+ if executed_calls:
699
+ model_parts = []
700
+ for call in executed_calls:
701
+ args_payload: Any = call.get("arguments", {})
702
+ if isinstance(args_payload, str):
703
+ try:
704
+ args_payload = json.loads(args_payload)
705
+ except json.JSONDecodeError:
706
+ args_payload = {}
707
+ if not isinstance(args_payload, dict):
708
+ args_payload = {}
709
+ model_parts.append(
710
+ types.Part.from_function_call(
711
+ name=call.get("name", ""),
712
+ args=args_payload,
713
+ ),
714
+ )
715
+ if model_parts:
716
+ conversation_history.append(types.Content(parts=model_parts, role="model"))
717
+
718
+ response_parts = []
719
+ for call in executed_calls:
720
+ call_id = call.get("call_id")
721
+ result_text = tool_results.get(call_id or "", "No result")
722
+ response_parts.append(
723
+ types.Part.from_function_response(
724
+ name=call.get("name", ""),
725
+ response={"result": result_text},
726
+ ),
727
+ )
728
+ if response_parts:
729
+ conversation_history.append(types.Content(parts=response_parts, role="user"))
1565
730
 
1566
- else:
1567
- # Both failed or cannot determine: use builtin tools
1568
- if all_tools:
1569
- manual_config["tools"] = all_tools
1570
- logger.info("[Gemini] Fallback: using builtin tools only (all advanced tools failed)")
731
+ last_continuation_chunk = None
1571
732
 
1572
- # Create new stream for fallback
1573
- stream = await client.aio.models.generate_content_stream(
733
+ while True:
734
+ continuation_stream = await client.aio.models.generate_content_stream(
1574
735
  model=model_name,
1575
- contents=full_content,
1576
- config=manual_config,
736
+ contents=conversation_history,
737
+ config=config,
1577
738
  )
739
+ stream = continuation_stream
1578
740
 
1579
- async for chunk in stream:
1580
- # Process text content
1581
- if hasattr(chunk, "text") and chunk.text:
1582
- chunk_text = chunk.text
1583
- full_content_text += chunk_text
1584
- log_stream_chunk(
1585
- "backend.gemini",
1586
- "fallback_content",
1587
- chunk_text,
1588
- agent_id,
1589
- )
1590
- yield StreamChunk(type="content", content=chunk_text)
1591
- # Buffer last chunk with candidates for fallback path
741
+ new_function_calls = []
742
+ continuation_text = ""
743
+
744
+ async for chunk in continuation_stream:
1592
745
  if hasattr(chunk, "candidates") and chunk.candidates:
1593
- last_response_with_candidates = chunk
1594
- else:
1595
- # Non-MCP streaming path: execute when MCP is disabled
1596
- try:
1597
- # Use the standard config (with builtin tools if configured)
1598
- stream = await client.aio.models.generate_content_stream(
1599
- model=model_name,
1600
- contents=full_content,
1601
- config=config,
1602
- )
746
+ last_continuation_chunk = chunk
747
+ for candidate in chunk.candidates:
748
+ if hasattr(candidate, "content") and candidate.content:
749
+ if hasattr(candidate.content, "parts") and candidate.content.parts:
750
+ for part in candidate.content.parts:
751
+ if hasattr(part, "function_call") and part.function_call:
752
+ tool_name = part.function_call.name
753
+ tool_args = dict(part.function_call.args) if part.function_call.args else {}
754
+ call_id = f"call_{len(new_function_calls)}"
755
+ new_function_calls.append(
756
+ {
757
+ "call_id": call_id,
758
+ "name": tool_name,
759
+ "arguments": json.dumps(tool_args),
760
+ },
761
+ )
1603
762
 
1604
- # Process streaming chunks
1605
- async for chunk in stream:
1606
- # Process text content
1607
763
  if hasattr(chunk, "text") and chunk.text:
1608
764
  chunk_text = chunk.text
1609
- full_content_text += chunk_text
765
+ continuation_text += chunk_text
1610
766
  log_backend_agent_message(
1611
767
  agent_id,
1612
768
  "RECV",
@@ -1615,86 +771,316 @@ class GeminiBackend(CustomToolAndMCPBackend):
1615
771
  )
1616
772
  log_stream_chunk("backend.gemini", "content", chunk_text, agent_id)
1617
773
  yield StreamChunk(type="content", content=chunk_text)
1618
- # Buffer last chunk with candidates for non-MCP path
1619
- if hasattr(chunk, "candidates") and chunk.candidates:
1620
- last_response_with_candidates = chunk
1621
774
 
1622
- except Exception as e:
1623
- error_msg = f"Non-MCP streaming error: {e}"
1624
- log_stream_chunk(
1625
- "backend.gemini",
1626
- "non_mcp_stream_error",
1627
- {"error_type": type(e).__name__, "error_message": str(e)},
1628
- agent_id,
1629
- )
1630
- yield StreamChunk(type="error", error=error_msg)
775
+ if continuation_text:
776
+ conversation_history.append(
777
+ types.Content(parts=[types.Part(text=continuation_text)], role="model"),
778
+ )
779
+ full_content_text += continuation_text
780
+
781
+ if last_continuation_chunk:
782
+ last_response_with_candidates = last_continuation_chunk
783
+
784
+ if not new_function_calls:
785
+ # ====================================================================
786
+ # Continuation Structured Coordination Output Parsing
787
+ # ====================================================================
788
+ # Check for structured coordination output when no function calls in continuation
789
+ if is_coordination and full_content_text:
790
+ # Try to parse structured response from accumulated text content
791
+ parsed = self.formatter.extract_structured_response(full_content_text)
792
+
793
+ if parsed and isinstance(parsed, dict):
794
+ # Convert structured response to tool calls
795
+ tool_calls = self.formatter.convert_structured_to_tool_calls(parsed)
796
+
797
+ if tool_calls:
798
+ # Categorize the tool calls
799
+ mcp_calls, custom_calls, provider_calls = self._categorize_tool_calls(tool_calls)
800
+
801
+ if provider_calls:
802
+ # Convert provider calls to tool_calls format for orchestrator
803
+ workflow_tool_calls = []
804
+ for call in provider_calls:
805
+ tool_name = call.get("name", "")
806
+ tool_args_str = call.get("arguments", "{}")
807
+
808
+ # Parse arguments if they're a string
809
+ if isinstance(tool_args_str, str):
810
+ try:
811
+ tool_args = json.loads(tool_args_str)
812
+ except json.JSONDecodeError:
813
+ tool_args = {}
814
+ else:
815
+ tool_args = tool_args_str
816
+
817
+ # Log the coordination action
818
+ logger.info(f"[Gemini] Continuation structured coordination action: {tool_name}")
819
+ log_tool_call(
820
+ agent_id,
821
+ tool_name,
822
+ tool_args,
823
+ None,
824
+ backend_name="gemini",
825
+ )
826
+
827
+ # Build tool call in standard format
828
+ workflow_tool_calls.append(
829
+ {
830
+ "id": call.get("call_id", f"call_{len(workflow_tool_calls)}"),
831
+ "type": "function",
832
+ "function": {
833
+ "name": tool_name,
834
+ "arguments": tool_args,
835
+ },
836
+ },
837
+ )
838
+
839
+ # Emit tool_calls chunk for orchestrator to process
840
+ if workflow_tool_calls:
841
+ log_stream_chunk("backend.gemini", "tool_calls", workflow_tool_calls, agent_id)
842
+ yield StreamChunk(
843
+ type="tool_calls",
844
+ tool_calls=workflow_tool_calls,
845
+ source="gemini",
846
+ )
847
+
848
+ if mcp_used:
849
+ yield StreamChunk(
850
+ type="mcp_status",
851
+ status="mcp_session_complete",
852
+ content="✅ [MCP] Session completed",
853
+ source="mcp_tools",
854
+ )
855
+
856
+ yield StreamChunk(type="done")
857
+ return
858
+
859
+ # No structured output found, break continuation loop
860
+ break
861
+
862
+ next_mcp_calls, next_custom_calls, provider_calls = self._categorize_tool_calls(new_function_calls)
863
+
864
+ # Handle provider calls emitted during continuation
865
+ if provider_calls:
866
+ workflow_tool_calls = []
867
+ for call in provider_calls:
868
+ tool_name = call.get("name", "")
869
+ tool_args_str = call.get("arguments", "{}")
870
+
871
+ if isinstance(tool_args_str, str):
872
+ try:
873
+ tool_args = json.loads(tool_args_str)
874
+ except json.JSONDecodeError:
875
+ tool_args = {}
876
+ else:
877
+ tool_args = tool_args_str
878
+
879
+ logger.info(f"[Gemini] Continuation coordination action: {tool_name}")
880
+ log_tool_call(
881
+ agent_id,
882
+ tool_name,
883
+ tool_args,
884
+ None,
885
+ backend_name="gemini",
886
+ )
887
+
888
+ workflow_tool_calls.append(
889
+ {
890
+ "id": call.get("call_id", f"call_{len(workflow_tool_calls)}"),
891
+ "type": "function",
892
+ "function": {
893
+ "name": tool_name,
894
+ "arguments": tool_args,
895
+ },
896
+ },
897
+ )
898
+
899
+ if workflow_tool_calls:
900
+ log_stream_chunk("backend.gemini", "tool_calls", workflow_tool_calls, agent_id)
901
+ yield StreamChunk(
902
+ type="tool_calls",
903
+ tool_calls=workflow_tool_calls,
904
+ source="gemini",
905
+ )
906
+
907
+ if mcp_used:
908
+ yield StreamChunk(
909
+ type="mcp_status",
910
+ status="mcp_session_complete",
911
+ content="✅ [MCP] Session completed",
912
+ source="mcp_tools",
913
+ )
914
+
915
+ yield StreamChunk(type="done")
916
+ return
917
+
918
+ new_tool_results: Dict[str, str] = {}
919
+ self._active_tool_result_store = new_tool_results
920
+
921
+ try:
922
+ for call in next_custom_calls:
923
+ async for chunk in self._execute_tool_with_logging(
924
+ call,
925
+ CUSTOM_TOOL_CONFIG,
926
+ updated_messages,
927
+ processed_call_ids,
928
+ ):
929
+ yield chunk
930
+
931
+ if next_mcp_calls and not await self._check_circuit_breaker_before_execution():
932
+ logger.warning("[Gemini] All MCP servers blocked by circuit breaker during continuation")
933
+ yield StreamChunk(
934
+ type="mcp_status",
935
+ status="mcp_blocked",
936
+ content="⚠️ [MCP] All servers blocked by circuit breaker",
937
+ source="circuit_breaker",
938
+ )
939
+ next_mcp_calls = []
940
+
941
+ for call in next_mcp_calls:
942
+ mcp_used = True
943
+
944
+ async for chunk in self._execute_tool_with_logging(
945
+ call,
946
+ MCP_TOOL_CONFIG,
947
+ updated_messages,
948
+ processed_call_ids,
949
+ ):
950
+ yield chunk
951
+ finally:
952
+ self._active_tool_result_store = None
953
+
954
+ if new_tool_results:
955
+ tool_results.update(new_tool_results)
956
+
957
+ executed_calls = next_custom_calls + next_mcp_calls
958
+
959
+ if executed_calls:
960
+ model_parts = []
961
+ for call in executed_calls:
962
+ args_payload: Any = call.get("arguments", {})
963
+ if isinstance(args_payload, str):
964
+ try:
965
+ args_payload = json.loads(args_payload)
966
+ except json.JSONDecodeError:
967
+ args_payload = {}
968
+ if not isinstance(args_payload, dict):
969
+ args_payload = {}
970
+ model_parts.append(
971
+ types.Part.from_function_call(
972
+ name=call.get("name", ""),
973
+ args=args_payload,
974
+ ),
975
+ )
976
+ if model_parts:
977
+ conversation_history.append(types.Content(parts=model_parts, role="model"))
978
+
979
+ response_parts = []
980
+ for call in executed_calls:
981
+ call_id = call.get("call_id")
982
+ result_text = new_tool_results.get(call_id or "", "No result")
983
+ response_parts.append(
984
+ types.Part.from_function_response(
985
+ name=call.get("name", ""),
986
+ response={"result": result_text},
987
+ ),
988
+ )
989
+ if response_parts:
990
+ conversation_history.append(types.Content(parts=response_parts, role="user"))
1631
991
 
1632
- content = full_content_text
992
+ # ====================================================================
993
+ # Completion Phase: Process structured tool calls and builtin indicators
994
+ # ====================================================================
995
+ final_response = last_response_with_candidates
1633
996
 
1634
- # Process tool calls - coordination and post-evaluation tool calls (MCP manual mode removed)
1635
997
  tool_calls_detected: List[Dict[str, Any]] = []
1636
998
 
1637
- # Process coordination tools OR post-evaluation tools if present
1638
- if (is_coordination or is_post_evaluation) and content.strip() and not tool_calls_detected:
1639
- # For structured output mode, the entire content is JSON
999
+ if (is_coordination or is_post_evaluation) and full_content_text.strip():
1000
+ content = full_content_text
1640
1001
  structured_response = None
1641
- # Try multiple parsing strategies
1002
+
1642
1003
  try:
1643
- # Strategy 1: Parse entire content as JSON (works for both modes)
1644
1004
  structured_response = json.loads(content.strip())
1645
1005
  except json.JSONDecodeError:
1646
- # Strategy 2: Extract JSON from mixed text content (handles markdown-wrapped JSON)
1647
1006
  structured_response = self.formatter.extract_structured_response(content)
1648
1007
 
1649
- if structured_response and isinstance(structured_response, dict) and "action_type" in structured_response:
1650
- # Convert to tool calls
1651
- tool_calls = self.formatter.convert_structured_to_tool_calls(structured_response)
1652
- if tool_calls:
1653
- tool_calls_detected = tool_calls
1654
- # Log conversion to tool calls (summary)
1655
- log_stream_chunk("backend.gemini", "tool_calls", tool_calls, agent_id)
1008
+ if structured_response and isinstance(structured_response, dict) and structured_response.get("action_type"):
1009
+ raw_tool_calls = self.formatter.convert_structured_to_tool_calls(structured_response)
1656
1010
 
1657
- # Log each tool call for analytics/debugging
1011
+ if raw_tool_calls:
1658
1012
  tool_type = "post_evaluation" if is_post_evaluation else "coordination"
1659
- try:
1660
- for tool_call in tool_calls:
1013
+ workflow_tool_calls: List[Dict[str, Any]] = []
1014
+
1015
+ for call in raw_tool_calls:
1016
+ tool_name = call.get("name", "")
1017
+ tool_args_str = call.get("arguments", "{}")
1018
+
1019
+ if isinstance(tool_args_str, str):
1020
+ try:
1021
+ tool_args = json.loads(tool_args_str)
1022
+ except json.JSONDecodeError:
1023
+ tool_args = {}
1024
+ else:
1025
+ tool_args = tool_args_str
1026
+
1027
+ try:
1661
1028
  log_tool_call(
1662
1029
  agent_id,
1663
- tool_call.get("function", {}).get("name", f"unknown_{tool_type}_tool"),
1664
- tool_call.get("function", {}).get("arguments", {}),
1030
+ tool_name or f"unknown_{tool_type}_tool",
1031
+ tool_args,
1665
1032
  result=f"{tool_type}_tool_called",
1666
1033
  backend_name="gemini",
1667
1034
  )
1668
- except Exception:
1669
- # Ensure logging does not interrupt flow
1670
- pass
1035
+ except Exception:
1036
+ pass
1037
+
1038
+ workflow_tool_calls.append(
1039
+ {
1040
+ "id": call.get("call_id", f"call_{len(workflow_tool_calls)}"),
1041
+ "type": "function",
1042
+ "function": {
1043
+ "name": tool_name,
1044
+ "arguments": tool_args,
1045
+ },
1046
+ },
1047
+ )
1671
1048
 
1672
- # Assign buffered final response (if available) so builtin tool indicators can be emitted
1673
- if last_response_with_candidates is not None:
1674
- final_response = last_response_with_candidates
1049
+ if workflow_tool_calls:
1050
+ tool_calls_detected = workflow_tool_calls
1051
+ log_stream_chunk("backend.gemini", "tool_calls", workflow_tool_calls, agent_id)
1052
+
1053
+ if tool_calls_detected:
1054
+ yield StreamChunk(type="tool_calls", tool_calls=tool_calls_detected, source="gemini")
1055
+
1056
+ if mcp_used:
1057
+ yield StreamChunk(
1058
+ type="mcp_status",
1059
+ status="mcp_session_complete",
1060
+ content="✅ [MCP] Session completed",
1061
+ source="mcp_tools",
1062
+ )
1063
+
1064
+ yield StreamChunk(type="done")
1065
+ return
1675
1066
 
1676
- # Process builtin tool results if any tools were used
1677
1067
  if builtin_tools and final_response and hasattr(final_response, "candidates") and final_response.candidates:
1678
- # Check for grounding or code execution results
1679
1068
  candidate = final_response.candidates[0]
1680
1069
 
1681
- # Check for web search results - only show if actually used
1682
1070
  if hasattr(candidate, "grounding_metadata") and candidate.grounding_metadata:
1683
- # Check if web search was actually used by looking for queries or chunks
1684
1071
  search_actually_used = False
1685
- search_queries = []
1072
+ search_queries: List[str] = []
1686
1073
 
1687
- # Look for web search queries
1688
1074
  if hasattr(candidate.grounding_metadata, "web_search_queries") and candidate.grounding_metadata.web_search_queries:
1689
1075
  try:
1690
1076
  for query in candidate.grounding_metadata.web_search_queries:
1691
- if query and query.strip():
1692
- search_queries.append(query.strip())
1077
+ if query and isinstance(query, str) and query.strip():
1078
+ trimmed_query = query.strip()
1079
+ search_queries.append(trimmed_query)
1693
1080
  search_actually_used = True
1694
1081
  except (TypeError, AttributeError):
1695
1082
  pass
1696
1083
 
1697
- # Look for grounding chunks (indicates actual search results)
1698
1084
  if hasattr(candidate.grounding_metadata, "grounding_chunks") and candidate.grounding_metadata.grounding_chunks:
1699
1085
  try:
1700
1086
  if len(candidate.grounding_metadata.grounding_chunks) > 0:
@@ -1702,9 +1088,7 @@ class GeminiBackend(CustomToolAndMCPBackend):
1702
1088
  except (TypeError, AttributeError):
1703
1089
  pass
1704
1090
 
1705
- # Only show indicators if search was actually used
1706
1091
  if search_actually_used:
1707
- # Enhanced web search logging
1708
1092
  log_stream_chunk(
1709
1093
  "backend.gemini",
1710
1094
  "web_search_result",
@@ -1716,17 +1100,17 @@ class GeminiBackend(CustomToolAndMCPBackend):
1716
1100
  "google_search_retrieval",
1717
1101
  {
1718
1102
  "queries": search_queries,
1719
- "chunks_found": len(candidate.grounding_metadata.grounding_chunks) if hasattr(candidate.grounding_metadata, "grounding_chunks") else 0,
1103
+ "chunks_found": len(getattr(candidate.grounding_metadata, "grounding_chunks", []) or []),
1720
1104
  },
1721
1105
  result="search_completed",
1722
1106
  backend_name="gemini",
1723
1107
  )
1108
+
1724
1109
  yield StreamChunk(
1725
1110
  type="content",
1726
1111
  content="🔍 [Builtin Tool: Web Search] Results integrated\n",
1727
1112
  )
1728
1113
 
1729
- # Show search queries
1730
1114
  for query in search_queries:
1731
1115
  log_stream_chunk(
1732
1116
  "backend.gemini",
@@ -1738,240 +1122,308 @@ class GeminiBackend(CustomToolAndMCPBackend):
1738
1122
 
1739
1123
  self.search_count += 1
1740
1124
 
1741
- # Check for code execution in the response parts
1125
+ enable_code_execution = bool(
1126
+ all_params.get("enable_code_execution") or all_params.get("code_execution"),
1127
+ )
1128
+
1742
1129
  if enable_code_execution and hasattr(candidate, "content") and hasattr(candidate.content, "parts"):
1743
- # Look for executable_code and code_execution_result parts
1744
- code_parts = []
1130
+ code_parts: List[str] = []
1131
+
1745
1132
  for part in candidate.content.parts:
1746
1133
  if hasattr(part, "executable_code") and part.executable_code:
1747
1134
  code_content = getattr(part.executable_code, "code", str(part.executable_code))
1748
1135
  code_parts.append(f"Code: {code_content}")
1749
1136
  elif hasattr(part, "code_execution_result") and part.code_execution_result:
1750
- result_content = getattr(
1751
- part.code_execution_result,
1752
- "output",
1753
- str(part.code_execution_result),
1754
- )
1137
+ result_content = getattr(part.code_execution_result, "output", str(part.code_execution_result))
1755
1138
  code_parts.append(f"Result: {result_content}")
1756
1139
 
1757
1140
  if code_parts:
1758
- # Code execution was actually used
1759
1141
  log_stream_chunk(
1760
1142
  "backend.gemini",
1761
1143
  "code_execution",
1762
1144
  "Code executed",
1763
1145
  agent_id,
1764
1146
  )
1765
-
1766
- # Log code execution as a tool call event
1767
- try:
1768
- log_tool_call(
1769
- agent_id,
1770
- "code_execution",
1771
- {"code_parts_count": len(code_parts)},
1772
- result="code_executed",
1773
- backend_name="gemini",
1774
- )
1775
- except Exception:
1776
- pass
1147
+ log_tool_call(
1148
+ agent_id,
1149
+ "code_execution",
1150
+ {"details": code_parts},
1151
+ result="code_execution_completed",
1152
+ backend_name="gemini",
1153
+ )
1777
1154
 
1778
1155
  yield StreamChunk(
1779
1156
  type="content",
1780
- content="💻 [Builtin Tool: Code Execution] Code executed\n",
1157
+ content="🧮 [Builtin Tool: Code Execution] Results integrated\n",
1781
1158
  )
1782
- # Also show the actual code and result
1783
- for part in code_parts:
1784
- if part.startswith("Code: "):
1785
- code_content = part[6:] # Remove "Code: " prefix
1786
- log_stream_chunk(
1787
- "backend.gemini",
1788
- "code_execution_result",
1789
- {
1790
- "code_parts": len(code_parts),
1791
- "execution_successful": True,
1792
- "snippet": code_content,
1793
- },
1794
- agent_id,
1795
- )
1796
- yield StreamChunk(
1797
- type="content",
1798
- content=f"💻 [Code Executed]\n```python\n{code_content}\n```\n",
1799
- )
1800
- elif part.startswith("Result: "):
1801
- result_content = part[8:] # Remove "Result: " prefix
1802
- log_stream_chunk(
1803
- "backend.gemini",
1804
- "code_execution_result",
1805
- {
1806
- "code_parts": len(code_parts),
1807
- "execution_successful": True,
1808
- "result": result_content,
1809
- },
1810
- agent_id,
1811
- )
1812
- yield StreamChunk(
1813
- type="content",
1814
- content=f"📊 [Result] {result_content}\n",
1815
- )
1159
+
1160
+ for entry in code_parts:
1161
+ yield StreamChunk(type="content", content=f"🧮 {entry}\n")
1816
1162
 
1817
1163
  self.code_execution_count += 1
1818
1164
 
1819
- # Yield coordination tool calls if detected
1820
- if tool_calls_detected:
1821
- # Enhanced tool calls summary logging
1822
- log_stream_chunk(
1823
- "backend.gemini",
1824
- "tool_calls_yielded",
1825
- {
1826
- "tool_count": len(tool_calls_detected),
1827
- "tool_names": [tc.get("function", {}).get("name") for tc in tool_calls_detected],
1828
- },
1829
- agent_id,
1165
+ elif final_response and hasattr(final_response, "candidates"):
1166
+ for candidate in final_response.candidates:
1167
+ if hasattr(candidate, "grounding_metadata"):
1168
+ self.search_count += 1
1169
+ logger.debug(f"[Gemini] Grounding (web search) used, count: {self.search_count}")
1170
+
1171
+ if hasattr(candidate, "content") and candidate.content:
1172
+ if hasattr(candidate.content, "parts"):
1173
+ for part in candidate.content.parts:
1174
+ if hasattr(part, "executable_code") or hasattr(part, "code_execution_result"):
1175
+ self.code_execution_count += 1
1176
+ logger.debug(f"[Gemini] Code execution used, count: {self.code_execution_count}")
1177
+ break
1178
+
1179
+ # Emit completion status
1180
+ if mcp_used:
1181
+ yield StreamChunk(
1182
+ type="mcp_status",
1183
+ status="mcp_session_complete",
1184
+ content="✅ [MCP] Session completed",
1185
+ source="mcp_tools",
1830
1186
  )
1831
- yield StreamChunk(type="tool_calls", tool_calls=tool_calls_detected)
1832
1187
 
1833
- # Build complete message
1834
- complete_message = {"role": "assistant", "content": content.strip()}
1835
- if tool_calls_detected:
1836
- complete_message["tool_calls"] = tool_calls_detected
1837
-
1838
- # Enhanced complete message logging with metadata
1839
- log_stream_chunk(
1840
- "backend.gemini",
1841
- "complete_message",
1842
- {
1843
- "content_length": len(content.strip()),
1844
- "has_tool_calls": bool(tool_calls_detected),
1845
- },
1846
- agent_id,
1847
- )
1848
- yield StreamChunk(type="complete_message", complete_message=complete_message)
1849
- log_stream_chunk("backend.gemini", "done", None, agent_id)
1850
1188
  yield StreamChunk(type="done")
1851
1189
 
1852
1190
  except Exception as e:
1853
- error_msg = f"Gemini API error: {e}"
1854
- # Enhanced error logging with structured details
1855
- log_stream_chunk(
1856
- "backend.gemini",
1857
- "stream_error",
1858
- {"error_type": type(e).__name__, "error_message": str(e)},
1859
- agent_id,
1860
- )
1861
- yield StreamChunk(type="error", error=error_msg)
1191
+ logger.error(f"[Gemini] Error in stream_with_tools: {e}")
1192
+ raise
1193
+
1862
1194
  finally:
1863
- # Cleanup resources
1864
- await self.mcp_manager.cleanup_genai_resources(stream, client)
1865
- # Ensure context manager exit for MCP cleanup
1866
- try:
1867
- await self.__aexit__(None, None, None)
1868
- except Exception as e:
1869
- log_backend_activity(
1870
- "gemini",
1871
- "MCP cleanup failed",
1872
- {"error": str(e)},
1873
- agent_id=self.agent_id,
1874
- )
1195
+ await self._cleanup_genai_resources(stream, client)
1875
1196
 
1876
- def get_provider_name(self) -> str:
1877
- """Get the provider name."""
1878
- return "Gemini"
1197
+ async def _try_close_resource(
1198
+ self,
1199
+ resource: Any,
1200
+ method_names: tuple,
1201
+ resource_label: str,
1202
+ ) -> bool:
1203
+ """Try to close a resource using one of the provided method names.
1879
1204
 
1880
- def get_filesystem_support(self) -> FilesystemSupport:
1881
- """Gemini supports filesystem through MCP servers."""
1882
- return FilesystemSupport.MCP
1205
+ Args:
1206
+ resource: Object to close
1207
+ method_names: Method names to try (e.g., ("aclose", "close"))
1208
+ resource_label: Label for error logging
1883
1209
 
1884
- def get_supported_builtin_tools(self) -> List[str]:
1885
- """Get list of builtin tools supported by Gemini."""
1886
- return ["google_search_retrieval", "code_execution"]
1210
+ Returns:
1211
+ True if closed successfully, False otherwise
1212
+ """
1213
+ if resource is None:
1214
+ return False
1887
1215
 
1888
- def get_mcp_results(self) -> Dict[str, Any]:
1216
+ for method_name in method_names:
1217
+ close_method = getattr(resource, method_name, None)
1218
+ if close_method is not None:
1219
+ try:
1220
+ result = close_method()
1221
+ if hasattr(result, "__await__"):
1222
+ await result
1223
+ return True
1224
+ except Exception as e:
1225
+ log_backend_activity(
1226
+ "gemini",
1227
+ f"{resource_label} cleanup failed",
1228
+ {"error": str(e), "method": method_name},
1229
+ agent_id=self.agent_id,
1230
+ )
1231
+ return False
1232
+ return False
1233
+
1234
+ async def _cleanup_genai_resources(self, stream, client) -> None:
1235
+ """Cleanup google-genai resources to avoid unclosed aiohttp sessions.
1236
+
1237
+ Cleanup order is critical: stream → session → transport → client.
1238
+ Each resource is cleaned independently with error isolation.
1889
1239
  """
1890
- Get all captured MCP tool calls and responses.
1240
+ # 1. Close stream
1241
+ await self._try_close_resource(stream, ("aclose", "close"), "Stream")
1242
+
1243
+ # 2. Close internal aiohttp session (requires special handling)
1244
+ if client is not None:
1245
+ base_client = getattr(client, "_api_client", None)
1246
+ if base_client is not None:
1247
+ session = getattr(base_client, "_aiohttp_session", None)
1248
+ if session is not None and not getattr(session, "closed", True):
1249
+ try:
1250
+ await session.close()
1251
+ log_backend_activity(
1252
+ "gemini",
1253
+ "Closed google-genai aiohttp session",
1254
+ {},
1255
+ agent_id=self.agent_id,
1256
+ )
1257
+ base_client._aiohttp_session = None
1258
+ # Yield control to allow connector cleanup
1259
+ await asyncio.sleep(0)
1260
+ except Exception as e:
1261
+ log_backend_activity(
1262
+ "gemini",
1263
+ "Failed to close google-genai aiohttp session",
1264
+ {"error": str(e)},
1265
+ agent_id=self.agent_id,
1266
+ )
1891
1267
 
1892
- Returns:
1893
- Dict containing:
1894
- - calls: List of all MCP tool calls
1895
- - responses: List of all MCP tool responses
1896
- - pairs: List of matched call-response pairs
1897
- - summary: Statistical summary of interactions
1268
+ # 3. Close internal async transport
1269
+ if client is not None:
1270
+ aio_obj = getattr(client, "aio", None)
1271
+ await self._try_close_resource(aio_obj, ("close", "stop"), "Client AIO")
1272
+
1273
+ # 4. Close client
1274
+ await self._try_close_resource(client, ("aclose", "close"), "Client")
1275
+
1276
+ def _append_tool_result_message(
1277
+ self,
1278
+ updated_messages: List[Dict[str, Any]],
1279
+ call: Dict[str, Any],
1280
+ result: Any,
1281
+ tool_type: str,
1282
+ ) -> None:
1283
+ """Append tool result to messages in Gemini conversation format.
1284
+
1285
+ Gemini uses a different message format than OpenAI/Response API.
1286
+ We need to append messages in a format that Gemini SDK can understand
1287
+ when making recursive calls.
1288
+
1289
+ Args:
1290
+ updated_messages: Message list to append to
1291
+ call: Tool call dictionary with call_id, name, arguments
1292
+ result: Tool execution result
1293
+ tool_type: "custom" or "mcp"
1898
1294
  """
1899
- return {
1900
- "calls": self.mcp_extractor.mcp_calls,
1901
- "responses": self.mcp_extractor.mcp_responses,
1902
- "pairs": self.mcp_extractor.call_response_pairs,
1903
- "summary": self.mcp_extractor.get_summary(),
1295
+ tool_result_msg = {
1296
+ "role": "tool",
1297
+ "name": call.get("name", ""),
1298
+ "content": str(result),
1904
1299
  }
1300
+ updated_messages.append(tool_result_msg)
1905
1301
 
1906
- def get_mcp_paired_results(self) -> List[Dict[str, Any]]:
1907
- """
1908
- Get only the paired MCP tool calls and responses.
1302
+ tool_results_store = getattr(self, "_active_tool_result_store", None)
1303
+ call_id = call.get("call_id")
1304
+ if isinstance(tool_results_store, dict) and call_id:
1305
+ tool_results_store[call_id] = str(result)
1909
1306
 
1910
- Returns:
1911
- List of dictionaries containing matched call-response pairs
1912
- """
1913
- return self.mcp_extractor.call_response_pairs
1307
+ def _append_tool_error_message(
1308
+ self,
1309
+ updated_messages: List[Dict[str, Any]],
1310
+ call: Dict[str, Any],
1311
+ error_msg: str,
1312
+ tool_type: str,
1313
+ ) -> None:
1314
+ """Append tool error to messages in Gemini conversation format.
1914
1315
 
1915
- def get_mcp_summary(self) -> Dict[str, Any]:
1316
+ Args:
1317
+ updated_messages: Message list to append to
1318
+ call: Tool call dictionary with call_id, name, arguments
1319
+ error_msg: Error message string
1320
+ tool_type: "custom" or "mcp"
1916
1321
  """
1917
- Get a summary of MCP tool interactions.
1322
+ # Append error as function result
1323
+ error_result_msg = {
1324
+ "role": "tool",
1325
+ "name": call.get("name", ""),
1326
+ "content": f"Error: {error_msg}",
1327
+ }
1328
+ updated_messages.append(error_result_msg)
1918
1329
 
1919
- Returns:
1920
- Dictionary with statistics about MCP tool usage
1330
+ tool_results_store = getattr(self, "_active_tool_result_store", None)
1331
+ call_id = call.get("call_id")
1332
+ if isinstance(tool_results_store, dict) and call_id:
1333
+ tool_results_store[call_id] = f"Error: {error_msg}"
1334
+
1335
+ async def _execute_custom_tool(self, call: Dict[str, Any]) -> AsyncGenerator[CustomToolChunk, None]:
1336
+ """Execute custom tool with streaming support - async generator for base class.
1337
+
1338
+ This method is called by _execute_tool_with_logging and yields CustomToolChunk
1339
+ objects for intermediate streaming output. The base class detects the async
1340
+ generator and streams intermediate results to users in real-time.
1341
+
1342
+ Args:
1343
+ call: Tool call dictionary with name and arguments
1344
+
1345
+ Yields:
1346
+ CustomToolChunk objects with streaming data
1347
+
1348
+ Note:
1349
+ - Intermediate chunks (completed=False) are streamed to users in real-time
1350
+ - Final chunk (completed=True) contains the accumulated result for message history
1351
+ - The base class automatically handles extracting and displaying intermediate chunks
1921
1352
  """
1922
- return self.mcp_extractor.get_summary()
1353
+ async for chunk in self.stream_custom_tool_execution(call):
1354
+ yield chunk
1355
+
1356
+ def get_provider_name(self) -> str:
1357
+ """Get the provider name."""
1358
+ return "Gemini"
1359
+
1360
+ def get_filesystem_support(self) -> FilesystemSupport:
1361
+ """Gemini supports filesystem through MCP servers."""
1362
+ return FilesystemSupport.MCP
1923
1363
 
1924
- def clear_mcp_results(self):
1925
- """Clear all stored MCP interaction data."""
1926
- self.mcp_extractor.clear()
1364
+ def get_supported_builtin_tools(self) -> List[str]:
1365
+ """Get list of builtin tools supported by Gemini."""
1366
+ return ["google_search_retrieval", "code_execution"]
1927
1367
 
1928
1368
  def reset_tool_usage(self):
1929
1369
  """Reset tool usage tracking."""
1930
1370
  self.search_count = 0
1931
1371
  self.code_execution_count = 0
1932
- # Reset MCP monitoring metrics
1933
- self._mcp_tool_calls_count = 0
1934
- self._mcp_tool_failures = 0
1935
- self._mcp_tool_successes = 0
1936
- self._mcp_connection_retries = 0
1937
- # Clear MCP extractor data
1938
- self.mcp_extractor.clear()
1372
+ # Reset MCP monitoring metrics when available
1373
+ for attr in (
1374
+ "_mcp_tool_calls_count",
1375
+ "_mcp_tool_failures",
1376
+ "_mcp_tool_successes",
1377
+ "_mcp_connection_retries",
1378
+ ):
1379
+ if hasattr(self, attr):
1380
+ setattr(self, attr, 0)
1939
1381
  super().reset_token_usage()
1940
1382
 
1941
1383
  async def cleanup_mcp(self):
1942
1384
  """Cleanup MCP connections - override parent class to use Gemini-specific cleanup."""
1943
- if self._mcp_client:
1385
+ if MCPResourceManager:
1944
1386
  try:
1945
- await self._mcp_client.disconnect()
1946
- log_backend_activity("gemini", "MCP client disconnected", {}, agent_id=self.agent_id)
1947
- except (
1948
- MCPConnectionError,
1949
- MCPTimeoutError,
1950
- MCPServerError,
1951
- MCPError,
1952
- Exception,
1953
- ) as e:
1387
+ await super().cleanup_mcp()
1388
+ return
1389
+ except Exception as error:
1390
+ log_backend_activity(
1391
+ "gemini",
1392
+ "MCP cleanup via resource manager failed",
1393
+ {"error": str(error)},
1394
+ agent_id=self.agent_id,
1395
+ )
1396
+ # Fall back to manual cleanup below
1397
+
1398
+ if not self._mcp_client:
1399
+ return
1400
+
1401
+ try:
1402
+ await self._mcp_client.disconnect()
1403
+ log_backend_activity("gemini", "MCP client disconnected", {}, agent_id=self.agent_id)
1404
+ except (
1405
+ MCPConnectionError,
1406
+ MCPTimeoutError,
1407
+ MCPServerError,
1408
+ MCPError,
1409
+ Exception,
1410
+ ) as e:
1411
+ if MCPErrorHandler:
1954
1412
  MCPErrorHandler.get_error_details(e, "disconnect", log=True)
1955
- finally:
1956
- self._mcp_client = None
1957
- self._mcp_initialized = False
1958
- # Also clear parent class attributes if they exist (for compatibility)
1959
- if hasattr(self, "_mcp_functions"):
1960
- self._mcp_functions.clear()
1961
- if hasattr(self, "_mcp_function_names"):
1962
- self._mcp_function_names.clear()
1413
+ else:
1414
+ logger.exception("[Gemini] MCP disconnect error during cleanup")
1415
+ finally:
1416
+ self._mcp_client = None
1417
+ self._mcp_initialized = False
1418
+ if hasattr(self, "_mcp_functions"):
1419
+ self._mcp_functions.clear()
1420
+ if hasattr(self, "_mcp_function_names"):
1421
+ self._mcp_function_names.clear()
1963
1422
 
1964
1423
  async def __aenter__(self) -> "GeminiBackend":
1965
1424
  """Async context manager entry."""
1966
- try:
1967
- await self.mcp_manager.setup_mcp_tools(agent_id=self.agent_id)
1968
- except Exception as e:
1969
- log_backend_activity(
1970
- "gemini",
1971
- "MCP setup failed during context entry",
1972
- {"error": str(e)},
1973
- agent_id=self.agent_id,
1974
- )
1425
+ # Call parent class __aenter__ which handles MCP setup
1426
+ await super().__aenter__()
1975
1427
  return self
1976
1428
 
1977
1429
  async def __aexit__(
@@ -1984,11 +1436,15 @@ class GeminiBackend(CustomToolAndMCPBackend):
1984
1436
  # Parameters are required by context manager protocol but not used
1985
1437
  _ = (exc_type, exc_val, exc_tb)
1986
1438
  try:
1987
- await self.cleanup_mcp()
1988
- except Exception as e:
1989
- log_backend_activity(
1990
- "gemini",
1991
- "Backend cleanup error",
1992
- {"error": str(e)},
1993
- agent_id=self.agent_id,
1994
- )
1439
+ await super().__aexit__(exc_type, exc_val, exc_tb)
1440
+ finally:
1441
+ if not MCPResourceManager:
1442
+ try:
1443
+ await self.cleanup_mcp()
1444
+ except Exception as e:
1445
+ log_backend_activity(
1446
+ "gemini",
1447
+ "Backend cleanup error",
1448
+ {"error": str(e)},
1449
+ agent_id=self.agent_id,
1450
+ )