massgen 0.0.3__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (268) hide show
  1. massgen/__init__.py +142 -8
  2. massgen/adapters/__init__.py +29 -0
  3. massgen/adapters/ag2_adapter.py +483 -0
  4. massgen/adapters/base.py +183 -0
  5. massgen/adapters/tests/__init__.py +0 -0
  6. massgen/adapters/tests/test_ag2_adapter.py +439 -0
  7. massgen/adapters/tests/test_agent_adapter.py +128 -0
  8. massgen/adapters/utils/__init__.py +2 -0
  9. massgen/adapters/utils/ag2_utils.py +236 -0
  10. massgen/adapters/utils/tests/__init__.py +0 -0
  11. massgen/adapters/utils/tests/test_ag2_utils.py +138 -0
  12. massgen/agent_config.py +329 -55
  13. massgen/api_params_handler/__init__.py +10 -0
  14. massgen/api_params_handler/_api_params_handler_base.py +99 -0
  15. massgen/api_params_handler/_chat_completions_api_params_handler.py +176 -0
  16. massgen/api_params_handler/_claude_api_params_handler.py +113 -0
  17. massgen/api_params_handler/_response_api_params_handler.py +130 -0
  18. massgen/backend/__init__.py +39 -4
  19. massgen/backend/azure_openai.py +385 -0
  20. massgen/backend/base.py +341 -69
  21. massgen/backend/base_with_mcp.py +1102 -0
  22. massgen/backend/capabilities.py +386 -0
  23. massgen/backend/chat_completions.py +577 -130
  24. massgen/backend/claude.py +1033 -537
  25. massgen/backend/claude_code.py +1203 -0
  26. massgen/backend/cli_base.py +209 -0
  27. massgen/backend/docs/BACKEND_ARCHITECTURE.md +126 -0
  28. massgen/backend/{CLAUDE_API_RESEARCH.md → docs/CLAUDE_API_RESEARCH.md} +18 -18
  29. massgen/backend/{GEMINI_API_DOCUMENTATION.md → docs/GEMINI_API_DOCUMENTATION.md} +9 -9
  30. massgen/backend/docs/Gemini MCP Integration Analysis.md +1050 -0
  31. massgen/backend/docs/MCP_IMPLEMENTATION_CLAUDE_BACKEND.md +177 -0
  32. massgen/backend/docs/MCP_INTEGRATION_RESPONSE_BACKEND.md +352 -0
  33. massgen/backend/docs/OPENAI_GPT5_MODELS.md +211 -0
  34. massgen/backend/{OPENAI_RESPONSES_API_FORMAT.md → docs/OPENAI_RESPONSE_API_TOOL_CALLS.md} +3 -3
  35. massgen/backend/docs/OPENAI_response_streaming.md +20654 -0
  36. massgen/backend/docs/inference_backend.md +257 -0
  37. massgen/backend/docs/permissions_and_context_files.md +1085 -0
  38. massgen/backend/external.py +126 -0
  39. massgen/backend/gemini.py +1850 -241
  40. massgen/backend/grok.py +40 -156
  41. massgen/backend/inference.py +156 -0
  42. massgen/backend/lmstudio.py +171 -0
  43. massgen/backend/response.py +1095 -322
  44. massgen/chat_agent.py +131 -113
  45. massgen/cli.py +1560 -275
  46. massgen/config_builder.py +2396 -0
  47. massgen/configs/BACKEND_CONFIGURATION.md +458 -0
  48. massgen/configs/README.md +559 -216
  49. massgen/configs/ag2/ag2_case_study.yaml +27 -0
  50. massgen/configs/ag2/ag2_coder.yaml +34 -0
  51. massgen/configs/ag2/ag2_coder_case_study.yaml +36 -0
  52. massgen/configs/ag2/ag2_gemini.yaml +27 -0
  53. massgen/configs/ag2/ag2_groupchat.yaml +108 -0
  54. massgen/configs/ag2/ag2_groupchat_gpt.yaml +118 -0
  55. massgen/configs/ag2/ag2_single_agent.yaml +21 -0
  56. massgen/configs/basic/multi/fast_timeout_example.yaml +37 -0
  57. massgen/configs/basic/multi/gemini_4o_claude.yaml +31 -0
  58. massgen/configs/basic/multi/gemini_gpt5nano_claude.yaml +36 -0
  59. massgen/configs/{gemini_4o_claude.yaml → basic/multi/geminicode_4o_claude.yaml} +3 -3
  60. massgen/configs/basic/multi/geminicode_gpt5nano_claude.yaml +36 -0
  61. massgen/configs/basic/multi/glm_gemini_claude.yaml +25 -0
  62. massgen/configs/basic/multi/gpt4o_audio_generation.yaml +30 -0
  63. massgen/configs/basic/multi/gpt4o_image_generation.yaml +31 -0
  64. massgen/configs/basic/multi/gpt5nano_glm_qwen.yaml +26 -0
  65. massgen/configs/basic/multi/gpt5nano_image_understanding.yaml +26 -0
  66. massgen/configs/{three_agents_default.yaml → basic/multi/three_agents_default.yaml} +8 -4
  67. massgen/configs/basic/multi/three_agents_opensource.yaml +27 -0
  68. massgen/configs/basic/multi/three_agents_vllm.yaml +20 -0
  69. massgen/configs/basic/multi/two_agents_gemini.yaml +19 -0
  70. massgen/configs/{two_agents.yaml → basic/multi/two_agents_gpt5.yaml} +14 -6
  71. massgen/configs/basic/multi/two_agents_opensource_lmstudio.yaml +31 -0
  72. massgen/configs/basic/multi/two_qwen_vllm_sglang.yaml +28 -0
  73. massgen/configs/{single_agent.yaml → basic/single/single_agent.yaml} +1 -1
  74. massgen/configs/{single_flash2.5.yaml → basic/single/single_flash2.5.yaml} +1 -2
  75. massgen/configs/basic/single/single_gemini2.5pro.yaml +16 -0
  76. massgen/configs/basic/single/single_gpt4o_audio_generation.yaml +22 -0
  77. massgen/configs/basic/single/single_gpt4o_image_generation.yaml +22 -0
  78. massgen/configs/basic/single/single_gpt4o_video_generation.yaml +24 -0
  79. massgen/configs/basic/single/single_gpt5nano.yaml +20 -0
  80. massgen/configs/basic/single/single_gpt5nano_file_search.yaml +18 -0
  81. massgen/configs/basic/single/single_gpt5nano_image_understanding.yaml +17 -0
  82. massgen/configs/basic/single/single_gptoss120b.yaml +15 -0
  83. massgen/configs/basic/single/single_openrouter_audio_understanding.yaml +15 -0
  84. massgen/configs/basic/single/single_qwen_video_understanding.yaml +15 -0
  85. massgen/configs/debug/code_execution/command_filtering_blacklist.yaml +29 -0
  86. massgen/configs/debug/code_execution/command_filtering_whitelist.yaml +28 -0
  87. massgen/configs/debug/code_execution/docker_verification.yaml +29 -0
  88. massgen/configs/debug/skip_coordination_test.yaml +27 -0
  89. massgen/configs/debug/test_sdk_migration.yaml +17 -0
  90. massgen/configs/docs/DISCORD_MCP_SETUP.md +208 -0
  91. massgen/configs/docs/TWITTER_MCP_ENESCINAR_SETUP.md +82 -0
  92. massgen/configs/providers/azure/azure_openai_multi.yaml +21 -0
  93. massgen/configs/providers/azure/azure_openai_single.yaml +19 -0
  94. massgen/configs/providers/claude/claude.yaml +14 -0
  95. massgen/configs/providers/gemini/gemini_gpt5nano.yaml +28 -0
  96. massgen/configs/providers/local/lmstudio.yaml +11 -0
  97. massgen/configs/providers/openai/gpt5.yaml +46 -0
  98. massgen/configs/providers/openai/gpt5_nano.yaml +46 -0
  99. massgen/configs/providers/others/grok_single_agent.yaml +19 -0
  100. massgen/configs/providers/others/zai_coding_team.yaml +108 -0
  101. massgen/configs/providers/others/zai_glm45.yaml +12 -0
  102. massgen/configs/{creative_team.yaml → teams/creative/creative_team.yaml} +16 -6
  103. massgen/configs/{travel_planning.yaml → teams/creative/travel_planning.yaml} +16 -6
  104. massgen/configs/{news_analysis.yaml → teams/research/news_analysis.yaml} +16 -6
  105. massgen/configs/{research_team.yaml → teams/research/research_team.yaml} +15 -7
  106. massgen/configs/{technical_analysis.yaml → teams/research/technical_analysis.yaml} +16 -6
  107. massgen/configs/tools/code-execution/basic_command_execution.yaml +25 -0
  108. massgen/configs/tools/code-execution/code_execution_use_case_simple.yaml +41 -0
  109. massgen/configs/tools/code-execution/docker_claude_code.yaml +32 -0
  110. massgen/configs/tools/code-execution/docker_multi_agent.yaml +32 -0
  111. massgen/configs/tools/code-execution/docker_simple.yaml +29 -0
  112. massgen/configs/tools/code-execution/docker_with_resource_limits.yaml +32 -0
  113. massgen/configs/tools/code-execution/multi_agent_playwright_automation.yaml +57 -0
  114. massgen/configs/tools/filesystem/cc_gpt5_gemini_filesystem.yaml +34 -0
  115. massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +68 -0
  116. massgen/configs/tools/filesystem/claude_code_flash2.5.yaml +43 -0
  117. massgen/configs/tools/filesystem/claude_code_flash2.5_gptoss.yaml +49 -0
  118. massgen/configs/tools/filesystem/claude_code_gpt5nano.yaml +31 -0
  119. massgen/configs/tools/filesystem/claude_code_single.yaml +40 -0
  120. massgen/configs/tools/filesystem/fs_permissions_test.yaml +87 -0
  121. massgen/configs/tools/filesystem/gemini_gemini_workspace_cleanup.yaml +54 -0
  122. massgen/configs/tools/filesystem/gemini_gpt5_filesystem_casestudy.yaml +30 -0
  123. massgen/configs/tools/filesystem/gemini_gpt5nano_file_context_path.yaml +43 -0
  124. massgen/configs/tools/filesystem/gemini_gpt5nano_protected_paths.yaml +45 -0
  125. massgen/configs/tools/filesystem/gpt5mini_cc_fs_context_path.yaml +31 -0
  126. massgen/configs/tools/filesystem/grok4_gpt5_gemini_filesystem.yaml +32 -0
  127. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_claude_code_filesystem_multiturn.yaml +58 -0
  128. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_gemini_filesystem_multiturn.yaml +58 -0
  129. massgen/configs/tools/filesystem/multiturn/two_claude_code_filesystem_multiturn.yaml +47 -0
  130. massgen/configs/tools/filesystem/multiturn/two_gemini_flash_filesystem_multiturn.yaml +48 -0
  131. massgen/configs/tools/mcp/claude_code_discord_mcp_example.yaml +27 -0
  132. massgen/configs/tools/mcp/claude_code_simple_mcp.yaml +35 -0
  133. massgen/configs/tools/mcp/claude_code_twitter_mcp_example.yaml +32 -0
  134. massgen/configs/tools/mcp/claude_mcp_example.yaml +24 -0
  135. massgen/configs/tools/mcp/claude_mcp_test.yaml +27 -0
  136. massgen/configs/tools/mcp/five_agents_travel_mcp_test.yaml +157 -0
  137. massgen/configs/tools/mcp/five_agents_weather_mcp_test.yaml +103 -0
  138. massgen/configs/tools/mcp/gemini_mcp_example.yaml +24 -0
  139. massgen/configs/tools/mcp/gemini_mcp_filesystem_test.yaml +23 -0
  140. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_sharing.yaml +23 -0
  141. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_single_agent.yaml +17 -0
  142. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_with_claude_code.yaml +24 -0
  143. massgen/configs/tools/mcp/gemini_mcp_test.yaml +27 -0
  144. massgen/configs/tools/mcp/gemini_notion_mcp.yaml +52 -0
  145. massgen/configs/tools/mcp/gpt5_nano_mcp_example.yaml +24 -0
  146. massgen/configs/tools/mcp/gpt5_nano_mcp_test.yaml +27 -0
  147. massgen/configs/tools/mcp/gpt5mini_claude_code_discord_mcp_example.yaml +38 -0
  148. massgen/configs/tools/mcp/gpt_oss_mcp_example.yaml +25 -0
  149. massgen/configs/tools/mcp/gpt_oss_mcp_test.yaml +28 -0
  150. massgen/configs/tools/mcp/grok3_mini_mcp_example.yaml +24 -0
  151. massgen/configs/tools/mcp/grok3_mini_mcp_test.yaml +27 -0
  152. massgen/configs/tools/mcp/multimcp_gemini.yaml +111 -0
  153. massgen/configs/tools/mcp/qwen_api_mcp_example.yaml +25 -0
  154. massgen/configs/tools/mcp/qwen_api_mcp_test.yaml +28 -0
  155. massgen/configs/tools/mcp/qwen_local_mcp_example.yaml +24 -0
  156. massgen/configs/tools/mcp/qwen_local_mcp_test.yaml +27 -0
  157. massgen/configs/tools/planning/five_agents_discord_mcp_planning_mode.yaml +140 -0
  158. massgen/configs/tools/planning/five_agents_filesystem_mcp_planning_mode.yaml +151 -0
  159. massgen/configs/tools/planning/five_agents_notion_mcp_planning_mode.yaml +151 -0
  160. massgen/configs/tools/planning/five_agents_twitter_mcp_planning_mode.yaml +155 -0
  161. massgen/configs/tools/planning/gpt5_mini_case_study_mcp_planning_mode.yaml +73 -0
  162. massgen/configs/tools/web-search/claude_streamable_http_test.yaml +43 -0
  163. massgen/configs/tools/web-search/gemini_streamable_http_test.yaml +43 -0
  164. massgen/configs/tools/web-search/gpt5_mini_streamable_http_test.yaml +43 -0
  165. massgen/configs/tools/web-search/gpt_oss_streamable_http_test.yaml +44 -0
  166. massgen/configs/tools/web-search/grok3_mini_streamable_http_test.yaml +43 -0
  167. massgen/configs/tools/web-search/qwen_api_streamable_http_test.yaml +44 -0
  168. massgen/configs/tools/web-search/qwen_local_streamable_http_test.yaml +43 -0
  169. massgen/coordination_tracker.py +708 -0
  170. massgen/docker/README.md +462 -0
  171. massgen/filesystem_manager/__init__.py +21 -0
  172. massgen/filesystem_manager/_base.py +9 -0
  173. massgen/filesystem_manager/_code_execution_server.py +545 -0
  174. massgen/filesystem_manager/_docker_manager.py +477 -0
  175. massgen/filesystem_manager/_file_operation_tracker.py +248 -0
  176. massgen/filesystem_manager/_filesystem_manager.py +813 -0
  177. massgen/filesystem_manager/_path_permission_manager.py +1261 -0
  178. massgen/filesystem_manager/_workspace_tools_server.py +1815 -0
  179. massgen/formatter/__init__.py +10 -0
  180. massgen/formatter/_chat_completions_formatter.py +284 -0
  181. massgen/formatter/_claude_formatter.py +235 -0
  182. massgen/formatter/_formatter_base.py +156 -0
  183. massgen/formatter/_response_formatter.py +263 -0
  184. massgen/frontend/__init__.py +1 -2
  185. massgen/frontend/coordination_ui.py +471 -286
  186. massgen/frontend/displays/base_display.py +56 -11
  187. massgen/frontend/displays/create_coordination_table.py +1956 -0
  188. massgen/frontend/displays/rich_terminal_display.py +1259 -619
  189. massgen/frontend/displays/simple_display.py +9 -4
  190. massgen/frontend/displays/terminal_display.py +27 -68
  191. massgen/logger_config.py +681 -0
  192. massgen/mcp_tools/README.md +232 -0
  193. massgen/mcp_tools/__init__.py +105 -0
  194. massgen/mcp_tools/backend_utils.py +1035 -0
  195. massgen/mcp_tools/circuit_breaker.py +195 -0
  196. massgen/mcp_tools/client.py +894 -0
  197. massgen/mcp_tools/config_validator.py +138 -0
  198. massgen/mcp_tools/docs/circuit_breaker.md +646 -0
  199. massgen/mcp_tools/docs/client.md +950 -0
  200. massgen/mcp_tools/docs/config_validator.md +478 -0
  201. massgen/mcp_tools/docs/exceptions.md +1165 -0
  202. massgen/mcp_tools/docs/security.md +854 -0
  203. massgen/mcp_tools/exceptions.py +338 -0
  204. massgen/mcp_tools/hooks.py +212 -0
  205. massgen/mcp_tools/security.py +780 -0
  206. massgen/message_templates.py +342 -64
  207. massgen/orchestrator.py +1515 -241
  208. massgen/stream_chunk/__init__.py +35 -0
  209. massgen/stream_chunk/base.py +92 -0
  210. massgen/stream_chunk/multimodal.py +237 -0
  211. massgen/stream_chunk/text.py +162 -0
  212. massgen/tests/mcp_test_server.py +150 -0
  213. massgen/tests/multi_turn_conversation_design.md +0 -8
  214. massgen/tests/test_azure_openai_backend.py +156 -0
  215. massgen/tests/test_backend_capabilities.py +262 -0
  216. massgen/tests/test_backend_event_loop_all.py +179 -0
  217. massgen/tests/test_chat_completions_refactor.py +142 -0
  218. massgen/tests/test_claude_backend.py +15 -28
  219. massgen/tests/test_claude_code.py +268 -0
  220. massgen/tests/test_claude_code_context_sharing.py +233 -0
  221. massgen/tests/test_claude_code_orchestrator.py +175 -0
  222. massgen/tests/test_cli_backends.py +180 -0
  223. massgen/tests/test_code_execution.py +679 -0
  224. massgen/tests/test_external_agent_backend.py +134 -0
  225. massgen/tests/test_final_presentation_fallback.py +237 -0
  226. massgen/tests/test_gemini_planning_mode.py +351 -0
  227. massgen/tests/test_grok_backend.py +7 -10
  228. massgen/tests/test_http_mcp_server.py +42 -0
  229. massgen/tests/test_integration_simple.py +198 -0
  230. massgen/tests/test_mcp_blocking.py +125 -0
  231. massgen/tests/test_message_context_building.py +29 -47
  232. massgen/tests/test_orchestrator_final_presentation.py +48 -0
  233. massgen/tests/test_path_permission_manager.py +2087 -0
  234. massgen/tests/test_rich_terminal_display.py +14 -13
  235. massgen/tests/test_timeout.py +133 -0
  236. massgen/tests/test_v3_3agents.py +11 -12
  237. massgen/tests/test_v3_simple.py +8 -13
  238. massgen/tests/test_v3_three_agents.py +11 -18
  239. massgen/tests/test_v3_two_agents.py +8 -13
  240. massgen/token_manager/__init__.py +7 -0
  241. massgen/token_manager/token_manager.py +400 -0
  242. massgen/utils.py +52 -16
  243. massgen/v1/agent.py +45 -91
  244. massgen/v1/agents.py +18 -53
  245. massgen/v1/backends/gemini.py +50 -153
  246. massgen/v1/backends/grok.py +21 -54
  247. massgen/v1/backends/oai.py +39 -111
  248. massgen/v1/cli.py +36 -93
  249. massgen/v1/config.py +8 -12
  250. massgen/v1/logging.py +43 -127
  251. massgen/v1/main.py +18 -32
  252. massgen/v1/orchestrator.py +68 -209
  253. massgen/v1/streaming_display.py +62 -163
  254. massgen/v1/tools.py +8 -12
  255. massgen/v1/types.py +9 -23
  256. massgen/v1/utils.py +5 -23
  257. massgen-0.1.0.dist-info/METADATA +1245 -0
  258. massgen-0.1.0.dist-info/RECORD +273 -0
  259. massgen-0.1.0.dist-info/entry_points.txt +2 -0
  260. massgen/frontend/logging/__init__.py +0 -9
  261. massgen/frontend/logging/realtime_logger.py +0 -197
  262. massgen-0.0.3.dist-info/METADATA +0 -568
  263. massgen-0.0.3.dist-info/RECORD +0 -76
  264. massgen-0.0.3.dist-info/entry_points.txt +0 -2
  265. /massgen/backend/{Function calling openai responses.md → docs/Function calling openai responses.md} +0 -0
  266. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/WHEEL +0 -0
  267. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/licenses/LICENSE +0 -0
  268. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/top_level.txt +0 -0
