alita-sdk 0.3.257__py3-none-any.whl → 0.3.584__py3-none-any.whl

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

Potentially problematic release.


This version of alita-sdk might be problematic. Click here for more details.

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