alita-sdk 0.3.263__py3-none-any.whl → 0.3.499__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 (248) 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 +1256 -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 +64 -8
  30. alita_sdk/community/inventory/__init__.py +224 -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/visualize.py +1370 -0
  58. alita_sdk/configurations/__init__.py +10 -0
  59. alita_sdk/configurations/ado.py +4 -2
  60. alita_sdk/configurations/azure_search.py +1 -1
  61. alita_sdk/configurations/bigquery.py +1 -1
  62. alita_sdk/configurations/bitbucket.py +94 -2
  63. alita_sdk/configurations/browser.py +18 -0
  64. alita_sdk/configurations/carrier.py +19 -0
  65. alita_sdk/configurations/confluence.py +96 -1
  66. alita_sdk/configurations/delta_lake.py +1 -1
  67. alita_sdk/configurations/figma.py +0 -5
  68. alita_sdk/configurations/github.py +65 -1
  69. alita_sdk/configurations/gitlab.py +79 -0
  70. alita_sdk/configurations/google_places.py +17 -0
  71. alita_sdk/configurations/jira.py +103 -0
  72. alita_sdk/configurations/postman.py +1 -1
  73. alita_sdk/configurations/qtest.py +1 -3
  74. alita_sdk/configurations/report_portal.py +19 -0
  75. alita_sdk/configurations/salesforce.py +19 -0
  76. alita_sdk/configurations/service_now.py +1 -12
  77. alita_sdk/configurations/sharepoint.py +19 -0
  78. alita_sdk/configurations/sonar.py +18 -0
  79. alita_sdk/configurations/sql.py +20 -0
  80. alita_sdk/configurations/testio.py +18 -0
  81. alita_sdk/configurations/testrail.py +88 -0
  82. alita_sdk/configurations/xray.py +94 -1
  83. alita_sdk/configurations/zephyr_enterprise.py +94 -1
  84. alita_sdk/configurations/zephyr_essential.py +95 -0
  85. alita_sdk/runtime/clients/artifact.py +12 -2
  86. alita_sdk/runtime/clients/client.py +235 -66
  87. alita_sdk/runtime/clients/mcp_discovery.py +342 -0
  88. alita_sdk/runtime/clients/mcp_manager.py +262 -0
  89. alita_sdk/runtime/clients/sandbox_client.py +373 -0
  90. alita_sdk/runtime/langchain/assistant.py +123 -17
  91. alita_sdk/runtime/langchain/constants.py +8 -1
  92. alita_sdk/runtime/langchain/document_loaders/AlitaDocxMammothLoader.py +315 -3
  93. alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +209 -31
  94. alita_sdk/runtime/langchain/document_loaders/AlitaImageLoader.py +1 -1
  95. alita_sdk/runtime/langchain/document_loaders/AlitaJSONLoader.py +8 -2
  96. alita_sdk/runtime/langchain/document_loaders/AlitaMarkdownLoader.py +66 -0
  97. alita_sdk/runtime/langchain/document_loaders/AlitaPDFLoader.py +79 -10
  98. alita_sdk/runtime/langchain/document_loaders/AlitaPowerPointLoader.py +52 -15
  99. alita_sdk/runtime/langchain/document_loaders/AlitaPythonLoader.py +9 -0
  100. alita_sdk/runtime/langchain/document_loaders/AlitaTableLoader.py +1 -4
  101. alita_sdk/runtime/langchain/document_loaders/AlitaTextLoader.py +15 -2
  102. alita_sdk/runtime/langchain/document_loaders/ImageParser.py +30 -0
  103. alita_sdk/runtime/langchain/document_loaders/constants.py +187 -40
  104. alita_sdk/runtime/langchain/interfaces/llm_processor.py +4 -2
  105. alita_sdk/runtime/langchain/langraph_agent.py +406 -91
  106. alita_sdk/runtime/langchain/utils.py +51 -8
  107. alita_sdk/runtime/llms/preloaded.py +2 -6
  108. alita_sdk/runtime/models/mcp_models.py +61 -0
  109. alita_sdk/runtime/toolkits/__init__.py +26 -0
  110. alita_sdk/runtime/toolkits/application.py +9 -2
  111. alita_sdk/runtime/toolkits/artifact.py +19 -7
  112. alita_sdk/runtime/toolkits/datasource.py +13 -6
  113. alita_sdk/runtime/toolkits/mcp.py +780 -0
  114. alita_sdk/runtime/toolkits/planning.py +178 -0
  115. alita_sdk/runtime/toolkits/subgraph.py +11 -6
  116. alita_sdk/runtime/toolkits/tools.py +214 -60
  117. alita_sdk/runtime/toolkits/vectorstore.py +9 -4
  118. alita_sdk/runtime/tools/__init__.py +22 -0
  119. alita_sdk/runtime/tools/application.py +16 -4
  120. alita_sdk/runtime/tools/artifact.py +312 -19
  121. alita_sdk/runtime/tools/function.py +100 -4
  122. alita_sdk/runtime/tools/graph.py +81 -0
  123. alita_sdk/runtime/tools/image_generation.py +212 -0
  124. alita_sdk/runtime/tools/llm.py +539 -180
  125. alita_sdk/runtime/tools/mcp_inspect_tool.py +284 -0
  126. alita_sdk/runtime/tools/mcp_remote_tool.py +181 -0
  127. alita_sdk/runtime/tools/mcp_server_tool.py +3 -1
  128. alita_sdk/runtime/tools/planning/__init__.py +36 -0
  129. alita_sdk/runtime/tools/planning/models.py +246 -0
  130. alita_sdk/runtime/tools/planning/wrapper.py +607 -0
  131. alita_sdk/runtime/tools/router.py +2 -1
  132. alita_sdk/runtime/tools/sandbox.py +375 -0
  133. alita_sdk/runtime/tools/vectorstore.py +62 -63
  134. alita_sdk/runtime/tools/vectorstore_base.py +156 -85
  135. alita_sdk/runtime/utils/AlitaCallback.py +106 -20
  136. alita_sdk/runtime/utils/mcp_client.py +465 -0
  137. alita_sdk/runtime/utils/mcp_oauth.py +244 -0
  138. alita_sdk/runtime/utils/mcp_sse_client.py +405 -0
  139. alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
  140. alita_sdk/runtime/utils/streamlit.py +41 -14
  141. alita_sdk/runtime/utils/toolkit_utils.py +28 -9
  142. alita_sdk/runtime/utils/utils.py +14 -0
  143. alita_sdk/tools/__init__.py +78 -35
  144. alita_sdk/tools/ado/__init__.py +0 -1
  145. alita_sdk/tools/ado/repos/__init__.py +10 -6
  146. alita_sdk/tools/ado/repos/repos_wrapper.py +12 -11
  147. alita_sdk/tools/ado/test_plan/__init__.py +10 -7
  148. alita_sdk/tools/ado/test_plan/test_plan_wrapper.py +56 -23
  149. alita_sdk/tools/ado/wiki/__init__.py +10 -11
  150. alita_sdk/tools/ado/wiki/ado_wrapper.py +114 -28
  151. alita_sdk/tools/ado/work_item/__init__.py +10 -11
  152. alita_sdk/tools/ado/work_item/ado_wrapper.py +63 -10
  153. alita_sdk/tools/advanced_jira_mining/__init__.py +10 -7
  154. alita_sdk/tools/aws/delta_lake/__init__.py +13 -11
  155. alita_sdk/tools/azure_ai/search/__init__.py +11 -7
  156. alita_sdk/tools/base_indexer_toolkit.py +392 -86
  157. alita_sdk/tools/bitbucket/__init__.py +18 -11
  158. alita_sdk/tools/bitbucket/api_wrapper.py +52 -9
  159. alita_sdk/tools/bitbucket/cloud_api_wrapper.py +5 -5
  160. alita_sdk/tools/browser/__init__.py +40 -16
  161. alita_sdk/tools/browser/crawler.py +3 -1
  162. alita_sdk/tools/browser/utils.py +15 -6
  163. alita_sdk/tools/carrier/__init__.py +17 -17
  164. alita_sdk/tools/carrier/backend_reports_tool.py +8 -4
  165. alita_sdk/tools/carrier/excel_reporter.py +8 -4
  166. alita_sdk/tools/chunkers/__init__.py +3 -1
  167. alita_sdk/tools/chunkers/code/codeparser.py +1 -1
  168. alita_sdk/tools/chunkers/sematic/json_chunker.py +1 -0
  169. alita_sdk/tools/chunkers/sematic/markdown_chunker.py +97 -6
  170. alita_sdk/tools/chunkers/sematic/proposal_chunker.py +1 -1
  171. alita_sdk/tools/chunkers/universal_chunker.py +270 -0
  172. alita_sdk/tools/cloud/aws/__init__.py +9 -6
  173. alita_sdk/tools/cloud/azure/__init__.py +9 -6
  174. alita_sdk/tools/cloud/gcp/__init__.py +9 -6
  175. alita_sdk/tools/cloud/k8s/__init__.py +9 -6
  176. alita_sdk/tools/code/linter/__init__.py +7 -7
  177. alita_sdk/tools/code/loaders/codesearcher.py +3 -2
  178. alita_sdk/tools/code/sonar/__init__.py +18 -12
  179. alita_sdk/tools/code_indexer_toolkit.py +199 -0
  180. alita_sdk/tools/confluence/__init__.py +14 -11
  181. alita_sdk/tools/confluence/api_wrapper.py +198 -58
  182. alita_sdk/tools/confluence/loader.py +10 -0
  183. alita_sdk/tools/custom_open_api/__init__.py +9 -4
  184. alita_sdk/tools/elastic/__init__.py +8 -7
  185. alita_sdk/tools/elitea_base.py +543 -64
  186. alita_sdk/tools/figma/__init__.py +10 -8
  187. alita_sdk/tools/figma/api_wrapper.py +352 -153
  188. alita_sdk/tools/github/__init__.py +13 -11
  189. alita_sdk/tools/github/api_wrapper.py +9 -26
  190. alita_sdk/tools/github/github_client.py +75 -12
  191. alita_sdk/tools/github/schemas.py +2 -1
  192. alita_sdk/tools/gitlab/__init__.py +11 -10
  193. alita_sdk/tools/gitlab/api_wrapper.py +135 -45
  194. alita_sdk/tools/gitlab_org/__init__.py +11 -9
  195. alita_sdk/tools/google/bigquery/__init__.py +12 -13
  196. alita_sdk/tools/google_places/__init__.py +18 -10
  197. alita_sdk/tools/jira/__init__.py +14 -8
  198. alita_sdk/tools/jira/api_wrapper.py +315 -168
  199. alita_sdk/tools/keycloak/__init__.py +8 -7
  200. alita_sdk/tools/localgit/local_git.py +56 -54
  201. alita_sdk/tools/memory/__init__.py +27 -11
  202. alita_sdk/tools/non_code_indexer_toolkit.py +7 -2
  203. alita_sdk/tools/ocr/__init__.py +8 -7
  204. alita_sdk/tools/openapi/__init__.py +10 -1
  205. alita_sdk/tools/pandas/__init__.py +8 -7
  206. alita_sdk/tools/pandas/api_wrapper.py +7 -25
  207. alita_sdk/tools/postman/__init__.py +8 -10
  208. alita_sdk/tools/postman/api_wrapper.py +19 -8
  209. alita_sdk/tools/postman/postman_analysis.py +8 -1
  210. alita_sdk/tools/pptx/__init__.py +8 -9
  211. alita_sdk/tools/qtest/__init__.py +19 -13
  212. alita_sdk/tools/qtest/api_wrapper.py +1784 -88
  213. alita_sdk/tools/rally/__init__.py +10 -9
  214. alita_sdk/tools/report_portal/__init__.py +20 -15
  215. alita_sdk/tools/salesforce/__init__.py +19 -15
  216. alita_sdk/tools/servicenow/__init__.py +14 -11
  217. alita_sdk/tools/sharepoint/__init__.py +14 -13
  218. alita_sdk/tools/sharepoint/api_wrapper.py +179 -39
  219. alita_sdk/tools/sharepoint/authorization_helper.py +191 -1
  220. alita_sdk/tools/sharepoint/utils.py +8 -2
  221. alita_sdk/tools/slack/__init__.py +10 -7
  222. alita_sdk/tools/sql/__init__.py +19 -18
  223. alita_sdk/tools/sql/api_wrapper.py +71 -23
  224. alita_sdk/tools/testio/__init__.py +18 -12
  225. alita_sdk/tools/testrail/__init__.py +10 -10
  226. alita_sdk/tools/testrail/api_wrapper.py +213 -45
  227. alita_sdk/tools/utils/__init__.py +28 -4
  228. alita_sdk/tools/utils/content_parser.py +181 -61
  229. alita_sdk/tools/utils/text_operations.py +254 -0
  230. alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +83 -27
  231. alita_sdk/tools/xray/__init__.py +12 -7
  232. alita_sdk/tools/xray/api_wrapper.py +58 -113
  233. alita_sdk/tools/zephyr/__init__.py +9 -6
  234. alita_sdk/tools/zephyr_enterprise/__init__.py +13 -8
  235. alita_sdk/tools/zephyr_enterprise/api_wrapper.py +17 -7
  236. alita_sdk/tools/zephyr_essential/__init__.py +13 -9
  237. alita_sdk/tools/zephyr_essential/api_wrapper.py +289 -47
  238. alita_sdk/tools/zephyr_essential/client.py +6 -4
  239. alita_sdk/tools/zephyr_scale/__init__.py +10 -7
  240. alita_sdk/tools/zephyr_scale/api_wrapper.py +6 -2
  241. alita_sdk/tools/zephyr_squad/__init__.py +9 -6
  242. {alita_sdk-0.3.263.dist-info → alita_sdk-0.3.499.dist-info}/METADATA +180 -33
  243. alita_sdk-0.3.499.dist-info/RECORD +433 -0
  244. alita_sdk-0.3.499.dist-info/entry_points.txt +2 -0
  245. alita_sdk-0.3.263.dist-info/RECORD +0 -342
  246. {alita_sdk-0.3.263.dist-info → alita_sdk-0.3.499.dist-info}/WHEEL +0 -0
  247. {alita_sdk-0.3.263.dist-info → alita_sdk-0.3.499.dist-info}/licenses/LICENSE +0 -0
  248. {alita_sdk-0.3.263.dist-info → alita_sdk-0.3.499.dist-info}/top_level.txt +0 -0
