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