massgen/backend/claude.py CHANGED
@@ -1,5 +1,4 @@
1
- from __future__ import annotations
2
-
1
+ # -*- coding: utf-8 -*-
3
2
  """
4
3
  Claude backend implementation using Anthropic's Messages API.
5
4
  Production-ready implementation with full multi-tool support.
@@ -20,14 +19,28 @@ Multi-Tool Capabilities:
20
19
  - Parallel and sequential tool execution supported
21
20
  - Perfect integration with MassGen StreamChunk pattern
22
21
  """
22
+ from __future__ import annotations
23
23
 
24
- import os
24
+ import base64
25
+ import binascii
25
26
  import json
26
- from typing import Dict, List, Any, AsyncGenerator, Optional
27
- from .base import LLMBackend, StreamChunk
27
+ import mimetypes
28
+ import os
29
+ from pathlib import Path
30
+ from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, Tuple
31
+
32
+ import anthropic
33
+ import httpx
28
34
 
35
+ from ..api_params_handler import ClaudeAPIParamsHandler
36
+ from ..formatter import ClaudeFormatter
37
+ from ..logger_config import log_backend_agent_message, log_stream_chunk, logger
38
+ from ..mcp_tools.backend_utils import MCPErrorHandler
39
+ from .base import FilesystemSupport, StreamChunk
40
+ from .base_with_mcp import MCPBackend, UploadFileError
29
41
 