@@ -1,76 +1,40 @@
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
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
9
  from pydantic import Field
10
10
 
11
- from ..langchain.utils import _extract_json, create_pydantic_model, create_params
11
+ from ..langchain.constants import ELITEA_RS
12
+ from ..langchain.utils import create_pydantic_model, propagate_the_input_mapping
12
13
 
13
14
  logger = logging.getLogger(__name__)
14
15
 
15
16
 
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.
23
-
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}")
33
-
34
- # Build the input messages
35
- input_messages = []
36
-
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)
48
-
49
- # Add the chat history messages
50
- if messages:
51
- input_messages.extend(messages)
52
-
53
- return input_messages
54
-
55
-
56
17
  class LLMNode(BaseTool):
57
18
  """Enhanced LLM node with chat history and tool binding support"""
58
19
 
59
20
  # Override BaseTool required fields
60
21
  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
-
22
+ description: str = Field(default='This is tool node for LLM with chat history and tool support',
23
+ description='Description of the LLM node')
24
+
63
25
  # LLM-specific fields
64
- prompt: Dict[str, str] = Field(default_factory=dict, description='Prompt configuration')
65
26
  client: Any = Field(default=None, description='LLM client instance')
66
27
  return_type: str = Field(default="str", description='Return type')
67
28
  response_key: str = Field(default="messages", description='Response key')
