alita-sdk 0.3.462__py3-none-any.whl → 0.3.627__py3-none-any.whl

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