alita-sdk 0.3.465__py3-none-any.whl → 0.3.497__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of alita-sdk might be problematic. Click here for more details.
- alita_sdk/cli/agent/__init__.py +5 -0
- alita_sdk/cli/agent/default.py +83 -1
- alita_sdk/cli/agent_loader.py +22 -4
- alita_sdk/cli/agent_ui.py +13 -3
- alita_sdk/cli/agents.py +1876 -186
- alita_sdk/cli/callbacks.py +96 -25
- alita_sdk/cli/cli.py +10 -1
- alita_sdk/cli/config.py +151 -9
- 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/input_handler.py +167 -4
- alita_sdk/cli/inventory.py +1256 -0
- alita_sdk/cli/toolkit.py +14 -17
- alita_sdk/cli/toolkit_loader.py +35 -5
- alita_sdk/cli/tools/__init__.py +8 -1
- alita_sdk/cli/tools/filesystem.py +910 -64
- alita_sdk/cli/tools/planning.py +143 -157
- alita_sdk/cli/tools/terminal.py +154 -20
- 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 +169 -0
- alita_sdk/community/inventory/visualize.py +1370 -0
- alita_sdk/configurations/bitbucket.py +0 -3
- alita_sdk/runtime/clients/client.py +108 -31
- alita_sdk/runtime/langchain/assistant.py +4 -2
- alita_sdk/runtime/langchain/constants.py +3 -1
- alita_sdk/runtime/langchain/document_loaders/AlitaExcelLoader.py +103 -60
- alita_sdk/runtime/langchain/document_loaders/constants.py +10 -6
- alita_sdk/runtime/langchain/langraph_agent.py +123 -31
- alita_sdk/runtime/llms/preloaded.py +2 -6
- alita_sdk/runtime/toolkits/__init__.py +2 -0
- alita_sdk/runtime/toolkits/application.py +1 -1
- alita_sdk/runtime/toolkits/mcp.py +107 -91
- alita_sdk/runtime/toolkits/planning.py +173 -0
- alita_sdk/runtime/toolkits/tools.py +59 -7
- alita_sdk/runtime/tools/artifact.py +46 -17
- alita_sdk/runtime/tools/function.py +2 -1
- alita_sdk/runtime/tools/llm.py +320 -32
- alita_sdk/runtime/tools/mcp_remote_tool.py +23 -7
- 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/vectorstore_base.py +44 -9
- alita_sdk/runtime/utils/AlitaCallback.py +106 -20
- alita_sdk/runtime/utils/mcp_client.py +465 -0
- alita_sdk/runtime/utils/mcp_oauth.py +80 -0
- alita_sdk/runtime/utils/mcp_tools_discovery.py +124 -0
- alita_sdk/runtime/utils/streamlit.py +6 -10
- alita_sdk/runtime/utils/toolkit_utils.py +14 -5
- alita_sdk/tools/__init__.py +54 -27
- alita_sdk/tools/ado/repos/repos_wrapper.py +1 -2
- alita_sdk/tools/base_indexer_toolkit.py +99 -20
- alita_sdk/tools/bitbucket/__init__.py +2 -2
- alita_sdk/tools/chunkers/__init__.py +3 -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/universal_chunker.py +270 -0
- alita_sdk/tools/code/loaders/codesearcher.py +3 -2
- alita_sdk/tools/code_indexer_toolkit.py +55 -22
- alita_sdk/tools/confluence/api_wrapper.py +63 -14
- alita_sdk/tools/elitea_base.py +86 -21
- alita_sdk/tools/jira/__init__.py +1 -1
- alita_sdk/tools/jira/api_wrapper.py +91 -40
- alita_sdk/tools/non_code_indexer_toolkit.py +1 -0
- alita_sdk/tools/qtest/__init__.py +1 -1
- alita_sdk/tools/sharepoint/api_wrapper.py +2 -2
- alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +17 -13
- alita_sdk/tools/zephyr_essential/api_wrapper.py +12 -13
- {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/METADATA +2 -1
- {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/RECORD +103 -61
- {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/WHEEL +0 -0
- {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/entry_points.txt +0 -0
- {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/licenses/LICENSE +0 -0
- {alita_sdk-0.3.465.dist-info → alita_sdk-0.3.497.dist-info}/top_level.txt +0 -0
alita_sdk/runtime/tools/llm.py
CHANGED
|
@@ -13,6 +13,7 @@ from ..langchain.utils import create_pydantic_model, propagate_the_input_mapping
|
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
|
+
|
|
16
17
|
class LLMNode(BaseTool):
|
|
17
18
|
"""Enhanced LLM node with chat history and tool binding support"""
|
|
18
19
|
|
|
@@ -33,6 +34,7 @@ class LLMNode(BaseTool):
|
|
|
33
34
|
available_tools: Optional[List[BaseTool]] = Field(default=None, description='Available tools for binding')
|
|
34
35
|
tool_names: Optional[List[str]] = Field(default=None, description='Specific tool names to filter')
|
|
35
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.')
|
|
36
38
|
|
|
37
39
|
def get_filtered_tools(self) -> List[BaseTool]:
|
|
38
40
|
"""
|
|
@@ -61,6 +63,47 @@ class LLMNode(BaseTool):
|
|
|
61
63
|
|
|
62
64
|
return filtered_tools
|
|
63
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
|
+
|
|
64
107
|
def invoke(
|
|
65
108
|
self,
|
|
66
109
|
state: Union[str, dict],
|
|
@@ -87,7 +130,9 @@ class LLMNode(BaseTool):
|
|
|
87
130
|
# or standalone LLM node for chat (with messages only)
|
|
88
131
|
if 'system' in func_args.keys():
|
|
89
132
|
# Flow for LLM node with prompt/task from pipeline
|
|
90
|
-
if
|
|
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}")
|
|
91
136
|
raise ToolException(f"LLMNode requires 'system' and 'task' parameters in input mapping. "
|
|
92
137
|
f"Actual params: {func_args}")
|
|
93
138
|
# cast to str in case user passes variable different from str
|
|
@@ -201,40 +246,146 @@ class LLMNode(BaseTool):
|
|
|
201
246
|
|
|
202
247
|
For MCP tools with persistent sessions, we reuse the same event loop
|
|
203
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
|
|
204
257
|
"""
|
|
258
|
+
import threading
|
|
259
|
+
|
|
260
|
+
# Check if there's a running loop
|
|
205
261
|
try:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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:
|
|
210
270
|
result_container = []
|
|
211
|
-
|
|
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
|
+
|
|
212
283
|
def run_in_thread():
|
|
284
|
+
"""Run coroutine in a new thread with its own event loop."""
|
|
213
285
|
new_loop = asyncio.new_event_loop()
|
|
214
286
|
asyncio.set_event_loop(new_loop)
|
|
215
287
|
try:
|
|
216
|
-
|
|
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)
|
|
217
293
|
finally:
|
|
218
294
|
new_loop.close()
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
|
|
221
307
|
thread.start()
|
|
222
|
-
thread.join()
|
|
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
|
+
|
|
223
318
|
return result_container[0] if result_container else None
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
#
|
|
319
|
+
|
|
320
|
+
# Scenario 2 & 3: No loop running - use or create persistent loop
|
|
321
|
+
else:
|
|
322
|
+
# Get or create persistent loop
|
|
228
323
|
if not hasattr(self.__class__, '_persistent_loop') or \
|
|
229
324
|
self.__class__._persistent_loop is None or \
|
|
230
325
|
self.__class__._persistent_loop.is_closed():
|
|
231
326
|
self.__class__._persistent_loop = asyncio.new_event_loop()
|
|
232
327
|
logger.debug("Created persistent event loop for async tools")
|
|
233
|
-
|
|
328
|
+
|
|
234
329
|
loop = self.__class__._persistent_loop
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
+
|
|
238
389
|
async def _arun(self, *args, **kwargs):
|
|
239
390
|
# Legacy async support
|
|
240
391
|
return self.invoke(kwargs, **kwargs)
|
|
@@ -282,12 +433,14 @@ class LLMNode(BaseTool):
|
|
|
282
433
|
|
|
283
434
|
# Try async invoke first (for MCP tools), fallback to sync
|
|
284
435
|
tool_result = None
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
291
444
|
tool_result = tool_to_execute.invoke(tool_args, config=config)
|
|
292
445
|
|
|
293
446
|
# Create tool message with result - preserve structured content
|
|
@@ -314,7 +467,8 @@ class LLMNode(BaseTool):
|
|
|
314
467
|
except Exception as e:
|
|
315
468
|
import traceback
|
|
316
469
|
error_details = traceback.format_exc()
|
|
317
|
-
|
|
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}")
|
|
318
472
|
# Create error tool message
|
|
319
473
|
from langchain_core.messages import ToolMessage
|
|
320
474
|
tool_message = ToolMessage(
|
|
@@ -345,16 +499,150 @@ class LLMNode(BaseTool):
|
|
|
345
499
|
break
|
|
346
500
|
|
|
347
501
|
except Exception as e:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
353
611
|
|
|
354
|
-
#
|
|
612
|
+
# Handle max iterations
|
|
355
613
|
if iteration >= self.steps_limit:
|
|
356
614
|
logger.warning(f"Reached maximum iterations ({self.steps_limit}) for tool execution")
|
|
357
|
-
|
|
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
|
|
358
646
|
warning_msg = f"Maximum tool execution iterations ({self.steps_limit}) reached. Stopping tool execution."
|
|
359
647
|
new_messages.append(AIMessage(content=warning_msg))
|
|
360
648
|
else:
|
|
@@ -20,10 +20,14 @@ from ..utils.mcp_oauth import (
|
|
|
20
20
|
fetch_resource_metadata_async,
|
|
21
21
|
infer_authorization_servers_from_realm,
|
|
22
22
|
)
|
|
23
|
-
from ..utils.
|
|
23
|
+
from ..utils.mcp_client import McpClient
|
|
24
24
|
|
|
25
25
|
logger = logging.getLogger(__name__)
|
|
26
26
|
|
|
27
|
+
# Global registry to store MCP tool session metadata by tool name
|
|
28
|
+
# This is used to pass session info to callbacks since LangChain's serialization doesn't include all fields
|
|
29
|
+
MCP_TOOL_SESSION_REGISTRY: Dict[str, Dict[str, Any]] = {}
|
|
30
|
+
|
|
27
31
|
|
|
28
32
|
class McpRemoteTool(McpServerTool):
|
|
29
33
|
"""
|
|
@@ -43,6 +47,7 @@ class McpRemoteTool(McpServerTool):
|
|
|
43
47
|
"""Update metadata with session info after model initialization."""
|
|
44
48
|
super().model_post_init(__context)
|
|
45
49
|
self._update_metadata_with_session()
|
|
50
|
+
self._register_session_metadata()
|
|
46
51
|
|
|
47
52
|
def _update_metadata_with_session(self):
|
|
48
53
|
"""Update the metadata dict with current session information."""
|
|
@@ -54,6 +59,15 @@ class McpRemoteTool(McpServerTool):
|
|
|
54
59
|
'mcp_server_url': canonical_resource(self.server_url)
|
|
55
60
|
})
|
|
56
61
|
|
|
62
|
+
def _register_session_metadata(self):
|
|
63
|
+
"""Register session metadata in global registry for callback access."""
|
|
64
|
+
if self.session_id and self.server_url:
|
|
65
|
+
MCP_TOOL_SESSION_REGISTRY[self.name] = {
|
|
66
|
+
'mcp_session_id': self.session_id,
|
|
67
|
+
'mcp_server_url': canonical_resource(self.server_url)
|
|
68
|
+
}
|
|
69
|
+
logger.debug(f"[MCP] Registered session metadata for tool '{self.name}': session={self.session_id}")
|
|
70
|
+
|
|
57
71
|
def __getstate__(self):
|
|
58
72
|
"""Custom serialization for pickle compatibility."""
|
|
59
73
|
state = super().__getstate__()
|
|
@@ -98,7 +112,7 @@ class McpRemoteTool(McpServerTool):
|
|
|
98
112
|
tool_name_for_server = self.name.rsplit(TOOLKIT_SPLITTER, 1)[-1] if TOOLKIT_SPLITTER in self.name else self.name
|
|
99
113
|
logger.warning(f"original_tool_name not set for '{self.name}', using extracted: {tool_name_for_server}")
|
|
100
114
|
|
|
101
|
-
logger.info(f"[MCP
|
|
115
|
+
logger.info(f"[MCP] Executing tool '{tool_name_for_server}' with session {self.session_id}")
|
|
102
116
|
|
|
103
117
|
try:
|
|
104
118
|
# Prepare headers
|
|
@@ -106,16 +120,18 @@ class McpRemoteTool(McpServerTool):
|
|
|
106
120
|
if self.server_headers:
|
|
107
121
|
headers.update(self.server_headers)
|
|
108
122
|
|
|
109
|
-
# Create
|
|
110
|
-
client =
|
|
123
|
+
# Create unified MCP client (auto-detects transport)
|
|
124
|
+
client = McpClient(
|
|
111
125
|
url=self.server_url,
|
|
112
126
|
session_id=self.session_id,
|
|
113
127
|
headers=headers,
|
|
114
128
|
timeout=self.tool_timeout_sec
|
|
115
129
|
)
|
|
116
130
|
|
|
117
|
-
# Execute tool call
|
|
118
|
-
|
|
131
|
+
# Execute tool call (client auto-detects SSE vs Streamable HTTP)
|
|
132
|
+
async with client:
|
|
133
|
+
await client.initialize()
|
|
134
|
+
result = await client.call_tool(tool_name_for_server, kwargs)
|
|
119
135
|
|
|
120
136
|
# Format the result
|
|
121
137
|
if isinstance(result, dict):
|
|
@@ -144,7 +160,7 @@ class McpRemoteTool(McpServerTool):
|
|
|
144
160
|
return str(result)
|
|
145
161
|
|
|
146
162
|
except Exception as e:
|
|
147
|
-
logger.error(f"[MCP
|
|
163
|
+
logger.error(f"[MCP] Tool execution failed: {e}", exc_info=True)
|
|
148
164
|
raise
|
|
149
165
|
|
|
150
166
|
def _parse_sse(self, text: str) -> Dict[str, Any]:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Planning tools for runtime agents.
|
|
3
|
+
|
|
4
|
+
Provides plan management for multi-step task execution with progress tracking.
|
|
5
|
+
Supports two storage backends:
|
|
6
|
+
1. PostgreSQL - when connection_string is provided (production/indexer_worker)
|
|
7
|
+
2. Filesystem - when no connection string (local CLI usage)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .wrapper import (
|
|
11
|
+
PlanningWrapper,
|
|
12
|
+
PlanStep,
|
|
13
|
+
PlanState,
|
|
14
|
+
FilesystemStorage,
|
|
15
|
+
PostgresStorage,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
AgentPlan,
|
|
19
|
+
PlanStatus,
|
|
20
|
+
ensure_plan_tables,
|
|
21
|
+
delete_plan_by_conversation_id,
|
|
22
|
+
cleanup_on_graceful_completion
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"PlanningWrapper",
|
|
27
|
+
"PlanStep",
|
|
28
|
+
"PlanState",
|
|
29
|
+
"FilesystemStorage",
|
|
30
|
+
"PostgresStorage",
|
|
31
|
+
"AgentPlan",
|
|
32
|
+
"PlanStatus",
|
|
33
|
+
"ensure_plan_tables",
|
|
34
|
+
"delete_plan_by_conversation_id",
|
|
35
|
+
"cleanup_on_graceful_completion",
|
|
36
|
+
]
|