68
29
  structured_output_dict: Optional[dict[str, str]] = Field(default=None, description='Structured output dictionary')
69
30
  output_variables: Optional[List[str]] = Field(default=None, description='Output variables')
31
+ input_mapping: Optional[dict[str, dict]] = Field(default=None, description='Input mapping')
70
32
  input_variables: Optional[List[str]] = Field(default=None, description='Input variables')
71
33
  structured_output: Optional[bool] = Field(default=False, description='Whether to use structured output')
72
34
  available_tools: Optional[List[BaseTool]] = Field(default=None, description='Available tools for binding')
73
35
  tool_names: Optional[List[str]] = Field(default=None, description='Specific tool names to filter')
36
+ steps_limit: Optional[int] = Field(default=25, description='Maximum steps for tool execution')
37
+ tool_execution_timeout: Optional[int] = Field(default=900, description='Timeout (seconds) for tool execution. Default is 15 minutes.')
74
38
 
75
39
  def get_filtered_tools(self) -> List[BaseTool]:
76
40
  """
@@ -99,11 +63,52 @@ class LLMNode(BaseTool):
99
63
 
100
64
  return filtered_tools
101
65
 
66
+ def _get_tool_truncation_suggestions(self, tool_name: Optional[str]) -> str:
67
+ """
68
+ Get context-specific suggestions for how to reduce output from a tool.
69
+
70
+ First checks if the tool itself provides truncation suggestions via
71
+ `truncation_suggestions` attribute or `get_truncation_suggestions()` method.
72
+ Falls back to generic suggestions if the tool doesn't provide any.
73
+
74
+ Args:
75
+ tool_name: Name of the tool that caused the context overflow
76
+
77
+ Returns:
78
+ Formatted string with numbered suggestions for the specific tool
79
+ """
80
+ suggestions = None
81
+
82
+ # Try to get suggestions from the tool itself
83
+ if tool_name:
84
+ filtered_tools = self.get_filtered_tools()
85
+ for tool in filtered_tools:
86
+ if tool.name == tool_name:
87
+ # Check for truncation_suggestions attribute
88
+ if hasattr(tool, 'truncation_suggestions') and tool.truncation_suggestions:
89
+ suggestions = tool.truncation_suggestions
90
+ break
91
+ # Check for get_truncation_suggestions method
92
+ elif hasattr(tool, 'get_truncation_suggestions') and callable(tool.get_truncation_suggestions):
93
+ suggestions = tool.get_truncation_suggestions()
94
+ break
95
+
96
+ # Fall back to generic suggestions if tool doesn't provide any
97
+ if not suggestions:
98
+ suggestions = [
99
+ "Check if the tool has parameters to limit output size (e.g., max_items, max_results, max_depth)",
100
+ "Target a more specific path or query instead of broad searches",
101
+ "Break the operation into smaller, focused requests",
102
+ ]
103
+
104
+ # Format as numbered list
105
+ return "\n".join(f"{i+1}. {s}" for i, s in enumerate(suggestions))
106
+
102
107
  def invoke(
103
- self,
104
- state: Union[str, dict],
105
- config: Optional[RunnableConfig] = None,
106
- **kwargs: Any,
108
+ self,
109
+ state: Union[str, dict],
110
+ config: Optional[RunnableConfig] = None,
111
+ **kwargs: Any,
107
112
  ) -> dict:
108
113
  """
