massgen 0.1.0a2__py3-none-any.whl → 0.1.1__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 (111) hide show
  1. massgen/__init__.py +1 -1
  2. massgen/agent_config.py +17 -0
  3. massgen/api_params_handler/_api_params_handler_base.py +1 -0
  4. massgen/api_params_handler/_chat_completions_api_params_handler.py +8 -1
  5. massgen/api_params_handler/_claude_api_params_handler.py +8 -1
  6. massgen/api_params_handler/_gemini_api_params_handler.py +73 -0
  7. massgen/api_params_handler/_response_api_params_handler.py +8 -1
  8. massgen/backend/base.py +31 -0
  9. massgen/backend/{base_with_mcp.py → base_with_custom_tool_and_mcp.py} +282 -11
  10. massgen/backend/chat_completions.py +182 -92
  11. massgen/backend/claude.py +115 -18
  12. massgen/backend/claude_code.py +378 -14
  13. massgen/backend/docs/CLAUDE_API_RESEARCH.md +3 -3
  14. massgen/backend/gemini.py +1275 -1607
  15. massgen/backend/gemini_mcp_manager.py +545 -0
  16. massgen/backend/gemini_trackers.py +344 -0
  17. massgen/backend/gemini_utils.py +43 -0
  18. massgen/backend/response.py +129 -70
  19. massgen/cli.py +643 -132
  20. massgen/config_builder.py +381 -32
  21. massgen/configs/README.md +111 -80
  22. massgen/configs/basic/multi/three_agents_default.yaml +1 -1
  23. massgen/configs/basic/single/single_agent.yaml +1 -1
  24. massgen/configs/providers/openai/gpt5_nano.yaml +3 -3
  25. massgen/configs/tools/custom_tools/claude_code_custom_tool_example.yaml +32 -0
  26. massgen/configs/tools/custom_tools/claude_code_custom_tool_example_no_path.yaml +28 -0
  27. massgen/configs/tools/custom_tools/claude_code_custom_tool_with_mcp_example.yaml +40 -0
  28. massgen/configs/tools/custom_tools/claude_code_custom_tool_with_wrong_mcp_example.yaml +38 -0
  29. massgen/configs/tools/custom_tools/claude_code_wrong_custom_tool_with_mcp_example.yaml +38 -0
  30. massgen/configs/tools/custom_tools/claude_custom_tool_example.yaml +24 -0
  31. massgen/configs/tools/custom_tools/claude_custom_tool_example_no_path.yaml +22 -0
  32. massgen/configs/tools/custom_tools/claude_custom_tool_with_mcp_example.yaml +35 -0
  33. massgen/configs/tools/custom_tools/claude_custom_tool_with_wrong_mcp_example.yaml +33 -0
  34. massgen/configs/tools/custom_tools/claude_wrong_custom_tool_with_mcp_example.yaml +33 -0
  35. massgen/configs/tools/custom_tools/gemini_custom_tool_example.yaml +24 -0
  36. massgen/configs/tools/custom_tools/gemini_custom_tool_example_no_path.yaml +22 -0
  37. massgen/configs/tools/custom_tools/gemini_custom_tool_with_mcp_example.yaml +35 -0
  38. massgen/configs/tools/custom_tools/gemini_custom_tool_with_wrong_mcp_example.yaml +33 -0
  39. massgen/configs/tools/custom_tools/gemini_wrong_custom_tool_with_mcp_example.yaml +33 -0
  40. massgen/configs/tools/custom_tools/github_issue_market_analysis.yaml +94 -0
  41. massgen/configs/tools/custom_tools/gpt5_nano_custom_tool_example.yaml +24 -0
  42. massgen/configs/tools/custom_tools/gpt5_nano_custom_tool_example_no_path.yaml +22 -0
  43. massgen/configs/tools/custom_tools/gpt5_nano_custom_tool_with_mcp_example.yaml +35 -0
  44. massgen/configs/tools/custom_tools/gpt5_nano_custom_tool_with_wrong_mcp_example.yaml +33 -0
  45. massgen/configs/tools/custom_tools/gpt5_nano_wrong_custom_tool_with_mcp_example.yaml +33 -0
  46. massgen/configs/tools/custom_tools/gpt_oss_custom_tool_example.yaml +25 -0
  47. massgen/configs/tools/custom_tools/gpt_oss_custom_tool_example_no_path.yaml +23 -0
  48. massgen/configs/tools/custom_tools/gpt_oss_custom_tool_with_mcp_example.yaml +34 -0
  49. massgen/configs/tools/custom_tools/gpt_oss_custom_tool_with_wrong_mcp_example.yaml +34 -0
  50. massgen/configs/tools/custom_tools/gpt_oss_wrong_custom_tool_with_mcp_example.yaml +34 -0
  51. massgen/configs/tools/custom_tools/grok3_mini_custom_tool_example.yaml +24 -0
  52. massgen/configs/tools/custom_tools/grok3_mini_custom_tool_example_no_path.yaml +22 -0
  53. massgen/configs/tools/custom_tools/grok3_mini_custom_tool_with_mcp_example.yaml +35 -0
  54. massgen/configs/tools/custom_tools/grok3_mini_custom_tool_with_wrong_mcp_example.yaml +33 -0
  55. massgen/configs/tools/custom_tools/grok3_mini_wrong_custom_tool_with_mcp_example.yaml +33 -0
  56. massgen/configs/tools/custom_tools/qwen_api_custom_tool_example.yaml +25 -0
  57. massgen/configs/tools/custom_tools/qwen_api_custom_tool_example_no_path.yaml +23 -0
  58. massgen/configs/tools/custom_tools/qwen_api_custom_tool_with_mcp_example.yaml +36 -0
  59. massgen/configs/tools/custom_tools/qwen_api_custom_tool_with_wrong_mcp_example.yaml +34 -0
  60. massgen/configs/tools/custom_tools/qwen_api_wrong_custom_tool_with_mcp_example.yaml +34 -0
  61. massgen/configs/tools/custom_tools/qwen_local_custom_tool_example.yaml +24 -0
  62. massgen/configs/tools/custom_tools/qwen_local_custom_tool_example_no_path.yaml +22 -0
  63. massgen/configs/tools/custom_tools/qwen_local_custom_tool_with_mcp_example.yaml +35 -0
  64. massgen/configs/tools/custom_tools/qwen_local_custom_tool_with_wrong_mcp_example.yaml +33 -0
  65. massgen/configs/tools/custom_tools/qwen_local_wrong_custom_tool_with_mcp_example.yaml +33 -0
  66. massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +1 -1
  67. massgen/configs/voting/gemini_gpt_voting_sensitivity.yaml +67 -0
  68. massgen/formatter/_chat_completions_formatter.py +104 -0
  69. massgen/formatter/_claude_formatter.py +120 -0
  70. massgen/formatter/_gemini_formatter.py +448 -0
  71. massgen/formatter/_response_formatter.py +88 -0
  72. massgen/frontend/coordination_ui.py +4 -2
  73. massgen/logger_config.py +35 -3
  74. massgen/message_templates.py +56 -6
  75. massgen/orchestrator.py +179 -10
  76. massgen/stream_chunk/base.py +3 -0
  77. massgen/tests/custom_tools_example.py +392 -0
  78. massgen/tests/mcp_test_server.py +17 -7
  79. massgen/tests/test_config_builder.py +423 -0
  80. massgen/tests/test_custom_tools.py +401 -0
  81. massgen/tests/test_tools.py +127 -0
  82. massgen/tool/README.md +935 -0
  83. massgen/tool/__init__.py +39 -0
  84. massgen/tool/_async_helpers.py +70 -0
  85. massgen/tool/_basic/__init__.py +8 -0
  86. massgen/tool/_basic/_two_num_tool.py +24 -0
  87. massgen/tool/_code_executors/__init__.py +10 -0
  88. massgen/tool/_code_executors/_python_executor.py +74 -0
  89. massgen/tool/_code_executors/_shell_executor.py +61 -0
  90. massgen/tool/_exceptions.py +39 -0
  91. massgen/tool/_file_handlers/__init__.py +10 -0
  92. massgen/tool/_file_handlers/_file_operations.py +218 -0
  93. massgen/tool/_manager.py +634 -0
  94. massgen/tool/_registered_tool.py +88 -0
  95. massgen/tool/_result.py +66 -0
  96. massgen/tool/_self_evolution/_github_issue_analyzer.py +369 -0
  97. massgen/tool/docs/builtin_tools.md +681 -0
  98. massgen/tool/docs/exceptions.md +794 -0
  99. massgen/tool/docs/execution_results.md +691 -0
  100. massgen/tool/docs/manager.md +887 -0
  101. massgen/tool/docs/workflow_toolkits.md +529 -0
  102. massgen/tool/workflow_toolkits/__init__.py +57 -0
  103. massgen/tool/workflow_toolkits/base.py +55 -0
  104. massgen/tool/workflow_toolkits/new_answer.py +126 -0
  105. massgen/tool/workflow_toolkits/vote.py +167 -0
  106. {massgen-0.1.0a2.dist-info → massgen-0.1.1.dist-info}/METADATA +89 -131
  107. {massgen-0.1.0a2.dist-info → massgen-0.1.1.dist-info}/RECORD +111 -36
  108. {massgen-0.1.0a2.dist-info → massgen-0.1.1.dist-info}/WHEEL +0 -0
  109. {massgen-0.1.0a2.dist-info → massgen-0.1.1.dist-info}/entry_points.txt +0 -0
  110. {massgen-0.1.0a2.dist-info → massgen-0.1.1.dist-info}/licenses/LICENSE +0 -0
  111. {massgen-0.1.0a2.dist-info → massgen-0.1.1.dist-info}/top_level.txt +0 -0
