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