109
114
  Invoke the LLM node with proper message handling and tool binding.
@@ -117,23 +122,39 @@ class LLMNode(BaseTool):
117
122
  Updated state with LLM response
118
123
  """
119
124
  # 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
-
125
+
126
+ func_args = propagate_the_input_mapping(input_mapping=self.input_mapping, input_variables=self.input_variables,
127
+ state=state)
128
+
129
+ # there are 2 possible flows here: LLM node from pipeline (with prompt and task)
130
+ # or standalone LLM node for chat (with messages only)
131
+ if 'system' in func_args.keys():
132
+ # Flow for LLM node with prompt/task from pipeline
133
+ if func_args.get('system') is None or func_args.get('task') is None:
134
+ raise ToolException(f"LLMNode requires 'system' and 'task' parameters in input mapping. "
135
+ f"Actual params: {func_args}")
136
+ raise ToolException(f"LLMNode requires 'system' and 'task' parameters in input mapping. "
137
+ f"Actual params: {func_args}")
138
+ # cast to str in case user passes variable different from str
139
+ messages = [SystemMessage(content=str(func_args.get('system'))), *func_args.get('chat_history', []), HumanMessage(content=str(func_args.get('task')))]
140
+ # Remove pre-last item if last two messages are same type and content
141
+ if len(messages) >= 2 and type(messages[-1]) == type(messages[-2]) and messages[-1].content == messages[
142
+ -2].content:
143
+ messages.pop(-2)
144
+ else:
145
+ # Flow for chat-based LLM node w/o prompt/task from pipeline but with messages in state
146
+ # verify messages structure
147
+ messages = state.get("messages", []) if isinstance(state, dict) else []
148
+ if messages:
149
+ # the last message has to be HumanMessage
150
+ if not isinstance(messages[-1], HumanMessage):
151
+ raise ToolException("LLMNode requires the last message to be a HumanMessage")
152
+ else:
153
+ raise ToolException("LLMNode requires 'messages' in state for chat-based interaction")
154
+
134
155
  # Get the LLM client, potentially with tools bound
135
156
  llm_client = self.client
136
-
157
+
137
158
  if len(self.tool_names or []) > 0:
138
159
  filtered_tools = self.get_filtered_tools()
139
160
  if filtered_tools:
@@ -141,7 +162,7 @@ class LLMNode(BaseTool):
141
162
  llm_client = self.client.bind_tools(filtered_tools)
142
163
  else:
143
164
  logger.warning("No tools to bind to LLM")
144
-
165
+
145
166
  try:
146
167
  if self.structured_output and self.output_variables:
147
168
  # Handle structured output
@@ -152,138 +173,64 @@ class LLMNode(BaseTool):
152
173
  }
153
174
  for key, value in (self.structured_output_dict or {}).items()
154
175
  }
176
+ # Add default output field for proper response to user
177
+ struct_params['elitea_response'] = {'description': 'final output to user', 'type': 'str'}
155
178
  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)
158
- result = completion.model_dump()
159
-
179
+ completion = llm_client.invoke(messages, config=config)
180
+ if hasattr(completion, 'tool_calls') and completion.tool_calls:
181
+ new_messages, _ = self._run_async_in_sync_context(
182
+ self.__perform_tool_calling(completion, messages, llm_client, config)
183
+ )
184
+ llm = self.__get_struct_output_model(llm_client, struct_model)
185
+ completion = llm.invoke(new_messages, config=config)
186
+ result = completion.model_dump()
187
+ else:
188
+ llm = self.__get_struct_output_model(llm_client, struct_model)
189
+ completion = llm.invoke(messages, config=config)
190
+ result = completion.model_dump()
191
+
160
192
  # Ensure messages are properly formatted
161
193
  if result.get('messages') and isinstance(result['messages'], list):
162
194
  result['messages'] = [{'role': 'assistant', 'content': '\n'.join(result['messages'])}]
163
-
195
+ else:
196
+ result['messages'] = messages + [AIMessage(content=result.get(ELITEA_RS, ''))]
197
+
164
198
  return result
165
199
  else:
166
200
  # Handle regular completion
167
- completion = llm_client.invoke(llm_input, config=config)
201
+ completion = llm_client.invoke(messages, config=config)
168
202
  logger.info(f"Initial completion: {completion}")
169
203
  # Handle both tool-calling and regular responses
170
204
  if hasattr(completion, 'tool_calls') and completion.tool_calls:
171
205
  # 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
200
-
201
- if tool_to_execute:
202
- 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
211
- )
212
- new_messages.append(tool_message)
213
-
214
- 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)
237
-
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")
241
- 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
251
-
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")
260
-
261
- return {"messages": new_messages}
206
+ new_messages, current_completion = self._run_async_in_sync_context(
207
+ self.__perform_tool_calling(completion, messages, llm_client, config)
208
+ )
209
+
210
+ output_msgs = {"messages": new_messages}
211
+ if self.output_variables:
212
+ if self.output_variables[0] == 'messages':
213
+ return output_msgs
214
+ output_msgs[self.output_variables[0]] = current_completion.content if current_completion else None
215
+
216
+ return output_msgs
262
217
  else:
263
218
  # Regular text response
264
219
  content = completion.content.strip() if hasattr(completion, 'content') else str(completion)
265
-
220
+
266
221
  # Try to extract JSON if output variables are specified (but exclude 'messages' which is handled separately)
267
222
  json_output_vars = [var for var in (self.output_variables or []) if var != 'messages']
268
223
  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
-
224
+ # set response to be the first output variable for non-structured output
225
+ response_data = {json_output_vars[0]: content}
226
+ new_messages = messages + [AIMessage(content=content)]
227
+ response_data['messages'] = new_messages
228
+ return response_data
229
+
283
230
  # Simple text response (either no output variables or JSON parsing failed)
284
231
  new_messages = messages + [AIMessage(content=content)]
285
232
  return {"messages": new_messages}
286
-
233
+
287
234
  except Exception as e:
288
235
  logger.error(f"Error in LLM Node: {format_exc()}")
289
236
  error_msg = f"Error: {e}"
@@ -293,3 +240,415 @@ class LLMNode(BaseTool):
293
240
  def _run(self, *args, **kwargs):
294
241
  # Legacy support for old interface
295
242
  return self.invoke(kwargs, **kwargs)
243
+
244
+ def _run_async_in_sync_context(self, coro):
245
+ """Run async coroutine from sync context.
246
+
247
+ For MCP tools with persistent sessions, we reuse the same event loop
248
+ that was used to create the MCP client and sessions (set by CLI).
249
+
250
+ When called from within a running event loop (e.g., nested LLM nodes),
251
+ we need to handle this carefully to avoid "event loop already running" errors.
252
+
253
+ This method handles three scenarios:
254
+ 1. Called from async context (event loop running) - creates new thread with new loop
255
+ 2. Called from sync context with persistent loop - reuses persistent loop
256
+ 3. Called from sync context without loop - creates new persistent loop
257
+ """
258
+ import threading
259
+
260
+ # Check if there's a running loop
261
+ try:
262
+ running_loop = asyncio.get_running_loop()
263
+ loop_is_running = True
264
+ logger.debug(f"Detected running event loop (id: {id(running_loop)}), executing tool calls in separate thread")
265
+ except RuntimeError:
266
+ loop_is_running = False
267
+
268
+ # Scenario 1: Loop is currently running - MUST use thread
269
+ if loop_is_running:
270
+ result_container = []
271
+ exception_container = []
272
+
273
+ # Try to capture Streamlit context from current thread for propagation
274
+ streamlit_ctx = None
275
+ try:
276
+ from streamlit.runtime.scriptrunner import get_script_run_ctx, add_script_run_ctx
277
+ streamlit_ctx = get_script_run_ctx()
278
+ if streamlit_ctx:
279
+ logger.debug("Captured Streamlit context for propagation to worker thread")
280
+ except (ImportError, Exception) as e:
281
+ logger.debug(f"Streamlit context not available or failed to capture: {e}")
282
+
283
+ def run_in_thread():
284
+ """Run coroutine in a new thread with its own event loop."""
285
+ new_loop = asyncio.new_event_loop()
286
+ asyncio.set_event_loop(new_loop)
287
+ try:
288
+ result = new_loop.run_until_complete(coro)
289
+ result_container.append(result)
290
+ except Exception as e:
291
+ logger.debug(f"Exception in async thread: {e}")
292
+ exception_container.append(e)
293
+ finally:
294
+ new_loop.close()
295
+ asyncio.set_event_loop(None)
296
+
297
+ thread = threading.Thread(target=run_in_thread, daemon=False)
298
+
299
+ # Propagate Streamlit context to the worker thread if available
300
+ if streamlit_ctx is not None:
301
+ try:
302
+ add_script_run_ctx(thread, streamlit_ctx)
303
+ logger.debug("Successfully propagated Streamlit context to worker thread")
304
+ except Exception as e:
305
+ logger.warning(f"Failed to propagate Streamlit context to worker thread: {e}")
306
+
307
+ thread.start()
308
+ thread.join(timeout=self.tool_execution_timeout) # 15 minute timeout for safety
309
+
310
+ if thread.is_alive():
311
+ logger.error("Async operation timed out after 5 minutes")
312
+ raise TimeoutError("Async operation in thread timed out")
313
+
314
+ # Re-raise exception if one occurred
315
+ if exception_container:
316
+ raise exception_container[0]
317
+
318
+ return result_container[0] if result_container else None
319
+
320
+ # Scenario 2 & 3: No loop running - use or create persistent loop
321
+ else:
322
+ # Get or create persistent loop
323
+ if not hasattr(self.__class__, '_persistent_loop') or \
324
+ self.__class__._persistent_loop is None or \
325
+ self.__class__._persistent_loop.is_closed():
326
+ self.__class__._persistent_loop = asyncio.new_event_loop()
327
+ logger.debug("Created persistent event loop for async tools")
328
+
329
+ loop = self.__class__._persistent_loop
330
+
331
+ # Double-check the loop is not running (safety check)
332
+ if loop.is_running():
333
+ logger.debug("Persistent loop is unexpectedly running, using thread execution")
334
+
335
+ result_container = []
336
+ exception_container = []
337
+
338
+ # Try to capture Streamlit context from current thread for propagation
339
+ streamlit_ctx = None
340
+ try:
341
+ from streamlit.runtime.scriptrunner import get_script_run_ctx, add_script_run_ctx
342
+ streamlit_ctx = get_script_run_ctx()
343
+ if streamlit_ctx:
344
+ logger.debug("Captured Streamlit context for propagation to worker thread")
345
+ except (ImportError, Exception) as e:
346
+ logger.debug(f"Streamlit context not available or failed to capture: {e}")
347
+
348
+ def run_in_thread():
349
+ """Run coroutine in a new thread with its own event loop."""
350
+ new_loop = asyncio.new_event_loop()
351
+ asyncio.set_event_loop(new_loop)
352
+ try:
353
+ result = new_loop.run_until_complete(coro)
354
+ result_container.append(result)
355
+ except Exception as ex:
356
+ logger.debug(f"Exception in async thread: {ex}")
357
+ exception_container.append(ex)
358
+ finally:
359
+ new_loop.close()
360
+ asyncio.set_event_loop(None)
361
+
362
+ thread = threading.Thread(target=run_in_thread, daemon=False)
363
+
364
+ # Propagate Streamlit context to the worker thread if available
365
+ if streamlit_ctx is not None:
366
+ try:
367
+ add_script_run_ctx(thread, streamlit_ctx)
368
+ logger.debug("Successfully propagated Streamlit context to worker thread")
369
+ except Exception as e:
370
+ logger.warning(f"Failed to propagate Streamlit context to worker thread: {e}")
371
+
372
+ thread.start()
373
+ thread.join(timeout=self.tool_execution_timeout)
374
+
375
+ if thread.is_alive():
376
+ logger.error("Async operation timed out after 15 minutes")
377
+ raise TimeoutError("Async operation in thread timed out")
378
+
379
+ if exception_container:
380
+ raise exception_container[0]
381
+
382
+ return result_container[0] if result_container else None
383
+ else:
384
+ # Loop exists but not running - safe to use run_until_complete
385
+ logger.debug(f"Using persistent loop (id: {id(loop)}) with run_until_complete")
386
+ asyncio.set_event_loop(loop)
387
+ return loop.run_until_complete(coro)
388
+
389
+ async def _arun(self, *args, **kwargs):
390
+ # Legacy async support
391
+ return self.invoke(kwargs, **kwargs)
392
+
393
+ async def __perform_tool_calling(self, completion, messages, llm_client, config):
394
+ # Handle iterative tool-calling and execution
395
+ logger.info(f"__perform_tool_calling called with {len(completion.tool_calls) if hasattr(completion, 'tool_calls') else 0} tool calls")
396
+ new_messages = messages + [completion]
397
+ iteration = 0
398
+
399
+ # Continue executing tools until no more tool calls or max iterations reached
400
+ current_completion = completion
401
+ while (hasattr(current_completion, 'tool_calls') and
402
+ current_completion.tool_calls and
403
+ iteration < self.steps_limit):
404
+
405
+ iteration += 1
406
+ logger.info(f"Tool execution iteration {iteration}/{self.steps_limit}")
407
+
408
+ # Execute each tool call in the current completion
409
+ tool_calls = current_completion.tool_calls if hasattr(current_completion.tool_calls,
410
+ '__iter__') else []
411
+
412
+ for tool_call in tool_calls:
413
+ tool_name = tool_call.get('name', '') if isinstance(tool_call, dict) else getattr(tool_call,
414
+ 'name',
415
+ '')
416
+ tool_args = tool_call.get('args', {}) if isinstance(tool_call, dict) else getattr(tool_call,
417
+ 'args',
418
+ {})
419
+ tool_call_id = tool_call.get('id', '') if isinstance(tool_call, dict) else getattr(
420
+ tool_call, 'id', '')
421
+
422
+ # Find the tool in filtered tools
423
+ filtered_tools = self.get_filtered_tools()
424
+ tool_to_execute = None
425
+ for tool in filtered_tools:
426
+ if tool.name == tool_name:
427
+ tool_to_execute = tool
428
+ break
429
+
430
+ if tool_to_execute:
431
+ try:
432
+ logger.info(f"Executing tool '{tool_name}' with args: {tool_args}")
433
+
434
+ # Try async invoke first (for MCP tools), fallback to sync
435
+ tool_result = None
436
+ if hasattr(tool_to_execute, 'ainvoke'):
437
+ try:
438
+ tool_result = await tool_to_execute.ainvoke(tool_args, config=config)
439
+ except (NotImplementedError, AttributeError):
440
+ logger.debug(f"Tool '{tool_name}' ainvoke failed, falling back to sync invoke")
441
+ tool_result = tool_to_execute.invoke(tool_args, config=config)
442
+ else:
443
+ # Sync-only tool
444
+ tool_result = tool_to_execute.invoke(tool_args, config=config)
445
+
446
+ # Create tool message with result - preserve structured content
447
+ from langchain_core.messages import ToolMessage
448
+
449
+ # Check if tool_result is structured content (list of dicts)
450
+ # TODO: need solid check for being compatible with ToolMessage content format
451
+ if isinstance(tool_result, list) and all(
452
+ isinstance(item, dict) and 'type' in item for item in tool_result
453
+ ):
454
+ # Use structured content directly for multimodal support
455
+ tool_message = ToolMessage(
456
+ content=tool_result,
457
+ tool_call_id=tool_call_id
458
+ )
459
+ else:
460
+ # Fallback to string conversion for other tool results
461
+ tool_message = ToolMessage(
462
+ content=str(tool_result),
463
+ tool_call_id=tool_call_id
464
+ )
465
+ new_messages.append(tool_message)
466
+
467
+ except Exception as e:
468
+ import traceback
469
+ error_details = traceback.format_exc()
470
+ # Use debug level to avoid duplicate output when CLI callbacks are active
471
+ logger.debug(f"Error executing tool '{tool_name}': {e}\n{error_details}")
472
+ # Create error tool message
473
+ from langchain_core.messages import ToolMessage
474
+ tool_message = ToolMessage(
475
+ content=f"Error executing {tool_name}: {str(e)}",
476
+ tool_call_id=tool_call_id
477
+ )
478
+ new_messages.append(tool_message)
479
+ else:
480
+ logger.warning(f"Tool '{tool_name}' not found in available tools")
481
+ # Create error tool message for missing tool
482
+ from langchain_core.messages import ToolMessage
483
+ tool_message = ToolMessage(
484
+ content=f"Tool '{tool_name}' not available",
485
+ tool_call_id=tool_call_id
486
+ )
487
+ new_messages.append(tool_message)
488
+
489
+ # Call LLM again with tool results to get next response
490
+ try:
491
+ current_completion = llm_client.invoke(new_messages, config=config)
492
+ new_messages.append(current_completion)
493
+
494
+ # Check if we still have tool calls
495
+ if hasattr(current_completion, 'tool_calls') and current_completion.tool_calls:
496
+ logger.info(f"LLM requested {len(current_completion.tool_calls)} more tool calls")
497
+ else:
498
+ logger.info("LLM completed without requesting more tools")
499
+ break
500
+
501
+ except Exception as e:
502
+ error_str = str(e).lower()
503
+
504
+ # Check for context window / token limit errors
505
+ is_context_error = any(indicator in error_str for indicator in [
506
+ 'context window', 'context_window', 'token limit', 'too long',
507
+ 'maximum context length', 'input is too long', 'exceeds the limit',
508
+ 'contextwindowexceedederror', 'max_tokens', 'content too large'
509
+ ])
510
+
511
+ # Check for Bedrock/Claude output limit errors
512
+ # These often manifest as "model identifier is invalid" when output exceeds limits
513
+ is_output_limit_error = any(indicator in error_str for indicator in [
514
+ 'model identifier is invalid',
515
+ 'bedrockexception',
516
+ 'output token',
517
+ 'response too large',
518
+ 'max_tokens_to_sample',
519
+ 'output_token_limit'
520
+ ])
521
+
522
+ if is_context_error or is_output_limit_error:
523
+ error_type = "output limit" if is_output_limit_error else "context window"
524
+ logger.warning(f"{error_type.title()} exceeded during tool execution iteration {iteration}")
525
+
526
+ # Find the last tool message and its associated tool name
527
+ last_tool_msg_idx = None
528
+ last_tool_name = None
529
+ last_tool_call_id = None
530
+
531
+ # First, find the last tool message
532
+ for i in range(len(new_messages) - 1, -1, -1):
533
+ msg = new_messages[i]
534
+ if hasattr(msg, 'tool_call_id') or (hasattr(msg, 'type') and getattr(msg, 'type', None) == 'tool'):
535
+ last_tool_msg_idx = i
536
+ last_tool_call_id = getattr(msg, 'tool_call_id', None)
537
+ break
538
+
539
+ # Find the tool name from the AIMessage that requested this tool call
540
+ if last_tool_call_id:
541
+ for i in range(last_tool_msg_idx - 1, -1, -1):
542
+ msg = new_messages[i]
543
+ if hasattr(msg, 'tool_calls') and msg.tool_calls:
544
+ for tc in msg.tool_calls:
545
+ tc_id = tc.get('id', '') if isinstance(tc, dict) else getattr(tc, 'id', '')
546
+ if tc_id == last_tool_call_id:
547
+ last_tool_name = tc.get('name', '') if isinstance(tc, dict) else getattr(tc, 'name', '')
548
+ break
549
+ if last_tool_name:
550
+ break
551
+
552
+ # Build dynamic suggestion based on the tool that caused the overflow
553
+ tool_suggestions = self._get_tool_truncation_suggestions(last_tool_name)
554
+
555
+ # Truncate the problematic tool result if found
556
+ if last_tool_msg_idx is not None:
557
+ from langchain_core.messages import ToolMessage
558
+ original_msg = new_messages[last_tool_msg_idx]
559
+ tool_call_id = getattr(original_msg, 'tool_call_id', 'unknown')
560
+
561
+ # Build error-specific guidance
562
+ if is_output_limit_error:
563
+ truncated_content = (
564
+ f"⚠️ MODEL OUTPUT LIMIT EXCEEDED\n\n"
565
+ f"The tool '{last_tool_name or 'unknown'}' returned data, but the model's response was too large.\n\n"
566
+ f"IMPORTANT: You must provide a SMALLER, more focused response.\n"
567
+ f"- Break down your response into smaller chunks\n"
568
+ f"- Summarize instead of listing everything\n"
569
+ f"- Focus on the most relevant information first\n"
570
+ f"- If listing items, show only top 5-10 most important\n\n"
571
+ f"Tool-specific tips:\n{tool_suggestions}\n\n"
572
+ f"Please retry with a more concise response."
573
+ )
574
+ else:
575
+ truncated_content = (
576
+ f"⚠️ TOOL OUTPUT TRUNCATED - Context window exceeded\n\n"
577
+ f"The tool '{last_tool_name or 'unknown'}' returned too much data for the model's context window.\n\n"
578
+ f"To fix this:\n{tool_suggestions}\n\n"
579
+ f"Please retry with more restrictive parameters."
580
+ )
581
+
582
+ truncated_msg = ToolMessage(
583
+ content=truncated_content,
584
+ tool_call_id=tool_call_id
585
+ )
586
+ new_messages[last_tool_msg_idx] = truncated_msg
587
+
588
+ logger.info(f"Truncated large tool result from '{last_tool_name}' and continuing")
589
+ # Continue to next iteration - the model will see the truncation message
590
+ continue
591
+ else:
592
+ # Couldn't find tool message, add error and break
593
+ if is_output_limit_error:
594
+ error_msg = (
595
+ "Model output limit exceeded. Please provide a more concise response. "
596
+ "Break down your answer into smaller parts and summarize where possible."
597
+ )
598
+ else:
599
+ error_msg = (
600
+ "Context window exceeded. The conversation or tool results are too large. "
601
+ "Try using tools with smaller output limits (e.g., max_items, max_depth parameters)."
602
+ )
603
+ new_messages.append(AIMessage(content=error_msg))
604
+ break
605
+ else:
606
+ logger.error(f"Error in LLM call during iteration {iteration}: {e}")
607
+ # Add error message and break the loop
608
+ error_msg = f"Error processing tool results in iteration {iteration}: {str(e)}"
609
+ new_messages.append(AIMessage(content=error_msg))
610
+ break
611
+
612
+ # Handle max iterations
613
+ if iteration >= self.steps_limit:
614
+ logger.warning(f"Reached maximum iterations ({self.steps_limit}) for tool execution")
615
+
616
+ # CRITICAL: Check if the last message is an AIMessage with pending tool_calls
617
+ # that were not processed. If so, we need to add placeholder ToolMessages to prevent
618
+ # the "assistant message with 'tool_calls' must be followed by tool messages" error
619
+ # when the conversation continues.
620
+ if new_messages:
621
+ last_msg = new_messages[-1]
622
+ if hasattr(last_msg, 'tool_calls') and last_msg.tool_calls:
623
+ from langchain_core.messages import ToolMessage
624
+ pending_tool_calls = last_msg.tool_calls if hasattr(last_msg.tool_calls, '__iter__') else []
625
+
626
+ # Check which tool_call_ids already have responses
627
+ existing_tool_call_ids = set()
628
+ for msg in new_messages:
629
+ if hasattr(msg, 'tool_call_id'):
630
+ existing_tool_call_ids.add(msg.tool_call_id)
631
+
632
+ # Add placeholder responses for any tool calls without responses
633
+ for tool_call in pending_tool_calls:
634
+ tool_call_id = tool_call.get('id', '') if isinstance(tool_call, dict) else getattr(tool_call, 'id', '')
635
+ tool_name = tool_call.get('name', '') if isinstance(tool_call, dict) else getattr(tool_call, 'name', '')
636
+
637
+ if tool_call_id and tool_call_id not in existing_tool_call_ids:
638
+ logger.info(f"Adding placeholder ToolMessage for interrupted tool call: {tool_name} ({tool_call_id})")
639
+ placeholder_msg = ToolMessage(
640
+ content=f"[Tool execution interrupted - step limit ({self.steps_limit}) reached before {tool_name} could be executed]",
641
+ tool_call_id=tool_call_id
642
+ )
643
+ new_messages.append(placeholder_msg)
644
+
645
+ # Add warning message - CLI or calling code can detect this and prompt user
646
+ warning_msg = f"Maximum tool execution iterations ({self.steps_limit}) reached. Stopping tool execution."
647
+ new_messages.append(AIMessage(content=warning_msg))
648
+ else:
649
+ logger.info(f"Tool execution completed after {iteration} iterations")
650
+
651
+ return new_messages, current_completion
652
+
653
+ def __get_struct_output_model(self, llm_client, pydantic_model):
654
+ return llm_client.with_structured_output(pydantic_model)