@@ -258,6 +258,110 @@ class ChatCompletionsFormatter(FormatterBase):
258
258
 
259
259
  return converted_tools
260
260
 
261
+ def format_custom_tools(self, custom_tools: Dict[str, Any]) -> List[Dict[str, Any]]:
262
+ """
263
+ Convert custom tools from RegisteredToolEntry format to Chat Completions API format.
264
+
265
+ Custom tools are provided as a dictionary where:
266
+ - Keys are tool names (str)
267
+ - Values are RegisteredToolEntry objects with:
268
+ - tool_name: str
269
+ - schema_def: dict with structure {"type": "function", "function": {...}}
270
+ - get_extended_schema: property that returns the schema with extensions
271
+
272
+ Chat Completions API expects: {"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}
273
+
274
+ Args:
275
+ custom_tools: Dictionary of tool_name -> RegisteredToolEntry objects
276
+
277
+ Returns:
278
+ List of tools in Chat Completions API format
279
+ """
280
+ if not custom_tools:
281
+ return []
282
+
283
+ converted_tools = []
284
+
285
+ # Handle dictionary format: {tool_name: RegisteredToolEntry, ...}
286
+ if isinstance(custom_tools, dict):
287
+ for tool_name, tool_entry in custom_tools.items():
288
+ # Check if it's a RegisteredToolEntry object with schema_def
289
+ if hasattr(tool_entry, "schema_def"):
290
+ tool_schema = tool_entry.schema_def
291
+
292
+ # Schema may already be in Chat Completions format
293
+ if tool_schema.get("type") == "function" and "function" in tool_schema:
294
+ # Already in correct format, just append
295
+ converted_tools.append(tool_schema)
296
+ elif tool_schema.get("type") == "function":
297
+ # Response API format, need to wrap in function object
298
+ converted_tools.append(
299
+ {
300
+ "type": "function",
301
+ "function": {
302
+ "name": tool_schema.get("name", tool_entry.tool_name if hasattr(tool_entry, "tool_name") else tool_name),
303
+ "description": tool_schema.get("description", ""),
304
+ "parameters": tool_schema.get("parameters", {}),
305
+ },
306
+ },
307
+ )
308
+ # Check if it has get_extended_schema property
309
+ elif hasattr(tool_entry, "get_extended_schema"):
310
+ tool_schema = tool_entry.get_extended_schema
311
+
312
+ if tool_schema.get("type") == "function" and "function" in tool_schema:
313
+ # Already in correct format
314
+ converted_tools.append(tool_schema)
315
+ elif tool_schema.get("type") == "function":
316
+ # Response API format, need to wrap
317
+ converted_tools.append(
318
+ {
319
+ "type": "function",
320
+ "function": {
321
+ "name": tool_schema.get("name", tool_entry.tool_name if hasattr(tool_entry, "tool_name") else tool_name),
322
+ "description": tool_schema.get("description", ""),
323
+ "parameters": tool_schema.get("parameters", {}),
324
+ },
325
+ },
326
+ )
327
+ # Handle list format for backward compatibility
328
+ elif isinstance(custom_tools, list):
329
+ for tool in custom_tools:
330
+ if hasattr(tool, "schema_def"):
331
+ tool_schema = tool.schema_def
332
+
333
+ if tool_schema.get("type") == "function" and "function" in tool_schema:
334
+ converted_tools.append(tool_schema)
335
+ elif tool_schema.get("type") == "function":
336
+ converted_tools.append(
337
+ {
338
+ "type": "function",
339
+ "function": {
340
+ "name": tool_schema.get("name", tool.tool_name),
341
+ "description": tool_schema.get("description", ""),
342
+ "parameters": tool_schema.get("parameters", {}),
343
+ },
344
+ },
345
+ )
346
+ elif hasattr(tool, "get_extended_schema"):
347
+ tool_schema = tool.get_extended_schema
348
+
349
+ if tool_schema.get("type") == "function" and "function" in tool_schema:
350
+ converted_tools.append(tool_schema)
351
+ elif tool_schema.get("type") == "function":
352
+ converted_tools.append(
353
+ {
354
+ "type": "function",
355
+ "function": {
356
+ "name": tool_schema.get("name", tool.tool_name),
357
+ "description": tool_schema.get("description", ""),
358
+ "parameters": tool_schema.get("parameters", {}),
359
+ },
360
+ },
361
+ )
362
+
363
+ return converted_tools
364
+
261
365
  def format_mcp_tools(self, mcp_functions: Dict[str, Any]) -> List[Dict[str, Any]]:
262
366
  """Convert MCP tools to Chat Completions format."""
263
367
  if not mcp_functions:
@@ -233,3 +233,123 @@ class ClaudeFormatter(FormatterBase):
233
233
  converted_tools.append(tool)
234
234
 
235
235
  return converted_tools
236
+
237
+ def format_custom_tools(self, custom_tools: Dict[str, Any]) -> List[Dict[str, Any]]:
238
+ """
239
+ Convert custom tools from RegisteredToolEntry format to Claude's custom tool format.
240
+
241
+ Custom tools are provided as a dictionary where:
242
+ - Keys are tool names (str)
243
+ - Values are RegisteredToolEntry objects with:
244
+ - tool_name: str
245
+ - schema_def: dict with structure {"type": "function", "function": {...}}
246
+ - get_extended_schema: property that returns the schema with extensions
247
+
248
+ Claude expects: {"type": "custom", "name": ..., "description": ..., "input_schema": ...}
249
+
250
+ Args:
251
+ custom_tools: Dictionary of tool_name -> RegisteredToolEntry objects
252
+
253
+ Returns:
254
+ List of tools in Claude's custom tool format
255
+ """
256
+ if not custom_tools:
257
+ return []
258
+
259
+ converted_tools = []
260
+
261
+ # Handle dictionary format: {tool_name: RegisteredToolEntry, ...}
262
+ if isinstance(custom_tools, dict):
263
+ for tool_name, tool_entry in custom_tools.items():
264
+ # Check if it's a RegisteredToolEntry object with schema_def
265
+ if hasattr(tool_entry, "schema_def"):
266
+ tool_schema = tool_entry.schema_def
267
+
268
+ # Extract function details from Chat Completions format
269
+ if tool_schema.get("type") == "function" and "function" in tool_schema:
270
+ func = tool_schema["function"]
271
+ converted_tools.append(
272
+ {
273
+ "type": "custom",
274
+ "name": func.get("name", tool_entry.tool_name if hasattr(tool_entry, "tool_name") else tool_name),
275
+ "description": func.get("description", ""),
276
+ "input_schema": func.get("parameters", {}),
277
+ },
278
+ )
279
+ elif tool_schema.get("type") == "function":
280
+ # Response API format - already has name, description, parameters at top level
281
+ converted_tools.append(
282
+ {
283
+ "type": "custom",
284
+ "name": tool_schema.get("name", tool_entry.tool_name if hasattr(tool_entry, "tool_name") else tool_name),
285
+ "description": tool_schema.get("description", ""),
286
+ "input_schema": tool_schema.get("parameters", {}),
287
+ },
288
+ )
289
+ else:
290
+ # Unknown format, try to extract what we can
291
+ converted_tools.append(
292
+ {
293
+ "type": "custom",
294
+ "name": tool_entry.tool_name if hasattr(tool_entry, "tool_name") else tool_name,
295
+ "description": tool_schema.get("description", ""),
296
+ "input_schema": tool_schema.get("parameters", {}),
297
+ },
298
+ )
299
+ # Handle direct schema format (for backward compatibility)
300
+ elif isinstance(tool_entry, dict):
301
+ if tool_entry.get("type") == "function" and "function" in tool_entry:
302
+ # Chat Completions format
303
+ func = tool_entry["function"]
304
+ converted_tools.append(
305
+ {
306
+ "type": "custom",
307
+ "name": func.get("name", tool_name),
308
+ "description": func.get("description", ""),
309
+ "input_schema": func.get("parameters", {}),
310
+ },
311
+ )
312
+ elif tool_entry.get("type") == "function":
313
+ # Response API format
314
+ converted_tools.append(
315
+ {
316
+ "type": "custom",
317
+ "name": tool_entry.get("name", tool_name),
318
+ "description": tool_entry.get("description", ""),
319
+ "input_schema": tool_entry.get("parameters", {}),
320
+ },
321
+ )
322
+ else:
323
+ # Already in Claude format or unknown
324
+ converted_tools.append(tool_entry)
325
+
326
+ # Handle list format (if custom_tools is already a list)
327
+ elif isinstance(custom_tools, list):
328
+ for tool in custom_tools:
329
+ if isinstance(tool, dict):
330
+ if tool.get("type") == "function" and "function" in tool:
331
+ # Chat Completions format
332
+ func = tool["function"]
333
+ converted_tools.append(
334
+ {
335
+ "type": "custom",
336
+ "name": func.get("name", ""),
337
+ "description": func.get("description", ""),
338
+ "input_schema": func.get("parameters", {}),
339
+ },
340
+ )
341
+ elif tool.get("type") == "function":
342
+ # Response API format
343
+ converted_tools.append(
344
+ {
345
+ "type": "custom",
346
+ "name": tool.get("name", ""),
347
+ "description": tool.get("description", ""),
348
+ "input_schema": tool.get("parameters", {}),
349
+ },
350
+ )
351
+ else:
352
+ # Already in Claude format or unknown
353
+ converted_tools.append(tool)
354
+
355
+ return converted_tools
@@ -0,0 +1,448 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Gemini formatter for message formatting, coordination prompts, and structured output parsing.
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ import re
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from ._formatter_base import FormatterBase
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class GeminiFormatter(FormatterBase):
17
+ def format_messages(self, messages: List[Dict[str, Any]]) -> str:
18
+ """
19
+ Build conversation content string from message history.
20
+
21
+ Behavior mirrors the formatting used previously in the Gemini backend:
22
+ - System messages buffered separately then prepended.
23
+ - User => "User: {content}"
24
+ - Assistant => "Assistant: {content}"
25
+ - Tool => "Tool Result: {content}"
26
+ """
27
+ conversation_content = ""
28
+ system_message = ""
29
+
30
+ for msg in messages:
31
+ role = msg.get("role")
32
+ if role == "system":
33
+ system_message = msg.get("content", "")
34
+ elif role == "user":
35
+ conversation_content += f"User: {msg.get('content', '')}\n"
36
+ elif role == "assistant":
37
+ conversation_content += f"Assistant: {msg.get('content', '')}\n"
38
+ elif role == "tool":
39
+ tool_output = msg.get("content", "")
40
+ conversation_content += f"Tool Result: {tool_output}\n"
41
+
42
+ # Combine system message and conversation
43
+ full_content = ""
44
+ if system_message:
45
+ full_content += f"{system_message}\n\n"
46
+ full_content += conversation_content
47
+ return full_content
48
+
49
+ def format_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
50
+ """
51
+ Gemini uses SDK-native tool format, not reformatting.
52
+ """
53
+ return tools or []
54
+
55
+ def format_mcp_tools(self, mcp_functions: Dict[str, Any]) -> List[Dict[str, Any]]:
56
+ """
57
+ MCP tools are passed via SDK sessions in stream_with_tools; not function declarations.
58
+ """
59
+ return []
60
+
61
+ # Coordination helpers
62
+
63
+ def has_coordination_tools(self, tools: List[Dict[str, Any]]) -> bool:
64
+ """Detect if tools contain vote/new_answer coordination tools."""
65
+ if not tools:
66
+ return False
67
+
68
+ tool_names = set()
69
+ for tool in tools:
70
+ if tool.get("type") == "function":
71
+ if "function" in tool:
72
+ tool_names.add(tool["function"].get("name", ""))
73
+ elif "name" in tool:
74
+ tool_names.add(tool.get("name", ""))
75
+
76
+ return "vote" in tool_names and "new_answer" in tool_names
77
+
78
+ def build_structured_output_prompt(self, base_content: str, valid_agent_ids: Optional[List[str]] = None) -> str:
79
+ """Build prompt that encourages structured output for coordination."""
80
+ agent_list = ""
81
+ if valid_agent_ids:
82
+ agent_list = f"Valid agents: {', '.join(valid_agent_ids)}"
83
+
84
+ return f"""{base_content}
85
+
86
+ IMPORTANT: You must respond with a structured JSON decision at the end of your response.
87
+
88
+ If you want to VOTE for an existing agent's answer:
89
+ {{
90
+ "action_type": "vote",
91
+ "vote_data": {{
92
+ "action": "vote",
93
+ "agent_id": "agent1", // Choose from: {agent_list or "agent1, agent2, agent3, etc."}
94
+ "reason": "Brief reason for your vote"
95
+ }}
96
+ }}
97
+
98
+ If you want to provide a NEW ANSWER:
99
+ {{
100
+ "action_type": "new_answer",
101
+ "answer_data": {{
102
+ "action": "new_answer",
103
+ "content": "Your complete improved answer here"
104
+ }}
105
+ }}
106
+
107
+ Make your decision and include the JSON at the very end of your response."""
108
+
109
+ def extract_structured_response(self, response_text: str) -> Optional[Dict[str, Any]]:
110
+ """Extract structured JSON response from model output."""
111
+ try:
112
+ # Strategy 0: Look for JSON inside markdown code blocks first
113
+ markdown_json_pattern = r"```json\s*(\{.*?\})\s*```"
114
+ markdown_matches = re.findall(markdown_json_pattern, response_text, re.DOTALL)
115
+
116
+ for match in reversed(markdown_matches):
117
+ try:
118
+ parsed = json.loads(match.strip())
119
+ if isinstance(parsed, dict) and "action_type" in parsed:
120
+ return parsed
121
+ except json.JSONDecodeError:
122
+ continue
123
+
124
+ # Strategy 1: Look for complete JSON blocks with proper braces
125
+ json_pattern = r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}"
126
+ json_matches = re.findall(json_pattern, response_text, re.DOTALL)
127
+
128
+ # Try parsing each match (in reverse order - last one first)
129
+ for match in reversed(json_matches):
130
+ try:
131
+ cleaned_match = match.strip()
132
+ parsed = json.loads(cleaned_match)
133
+ if isinstance(parsed, dict) and "action_type" in parsed:
134
+ return parsed
135
+ except json.JSONDecodeError:
136
+ continue
137
+
138
+ # Strategy 2: Look for JSON blocks with nested braces (more complex)
139
+ brace_count = 0
140
+ json_start = -1
141
+
142
+ for i, char in enumerate(response_text):
143
+ if char == "{":
144
+ if brace_count == 0:
145
+ json_start = i
146
+ brace_count += 1
147
+ elif char == "}":
148
+ brace_count -= 1
149
+ if brace_count == 0 and json_start >= 0:
150
+ # Found a complete JSON block
151
+ json_block = response_text[json_start : i + 1]
152
+ try:
153
+ parsed = json.loads(json_block)
154
+ if isinstance(parsed, dict) and "action_type" in parsed:
155
+ return parsed
156
+ except json.JSONDecodeError:
157
+ pass
158
+ json_start = -1
159
+
160
+ # Strategy 3: Line-by-line approach (fallback)
161
+ lines = response_text.strip().split("\n")
162
+ json_candidates = []
163
+
164
+ for i, line in enumerate(lines):
165
+ stripped = line.strip()
166
+ if stripped.startswith("{") and stripped.endswith("}"):
167
+ json_candidates.append(stripped)
168
+ elif stripped.startswith("{"):
169
+ # Multi-line JSON - collect until closing brace
170
+ json_text = stripped
171
+ for j in range(i + 1, len(lines)):
172
+ json_text += "\n" + lines[j].strip()
173
+ if lines[j].strip().endswith("}"):
174
+ json_candidates.append(json_text)
175
+ break
176
+
177
+ # Try to parse each candidate
178
+ for candidate in reversed(json_candidates):
179
+ try:
180
+ parsed = json.loads(candidate)
181
+ if isinstance(parsed, dict) and "action_type" in parsed:
182
+ return parsed
183
+ except json.JSONDecodeError:
184
+ continue
185
+
186
+ return None
187
+
188
+ except Exception:
189
+ return None
190
+
191
+ def convert_structured_to_tool_calls(self, structured_response: Dict[str, Any]) -> List[Dict[str, Any]]:
192
+ """Convert structured response to tool call format."""
193
+ action_type = structured_response.get("action_type")
194
+
195
+ if action_type == "vote":
196
+ vote_data = structured_response.get("vote_data", {})
197
+ return [
198
+ {
199
+ "id": f"vote_{abs(hash(str(vote_data))) % 10000 + 1}",
200
+ "type": "function",
201
+ "function": {
202
+ "name": "vote",
203
+ "arguments": {
204
+ "agent_id": vote_data.get("agent_id", ""),
205
+ "reason": vote_data.get("reason", ""),
206
+ },
207
+ },
208
+ },
209
+ ]
210
+
211
+ elif action_type == "new_answer":
212
+ answer_data = structured_response.get("answer_data", {})
213
+ return [
214
+ {
215
+ "id": f"new_answer_{abs(hash(str(answer_data))) % 10000 + 1}",
216
+ "type": "function",
217
+ "function": {
218
+ "name": "new_answer",
219
+ "arguments": {"content": answer_data.get("content", "")},
220
+ },
221
+ },
222
+ ]
223
+
224
+ return []
225
+
226
+ # Custom tools formatting for Gemini
227
+
228
+ def format_custom_tools(
229
+ self,
230
+ custom_tools: List[Dict[str, Any]],
231
+ return_sdk_objects: bool = True,
232
+ ) -> List[Any]:
233
+ """
234
+ Convert custom tools from OpenAI Chat Completions format to Gemini format.
235
+
236
+ Can return either SDK FunctionDeclaration objects (default) or Gemini-format dictionaries.
237
+
238
+ OpenAI format:
239
+ [{"type": "function", "function": {"name": ..., "description": ..., "parameters": {...}}}]
240
+
241
+ Gemini dictionary format:
242
+ [{"name": ..., "description": ..., "parameters": {...}}]
243
+
244
+ Gemini SDK format:
245
+ [FunctionDeclaration(name=..., description=..., parameters=Schema(...))]
246
+
247
+ Args:
248
+ custom_tools: List of tools in OpenAI Chat Completions format
249
+ return_sdk_objects: If True, return FunctionDeclaration objects;
250
+ if False, return Gemini-format dictionaries
251
+
252
+ Returns:
253
+ List of tools in Gemini SDK format (default) or dictionary format
254
+ """
255
+ if not custom_tools:
256
+ return []
257
+
258
+ # Step 1: Convert to Gemini dictionary format
259
+ gemini_dicts = self._convert_to_gemini_dict_format(custom_tools)
260
+
261
+ if not return_sdk_objects:
262
+ return gemini_dicts
263
+
264
+ # Step 2: Convert dictionaries to SDK FunctionDeclaration objects
265
+ return self._convert_to_function_declarations(gemini_dicts)
266
+
267
+ def _convert_to_gemini_dict_format(self, custom_tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
268
+ """
269
+ Convert OpenAI format to Gemini dictionary format (intermediate step).
270
+
271
+ Args:
272
+ custom_tools: List of tools in OpenAI Chat Completions format
273
+
274
+ Returns:
275
+ List of tools in Gemini-compatible dictionary format
276
+ """
277
+ if not custom_tools:
278
+ return []
279
+
280
+ converted_tools = []
281
+
282
+ for tool in custom_tools:
283
+ # Handle OpenAI Chat Completions format with type="function" wrapper
284
+ if isinstance(tool, dict) and tool.get("type") == "function" and "function" in tool:
285
+ func_def = tool["function"]
286
+ converted_tool = {
287
+ "name": func_def.get("name", ""),
288
+ "description": func_def.get("description", ""),
289
+ "parameters": func_def.get("parameters", {}),
290
+ }
291
+ converted_tools.append(converted_tool)
292
+ # Handle already-converted Gemini format (idempotent)
293
+ elif isinstance(tool, dict) and "name" in tool and "parameters" in tool:
294
+ # Already in Gemini format, pass through
295
+ converted_tools.append(tool)
296
+ else:
297
+ # Skip unrecognized formats
298
+ logger.warning(f"[GeminiFormatter] Skipping unrecognized tool format: {tool}")
299
+
300
+ return converted_tools
301
+
302
+ def _convert_to_function_declarations(self, tools_dicts: List[Dict[str, Any]]) -> List[Any]:
303
+ """
304
+ Convert Gemini-format tool dictionaries to FunctionDeclaration objects.
305
+
306
+ Args:
307
+ tools_dicts: List of tool dictionaries in Gemini format
308
+ [{"name": ..., "description": ..., "parameters": {...}}]
309
+
310
+ Returns:
311
+ List of google.genai.types.FunctionDeclaration objects
312
+ """
313
+ if not tools_dicts:
314
+ return []
315
+
316
+ try:
317
+ from google.genai import types
318
+ except ImportError:
319
+ logger.error("[GeminiFormatter] Cannot import google.genai.types for FunctionDeclaration")
320
+ logger.error("[GeminiFormatter] Falling back to dictionary format")
321
+ return tools_dicts # Fallback to dict format
322
+
323
+ function_declarations = []
324
+
325
+ for tool_dict in tools_dicts:
326
+ try:
327
+ # Create Schema object for parameters
328
+ params = tool_dict.get("parameters", {})
329
+
330
+ # Convert parameters to Schema object (recursive)
331
+ schema = self._build_schema_recursive(params)
332
+
333
+ # Create FunctionDeclaration object
334
+ func_decl = types.FunctionDeclaration(
335
+ name=tool_dict.get("name", ""),
336
+ description=tool_dict.get("description", ""),
337
+ parameters=schema,
338
+ )
339
+
340
+ function_declarations.append(func_decl)
341
+
342
+ logger.debug(
343
+ f"[GeminiFormatter] Converted tool '{tool_dict.get('name')}' " f"to FunctionDeclaration",
344
+ )
345
+
346
+ except Exception as e:
347
+ logger.error(
348
+ f"[GeminiFormatter] Failed to convert tool to FunctionDeclaration: {e}",
349
+ )
350
+ logger.error(f"[GeminiFormatter] Tool dict: {tool_dict}")
351
+ # Continue processing other tools instead of failing completely
352
+ continue
353
+
354
+ return function_declarations
355
+
356
+ def _build_schema_recursive(self, param_schema: Dict[str, Any]) -> Any:
357
+ """
358
+ Recursively build a Gemini Schema object from JSON Schema format.
359
+
360
+ Handles nested objects, arrays, and all standard JSON Schema types.
361
+
362
+ Args:
363
+ param_schema: JSON Schema dictionary (may be nested)
364
+
365
+ Returns:
366
+ google.genai.types.Schema object
367
+ """
368
+ try:
369
+ from google.genai import types
370
+ except ImportError:
371
+ logger.error("[GeminiFormatter] Cannot import google.genai.types")
372
+ return None
373
+
374
+ # Get the type (default to "object" for top-level parameters)
375
+ param_type = param_schema.get("type", "object")
376
+ gemini_type = self._convert_json_type_to_gemini_type(param_type)
377
+
378
+ # Build base schema kwargs
379
+ schema_kwargs = {
380
+ "type": gemini_type,
381
+ }
382
+
383
+ # Add description if present
384
+ if "description" in param_schema:
385
+ schema_kwargs["description"] = param_schema["description"]
386
+
387
+ # Handle object type with nested properties
388
+ if param_type == "object" and "properties" in param_schema:
389
+ schema_kwargs["properties"] = {prop_name: self._build_schema_recursive(prop_schema) for prop_name, prop_schema in param_schema["properties"].items()}
390
+
391
+ # Add required fields if present
392
+ if "required" in param_schema:
393
+ schema_kwargs["required"] = param_schema["required"]
394
+
395
+ # Handle array type with items
396
+ elif param_type == "array" and "items" in param_schema:
397
+ schema_kwargs["items"] = self._build_schema_recursive(param_schema["items"])
398
+
399
+ # Handle enum if present (for string/number types)
400
+ if "enum" in param_schema:
401
+ schema_kwargs["enum"] = param_schema["enum"]
402
+
403
+ # Handle format if present (e.g., "date-time", "email", etc.)
404
+ if "format" in param_schema:
405
+ schema_kwargs["format"] = param_schema["format"]
406
+
407
+ # Handle nullable if present
408
+ if "nullable" in param_schema:
409
+ schema_kwargs["nullable"] = param_schema["nullable"]
410
+
411
+ return types.Schema(**schema_kwargs)
412
+
413
+ def _convert_json_type_to_gemini_type(self, json_type: str) -> Any:
414
+ """
415
+ Convert JSON Schema type string to Gemini Type enum.
416
+
417
+ Args:
418
+ json_type: JSON Schema type like "string", "number", "integer", etc.
419
+
420
+ Returns:
421
+ Corresponding google.genai.types.Type enum value
422
+ """
423
+ try:
424
+ from google.genai import types
425
+ except ImportError:
426
+ # If we can't import, return string as fallback
427
+ # This shouldn't happen in practice since _build_schema_recursive checks first
428
+ return "STRING"
429
+
430
+ # Map JSON Schema types to Gemini Type enum
431
+ type_mapping = {
432
+ "string": types.Type.STRING,
433
+ "number": types.Type.NUMBER,
434
+ "integer": types.Type.INTEGER,
435
+ "boolean": types.Type.BOOLEAN,
436
+ "array": types.Type.ARRAY,
437
+ "object": types.Type.OBJECT,
438
+ }
439
+
440
+ # Return mapped type or default to STRING
441
+ gemini_type = type_mapping.get(json_type.lower(), types.Type.STRING)
442
+
443
+ if json_type.lower() not in type_mapping:
444
+ logger.warning(
445
+ f"[GeminiFormatter] Unknown JSON type '{json_type}', defaulting to STRING",
446
+ )
447
+
448
+ return gemini_type