alita-sdk 0.3.379__py3-none-any.whl → 0.3.627__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.
Files changed (278) hide show
  1. alita_sdk/cli/__init__.py +10 -0
  2. alita_sdk/cli/__main__.py +17 -0
  3. alita_sdk/cli/agent/__init__.py +5 -0
  4. alita_sdk/cli/agent/default.py +258 -0
  5. alita_sdk/cli/agent_executor.py +156 -0
  6. alita_sdk/cli/agent_loader.py +245 -0
  7. alita_sdk/cli/agent_ui.py +228 -0
  8. alita_sdk/cli/agents.py +3113 -0
  9. alita_sdk/cli/callbacks.py +647 -0
  10. alita_sdk/cli/cli.py +168 -0
  11. alita_sdk/cli/config.py +306 -0
  12. alita_sdk/cli/context/__init__.py +30 -0
  13. alita_sdk/cli/context/cleanup.py +198 -0
  14. alita_sdk/cli/context/manager.py +731 -0
  15. alita_sdk/cli/context/message.py +285 -0
  16. alita_sdk/cli/context/strategies.py +289 -0
  17. alita_sdk/cli/context/token_estimation.py +127 -0
  18. alita_sdk/cli/formatting.py +182 -0
  19. alita_sdk/cli/input_handler.py +419 -0
  20. alita_sdk/cli/inventory.py +1073 -0
  21. alita_sdk/cli/mcp_loader.py +315 -0
  22. alita_sdk/cli/testcases/__init__.py +94 -0
  23. alita_sdk/cli/testcases/data_generation.py +119 -0
  24. alita_sdk/cli/testcases/discovery.py +96 -0
  25. alita_sdk/cli/testcases/executor.py +84 -0
  26. alita_sdk/cli/testcases/logger.py +85 -0
  27. alita_sdk/cli/testcases/parser.py +172 -0
  28. alita_sdk/cli/testcases/prompts.py +91 -0
  29. alita_sdk/cli/testcases/reporting.py +125 -0
  30. alita_sdk/cli/testcases/setup.py +108 -0
  31. alita_sdk/cli/testcases/test_runner.py +282 -0
  32. alita_sdk/cli/testcases/utils.py +39 -0
  33. alita_sdk/cli/testcases/validation.py +90 -0
  34. alita_sdk/cli/testcases/workflow.py +196 -0
  35. alita_sdk/cli/toolkit.py +327 -0
  36. alita_sdk/cli/toolkit_loader.py +85 -0
  37. alita_sdk/cli/tools/__init__.py +43 -0
  38. alita_sdk/cli/tools/approval.py +224 -0
  39. alita_sdk/cli/tools/filesystem.py +1751 -0
  40. alita_sdk/cli/tools/planning.py +389 -0
  41. alita_sdk/cli/tools/terminal.py +414 -0
  42. alita_sdk/community/__init__.py +72 -12
  43. alita_sdk/community/inventory/__init__.py +236 -0
  44. alita_sdk/community/inventory/config.py +257 -0
  45. alita_sdk/community/inventory/enrichment.py +2137 -0
  46. alita_sdk/community/inventory/extractors.py +1469 -0
  47. alita_sdk/community/inventory/ingestion.py +3172 -0
  48. alita_sdk/community/inventory/knowledge_graph.py +1457 -0
  49. alita_sdk/community/inventory/parsers/__init__.py +218 -0
  50. alita_sdk/community/inventory/parsers/base.py +295 -0
  51. alita_sdk/community/inventory/parsers/csharp_parser.py +907 -0
  52. alita_sdk/community/inventory/parsers/go_parser.py +851 -0
  53. alita_sdk/community/inventory/parsers/html_parser.py +389 -0
  54. alita_sdk/community/inventory/parsers/java_parser.py +593 -0
  55. alita_sdk/community/inventory/parsers/javascript_parser.py +629 -0
  56. alita_sdk/community/inventory/parsers/kotlin_parser.py +768 -0
  57. alita_sdk/community/inventory/parsers/markdown_parser.py +362 -0
  58. alita_sdk/community/inventory/parsers/python_parser.py +604 -0
  59. alita_sdk/community/inventory/parsers/rust_parser.py +858 -0
  60. alita_sdk/community/inventory/parsers/swift_parser.py +832 -0
  61. alita_sdk/community/inventory/parsers/text_parser.py +322 -0
  62. alita_sdk/community/inventory/parsers/yaml_parser.py +370 -0
  63. alita_sdk/community/inventory/patterns/__init__.py +61 -0
  64. alita_sdk/community/inventory/patterns/ast_adapter.py +380 -0
  65. alita_sdk/community/inventory/patterns/loader.py +348 -0
  66. alita_sdk/community/inventory/patterns/registry.py +198 -0
  67. alita_sdk/community/inventory/presets.py +535 -0
  68. alita_sdk/community/inventory/retrieval.py +1403 -0
  69. alita_sdk/community/inventory/toolkit.py +173 -0
  70. alita_sdk/community/inventory/toolkit_utils.py +176 -0
  71. alita_sdk/community/inventory/visualize.py +1370 -0
  72. alita_sdk/configurations/__init__.py +1 -1
  73. alita_sdk/configurations/ado.py +141 -20
  74. alita_sdk/configurations/bitbucket.py +94 -2
  75. alita_sdk/configurations/confluence.py +130 -1
  76. alita_sdk/configurations/figma.py +76 -0
  77. alita_sdk/configurations/gitlab.py +91 -0
  78. alita_sdk/configurations/jira.py +103 -0
  79. alita_sdk/configurations/openapi.py +329 -0
  80. alita_sdk/configurations/qtest.py +72 -1
  81. alita_sdk/configurations/report_portal.py +96 -0
  82. alita_sdk/configurations/sharepoint.py +148 -0
  83. alita_sdk/configurations/testio.py +83 -0
  84. alita_sdk/configurations/testrail.py +88 -0
  85. alita_sdk/configurations/xray.py +93 -0
  86. alita_sdk/configurations/zephyr_enterprise.py +93 -0
  87. alita_sdk/configurations/zephyr_essential.py +75 -0
  88. alita_sdk/runtime/clients/artifact.py +3 -3
  89. alita_sdk/runtime/clients/client.py +388 -46
  90. alita_sdk/runtime/clients/mcp_discovery.py +342 -0
  91. alita_sdk/runtime/clients/mcp_manager.py +262 -0
  92. alita_sdk/runtime/clients/sandbox_client.py +8 -21
  93. alita_sdk/runtime/langchain/_constants_bkup.py +1318 -0
  94. alita_sdk/runtime/langchain/assistant.py +157 -39
  95. alita_sdk/runtime/langchain/constants.py +647 -1
  96. alita_sdk/runtime/langchain/document_loaders/AlitaDocxMammothLoader.py +315 -3
  97. alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +103 -60
  98. alita_sdk/runtime/langchain/document_loaders/AlitaJSONLinesLoader.py +77 -0
  99. alita_sdk/runtime/langchain/document_loaders/AlitaJSONLoader.py +10 -4
  100. alita_sdk/runtime/langchain/document_loaders/AlitaPowerPointLoader.py +226 -7
  101. alita_sdk/runtime/langchain/document_loaders/AlitaTextLoader.py +5 -2
  102. alita_sdk/runtime/langchain/document_loaders/constants.py +40 -19
  103. alita_sdk/runtime/langchain/langraph_agent.py +405 -84
  104. alita_sdk/runtime/langchain/utils.py +106 -7
  105. alita_sdk/runtime/llms/preloaded.py +2 -6
  106. alita_sdk/runtime/models/mcp_models.py +61 -0
  107. alita_sdk/runtime/skills/__init__.py +91 -0
  108. alita_sdk/runtime/skills/callbacks.py +498 -0
  109. alita_sdk/runtime/skills/discovery.py +540 -0
  110. alita_sdk/runtime/skills/executor.py +610 -0
  111. alita_sdk/runtime/skills/input_builder.py +371 -0
  112. alita_sdk/runtime/skills/models.py +330 -0
  113. alita_sdk/runtime/skills/registry.py +355 -0
  114. alita_sdk/runtime/skills/skill_runner.py +330 -0
  115. alita_sdk/runtime/toolkits/__init__.py +31 -0
  116. alita_sdk/runtime/toolkits/application.py +29 -10
  117. alita_sdk/runtime/toolkits/artifact.py +20 -11
  118. alita_sdk/runtime/toolkits/datasource.py +13 -6
  119. alita_sdk/runtime/toolkits/mcp.py +783 -0
  120. alita_sdk/runtime/toolkits/mcp_config.py +1048 -0
  121. alita_sdk/runtime/toolkits/planning.py +178 -0
  122. alita_sdk/runtime/toolkits/skill_router.py +238 -0
  123. alita_sdk/runtime/toolkits/subgraph.py +251 -6
  124. alita_sdk/runtime/toolkits/tools.py +356 -69
  125. alita_sdk/runtime/toolkits/vectorstore.py +11 -5
  126. alita_sdk/runtime/tools/__init__.py +10 -3
  127. alita_sdk/runtime/tools/application.py +27 -6
  128. alita_sdk/runtime/tools/artifact.py +511 -28
  129. alita_sdk/runtime/tools/data_analysis.py +183 -0
  130. alita_sdk/runtime/tools/function.py +67 -35
  131. alita_sdk/runtime/tools/graph.py +10 -4
  132. alita_sdk/runtime/tools/image_generation.py +148 -46
  133. alita_sdk/runtime/tools/llm.py +1003 -128
  134. alita_sdk/runtime/tools/loop.py +3 -1
  135. alita_sdk/runtime/tools/loop_output.py +3 -1
  136. alita_sdk/runtime/tools/mcp_inspect_tool.py +284 -0
  137. alita_sdk/runtime/tools/mcp_remote_tool.py +181 -0
  138. alita_sdk/runtime/tools/mcp_server_tool.py +8 -5
  139. alita_sdk/runtime/tools/planning/__init__.py +36 -0
  140. alita_sdk/runtime/tools/planning/models.py +246 -0
  141. alita_sdk/runtime/tools/planning/wrapper.py +607 -0
  142. alita_sdk/runtime/tools/router.py +2 -4
  143. alita_sdk/runtime/tools/sandbox.py +65 -48
  144. alita_sdk/runtime/tools/skill_router.py +776 -0
  145. alita_sdk/runtime/tools/tool.py +3 -1
  146. alita_sdk/runtime/tools/vectorstore.py +9 -3
  147. alita_sdk/runtime/tools/vectorstore_base.py +70 -14
  148. alita_sdk/runtime/utils/AlitaCallback.py +137 -21
  149. alita_sdk/runtime/utils/constants.py +5 -1
  150. alita_sdk/runtime/utils/mcp_client.py +492 -0
  151. alita_sdk/runtime/utils/mcp_oauth.py +361 -0
  152. alita_sdk/runtime/utils/mcp_sse_client.py +434 -0
  153. alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
  154. alita_sdk/runtime/utils/serialization.py +155 -0
  155. alita_sdk/runtime/utils/streamlit.py +40 -13
  156. alita_sdk/runtime/utils/toolkit_utils.py +30 -9
  157. alita_sdk/runtime/utils/utils.py +36 -0
  158. alita_sdk/tools/__init__.py +134 -35
  159. alita_sdk/tools/ado/repos/__init__.py +51 -32
  160. alita_sdk/tools/ado/repos/repos_wrapper.py +148 -89
  161. alita_sdk/tools/ado/test_plan/__init__.py +25 -9
  162. alita_sdk/tools/ado/test_plan/test_plan_wrapper.py +23 -1
  163. alita_sdk/tools/ado/utils.py +1 -18
  164. alita_sdk/tools/ado/wiki/__init__.py +25 -12
  165. alita_sdk/tools/ado/wiki/ado_wrapper.py +291 -22
  166. alita_sdk/tools/ado/work_item/__init__.py +26 -13
  167. alita_sdk/tools/ado/work_item/ado_wrapper.py +73 -11
  168. alita_sdk/tools/advanced_jira_mining/__init__.py +11 -8
  169. alita_sdk/tools/aws/delta_lake/__init__.py +13 -9
  170. alita_sdk/tools/aws/delta_lake/tool.py +5 -1
  171. alita_sdk/tools/azure_ai/search/__init__.py +11 -8
  172. alita_sdk/tools/azure_ai/search/api_wrapper.py +1 -1
  173. alita_sdk/tools/base/tool.py +5 -1
  174. alita_sdk/tools/base_indexer_toolkit.py +271 -84
  175. alita_sdk/tools/bitbucket/__init__.py +17 -11
  176. alita_sdk/tools/bitbucket/api_wrapper.py +59 -11
  177. alita_sdk/tools/bitbucket/cloud_api_wrapper.py +49 -35
  178. alita_sdk/tools/browser/__init__.py +5 -4
  179. alita_sdk/tools/carrier/__init__.py +5 -6
  180. alita_sdk/tools/carrier/backend_reports_tool.py +6 -6
  181. alita_sdk/tools/carrier/run_ui_test_tool.py +6 -6
  182. alita_sdk/tools/carrier/ui_reports_tool.py +5 -5
  183. alita_sdk/tools/chunkers/__init__.py +3 -1
  184. alita_sdk/tools/chunkers/code/treesitter/treesitter.py +37 -13
  185. alita_sdk/tools/chunkers/sematic/json_chunker.py +1 -0
  186. alita_sdk/tools/chunkers/sematic/markdown_chunker.py +97 -6
  187. alita_sdk/tools/chunkers/sematic/proposal_chunker.py +1 -1
  188. alita_sdk/tools/chunkers/universal_chunker.py +270 -0
  189. alita_sdk/tools/cloud/aws/__init__.py +10 -7
  190. alita_sdk/tools/cloud/azure/__init__.py +10 -7
  191. alita_sdk/tools/cloud/gcp/__init__.py +10 -7
  192. alita_sdk/tools/cloud/k8s/__init__.py +10 -7
  193. alita_sdk/tools/code/linter/__init__.py +10 -8
  194. alita_sdk/tools/code/loaders/codesearcher.py +3 -2
  195. alita_sdk/tools/code/sonar/__init__.py +11 -8
  196. alita_sdk/tools/code_indexer_toolkit.py +82 -22
  197. alita_sdk/tools/confluence/__init__.py +22 -16
  198. alita_sdk/tools/confluence/api_wrapper.py +107 -30
  199. alita_sdk/tools/confluence/loader.py +14 -2
  200. alita_sdk/tools/custom_open_api/__init__.py +12 -5
  201. alita_sdk/tools/elastic/__init__.py +11 -8
  202. alita_sdk/tools/elitea_base.py +493 -30
  203. alita_sdk/tools/figma/__init__.py +58 -11
  204. alita_sdk/tools/figma/api_wrapper.py +1235 -143
  205. alita_sdk/tools/figma/figma_client.py +73 -0
  206. alita_sdk/tools/figma/toon_tools.py +2748 -0
  207. alita_sdk/tools/github/__init__.py +14 -15
  208. alita_sdk/tools/github/github_client.py +224 -100
  209. alita_sdk/tools/github/graphql_client_wrapper.py +119 -33
  210. alita_sdk/tools/github/schemas.py +14 -5
  211. alita_sdk/tools/github/tool.py +5 -1
  212. alita_sdk/tools/github/tool_prompts.py +9 -22
  213. alita_sdk/tools/gitlab/__init__.py +16 -11
  214. alita_sdk/tools/gitlab/api_wrapper.py +218 -48
  215. alita_sdk/tools/gitlab_org/__init__.py +10 -9
  216. alita_sdk/tools/gitlab_org/api_wrapper.py +63 -64
  217. alita_sdk/tools/google/bigquery/__init__.py +13 -12
  218. alita_sdk/tools/google/bigquery/tool.py +5 -1
  219. alita_sdk/tools/google_places/__init__.py +11 -8
  220. alita_sdk/tools/google_places/api_wrapper.py +1 -1
  221. alita_sdk/tools/jira/__init__.py +17 -10
  222. alita_sdk/tools/jira/api_wrapper.py +92 -41
  223. alita_sdk/tools/keycloak/__init__.py +11 -8
  224. alita_sdk/tools/localgit/__init__.py +9 -3
  225. alita_sdk/tools/localgit/local_git.py +62 -54
  226. alita_sdk/tools/localgit/tool.py +5 -1
  227. alita_sdk/tools/memory/__init__.py +12 -4
  228. alita_sdk/tools/non_code_indexer_toolkit.py +1 -0
  229. alita_sdk/tools/ocr/__init__.py +11 -8
  230. alita_sdk/tools/openapi/__init__.py +491 -106
  231. alita_sdk/tools/openapi/api_wrapper.py +1368 -0
  232. alita_sdk/tools/openapi/tool.py +20 -0
  233. alita_sdk/tools/pandas/__init__.py +20 -12
  234. alita_sdk/tools/pandas/api_wrapper.py +38 -25
  235. alita_sdk/tools/pandas/dataframe/generator/base.py +3 -1
  236. alita_sdk/tools/postman/__init__.py +10 -9
  237. alita_sdk/tools/pptx/__init__.py +11 -10
  238. alita_sdk/tools/pptx/pptx_wrapper.py +1 -1
  239. alita_sdk/tools/qtest/__init__.py +31 -11
  240. alita_sdk/tools/qtest/api_wrapper.py +2135 -86
  241. alita_sdk/tools/rally/__init__.py +10 -9
  242. alita_sdk/tools/rally/api_wrapper.py +1 -1
  243. alita_sdk/tools/report_portal/__init__.py +12 -8
  244. alita_sdk/tools/salesforce/__init__.py +10 -8
  245. alita_sdk/tools/servicenow/__init__.py +17 -15
  246. alita_sdk/tools/servicenow/api_wrapper.py +1 -1
  247. alita_sdk/tools/sharepoint/__init__.py +10 -7
  248. alita_sdk/tools/sharepoint/api_wrapper.py +129 -38
  249. alita_sdk/tools/sharepoint/authorization_helper.py +191 -1
  250. alita_sdk/tools/sharepoint/utils.py +8 -2
  251. alita_sdk/tools/slack/__init__.py +10 -7
  252. alita_sdk/tools/slack/api_wrapper.py +2 -2
  253. alita_sdk/tools/sql/__init__.py +12 -9
  254. alita_sdk/tools/testio/__init__.py +10 -7
  255. alita_sdk/tools/testrail/__init__.py +11 -10
  256. alita_sdk/tools/testrail/api_wrapper.py +1 -1
  257. alita_sdk/tools/utils/__init__.py +9 -4
  258. alita_sdk/tools/utils/content_parser.py +103 -18
  259. alita_sdk/tools/utils/text_operations.py +410 -0
  260. alita_sdk/tools/utils/tool_prompts.py +79 -0
  261. alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +30 -13
  262. alita_sdk/tools/xray/__init__.py +13 -9
  263. alita_sdk/tools/yagmail/__init__.py +9 -3
  264. alita_sdk/tools/zephyr/__init__.py +10 -7
  265. alita_sdk/tools/zephyr_enterprise/__init__.py +11 -7
  266. alita_sdk/tools/zephyr_essential/__init__.py +10 -7
  267. alita_sdk/tools/zephyr_essential/api_wrapper.py +30 -13
  268. alita_sdk/tools/zephyr_essential/client.py +2 -2
  269. alita_sdk/tools/zephyr_scale/__init__.py +11 -8
  270. alita_sdk/tools/zephyr_scale/api_wrapper.py +2 -2
  271. alita_sdk/tools/zephyr_squad/__init__.py +10 -7
  272. {alita_sdk-0.3.379.dist-info → alita_sdk-0.3.627.dist-info}/METADATA +154 -8
  273. alita_sdk-0.3.627.dist-info/RECORD +468 -0
  274. alita_sdk-0.3.627.dist-info/entry_points.txt +2 -0
  275. alita_sdk-0.3.379.dist-info/RECORD +0 -360
  276. {alita_sdk-0.3.379.dist-info → alita_sdk-0.3.627.dist-info}/WHEEL +0 -0
  277. {alita_sdk-0.3.379.dist-info → alita_sdk-0.3.627.dist-info}/licenses/LICENSE +0 -0
  278. {alita_sdk-0.3.379.dist-info → alita_sdk-0.3.627.dist-info}/top_level.txt +0 -0
