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.
- alita_sdk/cli/__init__.py +10 -0
- alita_sdk/cli/__main__.py +17 -0
- alita_sdk/cli/agent/__init__.py +5 -0
- alita_sdk/cli/agent/default.py +258 -0
- alita_sdk/cli/agent_executor.py +155 -0
- alita_sdk/cli/agent_loader.py +215 -0
- alita_sdk/cli/agent_ui.py +228 -0
- alita_sdk/cli/agents.py +3601 -0
- alita_sdk/cli/callbacks.py +647 -0
- alita_sdk/cli/cli.py +168 -0
- alita_sdk/cli/config.py +306 -0
- alita_sdk/cli/context/__init__.py +30 -0
- alita_sdk/cli/context/cleanup.py +198 -0
- alita_sdk/cli/context/manager.py +731 -0
- alita_sdk/cli/context/message.py +285 -0
- alita_sdk/cli/context/strategies.py +289 -0
- alita_sdk/cli/context/token_estimation.py +127 -0
- alita_sdk/cli/formatting.py +182 -0
- alita_sdk/cli/input_handler.py +419 -0
- alita_sdk/cli/inventory.py +1256 -0
- alita_sdk/cli/mcp_loader.py +315 -0
- alita_sdk/cli/toolkit.py +327 -0
- alita_sdk/cli/toolkit_loader.py +85 -0
- alita_sdk/cli/tools/__init__.py +43 -0
- alita_sdk/cli/tools/approval.py +224 -0
- alita_sdk/cli/tools/filesystem.py +1751 -0
- alita_sdk/cli/tools/planning.py +389 -0
- alita_sdk/cli/tools/terminal.py +414 -0
- alita_sdk/community/__init__.py +64 -8
- alita_sdk/community/inventory/__init__.py +224 -0
- alita_sdk/community/inventory/config.py +257 -0
- alita_sdk/community/inventory/enrichment.py +2137 -0
- alita_sdk/community/inventory/extractors.py +1469 -0
- alita_sdk/community/inventory/ingestion.py +3172 -0
- alita_sdk/community/inventory/knowledge_graph.py +1457 -0
- alita_sdk/community/inventory/parsers/__init__.py +218 -0
- alita_sdk/community/inventory/parsers/base.py +295 -0
- alita_sdk/community/inventory/parsers/csharp_parser.py +907 -0
- alita_sdk/community/inventory/parsers/go_parser.py +851 -0
- alita_sdk/community/inventory/parsers/html_parser.py +389 -0
- alita_sdk/community/inventory/parsers/java_parser.py +593 -0
- alita_sdk/community/inventory/parsers/javascript_parser.py +629 -0
- alita_sdk/community/inventory/parsers/kotlin_parser.py +768 -0
- alita_sdk/community/inventory/parsers/markdown_parser.py +362 -0
- alita_sdk/community/inventory/parsers/python_parser.py +604 -0
- alita_sdk/community/inventory/parsers/rust_parser.py +858 -0
- alita_sdk/community/inventory/parsers/swift_parser.py +832 -0
- alita_sdk/community/inventory/parsers/text_parser.py +322 -0
- alita_sdk/community/inventory/parsers/yaml_parser.py +370 -0
- alita_sdk/community/inventory/patterns/__init__.py +61 -0
- alita_sdk/community/inventory/patterns/ast_adapter.py +380 -0
- alita_sdk/community/inventory/patterns/loader.py +348 -0
- alita_sdk/community/inventory/patterns/registry.py +198 -0
- alita_sdk/community/inventory/presets.py +535 -0
- alita_sdk/community/inventory/retrieval.py +1403 -0
- alita_sdk/community/inventory/toolkit.py +173 -0
- alita_sdk/community/inventory/visualize.py +1370 -0
- alita_sdk/configurations/__init__.py +10 -0
- alita_sdk/configurations/ado.py +4 -2
- alita_sdk/configurations/azure_search.py +1 -1
- alita_sdk/configurations/bigquery.py +1 -1
- alita_sdk/configurations/bitbucket.py +94 -2
- alita_sdk/configurations/browser.py +18 -0
- alita_sdk/configurations/carrier.py +19 -0
- alita_sdk/configurations/confluence.py +96 -1
- alita_sdk/configurations/delta_lake.py +1 -1
- alita_sdk/configurations/figma.py +0 -5
- alita_sdk/configurations/github.py +65 -1
- alita_sdk/configurations/gitlab.py +79 -0
- alita_sdk/configurations/google_places.py +17 -0
- alita_sdk/configurations/jira.py +103 -0
- alita_sdk/configurations/postman.py +1 -1
- alita_sdk/configurations/qtest.py +1 -3
- alita_sdk/configurations/report_portal.py +19 -0
- alita_sdk/configurations/salesforce.py +19 -0
- alita_sdk/configurations/service_now.py +1 -12
- alita_sdk/configurations/sharepoint.py +19 -0
- alita_sdk/configurations/sonar.py +18 -0
- alita_sdk/configurations/sql.py +20 -0
- alita_sdk/configurations/testio.py +18 -0
- alita_sdk/configurations/testrail.py +88 -0
- alita_sdk/configurations/xray.py +94 -1
- alita_sdk/configurations/zephyr_enterprise.py +94 -1
- alita_sdk/configurations/zephyr_essential.py +95 -0
- alita_sdk/runtime/clients/artifact.py +12 -2
- alita_sdk/runtime/clients/client.py +235 -66
- alita_sdk/runtime/clients/mcp_discovery.py +342 -0
- alita_sdk/runtime/clients/mcp_manager.py +262 -0
- alita_sdk/runtime/clients/sandbox_client.py +373 -0
- alita_sdk/runtime/langchain/assistant.py +123 -17
- alita_sdk/runtime/langchain/constants.py +8 -1
- alita_sdk/runtime/langchain/document_loaders/AlitaDocxMammothLoader.py +315 -3
- alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +209 -31
- alita_sdk/runtime/langchain/document_loaders/AlitaImageLoader.py +1 -1
- alita_sdk/runtime/langchain/document_loaders/AlitaJSONLoader.py +8 -2
- alita_sdk/runtime/langchain/document_loaders/AlitaMarkdownLoader.py +66 -0
- alita_sdk/runtime/langchain/document_loaders/AlitaPDFLoader.py +79 -10
- alita_sdk/runtime/langchain/document_loaders/AlitaPowerPointLoader.py +52 -15
- alita_sdk/runtime/langchain/document_loaders/AlitaPythonLoader.py +9 -0
- alita_sdk/runtime/langchain/document_loaders/AlitaTableLoader.py +1 -4
- alita_sdk/runtime/langchain/document_loaders/AlitaTextLoader.py +15 -2
- alita_sdk/runtime/langchain/document_loaders/ImageParser.py +30 -0
- alita_sdk/runtime/langchain/document_loaders/constants.py +187 -40
- alita_sdk/runtime/langchain/interfaces/llm_processor.py +4 -2
- alita_sdk/runtime/langchain/langraph_agent.py +406 -91
- alita_sdk/runtime/langchain/utils.py +51 -8
- alita_sdk/runtime/llms/preloaded.py +2 -6
- alita_sdk/runtime/models/mcp_models.py +61 -0
- alita_sdk/runtime/toolkits/__init__.py +26 -0
- alita_sdk/runtime/toolkits/application.py +9 -2
- alita_sdk/runtime/toolkits/artifact.py +19 -7
- alita_sdk/runtime/toolkits/datasource.py +13 -6
- alita_sdk/runtime/toolkits/mcp.py +780 -0
- alita_sdk/runtime/toolkits/planning.py +178 -0
- alita_sdk/runtime/toolkits/subgraph.py +11 -6
- alita_sdk/runtime/toolkits/tools.py +214 -60
- alita_sdk/runtime/toolkits/vectorstore.py +9 -4
- alita_sdk/runtime/tools/__init__.py +22 -0
- alita_sdk/runtime/tools/application.py +16 -4
- alita_sdk/runtime/tools/artifact.py +312 -19
- alita_sdk/runtime/tools/function.py +100 -4
- alita_sdk/runtime/tools/graph.py +81 -0
- alita_sdk/runtime/tools/image_generation.py +212 -0
- alita_sdk/runtime/tools/llm.py +539 -180
- alita_sdk/runtime/tools/mcp_inspect_tool.py +284 -0
- alita_sdk/runtime/tools/mcp_remote_tool.py +181 -0
- alita_sdk/runtime/tools/mcp_server_tool.py +3 -1
- alita_sdk/runtime/tools/planning/__init__.py +36 -0
- alita_sdk/runtime/tools/planning/models.py +246 -0
- alita_sdk/runtime/tools/planning/wrapper.py +607 -0
- alita_sdk/runtime/tools/router.py +2 -1
- alita_sdk/runtime/tools/sandbox.py +375 -0
- alita_sdk/runtime/tools/vectorstore.py +62 -63
- alita_sdk/runtime/tools/vectorstore_base.py +156 -85
- alita_sdk/runtime/utils/AlitaCallback.py +106 -20
- alita_sdk/runtime/utils/mcp_client.py +465 -0
- alita_sdk/runtime/utils/mcp_oauth.py +244 -0
- alita_sdk/runtime/utils/mcp_sse_client.py +405 -0
- alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
- alita_sdk/runtime/utils/streamlit.py +41 -14
- alita_sdk/runtime/utils/toolkit_utils.py +28 -9
- alita_sdk/runtime/utils/utils.py +14 -0
- alita_sdk/tools/__init__.py +78 -35
- alita_sdk/tools/ado/__init__.py +0 -1
- alita_sdk/tools/ado/repos/__init__.py +10 -6
- alita_sdk/tools/ado/repos/repos_wrapper.py +12 -11
- alita_sdk/tools/ado/test_plan/__init__.py +10 -7
- alita_sdk/tools/ado/test_plan/test_plan_wrapper.py +56 -23
- alita_sdk/tools/ado/wiki/__init__.py +10 -11
- alita_sdk/tools/ado/wiki/ado_wrapper.py +114 -28
- alita_sdk/tools/ado/work_item/__init__.py +10 -11
- alita_sdk/tools/ado/work_item/ado_wrapper.py +63 -10
- alita_sdk/tools/advanced_jira_mining/__init__.py +10 -7
- alita_sdk/tools/aws/delta_lake/__init__.py +13 -11
- alita_sdk/tools/azure_ai/search/__init__.py +11 -7
- alita_sdk/tools/base_indexer_toolkit.py +392 -86
- alita_sdk/tools/bitbucket/__init__.py +18 -11
- alita_sdk/tools/bitbucket/api_wrapper.py +52 -9
- alita_sdk/tools/bitbucket/cloud_api_wrapper.py +5 -5
- alita_sdk/tools/browser/__init__.py +40 -16
- alita_sdk/tools/browser/crawler.py +3 -1
- alita_sdk/tools/browser/utils.py +15 -6
- alita_sdk/tools/carrier/__init__.py +17 -17
- alita_sdk/tools/carrier/backend_reports_tool.py +8 -4
- alita_sdk/tools/carrier/excel_reporter.py +8 -4
- alita_sdk/tools/chunkers/__init__.py +3 -1
- alita_sdk/tools/chunkers/code/codeparser.py +1 -1
- alita_sdk/tools/chunkers/sematic/json_chunker.py +1 -0
- alita_sdk/tools/chunkers/sematic/markdown_chunker.py +97 -6
- alita_sdk/tools/chunkers/sematic/proposal_chunker.py +1 -1
- alita_sdk/tools/chunkers/universal_chunker.py +270 -0
- alita_sdk/tools/cloud/aws/__init__.py +9 -6
- alita_sdk/tools/cloud/azure/__init__.py +9 -6
- alita_sdk/tools/cloud/gcp/__init__.py +9 -6
- alita_sdk/tools/cloud/k8s/__init__.py +9 -6
- alita_sdk/tools/code/linter/__init__.py +7 -7
- alita_sdk/tools/code/loaders/codesearcher.py +3 -2
- alita_sdk/tools/code/sonar/__init__.py +18 -12
- alita_sdk/tools/code_indexer_toolkit.py +199 -0
- alita_sdk/tools/confluence/__init__.py +14 -11
- alita_sdk/tools/confluence/api_wrapper.py +198 -58
- alita_sdk/tools/confluence/loader.py +10 -0
- alita_sdk/tools/custom_open_api/__init__.py +9 -4
- alita_sdk/tools/elastic/__init__.py +8 -7
- alita_sdk/tools/elitea_base.py +543 -64
- alita_sdk/tools/figma/__init__.py +10 -8
- alita_sdk/tools/figma/api_wrapper.py +352 -153
- alita_sdk/tools/github/__init__.py +13 -11
- alita_sdk/tools/github/api_wrapper.py +9 -26
- alita_sdk/tools/github/github_client.py +75 -12
- alita_sdk/tools/github/schemas.py +2 -1
- alita_sdk/tools/gitlab/__init__.py +11 -10
- alita_sdk/tools/gitlab/api_wrapper.py +135 -45
- alita_sdk/tools/gitlab_org/__init__.py +11 -9
- alita_sdk/tools/google/bigquery/__init__.py +12 -13
- alita_sdk/tools/google_places/__init__.py +18 -10
- alita_sdk/tools/jira/__init__.py +14 -8
- alita_sdk/tools/jira/api_wrapper.py +315 -168
- alita_sdk/tools/keycloak/__init__.py +8 -7
- alita_sdk/tools/localgit/local_git.py +56 -54
- alita_sdk/tools/memory/__init__.py +27 -11
- alita_sdk/tools/non_code_indexer_toolkit.py +7 -2
- alita_sdk/tools/ocr/__init__.py +8 -7
- alita_sdk/tools/openapi/__init__.py +10 -1
- alita_sdk/tools/pandas/__init__.py +8 -7
- alita_sdk/tools/pandas/api_wrapper.py +7 -25
- alita_sdk/tools/postman/__init__.py +8 -10
- alita_sdk/tools/postman/api_wrapper.py +19 -8
- alita_sdk/tools/postman/postman_analysis.py +8 -1
- alita_sdk/tools/pptx/__init__.py +8 -9
- alita_sdk/tools/qtest/__init__.py +19 -13
- alita_sdk/tools/qtest/api_wrapper.py +1784 -88
- alita_sdk/tools/rally/__init__.py +10 -9
- alita_sdk/tools/report_portal/__init__.py +20 -15
- alita_sdk/tools/salesforce/__init__.py +19 -15
- alita_sdk/tools/servicenow/__init__.py +14 -11
- alita_sdk/tools/sharepoint/__init__.py +14 -13
- alita_sdk/tools/sharepoint/api_wrapper.py +179 -39
- alita_sdk/tools/sharepoint/authorization_helper.py +191 -1
- alita_sdk/tools/sharepoint/utils.py +8 -2
- alita_sdk/tools/slack/__init__.py +10 -7
- alita_sdk/tools/sql/__init__.py +19 -18
- alita_sdk/tools/sql/api_wrapper.py +71 -23
- alita_sdk/tools/testio/__init__.py +18 -12
- alita_sdk/tools/testrail/__init__.py +10 -10
- alita_sdk/tools/testrail/api_wrapper.py +213 -45
- alita_sdk/tools/utils/__init__.py +28 -4
- alita_sdk/tools/utils/content_parser.py +181 -61
- alita_sdk/tools/utils/text_operations.py +254 -0
- alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +83 -27
- alita_sdk/tools/xray/__init__.py +12 -7
- alita_sdk/tools/xray/api_wrapper.py +58 -113
- alita_sdk/tools/zephyr/__init__.py +9 -6
- alita_sdk/tools/zephyr_enterprise/__init__.py +13 -8
- alita_sdk/tools/zephyr_enterprise/api_wrapper.py +17 -7
- alita_sdk/tools/zephyr_essential/__init__.py +13 -9
- alita_sdk/tools/zephyr_essential/api_wrapper.py +289 -47
- alita_sdk/tools/zephyr_essential/client.py +6 -4
- alita_sdk/tools/zephyr_scale/__init__.py +10 -7
- alita_sdk/tools/zephyr_scale/api_wrapper.py +6 -2
- alita_sdk/tools/zephyr_squad/__init__.py +9 -6
- {alita_sdk-0.3.263.dist-info → alita_sdk-0.3.499.dist-info}/METADATA +180 -33
- alita_sdk-0.3.499.dist-info/RECORD +433 -0
- alita_sdk-0.3.499.dist-info/entry_points.txt +2 -0
- alita_sdk-0.3.263.dist-info/RECORD +0 -342
- {alita_sdk-0.3.263.dist-info → alita_sdk-0.3.499.dist-info}/WHEEL +0 -0
- {alita_sdk-0.3.263.dist-info → alita_sdk-0.3.499.dist-info}/licenses/LICENSE +0 -0
- {alita_sdk-0.3.263.dist-info → alita_sdk-0.3.499.dist-info}/top_level.txt +0 -0
alita_sdk/runtime/tools/llm.py
CHANGED
|
@@ -1,76 +1,40 @@
|
|
|
1
|
-
import
|
|
1
|
+
import asyncio
|
|
2
2
|
import logging
|
|
3
3
|
from traceback import format_exc
|
|
4
|
-
from typing import Any, Optional,
|
|
4
|
+
from typing import Any, Optional, List, Union
|
|
5
5
|
|
|
6
|
-
from langchain_core.messages import HumanMessage,
|
|
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.
|
|
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',
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
#
|
|
125
|
-
|
|
126
|
-
if
|
|
127
|
-
for
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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(
|
|
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 =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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)
|