30
- class ClaudeBackend(LLMBackend):
42
+
43
+ class ClaudeBackend(MCPBackend):
31
44
  """Claude backend using Anthropic's Messages API with full multi-tool support."""
32
45
 
33
46
  def __init__(self, api_key: Optional[str] = None, **kwargs):
@@ -35,556 +48,1071 @@ class ClaudeBackend(LLMBackend):
35
48
  self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
36
49
  self.search_count = 0 # Track web search usage for pricing
37
50
  self.code_session_hours = 0.0 # Track code execution usage
51
+ self.formatter = ClaudeFormatter()
52
+ self.api_params_handler = ClaudeAPIParamsHandler(self)
53
+ self._uploaded_file_ids: List[str] = []
54
+
55
+ def supports_upload_files(self) -> bool:
56
+ """Claude Vision supports inline images; Files API handles PDFs and text docs."""
38
57
 
39
- def convert_tools_to_claude_format(
40
- self, tools: List[Dict[str, Any]]
58
+ return True
59
+
60
+ async def stream_with_tools(
61
+ self,
62
+ messages: List[Dict[str, Any]],
63
+ tools: List[Dict[str, Any]],
64
+ **kwargs,
65
+ ) -> AsyncGenerator[StreamChunk, None]:
66
+ """Override to ensure Files API cleanup happens after streaming completes."""
67
+ try:
68
+ async for chunk in super().stream_with_tools(messages, tools, **kwargs):
69
+ yield chunk
70
+ finally:
71
+ await self._cleanup_files_api_resources(**kwargs)
72
+
73
+ async def _process_upload_files(
74
+ self,
75
+ messages: List[Dict[str, Any]],
76
+ all_params: Dict[str, Any],
41
77
  ) -> List[Dict[str, Any]]:
42
- """Convert tools to Claude's expected format.
78
+ """Convert upload_files entries into Claude-compatible multimodal content."""
43
79
 
44
- Input formats supported:
45
- - Response API format: {"type": "function", "name": ..., "description": ..., "parameters": ...}
46
- - Chat Completions format: {"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}
80
+ processed_messages = await super()._process_upload_files(messages, all_params)
81
+ if not processed_messages:
82
+ return processed_messages
47
83
 
48
- Claude format: {"type": "function", "name": ..., "description": ..., "input_schema": ...}
49
- """
50
- if not tools:
51
- return tools
52
-
53
- converted_tools = []
54
- for tool in tools:
55
- if tool.get("type") == "function":
56
- if "function" in tool:
57
- # Chat Completions format -> Claude custom tool
58
- func = tool["function"]
59
- converted_tools.append(
60
- {
61
- "type": "custom",
62
- "name": func["name"],
63
- "description": func["description"],
64
- "input_schema": func.get("parameters", {}),
65
- }
84
+ allowed_mime_types = {
85
+ "image/jpeg",
86
+ "image/png",
87
+ "image/gif",
88
+ "image/webp",
89
+ }
90
+ max_image_size_bytes = 5 * 1024 * 1024
91
+
92
+ for message in processed_messages:
93
+ content = message.get("content")
94
+ if not isinstance(content, list):
95
+ continue
96
+
97
+ converted_items: List[Dict[str, Any]] = []
98
+ for item in content:
99
+ if not isinstance(item, dict):
100
+ converted_items.append(item)
101
+ continue
102
+
103
+ item_type = item.get("type")
104
+ if item_type == "file_pending_upload":
105
+ converted_items.append(item)
106
+ continue
107
+
108
+ if item_type != "image":
109
+ converted_items.append(item)
110
+ continue
111
+
112
+ if "source" in item and isinstance(item["source"], dict):
113
+ converted_items.append(item)
114
+ continue
115
+
116
+ # Handle base64-encoded images
117
+ if "base64" in item:
118
+ mime_type = (item.get("mime_type") or "").lower()
119
+ if mime_type not in allowed_mime_types:
120
+ raise UploadFileError(
121
+ f"Unsupported Claude image MIME type: {mime_type or 'unknown'}",
122
+ )
123
+
124
+ try:
125
+ decoded = base64.b64decode(item["base64"], validate=True)
126
+ except binascii.Error as exc:
127
+ raise UploadFileError("Invalid base64 image data") from exc
128
+
129
+ if len(decoded) > max_image_size_bytes:
130
+ raise UploadFileError(
131
+ "Claude Vision image exceeds 5MB size limit",
132
+ )
133
+
134
+ converted_item = {key: value for key, value in item.items() if key not in {"base64", "mime_type"}}
135
+ converted_item["type"] = "image"
136
+ converted_item["source"] = {
137
+ "type": "base64",
138
+ "media_type": mime_type,
139
+ "data": item["base64"],
140
+ }
141
+ logger.debug(
142
+ "Converted base64 image for Claude Vision: %s",
143
+ converted_item.get("source_path", "inline"),
66
144
  )
67
- elif "name" in tool and "description" in tool:
68
- # Response API format -> Claude custom tool
69
- converted_tools.append(
70
- {
71
- "type": "custom",
72
- "name": tool["name"],
73
- "description": tool["description"],
74
- "input_schema": tool.get("parameters", {}),
75
- }
145
+ converted_items.append(converted_item)
146
+ continue
147
+
148
+ # Handle URL-referenced images
149
+ if "url" in item:
150
+ converted_item = {key: value for key, value in item.items() if key != "url"}
151
+ converted_item["type"] = "image"
152
+ converted_item["source"] = {
153
+ "type": "url",
154
+ "url": item["url"],
155
+ }
156
+ logger.debug(
157
+ "Converted URL image for Claude Vision: %s",
158
+ item["url"],
76
159
  )
77
- else:
78
- # Unknown format - keep as-is
79
- converted_tools.append(tool)
80
- else:
81
- # Non-function tool (builtin tools) - keep as-is
82
- converted_tools.append(tool)
160
+ converted_items.append(converted_item)
161
+ continue
83
162
 
84
- return converted_tools
163
+ # Handle Files API references
164
+ if "file_id" in item:
165
+ converted_item = {key: value for key, value in item.items() if key != "file_id"}
166
+ converted_item["type"] = "image"
167
+ converted_item["source"] = {
168
+ "type": "file",
169
+ "file_id": item["file_id"],
170
+ }
171
+ logger.debug(
172
+ "Attached Claude file_id reference for image: %s",
173
+ item["file_id"],
174
+ )
175
+ converted_items.append(converted_item)
176
+ continue
85
177
 
86
- def convert_messages_to_claude_format(
87
- self, messages: List[Dict[str, Any]]
88
- ) -> tuple:
89
- """Convert messages to Claude's expected format.
178
+ converted_items.append(item)
90
179
 
91
- Handle different tool message formats and extract system message:
92
- - Chat Completions tool message: {"role": "tool", "tool_call_id": "...", "content": "..."}
93
- - Response API tool message: {"type": "function_call_output", "call_id": "...", "output": "..."}
94
- - System messages: Extract and return separately for top-level system parameter
180
+ message["content"] = converted_items
95
181
 
96
- Returns:
97
- tuple: (converted_messages, system_message)
182
+ return processed_messages
183
+
184
+ async def _upload_files_via_files_api(
185
+ self,
186
+ messages: List[Dict[str, Any]],
187
+ client,
188
+ agent_id: Optional[str] = None,
189
+ ) -> List[Dict[str, Any]]:
190
+ """Upload files via Claude Files API and replace pending markers with document blocks.
191
+
192
+ Claude Files API only supports PDF and TXT files. Unsupported files are gracefully
193
+ skipped and replaced with informative text notes to maintain workflow continuity.
98
194
  """
99
- converted_messages = []
100
- system_message = ""
101
-
102
- for message in messages:
103
- if message.get("role") == "system":
104
- # Extract system message for top-level parameter
105
- system_message = message.get("content", "")
106
- elif message.get("role") == "tool":
107
- # Chat Completions tool message -> Claude tool result
108
- converted_messages.append(
109
- {
110
- "role": "user",
111
- "content": [
112
- {
113
- "type": "tool_result",
114
- "tool_use_id": message.get("tool_call_id"),
115
- "content": message.get("content", ""),
116
- }
117
- ],
118
- }
119
- )
120
- elif message.get("type") == "function_call_output":
121
- # Response API tool message -> Claude tool result
122
- converted_messages.append(
123
- {
124
- "role": "user",
125
- "content": [
126
- {
127
- "type": "tool_result",
128
- "tool_use_id": message.get("call_id"),
129
- "content": message.get("output", ""),
130
- }
131
- ],
132
- }
133
- )
134
- elif message.get("role") == "assistant" and "tool_calls" in message:
135
- # Assistant message with tool calls - convert to Claude format
136
- content = []
195
+ # Claude Files API only supports PDF and TXT files
196
+ CLAUDE_FILES_API_SUPPORTED_EXTENSIONS = {".pdf", ".txt"}
197
+ CLAUDE_FILES_API_SUPPORTED_MIME_TYPES = {
198
+ "application/pdf",
199
+ "text/plain",
200
+ "text/txt",
201
+ }
137
202
 
138
- # Add text content if present
139
- if message.get("content"):
140
- content.append({"type": "text", "text": message["content"]})
203
+ # Find all file_pending_upload markers
204
+ file_locations: List[Tuple[int, int]] = []
205
+ for msg_idx, message in enumerate(messages):
206
+ content = message.get("content")
207
+ if not isinstance(content, list):
208
+ continue
209
+ for item_idx, item in enumerate(content):
210
+ if isinstance(item, dict) and item.get("type") == "file_pending_upload":
211
+ file_locations.append((msg_idx, item_idx))
141
212
 
142
- # Convert tool calls to Claude tool use format
143
- for tool_call in message["tool_calls"]:
144
- tool_name = self.extract_tool_name(tool_call)
145
- tool_args = self.extract_tool_arguments(tool_call)
146
- tool_id = self.extract_tool_call_id(tool_call)
213
+ if not file_locations:
214
+ return messages
147
215
 
148
- content.append(
149
- {
150
- "type": "tool_use",
151
- "id": tool_id,
152
- "name": tool_name,
153
- "input": tool_args,
154
- }
216
+ httpx_client = None
217
+ try:
218
+ httpx_client = httpx.AsyncClient()
219
+
220
+ # Track uploaded file IDs, skipped files, failed uploads, and their corresponding locations
221
+ uploaded_files: List[Tuple[int, int, str]] = [] # (msg_idx, item_idx, file_id)
222
+ skipped_files: List[Tuple[int, int, str, str]] = [] # (msg_idx, item_idx, filename, reason)
223
+ failed_uploads: List[Tuple[int, int, str, str]] = [] # (msg_idx, item_idx, filename, reason)
224
+
225
+ for msg_idx, item_idx in file_locations:
226
+ marker = messages[msg_idx]["content"][item_idx]
227
+ source = marker.get("source")
228
+ file_path = marker.get("path")
229
+ url = marker.get("url")
230
+ mime_type = marker.get("mime_type", "application/octet-stream")
231
+ filename_hint = marker.get("filename") or marker.get("name")
232
+
233
+ # Validate file extension and MIME type for Claude Files API
234
+ file_ext = None
235
+ filename = None
236
+
237
+ if source == "local" and file_path:
238
+ file_ext = Path(file_path).suffix.lower()
239
+ filename = Path(file_path).name
240
+ # Re-validate MIME type using mimetypes module for accuracy
241
+ guessed_mime, _ = mimetypes.guess_type(file_path)
242
+ if guessed_mime:
243
+ mime_type = guessed_mime
244
+ elif source == "url" and url:
245
+ # Extract extension from URL (strip query parameters and fragments)
246
+ url_path = url.split("?")[0].split("#")[0]
247
+ file_ext = Path(url_path).suffix.lower()
248
+ filename = Path(url_path).name or url
249
+ if not filename_hint:
250
+ filename_hint = filename
251
+ # Re-validate MIME type using mimetypes module
252
+ guessed_mime, _ = mimetypes.guess_type(url_path)
253
+ if guessed_mime:
254
+ mime_type = guessed_mime
255
+
256
+ # Check if file type is supported (both extension and MIME type)
257
+ is_supported = False
258
+ skip_reason = None
259
+
260
+ if file_ext and file_ext.lower() in CLAUDE_FILES_API_SUPPORTED_EXTENSIONS:
261
+ # Extension is supported, now check MIME type
262
+ if mime_type and mime_type.lower() in CLAUDE_FILES_API_SUPPORTED_MIME_TYPES:
263
+ is_supported = True
264
+ else:
265
+ skip_reason = f"MIME type '{mime_type}' not supported (extension {file_ext} is valid)"
266
+ else:
267
+ skip_reason = f"File extension '{file_ext or 'unknown'}' not supported"
268
+
269
+ # If file is not supported, skip it gracefully and log warning
270
+ if not is_supported:
271
+ logger.warning(
272
+ f"[Agent {agent_id or 'default'}] Skipping unsupported file for Claude Files API: "
273
+ f"{filename or file_path or url} - {skip_reason}. "
274
+ f"Only PDF and TXT files are supported.",
155
275
  )
276
+ skipped_files.append((msg_idx, item_idx, filename or file_path or url or "unknown", skip_reason))
277
+ continue
156
278
 
157
- converted_messages.append({"role": "assistant", "content": content})
158
- elif message.get("role") in ["user", "assistant"]:
159
- # Keep user and assistant messages, skip system
160
- converted_message = dict(message)
161
- if isinstance(converted_message.get("content"), str):
162
- # Claude expects content to be text for simple messages
163
- pass # String content is fine
164
- converted_messages.append(converted_message)
279
+ try:
280
+ if source == "local" and file_path:
281
+ # Upload local file
282
+ path_obj = Path(file_path)
283
+ filename = path_obj.name
284
+ with open(file_path, "rb") as f:
285
+ file_bytes = f.read()
286
+
287
+ uploaded_file = await client.beta.files.upload(
288
+ file=(filename, file_bytes, mime_type),
289
+ )
290
+ file_id = getattr(uploaded_file, "id", None)
291
+ if file_id:
292
+ self._uploaded_file_ids.append(file_id)
293
+ uploaded_files.append((msg_idx, item_idx, file_id))
294
+ logger.info(
295
+ f"[Agent {agent_id or 'default'}] Uploaded local file via Files API: {filename} -> {file_id}",
296
+ )
297
+ else:
298
+ failure_reason = "Claude Files API response missing file_id"
299
+ failed_uploads.append(
300
+ (
301
+ msg_idx,
302
+ item_idx,
303
+ filename or filename_hint or file_path or "unknown",
304
+ failure_reason,
305
+ ),
306
+ )
307
+ logger.warning(
308
+ f"[Agent {agent_id or 'default'}] Failed to upload file via Files API: {failure_reason}",
309
+ )
165
310
 
166
- return converted_messages, system_message
311
+ elif source == "url" and url:
312
+ # Download and upload URL file
313
+ response = await httpx_client.get(url, timeout=30.0)
314
+ response.raise_for_status()
315
+
316
+ # Enforce Claude Files API 500 MB size limit
317
+ max_size_bytes = 500 * 1024 * 1024 # 500 MB
318
+ content_length = response.headers.get("Content-Length")
319
+ if content_length:
320
+ file_size = int(content_length)
321
+ if file_size > max_size_bytes:
322
+ raise UploadFileError(
323
+ f"File size {file_size / (1024 * 1024):.2f} MB exceeds Claude Files API limit of 500 MB",
324
+ )
167
325
 
168
- async def stream_with_tools(
169
- self, messages: List[Dict[str, Any]], tools: List[Dict[str, Any]], **kwargs
170
- ) -> AsyncGenerator[StreamChunk, None]:
171
- """Stream response using Claude's Messages API with full multi-tool support."""
172
- try:
173
- import anthropic
174
-
175
- # Initialize client
176
- client = anthropic.AsyncAnthropic(api_key=self.api_key)
177
-
178
- # Extract parameters
179
- model = kwargs.get(
180
- "model", "claude-3-5-haiku-latest"
181
- ) # Use model that supports code execution
182
- max_tokens = kwargs.get("max_tokens", 8192)
183
- temperature = kwargs.get("temperature", None)
184
- enable_web_search = kwargs.get("enable_web_search", False)
185
- enable_code_execution = kwargs.get("enable_code_execution", False)
186
-
187
- # Convert messages to Claude format and extract system message
188
- converted_messages, system_message = self.convert_messages_to_claude_format(
189
- messages
190
- )
326
+ file_bytes = response.content
191
327
 
192
- # Combine all tool types (Claude's key advantage!)
193
- combined_tools = []
328
+ # Cap bytes read if Content-Length was missing
329
+ if len(file_bytes) > max_size_bytes:
330
+ raise UploadFileError(
331
+ f"Downloaded file size {len(file_bytes) / (1024 * 1024):.2f} MB exceeds Claude Files API limit of 500 MB",
332
+ )
194
333
 
195
- # Add server-side tools if enabled (use correct Claude format)
196
- if enable_web_search:
197
- combined_tools.append(
198
- {"type": "web_search_20250305", "name": "web_search"}
199
- )
334
+ filename = url.split("/")[-1] or "document"
335
+
336
+ uploaded_file = await client.beta.files.upload(
337
+ file=(filename, file_bytes, mime_type),
338
+ )
339
+ file_id = getattr(uploaded_file, "id", None)
340
+ if file_id:
341
+ self._uploaded_file_ids.append(file_id)
342
+ uploaded_files.append((msg_idx, item_idx, file_id))
343
+ logger.info(
344
+ f"[Agent {agent_id or 'default'}] Uploaded URL file via Files API: {url} -> {file_id}",
345
+ )
346
+ else:
347
+ failure_reason = "Claude Files API response missing file_id"
348
+ failed_uploads.append(
349
+ (
350
+ msg_idx,
351
+ item_idx,
352
+ filename or filename_hint or url or "unknown",
353
+ failure_reason,
354
+ ),
355
+ )
356
+ logger.warning(
357
+ f"[Agent {agent_id or 'default'}] Failed to upload file via Files API: {failure_reason}",
358
+ )
200
359
 
201
- if enable_code_execution:
202
- combined_tools.append(
203
- {"type": "code_execution_20250522", "name": "code_execution"}
204
- )
360
+ except Exception as upload_error:
361
+ logger.warning(
362
+ f"[Agent {agent_id or 'default'}] Failed to upload file via Files API: {upload_error}",
363
+ )
364
+ failure_context = filename or filename_hint or file_path or url or "unknown"
365
+ failed_uploads.append((msg_idx, item_idx, failure_context, str(upload_error)))
366
+ continue
205
367
 
206
- # Add user-defined tools
207
- if tools:
208
- converted_tools = self.convert_tools_to_claude_format(tools)
209
- combined_tools.extend(converted_tools)
210
-
211
- # Build API parameters
212
- api_params = {
213
- "model": model,
214
- "messages": converted_messages,
215
- "max_tokens": max_tokens,
216
- "stream": True,
217
- }
368
+ except Exception as e:
369
+ logger.warning(f"[Agent {agent_id or 'default'}] Files API upload error: {e}")
370
+ raise UploadFileError(f"Files API upload failed: {e}") from e
371
+ finally:
372
+ if httpx_client:
373
+ await httpx_client.aclose()
374
+
375
+ # Clone messages and replace markers with document blocks or text notes
376
+ updated_messages = [msg.copy() for msg in messages]
377
+
378
+ # Replace successfully uploaded files with document blocks
379
+ for msg_idx, item_idx, file_id in reversed(uploaded_files):
380
+ content = updated_messages[msg_idx]["content"]
381
+ if isinstance(content, list):
382
+ # Create document block
383
+ document_block = {
384
+ "type": "document",
385
+ "source": {
386
+ "type": "file",
387
+ "file_id": file_id,
388
+ },
389
+ }
390
+ # Replace marker with document block
391
+ new_content = content[:item_idx] + [document_block] + content[item_idx + 1 :]
392
+ updated_messages[msg_idx]["content"] = new_content
393
+
394
+ # Replace skipped files with informative text notes
395
+ for msg_idx, item_idx, filename, reason in reversed(skipped_files):
396
+ content = updated_messages[msg_idx]["content"]
397
+ if isinstance(content, list):
398
+ # Create text note explaining the limitation
399
+ text_note = {
400
+ "type": "text",
401
+ "text": (f"\n[Note: File '{filename}' was not uploaded to Claude Files API. " f"Reason: {reason}. " f"Claude Files API only supports PDF and TXT files.]\n"),
402
+ }
403
+ # Replace marker with text note
404
+ new_content = content[:item_idx] + [text_note] + content[item_idx + 1 :]
405
+ updated_messages[msg_idx]["content"] = new_content
406
+
407
+ # Replace failed uploads with informative text notes
408
+ for msg_idx, item_idx, filename, reason in reversed(failed_uploads):
409
+ content = updated_messages[msg_idx]["content"]
410
+ if isinstance(content, list):
411
+ text_note = {
412
+ "type": "text",
413
+ "text": (f"\n[Note: File '{filename}' failed to upload to Claude Files API. " f"Reason: {reason}.]\n"),
414
+ }
415
+ new_content = content[:item_idx] + [text_note] + content[item_idx + 1 :]
416
+ updated_messages[msg_idx]["content"] = new_content
218
417
 
219
- if system_message:
220
- api_params["system"] = system_message
418
+ # Final sweep to ensure all file_pending_upload markers were replaced
419
+ self._ensure_no_pending_upload_markers(updated_messages)
221
420
 
222
- if temperature is not None:
223
- api_params["temperature"] = temperature
421
+ return updated_messages
224
422
 
225
- if combined_tools:
226
- api_params["tools"] = combined_tools
423
+ async def _cleanup_files_api_resources(self, **kwargs) -> None:
424
+ """Clean up uploaded files via Files API."""
425
+ if not self._uploaded_file_ids:
426
+ return
227
427
 
228
- # Set up beta features and create stream
229
- if enable_code_execution:
230
- # Code execution requires beta client and beta headers
231
- api_params["betas"] = ["code-execution-2025-05-22"]
232
- stream = await client.beta.messages.create(**api_params)
233
- else:
234
- # Regular client for non-code-execution requests
235
- stream = await client.messages.create(**api_params)
428
+ agent_id = kwargs.get("agent_id")
429
+ logger.info(
430
+ f"[Agent {agent_id or 'default'}] Cleaning up {len(self._uploaded_file_ids)} Files API resources...",
431
+ )
236
432
 
237
- content = ""
238
- current_tool_uses = {}
433
+ client = None
434
+ try:
435
+ client = self._create_client(**kwargs)
239
436
 
240
- async for event in stream:
437
+ for file_id in self._uploaded_file_ids:
241
438
  try:
242
- if event.type == "message_start":
243
- # Message started
439
+ await client.beta.files.delete(file_id)
440
+ logger.debug(f"[Agent {agent_id or 'default'}] Deleted Files API file: {file_id}")
441
+ except Exception as delete_error:
442
+ logger.warning(
443
+ f"[Agent {agent_id or 'default'}] Failed to delete Files API file {file_id}: {delete_error}",
444
+ )
445
+ continue
446
+
447
+ self._uploaded_file_ids.clear()
448
+ logger.info(f"[Agent {agent_id or 'default'}] Files API cleanup completed")
449
+
450
+ except Exception as e:
451
+ logger.warning(f"[Agent {agent_id or 'default'}] Files API cleanup error: {e}")
452
+ finally:
453
+ if client and hasattr(client, "aclose"):
454
+ await client.aclose()
455
+
456
+ def _ensure_no_pending_upload_markers(self, messages: List[Dict[str, Any]]) -> None:
457
+ """Raise UploadFileError if any file_pending_upload markers remain."""
458
+ if not messages:
459
+ return
460
+
461
+ for msg_idx, message in enumerate(messages):
462
+ content = message.get("content")
463
+ if not isinstance(content, list):
464
+ continue
465
+ for item_idx, item in enumerate(content):
466
+ if isinstance(item, dict) and item.get("type") == "file_pending_upload":
467
+ identifier = item.get("filename") or item.get("name") or item.get("path") or item.get("url") or "unknown"
468
+ raise UploadFileError(
469
+ "Claude Files API upload left unresolved file_pending_upload marker " f"(message {msg_idx}, item {item_idx}, source {identifier}).",
470
+ )
471
+
472
+ async def _stream_without_mcp_tools(
473
+ self,
474
+ messages: List[Dict[str, Any]],
475
+ tools: List[Dict[str, Any]],
476
+ client,
477
+ **kwargs,
478
+ ) -> AsyncGenerator[StreamChunk, None]:
479
+ """Override to integrate Files API uploads into non-MCP streaming."""
480
+ agent_id = kwargs.get("agent_id", None)
481
+ all_params = {**self.config, **kwargs}
482
+ processed_messages = await self._process_upload_files(messages, all_params)
483
+
484
+ # Check if we need to upload files via Files API
485
+ if all_params.get("_has_file_search_files"):
486
+ logger.info("Processing Files API uploads...")
487
+ processed_messages = await self._upload_files_via_files_api(processed_messages, client, agent_id)
488
+ all_params["_has_files_api_files"] = True
489
+ all_params.pop("_has_file_search_files", None)
490
+
491
+ self._ensure_no_pending_upload_markers(processed_messages)
492
+
493
+ api_params = await self.api_params_handler.build_api_params(processed_messages, tools, all_params)
494
+
495
+ # Remove any MCP tools from the tools list
496
+ if "tools" in api_params:
497
+ non_mcp_tools = []
498
+ for tool in api_params.get("tools", []):
499
+ # Check different formats for MCP tools
500
+ if tool.get("type") == "function":
501
+ name = tool.get("function", {}).get("name") if "function" in tool else tool.get("name")
502
+ if name and name in self._mcp_function_names:
244
503
  continue
504
+ elif tool.get("type") == "mcp":
505
+ continue
506
+ non_mcp_tools.append(tool)
507
+ if non_mcp_tools:
508
+ api_params["tools"] = non_mcp_tools
509
+ else:
510
+ api_params.pop("tools", None)
511
+
512
+ # Create stream (handle betas)
513
+ if "betas" in api_params:
514
+ stream = await client.beta.messages.create(**api_params)
515
+ else:
516
+ stream = await client.messages.create(**api_params)
517
+
518
+ # Process stream chunks
519
+ async for chunk in self._process_stream(stream, all_params, agent_id):
520
+ yield chunk
521
+
522
+ async def _stream_with_mcp_tools(
523
+ self,
524
+ current_messages: List[Dict[str, Any]],
525
+ tools: List[Dict[str, Any]],
526
+ client,
527
+ **kwargs,
528
+ ) -> AsyncGenerator[StreamChunk, None]:
529
+ """Recursively stream responses, executing MCP function calls when detected."""
530
+
531
+ # Build API params for this iteration
532
+ all_params = {**self.config, **kwargs}
533
+
534
+ # Check if we need to upload files via Files API
535
+ if all_params.get("_has_file_search_files"):
536
+ logger.info("Processing Files API uploads in MCP mode...")
537
+ agent_id = kwargs.get("agent_id")
538
+ current_messages = await self._upload_files_via_files_api(current_messages, client, agent_id)
539
+ all_params["_has_files_api_files"] = True
540
+ all_params.pop("_has_file_search_files", None)
541
+
542
+ self._ensure_no_pending_upload_markers(current_messages)
245
543
 
246
- elif event.type == "content_block_start":
247
- # Content block started (text, tool use, or tool result)
248
- if hasattr(event, "content_block"):
249
- if event.content_block.type == "tool_use":
250
- # Regular tool use started (user-defined tools)
251
- tool_id = event.content_block.id
252
- tool_name = event.content_block.name
253
- current_tool_uses[tool_id] = {
254
- "id": tool_id,
255
- "name": tool_name,
256
- "input": "", # Will accumulate JSON fragments
257
- "index": getattr(event, "index", None),
258
- }
259
- elif event.content_block.type == "server_tool_use":
260
- # Server-side tool use (code execution, web search) - show status immediately
261
- tool_id = event.content_block.id
262
- tool_name = event.content_block.name
263
- current_tool_uses[tool_id] = {
264
- "id": tool_id,
265
- "name": tool_name,
266
- "input": "", # Will accumulate JSON fragments
267
- "index": getattr(event, "index", None),
268
- "server_side": True,
269
- }
270
-
271
- # Show tool execution starting
544
+ api_params = await self.api_params_handler.build_api_params(current_messages, tools, all_params)
545
+
546
+ agent_id = kwargs.get("agent_id", None)
547
+
548
+ # Create stream (handle code execution beta)
549
+ if "betas" in api_params:
550
+ stream = await client.beta.messages.create(**api_params)
551
+ else:
552
+ stream = await client.messages.create(**api_params)
553
+
554
+ content = ""
555
+ current_tool_uses: Dict[str, Dict[str, Any]] = {}
556
+ mcp_tool_calls: List[Dict[str, Any]] = []
557
+ response_completed = False
558
+
559
+ async for event in stream:
560
+ try:
561
+ if event.type == "message_start":
562
+ continue
563
+ elif event.type == "content_block_start":
564
+ if hasattr(event, "content_block"):
565
+ if event.content_block.type == "tool_use":
566
+ tool_id = event.content_block.id
567
+ tool_name = event.content_block.name
568
+ current_tool_uses[tool_id] = {
569
+ "id": tool_id,
570
+ "name": tool_name,
571
+ "input": "",
572
+ "index": getattr(event, "index", None),
573
+ }
574
+ elif event.content_block.type == "server_tool_use":
575
+ tool_id = event.content_block.id
576
+ tool_name = event.content_block.name
577
+ current_tool_uses[tool_id] = {
578
+ "id": tool_id,
579
+ "name": tool_name,
580
+ "input": "",
581
+ "index": getattr(event, "index", None),
582
+ "server_side": True,
583
+ }
584
+ if tool_name == "code_execution":
585
+ yield StreamChunk(
586
+ type="content",
587
+ content="\n💻 [Code Execution] Starting...\n",
588
+ )
589
+ elif tool_name == "web_search":
590
+ yield StreamChunk(
591
+ type="content",
592
+ content="\n🔍 [Web Search] Starting search...\n",
593
+ )
594
+ elif event.content_block.type == "code_execution_tool_result":
595
+ result_block = event.content_block
596
+ result_parts = []
597
+ if hasattr(result_block, "stdout") and result_block.stdout:
598
+ result_parts.append(f"Output: {result_block.stdout.strip()}")
599
+ if hasattr(result_block, "stderr") and result_block.stderr:
600
+ result_parts.append(f"Error: {result_block.stderr.strip()}")
601
+ if hasattr(result_block, "return_code") and result_block.return_code != 0:
602
+ result_parts.append(f"Exit code: {result_block.return_code}")
603
+ if result_parts:
604
+ result_text = f"\n💻 [Code Execution Result]\n{chr(10).join(result_parts)}\n"
605
+ yield StreamChunk(type="content", content=result_text)
606
+ elif event.type == "content_block_delta":
607
+ if hasattr(event, "delta"):
608
+ if event.delta.type == "text_delta":
609
+ text_chunk = event.delta.text
610
+ content += text_chunk
611
+ log_backend_agent_message(
612
+ agent_id or "default",
613
+ "RECV",
614
+ {"content": text_chunk},
615
+ backend_name="claude",
616
+ )
617
+ log_stream_chunk("backend.claude", "content", text_chunk, agent_id)
618
+ yield StreamChunk(type="content", content=text_chunk)
619
+ elif event.delta.type == "input_json_delta":
620
+ if hasattr(event, "index"):
621
+ for tool_id, tool_data in current_tool_uses.items():
622
+ if tool_data.get("index") == event.index:
623
+ partial_json = getattr(event.delta, "partial_json", "")
624
+ tool_data["input"] += partial_json
625
+ break
626
+ elif event.type == "content_block_stop":
627
+ if hasattr(event, "index"):
628
+ for tool_id, tool_data in current_tool_uses.items():
629
+ if tool_data.get("index") == event.index and tool_data.get("server_side"):
630
+ tool_name = tool_data.get("name", "")
631
+ tool_input = tool_data.get("input", "")
632
+ try:
633
+ parsed_input = json.loads(tool_input) if tool_input else {}
634
+ except json.JSONDecodeError:
635
+ parsed_input = {"raw_input": tool_input}
272
636
  if tool_name == "code_execution":
637
+ code = parsed_input.get("code", "")
638
+ if code:
639
+ yield StreamChunk(type="content", content=f"💻 [Code] {code}\n")
273
640
  yield StreamChunk(
274
641
  type="content",
275
- content=f"\n💻 [Code Execution] Starting...\n",
642
+ content=" [Code Execution] Completed\n",
276
643
  )
277
644
  elif tool_name == "web_search":
645
+ query = parsed_input.get("query", "")
646
+ if query:
647
+ yield StreamChunk(
648
+ type="content",
649
+ content=f"🔍 [Query] '{query}'\n",
650
+ )
278
651
  yield StreamChunk(
279
652
  type="content",
280
- content=f"\n🔍 [Web Search] Starting search...\n",
281
- )
282
- elif (
283
- event.content_block.type == "code_execution_tool_result"
284
- ):
285
- # Code execution result - format properly
286
- result_block = event.content_block
287
-
288
- # Format execution result nicely
289
- result_parts = []
290
- if (
291
- hasattr(result_block, "stdout")
292
- and result_block.stdout
293
- ):
294
- result_parts.append(
295
- f"Output: {result_block.stdout.strip()}"
296
- )
297
- if (
298
- hasattr(result_block, "stderr")
299
- and result_block.stderr
300
- ):
301
- result_parts.append(
302
- f"Error: {result_block.stderr.strip()}"
303
- )
304
- if (
305
- hasattr(result_block, "return_code")
306
- and result_block.return_code != 0
307
- ):
308
- result_parts.append(
309
- f"Exit code: {result_block.return_code}"
653
+ content=" [Web Search] Completed\n",
310
654
  )
655
+ tool_data["processed"] = True
656
+ break
657
+ elif event.type == "message_delta":
658
+ pass
659
+ elif event.type == "message_stop":
660
+ # Identify MCP and non-MCP tool calls among current_tool_uses
661
+ non_mcp_tool_calls = []
662
+ if current_tool_uses:
663
+ for tool_use in current_tool_uses.values():
664
+ tool_name = tool_use.get("name", "")
665
+ is_server_side = tool_use.get("server_side", False)
666
+ if is_server_side:
667
+ continue
668
+ # Parse accumulated JSON input for tool
669
+ tool_input = tool_use.get("input", "")
670
+ try:
671
+ parsed_input = json.loads(tool_input) if tool_input else {}
672
+ except json.JSONDecodeError:
673
+ parsed_input = {"raw_input": tool_input}
674
+
675
+ if self.is_mcp_tool_call(tool_name):
676
+ mcp_tool_calls.append(
677
+ {
678
+ "id": tool_use["id"],
679
+ "type": "function",
680
+ "function": {
681
+ "name": tool_name,
682
+ "arguments": parsed_input,
683
+ },
684
+ },
685
+ )
686
+ else:
687
+ non_mcp_tool_calls.append(
688
+ {
689
+ "id": tool_use["id"],
690
+ "type": "function",
691
+ "function": {
692
+ "name": tool_name,
693
+ "arguments": parsed_input,
694
+ },
695
+ },
696
+ )
697
+ # Emit non-MCP tool calls for the caller to execute
698
+ if non_mcp_tool_calls:
699
+ log_stream_chunk("backend.claude", "tool_calls", non_mcp_tool_calls, agent_id)
700
+ yield StreamChunk(type="tool_calls", tool_calls=non_mcp_tool_calls)
701
+ response_completed = True
702
+ break
703
+ except Exception as event_error:
704
+ error_msg = f"Event processing error: {event_error}"
705
+ log_stream_chunk("backend.claude", "error", error_msg, agent_id)
706
+ yield StreamChunk(type="error", error=error_msg)
707
+ continue
708
+
709
+ # If we captured MCP tool calls, execute them and recurse
710
+ if response_completed and mcp_tool_calls:
711
+ # Circuit breaker pre-execution check using base class method
712
+ if not await self._check_circuit_breaker_before_execution():
713
+ yield StreamChunk(
714
+ type="mcp_status",
715
+ status="mcp_blocked",
716
+ content="⚠️ [MCP] All servers blocked by circuit breaker",
717
+ source="circuit_breaker",
718
+ )
719
+ yield StreamChunk(type="done")
720
+ return
311
721
 
312
- if result_parts:
313
- result_text = f"\n💻 [Code Execution Result]\n{chr(10).join(result_parts)}\n"
314
- yield StreamChunk(
315
- type="content", content=result_text
316
- )
722
+ updated_messages = current_messages.copy()
317
723
 
318
- elif event.type == "content_block_delta":
319
- # Content streaming
320
- if hasattr(event, "delta"):
321
- if event.delta.type == "text_delta":
322
- # Text content
323
- text_chunk = event.delta.text
324
- content += text_chunk
325
- yield StreamChunk(type="content", content=text_chunk)
326
-
327
- elif event.delta.type == "input_json_delta":
328
- # Tool input streaming - accumulate JSON fragments
329
- if hasattr(event, "index"):
330
- # Find tool by index
331
- for tool_id, tool_data in current_tool_uses.items():
332
- if tool_data.get("index") == event.index:
333
- # Accumulate partial JSON
334
- partial_json = getattr(
335
- event.delta, "partial_json", ""
336
- )
337
- tool_data["input"] += partial_json
338
- break
339
-
340
- elif event.type == "content_block_stop":
341
- # Content block completed - check if it was a server-side tool
342
- if hasattr(event, "index"):
343
- # Find the tool that just completed
344
- for tool_id, tool_data in current_tool_uses.items():
345
- if tool_data.get(
346
- "index"
347
- ) == event.index and tool_data.get("server_side"):
348
- tool_name = tool_data.get("name", "")
349
-
350
- # Parse the accumulated input to show what was executed
351
- tool_input = tool_data.get("input", "")
352
- try:
353
- if tool_input:
354
- parsed_input = json.loads(tool_input)
355
- else:
356
- parsed_input = {}
357
- except json.JSONDecodeError:
358
- parsed_input = {"raw_input": tool_input}
359
-
360
- if tool_name == "code_execution":
361
- code = parsed_input.get("code", "")
362
- if code:
363
- yield StreamChunk(
364
- type="content",
365
- content=f"💻 [Code] {code}\n",
366
- )
367
- yield StreamChunk(
368
- type="content",
369
- content=f"✅ [Code Execution] Completed\n",
370
- )
724
+ # Build assistant message with tool_use blocks for all MCP tool calls
725
+ assistant_content = []
726
+ if content: # Add text content if any
727
+ assistant_content.append({"type": "text", "text": content})
371
728
 
372
- # Yield builtin tool result immediately
373
- builtin_result = {
374
- "id": tool_id,
375
- "tool_type": "code_execution",
376
- "status": "completed",
377
- "code": code,
378
- "input": parsed_input,
379
- }
380
- yield StreamChunk(
381
- type="builtin_tool_results",
382
- builtin_tool_results=[builtin_result],
383
- )
729
+ for tool_call in mcp_tool_calls:
730
+ tool_name = tool_call["function"]["name"]
731
+ tool_args = tool_call["function"]["arguments"]
732
+ tool_id = tool_call["id"]
384
733
 
385
- elif tool_name == "web_search":
386
- query = parsed_input.get("query", "")
387
- if query:
388
- yield StreamChunk(
389
- type="content",
390
- content=f"🔍 [Query] '{query}'\n",
391
- )
392
- yield StreamChunk(
393
- type="content",
394
- content=f"✅ [Web Search] Completed\n",
395
- )
734
+ assistant_content.append(
735
+ {
736
+ "type": "tool_use",
737
+ "id": tool_id,
738
+ "name": tool_name,
739
+ "input": tool_args,
740
+ },
741
+ )
396
742
 
397
- # Yield builtin tool result immediately
398
- builtin_result = {
399
- "id": tool_id,
400
- "tool_type": "web_search",
401
- "status": "completed",
402
- "query": query,
403
- "input": parsed_input,
404
- }
405
- yield StreamChunk(
406
- type="builtin_tool_results",
407
- builtin_tool_results=[builtin_result],
408
- )
743
+ # Append the assistant message with tool uses
744
+ updated_messages.append({"role": "assistant", "content": assistant_content})
409
745
 
410
- # Mark this tool as processed so we don't duplicate it later
411
- tool_data["processed"] = True
412
- break
746
+ # Now execute the MCP tool calls and append results
747
+ for tool_call in mcp_tool_calls:
748
+ function_name = tool_call["function"]["name"]
413
749
 
414
- elif event.type == "message_delta":
415
- # Message metadata updates (usage, etc.)
416
- if hasattr(event, "usage"):
417
- # Track token usage
418
- pass
750
+ # Yield MCP tool call status
751
+ yield StreamChunk(
752
+ type="mcp_status",
753
+ status="mcp_tool_called",
754
+ content=f"🔧 [MCP Tool] Calling {function_name}...",
755
+ source=f"mcp_{function_name}",
756
+ )
419
757
 
420
- elif event.type == "message_stop":
421
- # Message completed - build final response
758
+ try:
759
+ # Execute MCP function
760
+ args_json = json.dumps(tool_call["function"]["arguments"]) if isinstance(tool_call["function"].get("arguments"), (dict, list)) else tool_call["function"].get("arguments", "{}")
761
+ result_list = await self._execute_mcp_function_with_retry(function_name, args_json)
762
+ if not result_list or (isinstance(result_list[0], str) and result_list[0].startswith("Error:")):
763
+ logger.warning(f"MCP function {function_name} failed after retries: {result_list[0] if result_list else 'unknown error'}")
764
+ continue
765
+ result_str = result_list[0]
766
+ result_obj = result_list[1] if len(result_list) > 1 else None
767
+ except Exception as e:
768
+ logger.error(f"Unexpected error in MCP function execution: {e}")
769
+ continue
422
770
 
423
- # Handle any completed tool uses
424
- if current_tool_uses:
425
- # Separate server-side tools from user-defined tools
426
- builtin_tool_results = []
427
- user_tool_calls = []
771
+ # Build tool result message: { "role":"user", "content":[{ "type":"tool_result", "tool_use_id": tool_call["id"], "content": result_str }] }
772
+ tool_result_msg = {
773
+ "role": "user",
774
+ "content": [
775
+ {
776
+ "type": "tool_result",
777
+ "tool_use_id": tool_call["id"],
778
+ "content": result_str,
779
+ },
780
+ ],
781
+ }
428
782
 
429
- for tool_use in current_tool_uses.values():
430
- tool_name = tool_use.get("name", "")
431
- is_server_side = tool_use.get("server_side", False)
783
+ # Append to updated_messages
784
+ updated_messages.append(tool_result_msg)
432
785
 
433
- # Parse accumulated JSON input
434
- tool_input = tool_use.get("input", "")
435
- try:
436
- if tool_input:
437
- parsed_input = json.loads(tool_input)
438
- else:
439
- parsed_input = {}
440
- except json.JSONDecodeError:
441
- parsed_input = {"raw_input": tool_input}
786
+ yield StreamChunk(
787
+ type="mcp_status",
788
+ status="function_call",
789
+ content=f"Arguments for Calling {function_name}: {json.dumps(tool_call['function'].get('arguments', {}))}",
790
+ source=f"mcp_{function_name}",
791
+ )
442
792
 
443
- if is_server_side or tool_name in [
444
- "web_search",
445
- "code_execution",
446
- ]:
447
- # Convert server-side tools to builtin_tool_results
448
- builtin_result = {
449
- "id": tool_use["id"],
450
- "tool_type": tool_name,
451
- "status": "completed",
452
- "input": parsed_input,
453
- }
454
-
455
- # Add tool-specific data
456
- if tool_name == "code_execution":
457
- builtin_result["code"] = parsed_input.get(
458
- "code", ""
459
- )
460
- # Note: actual execution results come via content_block events
461
- elif tool_name == "web_search":
462
- builtin_result["query"] = parsed_input.get(
463
- "query", ""
464
- )
465
- # Note: search results come via content_block events
466
-
467
- builtin_tool_results.append(builtin_result)
468
- else:
469
- # User-defined tools that need external execution
470
- user_tool_calls.append(
471
- {
472
- "id": tool_use["id"],
473
- "type": "function",
474
- "function": {
475
- "name": tool_name,
476
- "arguments": parsed_input,
477
- },
478
- }
479
- )
793
+ # If result_obj might be structured, try to display summary
794
+ result_display = None
795
+ try:
796
+ if hasattr(result_obj, "content") and result_obj.content:
797
+ part = result_obj.content[0]
798
+ if hasattr(part, "text"):
799
+ result_display = str(part.text)
800
+ except Exception:
801
+ result_display = None
802
+ if result_display:
803
+ yield StreamChunk(
804
+ type="mcp_status",
805
+ status="function_call_output",
806
+ content=f"Results for Calling {function_name}: {result_display}",
807
+ source=f"mcp_{function_name}",
808
+ )
809
+ else:
810
+ yield StreamChunk(
811
+ type="mcp_status",
812
+ status="function_call_output",
813
+ content=f"Results for Calling {function_name}: {result_str}",
814
+ source=f"mcp_{function_name}",
815
+ )
480
816
 
481
- # Only yield builtin tool results that weren't already processed during content_block_stop
482
- unprocessed_builtin_results = []
483
- for result in builtin_tool_results:
484
- tool_id = result.get("id")
485
- # Check if this tool was already processed during streaming
486
- tool_data = current_tool_uses.get(tool_id, {})
487
- if not tool_data.get("processed"):
488
- unprocessed_builtin_results.append(result)
817
+ logger.info(f"Executed MCP function {function_name} (stdio/streamable-http)")
818
+ yield StreamChunk(
819
+ type="mcp_status",
820
+ status="mcp_tool_response",
821
+ content=f"✅ [MCP Tool] {function_name} completed",
822
+ source=f"mcp_{function_name}",
823
+ )
824
+
825
+ # Trim updated_messages using base class method
826
+ updated_messages = self._trim_message_history(updated_messages)
489
827
 
490
- if unprocessed_builtin_results:
828
+ # After processing all MCP calls, recurse: async for chunk in self._stream_mcp_recursive(updated_messages, tools, client, **kwargs): yield chunk
829
+ async for chunk in self._stream_with_mcp_tools(updated_messages, tools, client, **kwargs):
830
+ yield chunk
831
+ return
832
+ else:
833
+ # No MCP function calls; finalize this turn
834
+ # Ensure termination with a done chunk when no further tool calls
835
+ complete_message = {
836
+ "role": "assistant",
837
+ "content": content.strip(),
838
+ }
839
+ log_stream_chunk("backend.claude", "complete_message", complete_message, agent_id)
840
+ yield StreamChunk(type="complete_message", complete_message=complete_message)
841
+ yield StreamChunk(
842
+ type="mcp_status",
843
+ status="mcp_session_complete",
844
+ content="✅ [MCP] Session completed",
845
+ source="mcp_session",
846
+ )
847
+ yield StreamChunk(type="done")
848
+ return
849
+
850
+ async def _process_stream(
851
+ self,
852
+ stream,
853
+ all_params: Dict[str, Any],
854
+ agent_id: Optional[str],
855
+ ) -> AsyncGenerator[StreamChunk, None]:
856
+ """Process stream events and yield StreamChunks."""
857
+ content_local = ""
858
+ current_tool_uses_local: Dict[str, Dict[str, Any]] = {}
859
+
860
+ async for chunk in stream:
861
+ try:
862
+ if chunk.type == "message_start":
863
+ continue
864
+ elif chunk.type == "content_block_start":
865
+ if hasattr(chunk, "content_block"):
866
+ if chunk.content_block.type == "tool_use":
867
+ tool_id = chunk.content_block.id
868
+ tool_name = chunk.content_block.name
869
+ current_tool_uses_local[tool_id] = {
870
+ "id": tool_id,
871
+ "name": tool_name,
872
+ "input": "",
873
+ "index": getattr(chunk, "index", None),
874
+ }
875
+ elif chunk.content_block.type == "server_tool_use":
876
+ tool_id = chunk.content_block.id
877
+ tool_name = chunk.content_block.name
878
+ current_tool_uses_local[tool_id] = {
879
+ "id": tool_id,
880
+ "name": tool_name,
881
+ "input": "",
882
+ "index": getattr(chunk, "index", None),
883
+ "server_side": True,
884
+ }
885
+ if tool_name == "code_execution":
491
886
  yield StreamChunk(
492
- type="builtin_tool_results",
493
- builtin_tool_results=unprocessed_builtin_results,
887
+ type="content",
888
+ content="\n💻 [Code Execution] Starting...\n",
494
889
  )
495
-
496
- # Yield user tool calls if any
497
- if user_tool_calls:
890
+ elif tool_name == "web_search":
498
891
  yield StreamChunk(
499
- type="tool_calls", tool_calls=user_tool_calls
892
+ type="content",
893
+ content="\n🔍 [Web Search] Starting search...\n",
500
894
  )
501
-
502
- # Build complete message with only user tool calls (builtin tools are handled separately)
503
- complete_message = {
504
- "role": "assistant",
505
- "content": content.strip(),
506
- }
507
- if user_tool_calls:
508
- complete_message["tool_calls"] = user_tool_calls
509
- yield StreamChunk(
510
- type="complete_message",
511
- complete_message=complete_message,
895
+ elif chunk.content_block.type == "code_execution_tool_result":
896
+ result_block = chunk.content_block
897
+ result_parts = []
898
+ if hasattr(result_block, "stdout") and result_block.stdout:
899
+ result_parts.append(f"Output: {result_block.stdout.strip()}")
900
+ if hasattr(result_block, "stderr") and result_block.stderr:
901
+ result_parts.append(f"Error: {result_block.stderr.strip()}")
902
+ if hasattr(result_block, "return_code") and result_block.return_code != 0:
903
+ result_parts.append(f"Exit code: {result_block.return_code}")
904
+ if result_parts:
905
+ result_text = f"\n💻 [Code Execution Result]\n{chr(10).join(result_parts)}\n"
906
+ yield StreamChunk(
907
+ type="content",
908
+ content=result_text,
909
+ )
910
+ elif chunk.type == "content_block_delta":
911
+ if hasattr(chunk, "delta"):
912
+ if chunk.delta.type == "text_delta":
913
+ text_chunk = chunk.delta.text
914
+ content_local += text_chunk
915
+ log_backend_agent_message(
916
+ agent_id or "default",
917
+ "RECV",
918
+ {"content": text_chunk},
919
+ backend_name="claude",
512
920
  )
513
- else:
514
- # Regular text response
515
- complete_message = {
516
- "role": "assistant",
517
- "content": content.strip(),
518
- }
519
- yield StreamChunk(
520
- type="complete_message",
521
- complete_message=complete_message,
921
+ log_stream_chunk(
922
+ "backend.claude",
923
+ "content",
924
+ text_chunk,
925
+ agent_id,
926
+ )
927
+ yield StreamChunk(type="content", content=text_chunk)
928
+ elif chunk.delta.type == "input_json_delta":
929
+ if hasattr(chunk, "index"):
930
+ for (
931
+ tool_id,
932
+ tool_data,
933
+ ) in current_tool_uses_local.items():
934
+ if tool_data.get("index") == chunk.index:
935
+ partial_json = getattr(
936
+ chunk.delta,
937
+ "partial_json",
938
+ "",
939
+ )
940
+ tool_data["input"] += partial_json
941
+ break
942
+ elif chunk.type == "content_block_stop":
943
+ if hasattr(chunk, "index"):
944
+ for (
945
+ tool_id,
946
+ tool_data,
947
+ ) in current_tool_uses_local.items():
948
+ if tool_data.get("index") == chunk.index and tool_data.get("server_side"):
949
+ tool_name = tool_data.get("name", "")
950
+ tool_input = tool_data.get("input", "")
951
+ try:
952
+ parsed_input = json.loads(tool_input) if tool_input else {}
953
+ except json.JSONDecodeError:
954
+ parsed_input = {"raw_input": tool_input}
955
+ if tool_name == "code_execution":
956
+ code = parsed_input.get("code", "")
957
+ if code:
958
+ yield StreamChunk(
959
+ type="content",
960
+ content=f"💻 [Code] {code}\n",
961
+ )
962
+ yield StreamChunk(
963
+ type="content",
964
+ content="✅ [Code Execution] Completed\n",
965
+ )
966
+ elif tool_name == "web_search":
967
+ query = parsed_input.get("query", "")
968
+ if query:
969
+ yield StreamChunk(
970
+ type="content",
971
+ content=f"🔍 [Query] '{query}'\n",
972
+ )
973
+ yield StreamChunk(
974
+ type="content",
975
+ content="✅ [Web Search] Completed\n",
976
+ )
977
+ tool_data["processed"] = True
978
+ break
979
+ elif chunk.type == "message_delta":
980
+ pass
981
+ elif chunk.type == "message_stop":
982
+ # Build final response and yield tool_calls for user-defined non-MCP tools
983
+ user_tool_calls = []
984
+ for tool_use in current_tool_uses_local.values():
985
+ tool_name = tool_use.get("name", "")
986
+ is_server_side = tool_use.get("server_side", False)
987
+ if not is_server_side and tool_name not in ["web_search", "code_execution"]:
988
+ tool_input = tool_use.get("input", "")
989
+ try:
990
+ parsed_input = json.loads(tool_input) if tool_input else {}
991
+ except json.JSONDecodeError:
992
+ parsed_input = {"raw_input": tool_input}
993
+ user_tool_calls.append(
994
+ {
995
+ "id": tool_use["id"],
996
+ "type": "function",
997
+ "function": {
998
+ "name": tool_name,
999
+ "arguments": parsed_input,
1000
+ },
1001
+ },
522
1002
  )
523
1003
 
524
- # Track usage for pricing
525
- if enable_web_search:
526
- self.search_count += 1 # Approximate search usage
527
-
528
- if enable_code_execution:
529
- self.code_session_hours += 0.083 # 5 min minimum session
530
-
531
- yield StreamChunk(type="done")
532
- return
533
-
534
- except Exception as event_error:
1004
+ if user_tool_calls:
1005
+ log_stream_chunk(
1006
+ "backend.claude",
1007
+ "tool_calls",
1008
+ user_tool_calls,
1009
+ agent_id,
1010
+ )
1011
+ yield StreamChunk(
1012
+ type="tool_calls",
1013
+ tool_calls=user_tool_calls,
1014
+ )
1015
+
1016
+ complete_message = {
1017
+ "role": "assistant",
1018
+ "content": content_local.strip(),
1019
+ }
1020
+ if user_tool_calls:
1021
+ complete_message["tool_calls"] = user_tool_calls
1022
+ log_stream_chunk(
1023
+ "backend.claude",
1024
+ "complete_message",
1025
+ complete_message,
1026
+ agent_id,
1027
+ )
535
1028
  yield StreamChunk(
536
- type="error", error=f"Event processing error: {event_error}"
1029
+ type="complete_message",
1030
+ complete_message=complete_message,
537
1031
  )
538
- continue
539
1032
 
540
- except Exception as e:
541
- yield StreamChunk(type="error", error=f"Claude API error: {e}")
542
-
543
- def get_provider_name(self) -> str:
544
- """Get the provider name."""
545
- return "Claude"
1033
+ # Track usage for pricing
1034
+ if all_params.get("enable_web_search", False):
1035
+ self.search_count += 1
1036
+ if all_params.get("enable_code_execution", False):
1037
+ self.code_session_hours += 0.083
1038
+
1039
+ log_stream_chunk("backend.claude", "done", None, agent_id)
1040
+ yield StreamChunk(type="done")
1041
+ return
1042
+ except Exception as event_error:
1043
+ error_msg = f"Event processing error: {event_error}"
1044
+ log_stream_chunk("backend.claude", "error", error_msg, agent_id)
1045
+ yield StreamChunk(type="error", error=error_msg)
1046
+ continue
1047
+
1048
+ async def _handle_mcp_error_and_fallback(
1049
+ self,
1050
+ error: Exception,
1051
+ api_params: Dict[str, Any],
1052
+ provider_tools: List[Dict[str, Any]],
1053
+ stream_func: Callable[[Dict[str, Any]], AsyncGenerator[StreamChunk, None]],
1054
+ ) -> AsyncGenerator[StreamChunk, None]:
1055
+ """Handle MCP errors with user-friendly messaging and fallback to non-MCP tools."""
546
1056
 
547
- def get_supported_builtin_tools(self) -> List[str]:
548
- """Get list of builtin tools supported by Claude."""
549
- return ["web_search", "code_execution"]
1057
+ async with self._stats_lock:
1058
+ self._mcp_tool_failures += 1
1059
+ call_index_snapshot = self._mcp_tool_calls_count
550
1060
 
551
- def extract_tool_name(self, tool_call: Dict[str, Any]) -> str:
552
- """Extract tool name from tool call (handles multiple formats)."""
553
- # Chat Completions format
554
- if "function" in tool_call:
555
- return tool_call.get("function", {}).get("name", "unknown")
556
- # Claude native format
557
- elif "name" in tool_call:
558
- return tool_call.get("name", "unknown")
559
- # Fallback
560
- return "unknown"
561
-
562
- def extract_tool_arguments(self, tool_call: Dict[str, Any]) -> Dict[str, Any]:
563
- """Extract tool arguments from tool call (handles multiple formats)."""
564
- # Chat Completions format
565
- if "function" in tool_call:
566
- args = tool_call.get("function", {}).get("arguments", {})
567
- # Claude native format
568
- elif "input" in tool_call:
569
- args = tool_call.get("input", {})
1061
+ if MCPErrorHandler:
1062
+ log_type, user_message, _ = MCPErrorHandler.get_error_details(error) # type: ignore[assignment]
570
1063
  else:
571
- args = {}
1064
+ log_type, user_message = "mcp_error", "[MCP] Error occurred"
572
1065
 
573
- # Ensure JSON parsing if needed
574
- if isinstance(args, str):
575
- try:
576
- return json.loads(args)
577
- except:
578
- return {}
579
- return args
580
-
581
- def extract_tool_call_id(self, tool_call: Dict[str, Any]) -> str:
582
- """Extract tool call ID from tool call."""
583
- return tool_call.get("id") or tool_call.get("call_id") or ""
584
-
585
- def create_tool_result_message(
586
- self, tool_call: Dict[str, Any], result_content: str
587
- ) -> Dict[str, Any]:
1066
+ logger.warning(f"MCP tool call #{call_index_snapshot} failed - {log_type}: {error}")
1067
+
1068
+ yield StreamChunk(
1069
+ type="content",
1070
+ content=f"\n⚠️ {user_message} ({error}); continuing without MCP tools\n",
1071
+ )
1072
+
1073
+ # Build non-MCP configuration and stream fallback
1074
+ fallback_params = dict(api_params)
1075
+
1076
+ # Remove any MCP tools from the tools list
1077
+ if "tools" in fallback_params and self._mcp_functions:
1078
+ mcp_names = set(self._mcp_functions.keys())
1079
+ non_mcp_tools = []
1080
+ for tool in fallback_params["tools"]:
1081
+ name = tool.get("name")
1082
+ if name in mcp_names:
1083
+ continue
1084
+ non_mcp_tools.append(tool)
1085
+ fallback_params["tools"] = non_mcp_tools
1086
+
1087
+ # Add back provider tools if they were present
1088
+ if provider_tools:
1089
+ if "tools" not in fallback_params:
1090
+ fallback_params["tools"] = []
1091
+ fallback_params["tools"].extend(provider_tools)
1092
+
1093
+ async for chunk in stream_func(fallback_params):
1094
+ yield chunk
1095
+
1096
+ async def _execute_mcp_function_with_retry(
1097
+ self,
1098
+ function_name: str,
1099
+ arguments_json: str,
1100
+ max_retries: int = 3,
1101
+ ) -> List[str | Any]:
1102
+ """Execute MCP function with Claude-specific formatting."""
1103
+ # Use parent class method which returns tuple
1104
+ result_str, result_obj = await super()._execute_mcp_function_with_retry(
1105
+ function_name,
1106
+ arguments_json,
1107
+ max_retries,
1108
+ )
1109
+
1110
+ # Convert to list format expected by Claude streaming
1111
+ if result_str.startswith("Error:"):
1112
+ return [result_str]
1113
+ return [result_str, result_obj]
1114
+
1115
+ def create_tool_result_message(self, tool_call: Dict[str, Any], result_content: str) -> Dict[str, Any]:
588
1116
  """Create tool result message in Claude's expected format."""
589
1117
  tool_call_id = self.extract_tool_call_id(tool_call)
590
1118
  return {
@@ -594,7 +1122,7 @@ class ClaudeBackend(LLMBackend):
594
1122
  "type": "tool_result",
595
1123
  "tool_use_id": tool_call_id,
596
1124
  "content": result_content,
597
- }
1125
+ },
598
1126
  ],
599
1127
  }
600
1128
 
@@ -607,55 +1135,23 @@ class ClaudeBackend(LLMBackend):
607
1135
  return item.get("content", "")
608
1136
  return ""
609
1137
 
610
- def estimate_tokens(self, text: str) -> int:
611
- """Estimate token count for text (Claude uses ~4 chars per token)."""
612
- return len(text) // 4
613
-
614
- def calculate_cost(
615
- self, input_tokens: int, output_tokens: int, model: str
616
- ) -> float:
617
- """Calculate cost for Claude token usage (2025 pricing)."""
618
- model_lower = model.lower()
619
-
620
- if "claude-4" in model_lower:
621
- if "opus" in model_lower:
622
- # Claude 4 Opus
623
- input_cost = (input_tokens / 1_000_000) * 15.0
624
- output_cost = (output_tokens / 1_000_000) * 75.0
625
- else:
626
- # Claude 4 Sonnet
627
- input_cost = (input_tokens / 1_000_000) * 3.0
628
- output_cost = (output_tokens / 1_000_000) * 15.0
629
- elif "claude-3.7" in model_lower or "claude-3-7" in model_lower:
630
- # Claude 3.7 Sonnet
631
- input_cost = (input_tokens / 1_000_000) * 3.0
632
- output_cost = (output_tokens / 1_000_000) * 15.0
633
- elif "claude-3.5" in model_lower or "claude-3-5" in model_lower:
634
- if "haiku" in model_lower:
635
- # Claude 3.5 Haiku
636
- input_cost = (input_tokens / 1_000_000) * 1.0
637
- output_cost = (output_tokens / 1_000_000) * 5.0
638
- else:
639
- # Claude 3.5 Sonnet (legacy)
640
- input_cost = (input_tokens / 1_000_000) * 3.0
641
- output_cost = (output_tokens / 1_000_000) * 15.0
642
- else:
643
- # Default fallback (assume Claude 4 Sonnet pricing)
644
- input_cost = (input_tokens / 1_000_000) * 3.0
645
- output_cost = (output_tokens / 1_000_000) * 15.0
646
-
647
- # Add tool usage costs
648
- tool_costs = 0.0
649
- if self.search_count > 0:
650
- tool_costs += (self.search_count / 1000) * 10.0 # $10 per 1,000 searches
651
-
652
- if self.code_session_hours > 0:
653
- tool_costs += self.code_session_hours * 0.05 # $0.05 per session-hour
654
-
655
- return input_cost + output_cost + tool_costs
656
-
657
1138
  def reset_tool_usage(self):
658
1139
  """Reset tool usage tracking."""
659
1140
  self.search_count = 0
660
1141
  self.code_session_hours = 0.0
661
1142
  super().reset_token_usage()
1143
+
1144
+ def _create_client(self, **kwargs):
1145
+ return anthropic.AsyncAnthropic(api_key=self.api_key)
1146
+
1147
+ def get_provider_name(self) -> str:
1148
+ """Get the provider name."""
1149
+ return "Claude"
1150
+
1151
+ def get_supported_builtin_tools(self) -> List[str]:
1152
+ """Get list of builtin tools supported by Claude."""
1153
+ return ["web_search", "code_execution"]
1154
+
1155
+ def get_filesystem_support(self) -> FilesystemSupport:
1156
+ """Claude supports filesystem through MCP servers."""
1157
+ return FilesystemSupport.MCP