@@ -1,16 +1,60 @@
1
+ import asyncio
1
2
  import logging
2
3
  from traceback import format_exc
3
- from typing import Any, Optional, List, Union
4
+ from typing import Any, Optional, List, Union, Literal, Dict
4
5
 
5
6
  from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
6
7
  from langchain_core.runnables import RunnableConfig
7
8
  from langchain_core.tools import BaseTool, ToolException
9
+ from langchain_core.callbacks import dispatch_custom_event
8
10
  from pydantic import Field
9
11
 
12
+ from ..langchain.constants import ELITEA_RS
10
13
  from ..langchain.utils import create_pydantic_model, propagate_the_input_mapping
11
14
 
12
15
  logger = logging.getLogger(__name__)
13
16
 
17
+
18
+ # def _is_thinking_model(llm_client: Any) -> bool:
19
+ # """
20
+ # Check if a model uses extended thinking capability by reading cached metadata.
21
+
22
+ # Thinking models require special message formatting where assistant messages
23
+ # must start with thinking blocks before tool_use blocks.
24
+
25
+ # This function reads the `_supports_reasoning` attribute that should be set
26
+ # when the LLM client is created (by checking the model's supports_reasoning field).
27
+
28
+ # Args:
29
+ # llm_client: LLM client instance with optional _supports_reasoning attribute
30
+
31
+ # Returns:
32
+ # True if the model is a thinking model, False otherwise
33
+ # """
34
+ # if not llm_client:
35
+ # return False
36
+
37
+ # # Check if supports_reasoning was cached on the client
38
+ # supports_reasoning = getattr(llm_client, '_supports_reasoning', False)
39
+
40
+ # if supports_reasoning:
41
+ # model_name = getattr(llm_client, 'model_name', None) or getattr(llm_client, 'model', 'unknown')
42
+ # logger.debug(f"Model '{model_name}' is a thinking/reasoning model (cached from API metadata)")
43
+
44
+ # return supports_reasoning
45
+
46
+ JSON_INSTRUCTION_TEMPLATE = (
47
+ "\n\n**IMPORTANT: You MUST respond with ONLY a valid JSON object.**\n\n"
48
+ "Required JSON fields:\n{field_descriptions}\n\n"
49
+ "Example format:\n"
50
+ "{{\n{example_fields}\n}}\n\n"
51
+ "Rules:\n"
52
+ "1. Output ONLY the JSON object - no markdown, no explanations, no extra text\n"
53
+ "2. Ensure all required fields are present\n"
54
+ "3. Use proper JSON syntax with double quotes for strings\n"
55
+ "4. Do not wrap the JSON in code blocks or backticks"
56
+ )
57
+
14
58
  class LLMNode(BaseTool):
15
59
  """Enhanced LLM node with chat history and tool binding support"""
16
60
 
@@ -23,13 +67,250 @@ class LLMNode(BaseTool):
23
67
  client: Any = Field(default=None, description='LLM client instance')
24
68
  return_type: str = Field(default="str", description='Return type')
25
69
  response_key: str = Field(default="messages", description='Response key')
26
- structured_output_dict: Optional[dict[str, str]] = Field(default=None, description='Structured output dictionary')
70
+ structured_output_dict: Optional[Dict[str, Any]] = Field(default=None, description='Structured output dictionary')
27
71
  output_variables: Optional[List[str]] = Field(default=None, description='Output variables')
28
72
  input_mapping: Optional[dict[str, dict]] = Field(default=None, description='Input mapping')
29
73
  input_variables: Optional[List[str]] = Field(default=None, description='Input variables')
30
74
  structured_output: Optional[bool] = Field(default=False, description='Whether to use structured output')
31
75
  available_tools: Optional[List[BaseTool]] = Field(default=None, description='Available tools for binding')
32
76
  tool_names: Optional[List[str]] = Field(default=None, description='Specific tool names to filter')
77
+ steps_limit: Optional[int] = Field(default=25, description='Maximum steps for tool execution')
78
+ tool_execution_timeout: Optional[int] = Field(default=900, description='Timeout (seconds) for tool execution. Default is 15 minutes.')
79
+
80
+ def _prepare_structured_output_params(self) -> dict:
81
+ """
82
+ Prepare structured output parameters from structured_output_dict.
83
+
84
+ Expected self.structured_output_dict formats:
85
+ - {"field": "str"} / {"field": "list"} / {"field": "list[dict]"} / {"field": "any"} ...
86
+ - OR {"field": {"type": "...", "description": "...", "default": ...}} (optional)
87
+
88
+ Returns:
89
+ Dict[str, Dict] suitable for create_pydantic_model(...)
90
+ """
91
+ struct_params: dict[str, dict] = {}
92
+
93
+ for key, value in (self.structured_output_dict or {}).items():
94
+ # Allow either a plain type string or a dict with details
95
+ if isinstance(value, dict):
96
+ type_str = str(value.get("type") or "any")
97
+ desc = value.get("description", "") or ""
98
+ entry: dict = {"type": type_str, "description": desc}
99
+ if "default" in value:
100
+ entry["default"] = value["default"]
101
+ else:
102
+ # Ensure we always have a string type
103
+ if isinstance(value, str):
104
+ type_str = value
105
+ else:
106
+ # If it's already a type object, convert to string representation
107
+ type_str = getattr(value, '__name__', 'any')
108
+
109
+ entry = {"type": type_str, "description": ""}
110
+
111
+ struct_params[key] = entry
112
+
113
+ # Add default output field for proper response to user
114
+ struct_params[ELITEA_RS] = {
115
+ "description": "final output to user (summarized output from LLM)",
116
+ "type": "str",
117
+ "default": None,
118
+ }
119
+
120
+ return struct_params
121
+
122
+ def _invoke_with_structured_output(self, llm_client: Any, messages: List, struct_model: Any, config: RunnableConfig):
123
+ """
124
+ Invoke LLM with structured output, handling tool calls if present.
125
+
126
+ Args:
127
+ llm_client: LLM client instance
128
+ messages: List of conversation messages
129
+ struct_model: Pydantic model for structured output
130
+ config: Runnable configuration
131
+
132
+ Returns:
133
+ Tuple of (completion, initial_completion, final_messages)
134
+ """
135
+ initial_completion = llm_client.invoke(messages, config=config)
136
+
137
+ if hasattr(initial_completion, 'tool_calls') and initial_completion.tool_calls:
138
+ # Handle tool calls first, then apply structured output
139
+ new_messages, _ = self._run_async_in_sync_context(
140
+ self.__perform_tool_calling(initial_completion, messages, llm_client, config)
141
+ )
142
+ llm = self.__get_struct_output_model(llm_client, struct_model)
143
+ completion = llm.invoke(new_messages, config=config)
144
+ return completion, initial_completion, new_messages
145
+ else:
146
+ # Direct structured output without tool calls
147
+ llm = self.__get_struct_output_model(llm_client, struct_model)
148
+ completion = llm.invoke(messages, config=config)
149
+ return completion, initial_completion, messages
150
+
151
+ def _build_json_instruction(self, struct_model: Any) -> str:
152
+ """
153
+ Build JSON instruction message for fallback handling.
154
+
155
+ Args:
156
+ struct_model: Pydantic model with field definitions
157
+
158
+ Returns:
159
+ Formatted JSON instruction string
160
+ """
161
+ field_descriptions = []
162
+ for name, field in struct_model.model_fields.items():
163
+ field_type = field.annotation.__name__ if hasattr(field.annotation, '__name__') else str(field.annotation)
164
+ field_desc = field.description or field_type
165
+ field_descriptions.append(f" - {name} ({field_type}): {field_desc}")
166
+
167
+ example_fields = ",\n".join([
168
+ f' "{k}": <{field.annotation.__name__ if hasattr(field.annotation, "__name__") else "value"}>'
169
+ for k, field in struct_model.model_fields.items()
170
+ ])
171
+
172
+ return JSON_INSTRUCTION_TEMPLATE.format(
173
+ field_descriptions="\n".join(field_descriptions),
174
+ example_fields=example_fields
175
+ )
176
+
177
+ def _create_fallback_completion(self, content: str, struct_model: Any) -> Any:
178
+ """
179
+ Create a fallback completion object when JSON parsing fails.
180
+
181
+ Args:
182
+ content: Plain text content from LLM
183
+ struct_model: Pydantic model to construct
184
+
185
+ Returns:
186
+ Pydantic model instance with fallback values
187
+ """
188
+ result_dict = {}
189
+ for k, field in struct_model.model_fields.items():
190
+ if k == ELITEA_RS:
191
+ result_dict[k] = content
192
+ elif field.is_required():
193
+ # Set default values for required fields based on type
194
+ result_dict[k] = field.default if field.default is not None else None
195
+ else:
196
+ result_dict[k] = field.default
197
+ return struct_model.model_construct(**result_dict)
198
+
199
+ def _handle_structured_output_fallback(self, llm_client: Any, messages: List, struct_model: Any,
200
+ config: RunnableConfig, original_error: Exception) -> Any:
201
+ """
202
+ Handle structured output fallback through multiple strategies.
203
+
204
+ Tries fallback methods in order:
205
+ 1. json_mode with explicit instructions
206
+ 2. function_calling method
207
+ 3. Plain text with JSON extraction
208
+
209
+ Args:
210
+ llm_client: LLM client instance
211
+ messages: Original conversation messages
212
+ struct_model: Pydantic model for structured output
213
+ config: Runnable configuration
214
+ original_error: The original ValueError that triggered fallback
215
+
216
+ Returns:
217
+ Completion with structured output (best effort)
218
+
219
+ Raises:
220
+ Propagates exceptions from LLM invocation
221
+ """
222
+ logger.error(f"Error invoking structured output model: {format_exc()}")
223
+ logger.info("Attempting to fall back to json mode")
224
+
225
+ # Build JSON instruction once
226
+ json_instruction = self._build_json_instruction(struct_model)
227
+
228
+ # Add instruction to messages
229
+ modified_messages = messages.copy()
230
+ if modified_messages and isinstance(modified_messages[-1], HumanMessage):
231
+ modified_messages[-1] = HumanMessage(
232
+ content=modified_messages[-1].content + json_instruction
233
+ )
234
+ else:
235
+ modified_messages.append(HumanMessage(content=json_instruction))
236
+
237
+ # Try json_mode with explicit instructions
238
+ try:
239
+ completion = self.__get_struct_output_model(
240
+ llm_client, struct_model, method="json_mode"
241
+ ).invoke(modified_messages, config=config)
242
+ return completion
243
+ except Exception as json_mode_error:
244
+ logger.warning(f"json_mode also failed: {json_mode_error}")
245
+ logger.info("Falling back to function_calling method")
246
+
247
+ # Try function_calling as a third fallback
248
+ try:
249
+ completion = self.__get_struct_output_model(
250
+ llm_client, struct_model, method="function_calling"
251
+ ).invoke(modified_messages, config=config)
252
+ return completion
253
+ except Exception as function_calling_error:
254
+ logger.error(f"function_calling also failed: {function_calling_error}")
255
+ logger.info("Final fallback: using plain LLM response")
256
+
257
+ # Last resort: get plain text response and wrap in structure
258
+ plain_completion = llm_client.invoke(modified_messages, config=config)
259
+ content = plain_completion.content.strip() if hasattr(plain_completion, 'content') else str(plain_completion)
260
+
261
+ # Try to extract JSON from the response
262
+ import json
263
+ import re
264
+
265
+ json_match = re.search(r'\{.*\}', content, re.DOTALL)
266
+ if json_match:
267
+ try:
268
+ parsed_json = json.loads(json_match.group(0))
269
+ # Validate it has expected fields and wrap in pydantic model
270
+ completion = struct_model(**parsed_json)
271
+ return completion
272
+ except (json.JSONDecodeError, Exception) as parse_error:
273
+ logger.warning(f"Could not parse extracted JSON: {parse_error}")
274
+ return self._create_fallback_completion(content, struct_model)
275
+ else:
276
+ # No JSON found, create response with content in elitea_response
277
+ return self._create_fallback_completion(content, struct_model)
278
+
279
+ def _format_structured_output_result(self, result: dict, messages: List, initial_completion: Any) -> dict:
280
+ """
281
+ Format structured output result with properly formatted messages.
282
+
283
+ Args:
284
+ result: Result dictionary from model_dump()
285
+ messages: Original conversation messages
286
+ initial_completion: Initial completion before tool calls
287
+
288
+ Returns:
289
+ Formatted result dictionary with messages
290
+ """
291
+ # Ensure messages are properly formatted
292
+ if result.get('messages') and isinstance(result['messages'], list):
293
+ result['messages'] = [{'role': 'assistant', 'content': '\n'.join(result['messages'])}]
294
+ else:
295
+ # Extract content from initial_completion, handling thinking blocks
296
+ fallback_content = result.get(ELITEA_RS, '')
297
+ if not fallback_content and initial_completion:
298
+ content_parts = self._extract_content_from_completion(initial_completion)
299
+ fallback_content = content_parts.get('text') or ''
300
+ thinking = content_parts.get('thinking')
301
+
302
+ # Log thinking if present
303
+ if thinking:
304
+ logger.debug(f"Thinking content present in structured output: {thinking[:100]}...")
305
+
306
+ if not fallback_content:
307
+ # Final fallback to raw content
308
+ content = initial_completion.content
309
+ fallback_content = content if isinstance(content, str) else str(content)
310
+
311
+ result['messages'] = messages + [AIMessage(content=fallback_content)]
312
+
313
+ return result
33
314
 
34
315
  def get_filtered_tools(self) -> List[BaseTool]:
35
316
  """
@@ -58,6 +339,47 @@ class LLMNode(BaseTool):
58
339
 
59
340
  return filtered_tools
60
341
 
342
+ def _get_tool_truncation_suggestions(self, tool_name: Optional[str]) -> str:
343
+ """
344
+ Get context-specific suggestions for how to reduce output from a tool.
345
+
346
+ First checks if the tool itself provides truncation suggestions via
347
+ `truncation_suggestions` attribute or `get_truncation_suggestions()` method.
348
+ Falls back to generic suggestions if the tool doesn't provide any.
349
+
350
+ Args:
351
+ tool_name: Name of the tool that caused the context overflow
352
+
353
+ Returns:
354
+ Formatted string with numbered suggestions for the specific tool
355
+ """
356
+ suggestions = None
357
+
358
+ # Try to get suggestions from the tool itself
359
+ if tool_name:
360
+ filtered_tools = self.get_filtered_tools()
361
+ for tool in filtered_tools:
362
+ if tool.name == tool_name:
363
+ # Check for truncation_suggestions attribute
364
+ if hasattr(tool, 'truncation_suggestions') and tool.truncation_suggestions:
365
+ suggestions = tool.truncation_suggestions
366
+ break
367
+ # Check for get_truncation_suggestions method
368
+ elif hasattr(tool, 'get_truncation_suggestions') and callable(tool.get_truncation_suggestions):
369
+ suggestions = tool.get_truncation_suggestions()
370
+ break
371
+
372
+ # Fall back to generic suggestions if tool doesn't provide any
373
+ if not suggestions:
374
+ suggestions = [
375
+ "Check if the tool has parameters to limit output size (e.g., max_items, max_results, max_depth)",
376
+ "Target a more specific path or query instead of broad searches",
377
+ "Break the operation into smaller, focused requests",
378
+ ]
379
+
380
+ # Format as numbered list
381
+ return "\n".join(f"{i+1}. {s}" for i, s in enumerate(suggestions))
382
+
61
383
  def invoke(
62
384
  self,
63
385
  state: Union[str, dict],
@@ -84,12 +406,15 @@ class LLMNode(BaseTool):
84
406
  # or standalone LLM node for chat (with messages only)
85
407
  if 'system' in func_args.keys():
86
408
  # Flow for LLM node with prompt/task from pipeline
87
- if not func_args.get('system') or not func_args.get('task'):
409
+ if func_args.get('system') is None or func_args.get('task') is None:
88
410
  raise ToolException(f"LLMNode requires 'system' and 'task' parameters in input mapping. "
89
411
  f"Actual params: {func_args}")
90
412
  # cast to str in case user passes variable different from str
91
- messages = [SystemMessage(content=str(func_args.get('system'))), HumanMessage(content=str(func_args.get('task')))]
92
- messages.extend(func_args.get('chat_history', []))
413
+ messages = [SystemMessage(content=str(func_args.get('system'))), *func_args.get('chat_history', []), HumanMessage(content=str(func_args.get('task')))]
414
+ # Remove pre-last item if last two messages are same type and content
415
+ if len(messages) >= 2 and type(messages[-1]) == type(messages[-2]) and messages[-1].content == messages[
416
+ -2].content:
417
+ messages.pop(-2)
93
418
  else:
94
419
  # Flow for chat-based LLM node w/o prompt/task from pipeline but with messages in state
95
420
  # verify messages structure
@@ -115,21 +440,23 @@ class LLMNode(BaseTool):
115
440
  try:
116
441
  if self.structured_output and self.output_variables:
117
442
  # Handle structured output
118
- struct_params = {
119
- key: {
120
- "type": 'list[str]' if 'list' in value else value,
121
- "description": ""
122
- }
123
- for key, value in (self.structured_output_dict or {}).items()
124
- }
443
+ struct_params = self._prepare_structured_output_params()
125
444
  struct_model = create_pydantic_model(f"LLMOutput", struct_params)
126
- llm = llm_client.with_structured_output(struct_model)
127
- completion = llm.invoke(messages, config=config)
128
- result = completion.model_dump()
129
445
 
130
- # Ensure messages are properly formatted
131
- if result.get('messages') and isinstance(result['messages'], list):
132
- result['messages'] = [{'role': 'assistant', 'content': '\n'.join(result['messages'])}]
446
+ try:
447
+ completion, initial_completion, final_messages = self._invoke_with_structured_output(
448
+ llm_client, messages, struct_model, config
449
+ )
450
+ except ValueError as e:
451
+ # Handle fallback for structured output failures
452
+ completion = self._handle_structured_output_fallback(
453
+ llm_client, messages, struct_model, config, e
454
+ )
455
+ initial_completion = None
456
+ final_messages = messages
457
+
458
+ result = completion.model_dump()
459
+ result = self._format_structured_output_result(result, final_messages, initial_completion or completion)
133
460
 
134
461
  return result
135
462
  else:
@@ -139,138 +466,686 @@ class LLMNode(BaseTool):
139
466
  # Handle both tool-calling and regular responses
140
467
  if hasattr(completion, 'tool_calls') and completion.tool_calls:
141
468
  # Handle iterative tool-calling and execution
142
- new_messages = messages + [completion]
143
- max_iterations = 15
144
- iteration = 0
145
-
146
- # Continue executing tools until no more tool calls or max iterations reached
147
- current_completion = completion
148
- while (hasattr(current_completion, 'tool_calls') and
149
- current_completion.tool_calls and
150
- iteration < max_iterations):
151
-
152
- iteration += 1
153
- logger.info(f"Tool execution iteration {iteration}/{max_iterations}")
154
-
155
- # Execute each tool call in the current completion
156
- tool_calls = current_completion.tool_calls if hasattr(current_completion.tool_calls,
157
- '__iter__') else []
158
-
159
- for tool_call in tool_calls:
160
- tool_name = tool_call.get('name', '') if isinstance(tool_call, dict) else getattr(tool_call,
161
- 'name',
162
- '')
163
- tool_args = tool_call.get('args', {}) if isinstance(tool_call, dict) else getattr(tool_call,
164
- 'args',
165
- {})
166
- tool_call_id = tool_call.get('id', '') if isinstance(tool_call, dict) else getattr(
167
- tool_call, 'id', '')
168
-
169
- # Find the tool in filtered tools
170
- filtered_tools = self.get_filtered_tools()
171
- tool_to_execute = None
172
- for tool in filtered_tools:
173
- if tool.name == tool_name:
174
- tool_to_execute = tool
175
- break
469
+ new_messages, current_completion = self._run_async_in_sync_context(
470
+ self.__perform_tool_calling(completion, messages, llm_client, config)
471
+ )
176
472
 
177
- if tool_to_execute:
473
+ output_msgs = {"messages": new_messages}
474
+ if self.output_variables:
475
+ if self.output_variables[0] == 'messages':
476
+ return output_msgs
477
+ # Extract content properly from thinking-enabled responses
478
+ if current_completion:
479
+ content_parts = self._extract_content_from_completion(current_completion)
480
+ text_content = content_parts.get('text')
481
+ thinking = content_parts.get('thinking')
482
+
483
+ # Dispatch thinking event if present
484
+ if thinking:
178
485
  try:
179
- logger.info(f"Executing tool '{tool_name}' with args: {tool_args}")
180
- tool_result = tool_to_execute.invoke(tool_args)
181
-
182
- # Create tool message with result - preserve structured content
183
- from langchain_core.messages import ToolMessage
184
-
185
- # Check if tool_result is structured content (list of dicts)
186
- # TODO: need solid check for being compatible with ToolMessage content format
187
- if isinstance(tool_result, list) and all(
188
- isinstance(item, dict) and 'type' in item for item in tool_result
189
- ):
190
- # Use structured content directly for multimodal support
191
- tool_message = ToolMessage(
192
- content=tool_result,
193
- tool_call_id=tool_call_id
194
- )
195
- else:
196
- # Fallback to string conversion for other tool results
197
- tool_message = ToolMessage(
198
- content=str(tool_result),
199
- tool_call_id=tool_call_id
200
- )
201
- new_messages.append(tool_message)
202
-
203
- except Exception as e:
204
- logger.error(f"Error executing tool '{tool_name}': {e}")
205
- # Create error tool message
206
- from langchain_core.messages import ToolMessage
207
- tool_message = ToolMessage(
208
- content=f"Error executing {tool_name}: {str(e)}",
209
- tool_call_id=tool_call_id
486
+ model_name = getattr(llm_client, 'model_name', None) or getattr(llm_client, 'model', 'LLM')
487
+ dispatch_custom_event(
488
+ name="thinking_step",
489
+ data={
490
+ "message": thinking,
491
+ "tool_name": f"LLM ({model_name})",
492
+ "toolkit": "reasoning",
493
+ },
494
+ config=config,
210
495
  )
211
- new_messages.append(tool_message)
496
+ except Exception as e:
497
+ logger.warning(f"Failed to dispatch thinking event: {e}")
498
+
499
+ if text_content:
500
+ output_msgs[self.output_variables[0]] = text_content
212
501
  else:
213
- logger.warning(f"Tool '{tool_name}' not found in available tools")
214
- # Create error tool message for missing tool
215
- from langchain_core.messages import ToolMessage
216
- tool_message = ToolMessage(
217
- content=f"Tool '{tool_name}' not available",
218
- tool_call_id=tool_call_id
219
- )
220
- new_messages.append(tool_message)
221
-
222
- # Call LLM again with tool results to get next response
223
- try:
224
- current_completion = llm_client.invoke(new_messages, config=config)
225
- new_messages.append(current_completion)
502
+ # Fallback to raw content
503
+ content = current_completion.content
504
+ output_msgs[self.output_variables[0]] = content if isinstance(content, str) else str(content)
505
+ else:
506
+ output_msgs[self.output_variables[0]] = None
226
507
 
227
- # Check if we still have tool calls
228
- if hasattr(current_completion, 'tool_calls') and current_completion.tool_calls:
229
- logger.info(f"LLM requested {len(current_completion.tool_calls)} more tool calls")
230
- else:
231
- logger.info("LLM completed without requesting more tools")
232
- break
233
-
234
- except Exception as e:
235
- logger.error(f"Error in LLM call during iteration {iteration}: {e}")
236
- # Add error message and break the loop
237
- error_msg = f"Error processing tool results in iteration {iteration}: {str(e)}"
238
- new_messages.append(AIMessage(content=error_msg))
239
- break
240
-
241
- # Log completion status
242
- if iteration >= max_iterations:
243
- logger.warning(f"Reached maximum iterations ({max_iterations}) for tool execution")
244
- # Add a warning message to the chat
245
- warning_msg = f"Maximum tool execution iterations ({max_iterations}) reached. Stopping tool execution."
246
- new_messages.append(AIMessage(content=warning_msg))
247
- else:
248
- logger.info(f"Tool execution completed after {iteration} iterations")
249
-
250
- return {"messages": new_messages}
508
+ return output_msgs
251
509
  else:
252
- # Regular text response
253
- content = completion.content.strip() if hasattr(completion, 'content') else str(completion)
510
+ # Regular text response - handle both simple strings and thinking-enabled responses
511
+ content_parts = self._extract_content_from_completion(completion)
512
+ thinking = content_parts.get('thinking')
513
+ text_content = content_parts.get('text') or ''
514
+
515
+ # Fallback to string representation if no content extracted
516
+ if not text_content:
517
+ if hasattr(completion, 'content'):
518
+ content = completion.content
519
+ text_content = content.strip() if isinstance(content, str) else str(content)
520
+ else:
521
+ text_content = str(completion)
522
+
523
+ # Dispatch thinking step event to chat if present
524
+ if thinking:
525
+ logger.info(f"Model thinking: {thinking[:200]}..." if len(thinking) > 200 else f"Model thinking: {thinking}")
526
+
527
+ # Dispatch custom event for thinking step to be displayed in chat
528
+ try:
529
+ model_name = getattr(llm_client, 'model_name', None) or getattr(llm_client, 'model', 'LLM')
530
+ dispatch_custom_event(
531
+ name="thinking_step",
532
+ data={
533
+ "message": thinking,
534
+ "tool_name": f"LLM ({model_name})",
535
+ "toolkit": "reasoning",
536
+ },
537
+ config=config,
538
+ )
539
+ except Exception as e:
540
+ logger.warning(f"Failed to dispatch thinking event: {e}")
541
+
542
+ # Build the AI message with both thinking and text
543
+ # Store thinking in additional_kwargs for potential future use
544
+ ai_message_kwargs = {'content': text_content}
545
+ if thinking:
546
+ ai_message_kwargs['additional_kwargs'] = {'thinking': thinking}
547
+ ai_message = AIMessage(**ai_message_kwargs)
254
548
 
255
549
  # Try to extract JSON if output variables are specified (but exclude 'messages' which is handled separately)
256
550
  json_output_vars = [var for var in (self.output_variables or []) if var != 'messages']
257
551
  if json_output_vars:
258
552
  # set response to be the first output variable for non-structured output
259
- response_data = {json_output_vars[0]: content}
260
- new_messages = messages + [AIMessage(content=content)]
553
+ response_data = {json_output_vars[0]: text_content}
554
+ new_messages = messages + [ai_message]
261
555
  response_data['messages'] = new_messages
262
556
  return response_data
263
557
 
264
558
  # Simple text response (either no output variables or JSON parsing failed)
265
- new_messages = messages + [AIMessage(content=content)]
559
+ new_messages = messages + [ai_message]
266
560
  return {"messages": new_messages}
267
561
 
268
562
  except Exception as e:
563
+ # Enhanced error logging with model diagnostics
564
+ model_info = getattr(llm_client, 'model_name', None) or getattr(llm_client, 'model', 'unknown')
269
565
  logger.error(f"Error in LLM Node: {format_exc()}")
566
+ logger.error(f"Model being used: {model_info}")
567
+ logger.error(f"Error type: {type(e).__name__}")
568
+
270
569
  error_msg = f"Error: {e}"
271
570
  new_messages = messages + [AIMessage(content=error_msg)]
272
571
  return {"messages": new_messages}
273
572
 
274
573
  def _run(self, *args, **kwargs):
275
574
  # Legacy support for old interface
276
- return self.invoke(kwargs, **kwargs)
575
+ return self.invoke(kwargs, **kwargs)
576
+
577
+ @staticmethod
578
+ def _extract_content_from_completion(completion) -> dict:
579
+ """Extract thinking and text content from LLM completion.
580
+
581
+ Handles Anthropic's extended thinking format where content is a list
582
+ of blocks with types: 'thinking' and 'text'.
583
+
584
+ Args:
585
+ completion: LLM completion object with content attribute
586
+
587
+ Returns:
588
+ dict with 'thinking' and 'text' keys
589
+ """
590
+ result = {'thinking': None, 'text': None}
591
+
592
+ if not hasattr(completion, 'content'):
593
+ return result
594
+
595
+ content = completion.content
596
+
597
+ # Handle list of content blocks (Anthropic extended thinking format)
598
+ if isinstance(content, list):
599
+ thinking_blocks = []
600
+ text_blocks = []
601
+
602
+ for block in content:
603
+ if isinstance(block, dict):
604
+ block_type = block.get('type', '')
605
+ if block_type == 'thinking':
606
+ thinking_blocks.append(block.get('thinking', ''))
607
+ elif block_type == 'text':
608
+ text_blocks.append(block.get('text', ''))
609
+ elif hasattr(block, 'type'):
610
+ # Handle object format
611
+ if block.type == 'thinking':
612
+ thinking_blocks.append(getattr(block, 'thinking', ''))
613
+ elif block.type == 'text':
614
+ text_blocks.append(getattr(block, 'text', ''))
615
+
616
+ if thinking_blocks:
617
+ result['thinking'] = '\n\n'.join(thinking_blocks)
618
+ if text_blocks:
619
+ result['text'] = '\n\n'.join(text_blocks)
620
+
621
+ # Handle simple string content
622
+ elif isinstance(content, str):
623
+ result['text'] = content
624
+
625
+ return result
626
+
627
+ def _run_async_in_sync_context(self, coro):
628
+ """Run async coroutine from sync context.
629
+
630
+ For MCP tools with persistent sessions, we reuse the same event loop
631
+ that was used to create the MCP client and sessions (set by CLI).
632
+
633
+ When called from within a running event loop (e.g., nested LLM nodes),
634
+ we need to handle this carefully to avoid "event loop already running" errors.
635
+
636
+ This method handles three scenarios:
637
+ 1. Called from async context (event loop running) - creates new thread with new loop
638
+ 2. Called from sync context with persistent loop - reuses persistent loop
639
+ 3. Called from sync context without loop - creates new persistent loop
640
+ """
641
+ import threading
642
+
643
+ # Check if there's a running loop
644
+ try:
645
+ running_loop = asyncio.get_running_loop()
646
+ loop_is_running = True
647
+ logger.debug(f"Detected running event loop (id: {id(running_loop)}), executing tool calls in separate thread")
648
+ except RuntimeError:
649
+ loop_is_running = False
650
+
651
+ # Scenario 1: Loop is currently running - MUST use thread
652
+ if loop_is_running:
653
+ result_container = []
654
+ exception_container = []
655
+
656
+ # Try to capture Streamlit context from current thread for propagation
657
+ streamlit_ctx = None
658
+ try:
659
+ from streamlit.runtime.scriptrunner import get_script_run_ctx, add_script_run_ctx
660
+ streamlit_ctx = get_script_run_ctx()
661
+ if streamlit_ctx:
662
+ logger.debug("Captured Streamlit context for propagation to worker thread")
663
+ except (ImportError, Exception) as e:
664
+ logger.debug(f"Streamlit context not available or failed to capture: {e}")
665
+
666
+ def run_in_thread():
667
+ """Run coroutine in a new thread with its own event loop."""
668
+ new_loop = asyncio.new_event_loop()
669
+ asyncio.set_event_loop(new_loop)
670
+ try:
671
+ result = new_loop.run_until_complete(coro)
672
+ result_container.append(result)
673
+ except Exception as e:
674
+ logger.debug(f"Exception in async thread: {e}")
675
+ exception_container.append(e)
676
+ finally:
677
+ new_loop.close()
678
+ asyncio.set_event_loop(None)
679
+
680
+ thread = threading.Thread(target=run_in_thread, daemon=False)
681
+
682
+ # Propagate Streamlit context to the worker thread if available
683
+ if streamlit_ctx is not None:
684
+ try:
685
+ add_script_run_ctx(thread, streamlit_ctx)
686
+ logger.debug("Successfully propagated Streamlit context to worker thread")
687
+ except Exception as e:
688
+ logger.warning(f"Failed to propagate Streamlit context to worker thread: {e}")
689
+
690
+ thread.start()
691
+ thread.join(timeout=self.tool_execution_timeout) # 15 minute timeout for safety
692
+
693
+ if thread.is_alive():
694
+ logger.error("Async operation timed out after 5 minutes")
695
+ raise TimeoutError("Async operation in thread timed out")
696
+
697
+ # Re-raise exception if one occurred
698
+ if exception_container:
699
+ raise exception_container[0]
700
+
701
+ return result_container[0] if result_container else None
702
+
703
+ # Scenario 2 & 3: No loop running - use or create persistent loop
704
+ else:
705
+ # Get or create persistent loop
706
+ if not hasattr(self.__class__, '_persistent_loop') or \
707
+ self.__class__._persistent_loop is None or \
708
+ self.__class__._persistent_loop.is_closed():
709
+ self.__class__._persistent_loop = asyncio.new_event_loop()
710
+ logger.debug("Created persistent event loop for async tools")
711
+
712
+ loop = self.__class__._persistent_loop
713
+
714
+ # Double-check the loop is not running (safety check)
715
+ if loop.is_running():
716
+ logger.debug("Persistent loop is unexpectedly running, using thread execution")
717
+
718
+ result_container = []
719
+ exception_container = []
720
+
721
+ # Try to capture Streamlit context from current thread for propagation
722
+ streamlit_ctx = None
723
+ try:
724
+ from streamlit.runtime.scriptrunner import get_script_run_ctx, add_script_run_ctx
725
+ streamlit_ctx = get_script_run_ctx()
726
+ if streamlit_ctx:
727
+ logger.debug("Captured Streamlit context for propagation to worker thread")
728
+ except (ImportError, Exception) as e:
729
+ logger.debug(f"Streamlit context not available or failed to capture: {e}")
730
+
731
+ def run_in_thread():
732
+ """Run coroutine in a new thread with its own event loop."""
733
+ new_loop = asyncio.new_event_loop()
734
+ asyncio.set_event_loop(new_loop)
735
+ try:
736
+ result = new_loop.run_until_complete(coro)
737
+ result_container.append(result)
738
+ except Exception as ex:
739
+ logger.debug(f"Exception in async thread: {ex}")
740
+ exception_container.append(ex)
741
+ finally:
742
+ new_loop.close()
743
+ asyncio.set_event_loop(None)
744
+
745
+ thread = threading.Thread(target=run_in_thread, daemon=False)
746
+
747
+ # Propagate Streamlit context to the worker thread if available
748
+ if streamlit_ctx is not None:
749
+ try:
750
+ add_script_run_ctx(thread, streamlit_ctx)
751
+ logger.debug("Successfully propagated Streamlit context to worker thread")
752
+ except Exception as e:
753
+ logger.warning(f"Failed to propagate Streamlit context to worker thread: {e}")
754
+
755
+ thread.start()
756
+ thread.join(timeout=self.tool_execution_timeout)
757
+
758
+ if thread.is_alive():
759
+ logger.error("Async operation timed out after 15 minutes")
760
+ raise TimeoutError("Async operation in thread timed out")
761
+
762
+ if exception_container:
763
+ raise exception_container[0]
764
+
765
+ return result_container[0] if result_container else None
766
+ else:
767
+ # Loop exists but not running - safe to use run_until_complete
768
+ logger.debug(f"Using persistent loop (id: {id(loop)}) with run_until_complete")
769
+ asyncio.set_event_loop(loop)
770
+ return loop.run_until_complete(coro)
771
+
772
+ async def _arun(self, *args, **kwargs):
773
+ # Legacy async support
774
+ return self.invoke(kwargs, **kwargs)
775
+
776
+ async def __perform_tool_calling(self, completion, messages, llm_client, config):
777
+ # Handle iterative tool-calling and execution
778
+ logger.info(f"__perform_tool_calling called with {len(completion.tool_calls) if hasattr(completion, 'tool_calls') else 0} tool calls")
779
+
780
+ # Check if this is a thinking model - they require special message handling
781
+ # model_name = getattr(llm_client, 'model_name', None) or getattr(llm_client, 'model', '')
782
+ # if _is_thinking_model(llm_client):
783
+ # logger.warning(
784
+ # f"⚠️ THINKING/REASONING MODEL DETECTED: '{model_name}'\n"
785
+ # f"Tool execution with thinking models may fail due to message format requirements.\n"
786
+ # f"Thinking models require 'thinking_blocks' to be preserved between turns, which this "
787
+ # f"framework cannot do.\n"
788
+ # f"Recommendation: Use standard model variants (e.g., claude-3-5-sonnet-20241022-v2:0) "
789
+ # f"instead of thinking/reasoning variants for tool calling.\n"
790
+ # f"See: https://docs.litellm.ai/docs/reasoning_content"
791
+ # )
792
+
793
+ new_messages = messages + [completion]
794
+ iteration = 0
795
+
796
+ # Continue executing tools until no more tool calls or max iterations reached
797
+ current_completion = completion
798
+ while (hasattr(current_completion, 'tool_calls') and
799
+ current_completion.tool_calls and
800
+ iteration < self.steps_limit):
801
+
802
+ iteration += 1
803
+ logger.info(f"Tool execution iteration {iteration}/{self.steps_limit}")
804
+
805
+ # Execute each tool call in the current completion
806
+ tool_calls = current_completion.tool_calls if hasattr(current_completion.tool_calls,
807
+ '__iter__') else []
808
+
809
+ for tool_call in tool_calls:
810
+ tool_name = tool_call.get('name', '') if isinstance(tool_call, dict) else getattr(tool_call,
811
+ 'name',
812
+ '')
813
+ tool_args = tool_call.get('args', {}) if isinstance(tool_call, dict) else getattr(tool_call,
814
+ 'args',
815
+ {})
816
+ tool_call_id = tool_call.get('id', '') if isinstance(tool_call, dict) else getattr(
817
+ tool_call, 'id', '')
818
+
819
+ # Find the tool in filtered tools
820
+ filtered_tools = self.get_filtered_tools()
821
+ tool_to_execute = None
822
+ for tool in filtered_tools:
823
+ if tool.name == tool_name:
824
+ tool_to_execute = tool
825
+ break
826
+
827
+ if tool_to_execute:
828
+ try:
829
+ logger.info(f"Executing tool '{tool_name}' with args: {tool_args}")
830
+
831
+ # Try async invoke first (for MCP tools), fallback to sync
832
+ tool_result = None
833
+ if hasattr(tool_to_execute, 'ainvoke'):
834
+ try:
835
+ tool_result = await tool_to_execute.ainvoke(tool_args, config=config)
836
+ except (NotImplementedError, AttributeError):
837
+ logger.debug(f"Tool '{tool_name}' ainvoke failed, falling back to sync invoke")
838
+ tool_result = tool_to_execute.invoke(tool_args, config=config)
839
+ else:
840
+ # Sync-only tool
841
+ tool_result = tool_to_execute.invoke(tool_args, config=config)
842
+
843
+ # Create tool message with result - preserve structured content
844
+ from langchain_core.messages import ToolMessage
845
+
846
+ # Check if tool_result is structured content (list of dicts)
847
+ # TODO: need solid check for being compatible with ToolMessage content format
848
+ if isinstance(tool_result, list) and all(
849
+ isinstance(item, dict) and 'type' in item for item in tool_result
850
+ ):
851
+ # Use structured content directly for multimodal support
852
+ tool_message = ToolMessage(
853
+ content=tool_result,
854
+ tool_call_id=tool_call_id
855
+ )
856
+ else:
857
+ # Fallback to string conversion for other tool results
858
+ tool_message = ToolMessage(
859
+ content=str(tool_result),
860
+ tool_call_id=tool_call_id
861
+ )
862
+ new_messages.append(tool_message)
863
+
864
+ except Exception as e:
865
+ import traceback
866
+ error_details = traceback.format_exc()
867
+ # Use debug level to avoid duplicate output when CLI callbacks are active
868
+ logger.debug(f"Error executing tool '{tool_name}': {e}\n{error_details}")
869
+ # Create error tool message
870
+ from langchain_core.messages import ToolMessage
871
+ tool_message = ToolMessage(
872
+ content=f"Error executing {tool_name}: {str(e)}",
873
+ tool_call_id=tool_call_id
874
+ )
875
+ new_messages.append(tool_message)
876
+ else:
877
+ logger.warning(f"Tool '{tool_name}' not found in available tools")
878
+ # Create error tool message for missing tool
879
+ from langchain_core.messages import ToolMessage
880
+ tool_message = ToolMessage(
881
+ content=f"Tool '{tool_name}' not available",
882
+ tool_call_id=tool_call_id
883
+ )
884
+ new_messages.append(tool_message)
885
+
886
+ # Call LLM again with tool results to get next response
887
+ try:
888
+ current_completion = llm_client.invoke(new_messages, config=config)
889
+ new_messages.append(current_completion)
890
+
891
+ # Check if we still have tool calls
892
+ if hasattr(current_completion, 'tool_calls') and current_completion.tool_calls:
893
+ logger.info(f"LLM requested {len(current_completion.tool_calls)} more tool calls")
894
+ else:
895
+ logger.info("LLM completed without requesting more tools")
896
+ break
897
+
898
+ except Exception as e:
899
+ error_str = str(e).lower()
900
+
901
+ # Check for thinking model message format errors
902
+ is_thinking_format_error = any(indicator in error_str for indicator in [
903
+ 'expected `thinking`',
904
+ 'expected `redacted_thinking`',
905
+ 'thinking block',
906
+ 'must start with a thinking block',
907
+ 'when `thinking` is enabled'
908
+ ])
909
+
910
+ # Check for non-recoverable errors that should fail immediately
911
+ # These indicate configuration or permission issues, not content size issues
912
+ is_non_recoverable = any(indicator in error_str for indicator in [
913
+ 'model identifier is invalid',
914
+ 'authentication',
915
+ 'unauthorized',
916
+ 'access denied',
917
+ 'permission denied',
918
+ 'invalid credentials',
919
+ 'api key',
920
+ 'quota exceeded',
921
+ 'rate limit'
922
+ ])
923
+
924
+ # Check for context window / token limit errors
925
+ is_context_error = any(indicator in error_str for indicator in [
926
+ 'context window', 'context_window', 'token limit', 'too long',
927
+ 'maximum context length', 'input is too long', 'exceeds the limit',
928
+ 'contextwindowexceedederror', 'max_tokens', 'content too large'
929
+ ])
930
+
931
+ # Check for Bedrock/Claude output limit errors (recoverable by truncation)
932
+ is_output_limit_error = any(indicator in error_str for indicator in [
933
+ 'output token',
934
+ 'response too large',
935
+ 'max_tokens_to_sample',
936
+ 'output_token_limit',
937
+ 'output exceeds'
938
+ ])
939
+
940
+ # Handle thinking model format errors
941
+ if is_thinking_format_error:
942
+ model_info = getattr(llm_client, 'model_name', None) or getattr(llm_client, 'model', 'unknown')
943
+ logger.error(f"Thinking model message format error during tool execution iteration {iteration}")
944
+ logger.error(f"Model: {model_info}")
945
+ logger.error(f"Error details: {e}")
946
+
947
+ error_msg = (
948
+ f"⚠️ THINKING MODEL FORMAT ERROR\n\n"
949
+ f"The model '{model_info}' uses extended thinking and requires specific message formatting.\n\n"
950
+ f"**Issue**: When 'thinking' is enabled, assistant messages must start with thinking blocks "
951
+ f"before any tool_use blocks. This framework cannot preserve thinking_blocks during iterative "
952
+ f"tool execution.\n\n"
953
+ f"**Root Cause**: Anthropic's Messages API is stateless - clients must manually preserve and "
954
+ f"resend thinking_blocks with every tool response. LangChain's message abstraction doesn't "
955
+ f"include thinking_blocks, so they are lost between turns.\n\n"
956
+ f"**Solutions**:\n"
957
+ f"1. **Recommended**: Use non-thinking model variants:\n"
958
+ f" - claude-3-5-sonnet-20241022-v2:0 (instead of thinking variants)\n"
959
+ f" - anthropic.claude-3-5-sonnet-20241022-v2:0 (Bedrock)\n"
960
+ f"2. Disable extended thinking: Set reasoning_effort=None or remove thinking config\n"
961
+ f"3. Use LiteLLM directly with modify_params=True (handles thinking_blocks automatically)\n"
962
+ f"4. Avoid tool calling with thinking models (use for reasoning tasks only)\n\n"
963
+ f"**Technical Context**: {str(e)}\n\n"
964
+ f"References:\n"
965
+ f"- https://docs.claude.com/en/docs/build-with-claude/extended-thinking\n"
966
+ f"- https://docs.litellm.ai/docs/reasoning_content (See 'Tool Calling with thinking' section)"
967
+ )
968
+ new_messages.append(AIMessage(content=error_msg))
969
+ raise ValueError(error_msg)
970
+
971
+ # Handle non-recoverable errors immediately
972
+ if is_non_recoverable:
973
+ # Enhanced error logging with model information for better diagnostics
974
+ model_info = getattr(llm_client, 'model_name', None) or getattr(llm_client, 'model', 'unknown')
975
+ logger.error(f"Non-recoverable error during tool execution iteration {iteration}")
976
+ logger.error(f"Model: {model_info}")
977
+ logger.error(f"Error details: {e}")
978
+ logger.error(f"Error type: {type(e).__name__}")
979
+
980
+ # Provide detailed error message for debugging
981
+ error_details = []
982
+ error_details.append(f"Model configuration error: {str(e)}")
983
+ error_details.append(f"Model identifier: {model_info}")
984
+
985
+ # Check for common Bedrock model ID issues
986
+ if 'model identifier is invalid' in error_str:
987
+ error_details.append("\nPossible causes:")
988
+ error_details.append("1. Model not available in the configured AWS region")
989
+ error_details.append("2. Model not enabled in your AWS Bedrock account")
990
+ error_details.append("3. LiteLLM model group prefix not stripped (check for prefixes like '1_')")
991
+ error_details.append("4. Incorrect model version or typo in model name")
992
+ error_details.append("\nPlease verify:")
993
+ error_details.append("- AWS Bedrock console shows this model as available")
994
+ error_details.append("- LiteLLM router configuration is correct")
995
+ error_details.append("- Model ID doesn't contain unexpected prefixes")
996
+
997
+ error_msg = "\n".join(error_details)
998
+ new_messages.append(AIMessage(content=error_msg))
999
+ break
1000
+
1001
+ if is_context_error or is_output_limit_error:
1002
+ error_type = "output limit" if is_output_limit_error else "context window"
1003
+ logger.warning(f"{error_type.title()} exceeded during tool execution iteration {iteration}")
1004
+
1005
+ # Find the last tool message and its associated tool name
1006
+ last_tool_msg_idx = None
1007
+ last_tool_name = None
1008
+ last_tool_call_id = None
1009
+
1010
+ # First, find the last tool message
1011
+ for i in range(len(new_messages) - 1, -1, -1):
1012
+ msg = new_messages[i]
1013
+ if hasattr(msg, 'tool_call_id') or (hasattr(msg, 'type') and getattr(msg, 'type', None) == 'tool'):
1014
+ last_tool_msg_idx = i
1015
+ last_tool_call_id = getattr(msg, 'tool_call_id', None)
1016
+ break
1017
+
1018
+ # Find the tool name from the AIMessage that requested this tool call
1019
+ if last_tool_call_id:
1020
+ for i in range(last_tool_msg_idx - 1, -1, -1):
1021
+ msg = new_messages[i]
1022
+ if hasattr(msg, 'tool_calls') and msg.tool_calls:
1023
+ for tc in msg.tool_calls:
1024
+ tc_id = tc.get('id', '') if isinstance(tc, dict) else getattr(tc, 'id', '')
1025
+ if tc_id == last_tool_call_id:
1026
+ last_tool_name = tc.get('name', '') if isinstance(tc, dict) else getattr(tc, 'name', '')
1027
+ break
1028
+ if last_tool_name:
1029
+ break
1030
+
1031
+ # Build dynamic suggestion based on the tool that caused the overflow
1032
+ tool_suggestions = self._get_tool_truncation_suggestions(last_tool_name)
1033
+
1034
+ # Truncate the problematic tool result if found
1035
+ if last_tool_msg_idx is not None:
1036
+ from langchain_core.messages import ToolMessage
1037
+ original_msg = new_messages[last_tool_msg_idx]
1038
+ tool_call_id = getattr(original_msg, 'tool_call_id', 'unknown')
1039
+
1040
+ # Build error-specific guidance
1041
+ if is_output_limit_error:
1042
+ truncated_content = (
1043
+ f"⚠️ MODEL OUTPUT LIMIT EXCEEDED\n\n"
1044
+ f"The tool '{last_tool_name or 'unknown'}' returned data, but the model's response was too large.\n\n"
1045
+ f"IMPORTANT: You must provide a SMALLER, more focused response.\n"
1046
+ f"- Break down your response into smaller chunks\n"
1047
+ f"- Summarize instead of listing everything\n"
1048
+ f"- Focus on the most relevant information first\n"
1049
+ f"- If listing items, show only top 5-10 most important\n\n"
1050
+ f"Tool-specific tips:\n{tool_suggestions}\n\n"
1051
+ f"Please retry with a more concise response."
1052
+ )
1053
+ else:
1054
+ truncated_content = (
1055
+ f"⚠️ TOOL OUTPUT TRUNCATED - Context window exceeded\n\n"
1056
+ f"The tool '{last_tool_name or 'unknown'}' returned too much data for the model's context window.\n\n"
1057
+ f"To fix this:\n{tool_suggestions}\n\n"
1058
+ f"Please retry with more restrictive parameters."
1059
+ )
1060
+
1061
+ truncated_msg = ToolMessage(
1062
+ content=truncated_content,
1063
+ tool_call_id=tool_call_id
1064
+ )
1065
+ new_messages[last_tool_msg_idx] = truncated_msg
1066
+
1067
+ logger.info(f"Truncated large tool result from '{last_tool_name}' and retrying LLM call")
1068
+
1069
+ # CRITICAL FIX: Call LLM again with truncated message to get fresh completion
1070
+ # This prevents duplicate tool_call_ids that occur when we continue with
1071
+ # the same current_completion that still has the original tool_calls
1072
+ try:
1073
+ current_completion = llm_client.invoke(new_messages, config=config)
1074
+ new_messages.append(current_completion)
1075
+
1076
+ # Continue to process any new tool calls in the fresh completion
1077
+ if hasattr(current_completion, 'tool_calls') and current_completion.tool_calls:
1078
+ logger.info(f"LLM requested {len(current_completion.tool_calls)} more tool calls after truncation")
1079
+ continue
1080
+ else:
1081
+ logger.info("LLM completed after truncation without requesting more tools")
1082
+ break
1083
+ except Exception as retry_error:
1084
+ logger.error(f"Error retrying LLM after truncation: {retry_error}")
1085
+ error_msg = f"Failed to retry after truncation: {str(retry_error)}"
1086
+ new_messages.append(AIMessage(content=error_msg))
1087
+ break
1088
+ else:
1089
+ # Couldn't find tool message, add error and break
1090
+ if is_output_limit_error:
1091
+ error_msg = (
1092
+ "Model output limit exceeded. Please provide a more concise response. "
1093
+ "Break down your answer into smaller parts and summarize where possible."
1094
+ )
1095
+ else:
1096
+ error_msg = (
1097
+ "Context window exceeded. The conversation or tool results are too large. "
1098
+ "Try using tools with smaller output limits (e.g., max_items, max_depth parameters)."
1099
+ )
1100
+ new_messages.append(AIMessage(content=error_msg))
1101
+ break
1102
+ else:
1103
+ logger.error(f"Error in LLM call during iteration {iteration}: {e}")
1104
+ # Add error message and break the loop
1105
+ error_msg = f"Error processing tool results in iteration {iteration}: {str(e)}"
1106
+ new_messages.append(AIMessage(content=error_msg))
1107
+ break
1108
+
1109
+ # Handle max iterations
1110
+ if iteration >= self.steps_limit:
1111
+ logger.warning(f"Reached maximum iterations ({self.steps_limit}) for tool execution")
1112
+
1113
+ # CRITICAL: Check if the last message is an AIMessage with pending tool_calls
1114
+ # that were not processed. If so, we need to add placeholder ToolMessages to prevent
1115
+ # the "assistant message with 'tool_calls' must be followed by tool messages" error
1116
+ # when the conversation continues.
1117
+ if new_messages:
1118
+ last_msg = new_messages[-1]
1119
+ if hasattr(last_msg, 'tool_calls') and last_msg.tool_calls:
1120
+ from langchain_core.messages import ToolMessage
1121
+ pending_tool_calls = last_msg.tool_calls if hasattr(last_msg.tool_calls, '__iter__') else []
1122
+
1123
+ # Check which tool_call_ids already have responses
1124
+ existing_tool_call_ids = set()
1125
+ for msg in new_messages:
1126
+ if hasattr(msg, 'tool_call_id'):
1127
+ existing_tool_call_ids.add(msg.tool_call_id)
1128
+
1129
+ # Add placeholder responses for any tool calls without responses
1130
+ for tool_call in pending_tool_calls:
1131
+ tool_call_id = tool_call.get('id', '') if isinstance(tool_call, dict) else getattr(tool_call, 'id', '')
1132
+ tool_name = tool_call.get('name', '') if isinstance(tool_call, dict) else getattr(tool_call, 'name', '')
1133
+
1134
+ if tool_call_id and tool_call_id not in existing_tool_call_ids:
1135
+ logger.info(f"Adding placeholder ToolMessage for interrupted tool call: {tool_name} ({tool_call_id})")
1136
+ placeholder_msg = ToolMessage(
1137
+ content=f"[Tool execution interrupted - step limit ({self.steps_limit}) reached before {tool_name} could be executed]",
1138
+ tool_call_id=tool_call_id
1139
+ )
1140
+ new_messages.append(placeholder_msg)
1141
+
1142
+ # Add warning message - CLI or calling code can detect this and prompt user
1143
+ warning_msg = f"Maximum tool execution iterations ({self.steps_limit}) reached. Stopping tool execution."
1144
+ new_messages.append(AIMessage(content=warning_msg))
1145
+ else:
1146
+ logger.info(f"Tool execution completed after {iteration} iterations")
1147
+
1148
+ return new_messages, current_completion
1149
+
1150
+ def __get_struct_output_model(self, llm_client, pydantic_model, method: Literal["function_calling", "json_mode", "json_schema"] = "function_calling"):
1151
+ return llm_client.with_structured_output(pydantic_model, method=method)